[
  {
    "path": ".agents/settings.local.json",
    "content": "{\n  \"permissions\": {\n    \"allow\": [\n      \"Bash(gh pr view:*)\",\n      \"Bash(pnpm bump:*)\",\n      \"Bash(git add:*)\",\n      \"Bash(git commit:*)\",\n      \"Bash(ls:*)\",\n      \"Bash(npx:*)\",\n      \"Bash(git stash:*)\",\n      \"Bash(git show:*)\",\n      \"Bash(git -C /Users/diygod/Code/Projects/Folo status --short)\",\n      \"Bash(git -C /Users/diygod/Code/Projects/Folo add apps/mobile/changelog/next.md)\",\n      \"Bash(git -C /Users/diygod/Code/Projects/Folo commit:*)\",\n      \"Bash(git checkout:*)\",\n      \"Bash(git fetch:*)\",\n      \"Bash(git merge:*)\",\n      \"Bash(git rm:*)\",\n      \"Bash(git push:*)\",\n      \"Bash(gh api:*)\",\n      \"Bash(pnpm exec vv:*)\"\n    ]\n  }\n}\n"
  },
  {
    "path": ".agents/skills/desktop-release/SKILL.md",
    "content": "---\nname: desktop-release\ndescription: Perform a regular desktop release from the dev branch. Gathers commits since last release, updates changelog, evaluates mainHash changes, bumps version, and creates release PR.\ndisable-model-invocation: true\nallowed-tools: Bash, Read, Write, Edit, Glob, Grep\n---\n\n# Desktop Regular Release\n\nPerform a regular desktop release. This skill handles the full release workflow from the `dev` branch.\n\n## Pre-flight checks\n\n1. Confirm the current branch is `dev`. If not, abort with a warning.\n2. Run `git pull --rebase` in the repo root to ensure the local branch is up to date.\n3. Read `apps/desktop/package.json` to get the current `version` and `mainHash`.\n\n## Step 1: Gather changes since last release\n\n1. Find the last release tag:\n   ```bash\n   git tag --sort=-creatordate | grep '^desktop/v' | head -1\n   ```\n2. Get all commits since that tag on the current branch:\n   ```bash\n   git log <last-tag>..HEAD --oneline --no-merges\n   ```\n3. Categorize commits into:\n   - **Shiny new things** (feat: commits, new features)\n   - **Improvements** (refactor:, perf:, chore: improvements, dependency updates)\n   - **No longer broken** (fix: commits, bug fixes)\n   - **Thanks** (identify external contributor GitHub usernames from commits)\n\n## Step 2: Update changelog\n\n1. Read `apps/desktop/changelog/next.md`.\n2. Present the categorized changes to the user and draft the changelog content.\n3. Wait for user confirmation or edits before writing.\n4. Write the final content to `apps/desktop/changelog/next.md`, following the template format:\n\n   ```markdown\n   # What's new in vNEXT_VERSION\n\n   ## Shiny new things\n\n   - description of new feature\n\n   ## Improvements\n\n   - description of improvement\n\n   ## No longer broken\n\n   - description of fix\n\n   ## Thanks\n\n   Special thanks to volunteer contributors @username for their valuable contributions\n   ```\n\n5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump.\n\n## Step 3: Commit changelog updates before bump\n\n`nbump` requires a clean working tree. Commit changelog edits before running bump.\n\n1. Stage the changelog update:\n   ```bash\n   git add apps/desktop/changelog/next.md\n   ```\n2. Commit it on `dev`:\n   ```bash\n   git commit -m \"docs(desktop): prepare release changelog\"\n   ```\n3. If there are no changes to commit, continue without creating an extra commit.\n\n## Step 4: Evaluate mainHash\n\nThis is critical for determining whether users need a full app update or can use the lightweight renderer hot update.\n\n1. Check what files changed in `apps/desktop/layer/main/` since the last release tag:\n   ```bash\n   git diff <last-tag>..HEAD --name-only -- apps/desktop/layer/main/\n   ```\n2. Also check changes to `apps/desktop/package.json` fields other than version/mainHash (since package.json is included in the hash calculation):\n   ```bash\n   git diff <last-tag>..HEAD -- apps/desktop/package.json\n   ```\n\n**Decision logic:**\n\n- If there are **NO changes** in `layer/main/` and no meaningful `package.json` changes (only version/mainHash/changelog-related), then mainHash should NOT be updated. Users will get a fast renderer-only hot update.\n- If there are **trivial changes** in `layer/main/` (typo fixes, comment changes, logging tweaks) that don't affect runtime behavior, recommend NOT updating mainHash. Present the changes to the user and ask for confirmation.\n- If there are **meaningful changes** in `layer/main/` (new features, bug fixes, dependency changes, API changes), mainHash MUST be updated. Users will need a full app update.\n\nPresent your analysis to the user with:\n\n- List of changed files in `layer/main/`\n- A summary of what changed\n- Your recommendation (update or skip mainHash)\n- Ask for explicit confirmation\n\n## Step 5: Save old mainHash and execute bump\n\n1. Save the current mainHash from `apps/desktop/package.json` for later comparison.\n2. Verify working tree is clean before bump:\n   ```bash\n   git status --short\n   ```\n3. Change directory to `apps/desktop/` and run the bump:\n   ```bash\n   cd apps/desktop && pnpm bump\n   ```\n4. This command will:\n   - Pull latest changes\n   - Apply changelog (rename next.md to {version}.md, create new next.md)\n   - Recalculate mainHash and write to package.json\n   - Format package.json\n   - Bump minor version\n   - Commit with message `release(desktop): release v{NEW_VERSION}`\n   - Create branch `release/desktop/{NEW_VERSION}`\n   - Push branch and create PR to `main`\n\n## Step 6: Restore mainHash if skipping update\n\nIf Step 4 decided mainHash should NOT be updated, restore the old value now. The bump has already committed, pushed, and created the PR on a new release branch, so we amend the commit and force push. This is safe because the release branch was just created.\n\n1. Change back to the repo root first (Step 5 left the working directory at `apps/desktop/`):\n   ```bash\n   cd ../..\n   ```\n2. Ensure you are on the `release/desktop/{NEW_VERSION}` branch (bump should have switched to it).\n3. Replace the recalculated mainHash with the saved old value in `apps/desktop/package.json`.\n4. Stage and amend the release commit:\n   ```bash\n   git add apps/desktop/package.json && git commit --amend --no-edit\n   ```\n5. Force push the release branch:\n   ```bash\n   git push --force origin release/desktop/{NEW_VERSION}\n   ```\n\nIf Step 4 decided mainHash SHOULD be updated, skip this step entirely — the bump already wrote the correct new value.\n\n## Step 7: Verify\n\n1. Confirm the PR was created successfully by checking the output.\n2. Report the new version number and PR URL to the user.\n3. Summarize:\n   - New version: v{NEW_VERSION}\n   - mainHash updated: yes/no (and why)\n   - Changelog highlights\n   - PR URL\n\n## Reference\n\n- Bump config: `apps/desktop/bump.config.ts`\n- Changelog dir: `apps/desktop/changelog/`\n- Changelog template: `apps/desktop/changelog/next.template.md`\n- mainHash generator: `apps/desktop/plugins/vite/generate-main-hash.ts`\n- Hot updater logic: `apps/desktop/layer/main/src/updater/hot-updater.ts`\n- CI build workflow: `.github/workflows/build-desktop.yml`\n- Tag workflow: `.github/workflows/tag.yml`\n"
  },
  {
    "path": ".agents/skills/installing-mobile-preview-builds/SKILL.md",
    "content": "---\nname: installing-mobile-preview-builds\ndescription: Builds and installs the iOS preview build for apps/mobile using EAS local build and devicectl. Use when the user asks to install a preview/internal iOS build on a connected iPhone for production-like testing.\ndisable-model-invocation: true\nallowed-tools: Bash, Read, Glob, Grep\nargument-hint: \"[device-udid-or-name(optional)]\"\n---\n\n# Install Mobile Preview Build (iOS)\n\nUse this skill to create a fresh local `preview` iOS build and install it on a connected iPhone.\n\n## Inputs\n\n- Optional `$ARGUMENTS`: device identifier (UDID or exact device name).\n- If no argument is provided, auto-select the first paired iPhone from `xcrun devicectl list devices`.\n\n## Workflow\n\n1. Validate repo and tooling.\n   - Run from repo root and ensure `apps/mobile` exists.\n   - Verify `pnpm`, `xcrun`, `xcodebuild`, and `eas-cli` are available.\n   - Verify EAS login:\n     ```bash\n     cd apps/mobile\n     pnpm dlx eas-cli whoami\n     ```\n2. Resolve target device.\n   - List paired devices:\n     ```bash\n     xcrun devicectl list devices\n     ```\n   - Choose device in this order:\n     - `$ARGUMENTS` if provided and matches exactly one device.\n     - Otherwise, first paired iPhone.\n3. Trigger local `preview` iOS build.\n\n   ```bash\n   mkdir -p .context/preview-install\n   cd apps/mobile\n   pnpm dlx eas-cli build -p ios --profile preview --non-interactive --local --output=./build-preview.ipa\n   cd ../..\n   cp apps/mobile/build-preview.ipa .context/preview-install/folo-preview.ipa\n   ```\n\n4. Install to device locally.\n   ```bash\n   unzip -q -o .context/preview-install/folo-preview.ipa -d .context/preview-install/unpacked\n   APP_PATH=$(find .context/preview-install/unpacked/Payload -maxdepth 1 -name '*.app' -type d | head -n 1)\n   xcrun devicectl device install app --device \"<device-id>\" \"$APP_PATH\"\n   ```\n5. Try launching app.\n\n   ```bash\n   xcrun devicectl device process launch --device \"<device-id>\" is.follow --activate\n   ```\n\n   - If launch fails due to locked device, instruct the user to unlock iPhone and open `Folo` manually.\n\n## Failure Handling\n\n- If local build fails, report:\n  - build mode (`local`)\n  - failing command\n  - key error message from command output\n- If app config fails with `Assets source directory not found ... /out/rn-web`, prebuild assets then retry once:\n  ```bash\n  pnpm --filter @follow/rn-micro-web-app build --outDir out/rn-web/html-renderer\n  ```\n\n## Output Format\n\nAlways return:\n\n1. Build mode (`local`) and final status.\n2. Local IPA path.\n3. Target device identifier.\n4. Install result (`installed` or `failed`) and launch result.\n5. Next action for the user if manual action is required.\n"
  },
  {
    "path": ".agents/skills/mobile-e2e/SKILL.md",
    "content": "---\nname: mobile-e2e\ndescription: Run apps/mobile Maestro end-to-end tests in this repo. Use when an agent needs to validate mobile auth flows on iOS Simulator or Android Emulator. Current maintained coverage is register, sign out, and sign in.\ndisable-model-invocation: true\nallowed-tools: Bash, Read, Write, Edit, Glob, Grep\n---\n\n# Mobile E2E\n\nRun the mobile Maestro tests for `apps/mobile`.\n\n## Files that matter\n\n- Runner: `apps/mobile/e2e/run-maestro.sh`\n- iOS auth flow: `apps/mobile/e2e/flows/ios/auth.yaml`\n- Android auth flow: `apps/mobile/e2e/flows/android/core.yaml`\n- Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml`\n- Artifacts: `apps/mobile/e2e/artifacts/`\n\n## Always do first\n\nFrom repo root:\n\n```bash\ncd apps/mobile\npnpm run e2e:doctor\npnpm run typecheck\n```\n\n## iOS\n\nUse a simulator `.app` build, not an Expo development client.\n\n### Preferred simulator\n\nPrefer the **latest installed iOS runtime** and a **latest-generation iPhone simulator**.\n\nWhen multiple simulators are available, bias toward the newest iPhone model on the newest installed iOS version.\n\n### Boot simulator\n\n```bash\nxcrun simctl boot <IOS_UDID>\nxcrun simctl bootstatus <IOS_UDID> -b\nopen -a Simulator --args -CurrentDeviceUDID <IOS_UDID>\n```\n\n### App bundle\n\n`run-maestro.sh` can resolve the app bundle from one of these sources:\n\n- `MAESTRO_IOS_APP_PATH`\n- a local `build-*.tar.gz` in `apps/mobile`\n- an existing `DerivedData/.../Release-iphonesimulator/Folo.app`\n\nIf none of those exist, build one first.\n\n### Build simulator app when missing\n\nIf `Folo.app` is not available yet:\n\n```bash\ncd apps/mobile/ios\npod install\nxcodebuild -workspace Folo.xcworkspace \\\n  -scheme Folo \\\n  -configuration Release \\\n  -sdk iphonesimulator \\\n  -destination 'id=<IOS_UDID>' \\\n  build\n```\n\n### Apple Silicon simulator optimization\n\nWhen running on an Apple Silicon Mac and building only for the simulator used in the current run, prefer compiling only the active `arm64` simulator architecture:\n\n```bash\nxcodebuild ... \\\n  ONLY_ACTIVE_ARCH=YES \\\n  ARCHS=arm64\n```\n\nUse this optimization only for local self-test / e2e simulator builds tied to the current machine. Do not use it when you need a universal simulator app for other machines or when running on Intel Macs.\n\nExpected output pattern:\n\n```bash\n~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app\n```\n\n### Run iOS auth flow\n\n```bash\ncd apps/mobile\nMAESTRO_IOS_DEVICE_ID=<IOS_UDID> \\\nMAESTRO_IOS_APP_PATH=<PATH_TO_Folo.app> \\\npnpm run e2e:ios\n```\n\n## Android\n\nUse a **release APK**, not an Expo development build.\n\n### Java\n\nUse Android Studio bundled JBR:\n\n```bash\nexport JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\"\nexport PATH=\"$JAVA_HOME/bin:$PATH\"\n```\n\n### Android SDK\n\n```bash\nexport ANDROID_HOME=\"$HOME/Library/Android/sdk\"\nexport ANDROID_SDK_ROOT=\"$HOME/Library/Android/sdk\"\n```\n\nIf `apps/mobile/android/local.properties` is missing, create it with:\n\n```bash\necho \"sdk.dir=$HOME/Library/Android/sdk\" > apps/mobile/android/local.properties\n```\n\n### Build release APK\n\nIf `apps/mobile/android` does not exist locally, generate it first with Expo prebuild / run-android tooling.\n\nThen build the release APK:\n\n```bash\ncd apps/mobile/android\n./gradlew app:assembleRelease --console=plain\n```\n\nExpected APK path:\n\n```bash\napps/mobile/android/app/build/outputs/apk/release/app-release.apk\n```\n\n### Install to emulator\n\n```bash\nadb -s emulator-5554 install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk\n```\n\n### Run Android auth flow\n\nStart a booted emulator first, then:\n\n```bash\ncd apps/mobile\npnpm run e2e:android\n```\n\n## Result checks\n\nSuccessful auth validation means:\n\n- register flow finishes\n- sign-out reaches `login-screen`\n- login flow makes `login-screen` disappear\n\n## Debugging output\n\nInspect these folders after a run:\n\n```bash\napps/mobile/e2e/artifacts/ios/\napps/mobile/e2e/artifacts/android/\n```\n\nFor a one-off focused run, invoke Maestro directly against a single flow and a custom debug directory.\n"
  },
  {
    "path": ".agents/skills/mobile-release/SKILL.md",
    "content": "---\nname: mobile-release\ndescription: Perform a regular mobile release from the dev branch. Gathers commits since last release, updates changelog, bumps version, updates iOS Info.plist, and creates release PR to mobile-main.\ndisable-model-invocation: true\nallowed-tools: Bash, Read, Write, Edit, Glob, Grep\n---\n\n# Mobile Regular Release\n\nPerform a regular mobile release. This skill handles the full release workflow from the `dev` branch.\n\n## Pre-flight checks\n\n1. Confirm the current branch is `dev`. If not, abort with a warning.\n2. Run `git pull --rebase` in the repo root to ensure the local branch is up to date.\n3. Read `apps/mobile/package.json` to get the current `version`.\n\n## Step 1: Gather changes since last release\n\n1. Find the last release tag (both old `mobile@` and new `mobile/v` prefixes exist):\n   ```bash\n   git tag --sort=-creatordate | grep -E '^mobile[@/]' | head -1\n   ```\n2. If no tag found, find the last release commit by matching only the subject line:\n   ```bash\n   git log --format=\"%H %s\" | grep \"^[a-f0-9]* release(mobile): release v\" | head -1 | awk '{print $1}'\n   ```\n3. Get all commits since the last release on the current branch:\n   ```bash\n   git log <last-tag-or-commit>..HEAD --oneline --no-merges\n   ```\n4. Categorize commits into:\n   - **Shiny new things** (feat: commits, new features)\n   - **Improvements** (refactor:, perf:, chore: improvements, dependency updates)\n   - **No longer broken** (fix: commits, bug fixes)\n   - **Thanks** (identify external contributor GitHub usernames from commits)\n\n## Step 2: Update changelog\n\n1. Read `apps/mobile/changelog/next.md`.\n2. Present the categorized changes to the user and draft the changelog content.\n3. Wait for user confirmation or edits before writing.\n4. Write the final content to `apps/mobile/changelog/next.md`, following the template format:\n\n   ```markdown\n   # What's New in vNEXT_VERSION\n\n   ## Shiny new things\n\n   - description of new feature\n\n   ## Improvements\n\n   - description of improvement\n\n   ## No longer broken\n\n   - description of fix\n\n   ## Thanks\n\n   Special thanks to volunteer contributors @username for their valuable contributions\n   ```\n\n5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump.\n\n## Step 3: Commit changelog updates before bump\n\n`nbump` requires a clean working tree. Commit changelog edits before running bump.\n\n1. Stage the changelog update:\n   ```bash\n   git add apps/mobile/changelog/next.md\n   ```\n2. Commit it on `dev`:\n   ```bash\n   git commit -m \"docs(mobile): prepare release changelog\"\n   ```\n3. If there are no changes to commit, continue without creating an extra commit.\n\n## Step 4: Execute bump\n\n1. Verify working tree is clean before bump:\n   ```bash\n   git status --short\n   ```\n2. Change directory to `apps/mobile/` and run the bump:\n   ```bash\n   cd apps/mobile && pnpm bump\n   ```\n3. This is an interactive `nbump` command that prompts for version selection. It will:\n   - Pull latest changes\n   - Apply changelog (rename next.md to {version}.md, create new next.md from template)\n   - Format package.json with eslint + prettier\n   - Bump version in `package.json`\n   - Update `ios/Folo/Info.plist`:\n     - Set `CFBundleShortVersionString` to the new version\n     - Increment `CFBundleVersion` (build number) by 1\n   - Commit with message `release(mobile): release v{NEW_VERSION}`\n   - Create branch `release/mobile/{NEW_VERSION}`\n   - Push branch and create PR to `mobile-main`\n\n## Step 5: Verify\n\n1. Confirm the PR was created successfully by checking the output.\n2. Report the new version number and PR URL to the user.\n3. Summarize:\n   - New version: v{NEW_VERSION}\n   - Changelog highlights\n   - PR URL\n\n## Post-release (manual steps, inform user)\n\nAfter the release PR is merged to `mobile-main`:\n\n1. **Trigger production builds** via GitHub Actions `workflow_dispatch`:\n   - Go to \"Build iOS\" workflow, select `mobile-main` branch, profile = `production`\n   - Go to \"Build Android\" workflow, select `mobile-main` branch, profile = `production`\n2. Production builds auto-submit to App Store (via `eas submit`) and Google Play (as draft).\n3. After submission, go to App Store Connect and Google Play Console to complete the review/release process.\n\n## Reference\n\n- Bump config: `apps/mobile/bump.config.ts`\n- Changelog dir: `apps/mobile/changelog/`\n- Changelog template: `apps/mobile/changelog/next.template.md`\n- Apply changelog script: `apps/mobile/scripts/apply-changelog.ts`\n- EAS config: `apps/mobile/eas.json`\n- App config: `apps/mobile/app.config.ts`\n- iOS Info.plist: `apps/mobile/ios/Folo/Info.plist`\n- CI build iOS: `.github/workflows/build-ios.yml`\n- CI build Android: `.github/workflows/build-android.yml`\n"
  },
  {
    "path": ".agents/skills/mobile-self-test/SKILL.md",
    "content": "---\nname: mobile-self-test\ndescription: Self-test a mobile feature change or bug fix after implementation in `apps/mobile`. Use this whenever the user asks to verify a mobile change, run simulator acceptance, smoke-test a mobile PR, or provide screenshot proof for a mobile fix. This skill decides between prod vs local API mode, starts the local follow-server when needed, builds a release app, uses Maestro only to bootstrap registration for non-auth work, then switches to screenshot-driven visual validation and returns screenshot evidence.\ndisable-model-invocation: true\nallowed-tools: Bash, Read, Write, Edit, Glob, Grep\n---\n\n# Mobile Self Test\n\nValidate a mobile change after implementation.\n\nThis skill extends `../mobile-e2e/SKILL.md`. Read that skill first for the baseline doctor checks, iOS simulator boot rules, Java/Android SDK setup, and Maestro artifact conventions. Then apply the extra rules in this skill.\n\n## Files that matter\n\n- Reference skill: `../mobile-e2e/SKILL.md`\n- Runner: `apps/mobile/e2e/run-maestro.sh`\n- iOS register flow: `apps/mobile/e2e/flows/ios/register.yaml`\n- Android register flow: `apps/mobile/e2e/flows/android/register.yaml`\n- Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml`\n- Expo config: `apps/mobile/app.config.ts`\n- Build profiles: `apps/mobile/eas.json`\n- Mobile artifacts: `apps/mobile/e2e/artifacts/`\n- Local server repo: `/Users/diygod/Code/Projects/follow-server`\n\n## Default assumptions\n\n- Prefer **iOS simulator** unless the user explicitly asks for Android or the change is Android-specific.\n- Default to **prod API mode** when the user did not specify a mode.\n- Default to **local API mode** when the task also involves local server changes, backend debugging, or modified files in `/Users/diygod/Code/Projects/follow-server`.\n- Keep `EXPO_PUBLIC_E2E_LANGUAGE=en` unless the user explicitly wants another language. The existing Maestro flows assume English UI.\n\n## Simulator and emulator isolation\n\nThis section overrides the shared device-selection guidance from `../mobile-e2e/SKILL.md`.\n\nSelf-test runs must be isolated because other agents may be using simulators or emulators on the same machine.\n\n- Always create a dedicated temporary simulator or emulator for the current run.\n- Never reuse `booted`, an already-running simulator, or a generic Android serial such as `emulator-5554`.\n- Record the temporary device name and identifier immediately after creation, then use only that stored identifier for build, install, launch, screenshots, and Maestro.\n- Register cleanup before booting the device so the temporary simulator or emulator is deleted even if the test fails midway.\n- If cleanup fails, report the leftover device name and identifier in the final response.\n\n## Decide API mode first\n\nUse this decision order:\n\n1. If the user explicitly asks for `prod` or `local`, obey that.\n2. Otherwise, if the task depends on local backend changes or local server behavior, use `local`.\n3. Otherwise, use `prod`.\n\nMap the chosen mode into the release build:\n\n- `prod` mode: `EXPO_PUBLIC_E2E_ENV_PROFILE=prod`\n- `local` mode: `EXPO_PUBLIC_E2E_ENV_PROFILE=local`\n\nDo not silently reuse a build from the other mode. Rebuild the release app when switching between `prod` and `local`.\n\n## Always do first\n\nFrom repo root:\n\n```bash\ncd apps/mobile\npnpm run e2e:doctor\npnpm run typecheck\n```\n\nIf these fail, stop and report the blocker before attempting simulator work.\n\n## Local server mode\n\n`local` mode requires the local server to be available at `http://localhost:3000`.\n\nBefore starting anything, check whether it is already running. Do not start a duplicate server.\n\n```bash\nFOLLOW_SERVER_LOG=/tmp/follow-server-dev-core.log\n\nif pgrep -af \"pnpm dev:core\" >/dev/null 2>&1 || lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then\n  echo \"follow-server already running\"\nelse\n  (\n    cd /Users/diygod/Code/Projects/follow-server\n    nohup pnpm dev:core >\"$FOLLOW_SERVER_LOG\" 2>&1 &\n  )\nfi\n\nfor _ in $(seq 1 60); do\n  nc -z 127.0.0.1 3000 >/dev/null 2>&1 && break\n  sleep 2\ndone\n\nnc -z 127.0.0.1 3000 >/dev/null 2>&1\n```\n\nIf the task depends on other local surfaces such as `http://localhost:2233`, call that out explicitly instead of pretending the mobile test fully covers it.\n\n## Release build profiles for self-test\n\nUse release-style builds so the test matches user-facing behavior.\n\n- iOS simulator builds: `PROFILE=e2e-ios-simulator`\n- Android emulator builds: `PROFILE=e2e-android`\n\nAlways pair those with the chosen API mode and language:\n\n```bash\nexport EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local>\nexport EXPO_PUBLIC_E2E_LANGUAGE=en\n```\n\n## iOS workflow\n\nDo not attach to an existing simulator from `../mobile-e2e/SKILL.md`. Create a dedicated temporary simulator for this run and keep using only its UDID.\n\n### Create a dedicated temporary simulator\n\nPick the latest available iOS runtime and a recent iPhone device type, then create a temporary simulator.\n\n```bash\nIOS_SIM_NAME=\"CodexSelfTest-$(date +%Y%m%d-%H%M%S)\"\nIOS_RUNTIME_ID=\"<latest available iOS runtime identifier from `xcrun simctl list runtimes`>\"\nIOS_DEVICE_TYPE_ID=\"<recent iPhone device type identifier from `xcrun simctl list devicetypes`>\"\n\nIOS_UDID=\"$(xcrun simctl create \"$IOS_SIM_NAME\" \"$IOS_DEVICE_TYPE_ID\" \"$IOS_RUNTIME_ID\")\"\n\ncleanup_ios_simulator() {\n  xcrun simctl shutdown \"$IOS_UDID\" >/dev/null 2>&1 || true\n  xcrun simctl delete \"$IOS_UDID\" >/dev/null 2>&1 || true\n}\n\ntrap cleanup_ios_simulator EXIT\n```\n\nDo not switch to another simulator after `IOS_UDID` is created.\n\n### Boot the dedicated simulator\n\n```bash\nxcrun simctl boot \"$IOS_UDID\"\nxcrun simctl bootstatus \"$IOS_UDID\" -b\nopen -a Simulator --args -CurrentDeviceUDID \"$IOS_UDID\"\n```\n\nIf other simulators are already booted, leave them alone and continue using only `IOS_UDID`.\n\n### Build release simulator app\n\n```bash\ncd apps/mobile/ios\npod install\n\nPROFILE=e2e-ios-simulator \\\nEXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local> \\\nEXPO_PUBLIC_E2E_LANGUAGE=en \\\nxcodebuild -workspace Folo.xcworkspace \\\n  -scheme Folo \\\n  -configuration Release \\\n  -sdk iphonesimulator \\\n  -destination \"id=$IOS_UDID\" \\\n  clean build\n```\n\nOn Apple Silicon Macs, when the build is only for the dedicated simulator created for the current self-test run, prefer compiling only the active `arm64` simulator architecture:\n\n```bash\nONLY_ACTIVE_ARCH=YES \\\nARCHS=arm64\n```\n\nDo not use that optimization when you need a universal simulator bundle for other machines or when the host Mac is Intel.\n\nExpected output pattern:\n\n```bash\n~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app\n```\n\n### Install app on simulator\n\n```bash\nxcrun simctl install \"$IOS_UDID\" <PATH_TO_Folo.app>\nxcrun simctl launch \"$IOS_UDID\" is.follow\n```\n\n## Android workflow\n\nReuse the Java and Android SDK setup from `../mobile-e2e/SKILL.md`.\n\nDo not attach to a shared emulator. Create a dedicated temporary AVD for this run and keep using only its recorded serial.\n\n### Create a dedicated temporary AVD\n\nCreate a fresh AVD backed by an installed phone system image.\n\n```bash\nANDROID_AVD_NAME=\"codex-self-test-$(date +%Y%m%d-%H%M%S)\"\nANDROID_AVD_PACKAGE=\"<installed Android system image package>\"\nANDROID_AVD_DEVICE=\"<phone hardware profile>\"\n\navdmanager create avd -n \"$ANDROID_AVD_NAME\" -k \"$ANDROID_AVD_PACKAGE\" -d \"$ANDROID_AVD_DEVICE\" --force\n\nANDROID_EMULATOR_PORT=\"\"\nfor port in 5554 5556 5558 5560 5562 5564; do\n  if ! lsof -nP -iTCP:$port >/dev/null 2>&1 && ! lsof -nP -iTCP:$((port + 1)) >/dev/null 2>&1; then\n    ANDROID_EMULATOR_PORT=\"$port\"\n    break\n  fi\ndone\n\n[ -n \"$ANDROID_EMULATOR_PORT\" ] || {\n  echo \"No free Android emulator port found\"\n  exit 1\n}\n\nANDROID_DEVICE_ID=\"emulator-$ANDROID_EMULATOR_PORT\"\n\ncleanup_android_emulator() {\n  adb -s \"$ANDROID_DEVICE_ID\" emu kill >/dev/null 2>&1 || true\n  avdmanager delete avd -n \"$ANDROID_AVD_NAME\" >/dev/null 2>&1 || true\n}\n\ntrap cleanup_android_emulator EXIT\n```\n\n### Boot the dedicated emulator\n\n```bash\nemulator @\"$ANDROID_AVD_NAME\" -port \"$ANDROID_EMULATOR_PORT\" -no-snapshot -wipe-data &\nadb -s \"$ANDROID_DEVICE_ID\" wait-for-device\n```\n\nIf other emulators are already booted, ignore them and continue using only `ANDROID_DEVICE_ID`.\n\nIf `apps/mobile/android` does not exist locally, generate it first.\n\n```bash\ncd apps/mobile\npnpm expo prebuild android\n```\n\n### Build release APK\n\n```bash\ncd apps/mobile/android\n\nPROFILE=e2e-android \\\nEXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local> \\\nEXPO_PUBLIC_E2E_LANGUAGE=en \\\n./gradlew clean app:assembleRelease --console=plain\n```\n\nExpected APK path:\n\n```bash\napps/mobile/android/app/build/outputs/apk/release/app-release.apk\n```\n\n### Install app on emulator\n\n```bash\nadb -s \"$ANDROID_DEVICE_ID\" install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk\nadb -s \"$ANDROID_DEVICE_ID\" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1\n```\n\n## Cleanup is mandatory\n\nDelete the temporary simulator or emulator created for the run before returning control to the user.\n\n### iOS cleanup\n\n```bash\nxcrun simctl shutdown \"$IOS_UDID\" >/dev/null 2>&1 || true\nxcrun simctl delete \"$IOS_UDID\" >/dev/null 2>&1 || true\n```\n\n### Android cleanup\n\n```bash\nadb -s \"$ANDROID_DEVICE_ID\" emu kill >/dev/null 2>&1 || true\navdmanager delete avd -n \"$ANDROID_AVD_NAME\" >/dev/null 2>&1 || true\n```\n\nDo not leave temporary devices behind for other agents.\n\n## Choose the auth strategy\n\nThis is the core difference from `mobile-e2e`.\n\n### A. Change is **not** related to login or registration\n\nUse the existing automated **registration** flow first to bootstrap a clean logged-in account, then do the real verification visually.\n\nExamples:\n\n- timeline behavior\n- subscription management\n- onboarding content after auth\n- settings pages unrelated to sign-in state\n- player, reader, share, discover, profile editing\n\nGenerate a unique test account before running the flow:\n\n```bash\nexport E2E_PASSWORD='Password123!'\nexport E2E_EMAIL=\"folo-self-test-$(date +%Y%m%d%H%M%S)@example.com\"\n```\n\nFor non-auth iOS self-tests, bootstrap auth through the standard iOS runner mode after the app has been installed and launched once:\n\n```bash\ncd apps/mobile\npnpm run e2e:ios:bootstrap\n```\n\nThis bootstrap path is the default for `prod` and `local` self-tests. Only skip it when the feature under test is login, registration, sign-out, session restoration, or another auth-specific flow that must be validated visually end-to-end.\n\n#### iOS registration bootstrap\n\n```bash\ncd apps/mobile\nmaestro test --format junit --platform ios --device \"$IOS_UDID\" \\\n  --debug-output e2e/artifacts/ios/register-bootstrap \\\n  -e E2E_EMAIL=\"$E2E_EMAIL\" \\\n  -e E2E_PASSWORD=\"$E2E_PASSWORD\" \\\n  e2e/flows/ios/register.yaml\n```\n\n#### Android registration bootstrap\n\n```bash\ncd apps/mobile\nmaestro test --format junit --platform android --device \"$ANDROID_DEVICE_ID\" \\\n  --debug-output e2e/artifacts/android/register-bootstrap \\\n  -e E2E_EMAIL=\"$E2E_EMAIL\" \\\n  -e E2E_PASSWORD=\"$E2E_PASSWORD\" \\\n  e2e/flows/android/register.yaml\n```\n\nAfter registration succeeds, continue with screenshot-driven visual testing.\n\n### B. Change **is** related to login, registration, logout, session handling, auth validation, or onboarding gates\n\nDo **not** rely on the existing Maestro auth flows for the actual verification. Use a fully visual/manual run instead so the changed UX itself is what gets tested.\n\nExamples:\n\n- register screen changes\n- login screen changes\n- credential validation changes\n- auth toggle changes\n- logout behavior\n- auth/session restoration\n- onboarding shown or hidden based on auth state\n\nFor auth-related work:\n\n- create the test account manually through the UI if needed\n- use screenshots after every critical step\n- verify success and error states visually\n- keep a clean record of the exact screen sequence shown to the user\n\n## Screenshot-driven visual testing\n\nOnce the app is in the right state, drive the rest of the validation with the visual toolchain available in the current environment. Screenshots are the source of truth for acceptance.\n\nCreate a timestamped artifact folder first:\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nARTIFACT_DIR=\"$REPO_ROOT/apps/mobile/e2e/artifacts/manual/$(date +%Y%m%d-%H%M%S)-<platform>-<prod-or-local>\"\nmkdir -p \"$ARTIFACT_DIR\"\n```\n\nCapture screenshots after each meaningful checkpoint.\n\n### iOS screenshot command\n\n```bash\nxcrun simctl io \"$IOS_UDID\" screenshot \"$ARTIFACT_DIR/<name>.png\"\n```\n\n### Android screenshot command\n\n```bash\nadb -s \"$ANDROID_DEVICE_ID\" exec-out screencap -p > \"$ARTIFACT_DIR/<name>.png\"\n```\n\nMinimum screenshot set for a complete self-test:\n\n1. entry screen before the changed flow\n2. the changed screen or interaction in progress\n3. the final success state or the reproduced bug state\n\nAdd more screenshots when the flow has multiple important states.\n\nDo not report success without screenshot evidence.\n\n## What to validate visually\n\nUse the screenshots to confirm at least these points when relevant:\n\n- the correct screen is reached\n- the changed control, copy, or layout is visible\n- loading, empty, error, and success states look correct\n- the operation completes without obvious regressions or blocking dialogs\n- the app is talking to the intended environment (`prod` or `local`)\n\nIf the UI or behavior is ambiguous, capture another screenshot instead of guessing.\n\n## Final user-facing output\n\nThe final response must include:\n\n- API mode used and why it was chosen\n- platform and dedicated simulator/emulator name plus identifier used\n- cleanup result for the temporary simulator/emulator\n- whether the local server was reused or started, plus log path if started\n- build command used\n- whether auth bootstrap was automated or fully visual\n- concise step-by-step result summary\n- pass/fail conclusion\n- screenshot evidence with absolute file paths\n\nIf the client supports local image rendering, attach the key screenshots as images in the final message. Otherwise, list the absolute paths clearly so the user can open them.\n\n## Failure handling\n\n- If doctor, typecheck, build, install, or server startup fails, stop and report the exact failing command.\n- If `local` mode cannot reach the local server, do not silently fall back to `prod`.\n- If the visual flow cannot be completed because the environment lacks the required interaction tooling, report that limitation clearly and still return the screenshots you captured.\n"
  },
  {
    "path": ".agents/skills/update-deps/SKILL.md",
    "content": "---\nname: update-deps\ndescription: Update all dependencies across frontend and backend projects. Reads changelogs for breaking changes, checks affected code, runs tests, and provides a summary. Use when updating npm dependencies across the monorepo.\ndisable-model-invocation: true\nallowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Task\n---\n\n# Update All Dependencies\n\nUpdate all npm dependencies across the frontend (current repo) and backend projects. This skill handles the full update workflow including changelog review, code impact analysis, testing, and summarization.\n\n## Step 0: Ask for backend directory\n\nAsk the user for the backend project directory path. Do NOT hardcode any path. Example prompt:\n\n> Please provide the backend project directory path (e.g., `/path/to/backend`).\n\nWait for the user's response before proceeding. Save the path as `BACKEND_DIR` for later use.\n\n## Step 1: Analyze current dependencies\n\n### Frontend (current repo)\n\n1. Run `pnpm outdated --recursive` in the current repo root to identify all outdated dependencies.\n2. Save the full output for later analysis.\n\n### Backend\n\n1. Run `pnpm outdated --recursive` in `BACKEND_DIR` to identify all outdated dependencies.\n2. Save the full output for later analysis.\n\nPresent the user with a summary of how many dependencies are outdated in each project.\n\n## Step 2: Update dependencies\n\n### Strategy\n\nUpdate in two phases to isolate issues:\n\n**Phase 1 — Patch and minor updates (safer):**\n\nFrom the `pnpm outdated` output, identify all dependencies where the update is a patch or minor version bump. Update them in batch:\n\n1. Frontend: For each patch/minor outdated package, run `pnpm update <package>@latest --recursive` in the repo root.\n2. Backend: For each patch/minor outdated package, run `pnpm update <package>@latest --recursive` in `BACKEND_DIR`.\n\n> **Why `@latest`?** Both projects use `save-exact=true`, so versions are pinned without `^` or `~`. Without `--latest`, `pnpm update` only resolves within the existing range, which for exact versions is a no-op.\n\n**Phase 2 — Major updates (requires careful review):**\n\nFor each dependency with a major version update available:\n\n1. Identify the dependency name, current version, and latest version.\n2. **Read the changelog** (Step 3) before updating.\n3. Only update after confirming no blocking breaking changes.\n4. Use `pnpm update <package>@latest --recursive` to update specific packages.\n\n**Important pnpm workspace notes:**\n\n- The frontend project uses `pnpm catalog` in `pnpm-workspace.yaml` for some shared versions. If a dependency is managed via catalog, update the version in `pnpm-workspace.yaml` instead of individual `package.json` files.\n- Both projects use `save-exact=true`, so versions are pinned without `^` or `~`.\n- Check `patchedDependencies` in `pnpm-workspace.yaml` or `package.json` — if a patched dependency is being updated, verify the patch still applies or remove it if no longer needed.\n\n## Step 3: Review changelogs for major updates\n\nFor each dependency with a **major version update**, you MUST read the changelog before updating.\n\n### How to find changelogs\n\nUse these methods in order of preference:\n\n1. **npm registry**: Run `npm view <package> repository.url` to find the repo, then check for `CHANGELOG.md` or GitHub releases.\n2. **GitHub releases**: Search `https://github.com/<owner>/<repo>/releases` using WebFetch.\n3. **Web search**: Use WebSearch to find `<package> changelog <old-version> to <new-version>`.\n\n### What to look for\n\n- **Breaking changes**: API removals, renamed exports, changed defaults, dropped Node.js version support.\n- **Deprecated features**: Features being removed in future versions.\n- **Migration guides**: Official upgrade instructions.\n- **Peer dependency changes**: New or changed peer dependency requirements.\n\n### Document findings\n\nFor each major update, record:\n\n- Package name and version change (e.g., `foo: 2.x → 3.x`)\n- Breaking changes summary\n- Whether our code is affected (and how)\n\n## Step 4: Check affected code\n\nFor each dependency with breaking changes identified in Step 3:\n\n1. Use `Grep` to find all imports and usages of the affected package across the relevant project (frontend or backend).\n2. Read the files containing usages.\n3. Compare the usage against the breaking change description.\n4. If our code uses an affected API:\n   - Attempt to fix the code following the migration guide.\n   - If the fix is complex or risky, **skip updating this dependency** and note it in the summary.\n5. If our code does NOT use any affected API, proceed with the update.\n\n## Step 5: Run tests and checks\n\nAfter all updates are applied:\n\n### Frontend\n\nRun these commands sequentially in the repo root and capture results:\n\n```bash\npnpm install\npnpm typecheck\npnpm test\npnpm lint\n```\n\n### Backend\n\nRun these commands sequentially in `BACKEND_DIR` and capture results:\n\n```bash\npnpm install\npnpm typecheck\npnpm test\npnpm lint\n```\n\n### Handle failures\n\n- **TypeScript errors**: Read the error output, identify which updated dependency caused the issue, and fix the type errors. If unfixable, revert that specific dependency update.\n- **Test failures**: Analyze the failure, check if it's related to a dependency update, and fix or revert.\n- **Lint errors**: Run `pnpm lint:fix` first. If issues persist, fix manually or revert the causing update.\n\nRepeat the test cycle until all checks pass.\n\n## Step 6: Summary\n\nPresent the user with a comprehensive summary:\n\n### Update report\n\n```\n## Dependencies Updated\n\n### Frontend\n- <package>: <old-version> → <new-version> (patch/minor/major)\n- ...\n\n### Backend\n- <package>: <old-version> → <new-version> (patch/minor/major)\n- ...\n\n## Skipped Updates (with reasons)\n- <package>: <reason why not updated>\n- ...\n\n## Key Changelog Highlights\n\n### Breaking Changes Applied\n- <package> <version>: <what changed and how we adapted>\n\n### Notable New Features\n- <package> <version>: <brief description>\n\n### Deprecation Warnings\n- <package> <version>: <what's deprecated and timeline>\n\n## Test Results\n- Frontend typecheck: ✅/❌\n- Frontend tests: ✅/❌\n- Frontend lint: ✅/❌\n- Backend typecheck: ✅/❌\n- Backend tests: ✅/❌\n- Backend lint: ✅/❌\n```\n\nAsk the user if they want to commit the changes.\n"
  },
  {
    "path": ".cursorignore",
    "content": "# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n*.splinecode filter=lfs diff=lfs merge=lfs -text\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 🐞 Bug report\ndescription: Report an issue\nlabels: [pending triage, bug]\ntype: Bug\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n\n  - type: dropdown\n    id: platform\n    attributes:\n      label: Platform\n      description: On which platforms does this bug occur?\n      multiple: true\n      options:\n        - Desktop - macOS\n        - Desktop - Windows\n        - Desktop - Linux\n        - Desktop - Web\n        - Mobile - iOS\n        - Mobile - Android\n        - Mobile - Web\n    validations:\n      required: true\n\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!\n      placeholder: Bug description\n    validations:\n      required: true\n\n  - type: input\n    id: entryId\n    attributes:\n      label: Entry ID\n      description: Please provide the entry id of the entry that is causing the issue. If you are not sure, please provide the entry url.\n\n  - type: textarea\n    id: relevant-information\n    attributes:\n      label: Relevant Information\n      description: Please provide your user id, feed id, feed url, or any other information that can help us reproduce the issue.\n      placeholder: User ID, Feed ID, Feed URL, etc.\n    validations:\n      required: false\n\n  - type: textarea\n    id: reproduction\n    attributes:\n      label: Reproduction Video\n      description: If possible, please provide a video that demonstrates the bug.\n    validations:\n      required: false\n\n  - type: textarea\n    id: environment\n    attributes:\n      label: Environment\n      description: Please provide the environment in which you are using the application. You can find this information by going to Preferences > About and clicking the copy button next to the version tag.\n\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please make sure you do the following\n      options:\n        - label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.\n          required: true\n        - label: Check that this is a concrete bug. For Q&A, please open a GitHub Discussion instead.\n          required: true\n        - label: This issue is valid\n          required: true\n\n  - type: checkboxes\n    id: contributions\n    attributes:\n      label: Contributions\n      description: Please note that Open Source projects are maintained by volunteers, where your cases might not be always relevant to the others. It would make things move faster if you could help investigate and propose solutions.\n      options:\n        - label: I am willing to submit a PR to fix this issue\n        - label: I am willing to submit a PR with failing tests (actually just go ahead and do it, thanks!)\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 💬 Follow's Discord Server\n    url: https://discord.gg/tUDVZjEr\n    about: Want to discuss / chat with the community? Here you go!\n  - name: Discuss an issue\n    url: https://github.com/RSSNext/Follow/discussions\n    about: For general questions, ideas, or non-bug related discussions, please use GitHub Discussions.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: 🚀 New feature proposal\ndescription: Propose a new feature\nlabels: [enhancement]\ntype: Feature\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for your interest in the project and taking the time to fill out this feature report!\n  - type: textarea\n    id: feature-description\n    attributes:\n      label: Clear and concise description of the problem\n      description: \"As a developer using this project I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description. Thanks!\"\n    validations:\n      required: true\n  - type: textarea\n    id: suggested-solution\n    attributes:\n      label: Suggested solution\n      description: \"In module [xy] we could provide following implementation...\"\n    validations:\n      required: true\n  - type: textarea\n    id: alternative\n    attributes:\n      label: Alternative\n      description: Clear and concise description of any alternative solutions or features you've considered.\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: Any other context or screenshots about the feature request here.\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please make sure you do the following\n      options:\n        - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.\n          required: true\n        - label: This issue is valid\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/i18n.yml",
    "content": "name: 🌐 Internationalization (i18n)\ndescription: Contribute to or report issues with translations\ntitle: \"[i18n]: \"\nlabels: [\"i18n\", \"triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to contribute to our internationalization efforts!\n\n        Before proceeding, please check our [i18n Contribution Guidelines](https://github.com/RSSNext/Follow/blob/dev/wiki/contribute-i18n.md) for detailed instructions.\n  - type: dropdown\n    id: type\n    attributes:\n      label: Type of i18n contribution\n      options:\n        - New language support\n        - Update existing translations\n        - Report incorrect translation\n        - Other i18n-related issue\n    validations:\n      required: true\n  - type: input\n    id: language\n    attributes:\n      label: Language\n      description: What language are you contributing to or reporting about?\n      placeholder: e.g., Spanish, French, Japanese\n    validations:\n      required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: Please provide details about your contribution or the issue you're reporting.\n      placeholder: |\n        For new languages: List any specific challenges or considerations.\n        For updates: Describe what you're changing and why.\n        For issues: Provide the incorrect translation and suggest a correction.\n    validations:\n      required: true\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: Add any other context, screenshots, or file references here.\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com/code-of-conduct)\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n        - label: This issue is valid\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/typo.yml",
    "content": "name: 👀 Typo / Grammar fix\ndescription: You can just go ahead and send a PR! Thank you!\nlabels: []\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## PR Welcome!\n\n        If the typo / grammar issue is trivial and straightforward, you can help by **directly sending a quick pull request**!\n        If you spot multiple of them, we suggest combining them into a single PR. Thanks!\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional context\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please make sure you do the following\n      options:\n        - label: This issue is valid\n          required: true\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!-- DO NOT IGNORE THE TEMPLATE!\n\nThank you for contributing!\n\nBefore submitting the PR, please make sure you do the following:\n\n- Read the [Contributing Guide](/contribute).\n- Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.\n- Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`).\n- Ideally, include relevant tests that fail without this PR but pass with it.\n\n-->\n\n### Description\n\n<!-- Please insert your description here and provide especially info about the \"what\" this PR is solving -->\n\n### PR Type\n\n<!-- Please check the type of PR: -->\n\n- [ ] Feature\n- [ ] Bugfix\n- [ ] Hotfix\n- [ ] Other (please describe):\n\n### Screenshots (if UI change)\n\n### Demo Video (if new feature)\n\n### Linked Issues\n\n### Additional context\n\n<!-- e.g. is there anything you'd like reviewers to focus on? -->\n\n### Changelog\n\n<!-- Please ensure the changelog/next.md is updated if this is a feature or hotfix: -->\n\n- [ ] I have updated the changelog/next.md with my changes.\n"
  },
  {
    "path": ".github/actions/setup-version/action.yml",
    "content": "name: Setup Version\ndescription: \"Setup Version\"\ninputs:\n  type:\n    required: true\n    description: \"Type of the app, either 'desktop' or 'mobile'\"\n    default: \"desktop\"\noutputs:\n  APP_VERSION:\n    description: \"App Version\"\n    value: ${{ steps.version.outputs.APP_VERSION }}\nruns:\n  using: \"composite\"\n  steps:\n    - name: \"Write Version\"\n      id: version\n      shell: bash\n      run: |\n        if [ \"${{ github.ref_type }}\" == \"tag\" ]; then\n          # Handle new tag format: desktop/v1.2.3 or mobile/v1.2.3\n          if [[ \"${{ github.ref_name }}\" =~ ^(desktop|mobile)/v(.*)$ ]]; then\n            APP_VERSION=\"${BASH_REMATCH[2]}\"\n          else\n            # Fallback for old format: v1.2.3\n            APP_VERSION=$(echo \"${{ github.ref_name }}\" | sed 's/^v//')\n          fi\n        else\n          if [ \"${{ inputs.type }}\" == \"desktop\" ]; then\n            APP_VERSION=$(node -p \"require('./apps/desktop/package.json').version\")\n          else\n            APP_VERSION=$(node -p \"require('./apps/mobile/package.json').version\")\n          fi\n        fi\n        echo $APP_VERSION\n        echo \"APP_VERSION=$APP_VERSION\" >> \"$GITHUB_OUTPUT\"\n"
  },
  {
    "path": ".github/actions/setup-xcode/action.yml",
    "content": "name: \"Setup Xcode\"\ndescription: \"Setup specific Xcode version for iOS builds\"\ninputs:\n  xcode-version:\n    description: \"Xcode version to use\"\n    required: false\n    default: \"26.0.1\"\n\nruns:\n  using: \"composite\"\n  steps:\n    - uses: maxim-lobanov/setup-xcode@v1\n      with:\n        xcode-version: ${{ inputs.xcode-version }}\n\n    - name: Setup Xcode Environment\n      run: |\n        set -e\n\n        echo \"=== Checking CI Environment ===\"\n        echo \"Current user: $(whoami)\"\n        echo \"User groups: $(groups)\"\n        echo \"Sudo available: $(sudo -n true 2>/dev/null && echo 'YES' || echo 'NO')\"\n        echo \"Xcode path: $(xcode-select -p 2>/dev/null || echo 'Not set')\"\n\n        # Set Xcode path and accept license\n        echo \"=== Setting up Xcode ===\"\n        if command -v sudo >/dev/null && sudo -n true 2>/dev/null; then\n            sudo xcode-select -s /Applications/Xcode_${{ inputs.xcode-version }}.app/Contents/Developer\n            sudo xcodebuild -license accept\n            echo \"Using sudo for Xcode setup\"\n        else\n            xcode-select -s /Applications/Xcode_${{ inputs.xcode-version }}.app/Contents/Developer\n            xcodebuild -license accept\n            echo \"Using direct access for Xcode setup\"\n        fi\n\n        echo \"Final Xcode path: $(xcode-select -p)\"\n      shell: bash\n\n    - name: Start Simulator Service\n      run: |\n        echo \"=== Starting Simulator Service ===\"\n        # Try to start simulator service (ignore errors if already running)\n        sudo launchctl load -w /System/Library/LaunchDaemons/com.apple.CoreSimulator.CoreSimulatorService.plist 2>/dev/null || {\n            echo \"CoreSimulator service might already be running or command unavailable\"\n        }\n\n        # Alternative for newer macOS versions\n        sudo launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.CoreSimulator.CoreSimulatorService.plist 2>/dev/null || {\n            echo \"Bootstrap command failed or service already running\"\n        }\n\n        # Wait a moment for service to start\n        sleep 3\n      shell: bash\n\n    - name: Download iOS Platform\n      run: |\n        set -e\n\n        echo \"=== Checking Available SDKs ===\"\n        echo \"Currently available SDKs:\"\n        xcodebuild -showsdks | grep -E \"(iOS|Simulator)\" || echo \"No iOS SDKs found yet\"\n\n        echo \"=== Attempting iOS Platform Download ===\"\n\n        # Method 1: Try standard download\n        if xcodebuild -downloadPlatform iOS -quiet 2>/dev/null; then\n            echo \"✅ iOS platform downloaded successfully with xcodebuild\"\n        elif xcrun xcodebuild -downloadPlatform iOS -quiet 2>/dev/null; then\n            echo \"✅ iOS platform downloaded successfully with xcrun xcodebuild\"\n        else\n            echo \"⚠️  Platform download failed, checking if iOS SDK is already available...\"\n\n            # Check if iOS SDK is available\n            if xcodebuild -showsdks | grep -q \"iOS\"; then\n                echo \"✅ iOS SDK is already available, continuing...\"\n            else\n                echo \"❌ No iOS SDK found and download failed\"\n                echo \"Available SDKs:\"\n                xcodebuild -showsdks\n\n                # Try alternative download method\n                echo \"Trying alternative download method...\"\n                xcodebuild -downloadAllPlatforms -quiet || {\n                    echo \"❌ All download methods failed\"\n                    exit 1\n                }\n            fi\n        fi\n\n        echo \"=== Final SDK Status ===\"\n        echo \"Available iOS SDKs:\"\n        xcodebuild -showsdks | grep -E \"(iOS|Simulator)\" || echo \"No iOS SDKs found\"\n\n        echo \"Available simulators:\"\n        xcrun simctl list devices available | head -20 || echo \"No simulators found\"\n      shell: bash\n\n    - name: Verify Setup\n      run: |\n        echo \"=== Verification ===\"\n        echo \"Xcode version: $(xcodebuild -version | head -1)\"\n        echo \"Selected Xcode: $(xcode-select -p)\"\n        echo \"iOS SDK available: $(xcodebuild -showsdks | grep -c iOS || echo 0)\"\n\n        # Test basic functionality\n        if xcodebuild -showsdks | grep -q \"iOS\"; then\n            echo \"✅ Setup completed successfully!\"\n        else\n            echo \"⚠️  Setup completed but iOS SDK may not be fully available\"\n        fi\n      shell: bash\n"
  },
  {
    "path": ".github/advanced-issue-labeler.yml",
    "content": "policy:\n  - section:\n      - id: [platform]\n        block-list: [\"None\", \"Other\"]\n        label:\n          - name: \"platform: desktop\"\n            keys: [\"Desktop - macOS\", \"Desktop - Windows\", \"Desktop - Linux\", \"Desktop - Web\"]\n          - name: \"platform: mobile\"\n            keys: [\"Mobile - iOS\", \"Mobile - Android\", \"Mobile - Web\"]\n"
  },
  {
    "path": ".github/copilot-instructions.md",
    "content": "Before you start, you need to read and follow the rules in @../CLAUDE.md\n"
  },
  {
    "path": ".github/dependabot.yaml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: npm\n    directory: /\n    schedule:\n      interval: weekly\n      day: friday\n      time: \"12:00\"\n      timezone: Asia/Singapore\n    target-branch: dev\n    ignore:\n      - dependency-name: \"@shopify/flash-list\"\n        versions: [\">1.7.3\"]\n\n      # Stuck by tailwindcss 4\n      - dependency-name: tailwindcss\n        versions: [\">=4.0.0\"]\n      - dependency-name: daisyui\n        versions: [\">=5.0.0\"]\n\n      # Stuck by expo 52\n      - dependency-name: react-native\n        versions: [\">=0.78.0\"]\n\n      # It's using export map and metro doesn't support it well\n      - dependency-name: unist-util-visit-parents\n        versions: [\">=6.0.0\"]\n      # electron 35\n      - dependency-name: electron\n        versions: [\">=35.0.0\"]\n      - dependency-name: react-native-sheet-transitions\n        versions: [\">0.1.2\"]\n\n      # filter not work\n      - dependency-name: unplugin-ast\n        versions: [\"0.14.5\"]\n\n    open-pull-requests-limit: 100\n    groups:\n      minor-and-patch:\n        applies-to: version-updates\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      pathed:\n        patterns:\n          - immer\n          - re-resizable\n          - electron-context-menu\n          - \"@mozilla/readability\"\n          - daisyui\n          - jsonpointer\n          - workbox-precaching\n          - \"@pengx17/electron-forge-maker-appimage\"\n          - \"@microflash/remark-callout-directives\"\n          - react-native-track-player\n          - react-native-sheet-transitions\n\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: daily\n    target-branch: dev\n    open-pull-requests-limit: 100\n"
  },
  {
    "path": ".github/prompts/similar_issues.prompt.yml",
    "content": "messages:\n  - role: system\n    content: |-\n      You are a GitHub assistant with access to GitHub Model Context Protocol (MCP)\n      tools in read-only mode. Your task is to search this repository's issues to find\n      previously filed issues similar to the provided issue title and body. Use the\n      GitHub tools via MCP to perform the search and retrieve real issue data (do not\n      fabricate results).\n\n      Consider semantic similarity across title and body. Exclude\n      issues with the same ID/number as the current issue. Return up to 3 of the most similar past issues. If none are\n      reasonably similar, return an empty list. Output must follow the response schema\n      exactly and include only data you actually retrieved from GitHub tools.\n      The current GitHub repository is: \"{{repository}}\".\n  - role: user\n    content: |-\n      Find similar issues for this new issue:\n      Title: {{issue_title}}\n      Body:\n      {{issue_body}}\nmodel: openai/gpt-4.1-mini\nresponseFormat: json_schema\njsonSchema: |-\n  {\n    \"name\": \"similar_issues_result\",\n    \"strict\": true,\n    \"schema\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"matches\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"number\": { \"type\": \"integer\" },\n              \"title\": { \"type\": \"string\" },\n              \"url\": { \"type\": \"string\" },\n              \"similarity_score\": { \"type\": \"number\", \"minimum\": 0, \"maximum\": 1 }\n            },\n            \"required\": [\"number\", \"title\", \"url\", \"similarity_score\"],\n            \"additionalProperties\": false\n          }\n        }\n      },\n      \"required\": [\"matches\"],\n      \"additionalProperties\": false\n    }\n  }\n"
  },
  {
    "path": ".github/scripts/extract-release-info.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Extract release version and platform information from git commit messages\n * Used by GitHub Actions to determine if a release tag should be created\n */\n\nimport { execSync } from \"node:child_process\"\nimport { appendFileSync } from \"node:fs\"\n\n// Configuration\nconst RELEASE_PATTERNS = {\n  desktop: /release\\(desktop\\): Release (v\\d+\\.\\d+\\.\\d+(-[0-9A-Z-.]+)?)/i,\n  mobile: /release\\(mobile\\): Release (v\\d+\\.\\d+\\.\\d+(-[0-9A-Z-.]+)?)/i,\n}\n\nconst EXIT_CODES = {\n  SUCCESS: 0,\n  GIT_ERROR: 2,\n  ENV_ERROR: 3,\n  OUTPUT_ERROR: 4,\n}\n\n/**\n * Write environment variable to GitHub Environment\n * @param {string} key - Environment variable key\n * @param {string} value - Environment variable value\n */\nfunction setGitHubEnv(key, value) {\n  try {\n    if (!process.env.GITHUB_ENV) {\n      throw new Error(\"GITHUB_ENV not set - not running in GitHub Actions\")\n    }\n    appendFileSync(process.env.GITHUB_ENV, `${key}=${value}\\n`)\n  } catch (error) {\n    console.error(`Failed to set environment variable ${key}:`, error.message)\n    process.exit(EXIT_CODES.ENV_ERROR)\n  }\n}\n\n/**\n * Write output variable to GitHub Output\n * @param {string} key - Output key\n * @param {string} value - Output value\n */\nfunction setGitHubOutput(key, value) {\n  try {\n    if (!process.env.GITHUB_OUTPUT) {\n      return\n    }\n    appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\\n`)\n  } catch (error) {\n    console.error(`Failed to set output variable ${key}:`, error.message)\n    process.exit(EXIT_CODES.OUTPUT_ERROR)\n  }\n}\n\n/**\n * Get the latest commit message\n * @returns {string} Latest commit message\n */\nfunction getLatestCommitMessage() {\n  try {\n    return execSync(\"git log -1 --pretty=%B\", { encoding: \"utf-8\" }).toString().trim()\n  } catch (error) {\n    console.error(\"Failed to get git commit message:\", error.message)\n    process.exit(EXIT_CODES.GIT_ERROR)\n  }\n}\n\n/**\n * Extract release information from commit message\n * @param {string} commitMessage - Git commit message\n * @returns {Object|null} Release information or null if no release found\n */\nfunction extractReleaseInfo(commitMessage) {\n  for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) {\n    const match = commitMessage.match(regex)\n    if (match) {\n      const version = match[1]\n      const tagName = `${platform}/${version}`\n\n      return {\n        platform,\n        version,\n        tagName,\n      }\n    }\n  }\n\n  return null\n}\n\n/**\n * Main execution function\n */\nfunction main() {\n  try {\n    console.info(\"Extracting release information from commit message...\")\n\n    const commitMessage = getLatestCommitMessage()\n    console.info(`Commit message: ${commitMessage}`)\n\n    const releaseInfo = extractReleaseInfo(commitMessage)\n\n    if (!releaseInfo) {\n      console.info(\"No desktop or mobile release found in commit message.\")\n      process.exit(EXIT_CODES.SUCCESS)\n    }\n\n    const { platform, version, tagName } = releaseInfo\n\n    // Set GitHub Environment variables\n    setGitHubEnv(\"tag_version\", tagName)\n    setGitHubEnv(\"platform\", platform)\n    setGitHubEnv(\"version\", version)\n    setGitHubOutput(\"tag_version\", tagName)\n    setGitHubOutput(\"platform\", platform)\n    setGitHubOutput(\"version\", version)\n\n    console.info(`Found ${platform} release: ${version}`)\n    console.info(`Tag will be created: ${tagName}`)\n\n    process.exit(EXIT_CODES.SUCCESS)\n  } catch (error) {\n    console.error(\"Unexpected error:\", error.message)\n    process.exit(EXIT_CODES.GIT_ERROR)\n  }\n}\n\nmain()\n"
  },
  {
    "path": ".github/workflows/build-android.yml",
    "content": "name: 🤖 Build Android\n\non:\n  push:\n    branches:\n      - \"**\"\n    paths:\n      - \"apps/mobile/**\"\n      - \"pnpm-lock.yaml\"\n  workflow_dispatch:\n    inputs:\n      profile:\n        type: choice\n        default: preview\n        options:\n          - preview\n          - production\n        description: \"Build profile\"\n      release:\n        type: boolean\n        default: false\n        description: \"Create a release draft for the build\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }}\n  cancel-in-progress: true\n\njobs:\n  build:\n    name: Build Android apk for device\n    if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):'))\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: 🧹 Claim disk space\n        run: |\n          df -h /\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf /usr/local/.ghcup\n          sudo rm -rf /opt/hostedtoolcache/CodeQL\n          sudo rm -rf /usr/share/swift\n          sudo rm -rf /usr/local/julia*\n          df -h /\n\n      - name: 📦 Checkout code\n        uses: actions/checkout@v6\n\n      - name: 📦 Setup pnpm\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n          cache: \"pnpm\"\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v5\n        with:\n          java-version: \"17\"\n          distribution: \"zulu\"\n\n      - name: Setup Android SDK\n        uses: android-actions/setup-android@v3\n\n      - name: 📱 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: 🔨 Build Android app\n        working-directory: apps/mobile\n        run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --local --output=${{ github.workspace }}/build.${{ github.event.inputs.profile == 'production' && 'aab' || 'apk' }}\n\n      - name: 📤 Upload apk Artifact\n        if: github.event.inputs.profile != 'production'\n        uses: actions/upload-artifact@v7\n        with:\n          name: app-android\n          path: ${{ github.workspace }}/build.apk\n          retention-days: 90\n\n      - name: 📤 Upload aab Artifact\n        if: github.event.inputs.profile == 'production'\n        uses: actions/upload-artifact@v7\n        with:\n          name: aab-android\n          path: ${{ github.workspace }}/build.aab\n          retention-days: 90\n\n      - name: Submit to Google Play\n        if: github.event.inputs.profile == 'production'\n        working-directory: apps/mobile\n        run: eas submit --platform android --path ${{ github.workspace }}/build.aab --non-interactive\n\n      - name: Setup Version\n        if: github.event.inputs.release == 'true'\n        id: version\n        uses: ./.github/actions/setup-version\n        with:\n          type: \"mobile\"\n\n      - name: Prepare Release Notes\n        if: github.event.inputs.release == 'true'\n        id: release_notes\n        run: |\n          version=\"${{ steps.version.outputs.APP_VERSION }}\"\n          changelog_file=\"apps/mobile/changelog/${version}.md\"\n          release_notes_file=\"$RUNNER_TEMP/mobile-release-notes.md\"\n\n          if [ -f \"$changelog_file\" ]; then\n            cp \"$changelog_file\" \"$release_notes_file\"\n          else\n            {\n              echo \"# What's New in v${version}\"\n              echo\n              echo \"- No changelog file found at ${changelog_file}.\"\n            } > \"$release_notes_file\"\n          fi\n\n          echo \"body_path=${release_notes_file}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Create Release Draft\n        if: github.event.inputs.release == 'true'\n        uses: softprops/action-gh-release@v2\n        with:\n          name: Mobile v${{ steps.version.outputs.APP_VERSION }}\n          draft: true\n          prerelease: true\n          tag_name: mobile/v${{ steps.version.outputs.APP_VERSION }}\n          body_path: ${{ steps.release_notes.outputs.body_path }}\n          # .aab cannot be installed directly on your Android Emulator or device.\n          files: ${{ github.workspace }}/build.apk\n"
  },
  {
    "path": ".github/workflows/build-desktop.yml",
    "content": "name: 🖥️ Build Desktop\n\non:\n  push:\n    branches:\n      - \"**\"\n    paths:\n      - \"apps/desktop/**\"\n      - \"packages/**\"\n      - \"pnpm-lock.yaml\"\n      - \".github/workflows/build-desktop.yml\"\n  workflow_dispatch:\n    inputs:\n      tag_version:\n        type: boolean\n        description: \"Tag Version\"\n      store:\n        type: boolean\n        description: \"Build for Mac App Store and Microsoft Store\"\n      build_version:\n        type: string\n        description: \"Build Version, only available when mas is true\"\n\n# https://docs.github.com/en/enterprise-cloud@latest/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.tag_version == 'true' && 'tag-version' || github.event.inputs.store == 'true' && 'store' || 'manual') || 'build' }}\n  cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}\nenv:\n  VITE_WEB_URL: ${{ vars.VITE_WEB_URL }}\n  VITE_API_URL: ${{ vars.VITE_API_URL }}\n  VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}\n  VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }}\n  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n  NODE_OPTIONS: --max-old-space-size=8192\n\njobs:\n  release:\n    if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(desktop):'))\n    runs-on: ${{ matrix.os }}\n    env:\n      PROD: ${{ github.event.inputs.tag_version == 'true' || github.ref_type == 'tag' || github.event.inputs.store == 'true' }}\n      RELEASE: ${{ github.event.inputs.tag_version == 'true' || github.ref_type == 'tag' }}\n\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [macos-latest, ubuntu-latest, windows-latest]\n        exclude:\n          - os: ${{ github.event.inputs.store == 'true' && 'ubuntu-latest' }}\n\n    permissions:\n      id-token: write\n      contents: write\n      attestations: write\n\n    steps:\n      - name: Check out Git repository Fully\n        uses: actions/checkout@v6\n        if: env.PROD == 'true'\n        with:\n          fetch-depth: 0\n          lfs: true\n      - name: Check out Git repository\n        uses: actions/checkout@v6\n        if: env.PROD == 'false'\n        with:\n          fetch-depth: 1\n          lfs: true\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n\n      - name: Use Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n          cache: \"pnpm\"\n\n      - name: Install Python setuptools\n        if: runner.os == 'macOS'\n        run: brew install python-setuptools\n\n      - name: Install appdmg\n        if: runner.os == 'macOS'\n        run: pnpm add -g appdmg\n\n      - name: Install the Apple certificate and provisioning profile\n        env:\n          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}\n          BUILD_CERTIFICATE_MAS_BASE64: ${{ secrets.BUILD_CERTIFICATE_MAS_BASE64 }}\n          BUILD_CERTIFICATE_MASPKG_BASE64: ${{ secrets.BUILD_CERTIFICATE_MASPKG_BASE64 }}\n          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}\n          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}\n          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n        if: runner.os == 'macOS' && env.BUILD_CERTIFICATE_BASE64 != '' && env.BUILD_CERTIFICATE_MAS_BASE64 != '' && env.BUILD_CERTIFICATE_MASPKG_BASE64 != '' && env.BUILD_PROVISION_PROFILE_BASE64 != ''\n        run: |\n          # create variables\n          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12\n          CERTIFICATE_MAS_PATH=$RUNNER_TEMP/build_certificate_mas.p12\n          CERTIFICATE_MASPKG_PATH=$RUNNER_TEMP/build_certificate_maspkg.p12\n          PP_PATH=$RUNNER_TEMP/build_pp.provisionprofile\n          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db\n\n          # import certificate and provisioning profile from secrets\n          echo -n \"$BUILD_CERTIFICATE_BASE64\" | base64 --decode -o $CERTIFICATE_PATH\n          echo -n \"$BUILD_CERTIFICATE_MAS_BASE64\" | base64 --decode -o $CERTIFICATE_MAS_PATH\n          echo -n \"$BUILD_CERTIFICATE_MASPKG_BASE64\" | base64 --decode -o $CERTIFICATE_MASPKG_PATH\n          echo -n \"$BUILD_PROVISION_PROFILE_BASE64\" | base64 --decode -o $PP_PATH\n\n          # create temporary keychain\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n          # import certificate to keychain\n          security import $CERTIFICATE_PATH -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n          security import $CERTIFICATE_MAS_PATH -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n          security import $CERTIFICATE_MASPKG_PATH -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n          security set-key-partition-list -S apple-tool:,apple: -k \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n          security list-keychain -d user -s $KEYCHAIN_PATH\n          security find-identity $KEYCHAIN_PATH\n\n          # apply provisioning profile\n          mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles\n          cp $PP_PATH ~/Library/MobileDevice/Provisioning\\ Profiles\n\n      - name: Install dependencies\n        run: pnpm i\n\n      - name: Prebuild packages (Windows)\n        if: runner.os == 'windows'\n        run: pnpm run build:packages\n\n      - name: Update main hash\n        working-directory: apps/desktop\n        run: pnpm update:main-hash\n      - name: Build - Vite\n        working-directory: apps/desktop\n        run: pnpm build:electron-vite ${{ env.PROD == 'false' && '--mode staging' || '' }}\n\n      - name: Build - Windows and Linux\n        if: runner.os == 'Linux' || (runner.os == 'Windows' && github.event.inputs.store != 'true')\n        working-directory: apps/desktop\n        run: pnpm build:electron-forge ${{ env.PROD == 'false' && '--mode=staging' || '' }}\n\n      - name: Build - macOS\n        if: runner.os == 'macOS' && github.event.inputs.store != 'true'\n        env:\n          APPLE_ID: ${{ secrets.APPLE_ID }}\n          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}\n          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}\n          OSX_SIGN_KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db\n          OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY }}\n        uses: nick-fields/retry@v3\n        with:\n          max_attempts: 3\n          timeout_minutes: 10\n          command: |\n            cd apps/desktop\n            npx electron-forge make --arch=x64 --platform=darwin ${{ env.PROD == 'false' && '--mode=staging' || '' }}\n            npx electron-forge make --arch=arm64 --platform=darwin ${{ env.PROD == 'false' && '--mode=staging' || '' }}\n            npx tsx scripts/merge-yml.ts\n\n      - name: Build - Mac App Store\n        if: runner.os == 'macOS' && github.event.inputs.store == 'true'\n        env:\n          OSX_SIGN_KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db\n          OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY_MAS }}\n          OSX_SIGN_PROVISIONING_PROFILE_PATH: ${{ runner.temp }}/build_pp.provisionprofile\n          BUILD_VERSION: ${{ github.event.inputs.build_version }}\n        working-directory: apps/desktop\n        run: pnpm build:electron-forge:mas\n\n      - name: Build - Microsoft Store\n        if: runner.os == 'Windows' && github.event.inputs.store == 'true'\n        working-directory: apps/desktop\n        run: pnpm build:electron-forge:ms\n\n      - name: Build - Renderer\n        if: runner.os == 'Linux'\n        working-directory: apps/desktop\n        run: pnpm build:render\n\n      - name: Upload file (macos-arm64-dmg)\n        uses: actions/upload-artifact@v7\n        if: runner.os == 'macOS'\n        with:\n          name: macos-arm64-dmg\n          path: |\n            apps/desktop/out/make/**/*arm64.dmg\n            apps/desktop/out/make/**/latest-mac.yml\n          retention-days: 90\n\n      - name: Upload file (macos-x64-dmg)\n        uses: actions/upload-artifact@v7\n        if: runner.os == 'macOS'\n        with:\n          name: macos-x64-dmg\n          path: |\n            apps/desktop/out/make/**/*x64.dmg\n            apps/desktop/out/make/**/latest-mac.yml\n          retention-days: 90\n\n      - name: Upload file (macos-mas-pkg)\n        uses: actions/upload-artifact@v7\n        if: runner.os == 'macOS'\n        with:\n          name: macos-mas-pkg\n          path: |\n            apps/desktop/out/make/**/*.pkg\n          retention-days: 90\n\n      - name: Upload file (windows-x64-exe unsigned)\n        uses: actions/upload-artifact@v7\n        id: upload-unsigned-windows-x64-exe\n        if: runner.os == 'windows' && github.event.inputs.store != 'true'\n        with:\n          name: windows-x64-exe\n          path: |\n            apps/desktop/out/make/**/*x64.exe\n            apps/desktop/out/make/**/latest.yml\n          retention-days: 90\n\n      - uses: signpath/github-action-submit-signing-request@v2.0\n        continue-on-error: true\n        if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true'\n        with:\n          api-token: \"${{ secrets.SIGNPATH_API_TOKEN }}\"\n          organization-id: \"8c651516-fdaf-40a1-9fea-001dffde850e\"\n          project-slug: \"Folo\"\n          signing-policy-slug: \"release-signing\"\n          artifact-configuration-slug: \"github\"\n          github-artifact-id: \"${{ steps.upload-unsigned-windows-x64-exe.outputs.artifact-id }}\"\n          output-artifact-directory: \"apps/desktop/out/make/\"\n\n      - name: Update latest.yml\n        if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true'\n        run: npx tsx apps/desktop/scripts/update-windows-yml.ts\n\n      - name: Upload file (windows-x64-exe signed)\n        uses: actions/upload-artifact@v7\n        if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true'\n        with:\n          name: windows-x64-exe\n          path: |\n            apps/desktop/out/make/**/*x64.exe\n            apps/desktop/out/make/**/latest.yml\n          retention-days: 90\n          overwrite: true\n\n      - name: Upload file (windows-x64-appx)\n        uses: actions/upload-artifact@v7\n        if: runner.os == 'windows'\n        with:\n          name: windows-x64-appx\n          path: |\n            apps/desktop/out/make/**/*.appx\n          retention-days: 90\n\n      - name: Upload file (linux-x64-appimage)\n        uses: actions/upload-artifact@v7\n        if: runner.os == 'linux'\n        with:\n          name: linux-x64-appimage\n          path: |\n            apps/desktop/out/make/**/*x64.AppImage\n            apps/desktop/out/make/**/latest-linux.yml\n          retention-days: 90\n\n      - name: Generate artifact attestation\n        if: env.RELEASE == 'true'\n        continue-on-error: true\n        uses: actions/attest-build-provenance@v4\n        with:\n          subject-path: |\n            apps/desktop/out/make/**/Folo-*.dmg\n            apps/desktop/out/make/**/Folo-*.zip\n            apps/desktop/out/make/**/Folo-*.exe\n            apps/desktop/out/make/**/Folo-*.AppImage\n            apps/desktop/out/make/**/*.yml\n            apps/desktop/dist/manifest.yml\n            apps/desktop/dist/*.tar.gz\n\n      - run: npx changelogithub\n        if: env.RELEASE == 'true'\n        continue-on-error: true\n        env:\n          GITHUB_TOKEN: ${{ github.token }}\n\n      - name: Setup Version\n        if: env.RELEASE == 'true'\n        id: version\n        uses: ./.github/actions/setup-version\n        with:\n          type: \"desktop\"\n\n      - name: Prepare Release Notes\n        if: env.RELEASE == 'true'\n        id: release_notes\n        shell: bash\n        run: |\n          version=\"${{ steps.version.outputs.APP_VERSION }}\"\n          changelog_file=\"apps/desktop/changelog/${version}.md\"\n          release_notes_file=\"$RUNNER_TEMP/desktop-release-notes.md\"\n\n          if [ -f \"$changelog_file\" ]; then\n            cp \"$changelog_file\" \"$release_notes_file\"\n          else\n            {\n              echo \"# What's New in v${version}\"\n              echo\n              echo \"- No changelog file found at ${changelog_file}.\"\n            } > \"$release_notes_file\"\n          fi\n\n          echo \"body_path=${release_notes_file}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Create Release Draft\n        if: env.RELEASE == 'true'\n        uses: softprops/action-gh-release@v2\n        with:\n          name: Desktop v${{ steps.version.outputs.APP_VERSION }}\n          draft: false\n          prerelease: true\n          tag_name: desktop/v${{ steps.version.outputs.APP_VERSION }}\n          body_path: ${{ steps.release_notes.outputs.body_path }}\n          files: |\n            apps/desktop/out/make/**/Folo-*.dmg\n            apps/desktop/out/make/**/Folo-*.zip\n            apps/desktop/out/make/**/Folo-*.exe\n            apps/desktop/out/make/**/Folo-*.AppImage\n            apps/desktop/out/make/**/*.yml\n            apps/desktop/dist/manifest.yml\n            apps/desktop/dist/*.tar.gz\n"
  },
  {
    "path": ".github/workflows/build-ios-development.yml",
    "content": "name: 📱 Build iOS for development\n\non:\n  push:\n    branches:\n      - \"**\"\n    paths:\n      - \"apps/mobile/web-app/**\"\n      - \"apps/mobile/native/**\"\n      - \"apps/mobile/package.json\"\n      - \"apps/mobile/app.config.ts\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  check-runner:\n    runs-on: ubuntu-latest\n    outputs:\n      runner-label: ${{ steps.set-runner.outputs.runner-label }}\n\n    steps:\n      - name: Set runner\n        id: set-runner\n        run: |\n          runners=$(curl -s -H \"Accept: application/vnd.github+json\" -H \"Authorization: Bearer ${{ secrets.RUNNER_GITHUB_TOKEN }}\" \"https://api.github.com/repos/${{ github.repository }}/actions/runners\")\n          available=$(echo \"$runners\" | jq '.runners[]? | select(.status == \"online\" and .busy == false and .labels[] .name == \"macOS\")')\n          if [ -n \"$available\" ]; then\n            echo \"runner-label=self-hosted\" >> $GITHUB_OUTPUT\n          else\n            echo \"runner-label=macos-latest\" >> $GITHUB_OUTPUT\n          fi\n\n  build-ipa-device-self-hosted:\n    name: Build iOS IPA for device (self-hosted)\n    if: github.secret_source != 'None' && needs.check-runner.outputs.runner-label == 'self-hosted'\n    needs: check-runner\n    runs-on: [self-hosted, macOS]\n\n    steps:\n      - name: 📦 Checkout code\n        uses: actions/checkout@v6\n\n      - name: 📱 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: 🔨 Build iOS IPA\n        working-directory: apps/mobile\n        run: eas build --platform ios --profile development --non-interactive --local --output=./build.ipa\n        env:\n          SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}\n          CI: true\n\n      # Optional: Upload artifact\n      - name: 📤 Upload IPA\n        uses: actions/upload-artifact@v7\n        with:\n          name: app-ios-development-device\n          path: apps/mobile/build.ipa\n          retention-days: 90\n\n      - name: Clear Xcode cache\n        run: |\n          rm -rf ~/Library/Developer/Xcode/DerivedData\n\n  build-ipa-device-github:\n    name: Build iOS IPA for device (GitHub-hosted)\n    if: github.secret_source != 'None' && needs.check-runner.outputs.runner-label == 'macos-latest'\n    needs: check-runner\n    runs-on: macos-latest\n\n    steps:\n      - name: 📦 Checkout code\n        uses: actions/checkout@v6\n\n      - name: 🔧 Setup Xcode\n        uses: ./.github/actions/setup-xcode\n\n      - name: 📦 Setup pnpm\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n          cache: \"pnpm\"\n\n      - name: 📱 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: 🔨 Build iOS IPA\n        working-directory: apps/mobile\n        run: eas build --platform ios --profile development --non-interactive --local --output=./build.ipa\n        env:\n          CI: true\n          SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}\n      # Optional: Upload artifact\n      - name: 📤 Upload IPA\n        uses: actions/upload-artifact@v7\n        with:\n          name: app-ios-development-device\n          path: apps/mobile/build.ipa\n          retention-days: 90\n\n  build-simulator:\n    name: Build iOS IPA for simulator\n    if: github.secret_source != 'None'\n    runs-on: macos-latest\n\n    steps:\n      - name: 📦 Checkout code\n        uses: actions/checkout@v6\n\n      - name: 🔧 Setup Xcode\n        uses: ./.github/actions/setup-xcode\n\n      - name: 📦 Setup pnpm\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n          cache: \"pnpm\"\n\n      - name: 📱 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: 🔨 Build iOS IPA\n        working-directory: apps/mobile\n        run: eas build --platform ios --profile ios-simulator --non-interactive --local --output=./build-simulator.ipa\n        env:\n          CI: true\n          SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}\n      # Optional: Upload artifact\n      - name: 📤 Upload IPA\n        uses: actions/upload-artifact@v7\n        with:\n          name: app-ios-development-simulator\n          path: apps/mobile/build-simulator.ipa\n          retention-days: 90\n"
  },
  {
    "path": ".github/workflows/build-ios.yml",
    "content": "name: 🍎 Build iOS\n\non:\n  push:\n    branches:\n      - \"**\"\n    paths:\n      - \"apps/mobile/**\"\n      - \"pnpm-lock.yaml\"\n  workflow_dispatch:\n    inputs:\n      profile:\n        type: choice\n        default: preview\n        options:\n          - preview\n          - production\n        description: \"Build profile\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }}\n  cancel-in-progress: true\n\njobs:\n  check-runner:\n    if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):'))\n    runs-on: ubuntu-latest\n    outputs:\n      runner-label: ${{ steps.set-runner.outputs.runner-label }}\n\n    steps:\n      - name: Set runner\n        id: set-runner\n        run: |\n          runners=$(curl -s -H \"Accept: application/vnd.github+json\" -H \"Authorization: Bearer ${{ secrets.RUNNER_GITHUB_TOKEN }}\" \"https://api.github.com/repos/${{ github.repository }}/actions/runners\")\n          available=$(echo \"$runners\" | jq '.runners[]? | select(.status == \"online\" and .busy == false and .labels[] .name == \"macOS\")')\n          if [ -n \"$available\" ]; then\n            echo \"runner-label=self-hosted\" >> $GITHUB_OUTPUT\n          else\n            echo \"runner-label=macos-latest\" >> $GITHUB_OUTPUT\n          fi\n\n  build-ipa-self-hosted:\n    name: Build iOS IPA (self-hosted)\n    if: needs.check-runner.outputs.runner-label == 'self-hosted'\n    needs: check-runner\n    runs-on: [self-hosted, macOS]\n\n    steps:\n      - name: 📦 Checkout code\n        uses: actions/checkout@v6\n\n      - name: 📱 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: 🔨 Build iOS IPA\n        working-directory: apps/mobile\n        run: eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive --local --output=./build.ipa\n        env:\n          SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}\n          CI: true\n\n      # Optional: Upload artifact\n      - name: 📤 Upload IPA\n        uses: actions/upload-artifact@v7\n        with:\n          name: app-ios\n          path: apps/mobile/build.ipa\n          retention-days: 90\n\n      - name: Clear Xcode cache\n        run: |\n          rm -rf ~/Library/Developer/Xcode/DerivedData\n\n      - name: Submit to App Store\n        if: github.event.inputs.profile == 'production'\n        working-directory: apps/mobile\n        run: eas submit --platform ios --path build.ipa --non-interactive\n\n  build-ipa-github:\n    name: Build iOS IPA (GitHub)\n    if: needs.check-runner.outputs.runner-label == 'macos-latest'\n    needs: check-runner\n    runs-on: macos-latest\n\n    steps:\n      - name: 📦 Checkout code\n        uses: actions/checkout@v6\n\n      - name: 🔧 Setup Xcode\n        uses: ./.github/actions/setup-xcode\n\n      - name: 📦 Setup pnpm\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n          cache: \"pnpm\"\n\n      - name: 📱 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: 🔨 Build iOS IPA\n        working-directory: apps/mobile\n        run: eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive --local --output=./build.ipa\n        env:\n          CI: true\n          SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}\n      # Optional: Upload artifact\n      - name: 📤 Upload IPA\n        uses: actions/upload-artifact@v7\n        with:\n          name: app-ios\n          path: apps/mobile/build.ipa\n          retention-days: 90\n\n      - name: Submit to App Store\n        if: github.event.inputs.profile == 'production'\n        working-directory: apps/mobile\n        run: eas submit --platform ios --path build.ipa --non-interactive\n"
  },
  {
    "path": ".github/workflows/build-web.yml",
    "content": "on:\n  pull_request:\n  push:\n    branches: [main, dev]\n\nname: 🌐 CI Build web and SSR server\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}-ssr\n  cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}\njobs:\n  build:\n    name: Build web and SSR server\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [lts/*]\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          lfs: true\n      - name: Cache turbo build setup\n        uses: actions/cache@v5\n        with:\n          path: .turbo\n          key: ${{ runner.os }}-turbo-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-turbo-\n\n      - name: Checkout LFS objects\n        run: git lfs checkout\n\n      - uses: pnpm/action-setup@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: \"pnpm\"\n      - name: Install dependencies\n        run: pnpm install\n      - name: Build web and SSR server\n        run: |\n          npm exec turbo run Folo#build:web @follow/ssr#build\n"
  },
  {
    "path": ".github/workflows/deploy-cloudflare-desktop.yml",
    "content": "name: \"\\u2601\\ufe0f Deploy Desktop to Cloudflare\"\n\non:\n  push:\n    branches: [main, dev]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  deploy:\n    name: Build & Deploy Desktop Web\n    runs-on: ubuntu-latest\n    env:\n      VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}\n      VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          lfs: true\n\n      - name: Checkout LFS objects\n        run: git lfs checkout\n\n      - name: Cache turbo build setup\n        uses: actions/cache@v5\n        with:\n          path: .turbo\n          key: ${{ runner.os }}-turbo-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-turbo-\n\n      - uses: pnpm/action-setup@v4\n\n      - name: Use Node.js LTS\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: \"pnpm\"\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Build desktop web (SPA)\n        working-directory: apps/desktop\n        env:\n          VITE_WEB_URL: ${{ github.ref == 'refs/heads/dev' && 'https://dev.folo.is' || 'https://app.folo.is' }}\n          VITE_API_URL: ${{ github.ref == 'refs/heads/dev' && 'https://api.dev.folo.is' || 'https://api.folo.is' }}\n        run: pnpm run build:web\n\n      - name: Deploy desktop web to Cloudflare (dev)\n        if: github.ref == 'refs/heads/dev'\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          workingDirectory: apps/desktop\n          command: deploy --env dev\n\n      - name: Deploy desktop web to Cloudflare (prod)\n        if: github.ref == 'refs/heads/main'\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          workingDirectory: apps/desktop\n          command: 'deploy --env=\"\"'\n"
  },
  {
    "path": ".github/workflows/deploy-cloudflare-landing.yml",
    "content": "name: \"\\u2601\\ufe0f Deploy Landing to Cloudflare\"\n\non:\n  push:\n    branches: [main, dev]\n  workflow_dispatch:\n    inputs:\n      confirm_production_deploy:\n        description: Deploy the selected branch to production (folo.is)\n        required: true\n        default: false\n        type: boolean\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  deploy:\n    name: Build & Deploy Landing Worker\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          lfs: true\n\n      - name: Checkout LFS objects\n        run: git lfs checkout\n\n      - name: Cache turbo build setup\n        uses: actions/cache@v5\n        with:\n          path: .turbo\n          key: ${{ runner.os }}-turbo-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-turbo-\n\n      - uses: pnpm/action-setup@v4\n\n      - name: Use Node.js LTS\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: \"pnpm\"\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Resolve deployment target\n        id: target\n        run: |\n          if [[ \"${{ github.event_name }}\" == \"workflow_dispatch\" ]]; then\n            if [[ \"${{ inputs.confirm_production_deploy }}\" != \"true\" ]]; then\n              echo \"Manual production deploy was not confirmed.\" >&2\n              exit 1\n            fi\n\n            echo \"environment=prod\" >> \"$GITHUB_OUTPUT\"\n            exit 0\n          fi\n\n          if [[ \"${{ github.ref }}\" == \"refs/heads/dev\" ]]; then\n            echo \"environment=dev\" >> \"$GITHUB_OUTPUT\"\n            exit 0\n          fi\n\n          echo \"environment=prod\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Build Landing Worker\n        run: pnpm exec turbo run @follow/landing#cf:build\n\n      - name: Deploy Landing to Cloudflare (dev)\n        if: steps.target.outputs.environment == 'dev'\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          workingDirectory: apps/landing\n          command: deploy --env dev --name landing-next-dev --routes landing.dev.folo.is/*\n\n      - name: Deploy Landing to Cloudflare (prod)\n        if: steps.target.outputs.environment == 'prod'\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          workingDirectory: apps/landing\n          command: deploy\n"
  },
  {
    "path": ".github/workflows/deploy-cloudflare-ssr.yml",
    "content": "name: \"\\u2601\\ufe0f Deploy SSR to Cloudflare\"\n\non:\n  push:\n    branches: [main, dev]\n  workflow_dispatch:\n    inputs:\n      confirm_production_deploy:\n        description: Deploy the selected branch to production (app.folo.is)\n        required: true\n        default: false\n        type: boolean\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  deploy:\n    name: Build & Deploy SSR Worker\n    runs-on: ubuntu-latest\n    env:\n      VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}\n      VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          lfs: true\n\n      - name: Checkout LFS objects\n        run: git lfs checkout\n\n      - name: Cache turbo build setup\n        uses: actions/cache@v5\n        with:\n          path: .turbo\n          key: ${{ runner.os }}-turbo-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-turbo-\n\n      - uses: pnpm/action-setup@v4\n\n      - name: Use Node.js LTS\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: \"pnpm\"\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Resolve deployment target\n        id: target\n        run: |\n          if [[ \"${{ github.event_name }}\" == \"workflow_dispatch\" ]]; then\n            if [[ \"${{ inputs.confirm_production_deploy }}\" != \"true\" ]]; then\n              echo \"Manual production deploy was not confirmed.\" >&2\n              exit 1\n            fi\n\n            echo \"environment=prod\" >> \"$GITHUB_OUTPUT\"\n            echo \"vite_web_url=https://app.folo.is\" >> \"$GITHUB_OUTPUT\"\n            echo \"vite_api_url=https://api.folo.is\" >> \"$GITHUB_OUTPUT\"\n            exit 0\n          fi\n\n          if [[ \"${{ github.ref }}\" == \"refs/heads/dev\" ]]; then\n            echo \"environment=dev\" >> \"$GITHUB_OUTPUT\"\n            echo \"vite_web_url=https://dev.folo.is\" >> \"$GITHUB_OUTPUT\"\n            echo \"vite_api_url=https://api.dev.folo.is\" >> \"$GITHUB_OUTPUT\"\n            exit 0\n          fi\n\n          echo \"environment=prod\" >> \"$GITHUB_OUTPUT\"\n          echo \"vite_web_url=https://app.folo.is\" >> \"$GITHUB_OUTPUT\"\n          echo \"vite_api_url=https://api.folo.is\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Build desktop web (SSR assets)\n        working-directory: apps/desktop\n        env:\n          VITE_WEB_URL: ${{ steps.target.outputs.vite_web_url }}\n          VITE_API_URL: ${{ steps.target.outputs.vite_api_url }}\n        run: pnpm run build:web\n\n      - name: Build SSR Worker\n        working-directory: apps/ssr\n        run: pnpm run build:worker\n\n      - name: Copy WASM file\n        run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm\n\n      - name: Deploy SSR Worker to Cloudflare (dev)\n        if: steps.target.outputs.environment == 'dev'\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          workingDirectory: apps/ssr\n          command: deploy --env dev\n\n      - name: Deploy SSR Worker to Cloudflare (prod)\n        if: steps.target.outputs.environment == 'prod'\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          workingDirectory: apps/ssr\n          command: 'deploy --env=\"\"'\n"
  },
  {
    "path": ".github/workflows/issue-labeler.yml",
    "content": "name: 🏷️ Issue labeler\non:\n  issues:\n    types: [opened]\n\npermissions:\n  contents: read\n\njobs:\n  label-component:\n    runs-on: ubuntu-latest\n\n    permissions:\n      # required for all workflows\n      issues: write\n\n      # only required for workflows in private repositories\n      actions: read\n      contents: read\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Parse issue form\n        uses: stefanbuck/github-issue-parser@v3\n        id: issue-parser\n        with:\n          template-path: .github/ISSUE_TEMPLATE/bug_report.yml\n\n      - name: Set labels based on platform field\n        uses: redhat-plumbers-in-action/advanced-issue-labeler@v3\n        with:\n          issue-form: ${{ steps.issue-parser.outputs.jsonString }}\n          token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "on:\n  pull_request:\n  push:\n    branches: [main, dev]\n\n# Do not use secrets or variables to allow for public access\nenv:\n  VITE_WEB_URL: https://app.folo.is\n  VITE_API_URL: https://api.follow.is\n\nname: ✨ CI Format, Typecheck and Lint\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}-lint\n  cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}\njobs:\n  build:\n    name: Format, Lint and Typecheck\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [lts/*]\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          lfs: true\n      - name: Cache turbo build setup\n        uses: actions/cache@v5\n        with:\n          path: .turbo\n          key: ${{ runner.os }}-turbo-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-turbo-\n\n      - name: Checkout LFS objects\n        run: git lfs checkout\n\n      - uses: pnpm/action-setup@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: \"pnpm\"\n      - name: Install dependencies\n        run: pnpm install\n      - name: Build web and SSR server\n        run: |\n          npm exec turbo run Folo#build:web @follow/ssr#build\n      - name: Format, Lint and Typecheck\n        run: |\n          export NODE_OPTIONS=\"--max_old_space_size=16384\"\n          npm exec turbo run format:check typecheck lint\n      - name: Run test\n        run: npm exec turbo run test\n"
  },
  {
    "path": ".github/workflows/pr-title-check.yml",
    "content": "name: ✅ PR Conventional Commit Validation\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened, edited]\n\njobs:\n  validate-pr-title:\n    runs-on: ubuntu-latest\n    steps:\n      - name: PR Conventional Commit Validation\n        uses: ytanikin/PRConventionalCommits@1.3.0\n        with:\n          task_types: '[\"feat\",\"fix\",\"docs\",\"test\",\"ci\",\"refactor\",\"perf\",\"chore\",\"revert\",\"release\",\"build\"]'\n          add_label: false\n"
  },
  {
    "path": ".github/workflows/similar-issues.yml",
    "content": "name: Similar Issues via AI MCP\n\non:\n  issues:\n    types: [opened]\n\njobs:\n  find-similar:\n    permissions:\n      contents: read\n      issues: write\n      models: read\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v6\n\n      - name: Prepare prompt variables\n        id: prepare_input\n        uses: actions/github-script@v8\n        with:\n          script: |\n            const issue = context.payload.issue || {};\n            const title = issue.title || '';\n            const body = issue.body || '';\n            const indent = '      ';\n            // Indent subsequent lines so YAML block scalar indentation remains valid\n            const bodyIndented = body.replace(/\\n/g, '\\n' + indent);\n            core.setOutput('issue_title_json', JSON.stringify(title));\n            core.setOutput('issue_body_indented_json', JSON.stringify(bodyIndented));\n\n      - name: Find similar issues with AI (MCP)\n        id: inference\n        uses: actions/ai-inference@v2\n        with:\n          prompt-file: ./.github/prompts/similar_issues.prompt.yml\n          input: |\n            issue_title: ${{ steps.prepare_input.outputs.issue_title_json }}\n            issue_body: ${{ steps.prepare_input.outputs.issue_body_indented_json }}\n            repository: ${{ github.repository }}\n          enable-github-mcp: true\n          token: ${{ secrets.GITHUB_TOKEN }}\n          github-mcp-token: ${{ secrets.USER_PAT }}\n          endpoint: https://models.github.ai/orgs/RSSNext/inference\n\n      - name: Prepare comment body\n        id: prepare\n        uses: actions/github-script@v8\n        env:\n          AI_RESPONSE: ${{ steps.inference.outputs.response }}\n        with:\n          script: |\n            let data;\n            try {\n              data = JSON.parse(process.env.AI_RESPONSE || '{}');\n            } catch (e) {\n              core.setOutput('has_matches', 'false');\n              return;\n            }\n            const matches = Array.isArray(data.matches) ? data.matches : [];\n            const rawIssueNumber = context?.payload?.issue?.number;\n            const issueNumber = typeof rawIssueNumber === 'string' ? Number(rawIssueNumber) : rawIssueNumber;\n            const filteredMatches = Number.isFinite(issueNumber)\n              ? matches.filter((m) => {\n                  const matchNumber = typeof m?.number === 'string' ? Number(m.number) : m?.number;\n                  if (Number.isFinite(matchNumber)) {\n                    return matchNumber !== issueNumber;\n                  }\n                  if (typeof m?.url === 'string') {\n                    const urlMatch = m.url.match(/\\/issues\\/(\\d+)(?:$|[?#])/);\n                    if (urlMatch) {\n                      return Number(urlMatch[1]) !== issueNumber;\n                    }\n                  }\n                  return true;\n                })\n              : matches;\n            if (!filteredMatches.length) {\n              core.setOutput('has_matches', 'false');\n              return;\n            }\n            const lines = [];\n            lines.push('I found similar issues that might help:');\n            for (const m of filteredMatches.slice(0, 3)) {\n              const num = m.number != null ? `#${m.number}` : '';\n              const title = m.title || 'Untitled';\n              const url = m.url || '';\n              const score = typeof m.similarity_score === 'number' ? ` (similarity: ${m.similarity_score.toFixed(2)})` : '';\n              lines.push(`- ${url}${score}`.trim());\n            }\n            core.setOutput('has_matches', 'true');\n            core.setOutput('comment_body', lines.join('\\n'));\n\n      - name: Comment similar issues\n        if: steps.prepare.outputs.has_matches == 'true'\n        uses: actions/github-script@v8\n        with:\n          script: |\n            const body = ${{ toJson(steps.prepare.outputs.comment_body) }};\n            await github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.payload.issue.number,\n              body\n            });\n"
  },
  {
    "path": ".github/workflows/sync.yaml",
    "content": "name: 🔄 Sync Release Branches To Dev\n\non:\n  push:\n    branches:\n      - main\n      - mobile-main\n\npermissions:\n  contents: write\n\njobs:\n  sync-to-dev:\n    runs-on: ubuntu-latest\n    if: |\n      (github.ref == 'refs/heads/main' && contains(github.event.head_commit.message || '', 'release(desktop):')) ||\n      (github.ref == 'refs/heads/mobile-main' && contains(github.event.head_commit.message || '', 'release(mobile):'))\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Set up Git\n        run: |\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n\n      - name: Merge source branch into dev\n        run: |\n          source_branch=\"${GITHUB_REF_NAME}\"\n          git fetch origin main mobile-main dev\n          git checkout dev\n          git merge --no-ff \"origin/${source_branch}\" -m \"chore(sync): merge ${source_branch} into dev\"\n          git push origin dev\n"
  },
  {
    "path": ".github/workflows/tag.yml",
    "content": "name: 🏷️ Release Orchestrator\n\non:\n  push:\n    branches:\n      - main\n      - mobile-main\n\npermissions:\n  contents: write\n  actions: write\n\njobs:\n  create_tag:\n    name: Create Release Tag\n    runs-on: ubuntu-latest\n    outputs:\n      tag_version: ${{ steps.release_info.outputs.tag_version }}\n      platform: ${{ steps.release_info.outputs.platform }}\n      version: ${{ steps.release_info.outputs.version }}\n      ref_name: ${{ steps.release_info.outputs.ref_name }}\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n\n      - name: Make script executable\n        run: |\n          chmod +x .github/scripts/extract-release-info.mjs\n\n      - name: Extract release information\n        id: extract_info\n        run: .github/scripts/extract-release-info.mjs\n        continue-on-error: true\n\n      - name: Expose release outputs\n        id: release_info\n        run: |\n          echo \"tag_version=${tag_version:-}\" >> \"$GITHUB_OUTPUT\"\n          echo \"platform=${platform:-}\" >> \"$GITHUB_OUTPUT\"\n          echo \"version=${version:-}\" >> \"$GITHUB_OUTPUT\"\n          echo \"ref_name=${GITHUB_REF_NAME}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Validate git configuration\n        if: steps.release_info.outputs.tag_version != ''\n        run: |\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n\n      - name: Create and push tag\n        if: steps.release_info.outputs.tag_version != ''\n        run: |\n          git fetch --tags --force\n          echo \"Creating tag: ${{ steps.release_info.outputs.tag_version }}\"\n          if git rev-parse \"${{ steps.release_info.outputs.tag_version }}\" >/dev/null 2>&1; then\n            echo \"Tag ${{ steps.release_info.outputs.tag_version }} already exists, skipping creation\"\n            exit 0\n          fi\n\n          git tag \"${{ steps.release_info.outputs.tag_version }}\"\n          git push origin \"${{ steps.release_info.outputs.tag_version }}\"\n          echo \"Successfully created and pushed tag: ${{ steps.release_info.outputs.tag_version }}\"\n\n  trigger_builds:\n    name: Trigger Platform Builds\n    needs: create_tag\n    if: needs.create_tag.outputs.tag_version != ''\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Configure Git\n        if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'\n        run: |\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n\n      - name: Resolve desktop build version\n        id: desktop_build_version\n        if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'\n        run: |\n          git fetch --tags --force\n          latest_tag=\"$(git tag -l 'desktop-build/v*' --sort=-v:refname | head -n 1)\"\n          if [ -z \"$latest_tag\" ]; then\n            last_build=110\n          else\n            last_build=\"${latest_tag#desktop-build/v}\"\n          fi\n\n          next_build=$((last_build + 1))\n          build_tag=\"desktop-build/v${next_build}\"\n\n          if git rev-parse \"$build_tag\" >/dev/null 2>&1; then\n            echo \"Build tag $build_tag already exists.\"\n            exit 1\n          fi\n\n          git tag \"$build_tag\"\n          git push origin \"$build_tag\"\n          echo \"build_version=${next_build}\" >> \"$GITHUB_OUTPUT\"\n          echo \"Desktop build version: ${next_build}\"\n\n      - name: Trigger Desktop Tag Version Build\n        if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'\n        uses: actions/github-script@v8\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            const response = await github.rest.actions.createWorkflowDispatch({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              workflow_id: 'build-desktop.yml',\n              ref: 'main',\n              inputs: {\n                tag_version: 'true',\n                store: 'false'\n              }\n            });\n            console.log('Desktop Tag Version build triggered successfully');\n\n      - name: Trigger Desktop Store Build\n        if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'\n        uses: actions/github-script@v8\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            const response = await github.rest.actions.createWorkflowDispatch({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              workflow_id: 'build-desktop.yml',\n              ref: 'main',\n              inputs: {\n                tag_version: 'false',\n                store: 'true',\n                build_version: '${{ steps.desktop_build_version.outputs.build_version }}'\n              }\n            });\n            console.log('Desktop store build triggered successfully');\n\n      - name: Trigger Mobile Preview Release Build\n        if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main'\n        uses: actions/github-script@v8\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            const response = await github.rest.actions.createWorkflowDispatch({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              workflow_id: 'build-android.yml',\n              ref: 'mobile-main',\n              inputs: {\n                profile: 'preview',\n                release: 'true'\n              }\n            });\n            console.log('Mobile preview release build triggered successfully');\n\n      - name: Trigger Mobile Production Android Build\n        if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main'\n        uses: actions/github-script@v8\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            const response = await github.rest.actions.createWorkflowDispatch({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              workflow_id: 'build-android.yml',\n              ref: 'mobile-main',\n              inputs: {\n                profile: 'production',\n                release: 'false'\n              }\n            });\n            console.log('Mobile production Android build triggered successfully');\n\n      - name: Trigger Mobile Production iOS Build\n        if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main'\n        uses: actions/github-script@v8\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            const response = await github.rest.actions.createWorkflowDispatch({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              workflow_id: 'build-ios.yml',\n              ref: 'mobile-main',\n              inputs: {\n                profile: 'production'\n              }\n            });\n            console.log('Mobile production iOS build triggered successfully');\n"
  },
  {
    "path": ".github/workflows/translator.yml",
    "content": "name: \"🌍 translator\"\non:\n  issues:\n    types: [opened, edited]\n  issue_comment:\n    types: [created, edited]\n  discussion:\n    types: [created, edited]\n  discussion_comment:\n    types: [created, edited]\n\njobs:\n  translate:\n    permissions:\n      issues: write\n      discussions: write\n      pull-requests: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          IS_MODIFY_TITLE: true\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\ndist\nout\n.next\n.open-next\nnext-env.d.ts\n.DS_Store\n*.log*\n.env\n.eslintcache\n.env.*\n!.env.example\n\n# Sentry Config File\n.env.sentry-build-plugin\n.vercel\nstats.html\n\nelectron.vite.config.*.mjs\nvite.config.*.mjs\n\n.generated\n.turbo\n\napps/desktop/src/renderer/dev-dist\ntsconfig.tsbuildinfo\nbuildServer.json\n\n**/**/generated-routes.ts\n\napps/desktop/build/appxmanifest.xml\napps/desktop/resources/cli\n\n.claude/settings.local.json\n.serena\n\n.wrangler\n\n# Local agent artifacts\n.codex/\n\n# E2E outputs\n/apps/desktop/e2e/playwright-report/\n/apps/desktop/e2e/test-results/\n/apps/mobile/e2e/artifacts/\n/apps/mobile/report.xml\n/report.xml\n\n# Mobile local E2E build artifacts\napps/mobile/build-*.tar.gz\n"
  },
  {
    "path": ".npmrc",
    "content": "shamefully-hoist=true\nnode-linker=hoisted\nsave-exact=true\n"
  },
  {
    "path": ".nvmrc",
    "content": "stable\n"
  },
  {
    "path": ".prettierignore",
    "content": "pnpm-lock.yaml\n\nCHANGELOG.md\n.context\n\napps/external/postcss.config.cjs\n\napps/mobile/android\napps/mobile/ios\napps/mobile/native/example\n\napps/mobile/native/android\napps/mobile/native/ios\napps/mobile/.expo\n\ngenerated-routes.ts\n"
  },
  {
    "path": ".prettierrc.mjs",
    "content": "/** @type {import(\"prettier\").Config & import(\"prettier-plugin-tailwindcss\").PluginOptions} */\nexport default {\n  semi: false,\n  singleQuote: false,\n  printWidth: 100,\n  tabWidth: 2,\n  trailingComma: \"all\",\n  objectWrap: \"preserve\",\n  plugins: [\"prettier-plugin-tailwindcss\"],\n  tailwindConfig: \"./apps/desktop/tailwind.config.ts\",\n  overrides: [\n    {\n      files: \"apps/mobile/**/*.{css,js,jsx,ts,tsx}\",\n      options: {\n        tailwindConfig: \"./apps/mobile/tailwind.config.ts\",\n      },\n    },\n    {\n      files: \"apps/mobile/web-app/html-renderer/**/*.{css,js,jsx,ts,tsx}\",\n      options: {\n        tailwindConfig: \"./apps/mobile/web-app/html-renderer/tailwind.config.ts\",\n      },\n    },\n    {\n      files: \"apps/ssr/**/*.{css,html,js,jsx,ts,tsx}\",\n      options: {\n        tailwindConfig: \"./apps/ssr/tailwind.config.ts\",\n      },\n    },\n  ],\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"dbaeumer.vscode-eslint\",\n    \"johnsoncodehk.vscode-tsslint\",\n    \"esbenp.prettier-vscode\",\n    \"bradlc.vscode-tailwindcss\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug Main Process\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceRoot}\",\n      \"runtimeExecutable\": \"${workspaceRoot}/node_modules/.bin/electron-vite\",\n      \"windows\": {\n        \"runtimeExecutable\": \"${workspaceRoot}/node_modules/.bin/electron-vite.cmd\"\n      },\n      \"runtimeArgs\": [\"--sourcemap\"],\n      \"env\": {\n        \"REMOTE_DEBUGGING_PORT\": \"9222\"\n      }\n    },\n    {\n      \"name\": \"Debug Renderer Process\",\n      \"port\": 9222,\n      \"request\": \"attach\",\n      \"type\": \"chrome\",\n      \"webRoot\": \"${workspaceFolder}/src/renderer\",\n      \"timeout\": 60000,\n      \"presentation\": {\n        \"hidden\": true\n      }\n    }\n  ],\n  \"compounds\": [\n    {\n      \"name\": \"Debug All\",\n      \"configurations\": [\"Debug Main Process\", \"Debug Renderer Process\"],\n      \"presentation\": {\n        \"order\": 1\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]\": {\n    \"editor.codeActionsOnSave\": {\n      \"source.fixAll.eslint\": \"explicit\"\n    }\n  },\n  \"files.associations\": {\n    \"*.css\": \"tailwindcss\"\n  },\n  \"tailwindCSS.experimental.classRegex\": [\n    [\"cva\\\\(([^)]*)\\\\)\", \"[\\\"'`]([^\\\"'`]*).*?[\\\"'`]\"],\n    [\"cx\\\\(([^)]*)\\\\)\", \"(?:'|\\\"|`)([^']*)(?:'|\\\"|`)\"],\n    [\"tw`([^`]*)`\", \"([^`]*)\"],\n    [\"[a-zA-Z]+[cC]lass[nN]ame[\\\"'`]?:\\\\s*[\\\"'`]([^\\\"'`]*)[\\\"'`]\", \"([^\\\"'`]*)\"],\n    [\"[a-zA-Z]+[cC]lass[nN]ame\\\\s*=\\\\s*[\\\"'`]([^\\\"'`]*)[\\\"'`]\", \"([^\\\"'`]*)\"]\n  ],\n  \"github.copilot.chat.codeGeneration.useInstructionFiles\": true,\n  \"tailwindCSS.experimental.configFile\": {\n    \"apps/mobile/tailwind.config.ts\": \"apps/mobile/**\",\n    \"apps/ssr/tailwind.config.ts\": \"apps/ssr/**\",\n    \"apps/desktop/tailwind.config.ts\": [\"!apps/mobile/**\", \"!apps/ssr/**\", \"**\"]\n  },\n  \"typescript.tsserver.maxTsServerMemory\": 8096,\n  \"typescript.tsserver.nodePath\": \"node\",\n  // If you do not want to autofix some rules on save\n  // You can put this in your user settings or workspace settings\n  \"eslint.codeActionsOnSave.rules\": [\n    \"!prefer-const\",\n    \"!unused-imports/no-unused-imports\",\n    \"!@stylistic/jsx-self-closing-comp\",\n    \"!tailwindcss/classnames-order\",\n    \"!arrow-body-style\",\n    \"*\"\n  ],\n  // If you want to silent stylistic rules\n  // You can put this in your user settings or workspace settings\n  \"eslint.rules.customizations\": [\n    {\n      \"rule\": \"@stylistic/*\",\n      \"severity\": \"off\",\n      \"fixable\": true\n    },\n    {\n      \"rule\": \"tailwindcss/classnames-order\",\n      \"severity\": \"off\"\n    },\n    {\n      \"rule\": \"antfu/consistent-list-newline\",\n      \"severity\": \"off\"\n    },\n    {\n      \"rule\": \"hyoban/jsx-attribute-spacing\",\n      \"severity\": \"off\"\n    },\n    {\n      \"rule\": \"simple-import-sort/*\",\n      \"severity\": \"off\"\n    },\n    {\n      \"rule\": \"prefer-const\",\n      \"severity\": \"off\"\n    },\n    {\n      \"rule\": \"unused-imports/no-unused-imports\",\n      \"severity\": \"off\"\n    }\n  ],\n  \"cSpell.words\": [\"Hydable\", \"rsshub\", \"Русский\"],\n  // \"editor.foldingImportsByDefault\": true,\n  \"commentTranslate.hover.enabled\": false,\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"i18n-ally.enabledFrameworks\": [\"i18next\"],\n  \"i18n-ally.displayLanguage\": \"en\",\n  \"i18n-ally.localesPaths\": [\"locales\"],\n  \"i18n-ally.namespace\": true,\n  \"i18n-ally.pathMatcher\": \"{namespaces}/{locale}.json\",\n  \"lldb.library\": \"/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB\",\n  \"lldb.launch.expressions\": \"native\",\n  \"[swift]\": {\n    \"editor.defaultFormatter\": \"swiftlang.swift-vscode\"\n  },\n  \"npm.exclude\": \"**/packages/**\"\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides concise, agent-focused guidance for working in this monorepo. It consolidates the repository's CLAUDE.md guides, .cursor rules, Cursor rules improvements, and modern agent best practices.\n\n## Project overview\n\n- Monorepo managed by pnpm workspaces + Turbo.\n- Apps:\n  - `apps/desktop` – Electron app (Vite + React renderer is the primary web app)\n  - `apps/mobile` – React Native app via Expo\n  - `apps/ssr` – Minimal SSR site for external sharing\n- Shared packages: `packages/internal` (components, atoms, hooks, store, utils, database, etc.).\n\n## Setup commands\n\n```bash\n# Install deps\npnpm install\n\n# Desktop – recommended (browser renderer)\ncd apps/desktop && pnpm run dev:web\n\n# Desktop – full Electron\ncd apps/desktop && pnpm run dev:electron\n\n# Mobile – Expo\ncd apps/mobile && pnpm run dev\n# or target platforms\ncd apps/mobile && pnpm run ios\ncd apps/mobile && pnpm run android\n\n# SSR\ncd apps/ssr && pnpm run dev\n\n# Build web version (desktop renderer)\npnpm run build:web\n```\n\n## Quality gates (must-pass before commit/PR)\n\n```bash\n# 1) Typecheck first (required)\npnpm run typecheck\n\n# 2) Lint and auto-fix\npnpm run lint:fix\n\n# 3) Tests\npnpm run test\n```\n\n- Run the above at the root, or use per-package variants as needed.\n- Follow this order strictly: typecheck → lint → test.\n- After every modification, run the following checks to catch errors early:\n\n```bash\nnpm exec turbo run format:check typecheck lint\nnpm exec turbo run test\n```\n\n## Code style and conventions\n\n- TypeScript strict; avoid `any` (use precise types). Comments in English. Keep solutions simple and maintainable.\n- Prefer CSS transitions/animations for simple UI interactions. Use JS-driven motion only when necessary to avoid frame drops.\n- Imports: use `pathe` instead of `node:path` for cross‑platform paths.\n- Organize shared, reusable UI in `packages/internal/components`; app-specific UI stays in its app.\n- **Style extraction**: Avoid inline styles in JSX. Extract complex styles (especially those using CSS variables, gradients, or multiple properties) to external style objects similar to React Native's `StyleSheet.create`. Place style objects in a `styles.ts` file alongside the component, using `CSSProperties` type for type safety.\n\n## Team preferences\n\n- Prefer CSS transitions/animations over JS-based motion for simple interactions to avoid frame drops.\n- Prefer simple, easy-to-maintain solutions.\n- Avoid using the `any` type in TypeScript.\n- Write code comments in English.\n\n## Architecture quick reference\n\n- State: Jotai for atoms, Zustand for complex stores, TanStack Query for server state.\n- Database: Drizzle + SQLite (see `packages/internal/database`).\n- Error handling: custom utils in `packages/internal/utils`; Sentry integrated.\n- i18n: i18next with flat keys only; no `defaultValue`. Provide `en`, `zh-CN`, `ja` for each feature. Avoid conflicting dotted keys.\n\n## UI system and design tokens\n\n### Tailwind + Apple UIKit colors (Desktop/Web)\n\n- Use Tailwind classes bound to Apple UIKit color tokens (light/dark adaptive). Prefix by CSS property:\n  - System colors: `text-red`, `bg-blue`, `border-gray`, etc. for `red|orange|yellow|green|mint|teal|cyan|blue|indigo|purple|pink|brown|gray`.\n  - Fill: `bg-fill[-secondary|-tertiary|-quaternary|-quinary]` and `bg-fill-vibrant[-secondary|-tertiary|-quaternary|-quinary]` (and `border-*` as needed).\n  - Text: `text-text`, `text-text-secondary|tertiary|quaternary|quinary`, `text-text-vibrant(-secondary|-tertiary|-quaternary|-quinary)`.\n  - Material: `bg-material-ultra-thick|thick|medium|thin|ultra-thin|opaque`.\n  - Control: `bg-control-enabled|disabled`.\n  - Interface: `bg-menu|popover|titlebar|sidebar|selection-focused|selection-focused-fill|selection-unfocused|selection-unfocused-fill|header-view|tooltip|under-window-background`.\n\nThese classes map to the UIKit color variables (see `.cursor rules/color` and `apps/desktop/AGENTS.md`).\n\n### Icons (Desktop/Web)\n\n- Prefer MingCute with `i-mgc-` prefix (e.g., `i-mgc-copy-cute-re`). Use `i-mingcute-` only if no `i-mgc-` exists.\n\n### Motion (Desktop/Web)\n\n- Use Framer Motion with LazyMotion via `m` from `motion/react` (e.g., `m.div`).\n- Prefer spring presets from `@follow/components/constants/spring.js` (`Spring.presets.smooth|snappy|bouncy`).\n- For simple micro-interactions, prefer CSS transitions first.\n\n### React Native (Mobile)\n\n- Styling: NativewindCSS; do not use external `StyleSheet.create` for new UI.\n- Icons: use `@/apps/mobile/icons` only.\n- Colors: use React Native UIKit color system (via `react-native-uikit-colors`) with Tailwind utilities:\n  - Backgrounds: `system-background`, `secondary-system-background`, `tertiary-system-background`.\n  - Grouped backgrounds: `system-grouped-background`, `secondary-system-grouped-background`, `tertiary-system-grouped-background`.\n  - Labels: `label`, `secondary-label`, `tertiary-label`, `quaternary-label`.\n  - Fills: `system-fill`, `secondary-system-fill`, `tertiary-system-fill`, `quaternary-system-fill`.\n  - Separators: `separator`, `opaque-separator`, `non-opaque-separator`.\n  - Semantic colors: `red|orange|yellow|green|mint|teal|cyan|blue|indigo|purple|pink|brown`, grays `gray..gray6`, and interactive `link`, `placeholder-text`.\n\n## Component placement\n\n1. Check existing components in `apps/desktop/layer/renderer/src/modules/renderer/components` for app-specific UI.\n2. If generic and reusable, implement in `packages/internal/components` and export from the package index.\n\n## Testing & CI tips\n\n- Use Vitest for unit tests; co-locate tests near source files.\n- After moving files or changing imports, run `pnpm lint` and `pnpm typecheck` for the affected package.\n- CI expects `pnpm typecheck`, `pnpm lint`, and `pnpm test` to pass before merge.\n\n## Agent workflow (Cursor-oriented improvements)\n\n- Status updates: provide brief progress notes when running tool batches.\n- Prefer semantic code search to explore unfamiliar areas; use exact grep only for symbols.\n- Default to parallelizing independent searches/reads to reduce latency.\n- Avoid multi-line speculative edits; keep edits minimal and targeted; preserve existing indentation.\n- When editing TypeScript, do not introduce `any`; keep types precise.\n- For UI, prefer CSS transitions for simple effects; use Framer Motion `m.*` only when needed.\n\n## Context7 (up-to-date docs)\n\n- Use Context7 to fetch current library docs before using APIs prone to change.\n- Workflow:\n  1. Resolve a library ID: resolve the library (e.g., React Native, Vite, TanStack Query).\n  2. Fetch docs scoped to the topic (e.g., hooks, routing).\n  3. Integrate code examples following our style rules.\n\n## Sequential Thinking (step-by-step problem solving)\n\n- Break work into small thought steps:\n  1. Define immediate goal/assumption.\n  2. Use suitable tool (search, code edit, error explainer, docs).\n  3. Record the output/results.\n  4. Decide next step or branch alternatives; compare trade-offs.\n- Encourage rollback/iteration if new information contradicts prior steps.\n\n## Subproject guides\n\n- This root AGENTS.md sets global rules. Each app/package should include its own `AGENTS.md` (e.g., `apps/desktop/AGENTS.md`, `apps/mobile/AGENTS.md`). The closest guide to the edited file takes precedence when rules conflict.\n\n## Quick checklists\n\n- Implementation\n  - [ ] Is code placed in the right package/app?\n  - [ ] Type-safe (no `any`), readable names, English comments where needed.\n  - [ ] Uses correct UIKit Tailwind tokens and icon sources.\n  - [ ] For motion: CSS first; `m.*` only if necessary.\n\n- Validation\n  - [ ] `pnpm typecheck` passes\n  - [ ] `pnpm lint:fix` passes cleanly\n  - [ ] Tests updated and pass\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, sex characteristics, gender identity and expression,\nlevel of experience, education, socio-economic status, nationality, personal\nappearance, race, religion, or sexual identity and orientation.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n- The use of sexualized language or imagery and unwelcome sexual attention or\n  advances\n- Trolling, insulting/derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team at follow@rss3.io. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see\nhttps://www.contributor-covenant.org/faq\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Folo\n\nThank you for considering contributing to Folo! We welcome contributions from the community to help improve and expand the project.\n\n## Getting Started\n\nBefore you start contributing, please ensure you have enabled [Corepack](https://nodejs.org/api/corepack.html). Corepack ensures you are using the correct version of the package manager specified in the `package.json`.\n\n```sh\ncorepack enable && corepack prepare\n```\n\n### Installing Dependencies\n\nTo install the necessary dependencies, run:\n\n```sh\npnpm install\n```\n\n## Development Setup\n\n### Develop in the Browser\n\nFor a more convenient development experience, we recommend developing in the browser:\n\n```sh\ncd apps/desktop && pnpm run dev:web\n```\n\nThis will open the browser at `https://app.folo.is/__debug_proxy`, allowing you to access the online API environment for development and debugging.\n\n### Develop in Electron\n\nIf you prefer to develop in Electron, follow these steps:\n\n0. Go to the `apps/desktop` directory:\n\n   ```sh\n   cd apps/desktop\n   ```\n\n1. Copy the example environment variables file:\n\n   ```sh\n   cp .env.example .env\n   ```\n\n2. Set `VITE_API_URL` to `https://api.follow.is` in your `.env` file.\n\n3. Run the development server:\n\n   ```sh\n   pnpm run dev:electron\n   ```\n\n> **Tip:** If you encounter login issues, copy the `__Secure-better-auth.session_token` from your browser's cookies into the app.\n\n### Develop in External SSR Web App\n\nTo develop in SSR, follow these steps:\n\n1. Go to the `apps/ssr` directory:\n\n   ```sh\n   cd apps/ssr\n   ```\n\n2. Run the development server:\n\n   ```sh\n   pnpm run dev\n   ```\n\n### Develop in Mobile App\n\nTo develop in the mobile app, follow these steps:\n\n> [!NOTE]\n> You need to have a Mac device to develop in the mobile app.\n>\n> And already installed Xcode and the necessary dependencies.\n\n1. Go to the `apps/mobile` directory:\n\n   ```sh\n   cd apps/mobile\n   ```\n\n2. Copy the example environment variables file:\n\n   ```sh\n   cp .env.example .env\n   ```\n\n   Then set the required environment variables in your `.env` file:\n\n   ```sh\n   echo 'EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN=\"xxx\"' >> .env\n   ```\n\n   Or manually edit the `.env` file to add:\n\n   ```\n   EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN=\"xxx\"\n   ```\n\n   the value is any string.\n\n3. Build and install Folo(dev) app from source: (This step will take a while and only need to be done once)\n\n   ```sh\n   pnpm expo prebuild --clean # Optional\n   pnpm run ios\n   ```\n\n4. Run the development server:\n\n   ```sh\n   pnpm run dev\n   ```\n\n#### Development Native Modules\n\nTo develop native iOS modules, follow these steps:\n\n1. Go to the `apps/mobile` directory:\n\n   ```sh\n   cd apps/mobile/ios\n   ```\n\n2. Open project in Xcode:\n\n   ```sh\n   open Folo.xcworkspace\n   ```\n\n3. Open `Pods` in left sidebar and select `FollowNative`:\n\n![](https://github.com/user-attachments/assets/a449c087-6d55-4cbd-bc4b-c61a08406e98)\n\n4. Build and run the project.\n\n## Contribution Guidelines\n\n- Ensure your code follows the project's coding standards and conventions.\n- Write clear, concise commit messages.\n- Include relevant tests for your changes.\n- Update documentation as necessary.\n\n## Community\n\nJoin our community to discuss ideas, ask questions, and share your contributions:\n\n- [Discord](https://discord.gg/AwWcAQ7euc)\n- [Twitter](https://x.com/intent/follow?screen_name=folo_is)\n\nWe look forward to your contributions!\n\n## License\n\nBy contributing to Folo, you agree that your contributions will be licensed under the GNU Affero General Public License version 3, with the special exceptions noted in the `README.md`.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n\n---\n\nFolo is licensed under the GNU Affero General Public License version 3 with the addition of the following special exception:\n\nAll content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <a href=\"https://github.com/RSSNext/Folo\">\n    <img src=\"https://github.com/RSSNext/Folo/raw/refs/heads/dev/apps/desktop/layer/renderer/public/icon.svg\" alt=\"Logo\" width=\"80\" height=\"80\">\n  </a>\n\n  <h3>Folo</h3>\n  <p>\n    <img src=\"https://github.com/user-attachments/assets/cbe924f2-d8b0-48b0-814e-7c06ccb1911c\" height=\"60\" />\n    &nbsp;&nbsp;&nbsp;\n    <img src=\"https://github.com/user-attachments/assets/6997a236-3df3-49d5-98a4-514f6d1a02c4\" height=\"60\" />\n    <br />\n    <br />\n    <a href=\"https://github.com/RSSNext/Folo/stargazers\"><img src=\"https://img.shields.io/github/stars/RSSNext/Follow?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars\" /></a>\n    <a href=\"https://github.com/RSSNext/Folo/graphs/contributors\"><img src=\"https://img.shields.io/github/contributors/RSSNext/Folo?style=flat-square&logo=github&label=Contributors&labelColor=black\" /></a>\n    <a href=\"https://status.follow.is/\" target=\"_blank\"><img src=\"https://status.follow.is/api/badge/18/uptime?color=%2344CC10&labelColor=black&style=flat-square\"/></a>\n    <a href=\"https://github.com/RSSNext/Folo/releases\"><img src=\"https://img.shields.io/github/downloads/RSSNext/Folo/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads\" /></a>\n    <a href=\"https://x.com/intent/follow?screen_name=folo_is\"><img src=\"https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square\" /></a>\n    <a href=\"https://discord.gg/AwWcAQ7euc\" target=\"_blank\"><img src=\"https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square\"/></a>\n    <br />\n    <a href=\"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\"><img src=\"https://img.shields.io/itunes/v/6739802604?style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black\" /></a>\n    <a href=\"https://play.google.com/store/apps/details?id=is.follow\" target=\"_blank\"><img src=\"https://img.shields.io/endpoint?url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dis.follow%26gl%3DUS%26hl%3Den%26l%3DAndroid%26m%3D%24version&style=flat-square&logo=google-play&label=Google%20Play&labelColor=black&color=FF5C00\"/></a>\n    <a href=\"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\"><img src=\"https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.folo.is%2Fupdates%2Fdistribution%2Fmas&query=data.storeVersion&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600\" /></a>\n    <a href=\"https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct\"><img src=\"https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.folo.is%2Fupdates%2Fdistribution%2Fmss&query=data.storeVersion&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600&prefix=v\" /></a>\n    <br />\n    <br />\n    <!-- <a href=\"https://github.com/RSSNext/Folo\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/59b957fb-59ed-4ef0-994e-f6a402a6fe2b\" alt=\"GitHub Trending\" height=\"55\"/></a>\n    <br />\n    <br /> -->\n    <a href=\"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/35747716-28bf-413a-822b-aa49d49f1aa0\" alt=\"Folo Mobile\" width=\"52%\"/></a>\n    <a href=\"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/198a0165-b8c9-45c1-9116-b473a13a8d0c\" alt=\"Folo Desktop\" width=\"46%\"/></a>\n    <br />\n    <br />\n\n  </p>\n</div>\n\nAs they say, your thoughts are what you read—and we’ve been consuming noisy feeds for too long! Folo organizes content into one timeline, keeping you updated on what matters, noise-free. Share lists, explore collections, and enjoy distraction-free browsing.\n\n## 👋🏻 Getting Started & Join Our Community\n\nWhether for users or professional developers, Folo will be your open information playground. Please be aware that Folo is currently under active development, and feedback is welcome for any [issue](https://github.com/RSSNext/Folo/issues) encountered.\n\nFeel free to try it using the following methods:\n\n| Operating System | Source                                                                                                                                                                                                                                                                                                                                                                                                                                |\n| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Any              | <a href=\"https://app.folo.is\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/51ef7800-b683-4493-83e8-eb4752366997\" alt=\"Browser\" height=\"55\"/></a>                                                                                                                                                                                                                                                              |\n| iOS              | <a href=\"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/a94d8698-2a11-4f43-9b0a-b756b17b61f7\" alt=\"App Store\" height=\"55\"/></a>                                                                                                                                                                                                              |\n| Android          | <a href=\"https://play.google.com/store/apps/details?id=is.follow\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/0d178e0b-3ace-4f75-bbde-ab3c0a416ce8\" alt=\"Google Play\" height=\"55\"/></a> <a href=\"https://github.com/RSSNext/Folo/releases/latest\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc\" alt=\"App Store\" height=\"55\"/></a>               |\n| macOS            | <a href=\"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/0d47f902-7fa3-494e-ad28-9ab11af5e6d4\" alt=\"Microsoft Store\" height=\"55\"/></a> <a href=\"https://github.com/RSSNext/Folo/releases/latest\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc\" alt=\"App Store\" height=\"55\"/></a> |\n| Windows          | <a href=\"https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/b3112bab-9dd0-4893-9488-890dcb368f70\" alt=\"Microsoft Store\" height=\"55\"/></a> <a href=\"https://github.com/RSSNext/Folo/releases/latest\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc\" alt=\"App Store\" height=\"55\"/></a>        |\n| Linux            | <a href=\"https://github.com/RSSNext/Folo/releases/latest\" target=\"_blank\"><img src=\"https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc\" alt=\"App Store\" height=\"55\"/></a>                                                                                                                                                                                                                                |\n\nYou can also install using the following methods maintained by our community:\n\n- If you are using Arch Linux, you can install the package [folo-appimage](https://aur.archlinux.org/packages/folo-appimage) that is maintained by [timochan](https://github.com/ttimochan) and [grtsinry43](https://github.com/grtsinry43).\n- If you are using Nix, you can install the package [follow](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/fo/follow/package.nix) that is maintained by [iosmanthus](https://github.com/iosmanthus).\n- If you are using macOS with [Homebrew](https://brew.sh), you can install the cask [folo](https://formulae.brew.sh/cask/folo) that is maintained by [realSunyz](https://github.com/realSunyz).\n- If you are using Windows with [Scoop](https://scoop.sh), you can install the manifest [folo](https://github.com/cscnk52/cetacea/blob/master/bucket/folo.json) that is maintained by [cscnk52](https://github.com/cscnk52).\n\n| [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/AwWcAQ7euc) | Join our Discord server to connect with developers, request features, and receive support. |\n| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |\n| [![](https://img.shields.io/badge/any_text-Follow-blue?color=2CA5E0&label=_&logo=x&labelColor=black&style=flat-square)](https://x.com/intent/follow?screen_name=folo_is)                                                                                                                        | Follow us on X/Twitter for product updates and to join in on reward activities.            |\n\n> \\[!IMPORTANT]\n>\n> **Star Us**, You will receive all release notifications from GitHub without any delay \\~\n\n![Image](https://github.com/user-attachments/assets/a08f9437-b24c-4388-8f01-2826e09eeaf2)\n\n<a href=\"https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=783512367\" target=\"_blank\" style=\"display: block\" align=\"center\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=783512367&image_size=auto&color_scheme=dark\" width=\"655\" height=\"auto\">\n    <img alt=\"Performance Stats of RSSNext/Folo - Last 28 days\" src=\"https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=783512367&image_size=auto&color_scheme=light\" width=\"655\" height=\"auto\">\n  </picture>\n</a>\n\n## ✨ Features\n\n### Customized Information Hub\n\nSubscribe to a vast range of feeds and curated lists. Curate your favorites and keep track of what matters most to you.\n\n![](https://github.com/user-attachments/assets/11dc7d21-f5d8-4e41-9269-24fc352aa02b)\n\n### AI At Your Fingertips\n\nA smarter and more efficient browsing with AI-powered features like translation, summary, and more.\n\n![](https://github.com/user-attachments/assets/37cf4f2f-4c5e-4775-86e8-2fa1a1b2ecf5)\n\n### Dynamic Content Support\n\nBecause we know content is more than just text. From articles to videos, images to audio — Folo gets it all covered.\n\n![](https://github.com/user-attachments/assets/d1379fd6-8767-476e-b0dc-d61753715e26)\n\n### More Than Just An App\n\nThis isn’t just another app. Folo is a community — introducing a new era of openness and community-driven experience.\n\n![](https://github.com/user-attachments/assets/62004a04-eaea-4f5d-bfbf-4e68b6b90286)\n\n## 🤝 Contributing\n\nYou are welcome to join the open source community to build together, please check our [Contributing Guide](./CONTRIBUTING.md) for more details.\n\n## 🔏 Code signing policy\n\nFolo for Windows uses free code signing provided by [SignPath.io](https://about.signpath.io/), a certificate by [SignPath Foundation](https://signpath.org/).\n\nFolo for macOS and iOS is signed and notarized by [Apple Developer Program](https://developer.apple.com/programs/).\n\nAll released files are verified with [GitHub artifact attestations](https://github.com/RSSNext/Folo/attestations) to ensure their provenance and integrity.\n\n## 📝 License\n\nFolo is licensed under the GNU Affero General Public License version 3 with the addition of the following special exception:\n\nAll content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nWe always recommend using the latest version of Follow to ensure you get all security updates.\n\n## Reporting a Vulnerability\n\nPlease report security vulnerabilities to follow@rss3.io.\n"
  },
  {
    "path": "api/vercel_webhook.ts",
    "content": "import crypto from \"node:crypto\"\n\nimport type { VercelRequest, VercelResponse } from \"@vercel/node\"\nimport getRawBody from \"raw-body\"\n\nexport default async function handler(request: VercelRequest, response: VercelResponse) {\n  const { WEBHOOK_SECRET: INTEGRATION_SECRET } = process.env\n\n  if (typeof INTEGRATION_SECRET != \"string\") {\n    return response.status(400).json({\n      code: \"invalid_secret\",\n      error: \"No integration secret found\",\n    })\n  }\n\n  const rawBody = await getRawBody(request)\n  const bodySignature = sha1(rawBody, INTEGRATION_SECRET)\n\n  if (bodySignature !== request.headers[\"x-vercel-signature\"]) {\n    return response.status(403).json({\n      code: \"invalid_signature\",\n      error: \"signature didn't match\",\n    })\n  }\n\n  const json = JSON.parse(rawBody.toString(\"utf-8\"))\n\n  switch (json.type) {\n    // https://vercel.com/docs/observability/webhooks-overview/webhooks-api#deployment.succeeded\n    case \"deployment.succeeded\": {\n      const { target } = json.payload || json.data\n\n      if (target === \"production\") {\n        await purgeCloudflareCache()\n      } else {\n        console.info(`Skipping non-production deployment: ${target}`, json)\n      }\n    }\n    // ...\n  }\n\n  return response.status(200).end(\"OK\")\n}\n\nfunction sha1(data: Buffer, secret: string): string {\n  return crypto.createHmac(\"sha1\", secret).update(data).digest(\"hex\")\n}\n\nexport const config = {\n  api: {\n    bodyParser: false,\n  },\n}\n\nasync function purgeCloudflareCache() {\n  const { CF_TOKEN, CF_ZONE_ID } = process.env\n\n  if (typeof CF_TOKEN !== \"string\" || typeof CF_ZONE_ID !== \"string\") {\n    throw new TypeError(\"No Cloudflare token or zone ID found\")\n  }\n\n  const apiUrl = `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache`\n\n  const manifestPath = await fetch(`https://app.folo.is/assets/manifest.txt?t=${Date.now()}`).then(\n    (res) => res.text(),\n  )\n\n  try {\n    await fetch(apiUrl, {\n      method: \"POST\",\n      headers: {\n        Authorization: CF_TOKEN,\n      },\n      body: JSON.stringify({\n        tags: [\"follow-assets\"],\n      }),\n    })\n\n    console.info(\"Successfully purged Cloudflare cache\")\n  } catch {\n    console.error(\"Failed to purge Cloudflare cache by tags, fallback to purge by files\")\n    const allPath = manifestPath.split(\"\\n\").map((path) => `https://app.folo.is/${path}`)\n\n    // Function to delay execution\n    const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\n    const taskPromise = [] as Promise<Response>[]\n    // Batch processing\n    for (let i = 0; i < allPath.length; i += 30) {\n      const batch = allPath.slice(i, i + 30)\n\n      const r = fetch(apiUrl, {\n        method: \"POST\",\n        headers: {\n          Authorization: CF_TOKEN,\n        },\n        body: JSON.stringify({\n          files: batch,\n        }),\n      })\n\n      taskPromise.push(r)\n\n      // Delay for 0.5 seconds between batches\n      if (i + 30 < allPath.length) {\n        await delay(500)\n      }\n    }\n\n    const result = await Promise.allSettled(taskPromise)\n    console.info(`Success: ${result.filter((r) => r.status === \"fulfilled\").length}`)\n    console.info(`Failed: ${result.filter((r) => r.status === \"rejected\").length}`)\n  }\n}\n"
  },
  {
    "path": "apps/cli/package.json",
    "content": "{\n  \"name\": \"folocli\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"description\": \"Folo CLI for terminal workflows and automation\",\n  \"author\": \"Folo Team\",\n  \"license\": \"AGPL-3.0-only\",\n  \"homepage\": \"https://github.com/RSSNext/Folo\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/RSSNext/Folo.git\"\n  },\n  \"keywords\": [\n    \"folo\",\n    \"rss\",\n    \"reader\",\n    \"cli\",\n    \"automation\"\n  ],\n  \"bin\": {\n    \"folo\": \"./dist/index.js\"\n  },\n  \"files\": [\n    \"dist\",\n    \"skill.md\"\n  ],\n  \"engines\": {\n    \"node\": \">=18\"\n  },\n  \"scripts\": {\n    \"build\": \"tsup --config tsup.config.ts && chmod +x dist/index.js\",\n    \"dev\": \"tsx src/index.ts\",\n    \"start\": \"node dist/index.js\",\n    \"test\": \"pnpm run build && vitest run\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"commander\": \"14.0.1\",\n    \"pathe\": \"2.0.3\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\",\n    \"@types/node\": \"25.2.3\",\n    \"tsup\": \"8.5.0\",\n    \"tsx\": \"4.21.0\",\n    \"typescript\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "apps/cli/skill.md",
    "content": "# Folo CLI Skill\n\n## Trigger Conditions\n\nUse this skill when a user asks to:\n\n- Manage RSS subscriptions\n- Browse timeline entries\n- Read entry details or readability content\n- Mark entries as read/unread\n- Search feeds/lists or trending sources\n- Import/export OPML\n- Check unread counts\n\n## Preconditions\n\n1. Node.js and npm are installed so the CLI can be executed with `npx`.\n2. Authentication is configured:\n   - `npx --yes folocli@latest login` (recommended, opens browser and auto-logins)\n   - or `npx --yes folocli@latest login --token <session-token>`\n   - or set `FOLO_TOKEN=<token>`\n\n## Execution Policy\n\n- Prefer `npx --yes folocli@latest ...` for all agent runs.\n- Do not require `npm install -g folocli`.\n- No separate update preflight is needed. Using `folocli@latest` is the update strategy.\n- If a user already has a working global `folo` binary, it is acceptable, but `npx --yes folocli@latest` remains the recommended default in docs and automation.\n\n## Output Contract\n\nDefault output is JSON with a stable envelope:\n\n```json\n{\n  \"ok\": true,\n  \"data\": {},\n  \"error\": null\n}\n```\n\nErrors return:\n\n```json\n{\n  \"ok\": false,\n  \"data\": null,\n  \"error\": {\n    \"code\": \"UNAUTHORIZED\",\n    \"message\": \"Token is invalid or expired.\"\n  }\n}\n```\n\nYou can switch output mode:\n\n- `--format json` (default)\n- `--format table`\n- `--format plain`\n\n## Core Workflows\n\n### 1. Timeline Reading\n\n1. Fetch timeline:\n   - `npx --yes folocli@latest timeline --limit 10`\n2. Get entry detail:\n   - `npx --yes folocli@latest entry get <entryId>`\n3. Get readability content:\n   - `npx --yes folocli@latest entry read <entryId>`\n\n### 2. Subscription Management\n\n1. Discover:\n   - `npx --yes folocli@latest search discover <keyword>`\n2. Add subscription:\n   - `npx --yes folocli@latest subscription add --feed <url>`\n   - or `npx --yes folocli@latest subscription add --list <listId>`\n3. List subscriptions:\n   - `npx --yes folocli@latest subscription list`\n\n### 3. Unread Processing\n\n1. Check unread total:\n   - `npx --yes folocli@latest unread count`\n2. List unread subscriptions:\n   - `npx --yes folocli@latest unread list`\n3. Read unread entries:\n   - `npx --yes folocli@latest timeline --unread-only --limit 20`\n4. Mark read:\n   - `npx --yes folocli@latest entry mark-read <entryId>`\n   - or batch: `npx --yes folocli@latest entry mark-all-read --view articles`\n\n### 4. Collection Operations\n\n- Add: `npx --yes folocli@latest collection add <entryId>`\n- Remove: `npx --yes folocli@latest collection remove <entryId>`\n- List: `npx --yes folocli@latest collection list --limit 20`\n\n### 5. OPML Import / Export\n\n- Export:\n  - `npx --yes folocli@latest opml export --output backup.opml`\n- Import:\n  - `npx --yes folocli@latest opml import feeds.opml`\n\n## Pagination Pattern\n\n`npx --yes folocli@latest timeline` returns:\n\n- `entries`\n- `nextCursor`\n- `hasNext`\n\nLoop until `hasNext` is `false`:\n\n1. `npx --yes folocli@latest timeline --limit 20`\n2. Read `nextCursor`\n3. `npx --yes folocli@latest timeline --limit 20 --cursor <nextCursor>`\n4. Repeat\n\n## Command Reference\n\n- `npx --yes folocli@latest login [--timeout <seconds>] [--token <token>]`\n- `npx --yes folocli@latest logout`\n- `npx --yes folocli@latest whoami`\n- `npx --yes folocli@latest auth login [--timeout <seconds>] [--token <token>]`\n- `npx --yes folocli@latest auth logout`\n- `npx --yes folocli@latest auth whoami`\n\n- `npx --yes folocli@latest timeline [--view <type>] [--limit <n>] [--unread-only] [--cursor <datetime>]`\n- `npx --yes folocli@latest timeline --feed <feedId> [--limit <n>] [--cursor <datetime>]`\n- `npx --yes folocli@latest timeline --list <listId> [--limit <n>] [--cursor <datetime>]`\n- `npx --yes folocli@latest timeline --category <name> [--view <type>] [--limit <n>]`\n\n- `npx --yes folocli@latest subscription list [--view <type>] [--category <name>]`\n- `npx --yes folocli@latest subscription add --feed <url> [--category <name>] [--view <type>] [--private]`\n- `npx --yes folocli@latest subscription add --list <listId> [--category <name>] [--view <type>]`\n- `npx --yes folocli@latest subscription remove <id> [--target feed|list|url]`\n- `npx --yes folocli@latest subscription update <id> [--target feed|list] [--category <name>] [--title <title>] [--view <type>] [--private|--public]`\n\n- `npx --yes folocli@latest entry get <entryId>`\n- `npx --yes folocli@latest entry read <entryId>`\n- `npx --yes folocli@latest entry mark-read <entryId>`\n- `npx --yes folocli@latest entry mark-unread <entryId>`\n- `npx --yes folocli@latest entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]`\n\n- `npx --yes folocli@latest feed get <feedId|feedUrl>`\n- `npx --yes folocli@latest feed refresh <feedId>`\n- `npx --yes folocli@latest feed analytics <feedId>`\n\n- `npx --yes folocli@latest list ls`\n- `npx --yes folocli@latest list get <listId>`\n- `npx --yes folocli@latest list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]`\n- `npx --yes folocli@latest list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]`\n- `npx --yes folocli@latest list delete <listId>`\n- `npx --yes folocli@latest list add-feed <listId> --feed <feedId>`\n- `npx --yes folocli@latest list remove-feed <listId> --feed <feedId>`\n\n- `npx --yes folocli@latest search discover <keyword> [--type feeds|lists]`\n- `npx --yes folocli@latest search rsshub <keyword> [--lang <lang>]`\n- `npx --yes folocli@latest search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]`\n\n- `npx --yes folocli@latest collection list [--limit <n>] [--cursor <datetime>]`\n- `npx --yes folocli@latest collection add <entryId> [--view <type>]`\n- `npx --yes folocli@latest collection remove <entryId>`\n\n- `npx --yes folocli@latest opml export [--output <file>]`\n- `npx --yes folocli@latest opml import <file> [--items <url1,url2,...>]`\n\n- `npx --yes folocli@latest unread count`\n- `npx --yes folocli@latest unread list [--view <type>]`\n\n## Error Recovery\n\n- `UNAUTHORIZED`\n  - Re-login: `npx --yes folocli@latest login`\n  - or `npx --yes folocli@latest login --token <token>`\n  - Or set `FOLO_TOKEN`\n- `HTTP_4xx` / `HTTP_5xx`\n  - Retry with `--verbose` for request details\n  - Verify `--api-url` if using non-default endpoint\n- `INVALID_ARGUMENT`\n  - Run `npx --yes folocli@latest <command> --help` to inspect accepted options\n"
  },
  {
    "path": "apps/cli/src/args.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport { parseFormat, parseISODate, parseNonNegativeInt, parsePositiveInt, parseView } from \"./args\"\n\ndescribe(\"args parsers\", () => {\n  it(\"parses named view values\", () => {\n    expect(parseView(\"articles\")).toBe(0)\n    expect(parseView(\"social\")).toBe(1)\n    expect(parseView(\"pictures\")).toBe(2)\n    expect(parseView(\"videos\")).toBe(3)\n    expect(parseView(\"audio\")).toBe(4)\n    expect(parseView(\"notifications\")).toBe(5)\n  })\n\n  it(\"parses numeric view values\", () => {\n    expect(parseView(\"0\")).toBe(0)\n    expect(parseView(\"5\")).toBe(5)\n  })\n\n  it(\"throws for invalid view values\", () => {\n    expect(() => parseView(\"foo\")).toThrowError(/Invalid view/)\n    expect(() => parseView(\"6\")).toThrowError(/Invalid view/)\n  })\n\n  it(\"parses positive integers\", () => {\n    expect(parsePositiveInt(\"1\")).toBe(1)\n    expect(parsePositiveInt(\"99\")).toBe(99)\n  })\n\n  it(\"throws for non-positive integers\", () => {\n    expect(() => parsePositiveInt(\"0\")).toThrowError(/positive integer/)\n    expect(() => parsePositiveInt(\"-1\")).toThrowError(/positive integer/)\n  })\n\n  it(\"parses non-negative integers\", () => {\n    expect(parseNonNegativeInt(\"0\")).toBe(0)\n    expect(parseNonNegativeInt(\"3\")).toBe(3)\n  })\n\n  it(\"throws for negative integers\", () => {\n    expect(() => parseNonNegativeInt(\"-1\")).toThrowError(/non-negative integer/)\n  })\n\n  it(\"parses ISO datetime\", () => {\n    expect(parseISODate(\"2026-02-25T10:30:00Z\")).toBe(\"2026-02-25T10:30:00.000Z\")\n  })\n\n  it(\"throws for invalid datetime\", () => {\n    expect(() => parseISODate(\"not-a-date\")).toThrowError(/Invalid datetime/)\n  })\n\n  it(\"parses output format\", () => {\n    expect(parseFormat(\"json\")).toBe(\"json\")\n    expect(parseFormat(\"table\")).toBe(\"table\")\n    expect(parseFormat(\"plain\")).toBe(\"plain\")\n  })\n\n  it(\"throws for invalid output format\", () => {\n    expect(() => parseFormat(\"yaml\")).toThrowError(/Invalid format/)\n  })\n})\n"
  },
  {
    "path": "apps/cli/src/args.ts",
    "content": "import type { OutputFormat } from \"./output\"\n\nconst viewMap: Readonly<Record<string, number>> = {\n  article: 0,\n  articles: 0,\n  social: 1,\n  socialmedia: 1,\n  picture: 2,\n  pictures: 2,\n  video: 3,\n  videos: 3,\n  audio: 4,\n  notification: 5,\n  notifications: 5,\n}\n\nexport const viewHelp =\n  \"articles(0) | social(1) | pictures(2) | videos(3) | audio(4) | notifications(5)\"\n\nexport const parseView = (value: string): number => {\n  const normalized = value.trim().toLowerCase()\n  if (/^\\d+$/.test(normalized)) {\n    const parsed = Number.parseInt(normalized, 10)\n    if (parsed >= 0 && parsed <= 5) {\n      return parsed\n    }\n  }\n\n  const mapped = viewMap[normalized]\n  if (mapped !== undefined) {\n    return mapped\n  }\n\n  throw new Error(`Invalid view \"${value}\". Use ${viewHelp}.`)\n}\n\nexport const parsePositiveInt = (value: string): number => {\n  const parsed = Number.parseInt(value, 10)\n  if (!Number.isInteger(parsed) || parsed <= 0) {\n    throw new Error(`Expected a positive integer, got \"${value}\".`)\n  }\n  return parsed\n}\n\nexport const parseNonNegativeInt = (value: string): number => {\n  const parsed = Number.parseInt(value, 10)\n  if (!Number.isInteger(parsed) || parsed < 0) {\n    throw new Error(`Expected a non-negative integer, got \"${value}\".`)\n  }\n  return parsed\n}\n\nexport const parseISODate = (value: string): string => {\n  const timestamp = Date.parse(value)\n  if (Number.isNaN(timestamp)) {\n    throw new TypeError(`Invalid datetime value \"${value}\".`)\n  }\n  return new Date(timestamp).toISOString()\n}\n\nexport const parseFormat = (value: string): OutputFormat => {\n  if (value === \"json\" || value === \"table\" || value === \"plain\") {\n    return value\n  }\n  throw new Error(`Invalid format \"${value}\". Use json, table, or plain.`)\n}\n"
  },
  {
    "path": "apps/cli/src/browser-login.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport { DEFAULT_VALUES } from \"../../../packages/internal/shared/src/env.common\"\nimport { resolveCLILoginUrl } from \"./browser-login\"\n\ndescribe(\"browser login helpers\", () => {\n  it(\"maps production API URL using env.common\", () => {\n    const url = resolveCLILoginUrl(DEFAULT_VALUES.PROD.API_URL, \"http://127.0.0.1:12345/callback\")\n    const parsed = new URL(url)\n\n    expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.PROD.WEB_URL).origin)\n    expect(parsed.pathname).toBe(\"/login\")\n    expect(parsed.searchParams.get(\"cli_callback\")).toBe(\"http://127.0.0.1:12345/callback\")\n  })\n\n  it(\"maps dev API URL using env.common\", () => {\n    const url = resolveCLILoginUrl(DEFAULT_VALUES.DEV.API_URL, \"http://127.0.0.1:12345/callback\")\n    const parsed = new URL(url)\n\n    expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.DEV.WEB_URL).origin)\n    expect(parsed.pathname).toBe(\"/login\")\n    expect(parsed.searchParams.get(\"cli_callback\")).toBe(\"http://127.0.0.1:12345/callback\")\n  })\n\n  it(\"maps local API URL using env.common\", () => {\n    const url = resolveCLILoginUrl(DEFAULT_VALUES.LOCAL.API_URL, \"http://127.0.0.1:12345/callback\")\n    const parsed = new URL(url)\n\n    expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin)\n    expect(parsed.pathname).toBe(\"/login\")\n    expect(parsed.searchParams.get(\"cli_callback\")).toBe(\"http://127.0.0.1:12345/callback\")\n  })\n\n  it(\"falls back to API origin when no mapping exists\", () => {\n    const url = resolveCLILoginUrl(\"https://api.follow.is\", \"http://localhost:3456/callback\")\n    const parsed = new URL(url)\n\n    expect(parsed.origin).toBe(\"https://api.follow.is\")\n    expect(parsed.pathname).toBe(\"/login\")\n    expect(parsed.searchParams.get(\"cli_callback\")).toBe(\"http://localhost:3456/callback\")\n  })\n\n  it(\"throws for invalid api url\", () => {\n    expect(() => resolveCLILoginUrl(\"not-a-url\", \"http://127.0.0.1:3333/callback\")).toThrowError(\n      /Invalid API URL/,\n    )\n  })\n})\n"
  },
  {
    "path": "apps/cli/src/browser-login.ts",
    "content": "import { spawnSync } from \"node:child_process\"\nimport { createServer } from \"node:http\"\nimport type { AddressInfo } from \"node:net\"\n\nimport { DEFAULT_VALUES } from \"../../../packages/internal/shared/src/env.common\"\nimport { CLIError } from \"./output\"\n\nconst LOCAL_CALLBACK_HOST = \"127.0.0.1\"\nconst LOCAL_CALLBACK_PATH = \"/callback\"\nconst DEFAULT_TIMEOUT_MS = 3 * 60 * 1000\n\nconst mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [\n  {\n    apiOrigin: new URL(DEFAULT_VALUES.PROD.API_URL).origin,\n    webOrigin: new URL(DEFAULT_VALUES.PROD.WEB_URL).origin,\n  },\n  {\n    apiOrigin: new URL(DEFAULT_VALUES.DEV.API_URL).origin,\n    webOrigin: new URL(DEFAULT_VALUES.DEV.WEB_URL).origin,\n  },\n  {\n    apiOrigin: new URL(DEFAULT_VALUES.LOCAL.API_URL).origin,\n    webOrigin: new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin,\n  },\n]\n\nconst successPageHtml = `<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>Folo CLI Login</title>\n  </head>\n  <body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;\">\n    <h1>Folo CLI login complete</h1>\n    <p>You can close this window and return to your terminal.</p>\n  </body>\n</html>\n`\n\nconst failurePageHtml = `<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>Folo CLI Login</title>\n  </head>\n  <body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;\">\n    <h1>Folo CLI login failed</h1>\n    <p>Missing token in callback URL. Please retry in terminal.</p>\n  </body>\n</html>\n`\n\nconst getOpenBrowserCommand = (url: string): { command: string; args: string[] } => {\n  if (process.platform === \"darwin\") {\n    return { command: \"open\", args: [url] }\n  }\n\n  if (process.platform === \"win32\") {\n    return { command: \"cmd\", args: [\"/c\", \"start\", \"\", url] }\n  }\n\n  return { command: \"xdg-open\", args: [url] }\n}\n\nconst openBrowser = (url: string) => {\n  const { command, args } = getOpenBrowserCommand(url)\n  const result = spawnSync(command, args, {\n    stdio: \"ignore\",\n  })\n\n  if (result.error || result.status !== 0) {\n    throw new CLIError(\n      \"BROWSER_OPEN_FAILED\",\n      `Failed to open browser automatically. Open this URL manually: ${url}`,\n    )\n  }\n}\n\nexport const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string => {\n  let api: URL\n  try {\n    api = new URL(apiUrl)\n  } catch {\n    throw new CLIError(\"INVALID_ARGUMENT\", `Invalid API URL: ${apiUrl}`)\n  }\n\n  const mappedWebOrigin = mappedWebOrigins.find((item) => item.apiOrigin === api.origin)?.webOrigin\n  const webUrl = new URL(mappedWebOrigin ?? api.origin)\n\n  webUrl.pathname = \"/login\"\n  webUrl.search = \"\"\n  webUrl.hash = \"\"\n  webUrl.searchParams.set(\"cli_callback\", callbackUrl)\n\n  return webUrl.toString()\n}\n\nexport interface BrowserLoginOptions {\n  apiUrl: string\n  timeoutMs?: number\n  onStatus?: (message: string) => void\n}\n\nexport interface BrowserLoginResult {\n  token: string\n  callbackUrl: string\n  loginUrl: string\n}\n\nexport const loginWithBrowser = async (\n  options: BrowserLoginOptions,\n): Promise<BrowserLoginResult> => {\n  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS\n  const onStatus = options.onStatus ?? (() => {})\n\n  if (timeoutMs <= 0) {\n    throw new CLIError(\"INVALID_ARGUMENT\", \"Browser login timeout must be greater than 0.\")\n  }\n\n  const result = await new Promise<BrowserLoginResult>((resolve, reject) => {\n    let settled = false\n    let timer: NodeJS.Timeout | undefined\n\n    const settle = (handler: () => void) => {\n      if (settled) {\n        return\n      }\n      settled = true\n      if (timer) {\n        clearTimeout(timer)\n      }\n      server.close(() => {\n        handler()\n      })\n    }\n\n    const server = createServer((req, res) => {\n      const requestUrl = new URL(\n        req.url ?? \"/\",\n        `http://${req.headers.host ?? LOCAL_CALLBACK_HOST}`,\n      )\n\n      if (requestUrl.pathname !== LOCAL_CALLBACK_PATH) {\n        res.statusCode = 404\n        res.end(\"Not Found\")\n        return\n      }\n\n      const token = requestUrl.searchParams.get(\"token\")\n      if (!token) {\n        res.statusCode = 400\n        res.setHeader(\"content-type\", \"text/html; charset=utf-8\")\n        res.end(failurePageHtml)\n        return\n      }\n\n      res.statusCode = 200\n      res.setHeader(\"content-type\", \"text/html; charset=utf-8\")\n      res.end(successPageHtml)\n\n      const callbackAddress = server.address() as AddressInfo | null\n      const callbackUrl = callbackAddress\n        ? `http://${LOCAL_CALLBACK_HOST}:${callbackAddress.port}${LOCAL_CALLBACK_PATH}`\n        : \"\"\n      const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)\n\n      settle(() => {\n        resolve({\n          token,\n          callbackUrl,\n          loginUrl,\n        })\n      })\n    })\n\n    server.once(\"error\", (error) => {\n      settle(() => {\n        reject(\n          new CLIError(\"NETWORK_ERROR\", `Failed to start local callback server: ${error.message}`),\n        )\n      })\n    })\n\n    server.listen(0, LOCAL_CALLBACK_HOST, () => {\n      const address = server.address() as AddressInfo | null\n      if (!address) {\n        settle(() => {\n          reject(new CLIError(\"NETWORK_ERROR\", \"Failed to bind local callback server.\"))\n        })\n        return\n      }\n\n      const callbackUrl = `http://${LOCAL_CALLBACK_HOST}:${address.port}${LOCAL_CALLBACK_PATH}`\n      const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)\n\n      onStatus(`Open this URL to sign in: ${loginUrl}`)\n\n      try {\n        openBrowser(loginUrl)\n        onStatus(\"Browser opened. Waiting for login confirmation...\")\n      } catch (error) {\n        onStatus((error as Error).message)\n        onStatus(\"Waiting for login confirmation...\")\n      }\n\n      timer = setTimeout(() => {\n        settle(() => {\n          reject(\n            new CLIError(\n              \"TIMEOUT\",\n              \"Timed out waiting for browser login. Please run `folo login` again.\",\n            ),\n          )\n        })\n      }, timeoutMs)\n    })\n  })\n\n  return result\n}\n"
  },
  {
    "path": "apps/cli/src/cli.e2e.test.ts",
    "content": "import type { ExecFileException } from \"node:child_process\"\nimport { execFile } from \"node:child_process\"\nimport { mkdtempSync } from \"node:fs\"\nimport { tmpdir } from \"node:os\"\nimport { promisify } from \"node:util\"\n\nimport { resolve } from \"pathe\"\nimport { describe, expect, it } from \"vitest\"\n\nconst execFileAsync = promisify(execFile)\nconst cliPath = resolve(process.cwd(), \"dist/index.js\")\nconst testToken = process.env.FOLO_TEST_TOKEN\nconst isolatedHome = mkdtempSync(resolve(tmpdir(), \"folocli-test-\"))\n\ntype CLIExecution = {\n  code: number\n  stdout: string\n  stderr: string\n}\n\nconst runCLI = async (args: string[]): Promise<CLIExecution> => {\n  try {\n    const { stdout, stderr } = await execFileAsync(\"node\", [cliPath, ...args], {\n      env: {\n        ...process.env,\n        HOME: isolatedHome,\n        USERPROFILE: isolatedHome,\n        FOLO_TOKEN: \"\",\n      },\n    })\n\n    return {\n      code: 0,\n      stdout,\n      stderr,\n    }\n  } catch (error) {\n    const execError = error as ExecFileException & {\n      stdout?: string\n      stderr?: string\n    }\n\n    return {\n      code: typeof execError.code === \"number\" ? execError.code : 1,\n      stdout: execError.stdout ?? \"\",\n      stderr: execError.stderr ?? \"\",\n    }\n  }\n}\n\ndescribe(\"cli e2e\", () => {\n  it(\"returns structured unauthorized error without token\", async () => {\n    const result = await runCLI([\"timeline\", \"--limit\", \"1\"])\n    expect(result.code).not.toBe(0)\n\n    const payload = JSON.parse(result.stderr) as {\n      ok: boolean\n      data: null\n      error: { code: string; message: string }\n    }\n    expect(payload.ok).toBe(false)\n    expect(payload.data).toBeNull()\n    expect(payload.error.code).toBe(\"UNAUTHORIZED\")\n  })\n\n  it.runIf(Boolean(testToken))(\"can fetch session with test token\", async () => {\n    const result = await runCLI([\"--token\", testToken!, \"whoami\"])\n    expect(result.code).toBe(0)\n\n    const payload = JSON.parse(result.stdout) as {\n      ok: boolean\n      data: {\n        user: { id: string }\n        session: { id: string }\n      }\n      error: null\n    }\n    expect(payload.ok).toBe(true)\n    expect(payload.error).toBeNull()\n    expect(typeof payload.data.user.id).toBe(\"string\")\n    expect(typeof payload.data.session.id).toBe(\"string\")\n  })\n\n  it.runIf(Boolean(testToken))(\"can fetch timeline with test token\", async () => {\n    const result = await runCLI([\"--token\", testToken!, \"timeline\", \"--limit\", \"1\"])\n    expect(result.code).toBe(0)\n\n    const payload = JSON.parse(result.stdout) as {\n      ok: boolean\n      data: {\n        entries: unknown[]\n        nextCursor: string | null\n        hasNext: boolean\n      }\n      error: null\n    }\n    expect(payload.ok).toBe(true)\n    expect(Array.isArray(payload.data.entries)).toBe(true)\n    expect(typeof payload.data.hasNext).toBe(\"boolean\")\n  })\n})\n"
  },
  {
    "path": "apps/cli/src/client.ts",
    "content": "import { FollowClient } from \"@follow-app/client-sdk\"\nimport type { Command } from \"commander\"\n\nimport type { FoloCLIConfig } from \"./config\"\nimport { readConfig } from \"./config\"\nimport type { OutputFormat } from \"./output\"\nimport { CLIError } from \"./output\"\n\nexport const defaultApiURL = \"https://api.folo.is\"\n\nconst readString = (value: unknown): string | undefined => {\n  return typeof value === \"string\" && value.length > 0 ? value : undefined\n}\n\nconst normalizeToken = (token: string | undefined) => {\n  if (!token || !token.includes(\"%\")) {\n    return token\n  }\n\n  try {\n    return decodeURIComponent(token)\n  } catch {\n    return token\n  }\n}\n\nexport interface GlobalOptions {\n  format: OutputFormat\n  apiUrl?: string\n  token?: string\n  verbose: boolean\n}\n\nexport interface ResolvedGlobalOptions extends GlobalOptions {\n  apiUrl: string\n}\n\nexport interface CommandContext {\n  client: FollowClient\n  options: ResolvedGlobalOptions\n  config: FoloCLIConfig\n  token?: string\n}\n\nexport const getGlobalOptions = (command: Command): GlobalOptions => {\n  const options = command.optsWithGlobals() as Record<string, unknown>\n\n  return {\n    format:\n      options.format === \"table\" || options.format === \"plain\" || options.format === \"json\"\n        ? options.format\n        : \"json\",\n    apiUrl: readString(options.apiUrl),\n    token: readString(options.token),\n    verbose: Boolean(options.verbose),\n  }\n}\n\nconst setupVerboseLogging = (client: FollowClient) => {\n  client.addRequestInterceptor((ctx) => {\n    const method = ctx.options.method || \"GET\"\n    console.error(`[request] ${method} ${ctx.url}`)\n    return ctx\n  })\n\n  client.addResponseInterceptor((ctx) => {\n    const method = ctx.options.method || \"GET\"\n    console.error(`[response] ${method} ${ctx.url} -> ${ctx.response.status}`)\n    return ctx.response\n  })\n}\n\nexport const createCommandContext = async (\n  command: Command,\n  requireAuth = true,\n): Promise<CommandContext> => {\n  const globalOptions = getGlobalOptions(command)\n  const config = await readConfig()\n  const token = normalizeToken(globalOptions.token ?? process.env.FOLO_TOKEN ?? config.token)\n  const apiUrl = globalOptions.apiUrl ?? config.apiUrl ?? defaultApiURL\n\n  if (requireAuth && !token) {\n    throw new CLIError(\n      \"UNAUTHORIZED\",\n      \"Missing token. Run `folo login` (browser sign-in) or set FOLO_TOKEN.\",\n    )\n  }\n\n  const client = new FollowClient({\n    baseURL: apiUrl,\n  })\n\n  if (token) {\n    client.setAuthToken(token)\n    client.setHeaders({\n      Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,\n    })\n  }\n\n  if (globalOptions.verbose) {\n    setupVerboseLogging(client)\n  }\n\n  return {\n    client,\n    token,\n    config,\n    options: {\n      ...globalOptions,\n      apiUrl,\n    },\n  }\n}\n"
  },
  {
    "path": "apps/cli/src/command.ts",
    "content": "import type { Command } from \"commander\"\n\nimport type { CommandContext } from \"./client\"\nimport { createCommandContext, getGlobalOptions } from \"./client\"\nimport { normalizeError, printFailure, printSuccess } from \"./output\"\n\ninterface RunCommandOptions {\n  requireAuth?: boolean\n}\n\nexport const runCommand = async (\n  command: Command,\n  handler: (context: CommandContext) => Promise<unknown>,\n  options: RunCommandOptions = {},\n) => {\n  const fallbackOptions = getGlobalOptions(command)\n  const requireAuth = options.requireAuth ?? true\n\n  let context: CommandContext | null = null\n  try {\n    context = await createCommandContext(command, requireAuth)\n    const result = await handler(context)\n    printSuccess(context.options.format, result)\n  } catch (error) {\n    const format = context?.options.format ?? fallbackOptions.format\n    const verbose = context?.options.verbose ?? fallbackOptions.verbose\n    printFailure(format, normalizeError(error, verbose))\n    process.exitCode = 1\n  }\n}\n"
  },
  {
    "path": "apps/cli/src/commands/auth.ts",
    "content": "import type { Command } from \"commander\"\n\nimport { parsePositiveInt } from \"../args\"\nimport { loginWithBrowser } from \"../browser-login\"\nimport { getGlobalOptions } from \"../client\"\nimport { runCommand } from \"../command\"\nimport { clearToken, getConfigPath, updateConfig } from \"../config\"\nimport { CLIError } from \"../output\"\n\ninterface AuthLoginOptions {\n  token?: string\n  timeout?: number\n}\n\nconst runLoginAction = async function (this: Command, options: AuthLoginOptions) {\n  await runCommand(\n    this,\n    async ({ client, options: globalOptions }) => {\n      let token = options.token ?? getGlobalOptions(this).token\n      if (!token) {\n        const timeoutMs = (options.timeout ?? 180) * 1000\n        const browserLogin = await loginWithBrowser({\n          apiUrl: globalOptions.apiUrl,\n          timeoutMs,\n          onStatus: (message) => {\n            console.error(`[auth] ${message}`)\n          },\n        })\n        token = browserLogin.token\n      }\n\n      client.setAuthToken(token)\n      const session = await client.api.auth.getSession()\n\n      if (!session.user || !session.session) {\n        throw new CLIError(\"UNAUTHORIZED\", \"Token is invalid or expired.\")\n      }\n\n      await updateConfig({\n        token,\n        apiUrl: globalOptions.apiUrl,\n      })\n\n      return {\n        message: \"Login successful.\",\n        configPath: getConfigPath(),\n        user: session.user,\n      }\n    },\n    { requireAuth: false },\n  )\n}\n\nconst runLogoutAction = async function (this: Command) {\n  await runCommand(\n    this,\n    async () => {\n      await clearToken()\n      return {\n        message: \"Logged out.\",\n        configPath: getConfigPath(),\n      }\n    },\n    { requireAuth: false },\n  )\n}\n\nconst runWhoamiAction = async function (this: Command) {\n  await runCommand(this, async ({ client }) => {\n    const session = await client.api.auth.getSession()\n    if (!session.user || !session.session) {\n      throw new CLIError(\"UNAUTHORIZED\", \"Token is invalid or expired.\")\n    }\n\n    return {\n      user: session.user,\n      session: session.session,\n      role: session.role,\n      roleEndAt: session.roleEndAt ?? null,\n      feedSubscriptionLimit: session.feedSubscriptionLimit,\n      rsshubSubscriptionLimit: session.rsshubSubscriptionLimit,\n    }\n  })\n}\n\nconst registerLoginCommand = (program: Command, name: string, description: string) => {\n  program\n    .command(name)\n    .description(description)\n    .option(\"--token <token>\", \"Session token from Folo\")\n    .option(\n      \"--timeout <seconds>\",\n      \"Browser login timeout in seconds (default: 180)\",\n      parsePositiveInt,\n    )\n    .action(runLoginAction)\n}\n\nconst registerLogoutCommand = (program: Command, name: string, description: string) => {\n  program.command(name).description(description).action(runLogoutAction)\n}\n\nconst registerWhoamiCommand = (program: Command, name: string, description: string) => {\n  program.command(name).description(description).action(runWhoamiAction)\n}\n\nexport const registerAuthCommand = (program: Command) => {\n  const authCommand = program.command(\"auth\").description(\"Authentication commands\")\n\n  registerLoginCommand(\n    authCommand,\n    \"login\",\n    \"Sign in via browser (or save a provided token) and verify authentication\",\n  )\n  registerLogoutCommand(authCommand, \"logout\", \"Clear stored token\")\n  registerWhoamiCommand(authCommand, \"whoami\", \"Show current session user\")\n\n  registerLoginCommand(program, \"login\", \"Sign in and store a CLI session\")\n  registerLogoutCommand(program, \"logout\", \"Clear the stored CLI session\")\n  registerWhoamiCommand(program, \"whoami\", \"Show the current CLI session user\")\n}\n"
  },
  {
    "path": "apps/cli/src/commands/collection.ts",
    "content": "import type { EntryListRequest } from \"@follow-app/client-sdk\"\nimport type { Command } from \"commander\"\n\nimport { parseISODate, parsePositiveInt, parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\n\ninterface CollectionListOptions {\n  limit: number\n  cursor?: string\n}\n\ninterface CollectionAddOptions {\n  view?: number\n}\n\nexport const registerCollectionCommand = (program: Command) => {\n  const collectionCommand = program.command(\"collection\").description(\"Manage collections\")\n\n  collectionCommand\n    .command(\"list\")\n    .description(\"List collected entries\")\n    .option(\"--limit <n>\", \"Number of entries to fetch\", parsePositiveInt, 20)\n    .option(\"--cursor <datetime>\", \"Pagination cursor\", parseISODate)\n    .action(async function (this: Command, options: CollectionListOptions) {\n      await runCommand(this, async ({ client }) => {\n        const request: EntryListRequest = {\n          isCollection: true,\n          limit: options.limit,\n          publishedAfter: options.cursor,\n        }\n\n        const response = await client.api.entries.list(request)\n        const entries = response.data\n        const nextCursor = entries.at(-1)?.entries.publishedAt ?? null\n\n        return {\n          entries,\n          nextCursor,\n          hasNext: Boolean(nextCursor) && entries.length >= options.limit,\n        }\n      })\n    })\n\n  collectionCommand\n    .command(\"add\")\n    .description(\"Add entry to collection\")\n    .argument(\"<entryId>\", \"Entry ID\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .action(async function (this: Command, entryId: string, options: CollectionAddOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.collections.post({\n          entryId,\n          view: options.view,\n        })\n        return response.data\n      })\n    })\n\n  collectionCommand\n    .command(\"remove\")\n    .description(\"Remove entry from collection\")\n    .argument(\"<entryId>\", \"Entry ID\")\n    .action(async function (this: Command, entryId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.collections.delete({\n          entryId,\n        })\n        return response.data\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/entry.ts",
    "content": "import type { MarkAllAsReadRequest } from \"@follow-app/client-sdk\"\nimport type { Command } from \"commander\"\n\nimport { parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\nimport { CLIError } from \"../output\"\n\ninterface MarkAllReadOptions {\n  feed?: string\n  list?: string\n  view?: number\n}\n\nexport const registerEntryCommand = (program: Command) => {\n  const entryCommand = program.command(\"entry\").description(\"Read and update entries\")\n\n  entryCommand\n    .command(\"get\")\n    .description(\"Get entry detail\")\n    .argument(\"<entryId>\", \"Entry ID\")\n    .action(async function (this: Command, entryId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.entries.get({ id: entryId })\n        return response.data\n      })\n    })\n\n  entryCommand\n    .command(\"read\")\n    .description(\"Get readability content\")\n    .argument(\"<entryId>\", \"Entry ID\")\n    .action(async function (this: Command, entryId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.entries.readability({ id: entryId })\n        return response.data\n      })\n    })\n\n  entryCommand\n    .command(\"mark-read\")\n    .description(\"Mark entry as read\")\n    .argument(\"<entryId>\", \"Entry ID\")\n    .action(async function (this: Command, entryId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.reads.markAsRead({ entryIds: [entryId] })\n        return response.data\n      })\n    })\n\n  entryCommand\n    .command(\"mark-unread\")\n    .description(\"Mark entry as unread\")\n    .argument(\"<entryId>\", \"Entry ID\")\n    .action(async function (this: Command, entryId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.reads.markAsUnread({ entryId })\n        return response.data\n      })\n    })\n\n  entryCommand\n    .command(\"mark-all-read\")\n    .description(\"Mark entries as read in current scope\")\n    .option(\"--feed <feedId>\", \"Mark all entries in a feed as read\")\n    .option(\"--list <listId>\", \"Mark all entries in a list as read\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .action(async function (this: Command, options: MarkAllReadOptions) {\n      await runCommand(this, async ({ client }) => {\n        if (options.feed && options.list) {\n          throw new CLIError(\"INVALID_ARGUMENT\", \"Use only one of --feed or --list.\")\n        }\n\n        const request: MarkAllAsReadRequest = {\n          feedId: options.feed,\n          listId: options.list,\n          view: options.view,\n        }\n\n        const response = await client.api.reads.markAllAsRead(request)\n        return response.data\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/feed.ts",
    "content": "import type { Command } from \"commander\"\n\nimport { runCommand } from \"../command\"\n\nconst isURL = (value: string) => value.startsWith(\"http://\") || value.startsWith(\"https://\")\n\nexport const registerFeedCommand = (program: Command) => {\n  const feedCommand = program.command(\"feed\").description(\"Manage feed data\")\n\n  feedCommand\n    .command(\"get\")\n    .description(\"Get feed detail by feed ID or URL\")\n    .argument(\"<feedIdOrUrl>\", \"Feed ID or URL\")\n    .action(async function (this: Command, feedIdOrUrl: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.feeds.get(\n          isURL(feedIdOrUrl) ? { url: feedIdOrUrl } : { id: feedIdOrUrl },\n        )\n        return response.data\n      })\n    })\n\n  feedCommand\n    .command(\"refresh\")\n    .description(\"Refresh feed\")\n    .argument(\"<feedId>\", \"Feed ID\")\n    .action(async function (this: Command, feedId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.feeds.refresh({ id: feedId })\n        return response.data\n      })\n    })\n\n  feedCommand\n    .command(\"analytics\")\n    .description(\"Get feed analytics\")\n    .argument(\"<feedId>\", \"Feed ID\")\n    .action(async function (this: Command, feedId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.feeds.analytics({ id: [feedId] })\n        return response.data\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/list.ts",
    "content": "import type { Command } from \"commander\"\n\nimport { parseNonNegativeInt, parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\nimport { CLIError } from \"../output\"\n\ninterface ListCreateOptions {\n  title: string\n  description?: string\n  view?: number\n  fee?: number\n  image?: string\n}\n\ninterface ListUpdateOptions {\n  title?: string\n  description?: string\n  view?: number\n  fee?: number\n  image?: string\n}\n\ninterface ListFeedOptions {\n  feed: string\n}\n\nexport const registerListCommand = (program: Command) => {\n  const listCommand = program.command(\"list\").description(\"Manage lists\")\n\n  listCommand\n    .command(\"ls\")\n    .description(\"List my lists\")\n    .action(async function (this: Command) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.lists.list({})\n        return response.data\n      })\n    })\n\n  listCommand\n    .command(\"get\")\n    .description(\"Get list detail\")\n    .argument(\"<listId>\", \"List ID\")\n    .action(async function (this: Command, listId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.lists.get({ listId })\n        return response.data\n      })\n    })\n\n  listCommand\n    .command(\"create\")\n    .description(\"Create a list\")\n    .requiredOption(\"--title <title>\", \"List title\")\n    .option(\"--description <description>\", \"List description\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--fee <amount>\", \"List fee\", parseNonNegativeInt, 0)\n    .option(\"--image <url>\", \"List image URL\")\n    .action(async function (this: Command, options: ListCreateOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.lists.create({\n          title: options.title,\n          description: options.description ?? null,\n          view: options.view ?? 0,\n          fee: options.fee ?? 0,\n          image: options.image ?? null,\n        })\n\n        return response.data\n      })\n    })\n\n  listCommand\n    .command(\"update\")\n    .description(\"Update a list\")\n    .argument(\"<listId>\", \"List ID\")\n    .option(\"--title <title>\", \"List title\")\n    .option(\"--description <description>\", \"List description\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--fee <amount>\", \"List fee\", parseNonNegativeInt)\n    .option(\"--image <url>\", \"List image URL\")\n    .action(async function (this: Command, listId: string, options: ListUpdateOptions) {\n      await runCommand(this, async ({ client }) => {\n        if (\n          options.title === undefined &&\n          options.description === undefined &&\n          options.view === undefined &&\n          options.fee === undefined &&\n          options.image === undefined\n        ) {\n          throw new CLIError(\n            \"INVALID_ARGUMENT\",\n            \"No update fields provided. Use at least one of --title, --description, --view, --fee, --image.\",\n          )\n        }\n\n        const response = await client.api.lists.update({\n          listId,\n          title: options.title,\n          description: options.description,\n          view: options.view,\n          fee: options.fee,\n          image: options.image,\n        })\n\n        return response.data\n      })\n    })\n\n  listCommand\n    .command(\"delete\")\n    .description(\"Delete a list\")\n    .argument(\"<listId>\", \"List ID\")\n    .action(async function (this: Command, listId: string) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.lists.delete({ listId })\n        return response.data\n      })\n    })\n\n  listCommand\n    .command(\"add-feed\")\n    .description(\"Add feed to a list\")\n    .argument(\"<listId>\", \"List ID\")\n    .requiredOption(\"--feed <feedId>\", \"Feed ID\")\n    .action(async function (this: Command, listId: string, options: ListFeedOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.lists.addFeeds({\n          listId,\n          feedId: options.feed,\n        })\n        return response.data\n      })\n    })\n\n  listCommand\n    .command(\"remove-feed\")\n    .description(\"Remove feed from a list\")\n    .argument(\"<listId>\", \"List ID\")\n    .requiredOption(\"--feed <feedId>\", \"Feed ID\")\n    .action(async function (this: Command, listId: string, options: ListFeedOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.lists.removeFeed({\n          listId,\n          feedId: options.feed,\n        })\n        return response.data\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/opml.ts",
    "content": "import { mkdir, readFile, writeFile } from \"node:fs/promises\"\n\nimport type { Command } from \"commander\"\nimport { basename, dirname } from \"pathe\"\n\nimport { runCommand } from \"../command\"\n\ninterface OpmlExportOptions {\n  output?: string\n}\n\ninterface OpmlImportOptions {\n  items?: string\n}\n\nconst parseItems = (value?: string): string[] => {\n  if (!value) {\n    return []\n  }\n\n  return value\n    .split(\",\")\n    .map((item) => item.trim())\n    .filter((item) => item.length > 0)\n}\n\nexport const registerOPMLCommand = (program: Command) => {\n  const opmlCommand = program.command(\"opml\").description(\"Import and export OPML\")\n\n  opmlCommand\n    .command(\"export\")\n    .description(\"Export subscriptions as OPML\")\n    .option(\"--output <file>\", \"Write exported OPML to file\")\n    .action(async function (this: Command, options: OpmlExportOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.subscriptions.export({\n          format: \"opml\",\n        })\n\n        if (!options.output) {\n          return response\n        }\n\n        await mkdir(dirname(options.output), { recursive: true })\n        await writeFile(options.output, response.content, \"utf8\")\n\n        return {\n          output: options.output,\n          filename: response.filename,\n          contentType: response.contentType,\n          bytes: Buffer.byteLength(response.content),\n        }\n      })\n    })\n\n  opmlCommand\n    .command(\"import\")\n    .description(\"Import subscriptions from OPML file\")\n    .argument(\"<file>\", \"Path to OPML or XML file\")\n    .option(\"--items <urls>\", \"Comma-separated feed URLs to import from parsed file\")\n    .action(async function (this: Command, filePath: string, options: OpmlImportOptions) {\n      await runCommand(this, async ({ client }) => {\n        const fileBuffer = await readFile(filePath)\n        const fileName = basename(filePath)\n        const formData = new FormData()\n\n        formData.append(\n          \"file\",\n          new Blob([fileBuffer], {\n            type: \"application/octet-stream\",\n          }),\n          fileName,\n        )\n\n        const items = parseItems(options.items)\n        if (items.length > 0) {\n          formData.append(\"items\", JSON.stringify(items))\n        }\n\n        const response = await client.api.subscriptions.import(formData)\n        return response.data\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/search.ts",
    "content": "import type { Command } from \"commander\"\n\nimport { parsePositiveInt, parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\n\ntype DiscoverTarget = \"feeds\" | \"lists\"\ntype TrendingRange = \"1d\" | \"3d\" | \"7d\" | \"30d\"\ntype TrendingLanguage = \"eng\" | \"cmn\"\n\nconst parseDiscoverTarget = (value: string): DiscoverTarget => {\n  if (value === \"feeds\" || value === \"lists\") {\n    return value\n  }\n  throw new Error(`Invalid discover type \"${value}\". Use feeds or lists.`)\n}\n\nconst parseTrendingRange = (value: string): TrendingRange => {\n  if (value === \"1d\" || value === \"3d\" || value === \"7d\" || value === \"30d\") {\n    return value\n  }\n  throw new Error(`Invalid range \"${value}\". Use one of 1d, 3d, 7d, 30d.`)\n}\n\nconst parseTrendingLanguage = (value: string): TrendingLanguage => {\n  if (value === \"eng\" || value === \"cmn\") {\n    return value\n  }\n  throw new Error(`Invalid language \"${value}\". Use eng or cmn.`)\n}\n\ninterface SearchDiscoverOptions {\n  type?: DiscoverTarget\n}\n\ninterface SearchRsshubOptions {\n  lang?: string\n}\n\ninterface SearchTrendingOptions {\n  category?: string\n  range?: TrendingRange\n  view?: number\n  limit?: number\n  language?: TrendingLanguage\n}\n\nexport const registerSearchCommand = (program: Command) => {\n  const searchCommand = program.command(\"search\").description(\"Discover feeds and lists\")\n\n  searchCommand\n    .command(\"discover\")\n    .description(\"Discover feeds or lists\")\n    .argument(\"<keyword>\", \"Search keyword\")\n    .option(\"--type <type>\", \"Discover target: feeds | lists\", parseDiscoverTarget)\n    .action(async function (this: Command, keyword: string, options: SearchDiscoverOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.discover.discover({\n          keyword,\n          target: options.type,\n        })\n        return response.data\n      })\n    })\n\n  searchCommand\n    .command(\"rsshub\")\n    .description(\"Search RSSHub routes by category keyword\")\n    .argument(\"<keyword>\", \"Category keyword\")\n    .option(\"--lang <lang>\", \"Language tag\")\n    .action(async function (this: Command, keyword: string, options: SearchRsshubOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.discover.rsshub({\n          categories: keyword,\n          lang: options.lang,\n        })\n        return response.data\n      })\n    })\n\n  searchCommand\n    .command(\"trending\")\n    .description(\"Get trending feeds\")\n    .option(\"--category <category>\", \"Filter by category keyword in title/description\")\n    .option(\"--range <range>\", \"Trending range: 1d | 3d | 7d | 30d\", parseTrendingRange, \"7d\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--limit <n>\", \"Result limit\", parsePositiveInt, 20)\n    .option(\"--language <lang>\", \"Language: eng | cmn\", parseTrendingLanguage)\n    .action(async function (this: Command, options: SearchTrendingOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.trending.getFeeds({\n          range: options.range,\n          view: options.view,\n          limit: options.limit,\n          language: options.language,\n        })\n\n        const keyword = options.category?.trim().toLowerCase()\n        const feeds = keyword\n          ? response.data.filter((item) => {\n              const title = item.feed.title?.toLowerCase() || \"\"\n              const description = item.feed.description?.toLowerCase() || \"\"\n              return title.includes(keyword) || description.includes(keyword)\n            })\n          : response.data\n\n        return {\n          feeds,\n        }\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/subscription.ts",
    "content": "import type {\n  SubscriptionCreateRequest,\n  SubscriptionDeleteRequest,\n  SubscriptionUpdateRequest,\n} from \"@follow-app/client-sdk\"\nimport type { Command } from \"commander\"\n\nimport { parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\nimport { CLIError } from \"../output\"\n\ntype SubscriptionTarget = \"feed\" | \"list\" | \"url\"\ntype UpdateTarget = \"feed\" | \"list\"\n\nconst parseSubscriptionTarget = (value: string): SubscriptionTarget => {\n  if (value === \"feed\" || value === \"list\" || value === \"url\") {\n    return value\n  }\n  throw new Error(`Invalid target \"${value}\". Use feed, list, or url.`)\n}\n\nconst parseUpdateTarget = (value: string): UpdateTarget => {\n  if (value === \"feed\" || value === \"list\") {\n    return value\n  }\n  throw new Error(`Invalid target \"${value}\". Use feed or list.`)\n}\n\ninterface SubscriptionListOptions {\n  view?: number\n  category?: string\n}\n\ninterface SubscriptionAddOptions {\n  feed?: string\n  list?: string\n  category?: string\n  view?: number\n  private?: boolean\n  title?: string\n}\n\ninterface SubscriptionRemoveOptions {\n  target: SubscriptionTarget\n}\n\ninterface SubscriptionUpdateOptions {\n  category?: string\n  title?: string\n  view?: number\n  private?: boolean\n  public?: boolean\n  target: UpdateTarget\n}\n\nexport const registerSubscriptionCommand = (program: Command) => {\n  const subscriptionCommand = program.command(\"subscription\").description(\"Manage subscriptions\")\n\n  subscriptionCommand\n    .command(\"list\")\n    .description(\"List subscriptions\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--category <name>\", \"Filter by category\")\n    .action(async function (this: Command, options: SubscriptionListOptions) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.subscriptions.get(\n          options.view !== undefined ? { view: options.view } : {},\n        )\n\n        const subscriptions = options.category\n          ? response.data.filter((item) => item.category === options.category)\n          : response.data\n\n        return {\n          subscriptions,\n        }\n      })\n    })\n\n  subscriptionCommand\n    .command(\"add\")\n    .description(\"Add a feed or list subscription\")\n    .option(\"--feed <url>\", \"Feed URL to subscribe\")\n    .option(\"--list <listId>\", \"List ID to subscribe\")\n    .option(\"--category <name>\", \"Subscription category\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--private\", \"Mark subscription as private\", false)\n    .option(\"--title <title>\", \"Custom subscription title\")\n    .action(async function (this: Command, options: SubscriptionAddOptions) {\n      await runCommand(this, async ({ client }) => {\n        const selected = [options.feed, options.list].filter(Boolean)\n        if (selected.length !== 1) {\n          throw new CLIError(\"INVALID_ARGUMENT\", \"Use either --feed or --list when adding.\")\n        }\n\n        const request: SubscriptionCreateRequest = {\n          view: options.view ?? 0,\n          category: options.category ?? null,\n          isPrivate: options.private || false,\n          title: options.title ?? null,\n        }\n\n        if (options.feed) {\n          request.url = options.feed\n          request.type = \"feed\"\n        }\n\n        if (options.list) {\n          request.listId = options.list\n          request.type = \"list\"\n        }\n\n        const response = await client.api.subscriptions.create(request)\n        return {\n          feed: response.feed,\n          list: response.list,\n          unread: response.unread,\n        }\n      })\n    })\n\n  subscriptionCommand\n    .command(\"remove\")\n    .description(\"Remove a subscription target\")\n    .argument(\"<id>\", \"Feed ID, list ID, or feed URL\")\n    .option(\n      \"--target <target>\",\n      \"Subscription target type: feed | list | url\",\n      parseSubscriptionTarget,\n      \"feed\",\n    )\n    .action(async function (this: Command, id: string, options: SubscriptionRemoveOptions) {\n      await runCommand(this, async ({ client }) => {\n        const request: SubscriptionDeleteRequest = {}\n\n        if (options.target === \"feed\") {\n          request.feedId = id\n        } else if (options.target === \"list\") {\n          request.listId = id\n        } else {\n          request.url = id\n        }\n\n        const response = await client.api.subscriptions.delete(request)\n        return response.data\n      })\n    })\n\n  subscriptionCommand\n    .command(\"update\")\n    .description(\"Update a subscription target\")\n    .argument(\"<id>\", \"Feed ID or list ID\")\n    .option(\"--category <name>\", \"Set category\")\n    .option(\"--title <title>\", \"Set custom title\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--private\", \"Set subscription private\", false)\n    .option(\"--public\", \"Set subscription public\", false)\n    .option(\"--target <target>\", \"Target type: feed | list\", parseUpdateTarget, \"feed\")\n    .action(async function (this: Command, id: string, options: SubscriptionUpdateOptions) {\n      await runCommand(this, async ({ client }) => {\n        if (options.private && options.public) {\n          throw new CLIError(\n            \"INVALID_ARGUMENT\",\n            \"Use only one of --private or --public when updating.\",\n          )\n        }\n\n        const request: SubscriptionUpdateRequest = {\n          category: options.category ?? undefined,\n          title: options.title ?? undefined,\n          view: options.view,\n        }\n\n        if (options.private) {\n          request.isPrivate = true\n        } else if (options.public) {\n          request.isPrivate = false\n        }\n\n        if (options.target === \"feed\") {\n          request.feedId = id\n        } else {\n          request.listId = id\n        }\n\n        if (\n          request.category === undefined &&\n          request.title === undefined &&\n          request.view === undefined &&\n          request.isPrivate === undefined\n        ) {\n          throw new CLIError(\n            \"INVALID_ARGUMENT\",\n            \"No update fields provided. Use at least one of --category, --title, --view, --private, --public.\",\n          )\n        }\n\n        const response = await client.api.subscriptions.update(request)\n        return response.data\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/timeline.ts",
    "content": "import type { EntryListRequest } from \"@follow-app/client-sdk\"\nimport type { Command } from \"commander\"\n\nimport { parseISODate, parsePositiveInt, parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\nimport { CLIError } from \"../output\"\n\ntype TimelineQuery = EntryListRequest & {\n  listId?: string\n}\n\ninterface TimelineOptions {\n  view?: number\n  limit: number\n  unreadOnly?: boolean\n  cursor?: string\n  feed?: string\n  list?: string\n  category?: string\n}\n\nexport const registerTimelineCommand = (program: Command) => {\n  program\n    .command(\"timeline\")\n    .description(\"List timeline entries\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .option(\"--limit <n>\", \"Number of entries to fetch\", parsePositiveInt, 20)\n    .option(\"--unread-only\", \"Only unread entries\", false)\n    .option(\"--cursor <datetime>\", \"Pagination cursor (publishedAfter)\", parseISODate)\n    .option(\"--feed <feedId>\", \"Filter timeline by feed\")\n    .option(\"--list <listId>\", \"Filter timeline by list\")\n    .option(\"--category <name>\", \"Filter timeline by subscription category\")\n    .action(async function (this: Command, options: TimelineOptions) {\n      await runCommand(this, async ({ client }) => {\n        const scopedFilters = [options.feed, options.list, options.category].filter(Boolean)\n        if (scopedFilters.length > 1) {\n          throw new CLIError(\n            \"INVALID_ARGUMENT\",\n            \"Use only one of --feed, --list, or --category at the same time.\",\n          )\n        }\n\n        const query: TimelineQuery = {\n          limit: options.limit,\n          read: options.unreadOnly ? false : undefined,\n          view: options.view,\n          publishedAfter: options.cursor,\n        }\n\n        if (options.feed) {\n          query.feedId = options.feed\n        }\n\n        if (options.list) {\n          query.listId = options.list\n        }\n\n        if (options.category) {\n          const subscriptions = await client.api.subscriptions.get(\n            options.view !== undefined ? { view: options.view } : {},\n          )\n\n          const feedIdList = subscriptions.data\n            .filter((item) => item.category === options.category)\n            .map((item) => item.feedId)\n            .filter((item): item is string => item.length > 0)\n\n          if (feedIdList.length === 0) {\n            return {\n              entries: [],\n              nextCursor: null,\n              hasNext: false,\n            }\n          }\n\n          query.feedIdList = feedIdList\n        }\n\n        const response = await client.api.entries.list(query)\n        const entries = response.data\n        const nextCursor = entries.at(-1)?.entries.publishedAt ?? null\n\n        return {\n          entries,\n          nextCursor,\n          hasNext: Boolean(nextCursor) && entries.length >= options.limit,\n        }\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/commands/unread.ts",
    "content": "import type {\n  InboxSubscriptionResponse,\n  ListSubscriptionResponse,\n  SubscriptionWithFeed,\n} from \"@follow-app/client-sdk\"\nimport type { Command } from \"commander\"\n\nimport { parseView, viewHelp } from \"../args\"\nimport { runCommand } from \"../command\"\n\ninterface UnreadListOptions {\n  view?: number\n}\n\nconst isInboxSubscription = (\n  value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse,\n): value is InboxSubscriptionResponse => {\n  return \"inboxes\" in value\n}\n\nconst isListSubscription = (\n  value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse,\n): value is ListSubscriptionResponse => {\n  return \"lists\" in value\n}\n\nconst resolveTitle = (\n  value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse,\n): string | null => {\n  if (value.title) {\n    return value.title\n  }\n  if (\"feeds\" in value) {\n    return value.feeds.title ?? null\n  }\n  if (\"lists\" in value) {\n    return value.lists.title ?? null\n  }\n  if (\"inboxes\" in value) {\n    return value.inboxes.title ?? null\n  }\n  return null\n}\n\nexport const registerUnreadCommand = (program: Command) => {\n  const unreadCommand = program.command(\"unread\").description(\"Unread status commands\")\n\n  unreadCommand\n    .command(\"count\")\n    .description(\"Get total unread count\")\n    .action(async function (this: Command) {\n      await runCommand(this, async ({ client }) => {\n        const response = await client.api.reads.getTotalCount()\n        return response.data\n      })\n    })\n\n  unreadCommand\n    .command(\"list\")\n    .description(\"List subscriptions with unread entries\")\n    .option(\"--view <type>\", `View type: ${viewHelp}`, parseView)\n    .action(async function (this: Command, options: UnreadListOptions) {\n      await runCommand(this, async ({ client }) => {\n        const [unreadResponse, subscriptionsResponse] = await Promise.all([\n          client.api.reads.get(options.view !== undefined ? { view: options.view } : {}),\n          client.api.subscriptions.get(options.view !== undefined ? { view: options.view } : {}),\n        ])\n\n        const unreadMap = unreadResponse.data\n        const items = subscriptionsResponse.data\n          .map((subscription) => {\n            const unreadKey = isInboxSubscription(subscription)\n              ? subscription.inboxId\n              : subscription.feedId\n            const unreadCount = unreadMap[unreadKey] ?? 0\n\n            return {\n              sourceType: isInboxSubscription(subscription)\n                ? \"inbox\"\n                : isListSubscription(subscription)\n                  ? \"list\"\n                  : \"feed\",\n              sourceId: isInboxSubscription(subscription)\n                ? subscription.inboxId\n                : isListSubscription(subscription)\n                  ? subscription.listId\n                  : subscription.feedId,\n              feedId: subscription.feedId,\n              title: resolveTitle(subscription),\n              category: subscription.category ?? null,\n              view: subscription.view,\n              unreadCount,\n              isPrivate: subscription.isPrivate,\n            }\n          })\n          .filter((item) => item.unreadCount > 0)\n          .sort((left, right) => right.unreadCount - left.unreadCount)\n\n        return {\n          total: items.reduce((sum, item) => sum + item.unreadCount, 0),\n          items,\n        }\n      })\n    })\n}\n"
  },
  {
    "path": "apps/cli/src/config.ts",
    "content": "import { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { homedir } from \"node:os\"\n\nimport { join } from \"pathe\"\n\nexport interface FoloCLIConfig {\n  token?: string\n  apiUrl?: string\n}\n\nconst configDir = join(homedir(), \".folo\")\nconst configPath = join(configDir, \"config.json\")\n\nconst normalizeConfig = (config: unknown): FoloCLIConfig => {\n  if (!config || typeof config !== \"object\") {\n    return {}\n  }\n\n  const source = config as Record<string, unknown>\n  return {\n    token: typeof source.token === \"string\" ? source.token : undefined,\n    apiUrl: typeof source.apiUrl === \"string\" ? source.apiUrl : undefined,\n  }\n}\n\nexport const getConfigPath = () => configPath\n\nexport const readConfig = async (): Promise<FoloCLIConfig> => {\n  try {\n    const raw = await readFile(configPath, \"utf8\")\n    return normalizeConfig(JSON.parse(raw))\n  } catch (error) {\n    const nodeError = error as NodeJS.ErrnoException\n    if (nodeError.code === \"ENOENT\") {\n      return {}\n    }\n\n    throw error\n  }\n}\n\nconst ensureConfigDir = async () => {\n  await mkdir(configDir, { recursive: true })\n}\n\nexport const writeConfig = async (config: FoloCLIConfig) => {\n  await ensureConfigDir()\n  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\\n`, \"utf8\")\n}\n\nexport const updateConfig = async (patch: Partial<FoloCLIConfig>) => {\n  const current = await readConfig()\n  const next: FoloCLIConfig = {\n    ...current,\n    ...patch,\n  }\n\n  if (!next.token) {\n    delete next.token\n  }\n  if (!next.apiUrl) {\n    delete next.apiUrl\n  }\n\n  await writeConfig(next)\n  return next\n}\n\nexport const clearToken = async () => {\n  const current = await readConfig()\n  if (!current.token) {\n    return\n  }\n\n  delete current.token\n  await writeConfig(current)\n}\n"
  },
  {
    "path": "apps/cli/src/index.ts",
    "content": "import { Command } from \"commander\"\n\nimport packageJSON from \"../package.json\"\nimport { parseFormat } from \"./args\"\nimport { defaultApiURL } from \"./client\"\nimport { registerAuthCommand } from \"./commands/auth\"\nimport { registerCollectionCommand } from \"./commands/collection\"\nimport { registerEntryCommand } from \"./commands/entry\"\nimport { registerFeedCommand } from \"./commands/feed\"\nimport { registerListCommand } from \"./commands/list\"\nimport { registerOPMLCommand } from \"./commands/opml\"\nimport { registerSearchCommand } from \"./commands/search\"\nimport { registerSubscriptionCommand } from \"./commands/subscription\"\nimport { registerTimelineCommand } from \"./commands/timeline\"\nimport { registerUnreadCommand } from \"./commands/unread\"\nimport type { OutputFormat } from \"./output\"\nimport { normalizeError, printFailure } from \"./output\"\n\nconst program = new Command()\n\nprogram\n  .name(\"folo\")\n  .description(\"Folo CLI client for structured automation\")\n  .version(packageJSON.version)\n  .option(\"-f, --format <format>\", \"Output format: json | table | plain\", parseFormat, \"json\")\n  .option(\"--api-url <url>\", `API base URL (default: ${defaultApiURL})`)\n  .option(\"--token <token>\", \"Override stored token\")\n  .option(\"--verbose\", \"Enable verbose request/response logging\", false)\n\nregisterAuthCommand(program)\nregisterTimelineCommand(program)\nregisterSubscriptionCommand(program)\nregisterEntryCommand(program)\nregisterFeedCommand(program)\nregisterListCommand(program)\nregisterSearchCommand(program)\nregisterCollectionCommand(program)\nregisterOPMLCommand(program)\nregisterUnreadCommand(program)\n\nconst resolveRequestedFormat = (argv: string[]): OutputFormat => {\n  for (let index = 0; index < argv.length; index += 1) {\n    const current = argv[index]\n    if ((current === \"--format\" || current === \"-f\") && argv[index + 1]) {\n      try {\n        return parseFormat(argv[index + 1]!)\n      } catch {\n        return \"json\"\n      }\n    }\n  }\n  return \"json\"\n}\n\ntry {\n  await program.parseAsync(process.argv)\n} catch (error) {\n  const format = resolveRequestedFormat(process.argv)\n  printFailure(format, normalizeError(error))\n  process.exitCode = 1\n}\n"
  },
  {
    "path": "apps/cli/src/output.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\"\n\nimport { CLIError, normalizeError, printFailure, printSuccess } from \"./output\"\n\ndescribe(\"output helpers\", () => {\n  beforeEach(() => {\n    vi.restoreAllMocks()\n  })\n\n  afterEach(() => {\n    vi.restoreAllMocks()\n  })\n\n  it(\"prints JSON success envelope\", () => {\n    const infoSpy = vi.spyOn(console, \"info\").mockImplementation(() => {})\n\n    printSuccess(\"json\", { value: 1 })\n\n    expect(infoSpy).toHaveBeenCalledTimes(1)\n    const payload = JSON.parse(infoSpy.mock.calls[0]![0] as string) as {\n      ok: boolean\n      data: { value: number }\n      error: null\n    }\n    expect(payload.ok).toBe(true)\n    expect(payload.data).toEqual({ value: 1 })\n    expect(payload.error).toBeNull()\n  })\n\n  it(\"prints JSON failure envelope\", () => {\n    const errorSpy = vi.spyOn(console, \"error\").mockImplementation(() => {})\n\n    printFailure(\"json\", { code: \"E_TEST\", message: \"boom\" })\n\n    expect(errorSpy).toHaveBeenCalledTimes(1)\n    const payload = JSON.parse(errorSpy.mock.calls[0]![0] as string) as {\n      ok: boolean\n      data: null\n      error: { code: string; message: string }\n    }\n    expect(payload.ok).toBe(false)\n    expect(payload.data).toBeNull()\n    expect(payload.error).toEqual({ code: \"E_TEST\", message: \"boom\" })\n  })\n\n  it(\"prints ascii table in table mode\", () => {\n    const infoSpy = vi.spyOn(console, \"info\").mockImplementation(() => {})\n\n    printSuccess(\"table\", [{ id: \"a\", count: 1 }])\n\n    expect(infoSpy).toHaveBeenCalledTimes(1)\n    const output = infoSpy.mock.calls[0]![0] as string\n    expect(output).toContain(\"| id\")\n    expect(output).toContain(\"| a\")\n  })\n\n  it(\"normalizes CLIError\", () => {\n    const normalized = normalizeError(new CLIError(\"E_CODE\", \"message\"))\n    expect(normalized).toEqual({\n      code: \"E_CODE\",\n      message: \"message\",\n    })\n  })\n\n  it(\"normalizes generic Error\", () => {\n    const normalized = normalizeError(new Error(\"generic\"))\n    expect(normalized).toEqual({\n      code: \"UNKNOWN_ERROR\",\n      message: \"generic\",\n    })\n  })\n})\n"
  },
  {
    "path": "apps/cli/src/output.ts",
    "content": "import { inspect } from \"node:util\"\n\nimport { FollowAPIError, FollowAuthError } from \"@follow-app/client-sdk\"\n\nexport type OutputFormat = \"json\" | \"table\" | \"plain\"\n\nexport interface OutputError {\n  code: string\n  message: string\n}\n\nexport class CLIError extends Error {\n  readonly code: string\n\n  constructor(code: string, message: string) {\n    super(message)\n    this.name = \"CLIError\"\n    this.code = code\n  }\n}\n\nconst stringifyJSON = (value: unknown) => {\n  return JSON.stringify(\n    value,\n    (_key, currentValue) => {\n      if (typeof currentValue === \"bigint\") {\n        return currentValue.toString()\n      }\n      return currentValue\n    },\n    2,\n  )\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => {\n  return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n\nconst toCellValue = (value: unknown): string | number | boolean | null => {\n  if (\n    value === null ||\n    typeof value === \"string\" ||\n    typeof value === \"number\" ||\n    typeof value === \"boolean\"\n  ) {\n    return value as string | number | boolean | null\n  }\n  if (value === undefined) {\n    return \"\"\n  }\n  return stringifyJSON(value)\n}\n\nconst toTableRow = (value: unknown): Record<string, string | number | boolean | null> => {\n  if (!isRecord(value)) {\n    return {\n      value: toCellValue(value),\n    }\n  }\n\n  const row: Record<string, string | number | boolean | null> = {}\n  for (const [key, currentValue] of Object.entries(value)) {\n    row[key] = toCellValue(currentValue)\n  }\n  return row\n}\n\nconst renderAsciiTable = (\n  rows: Array<Record<string, string | number | boolean | null>>,\n): string => {\n  if (rows.length === 0) {\n    return \"(empty)\"\n  }\n\n  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))))\n  const widths = columns.map((column) =>\n    Math.max(column.length, ...rows.map((row) => String(row[column] ?? \"\").length)),\n  )\n\n  const formatLine = (values: string[]) => {\n    return `| ${values.map((value, index) => value.padEnd(widths[index]!)).join(\" | \")} |`\n  }\n\n  const border = `+-${widths.map((width) => \"-\".repeat(width)).join(\"-+-\")}-+`\n  const header = formatLine(columns)\n  const body = rows.map((row) => formatLine(columns.map((column) => String(row[column] ?? \"\"))))\n\n  return [border, header, border, ...body, border].join(\"\\n\")\n}\n\nconst renderTable = (data: unknown) => {\n  const rows = Array.isArray(data) ? data.map(toTableRow) : [toTableRow(data)]\n  console.info(renderAsciiTable(rows))\n}\n\nconst renderPlain = (data: unknown) => {\n  if (Array.isArray(data)) {\n    console.info(data.map((item) => inspect(item, { depth: null, colors: false })).join(\"\\n\"))\n    return\n  }\n\n  if (typeof data === \"string\") {\n    console.info(data)\n    return\n  }\n\n  if (data === null || data === undefined) {\n    console.info(\"\")\n    return\n  }\n\n  console.info(\n    inspect(data, {\n      depth: null,\n      colors: false,\n      compact: false,\n    }),\n  )\n}\n\nexport const printSuccess = (format: OutputFormat, data: unknown) => {\n  if (format === \"json\") {\n    const payload = {\n      ok: true as const,\n      data,\n      error: null,\n    }\n    console.info(stringifyJSON(payload))\n    return\n  }\n\n  if (format === \"table\") {\n    renderTable(data)\n    return\n  }\n\n  renderPlain(data)\n}\n\nexport const printFailure = (format: OutputFormat, error: OutputError) => {\n  if (format === \"json\") {\n    const payload = {\n      ok: false as const,\n      data: null,\n      error,\n    }\n    console.error(stringifyJSON(payload))\n    return\n  }\n\n  console.error(`[${error.code}] ${error.message}`)\n}\n\nconst firstLine = (message: string) => {\n  const [head] = message.split(\"\\n\")\n  return head?.trim() || message\n}\n\nexport const normalizeError = (error: unknown, verbose = false): OutputError => {\n  if (error instanceof CLIError) {\n    return {\n      code: error.code,\n      message: error.message,\n    }\n  }\n\n  if (error instanceof FollowAuthError) {\n    return {\n      code: \"UNAUTHORIZED\",\n      message: verbose ? error.message : firstLine(error.message),\n    }\n  }\n\n  if (error instanceof FollowAPIError) {\n    return {\n      code: error.code ?? `HTTP_${error.status}`,\n      message: verbose ? error.message : firstLine(error.message),\n    }\n  }\n\n  if (error instanceof Error) {\n    return {\n      code: \"UNKNOWN_ERROR\",\n      message: error.message,\n    }\n  }\n\n  return {\n    code: \"UNKNOWN_ERROR\",\n    message: \"An unknown error occurred\",\n  }\n}\n"
  },
  {
    "path": "apps/cli/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2022\"],\n    \"moduleResolution\": \"Bundler\",\n    \"types\": [\"node\"],\n    \"noEmit\": true\n  },\n  \"include\": [\"src/**/*.ts\", \"tsup.config.ts\"]\n}\n"
  },
  {
    "path": "apps/cli/tsup.config.ts",
    "content": "import { defineConfig } from \"tsup\"\n\nexport default defineConfig({\n  entry: [\"src/index.ts\"],\n  format: [\"esm\"],\n  target: \"node18\",\n  outDir: \"dist\",\n  clean: true,\n  sourcemap: false,\n  dts: false,\n  splitting: false,\n  shims: false,\n  banner: {\n    js: \"#!/usr/bin/env node\",\n  },\n})\n"
  },
  {
    "path": "apps/cli/vitest.config.ts",
    "content": "import { defineProject } from \"vitest/config\"\n\nexport default defineProject({\n  test: {\n    environment: \"node\",\n  },\n})\n"
  },
  {
    "path": "apps/desktop/.env.example",
    "content": "VITE_WEB_URL=http://localhost:5173\nVITE_API_URL=http://localhost:3000\nVITE_IMGPROXY_URL=http://localhost:2873\nVITE_SENTRY_DSN=\nVITE_BUILD_TYPE=production\nVITE_INBOXES_EMAIL=@follow.re\n\nVITE_EDITOR=cursor\n\nVITE_PUBLIC_POSTHOG_KEY=\nVITE_PUBLIC_POSTHOG_HOST=\n"
  },
  {
    "path": "apps/desktop/AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides specific guidance for developing the Electron desktop application.\n\n## Architecture\n\n- **Main Process** (`layer/main/`) - Electron main process handling system integration\n- **Renderer Process** (`layer/renderer/`) - Vite + React renderer (primary web app)\n\nThe renderer is the **primary web application** - a Vite + React SPA that can run both in Electron and as a standalone web app.\n\n## UI Style\n\n- **UI Design Style**: Follow Vercel and Linear SaaS UI aesthetics - clean, modern, minimal design with subtle shadows, rounded corners, and excellent typography\n- **Tailwind CSS** for styling across all platforms\n- Platform-specific Tailwind configs in each app\n\n## Development Commands\n\n```bash\n# Recommended: Browser development (faster)\npnpm run dev:web\n\n# Full Electron development\npnpm run dev:electron\n\n# Build web version\npnpm run build:web\n```\n\n## UIKit Colors for Desktop Components\n\nFor desktop components (`apps/desktop/**/*`) and shared UI components (`packages/internal/components/**/*`), use Apple UIKit color system with Tailwind classes. **Important**: Always use the correct Tailwind prefix for each color category:\n\n**System Colors**: `text-red`, `bg-red`, `border-red` (same for `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `gray`)\n\n**Fill Colors**:\n\n- Background: `bg-fill`, `bg-fill-secondary`, `bg-fill-tertiary`, `bg-fill-quaternary`, `bg-fill-quinary`, `bg-fill-vibrant`, `bg-fill-vibrant-secondary`, `bg-fill-vibrant-tertiary`, `bg-fill-vibrant-quaternary`, `bg-fill-vibrant-quinary`\n- Border: `border-fill`, `border-fill-secondary`, etc.\n\n**Text Colors**: `text-text`, `text-text-secondary`, `text-text-tertiary`, `text-text-quaternary`, `text-text-quinary`, `text-text-vibrant`, `text-text-vibrant-secondary`, `text-text-vibrant-tertiary`, `text-text-vibrant-quaternary`, `text-text-vibrant-quinary`\n\n**Material Colors**: `bg-material-ultra-thick`, `bg-material-thick`, `bg-material-medium`, `bg-material-thin`, `bg-material-ultra-thin`, `bg-material-opaque`\n\n**Control Colors**: `bg-control-enabled`, `bg-control-disabled`\n\n**Interface Colors**: `bg-menu`, `bg-popover`, `bg-titlebar`, `bg-sidebar`, `bg-selection-focused`, `bg-selection-focused-fill`, `bg-selection-unfocused`, `bg-selection-unfocused-fill`, `bg-header-view`, `bg-tooltip`, `bg-under-window-background`\n\nThese colors automatically adapt to light/dark mode following Apple's design system. Remember to use the appropriate prefix (`text-`, `bg-`, `border-`) based on the CSS property you're styling.\n\n## Icons\n\nFor icon usage, prioritize the MingCute icon library with the `i-mgc-` prefix. Icons are available in the format `i-mgc-[icon-name]-[style]` where style can be `re` (regular), `fi` (filled), etc.\n\n**Important**: Always try to find an appropriate icon with the `i-mgc-` prefix first. Only use the `i-mingcute-` prefix as a fallback when no suitable `i-mgc-` icon exists.\n\nExamples:\n\n- Preferred: `i-mgc-copy-cute-re`, `i-mgc-external-link-cute-re`\n- Fallback only: `i-mingcute-copy-line` (only if no mgc equivalent exists)\n\n## Using Framer Motion\n\n- **LazyMotion Integration**: Project uses Framer Motion with LazyMotion for optimized bundle size\n- **Usage Rule**: Always use `m.` instead of `motion.` when creating animated components\n- **Import**: `import { m } from 'motion/react'`\n- **Examples**: `m.div`, `m.button`, `m.span` (not `motion.div`, `motion.button`, etc.)\n- **Benefits**: Reduces bundle size while maintaining all Framer Motion functionality\n- **Prefer Spring Presets**: Use predefined spring animations from `@follow/components/constants/spring.js`\n- **Available Presets Constants**: `Spring.presets.smooth`, `Spring.presets.snappy`, `Spring.presets.bouncy` (extracted from Apple's spring parameters)\n- **Usage Example**: `transition={Spring.presets.smooth}` or `transition={Spring.snappy(0.3, 0.1)}`\n- **Customization**: All presets accept optional `duration` and `extraBounce` parameters\n\n## Reusable UI Components\n\nWhen building UI components, follow this hierarchy:\n\n1. **Check Existing Components First**: Look in `apps/desktop/layer/renderer/src/modules/renderer/components` for app-specific components\n2. **Create Reusable Components**: If the component doesn't exist and is **generic/reusable** (not tied to specific app business logic), create it in `packages/internal/components`\n\n### Guidelines for Reusable Components (`packages/internal/components`)\n\n- **Purpose**: Components should be generic and reusable across different apps/contexts\n- **No Business Logic**: Avoid coupling with specific app business logic, APIs, or state management\n- **Follow All Style Rules**: Must adhere to UIKit colors, MingCute icons (`i-mgc-` prefix), and Framer Motion (`m.` prefix) guidelines\n- **Export Pattern**: Export components from appropriate index files for clean imports\n- **TypeScript**: Use proper TypeScript interfaces for props and maintain type safety\n\n**Example Structure**:\n\n```\npackages/internal/components/\n├── ui/           # Basic UI primitives (Button, Input, Modal)\n├── layout/       # Layout components (Grid, Stack, Container)\n├── feedback/     # User feedback (Toast, Loading, Alert)\n└── index.ts      # Main exports\n```\n\n**Import Examples**:\n\n```tsx\n// Correct - from shared components\nimport { Button, Modal } from \"@follow/components\"\n\n// App-specific components stay in name/components\nimport { FeedList } from \"~/modules/name/components\"\n```\n\n## Glassmorphic Depth Design System\n\nFollow uses a sophisticated glassmorphic depth design system for elevated UI components (modals, toasts, floating panels, etc.). This design provides visual hierarchy through layered transparency and subtle color accents.\n\n### Design Principles\n\n- **Multi-layer Depth**: Create visual depth through stacked transparent layers\n- **Subtle Color Accents**: Use brand colors at very low opacity (5-20%) for borders, glows, and backgrounds\n- **Refined Blur**: Heavy backdrop blur (backdrop-blur-2xl) for frosted glass effect\n- **Minimal Shadows**: Combine multiple soft shadows with accent colors for depth perception\n- **Smooth Animations**: Use Spring presets for all transitions\n\n### Color Usage\n\n- **Primary Accent**: Use CSS variable `--fo-a` (HSL: `331.7 84% 67%`) at 5-20% opacity for borders, glows, and highlights\n- **Border**: `hsl(var(--fo-a) / 0.2)` for main borders\n- **Inner Glow**: `hsl(var(--fo-a) / 0.05)` for subtle radial/linear gradients inside containers\n- **Shadows**: Layered shadows with accent tint:\n  - `0 8px 32px hsl(var(--fo-a) / 0.08)` - large soft glow\n  - `0 4px 16px hsl(var(--fo-a) / 0.06)` - medium shadow\n  - `0 2px 8px rgba(0, 0, 0, 0.1)` - close depth\n\n### Component Structure\n\n```tsx\n<div\n  className=\"rounded-2xl backdrop-blur-2xl\"\n  style={{\n    backgroundImage:\n      \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n    borderWidth: \"1px\",\n    borderStyle: \"solid\",\n    borderColor: \"hsl(var(--fo-a) / 0.2)\",\n    boxShadow:\n      \"0 8px 32px hsl(var(--fo-a) / 0.08), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n  }}\n>\n  {/* Inner glow layer */}\n  <div\n    className=\"absolute inset-0 rounded-2xl\"\n    style={{\n      background:\n        \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))\",\n    }}\n  />\n\n  {/* Content */}\n  <div className=\"relative\">{/* Your content here */}</div>\n</div>\n```\n\n### Interactive Elements\n\nFor hover states on buttons or interactive areas within glass containers:\n\n```tsx\n<button\n  onMouseEnter={(e) => {\n    e.currentTarget.style.background =\n      \"linear-gradient(to right, hsl(var(--fo-a) / 0.08), hsl(var(--fo-a) / 0.05))\"\n  }}\n  onMouseLeave={(e) => {\n    e.currentTarget.style.background = \"transparent\"\n  }}\n>\n  {/* Subtle shine effect */}\n  <div className=\"absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray/5 to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5\" />\n</button>\n```\n\n### Dividers\n\nUse gradient dividers within glass containers:\n\n```tsx\n<div\n  className=\"mx-4 h-px\"\n  style={{\n    background: \"linear-gradient(to right, transparent, hsl(var(--fo-a) / 0.2), transparent)\",\n  }}\n/>\n```\n\n### Animation Guidelines\n\n- Entry animations: `initial={{ y: 8, opacity: 0 }}` → `animate={{ y: 0, opacity: 1 }}`\n- Use `Spring.presets.snappy` for quick interactions\n- Use `Spring.presets.smooth` for larger movements\n- Keep scale animations subtle (1.0 ↔ 1.02)\n\n### When to Use\n\nApply this design system to:\n\n- Toast notifications\n- Modal dialogs\n- Floating panels and popovers\n- Ambient UI prompts\n- Contextual menus\n- Elevated cards with actions\n\n### Accessibility\n\n- Ensure sufficient contrast for text over glass backgrounds\n- Maintain border visibility in both light and dark modes\n- Preserve keyboard focus indicators\n- Keep animations respectful of `prefers-reduced-motion`\n\n## Build Outputs\n\n- Desktop: `apps/desktop/out/` for packaged applications\n- Web: `apps/desktop/out/web/` for static web assets\n"
  },
  {
    "path": "apps/desktop/build/appxmanifest-template.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<Package\n   xmlns=\"http://schemas.microsoft.com/appx/manifest/foundation/windows10\"\n   xmlns:uap=\"http://schemas.microsoft.com/appx/manifest/uap/windows10\"\n   xmlns:rescap=\"http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities\"\n   xmlns:mp=\"http://schemas.microsoft.com/appx/2014/phone/manifest\">\n  <Identity Name=\"{identityName}\"\n    ProcessorArchitecture=\"x64\"\n    Publisher=\"{publisherName}\"\n    Version=\"{packageVersion}\" />\n  <Properties>\n    <DisplayName>{packageDisplayName}</DisplayName>\n    <PublisherDisplayName>{publisherDisplayName}</PublisherDisplayName>\n    <Description>No description entered</Description>\n    <Logo>assets\\SampleAppx.50x50.png</Logo>\n  </Properties>\n  <Resources>\n    <Resource Language=\"en-us\" />\n    <Resource Language=\"zh-cn\" />\n    <Resource Language=\"zh-tw\" />\n    <Resource Language=\"ja\" />\n  </Resources>\n  <Dependencies>\n    <TargetDeviceFamily Name=\"Windows.Desktop\" MinVersion=\"10.0.14316.0\" MaxVersionTested=\"10.0.14316.0\" />\n  </Dependencies>\n  <Capabilities>\n    <rescap:Capability Name=\"runFullTrust\"/>\n  </Capabilities>\n  <Applications>\n    <Application Id=\"{packageName}\" Executable=\"{packageExecutable}\" EntryPoint=\"Windows.FullTrustApplication\">\n      <uap:VisualElements\n       BackgroundColor=\"{packageBackgroundColor}\"\n       DisplayName=\"{packageDisplayName}\"\n       Square150x150Logo=\"assets\\SampleAppx.150x150.png\"\n       Square44x44Logo=\"assets\\SampleAppx.44x44.png\"\n       Description=\"{packageDescription}\">\n        <uap:DefaultTile Wide310x150Logo=\"assets\\SampleAppx.310x150.png\" />\n      </uap:VisualElements>\n      <Extensions>{protocol}\n      </Extensions>\n    </Application>\n  </Applications>\n</Package>\n"
  },
  {
    "path": "apps/desktop/build/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n  </dict>\n</plist>\n"
  },
  {
    "path": "apps/desktop/build/entitlements.mas.child.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.app-sandbox</key>\n    <true/>\n    <key>com.apple.security.inherit</key>\n    <true/>\n  </dict>\n</plist>\n"
  },
  {
    "path": "apps/desktop/build/entitlements.mas.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.app-sandbox</key>\n    <true/>\n    <key>com.apple.security.files.user-selected.read-write</key>\n    <true/>\n    <key>com.apple.security.files.bookmarks.app-scope</key>\n    <true/>\n    <key>com.apple.security.network.client</key>\n    <true/>\n  </dict>\n</plist>\n"
  },
  {
    "path": "apps/desktop/bump.config.ts",
    "content": "/* eslint-disable no-template-curly-in-string */\nimport { defineConfig } from \"nbump\"\n\nexport default defineConfig({\n  leading: [\n    \"git pull --rebase\",\n    \"tsx scripts/apply-changelog.ts ${NEW_VERSION}\",\n    \"git add changelog\",\n    \"tsx plugins/vite/generate-main-hash.ts\",\n    \"pnpm eslint --fix package.json\",\n    \"pnpm prettier --ignore-unknown --write package.json\",\n    \"git add package.json\",\n  ],\n  trailing: [\"git checkout -b release/desktop/${NEW_VERSION}\"],\n  finally: [\n    \"git push origin release/desktop/${NEW_VERSION}\",\n    \"gh pr create --title 'release(desktop): Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base main --head release/desktop/${NEW_VERSION}\",\n  ],\n  push: false,\n  commitMessage: \"release(desktop): release v${NEW_VERSION}\",\n  tagPrefix: \"desktop@\",\n  changelog: false,\n  allowedBranches: [\"dev\"],\n})\n"
  },
  {
    "path": "apps/desktop/bump.hotfix.config.js",
    "content": "/* eslint-disable no-template-curly-in-string */\nimport { execSync } from \"node:child_process\"\n\nimport { defineConfig } from \"nbump\"\n\nconst currentBranch = execSync(\"git rev-parse --abbrev-ref HEAD\").toString().trim()\n\nexport default defineConfig({\n  before: [\"git pull --rebase\"],\n  after: [\n    `gh pr create --title 'chore(desktop): Release v\\${NEW_VERSION} for hotfix' --body 'v\\${NEW_VERSION}' --base main --head ${currentBranch}`,\n  ],\n  commit_message: \"release(desktop): hotfix to release v${NEW_VERSION}\",\n  tag: false,\n  changelog: false,\n  allowedBranches: [\"hotfix/*\"],\n})\n"
  },
  {
    "path": "apps/desktop/changelog/0.1.2.md",
    "content": "# What's new in v0.1.2\n\n## Features\n\n- Custom CSS is now supported, so you can add any CSS style and apply it to the Entry content view.\n- New Zen mode has been added, so you can now read the full text in full screen without interruption, and we've optimized the user experience of the ToC component in full screen mode as well as added a new brief timeline on the left side.\n- Support for hiding extra badges around the feed, such as Claim or Boost, which you may not want to see.\n\n## Improvements\n\n- Fixed the issue that Entry list cannot be loaded offline.\n- Optimized the performance experience of some scenarios.\n- The UI of some components has been fine-tuned to look more natural.\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.0.md",
    "content": "# What's new in v0.2.0\n\n## New Features\n\n- Feed owners can now reset their feeds.\n- Image Gallery: Clicking on the image button in the entry opens the Image gallery modal (only if there are multiple large images in the entry!).\n- Now you can export the data from the local database.\n- App: In consideration of your hard drive, now it supports clearing cache and limiting the size of cache.\n- Added a new feature to allow minimizing to the system tray by enabling a switch in the settings.\n- Discover Page: Enhance RSSHub recommendations with filters\n- Quickly update views or categories at once by dragging and dropping.\n\n## Improvements\n\n- Optimized the Zen mode experience on macOS.\n- Improvement web app global shortcuts.\n- Optimized the Timeline's data cache, reducing data reloads within a short period of time. You can go to Settings -> General -> Timeline -> Reduce timeline refetch to control this feature, default is enabled.\n\n## Bug Fixes\n\n- Fixed the issue where the Volume of VideoPlayer was not clickable.\n- While using arrow keys to switch between entries, the entry view will not scroll unexpexted.\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.1.md",
    "content": "# What's new in v0.2.1\n\n## New Features\n\n- Added zoom functionality for viewing pictures.\n- Set `Show Unread Only` as the default option.\n- Merged the redirect page with the login page.\n- Introduced entry conditions for actions.\n- Added `all` language filter in dicover page.\n\n## Improvements\n\n- Enhanced the logic for multi-selection and dragging.\n\n## Bug Fixes\n\n- Resolved issue with loading Instagram images.\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.2.md",
    "content": "# What's new in v0.2.2\n\n## New Features\n\n- And Or conditions for actions\n- Add achievement badge\n\n## Improvements\n\n- electron: Prompt when opening an external link whether to open the app\n- Improve the smoothness of some animations\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.3.md",
    "content": "# What's new in v0.2.3\n\n## New Features\n\n- Hold shift to quickly select multiple Feeds\n- Collect entry from List\n\n## Improvements\n\n## Bug Fixes\n\n- Action settings are invalid after loading archive entry\n- Redundant parameter on the transform form\n- Can't play normal video in video view\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.4.md",
    "content": "# What's new in v0.2.4-web\n\n## New Features\n\n🎉 HUGE NEWS! Follow finally goes mobile!\n\nEver wished you could Follow your favorite feeds while lounging on your couch? Well, now you can! We've made Follow fully responsive and mobile-friendly. Whether you're on your phone during your commute or browsing from bed (we won't judge), Follow's got your back!\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.5.md",
    "content": "# What's new in v0.2.5\n\n## New Features\n\n- Customizable columns for masonry view\n- Manually trigger AI summary or translation\n\n![](https://fastly.jsdelivr.net/gh/RSSNext/assets@main/masonry.mp4)\n\n## Improvements\n\n## Bug Fixes\n\n- Fixed some display and operation issues on mobile\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.6.md",
    "content": "# What's new in v0.2.6\n\nWe've made some ux optimizations:\n\n- [Mobile]: Now when returning to the list from the entry details, it will not return to the top of the page.\n- [Social Media]: Optimized the arrangement of multiple images with different aspect ratios.\n- [Social Media]: Long text auto-collapse.\n\nAnd many other bug fixes and improvements.\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.7.md",
    "content": "# What's new in v0.2.7\n\n## New Features\n\n1. New `Move to Category` operation in feed subscription context menu\n\n![](https://github.com/RSSNext/assets/blob/main/0.2.7/move-category.png?raw=true)\n\n2. New `Expand long social media` setting to automatically expand social media entries containing long text.\n\n![](https://github.com/RSSNext/assets/blob/main/0.2.7/expand-long-social-media.png?raw=true)\n\n3. New `Back Top` button and read progress indicator in entry content\n\n![](https://github.com/RSSNext/assets/blob/main/0.2.7/read-indicator.png?raw=true)\n\n4. Independent Action page\n\n## Bug Fixes\n\n- Resolved issue where back navigation to the pending view would not behave correctly after changing orientation in some device.\n- Fixed issue can't filter entries with no picture in the picture view.\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.8.md",
    "content": "# What's new in v0.2.8\n\n## New Features\n\n- Register or Login with email and password\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.2.9.md",
    "content": "# What's new in v0.2.9\n\n## New Features\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.0.md",
    "content": "# What's new in v0.3.0\n\n## New Features\n\n- **Custom RSSHub Instances**: Use custom RSSHub instances to improve data acquisition efficiency. Contribute your self-deployed RSSHub instances to earn additional $POWER.\n  ![Custom RSSHub](https://github.com/RSSNext/assets/blob/main/custom-rsshub.png?raw=true)\n- **OPML Export Options**: Optionally specify the RSSHub URL and folder classification mode when exporting OPML files.\n  ![Export OPML](https://github.com/RSSNext/assets/blob/main/export-options.png?raw=true)\n- **Account Management**: Update your email and manage linked social accounts easily.\n  ![Account Management](https://github.com/RSSNext/assets/blob/main/account-management.png?raw=true)\n\n## Improvements\n\n- Update email and manage linked social accounts\n- Scroll to top when re-navigating to Discover page while already on it\n\n## Bug Fixes\n\nSome bugs are fixed.\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.1.md",
    "content": "# What's new in v0.3.1\n\n## New Features\n\n- **Customize toolbar**: Customize the toolbar to display the items you most frequently use.\n  ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true)\n\n## Improvements\n\n- **Podcast Player**: Re-designed the podcast player in mobile to be more user-friendly.\n  ![Podcast Player](https://github.com/RSSNext/assets/blob/8f778dac8bb2e765acab2157497e4a77a60c5a0b/mobile-audio-player.png?raw=true)\n- **Social Media View Action**: Redesigned the toolbar style in the social media view. Now, the toolbar no longer jitters or gets obstructed when hovering over entries.\n  ![Social Media View Action](https://github.com/RSSNext/assets/blob/main/social-media-actions.png?raw=true)\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.10.md",
    "content": "# What's new in v0.3.10\n\n## New Features\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.11.md",
    "content": "# What's new in v0.3.11\n\n## New Features\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.12.md",
    "content": "# What's new in v0.3.12\n\n## New Features\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.13.md",
    "content": "# What's new in v0.3.13\n\n## New Features\n\n- New Action Language setting\n- Export entry content to a PDF file\n\n## Improvements\n\n## Bug Fixes\n\n- Unable to update subscription information in time\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.2.md",
    "content": "# What's new in v0.3.2\n\n## New Features\n\n- Added support for Two-Factor Authentication (2FA) for login and large transactions.\n\n## Improvements\n\n- Enhanced detection for translation needs and improved display of translated text.\n\n## Bug Fixes\n\n- Resolved an issue where some read marks were missed during fast scrolling.\n- Unable to copy inbox email address correctly\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.3.md",
    "content": "# What's new in v0.3.3\n\n## New Features\n\n## Improvements\n\n- Merge actions for toggling state\n- Action supports matching custom title\n\n## Bug Fixes\n\n- `Failed to set voice` error message appears every time the app starts\n- Can not replay TTS\n- Login state lost when restarting the app\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.4.md",
    "content": "# What's new in v0.3.4\n\n## New Features\n\n- The audio and notification views have been merged into the article view, being included in a new timeline selector\n  ![Timeline selector1](https://github.com/RSSNext/assets/blob/main/timeline-selector1.png?raw=true)\n  ![Timeline selector2](https://github.com/RSSNext/assets/blob/main/timeline-selector2.png?raw=true)\n- Support custom font and line height for content\n- Support custom date format for entry title date\n  ![Custom content styles](https://github.com/RSSNext/assets/blob/main/custom-content-styles.png?raw=true)\n\n## Improvements\n\n- Optimize translation display\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.5.md",
    "content": "# What's new in v0.3.5\n\n## New Features\n\n- Restore audio and notification views\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.6.md",
    "content": "# What's new in v0.3.6\n\n## New Features\n\n- Added a quick selector to the timeline column.\n\n![](https://github.com/RSSNext/assets/raw/refs/heads/main/timeline-selector.mp4)\n\n## Improvements\n\n## Bug Fixes\n\n- Resolved the issue of being unable to reset it to empty after using the proxy.\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.7.md",
    "content": "# What's new in v0.3.7\n\n## New Features\n\n- Revert to the previous list display styles.\n  ![Timeline selector3](https://github.com/RSSNext/assets/blob/main/timeline-selector3.png?raw=true)\n- The entries of the list is now displayed directly in the timeline.\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.8.md",
    "content": "# What's new in v0.3.8\n\n## New Features\n\n## Improvements\n\n## Bug Fixes\n\n- The app is stuck after closing the dialog\n- Can not sign in with email in the desktop app\n"
  },
  {
    "path": "apps/desktop/changelog/0.3.9.md",
    "content": "# What's new in v0.3.9\n\n## New Features\n\n## Improvements\n\n## Bug Fixes\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.0.md",
    "content": "# What's New in v0.4.0\n\n## New Features\n\n- New global settings for AI summary and translation (#3294)\n- Display estimated audio duration for entry titles (#3292)\n- Simplify settings via an enhanced settings toggle (217e1a8)\n\n## Improvements\n\n- Enhanced translation quality for entry content (#3294)\n- Refine toolbar customization (#3284)\n- Sign Windows executable files using SignPath (#3286)\n- Remove email verification toast notifications (9bb723a)\n- Restrict Zen mode display width (d107127)\n- Update MGC Icon to v1.36 (#3310)\n- Enhance UX for the withdraw modal (#3311)\n- Improve visibility and layout of the action settings button (0d5cb13)\n\n## Bug Fixes\n\n- Fix reCAPTCHA being unclickable (305c4bc)\n- Correct store not updating after marking items as read in the list (e8305f8)\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.1.md",
    "content": "# What's New in v0.4.1\n\n## New Features\n\n- Added a \"Mark Above as Read\" button at the bottom of the entry list (88963a9)\n\n## Improvements\n\n- Enable linking social accounts with a different email (#3355)\n- Integrated a more advanced AI model for enhanced performance\n- Increased the number of invitation codes that users can create\n- Excluded entries without images from the picture view for a streamlined browsing experience\n\n## Bug Fixes\n\n- Updated the icon for \"View Source Content\" to distinguish it from \"Open in Browser\" (#3373)\n- Fixed the problem of unread entries being incorrectly marked as read (#3305)\n- Corrected an issue where the modal's close button was unresponsive in certain scenarios (#3387)\n- Addressed a bug that prevented actions from being saved when empty (a997329)\n- Corrected an issue where unread statuses were not properly displayed in some instances\n- Fixed the timing of new content notifications to ensure they are sent only after the content is available\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.2.md",
    "content": "# What's New in v0.4.2\n\n## Shiny new things\n\n- Added Cubox integration (#3385)\n- Included document links on the actions and discover pages (acdca33)\n\n## Improvements\n\n- Optimized AI summary styles\n- Redesigned the search card in Discover; now displaying feed update time and frequency more intuitively\n- Revamped the header UI and interactions for a more concise, dynamic experience\n- Removed the mark-as-read confirmation (04e8a48)\n- Added pulse animation to the skeleton component (#3369)\n- Refined the sorting logic for RSSHub instances (a824343)\n- Enabled AI summary by default (8da8a71)\n- Enhanced the entry sharing copy by including both title and description (#3462)\n- Displayed text content within video modal preview (#3458)\n\n## No longer broken\n\n- Fixed incorrect translation results and formatting (55524b) (#3421)\n- Restored the entry list header in the inbox\n- Resolved issues with masonry layout column adjustments failing in certain cases (#3425)\n- Hid the \"open in browser\" option when it is unavailable (ffada7c)\n- Corrected the inbox unread count to ensure it is up to date (82baf0b)\n- Prevented updates to the inbox unread count for read entries during deletion (c6024e3)\n- Fixed an issue where videos were not displayed correctly at times (935e39b)\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.3.md",
    "content": "# What's New in v0.4.3\n\n## Shiny new things\n\n- Added server‑side readability with AI‑powered summaries and translation support (#3498)\n- You can now delete RSSHub instances (2d33a22)\n- Unread counts are displayed in the system tray (665221d)\n- Copy feed badge from the context menu (144f709)\n\n## Improvements\n\n- Option to save AI summaries as the Cubox description (#3478)\n- Improved email validation and smoother reCAPTCHA closure on the sign‑in and sign‑up pages (#3510)\n- Added new action icons and updated the share and AI icons (f526edf, f447d6a, 34da9d2)\n- Enhanced security with one‑time tokens (#3482)\n- Translated titles and their original text now appear on separate lines (49f3cc2)\n- TTS action is hidden by default (3157ecf)\n\n## No longer broken\n\n- Fixed TTS playback failure (a4d1653)\n- Fixed corner player not navigating to the correct entry (#1401)\n- Fixed issue that prevented saving to Readwise (53da938)\n- Fixed missing “Mark above as read” option in the picture masonry layout footer (e018736)\n- Fixed a potential login drop issue (0ee8510)\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.4.md",
    "content": "# What's new in v0.4.4\n\n## Shiny new things\n\n- Added status action that allows you to send notifications or trigger webhooks for starred entries (9222553)\n\n## Improvements\n\n- Refreshed color palette and numerous style tweaks for a cleaner look\n- Redesigned context and dropdown menus for faster, more intuitive navigation (c995da3)\n- Balances now display a maximum of two decimal places (#3564)\n\n## No longer broken\n\n- Fixed an issue that stopped local settings from syncing with the cloud (7048aa9)\n- Corrected mis-redirects caused by malformed site URLs (2bbb00c)\n- Resolved tap-selection problems in entry lists on iPad Web (9da9dec)\n- Eliminated a crash in the Settings menu (5120c2c)\n\n## Thanks\n\nSpecial thanks to external contributors @kovsu @Vixb1122 @johnsoncodehk for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.5.md",
    "content": "# What's new in v0.4.5\n\n## Shiny new things\n\n- Translation view toggle – choose between “translation-only” or bilingual display (#3568)\n- Mark Above/Below as Read option in the context menu (#3570)\n- Automation actions: auto start entries that match your rules (#3625)\n- Trending Feeds now spotlighted on the Discover page (eb6a409)\n- Timeline previews for feeds you haven’t subscribed to yet (eb26cde)\n- Invite-code gates removed – no invite code needed anymore (cee647c)\n\n## Improvements\n\n- Seamless fallback to client-side readability if the server parser fails (e6aa58e)\n- Onboarding completely redesigned and streamlined (0357b7d)\n- Removed long-unmaintained language packs (f48697c)\n- Faster search performance\n- AI summaries now render Markdown (#3641)\n- Upgraded to React 19 and React Native 0.79 (#3661)\n- Cleaner category layout in the Discover page\n- Smoother sign-up / sign-in flow (e7f88b7)\n- Dozens of visual polish tweaks across the app\n\n## No longer broken\n\n- Fixed occasional misplacement of the scroll indicator (87c9a48)\n- Font settings now apply correctly (5aed9a7)\n- Different views no longer clash with one another’s scroll positions (#3627)\n- Picture and video view sizes no longer interfere with each other (#3645)\n- Correct sorting icon in the feed list (#3675)\n- Custom fonts containing spaces now render properly (#3674)\n\n## Thanks\n\nSpecial thanks to external contributors @kovsu @ericyzhu @cuikaipeng @grtsinry43 @cscnk52 for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.6.md",
    "content": "# What's new in v0.4.6\n\n## Shiny new things\n\n- Hide read subscriptions. You can now hide subscriptions with no unread items. Enable it in Settings → General → Subscriptions → Hide Read. (1b88d72)\n- Vim-style keyboard navigation with smarter focus detection: (262a7ce c2d8ffa)\n  - Timeline focus: `j`/`k` or ↓/↑ to move between entries; `Enter` to open the selected entry.\n  - Entry focus: `h`/`l` or ←/→ to jump to previous/next entry, `j`/`k` or ↓/↑ to scroll, `Esc` or `Backspace` to return to the Timeline.\n  - Subscription list focus: `j`/`k` or ↓/↑ to move, `Enter` to open the Timeline, `Z` to collapse/expand a folder.\n\n## Improvements\n\n- Clearer RSSHub error messages. (ed95fb6b)\n- AI summary is now enabled by default. (f329ae9)\n- Social-media view shows a richer action bar. (b06d8ff)\n- No more notification-permission prompts when your actions don’t need them. (450b759)\n- Discover page now remembers your language preference. (180933b)\n\n## No longer broken\n\n- Fixed incorrect language display in Shiki code blocks. (20049b0)\n- Fixed occasional subscription-status errors. (9b0691a)\n- Fixed default view in the subscription dialog. (bf26a3e)\n- Fixed login dialog styling in dark mode. (e18f052)\n- Fixed duplicated action bar in social-media view. (a917231)\n\n## Thanks\n\nSpecial thanks to external contributors @cleves0315 @grtsinry43 for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.4.8.md",
    "content": "# What's new in v0.4.8\n\n## Shiny new things\n\n- Keyboard Shortcuts – a full-featured command system covering global actions, layout control, timeline navigation, content rendering, subscription management, and entry operations. Browse and remap shortcuts under `Preferences → Shortcuts`.\n- Zotero Integration – connect with the popular open-source reference manager to streamline your research workflow (#3738)\n- Discover now includes the complete RSSHub route catalogue with a dedicated RSSHub category page featuring route search, trending routes, and popular examples (5f06de0)\n- Notifications settings – explore and test every available notification channel in one place (0db149f)\n- New option to hide private subscriptions in the Timeline: `Preferences → General → Subscription → Hide Private` (#3773)\n\n## Improvements\n\n- Suscription form now shows each list’s subscriber count and last-updated time (e525edc)\n- Video view now displays the video’s total duration (2234b4b)\n- Added compatibility for both `folo` and `follow` URI schemes (4fa171b)\n\n## No longer broken\n\n- Resolved blur effect not applying on the macOS Electron vibrancy layer (33ef0c4)\n- “More” label stays hidden when the entry-history limit isn’t reached (04583a3)\n- Fixed certain videos failing to render inside entries (c48b94a)\n\n## Thanks\n\nSpecial thanks to external contributors @kovsu, @cscnk52, @cleves0315, @ericyzhu, @1411430556, and @ufec for their valuable contributions.\n"
  },
  {
    "path": "apps/desktop/changelog/0.5.0.md",
    "content": "# What's New in v0.5.0\n\n## Shiny new things\n\n- Double-clicking the draggable edge on either side of the entry list resets it to its default position (26c6853)\n- Brand-new Feed Manager panel (dbd43ae)\n- OPML import now offers a preview and lets you choose specific feeds to import (de6c2ff)\n- All-new Share sheet (5bbd96e)\n\n## Improvements\n\n- Switched to hCaptcha for a smoother human-verification experience (22aec92)\n- Gradually rolling out an experimental unified local database for mobile and desktop (#3809)\n- Implemented smooth scrolling (6c73ae5)\n- Significant overall performance boost\n- AI will skip summarising extra-short articles to avoid trivial summaries (852fb1d)\n- Added “Reset to defaults” button to the shortcuts page (#3834)\n- Added a border around 2-factor QR codes for better scan reliability (f7faf78)\n- RSSHub routes top-feed list now respects your Discover-language setting (14d4da3)\n- Audio player now prefers episode artwork over feed artwork (#3855)\n- You can now manually paste your login token to bypass Microsoft Store deeplink issues (47e07a4)\n- Added “Check for updates” button in Settings (ef304cd)\n\n## No longer broken\n\n- Dropdown menus now keep keyboard focus when opened (f31d43d)\n- “Mark as read” button is now perfectly centred (#3836)\n- Entry list no longer displays stale cached items (0a167ac)\n- Squashed numerous shortcut-key bugs\n\n## Thanks\n\nSpecial thanks to external contributors @ericyzhu @kovsu @yeeway0609 @cscnk52 for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.6.0.md",
    "content": "# What's New in v0.6.0\n\nThis version has been withdrawn.\n"
  },
  {
    "path": "apps/desktop/changelog/0.6.1.md",
    "content": "# What's New in v0.6.1\n\n## Shiny New Things\n\n- Import and export your Actions (394d00f)\n- Add a bio, website, and social links to your profile (507a525)\n- Upload a profile picture\n- Use video duration as an Action condition\n\n## Improvements\n\n- A snazzy new look for your personal profile\n- Redesigned the Actions page (1ace5ea)\n- Redesigned the RSSHub page (f9aca60)\n- Added length limits to certain profile fields\n- Simplified default commands in the entry tool (85122fb)\n- Enhanced UI labels and descriptions for clarity (2ed9f70)\n- Gradually rolling out an experimental unified local database for mobile and desktop (#3897 #3902)\n- Polished image-preview styling (cf72753)\n- Refined toast notifications (73f8011)\n\n## No Longer Broken\n\n- More reliable automatic recovery after database-migration failures (c2e0c3d)\n- Fixed unread counts not clearing in the macOS Docker build (70255af)\n- Fixed old entries showing during initial load (24ae065)\n- Fixed handling of links starting with `.` (de8eac8)\n- Fixed text-to-speech not working (82952b0)\n- Fixed star/unstar status not syncing across devices (fbd0b3)\n\n## Thanks\n\nSpecial thanks to volunteer contributors @kovsu @huanfe1 @cscnk52 @Olexandr88 @0-o0 @kingsword09 @ericyzhu for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.6.2.md",
    "content": "# What's new in v0.6.2\n\n## Shiny new things\n\n- New “Fade Read Items” option dims read entries in the timeline (eeeea47)\n- YouTube videos now play directly in the Articles view (#4096)\n- Choose your own accent color for the interface (47129d5)\n\n## Improvements\n\n- Clearer podcast duration display (#4001)\n- Added a Sign In / Sign Up toggle on the email page (81c544a)\n- Smarter visibility logic for the picture action bar (#4033)\n- Edit Feed now shows only categories relevant to the current view (#4094)\n- “Mark above as read” button no longer appear in the Starred list (#4083)\n- RSSHub and Trending queries persist between sessions (7a95a15)\n- Refined profile layout (#4101)\n\n## No longer broken\n\n- Discover page scroll indicator now can reach 100% (#3962)\n- “Mark as Read” keyboard shortcut works again (24113f7)\n- Fixed infinite scrolling when dragging in Customize Toolbar settings (#4018)\n- Fixed the bug that you can’t mark an entry as Unread (8ea13a3)\n- Clicking Read more in Social Media view no longer refreshes the page (#4027)\n- Entries with special characters in the title can be saved to Obsidian (f6cff2a)\n- Image downloads are now reliable (#4095)\n- Corrected Inbox “Mark as Read” logic (7c1bf99)\n- Prevented feed click event from bubbling to route item (#4113)\n- Fixed the bug that source content not triggered automatically (c5edc76)\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ericyzhu @cscnk52 @kovsu @Fatpandac @dai @LitoMore @kira-offgrid @AkaShark @RtYkk for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.6.3.md",
    "content": "# What's new in v0.6.3\n\n## Shiny new things\n\n- Hide From Timeline option for subscriptions to make your timeline cleaner.\n- BitTorrent attachment support and qBittorrent integration.\n\n## Improvements\n\n- Clearer loading indicators for both the entry list and the subscription list (e81220d)\n- The hyperlink in the article preview has been added with an emphasis color. (db67ec8)\n- Search results now persist across navigation.\n- Sort feed by subscription count or update per week in feed management.\n- Notification support for inbox.\n\n## No longer broken\n\n- Fresh entries are no longer incorrectly auto‑marked as read after a list refresh. (d3d1e05)\n- Fixed the timing of masonry column width calculations to avoid layout glitches. (17389d9)\n- Removed placeholders in the picture waterfall view to prevent incorrectly layout redraws. (3c4b152)\n- Corrected initial height calculation for items in the picture waterfall view. (82a3537)\n- Fixed the Microsoft Store build not being recognized by deep links. (943ef31)\n- Right-click context menu shortcut can't trigger the context menu. (b44bf71)\n- Text in the social media view is now selectable.\n- Translation settings can not be toggled.\n- Fixed can not mark read/unread for starred entries.\n- Feeds with invalid site URLs being hidden in the feed list.\n- List with unread entries being hidden when Hide Read option is enabled.\n- Inbox can not receive text only emails like Gmail Forward verification.\n\n## Thanks\n\nSpecial thanks to volunteer contributors @kovsu, @cscnk52, @yansq, @hellosunghyun for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.7.0.md",
    "content": "# What's new in v0.7.0\n\n## Shiny new things\n\n- Add custom integration configurations to adapt to more apps\n\n## Improvements\n\n- Redesign integration settings page for better user experience and support integration settings export and import.\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/0.8.0.md",
    "content": "# What's new in v0.8.0\n\n## Shiny new things\n\n- Smart onboarding that gets you started in seconds.\n- One place for all your feeds.\n- A cleaner, consistent reading experience for social media posts, picture galleries, videos, and articles.\n- Support subtitles for podcasts and videos.\n- Redesigned Actions for easier setup.\n\n## Improvements\n\n- We have made countless improvements and bug fixes in this version.\n"
  },
  {
    "path": "apps/desktop/changelog/0.9.0.md",
    "content": "# What's new in v0.9.0\n\n## Improvements\n\n- Removed the limit on the maximum number of views (b69a935)\n- Allow hiding the “All” view (0882e47)\n- Introduced a right-click menu for views, allowing quick access to hide or open settings (60dcd42)\n- Added a smooth sliding animation when opening entry details (1720ebb)\n\n## No longer broken\n\n- Fixed an issue where the scrollbar didn’t reset when switching between entry details (21124f5)\n"
  },
  {
    "path": "apps/desktop/changelog/1.0.0.md",
    "content": "# What's new in v1.0.0\n\n## Shiny new things\n\n- 🌟 **Folo is now the AI Reader** — a smarter way to follow everything.\n\n## Improvements\n\n- Added support for multilingual `params` in YouTube video previews (#4610)\n- Redirects now go to your configured first view instead of always defaulting to All (a82b0e9)\n\n## No longer broken\n\n- Fixed the hover indicator style on the audio player progress bar (#4600)\n- Fixed video autoplay in media previews (#4531)\n\n## Thanks\n\nSpecial thanks to volunteer contributors @yeeway0609 @kovsu @unixzii for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/1.1.0.md",
    "content": "# What's new in v1.1.0\n\n## Shiny new things\n\n- Reintroduced the clean, efficient three-column layout for a smoother reading experience.\n- Added out-of-the-box Fabric integration for MCP users.\n\n## Improvements\n\n- Automatically generate chat titles during live sessions.\n- Simplified AI usage metrics display with improved styling.\n- Added a progress bar to the onboarding feed subscription step.\n- Adopted a more reliable mechanism for checking software updates.\n- Windows now starts minimized to the system tray by default.\n- Timeline AI summaries now trigger automatically in all views.\n- Simplified the AI reasoning display for better clarity.\n- Onboarding flow now allows manual skipping.\n- Merged AI chat history and AI task history into a single dropdown for easier access.\n\n## No longer broken\n\n- Fixed an issue where the Windows EXE software update check failed to run properly.\n- Fixed a bug preventing category movement in the “All” view.\n- Fixed occasional AI chat interruptions under certain conditions.\n\n## Thanks\n\nSpecial thanks to volunteer contributors @kovsu for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/1.2.2.md",
    "content": "# What's new in v1.2\n\n## Shiny new things\n\n- **AI**\n  - Added **BYOK (Bring Your Own Key)** support — choose your own AI provider freely.\n  - Improved **AI Memory**: you can now update or refine past memories whenever you need.\n  - Reduced the “guiding tone” in **AI Summary** so summaries feel more natural and personal.\n  - Introduced **AI Timeline Sort (Beta)**: your timeline can now rearrange itself based on your reading preferences.\n\n![](https://cdn.follow.is/ai-memory.mp4)\n\n![](https://cdn.follow.is/share.mp4)\n\n- **UI**\n  - Several UI components have been refined with an improved design system for a cleaner, more consistent feel.\n\n- **Subscription**\n  - Added a new **Basic Plan (non-AI version)** for users who prefer a lighter subscription option.\n\n- **Selection**\n  - Added **underline sharing** and **Ask AI** directly from selected text to make reading actions smoother.\n\n## Improvements\n\n- **Feed**\n  - RSSHub subscriptions now include a clear identifier, making source management easier.\n\n- **Web**\n  - You can now browse recommended content without logging in — explore before committing.\n\n- **Onboarding**\n  - The onboarding flow has been fully redesigned for a smoother, more intuitive first-time experience.\n\n- **Entry**\n  - Entries now come with AI-generated labels to help you sort and revisit content effortlessly.\n\n## No longer broken\n\nWe fixed several bugs to make everything feel more stable and reliable.\n"
  },
  {
    "path": "apps/desktop/changelog/1.2.6.md",
    "content": "# What's new in v1.2.6\n\n## Shiny new things\n\n- Optimize Stripe subscription management UI with dynamic billing portal and status indicators\n\n## Improvements\n\n- Update AI SDK to v6.0.5\n- Refresh translation cache on mode changes\n\n## No longer broken\n\n- Fix AI chat message types alignment\n- Fix default custom integration fetch\n\n## Thanks\n\nSpecial thanks to volunteer contributors @TonyRL for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/1.3.0.md",
    "content": "# What's new in v1.3.0\n\n## Shiny new things\n\n- Markdown link supports open share feed link directly in the app\n\n## No longer broken\n\n- Fix preserve session when switching API domain\n- Fix handle invalid URL parsing in useFeedSafeUrl hook\n- Fix shortcuts page freeze\n- Fix icon service URL and image proxy URL\n\n## Thanks\n\nSpecial thanks to volunteer contributors @cuikaipeng @yjl9903 for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/1.3.1.md",
    "content": "# What's new in v1.3.1\n\n## Shiny new things\n\n- Supported French localization.\n\n## Improvements\n\n- Migrated error tracking to PostHog for better diagnostics.\n- Improved Japanese localization coverage.\n\n## No longer broken\n\n- Hardened external protocol handling to prevent potential security issues.\n- Fixed sorting crash when importing subscriptions with missing titles.\n\n## Thanks\n\nSpecial thanks to volunteer contributors @Kowyo and @q1uf3ng for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/1.4.0.md",
    "content": "# What's new in v1.4.0\n\n## Shiny new things\n\n- Added in-app review prompts\n\n## Improvements\n\n- Expanded desktop end-to-end coverage for auth and user flows\n\n## No longer broken\n\n- Removed the unwanted text selection toolbar\n- Fixed AI onboarding asset loading by switching the spline asset domain\n- Hardened setting sync authentication lifecycle\n\n## Thanks\n\nSpecial thanks to volunteer contributors for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/next.md",
    "content": "# What's new in vNEXT_VERSION\n\n## Shiny new things\n\n## Improvements\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/changelog/next.template.md",
    "content": "# What's new in vNEXT_VERSION\n\n## Shiny new things\n\n## Improvements\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/desktop/configs/vite.electron-render.config.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport { dirname, resolve } from \"pathe\"\nimport type { UserConfig } from \"vite\"\nimport { routeBuilderPlugin } from \"vite-plugin-route-builder\"\n\nimport { cleanupUnnecessaryFilesPlugin } from \"../plugins/vite/cleanup\"\nimport { createPlatformSpecificImportPlugin } from \"../plugins/vite/specific-import\"\nimport { viteRenderBaseConfig } from \"./vite.render.config\"\n\nconst root = resolve(fileURLToPath(dirname(import.meta.url)), \"..\")\n\nconst VITE_ROOT = resolve(root, \"layer/renderer\")\n\nconst mode = process.argv.find((arg) => arg.startsWith(\"--mode\"))?.split(\"=\")[1]\nconst isStaging = mode === \"staging\"\n\nexport default {\n  ...viteRenderBaseConfig,\n\n  plugins: [\n    ...viteRenderBaseConfig.plugins,\n    createPlatformSpecificImportPlugin(\"electron\"),\n    routeBuilderPlugin({\n      pagePattern: \"src/pages/**/*.tsx\",\n      outputPath: \"src/generated-routes.ts\",\n      enableInDev: true,\n    }),\n    cleanupUnnecessaryFilesPlugin([\n      \"og-image.png\",\n      \"icon-512x512.png\",\n      \"opengraph-image.png\",\n      \"favicon.ico\",\n      \"icon-192x192.png\",\n      \"favicon-dev.ico\",\n      \"apple-touch-icon-180x180.png\",\n      \"maskable-icon-512x512.png\",\n      \"pwa-64x64.png\",\n      \"pwa-192x192.png\",\n      \"pwa-512x512.png\",\n    ]),\n  ],\n\n  root: VITE_ROOT,\n  build: {\n    outDir: resolve(root, \"dist/renderer\"),\n    sourcemap: isStaging || !!process.env.CI,\n    target: \"esnext\",\n    rollupOptions: {\n      input: {\n        main: resolve(VITE_ROOT, \"index.html\"),\n      },\n    },\n    minify: !isStaging,\n  },\n  define: {\n    ...viteRenderBaseConfig.define,\n    ELECTRON: \"true\",\n  },\n} satisfies UserConfig\n"
  },
  {
    "path": "apps/desktop/configs/vite.render.config.ts",
    "content": "import { readFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport react from \"@vitejs/plugin-react\"\nimport { codeInspectorPlugin } from \"code-inspector-plugin\"\nimport { dirname, resolve } from \"pathe\"\nimport { prerelease } from \"semver\"\nimport type { UserConfig } from \"vite\"\n\nimport { getGitHash } from \"../../../scripts/lib\"\nimport { astPlugin } from \"../plugins/vite/ast\"\nimport { circularImportRefreshPlugin } from \"../plugins/vite/hmr\"\nimport { customI18nHmrPlugin } from \"../plugins/vite/i18n-hmr\"\nimport { localesJsonPlugin } from \"../plugins/vite/locales-json\"\nimport i18nCompleteness from \"../plugins/vite/utils/i18n-completeness\"\n\nconst pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), \"..\")\nconst pkg = JSON.parse(readFileSync(resolve(pkgDir, \"./package.json\"), \"utf8\"))\n\nconst getChangelogFileContent = () => {\n  const { version: pkgVersion } = pkg\n  const isDev = process.env.NODE_ENV === \"development\"\n  // get major-minor-patch, e.g. 0.2.0-beta.2 -> 0.2.0\n  const version = pkgVersion.split(\"-\")[0]\n  try {\n    return readFileSync(resolve(pkgDir, \"./changelog\", `${isDev ? \"next\" : version}.md`), \"utf8\")\n  } catch {\n    return \"\"\n  }\n}\n\nconst changelogFile = getChangelogFileContent()\nexport const viteRenderBaseConfig = {\n  worker: {\n    format: \"es\",\n  },\n  optimizeDeps: {\n    exclude: [\"sqlocal\", \"wa-sqlite\", \"@follow-app/client-sdk\"],\n  },\n  resolve: {\n    alias: {\n      \"~\": resolve(\"layer/renderer/src\"),\n      \"@pkg\": resolve(\"package.json\"),\n      \"@locales\": resolve(\"../../locales\"),\n      \"@follow/electron-main\": resolve(\"layer/main/src\"),\n    },\n  },\n  base: \"/\",\n\n  plugins: [\n    {\n      name: \"import-sql\",\n      transform(code, id) {\n        if (id.endsWith(\".sql\")) {\n          const json = JSON.stringify(code)\n            .replaceAll(\"\\u2028\", \"\\\\u2028\")\n            .replaceAll(\"\\u2029\", \"\\\\u2029\")\n\n          return {\n            code: `export default ${json}`,\n          }\n        }\n      },\n    },\n    localesJsonPlugin(),\n    codeInspectorPlugin({\n      bundler: \"vite\",\n      hotKeys: [\"altKey\"],\n    }),\n    react({\n      // jsxImportSource: \"@welldone-software/why-did-you-render\", // <-----\n    }),\n    circularImportRefreshPlugin(),\n\n    astPlugin,\n    customI18nHmrPlugin(),\n  ],\n  define: {\n    APP_VERSION: JSON.stringify(pkg.version),\n    APP_NAME: JSON.stringify(pkg.productName),\n    APP_DEV_CWD: JSON.stringify(process.cwd()),\n\n    GIT_COMMIT_SHA: JSON.stringify(process.env.VERCEL_GIT_COMMIT_SHA || getGitHash()),\n\n    RELEASE_CHANNEL: JSON.stringify((prerelease(pkg.version)?.[0] as string) || \"stable\"),\n\n    DEBUG: process.env.DEBUG === \"true\",\n\n    I18N_COMPLETENESS_MAP: JSON.stringify({ ...i18nCompleteness, en: 100 }),\n    CHANGELOG_CONTENT: JSON.stringify(changelogFile),\n    \"process.env.NODE_ENV\": JSON.stringify(process.env.NODE_ENV),\n  },\n} satisfies UserConfig\n"
  },
  {
    "path": "apps/desktop/dev-only/dev-app-update.yml",
    "content": "provider: custom\nchannel: latest\n"
  },
  {
    "path": "apps/desktop/e2e/playwright.config.ts",
    "content": "import { defineConfig, devices } from \"@playwright/test\"\n\nimport { resolveDesktopE2EEnv } from \"./support/env\"\n\nconst env = resolveDesktopE2EEnv()\n\nexport default defineConfig({\n  testDir: \"./tests\",\n  fullyParallel: false,\n  workers: 1,\n  timeout: 120_000,\n  expect: {\n    timeout: 15_000,\n  },\n  reporter: [[\"list\"], [\"html\", { open: \"never\", outputFolder: \"playwright-report\" }]],\n  outputDir: \"test-results\",\n  use: {\n    baseURL: env.webBaseURL,\n    trace: \"retain-on-failure\",\n    screenshot: \"only-on-failure\",\n    video: \"retain-on-failure\",\n    serviceWorkers: \"block\",\n  },\n  webServer: {\n    command: \"pnpm run dev:web\",\n    cwd: env.desktopAppDir,\n    env: {\n      ...process.env,\n      VITE_API_URL: process.env.FOLO_E2E_WEB_DEV_API_URL ?? env.apiURL,\n      VITE_WEB_URL: process.env.FOLO_E2E_WEB_DEV_WEB_URL ?? env.webURL,\n    },\n    url: env.webDevServerURL,\n    timeout: 120_000,\n    reuseExistingServer: !process.env.CI,\n  },\n  projects: [\n    {\n      name: \"web\",\n      testMatch: /tests\\/web\\/.*\\.spec\\.ts/,\n      use: {\n        ...devices[\"Desktop Chrome\"],\n        channel: \"chromium\",\n        ignoreHTTPSErrors: true,\n        launchOptions: {\n          args: [\"--disable-web-security\"],\n        },\n      },\n    },\n    {\n      name: \"electron\",\n      testMatch: /tests\\/electron\\/.*\\.spec\\.ts/,\n    },\n  ],\n})\n"
  },
  {
    "path": "apps/desktop/e2e/scripts/capture-ui-audit.ts",
    "content": "import { mkdir } from \"node:fs/promises\"\n\nimport { chromium } from \"@playwright/test\"\nimport { join } from \"pathe\"\n\nimport { createTestAccount, tryDeleteCurrentUser } from \"../support/account\"\nimport {\n  closeSettings,\n  dismissFeedForm,\n  followOnboardingFeed,\n  openSettings,\n  openWebApp,\n} from \"../support/app\"\nimport { bootstrapAuthenticatedWebSession } from \"../support/auth-bootstrap\"\nimport { buildWebAppURL, resolveDesktopE2EEnv } from \"../support/env\"\n\nconst SETTING_TABS = [\n  \"general\",\n  \"appearance\",\n  \"notifications\",\n  \"shortcuts\",\n  \"ai\",\n  \"integration\",\n  \"feeds\",\n  \"list\",\n  \"profile\",\n  \"data-control\",\n  \"cli\",\n  \"plan\",\n  \"about\",\n] as const\n\nconst SUBVIEW_ROUTES = [\"discover\", \"power\", \"action\", \"rsshub\", \"ai\"] as const\n\nconst waitForUiSettled = async (page: import(\"@playwright/test\").Page, delay = 1200) => {\n  await page.waitForLoadState(\"domcontentloaded\")\n  await page.waitForTimeout(delay)\n}\n\nconst waitForRouteReady = async (\n  page: import(\"@playwright/test\").Page,\n  route: (typeof SUBVIEW_ROUTES)[number],\n) => {\n  await waitForUiSettled(page, route === \"power\" ? 3500 : 1200)\n\n  if (route === \"power\") {\n    await page\n      .waitForFunction(\n        () =>\n          document.body.textContent?.includes(\"Your Balance\") ||\n          document.body.textContent?.includes(\"Transactions\") ||\n          document.body.textContent?.includes(\"Create Wallet\"),\n        undefined,\n        { timeout: 15_000 },\n      )\n      .catch(() => {})\n  }\n}\n\nasync function main() {\n  const env = resolveDesktopE2EEnv()\n  const outputDir = join(\n    env.desktopAppDir,\n    \"e2e\",\n    \"artifacts\",\n    \"ui-audit\",\n    `run-${new Date().toISOString().replaceAll(\":\", \"-\")}`,\n  )\n\n  await mkdir(outputDir, { recursive: true })\n\n  const browser = await chromium.launch({\n    channel: \"chromium\",\n    headless: true,\n    args: [\"--disable-web-security\"],\n  })\n\n  const context = await browser.newContext({\n    ignoreHTTPSErrors: true,\n    viewport: {\n      width: 1440,\n      height: 980,\n    },\n    colorScheme: \"light\",\n  })\n\n  let page = await context.newPage()\n  const account = createTestAccount(\"ui-audit\")\n\n  let screenshotIndex = 1\n  const capture = async (name: string) => {\n    const path = join(outputDir, `${String(screenshotIndex).padStart(2, \"0\")}-${name}.png`)\n    screenshotIndex += 1\n    await page.screenshot({ path, fullPage: false })\n    console.info(path)\n  }\n\n  const bootstrapAccount = async () => {\n    for (let attempt = 1; attempt <= 3; attempt += 1) {\n      try {\n        await bootstrapAuthenticatedWebSession(page, env, account)\n        return\n      } catch (error) {\n        await capture(`auth-bootstrap-attempt-${attempt}-failed`)\n        if (attempt === 3) {\n          throw error\n        }\n\n        await page.goto(buildWebAppURL(env, \"/\"), { waitUntil: \"domcontentloaded\" })\n        await waitForUiSettled(page)\n      }\n    }\n  }\n\n  try {\n    await openWebApp(page, env)\n    await waitForUiSettled(page)\n    await capture(\"00-login-modal\")\n    await page.close()\n    page = await context.newPage()\n\n    await bootstrapAccount()\n    await waitForUiSettled(page)\n    await capture(\"01-home-articles\")\n\n    await followOnboardingFeed(page, env)\n    await waitForUiSettled(page)\n    await capture(\"02-discover-follow\")\n    await dismissFeedForm(page)\n\n    const timelineTabs = await page.locator('[data-testid^=\"timeline-tab-\"]').all()\n    for (const tab of timelineTabs) {\n      const testId = await tab.getAttribute(\"data-testid\")\n      if (!testId) continue\n      await tab.click()\n      await waitForUiSettled(page)\n      await capture(`timeline-${testId.replace(\"timeline-tab-\", \"\")}`)\n    }\n\n    for (const route of SUBVIEW_ROUTES) {\n      await page.goto(buildWebAppURL(env, route), { waitUntil: \"domcontentloaded\" })\n      await waitForRouteReady(page, route)\n      await capture(`subview-${route}`)\n    }\n\n    await page.goto(buildWebAppURL(env, \"/\"), { waitUntil: \"domcontentloaded\" })\n    await waitForUiSettled(page)\n\n    await openSettings(page)\n    await waitForUiSettled(page)\n\n    for (const tab of SETTING_TABS) {\n      if (tab === \"general\") {\n        await capture(\"settings-general\")\n        continue\n      }\n\n      const tabTrigger = page.getByTestId(`settings-tab-${tab}`)\n      if (!(await tabTrigger.isVisible().catch(() => false))) {\n        continue\n      }\n\n      await tabTrigger.click()\n      await waitForUiSettled(page)\n      await capture(`settings-${tab}`)\n    }\n\n    await closeSettings(page)\n    await waitForUiSettled(page)\n    await capture(\"home-after-settings\")\n  } finally {\n    await tryDeleteCurrentUser(page, env).catch(() => null)\n    await context.close().catch(() => {})\n    await browser.close().catch(() => {})\n  }\n}\n\nvoid main()\n"
  },
  {
    "path": "apps/desktop/e2e/support/account.ts",
    "content": "import type { Page } from \"@playwright/test\"\n\nimport type { DesktopE2EEnv } from \"./env\"\n\nexport interface TestAccount {\n  email: string\n  password: string\n}\n\nexport const createTestAccount = (name: string): TestAccount => {\n  const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`\n\n  return {\n    email: `folo-e2e-${name}-${suffix}@example.com`,\n    password: process.env.FOLO_E2E_PASSWORD ?? \"Password123!\",\n  }\n}\n\nexport const tryDeleteCurrentUser = async (page: Page, env: DesktopE2EEnv) => {\n  return page.evaluate(async ({ apiURL }) => {\n    try {\n      const response = await fetch(`${apiURL}/better-auth/delete-user-custom`, {\n        method: \"POST\",\n        credentials: \"include\",\n        headers: {\n          \"content-type\": \"application/json\",\n        },\n        body: JSON.stringify({}),\n      })\n\n      return {\n        ok: response.ok,\n        status: response.status,\n        text: await response.text(),\n      }\n    } catch (error) {\n      return {\n        ok: false,\n        status: -1,\n        text: error instanceof Error ? error.message : String(error),\n      }\n    }\n  }, env)\n}\n"
  },
  {
    "path": "apps/desktop/e2e/support/app.ts",
    "content": "import type { Locator, Page } from \"@playwright/test\"\nimport { expect } from \"@playwright/test\"\n\nimport type { TestAccount } from \"./account\"\nimport type { DesktopE2EEnv } from \"./env\"\nimport { buildWebAppURL } from \"./env\"\n\nconst ONBOARDING_FEED_URL = \"folo://onboarding\"\n\nconst isVisible = async (locator: Locator) => locator.isVisible().catch(() => false)\nconst visibleByTestId = (page: Page, testId: string) =>\n  page.locator(`[data-testid=\"${testId}\"]:visible`).last()\n\nexport const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {\n  await page.addInitScript(\n    (nextEnv) => {\n      window.__FOLO_E2E_RECAPTCHA_TOKEN__ = \"e2e-token\"\n\n      const originalFetch = globalThis.fetch.bind(globalThis)\n      const authEndpoints = [\n        \"/better-auth/sign-in/email\",\n        \"/better-auth/sign-up/email\",\n        \"/better-auth/forget-password\",\n      ]\n\n      globalThis.fetch = async (input, init) => {\n        const request = input instanceof Request ? input : new Request(input, init)\n        const requestURL = new URL(request.url, globalThis.location.origin)\n        const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path))\n\n        if (!shouldInjectToken) {\n          return originalFetch(input, init)\n        }\n\n        const headers = new Headers(request.headers)\n        if (!headers.has(\"x-token\")) {\n          headers.set(\"x-token\", \"r3:e2e-token\")\n        }\n\n        return originalFetch(new Request(request, { headers }))\n      }\n\n      if (!nextEnv) {\n        return\n      }\n\n      const fixedEnv = {\n        VITE_API_URL: nextEnv.apiURL,\n        VITE_EXTERNAL_API_URL: nextEnv.apiURL,\n        VITE_WEB_URL: nextEnv.webURL,\n      }\n\n      const target =\n        (globalThis as typeof globalThis & { __followEnv?: Record<string, string> }).__followEnv ??\n        {}\n\n      const proxy = new Proxy(target, {\n        get(currentTarget, property, receiver) {\n          if (typeof property === \"string\" && property in fixedEnv) {\n            return fixedEnv[property as keyof typeof fixedEnv]\n          }\n\n          return Reflect.get(currentTarget, property, receiver)\n        },\n        set(currentTarget, property, value, receiver) {\n          if (typeof property === \"string\" && property in fixedEnv) {\n            return true\n          }\n\n          return Reflect.set(currentTarget, property, value, receiver)\n        },\n        ownKeys(currentTarget) {\n          return Array.from(new Set([...Reflect.ownKeys(currentTarget), ...Object.keys(fixedEnv)]))\n        },\n        getOwnPropertyDescriptor(currentTarget, property) {\n          if (typeof property === \"string\" && property in fixedEnv) {\n            return {\n              configurable: true,\n              enumerable: true,\n              writable: false,\n              value: fixedEnv[property as keyof typeof fixedEnv],\n            }\n          }\n\n          return Reflect.getOwnPropertyDescriptor(currentTarget, property)\n        },\n      })\n\n      Object.defineProperty(globalThis, \"__followEnv\", {\n        configurable: true,\n        enumerable: false,\n        get() {\n          return proxy\n        },\n        set() {},\n      })\n    },\n    env ? { apiURL: env.apiURL, webURL: env.webURL } : undefined,\n  )\n}\n\nexport const openWebApp = async (page: Page, env: DesktopE2EEnv, route = \"/\") => {\n  await injectRecaptchaToken(page, env)\n  await page.goto(buildWebAppURL(env, route), { waitUntil: \"domcontentloaded\" })\n}\n\nexport const waitForAuthenticated = async (page: Page) => {\n  const isAuthenticatedUiReady = async () => {\n    const profileVisible = await page\n      .getByTestId(\"profile-menu-trigger\")\n      .isVisible()\n      .catch(() => false)\n    const loginModalVisible = await page\n      .getByTestId(\"login-modal\")\n      .isVisible()\n      .catch(() => false)\n\n    return profileVisible && !loginModalVisible\n  }\n\n  await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true)\n}\n\nexport const waitForLoggedOut = async (page: Page) => {\n  await expect\n    .poll(\n      async () => {\n        const loginButtonVisible = await page\n          .getByTestId(\"login-button\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n        const loginModalVisible = await page\n          .getByTestId(\"login-modal\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n        const loginInputVisible = await page\n          .getByTestId(\"login-email-input\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n        const registerInputVisible = await page\n          .getByTestId(\"register-email-input\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n\n        return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible\n      },\n      { timeout: 30_000 },\n    )\n    .toBe(true)\n}\n\nexport const ensureLoginModal = async (page: Page) => {\n  await expect\n    .poll(\n      async () => {\n        const loginModalVisible = await page\n          .getByTestId(\"login-modal\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n        const loginButtonVisible = await page\n          .getByTestId(\"login-button\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n        const loginInputVisible = await page\n          .getByTestId(\"login-email-input\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n        const registerInputVisible = await page\n          .getByTestId(\"register-email-input\")\n          .last()\n          .isVisible()\n          .catch(() => false)\n\n        return loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible\n      },\n      { timeout: 30_000 },\n    )\n    .toBe(true)\n}\n\nconst ensureCredentialForm = async (page: Page, mode: \"register\" | \"login\") => {\n  await ensureLoginModal(page)\n\n  const targetInput = visibleByTestId(\n    page,\n    mode === \"register\" ? \"register-email-input\" : \"login-email-input\",\n  )\n  const loginButton = visibleByTestId(page, \"login-button\")\n  const loginModal = visibleByTestId(page, \"login-modal\")\n  const activeDialog = page.locator('[role=\"dialog\"]:visible').last()\n  const credentialProvider = visibleByTestId(page, \"login-provider-credential\")\n  const targetForm = visibleByTestId(page, mode === \"register\" ? \"register-form\" : \"login-form\")\n  const oppositeForm = visibleByTestId(page, mode === \"register\" ? \"login-form\" : \"register-form\")\n  const oppositeFormSwitcher = visibleByTestId(\n    page,\n    mode === \"register\" ? \"login-switch-register\" : \"register-switch-login\",\n  )\n\n  if (await isVisible(targetInput)) {\n    return\n  }\n\n  if (\n    (await isVisible(loginButton)) &&\n    !(await isVisible(loginModal)) &&\n    !(await isVisible(activeDialog))\n  ) {\n    await expect(loginButton).toBeVisible({ timeout: 30_000 })\n    await loginButton.click({ noWaitAfter: true })\n  }\n\n  if (await isVisible(targetInput)) {\n    return\n  }\n\n  if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) {\n    await expect(credentialProvider).toBeVisible({ timeout: 30_000 })\n    await credentialProvider.click({ timeout: 30_000, noWaitAfter: true })\n    await expect\n      .poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {\n        timeout: 30_000,\n      })\n      .toBe(true)\n  }\n\n  if (await isVisible(oppositeForm)) {\n    await expect(oppositeFormSwitcher).toBeVisible({ timeout: 30_000 })\n    await oppositeFormSwitcher.click({ timeout: 30_000, noWaitAfter: true })\n  }\n\n  await expect(targetInput).toBeVisible({ timeout: 30_000 })\n}\n\nexport const registerWithCredential = async (page: Page, account: TestAccount) => {\n  await ensureCredentialForm(page, \"register\")\n  await visibleByTestId(page, \"register-email-input\").fill(account.email)\n  await visibleByTestId(page, \"register-password-input\").fill(account.password)\n  const confirmPasswordInput = visibleByTestId(page, \"register-confirm-password-input\")\n  await confirmPasswordInput.fill(account.password)\n  const submit = visibleByTestId(page, \"register-submit\")\n  await expect(submit).toBeEnabled({ timeout: 30_000 })\n\n  await submit.click()\n  await waitForAuthenticated(page)\n}\n\nexport const loginWithCredential = async (page: Page, account: TestAccount) => {\n  await ensureCredentialForm(page, \"login\")\n  await visibleByTestId(page, \"login-email-input\").fill(account.email)\n  const passwordInput = visibleByTestId(page, \"login-password-input\")\n  await passwordInput.fill(account.password)\n  const submit = visibleByTestId(page, \"login-submit\")\n  await expect(submit).toBeEnabled({ timeout: 30_000 })\n\n  await submit.click()\n  await waitForAuthenticated(page)\n}\n\nexport const logoutFromProfileMenu = async (page: Page) => {\n  await page.keyboard.press(\"Escape\").catch(() => {})\n  if (page.url().startsWith(\"app://\")) {\n    await returnToMainShell(page)\n  }\n  await page.getByTestId(\"profile-menu-trigger\").click()\n\n  const signOutResponse = page\n    .waitForResponse(\n      (response) =>\n        response.request().method() === \"POST\" && response.url().includes(\"/better-auth/sign-out\"),\n      { timeout: 30_000 },\n    )\n    .catch(() => null)\n\n  await page.getByTestId(\"profile-menu-logout\").click()\n  await signOutResponse\n  await waitForLoggedOut(page)\n}\n\nconst waitForMainShell = async (page: Page) => {\n  await expect\n    .poll(\n      async () => {\n        const profileVisible = await page\n          .getByTestId(\"profile-menu-trigger\")\n          .isVisible()\n          .catch(() => false)\n        const timelineVisible = await page\n          .getByTestId(\"timeline-tab-articles\")\n          .isVisible()\n          .catch(() => false)\n\n        return profileVisible || timelineVisible\n      },\n      { timeout: 30_000 },\n    )\n    .toBe(true)\n}\n\nconst returnToMainShell = async (page: Page) => {\n  const discoverInput = page.getByTestId(\"discover-form-input\")\n  if (await discoverInput.isVisible().catch(() => false)) {\n    const backButton = page.getByTestId(\"subview-back\")\n\n    if (await backButton.isVisible().catch(() => false)) {\n      await backButton.click()\n    } else {\n      await page.keyboard.press(\"Escape\").catch(() => {})\n    }\n\n    if (await discoverInput.isVisible().catch(() => false)) {\n      await page.keyboard.press(\"Escape\").catch(() => {})\n    }\n\n    await expect\n      .poll(async () => discoverInput.isVisible().catch(() => false), { timeout: 15_000 })\n      .toBe(false)\n  }\n\n  await waitForMainShell(page)\n\n  const activeDialog = page.locator('[role=\"dialog\"]:visible').last()\n  if (await activeDialog.isVisible().catch(() => false)) {\n    await page.keyboard.press(\"Escape\").catch(() => {})\n\n    if (await activeDialog.isVisible().catch(() => false)) {\n      const modalClose = activeDialog.getByTestId(\"modal-close\").first()\n      if (await modalClose.isVisible().catch(() => false)) {\n        await modalClose.click().catch(() => {})\n      }\n    }\n\n    await expect\n      .poll(async () => activeDialog.isVisible().catch(() => false), { timeout: 10_000 })\n      .toBe(false)\n  }\n}\n\nconst waitForSettingsTabContent = async (page: Page, tab: \"general\" | \"feeds\") => {\n  if (tab === \"general\") {\n    await expect(page.getByTestId(\"settings-language-select\")).toBeVisible({ timeout: 15_000 })\n    return\n  }\n\n  await expect\n    .poll(async () => page.locator('[data-testid^=\"settings-feed-row-\"]').count(), {\n      timeout: 15_000,\n    })\n    .toBeGreaterThan(0)\n}\n\nexport const openSettings = async (page: Page, tab: \"general\" | \"feeds\" = \"general\") => {\n  await waitForAuthenticated(page)\n\n  const settingsModal = page.locator(\"#setting-modal\").first()\n\n  const openSettingsFromMenu = async () => {\n    await returnToMainShell(page)\n    const profileTrigger = page.getByTestId(\"profile-menu-trigger\")\n    await expect(profileTrigger).toBeVisible({ timeout: 15_000 })\n    await profileTrigger.click()\n\n    const preferencesItem = page.getByTestId(\"profile-menu-preferences\")\n    await expect(preferencesItem).toBeVisible({ timeout: 15_000 })\n    await preferencesItem.click()\n\n    await expect(settingsModal).toBeVisible({ timeout: 15_000 })\n  }\n\n  if (!(await settingsModal.isVisible().catch(() => false))) {\n    try {\n      await openSettingsFromMenu()\n    } catch {\n      await openSettingsFromMenu()\n    }\n  }\n\n  await openSettingsTab(page, tab)\n}\n\nexport const openSettingsTab = async (page: Page, tab: \"general\" | \"feeds\") => {\n  const settingsTab = page.getByTestId(`settings-tab-${tab}`)\n  await expect(settingsTab).toBeVisible({ timeout: 15_000 })\n\n  if (tab === \"feeds\") {\n    await expect\n      .poll(\n        async () => {\n          const className = (await settingsTab.getAttribute(\"class\")) ?? \"\"\n          return !className.includes(\"opacity-50\")\n        },\n        { timeout: 15_000 },\n      )\n      .toBe(true)\n  }\n\n  await settingsTab.click()\n  await waitForSettingsTabContent(page, tab)\n}\n\nexport const closeSettings = async (page: Page) => {\n  const settingsModal = page.locator(\"#setting-modal\").first()\n  if (!(await settingsModal.isVisible().catch(() => false))) {\n    return\n  }\n\n  await page.keyboard.press(\"Escape\").catch(() => {})\n\n  if (await settingsModal.isVisible().catch(() => false)) {\n    const modalClose = settingsModal.getByTestId(\"modal-close\").first()\n    if (await isVisible(modalClose)) {\n      await modalClose.click().catch(() => {})\n    }\n  }\n\n  await expect\n    .poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 })\n    .toBe(false)\n}\n\nexport const setLanguage = async (page: Page, label: string) => {\n  await page.getByTestId(\"settings-language-select\").click()\n  await page.getByRole(\"option\", { name: label }).click()\n}\n\nexport const getLanguageLabel = async (page: Page) => {\n  return page.getByTestId(\"settings-language-select\").textContent()\n}\n\nexport const openOnboardingFeedForm = async (\n  page: Page,\n  _env?: DesktopE2EEnv,\n  _options?: { electron?: boolean },\n) => {\n  const discoverInput = page.getByTestId(\"discover-form-input\")\n  if (!(await discoverInput.isVisible().catch(() => false))) {\n    await returnToMainShell(page)\n    const discoverTrigger = page.getByTestId(\"subscription-discover-trigger\")\n    await expect(discoverTrigger).toBeVisible({ timeout: 15_000 })\n    await discoverTrigger.click()\n  }\n\n  await expect(discoverInput).toBeVisible({ timeout: 15_000 })\n  await discoverInput.fill(ONBOARDING_FEED_URL)\n  await discoverInput.press(\"Enter\")\n  await expect(page.getByText(\"Welcome to Folo\").first()).toBeVisible({ timeout: 15_000 })\n}\n\nexport const followOnboardingFeed = async (\n  page: Page,\n  env: DesktopE2EEnv,\n  options?: { electron?: boolean },\n) => {\n  await openOnboardingFeedForm(page, env, options)\n  const onboardingDiscoverCard = page\n    .locator(\"[data-feed-id]\")\n    .filter({ hasText: \"Welcome to Folo\" })\n    .first()\n  const followButton = onboardingDiscoverCard.getByRole(\"button\", { name: /^Follow$/i })\n  if (await followButton.isVisible().catch(() => false)) {\n    await expect(followButton).toBeEnabled({ timeout: 15_000 })\n    await followButton.click()\n  }\n  await expect(page.getByText(\"Welcome to Folo\").first()).toBeVisible({ timeout: 15_000 })\n}\n\nexport const dismissFeedForm = async (page: Page) => {\n  const cancelButton = visibleByTestId(page, \"feed-form-cancel\")\n  const dialog = page.locator('[role=\"dialog\"]').last()\n\n  if (!(await cancelButton.isVisible().catch(() => false))) {\n    if (await dialog.isVisible().catch(() => false)) {\n      await page.keyboard.press(\"Escape\").catch(() => {})\n    }\n    return\n  }\n\n  await cancelButton.click()\n\n  if (\n    (await cancelButton.isVisible().catch(() => false)) ||\n    (await dialog.isVisible().catch(() => false))\n  ) {\n    await page.keyboard.press(\"Escape\").catch(() => {})\n  }\n}\n\nconst findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => {\n  const targetedFeedRow = onboardingFeedId\n    ? page.getByTestId(`settings-feed-row-${onboardingFeedId}`)\n    : null\n  const fallbackFeedRow = page\n    .locator('[data-testid^=\"settings-feed-row-\"]')\n    .filter({\n      hasText: \"Welcome to Folo\",\n    })\n    .first()\n  const settingsViewport = page.locator(\"#setting-modal [data-radix-scroll-area-viewport]\").first()\n\n  await settingsViewport\n    .evaluate((element) => {\n      if (element instanceof HTMLElement) {\n        element.scrollTop = 0\n      }\n    })\n    .catch(() => {})\n\n  for (let attempt = 0; attempt < 24; attempt++) {\n    if (targetedFeedRow && (await targetedFeedRow.isVisible().catch(() => false))) {\n      return targetedFeedRow\n    }\n\n    if (await fallbackFeedRow.isVisible().catch(() => false)) {\n      return fallbackFeedRow\n    }\n\n    await settingsViewport.hover().catch(() => {})\n    await page.mouse.wheel(0, 1200)\n    await page.waitForTimeout(150)\n  }\n\n  return targetedFeedRow && (await targetedFeedRow.count()) > 0 ? targetedFeedRow : fallbackFeedRow\n}\n\nexport const unsubscribeFirstFeedFromSettings = async (page: Page, _env?: DesktopE2EEnv) => {\n  const onboardingFeedItem = page\n    .locator(\"[data-feed-id]\")\n    .filter({\n      hasText: \"Welcome to Folo\",\n    })\n    .first()\n  const onboardingFeedId =\n    (await onboardingFeedItem.count()) > 0\n      ? await onboardingFeedItem.getAttribute(\"data-feed-id\")\n      : null\n\n  await openSettings(page)\n  await openSettingsTab(page, \"feeds\")\n  const feedRow = await findSettingsFeedRow(page, onboardingFeedId)\n\n  await expect(feedRow).toBeVisible({ timeout: 15_000 })\n  await feedRow.scrollIntoViewIfNeeded().catch(() => {})\n  const feedRowTestId = await feedRow.getAttribute(\"data-testid\")\n  await feedRow.click()\n\n  const unsubscribeButton = page.getByTestId(\"feeds-batch-unsubscribe\")\n  await expect(unsubscribeButton).toBeVisible({ timeout: 15_000 })\n  await unsubscribeButton.click()\n  await page.getByTestId(\"confirm-destroy\").click()\n\n  if (feedRowTestId) {\n    await expect(page.getByTestId(feedRowTestId)).toHaveCount(0, { timeout: 15_000 })\n  } else {\n    await expect(feedRow).toHaveCount(0, { timeout: 15_000 })\n  }\n}\n\nexport const expectOnboardingFeedUnsubscribed = async (\n  page: Page,\n  _env?: DesktopE2EEnv,\n  _options?: { electron?: boolean },\n) => {\n  await openOnboardingFeedForm(page)\n  await expect(page.getByTestId(\"feed-form-cancel\")).toHaveCount(0)\n}\n\nexport const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => {\n  await returnToMainShell(page)\n\n  const videosTab = page.getByTestId(\"timeline-tab-videos\")\n  await videosTab.click()\n  await expect(videosTab).toHaveAttribute(\"aria-pressed\", \"true\", { timeout: 15_000 })\n  await expect.poll(async () => page.locator(\"[data-entry-id]\").count()).toBeGreaterThan(0)\n\n  const articlesTab = page.getByTestId(\"timeline-tab-articles\")\n  await articlesTab.click()\n  await expect(articlesTab).toHaveAttribute(\"aria-pressed\", \"true\", { timeout: 15_000 })\n  await expect.poll(async () => page.locator(\"[data-entry-id]\").count()).toBeGreaterThan(0)\n\n  const unreadOnboardingEntry = page\n    .locator('[data-entry-id][data-read=\"false\"]:visible')\n    .filter({ has: page.locator(\"a[href]\") })\n    .first()\n  await expect(unreadOnboardingEntry).toBeVisible({ timeout: 15_000 })\n\n  const onboardingEntryId = await unreadOnboardingEntry.getAttribute(\"data-entry-id\")\n  expect(onboardingEntryId).toBeTruthy()\n\n  const onboardingEntry = page.locator(`[data-entry-id=\"${onboardingEntryId}\"]`)\n  const onboardingEntryLink = unreadOnboardingEntry.locator(\"a[href]\").first()\n\n  await unreadOnboardingEntry.scrollIntoViewIfNeeded().catch(() => {})\n  await expect(onboardingEntryLink).toBeVisible({ timeout: 15_000 })\n  await onboardingEntryLink.click()\n\n  const entryRender = page.getByTestId(\"entry-render\")\n  await expect(entryRender).toBeVisible({ timeout: 15_000 })\n  await expect(onboardingEntry).toHaveAttribute(\"data-active\", \"true\", { timeout: 15_000 })\n  await expect(onboardingEntry).toHaveAttribute(\"data-read\", \"true\", { timeout: 15_000 })\n\n  const toggleReadButton = page.getByTestId(\"command-action-entry-read\").last()\n  await expect(toggleReadButton).toBeVisible({ timeout: 15_000 })\n  await expect(toggleReadButton).toBeEnabled({ timeout: 15_000 })\n\n  await toggleReadButton.click()\n  await expect(onboardingEntry).toHaveAttribute(\"data-read\", \"false\", { timeout: 15_000 })\n\n  await toggleReadButton.click()\n  await expect(onboardingEntry).toHaveAttribute(\"data-read\", \"true\", { timeout: 15_000 })\n}\n"
  },
  {
    "path": "apps/desktop/e2e/support/auth-bootstrap.ts",
    "content": "import type { BrowserContext, Page } from \"@playwright/test\"\nimport { nanoid } from \"nanoid\"\n\nimport type { TestAccount } from \"./account\"\nimport { injectRecaptchaToken, waitForAuthenticated } from \"./app\"\nimport type { DesktopE2EEnv } from \"./env\"\nimport { buildWebAppURL } from \"./env\"\n\ntype AuthBootstrapResponse = {\n  token?: string | null\n  error?: {\n    message?: string\n  } | null\n}\n\ntype ParsedCookie = {\n  expires?: number\n  httpOnly: boolean\n  name: string\n  path: string\n  sameSite: \"Lax\" | \"None\" | \"Strict\"\n  secure: boolean\n  value: string\n}\n\nconst splitSetCookieHeader = (header: string) => {\n  const parts: string[] = []\n  let buffer = \"\"\n\n  for (const char of header) {\n    if (char === \",\") {\n      const recent = buffer.toLowerCase()\n      const hasExpires = recent.includes(\"expires=\")\n      const hasGmt = /gmt/i.test(recent)\n\n      if (hasExpires && !hasGmt) {\n        buffer += char\n        continue\n      }\n\n      if (buffer.trim()) {\n        parts.push(buffer.trim())\n      }\n      buffer = \"\"\n      continue\n    }\n\n    buffer += char\n  }\n\n  if (buffer.trim()) {\n    parts.push(buffer.trim())\n  }\n\n  return parts\n}\n\nconst parseSetCookieHeader = (header: string): ParsedCookie[] => {\n  return splitSetCookieHeader(header)\n    .map((cookie) => {\n      const [nameValue, ...attributes] = cookie.split(\";\").map((part) => part.trim())\n      const [name, ...valueParts] = nameValue?.split(\"=\") ?? []\n      if (!name) {\n        return null\n      }\n\n      const parsedCookie: ParsedCookie = {\n        name,\n        value: valueParts.join(\"=\"),\n        path: \"/\",\n        httpOnly: false,\n        secure: false,\n        sameSite: \"Lax\",\n      }\n\n      for (const attribute of attributes) {\n        const [rawKey, ...rawValueParts] = attribute.split(\"=\")\n        const key = rawKey?.toLowerCase()\n        const value = rawValueParts.join(\"=\")\n\n        switch (key) {\n          case \"expires\": {\n            const expires = new Date(value)\n            if (!Number.isNaN(expires.getTime())) {\n              parsedCookie.expires = expires.getTime() / 1000\n            }\n            break\n          }\n          case \"httponly\": {\n            parsedCookie.httpOnly = true\n            break\n          }\n          case \"path\": {\n            parsedCookie.path = value || \"/\"\n            break\n          }\n          case \"samesite\": {\n            if (value === \"None\" || value === \"Strict\" || value === \"Lax\") {\n              parsedCookie.sameSite = value\n            }\n            break\n          }\n          case \"secure\": {\n            parsedCookie.secure = true\n            break\n          }\n        }\n      }\n\n      return parsedCookie\n    })\n    .filter(Boolean)\n}\n\nconst requestAuth = async ({\n  apiURL,\n  path,\n  body,\n}: {\n  apiURL: string\n  body: Record<string, unknown>\n  path: string\n}) => {\n  const response = await fetch(new URL(path, apiURL), {\n    method: \"POST\",\n    headers: {\n      \"Cache-Control\": \"no-store\",\n      \"content-type\": \"application/json\",\n      \"x-app-name\": \"Folo Web\",\n      \"x-app-platform\": \"desktop/web\",\n      \"x-app-version\": \"1.4.0\",\n      \"x-client-id\": nanoid(),\n      \"x-session-id\": nanoid(),\n      \"x-token\": \"ac:fallback\",\n    },\n    body: JSON.stringify(body),\n  })\n\n  return {\n    response,\n    body: (await response.json().catch(() => null)) as AuthBootstrapResponse | null,\n    setCookie: response.headers.get(\"set-cookie\"),\n  }\n}\n\nconst signIn = (env: DesktopE2EEnv, account: TestAccount) =>\n  requestAuth({\n    apiURL: env.apiURL,\n    path: \"/better-auth/sign-in/email\",\n    body: {\n      email: account.email,\n      password: account.password,\n      rememberMe: true,\n    },\n  })\n\nconst signUp = (env: DesktopE2EEnv, account: TestAccount) =>\n  requestAuth({\n    apiURL: env.apiURL,\n    path: \"/better-auth/sign-up/email\",\n    body: {\n      email: account.email,\n      password: account.password,\n      name: account.email.split(\"@\")[0] ?? account.email,\n      callbackURL: `${env.webURL}/login`,\n    },\n  })\n\nconst applyCookiesToContext = async (\n  context: BrowserContext,\n  env: DesktopE2EEnv,\n  setCookieHeader: string,\n) => {\n  const cookies = parseSetCookieHeader(setCookieHeader)\n  await context.addCookies(\n    cookies.map((cookie) => ({\n      url: env.apiURL,\n      name: cookie.name,\n      value: cookie.value,\n      httpOnly: cookie.httpOnly,\n      secure: cookie.secure,\n      sameSite: cookie.sameSite,\n      expires: cookie.expires,\n    })),\n  )\n}\n\nexport const bootstrapAuthenticatedWebSession = async (\n  page: Page,\n  env: DesktopE2EEnv,\n  account: TestAccount,\n) => {\n  let signInResult = await signIn(env, account)\n\n  if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) {\n    const signUpResult = await signUp(env, account)\n    const signUpError = signUpResult.body?.error?.message?.toLowerCase() ?? \"\"\n    const isExistingAccount =\n      signUpError.includes(\"exist\") ||\n      signUpError.includes(\"already\") ||\n      signUpError.includes(\"taken\")\n\n    if ((!signUpResult.response.ok || signUpResult.body?.error) && !isExistingAccount) {\n      throw new Error(\n        signUpResult.body?.error?.message ||\n          signInResult.body?.error?.message ||\n          `auth bootstrap failed with ${signUpResult.response.status}`,\n      )\n    }\n\n    signInResult = await signIn(env, account)\n  }\n\n  if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) {\n    throw new Error(\n      signInResult.body?.error?.message || `sign in failed with ${signInResult.response.status}`,\n    )\n  }\n\n  await applyCookiesToContext(page.context(), env, signInResult.setCookie)\n  await injectRecaptchaToken(page, env)\n  await page.goto(buildWebAppURL(env, \"/\"), { waitUntil: \"domcontentloaded\" })\n  await waitForAuthenticated(page)\n}\n"
  },
  {
    "path": "apps/desktop/e2e/support/electron.ts",
    "content": "import { execSync } from \"node:child_process\"\nimport { mkdtemp, rm } from \"node:fs/promises\"\nimport { tmpdir } from \"node:os\"\n\nimport type { ElectronApplication, Page } from \"@playwright/test\"\nimport { _electron as electron } from \"@playwright/test\"\nimport { join } from \"pathe\"\n\nimport type { DesktopE2EEnv } from \"./env\"\n\nlet buildSignature: string | null = null\n\nconst ensureElectronBuilt = (env: DesktopE2EEnv) => {\n  const nextSignature = `${env.apiURL}|${env.webURL}`\n  if (buildSignature === nextSignature) {\n    return\n  }\n\n  execSync(\"pnpm run build:electron-vite\", {\n    cwd: env.desktopAppDir,\n    env: {\n      ...process.env,\n      VITE_API_URL: env.apiURL,\n      VITE_WEB_URL: env.webURL,\n    },\n    stdio: \"inherit\",\n  })\n\n  buildSignature = nextSignature\n}\n\nexport const launchElectronApp = async (env: DesktopE2EEnv) => {\n  ensureElectronBuilt(env)\n\n  const userDataDir = await mkdtemp(join(tmpdir(), \"folo-e2e-\"))\n  const electronApp = await electron.launch({\n    args: [env.desktopAppDir],\n    cwd: env.desktopAppDir,\n    env: {\n      ...process.env,\n      CI: process.env.CI ?? \"1\",\n      NODE_ENV: \"test\",\n      VITE_API_URL: env.apiURL,\n      VITE_WEB_URL: env.webURL,\n      FOLO_E2E_USER_DATA_DIR: userDataDir,\n    },\n    timeout: 120_000,\n  })\n\n  const page = await electronApp.firstWindow()\n  await page.waitForLoadState(\"domcontentloaded\")\n  await page.evaluate(() => {\n    window.__FOLO_E2E_RECAPTCHA_TOKEN__ = \"e2e-token\"\n\n    const originalFetch = globalThis.fetch.bind(globalThis)\n    const authEndpoints = [\n      \"/better-auth/sign-in/email\",\n      \"/better-auth/sign-up/email\",\n      \"/better-auth/forget-password\",\n    ]\n\n    globalThis.fetch = async (input, init) => {\n      const request = input instanceof Request ? input : new Request(input, init)\n      const requestURL = new URL(request.url, globalThis.location.origin)\n      const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path))\n\n      if (!shouldInjectToken) {\n        return originalFetch(input, init)\n      }\n\n      const headers = new Headers(request.headers)\n      if (!headers.has(\"x-token\")) {\n        headers.set(\"x-token\", \"r3:e2e-token\")\n      }\n\n      return originalFetch(new Request(request, { headers }))\n    }\n  })\n\n  return {\n    electronApp,\n    page,\n    userDataDir,\n  }\n}\n\nexport const closeElectronApp = async (app: {\n  electronApp: ElectronApplication\n  page: Page\n  userDataDir: string\n}) => {\n  await app.electronApp.close().catch(() => {})\n  await rm(app.userDataDir, { force: true, recursive: true })\n}\n"
  },
  {
    "path": "apps/desktop/e2e/support/env.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport { join } from \"pathe\"\n\nexport type DesktopE2EProfile = \"local\" | \"prod\"\n\nconst DESKTOP_E2E_PROFILES = {\n  local: {\n    apiURL: \"http://localhost:3000\",\n    webURL: \"http://localhost:2233\",\n    webBaseURL: \"http://localhost:2233\",\n    webUsesHashRouter: false,\n  },\n  prod: {\n    apiURL: \"https://api.folo.is\",\n    webURL: \"https://app.folo.is\",\n    webBaseURL: null,\n    webUsesHashRouter: true,\n  },\n} as const\n\nexport interface DesktopE2EEnv {\n  profile: DesktopE2EProfile\n  apiURL: string\n  webURL: string\n  webBaseURL: string\n  webUsesHashRouter: boolean\n  webDevServerURL: string\n  debugProxyPath: string\n  desktopAppDir: string\n}\n\nconst supportDir = fileURLToPath(new URL(\".\", import.meta.url))\nconst desktopAppDir = join(supportDir, \"..\", \"..\")\n\nconst normalizeRoute = (route: string) => {\n  if (!route || route === \"/\") {\n    return \"/\"\n  }\n\n  return route.startsWith(\"/\") ? route : `/${route}`\n}\n\nexport const resolveDesktopE2EEnv = (): DesktopE2EEnv => {\n  const profile = (process.env.FOLO_E2E_PROFILE ?? \"local\") as DesktopE2EProfile\n  const resolvedProfile = profile in DESKTOP_E2E_PROFILES ? profile : \"local\"\n  const profileConfig = DESKTOP_E2E_PROFILES[resolvedProfile]\n  const webDevServerURL = process.env.FOLO_E2E_WEB_DEV_SERVER_URL ?? \"http://localhost:2233\"\n  const debugProxyPath = process.env.FOLO_E2E_WEB_DEBUG_PROXY_PATH ?? \"/__debug_proxy.html\"\n\n  const webBaseURL =\n    resolvedProfile === \"prod\"\n      ? new URL(\n          `${debugProxyPath}?debug-host=${encodeURIComponent(webDevServerURL)}`,\n          profileConfig.webURL,\n        ).toString()\n      : profileConfig.webBaseURL\n\n  return {\n    profile: resolvedProfile,\n    apiURL: process.env.FOLO_E2E_API_URL ?? profileConfig.apiURL,\n    webURL: process.env.FOLO_E2E_WEB_URL ?? profileConfig.webURL,\n    webBaseURL,\n    webUsesHashRouter: profileConfig.webUsesHashRouter,\n    webDevServerURL,\n    debugProxyPath,\n    desktopAppDir,\n  }\n}\n\nexport const buildWebAppURL = (env: DesktopE2EEnv, route = \"/\") => {\n  const normalizedRoute = normalizeRoute(route)\n\n  if (env.webUsesHashRouter) {\n    const url = new URL(env.webBaseURL)\n    url.hash = normalizedRoute\n    return url.toString()\n  }\n\n  return new URL(normalizedRoute, `${env.webBaseURL}/`).toString()\n}\n\nexport const buildHashRoute = (route = \"/\") => normalizeRoute(route)\n"
  },
  {
    "path": "apps/desktop/e2e/tests/electron/core.spec.ts",
    "content": "import { expect, test } from \"@playwright/test\"\n\nimport { createTestAccount, tryDeleteCurrentUser } from \"../../support/account\"\nimport {\n  dismissFeedForm,\n  expectTimelineSwitchAndEntryReadFlow,\n  followOnboardingFeed,\n  loginWithCredential,\n  logoutFromProfileMenu,\n  registerWithCredential,\n  unsubscribeFirstFeedFromSettings,\n} from \"../../support/app\"\nimport { closeElectronApp, launchElectronApp } from \"../../support/electron\"\nimport { resolveDesktopE2EEnv } from \"../../support/env\"\n\ntest.describe(\"electron core flows\", () => {\n  test(\"covers registration, login, follow, unfollow, timeline and read state\", async () => {\n    test.setTimeout(240_000)\n\n    const env = resolveDesktopE2EEnv()\n    const account = createTestAccount(\"electron-core\")\n    let electronApp = await launchElectronApp(env)\n\n    try {\n      await test.step(\"registers a new account\", async () => {\n        await registerWithCredential(electronApp.page, account)\n      })\n\n      await test.step(\"logs out and logs back in\", async () => {\n        await logoutFromProfileMenu(electronApp.page)\n        await closeElectronApp(electronApp)\n        electronApp = await launchElectronApp(env)\n        await loginWithCredential(electronApp.page, account)\n      })\n\n      await test.step(\"follows onboarding feed\", async () => {\n        await followOnboardingFeed(electronApp.page, env)\n        await dismissFeedForm(electronApp.page)\n      })\n\n      await test.step(\"switches timeline, opens an entry, and toggles read state\", async () => {\n        await expectTimelineSwitchAndEntryReadFlow(electronApp.page)\n      })\n\n      await test.step(\"unsubscribes onboarding feed from settings\", async () => {\n        await unsubscribeFirstFeedFromSettings(electronApp.page)\n      })\n\n      const cleanup = await tryDeleteCurrentUser(electronApp.page, env)\n      expect(cleanup.status).toBeGreaterThanOrEqual(-1)\n      test.info().annotations.push({\n        type: \"cleanup\",\n        description: `delete-user-custom status=${cleanup.status}`,\n      })\n    } finally {\n      await closeElectronApp(electronApp)\n    }\n  })\n})\n"
  },
  {
    "path": "apps/desktop/e2e/tests/web/core.spec.ts",
    "content": "import { expect, test } from \"@playwright/test\"\n\nimport { createTestAccount, tryDeleteCurrentUser } from \"../../support/account\"\nimport {\n  closeSettings,\n  dismissFeedForm,\n  expectOnboardingFeedUnsubscribed,\n  expectTimelineSwitchAndEntryReadFlow,\n  followOnboardingFeed,\n  loginWithCredential,\n  logoutFromProfileMenu,\n  openWebApp,\n  registerWithCredential,\n  unsubscribeFirstFeedFromSettings,\n} from \"../../support/app\"\nimport { resolveDesktopE2EEnv } from \"../../support/env\"\n\ntest.describe(\"web core flows\", () => {\n  test(\"covers registration, login, follow, unfollow, timeline and read state\", async ({\n    page,\n    browser,\n  }) => {\n    test.setTimeout(180_000)\n\n    const env = resolveDesktopE2EEnv()\n    const account = createTestAccount(\"web-core\")\n    let activePage = page\n    let loginContext: Awaited<ReturnType<typeof browser.newContext>> | null = null\n\n    try {\n      await openWebApp(activePage, env)\n\n      await test.step(\"registers a new account\", async () => {\n        await registerWithCredential(activePage, account)\n      })\n\n      await test.step(\"follows onboarding feed\", async () => {\n        await followOnboardingFeed(activePage, env)\n        await dismissFeedForm(activePage)\n      })\n\n      await test.step(\"logs out and logs back in\", async () => {\n        await logoutFromProfileMenu(activePage)\n\n        loginContext = await browser.newContext()\n        activePage = await loginContext.newPage()\n        await openWebApp(activePage, env)\n        await loginWithCredential(activePage, account)\n      })\n\n      await test.step(\"switches timeline, opens an entry, and toggles read state\", async () => {\n        await expectTimelineSwitchAndEntryReadFlow(activePage)\n      })\n\n      await test.step(\"unsubscribes onboarding feed from settings\", async () => {\n        await unsubscribeFirstFeedFromSettings(activePage)\n        await closeSettings(activePage)\n        await expectOnboardingFeedUnsubscribed(activePage, env)\n      })\n\n      await test.step(\"re-subscribes onboarding feed\", async () => {\n        await followOnboardingFeed(activePage, env)\n        await dismissFeedForm(activePage)\n      })\n\n      await test.step(\"tries to clean up the temporary account\", async () => {\n        const cleanup = await tryDeleteCurrentUser(activePage, env)\n        expect(cleanup.status).toBeGreaterThanOrEqual(-1)\n        test.info().annotations.push({\n          type: \"cleanup\",\n          description: `delete-user-custom status=${cleanup.status}`,\n        })\n      })\n    } finally {\n      await loginContext?.close().catch(() => {})\n    }\n  })\n})\n"
  },
  {
    "path": "apps/desktop/e2e/tests/web/settings-sync.spec.ts",
    "content": "import type { BrowserContext } from \"@playwright/test\"\nimport { expect, test } from \"@playwright/test\"\n\nimport { createTestAccount, tryDeleteCurrentUser } from \"../../support/account\"\nimport {\n  getLanguageLabel,\n  loginWithCredential,\n  openSettings,\n  openWebApp,\n  registerWithCredential,\n  setLanguage,\n} from \"../../support/app\"\nimport { resolveDesktopE2EEnv } from \"../../support/env\"\n\nconst closeContextSafely = async (context: BrowserContext) => {\n  try {\n    await context.close()\n  } catch (error) {\n    if (error instanceof Error && error.message.includes(\"ENOENT\")) {\n      return\n    }\n\n    throw error\n  }\n}\n\ntest.describe(\"web multi-session sync\", () => {\n  test(\"syncs settings between two browser sessions\", async ({ browser }) => {\n    test.setTimeout(180_000)\n\n    const env = resolveDesktopE2EEnv()\n    const account = createTestAccount(\"web-sync\")\n\n    const contextA = await browser.newContext()\n    const contextB = await browser.newContext()\n    const pageA = await contextA.newPage()\n    const pageB = await contextB.newPage()\n\n    try {\n      await openWebApp(pageA, env)\n      await registerWithCredential(pageA, account)\n\n      await openWebApp(pageB, env)\n      await loginWithCredential(pageB, account)\n\n      await openSettings(pageA)\n      await openSettings(pageB)\n\n      await test.step(\"session A change syncs to session B\", async () => {\n        await setLanguage(pageA, \"日本語\")\n        await expect\n          .poll(async () => getLanguageLabel(pageA), { timeout: 15_000 })\n          .toContain(\"日本語\")\n        await expect\n          .poll(\n            async () => {\n              await pageB.reload({ waitUntil: \"domcontentloaded\" })\n              await openSettings(pageB)\n              return getLanguageLabel(pageB)\n            },\n            { timeout: 30_000 },\n          )\n          .toContain(\"日本語\")\n      })\n\n      await test.step(\"session B change syncs back to session A\", async () => {\n        await setLanguage(pageB, \"English\")\n        await expect\n          .poll(async () => getLanguageLabel(pageB), { timeout: 15_000 })\n          .toContain(\"English\")\n        await expect\n          .poll(\n            async () => {\n              await pageA.reload({ waitUntil: \"domcontentloaded\" })\n              await openSettings(pageA)\n              return getLanguageLabel(pageA)\n            },\n            { timeout: 60_000 },\n          )\n          .toContain(\"English\")\n      })\n\n      const cleanup = await tryDeleteCurrentUser(pageA, env)\n      expect(cleanup.status).toBeGreaterThanOrEqual(-1)\n      test.info().annotations.push({\n        type: \"cleanup\",\n        description: `delete-user-custom status=${cleanup.status}`,\n      })\n    } finally {\n      await closeContextSafely(contextA)\n      await closeContextSafely(contextB)\n    }\n  })\n})\n"
  },
  {
    "path": "apps/desktop/electron.vite.config.ts",
    "content": "import { defineConfig } from \"electron-vite\"\nimport { resolve } from \"pathe\"\n\nimport { getGitHash } from \"../../scripts/lib\"\nimport rendererConfig from \"./configs/vite.electron-render.config\"\n\nexport default defineConfig({\n  main: {\n    build: {\n      outDir: \"dist/main\",\n      lib: {\n        entry: \"./layer/main/src/index.ts\",\n      },\n    },\n    resolve: {\n      alias: {\n        \"@shared\": resolve(\"packages/shared/src\"),\n        \"@pkg\": resolve(\"./package.json\"),\n        \"@locales\": resolve(\"../../locales\"),\n        \"~\": resolve(\"./layer/main/src\"),\n        \"utf-8-validate\": resolve(\"./layer/main/src/shims/utf-8-validate.cjs\"),\n      },\n    },\n    define: {\n      ELECTRON: \"true\",\n      GIT_COMMIT_HASH: JSON.stringify(getGitHash()),\n    },\n  },\n  preload: {\n    build: {\n      outDir: \"dist/preload\",\n      lib: {\n        entry: \"./layer/main/preload/index.ts\",\n      },\n    },\n    resolve: {\n      alias: {\n        \"@pkg\": resolve(\"./package.json\"),\n        \"@locales\": resolve(\"../../locales\"),\n      },\n    },\n  },\n  renderer: rendererConfig,\n})\n"
  },
  {
    "path": "apps/desktop/forge.config.cts",
    "content": "import crypto from \"node:crypto\"\nimport fs, { readdirSync } from \"node:fs\"\nimport { cp, readdir } from \"node:fs/promises\"\n\nimport { FuseV1Options, FuseVersion } from \"@electron/fuses\"\nimport { MakerAppX } from \"@electron-forge/maker-appx\"\nimport { MakerDMG } from \"@electron-forge/maker-dmg\"\nimport { MakerPKG } from \"@electron-forge/maker-pkg\"\nimport { MakerSquirrel } from \"@electron-forge/maker-squirrel\"\nimport { MakerZIP } from \"@electron-forge/maker-zip\"\nimport { FusesPlugin } from \"@electron-forge/plugin-fuses\"\nimport type { ForgeConfig } from \"@electron-forge/shared-types\"\nimport MakerAppImage from \"@pengx17/electron-forge-maker-appimage\"\nimport setLanguages from \"electron-packager-languages\"\nimport yaml from \"js-yaml\"\nimport path, { resolve } from \"pathe\"\nimport { rimraf, rimrafSync } from \"rimraf\"\n\nconst ResolvedMakerAppImage: typeof MakerAppImage = (MakerAppImage as any).default || MakerAppImage\nconst platform = process.argv.find((arg) => arg.startsWith(\"--platform\"))?.split(\"=\")[1]\nconst mode = process.argv.find((arg) => arg.startsWith(\"--mode\"))?.split(\"=\")[1]\nconst isMicrosoftStore =\n  process.argv.find((arg) => arg.startsWith(\"--ms\"))?.split(\"=\")[1] === \"true\"\n\nconst isStaging = mode === \"staging\"\n\nconst artifactRegex = /.*\\.(?:exe|dmg|AppImage|zip)$/\nconst platformNamesMap = {\n  darwin: \"macos\",\n  linux: \"linux\",\n  win32: \"windows\",\n}\nconst ymlMapsMap = {\n  darwin: \"latest-mac.yml\",\n  linux: \"latest-linux.yml\",\n  win32: \"latest.yml\",\n}\n\nconst keepModules = new Set([\"font-list\", \"vscode-languagedetection\"])\nconst keepLanguages = new Set([\"en\", \"en_GB\", \"en-US\", \"en_US\"])\n\n// remove folders & files not to be included in the app\nasync function cleanSources(buildPath, _electronVersion, platform, _arch, callback) {\n  // folders & files to be included in the app\n  const appItems = new Set([\"dist\", \"node_modules\", \"package.json\", \"resources\"])\n\n  if (platform === \"darwin\" || platform === \"mas\") {\n    const frameworkResourcePath = resolve(\n      buildPath,\n      \"../../Frameworks/Electron Framework.framework/Versions/A/Resources\",\n    )\n\n    for (const file of readdirSync(frameworkResourcePath)) {\n      if (file.endsWith(\".lproj\") && !keepLanguages.has(file.split(\".\")[0]!)) {\n        rimrafSync(resolve(frameworkResourcePath, file))\n      }\n    }\n  }\n\n  // Keep only node_modules to be included in the app\n\n  await Promise.all([\n    ...(await readdir(buildPath).then((items) =>\n      items.filter((item) => !appItems.has(item)).map((item) => rimraf(path.join(buildPath, item))),\n    )),\n    ...(await readdir(path.join(buildPath, \"node_modules\")).then((items) =>\n      items\n        .filter((item) => !keepModules.has(item))\n        .map((item) => rimraf(path.join(buildPath, \"node_modules\", item))),\n    )),\n  ])\n\n  // copy needed node_modules to be included in the app\n  await Promise.all(\n    Array.from(keepModules.values()).map((item) => {\n      // Check is exist\n      if (fs.existsSync(path.join(buildPath, \"node_modules\", item))) {\n        // eslint-disable-next-line array-callback-return\n        return\n      }\n      return cp(\n        path.join(process.cwd(), \"../../node_modules\", item),\n        path.join(buildPath, \"node_modules\", item),\n        {\n          recursive: true,\n        },\n      )\n    }),\n  )\n\n  callback()\n}\n\nconst noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback()\n\nconst ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join(\"|\")})`)\n\nconst config: ForgeConfig = {\n  packagerConfig: {\n    name: isStaging ? \"Folo Staging\" : \"Folo\",\n    appCategoryType: \"public.app-category.news\",\n    buildVersion: process.env.BUILD_VERSION || undefined,\n    appBundleId: \"is.follow\",\n    icon: isStaging ? \"resources/icon-staging\" : \"resources/icon\",\n    extraResource: [\"./resources/app-update.yml\"],\n    protocols: [\n      {\n        name: \"Folo\",\n        schemes: [\"follow\"],\n      },\n      {\n        name: \"Folo\",\n        schemes: [\"folo\"],\n      },\n    ],\n\n    afterCopy: [\n      cleanSources,\n      process.platform !== \"win32\" ? noopAfterCopy : setLanguages([...keepLanguages.values()]),\n    ],\n    asar: true,\n    ignore: [ignorePattern],\n\n    prune: false,\n    extendInfo: {\n      ITSAppUsesNonExemptEncryption: false,\n    },\n    osxSign: {\n      optionsForFile:\n        platform === \"mas\"\n          ? (filePath) => {\n              const entitlements = filePath.includes(\".app/\")\n                ? \"build/entitlements.mas.child.plist\"\n                : \"build/entitlements.mas.plist\"\n              return {\n                hardenedRuntime: false,\n                entitlements,\n              }\n            }\n          : () => ({\n              entitlements: \"build/entitlements.mac.plist\",\n            }),\n      keychain: process.env.OSX_SIGN_KEYCHAIN_PATH,\n      identity: process.env.OSX_SIGN_IDENTITY,\n      provisioningProfile: process.env.OSX_SIGN_PROVISIONING_PROFILE_PATH,\n    },\n    ...(process.env.APPLE_ID &&\n      process.env.APPLE_PASSWORD &&\n      process.env.APPLE_TEAM_ID && {\n        osxNotarize: {\n          appleId: process.env.APPLE_ID!,\n          appleIdPassword: process.env.APPLE_PASSWORD!,\n          teamId: process.env.APPLE_TEAM_ID!,\n        },\n      }),\n  },\n  rebuildConfig: {},\n  makers: [\n    new MakerZIP({}, [\"darwin\"]),\n    new MakerDMG(\n      {\n        overwrite: true,\n        background: \"static/dmg-background.png\",\n        icon: \"static/dmg-icon.icns\",\n        iconSize: 160,\n        additionalDMGOptions: {\n          window: {\n            size: {\n              width: 660,\n              height: 400,\n            },\n          },\n        },\n        contents: (opts) => [\n          {\n            x: 180,\n            y: 170,\n            type: \"file\",\n            path: (opts as any).appPath,\n          },\n          {\n            x: 480,\n            y: 170,\n            type: \"link\",\n            path: \"/Applications\",\n          },\n        ],\n      },\n      [\"darwin\", \"mas\"],\n    ),\n    new ResolvedMakerAppImage({\n      config: {\n        icons: [\n          {\n            file: isStaging ? \"resources/icon-staging.png\" : \"resources/icon.png\",\n            size: 256,\n          },\n        ],\n      },\n    }),\n    new MakerPKG(\n      {\n        name: \"Folo\",\n        keychain: process.env.KEYCHAIN_PATH,\n      },\n      [\"mas\"],\n    ),\n    // Only include AppX maker for Microsoft Store builds\n    ...(isMicrosoftStore\n      ? [\n          new MakerAppX({\n            publisher: \"CN=7CBBEB6A-9B0E-4387-BAE3-576D0ACA279E\",\n            packageDisplayName: \"Folo - Follow everything in one place\",\n            devCert: \"build/dev.pfx\",\n            assets: \"static/appx\",\n            manifest: \"build/appxmanifest.xml\",\n            // @ts-ignore\n            publisherDisplayName: \"Natural Selection Labs\",\n            identityName: \"NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo\",\n            packageBackgroundColor: \"#FF5C00\",\n            protocol: \"folo\",\n          }),\n        ]\n      : [\n          new MakerSquirrel({\n            name: \"Folo\",\n            setupIcon: isStaging ? \"resources/icon-staging.ico\" : \"resources/icon.ico\",\n            iconUrl: \"https://app.folo.is/favicon.ico\",\n          }),\n        ]),\n  ],\n  plugins: [\n    // Fuses are used to enable/disable various Electron functionality\n    // at package time, before code signing the application\n    new FusesPlugin({\n      version: FuseVersion.V1,\n      [FuseV1Options.RunAsNode]: false,\n      [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,\n      [FuseV1Options.EnableNodeCliInspectArguments]: false,\n      [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,\n      [FuseV1Options.OnlyLoadAppFromAsar]: true,\n    }),\n  ],\n  publishers: [\n    {\n      name: \"@electron-forge/publisher-github\",\n      config: {\n        repository: {\n          owner: \"RSSNext\",\n          name: \"follow\",\n        },\n        draft: true,\n      },\n    },\n  ],\n  hooks: {\n    postMake: async (_config, makeResults) => {\n      const yml: {\n        version?: string\n        files: {\n          url: string\n          sha512: string\n          size: number\n        }[]\n        releaseDate?: string\n      } = {\n        version: makeResults[0]?.packageJSON?.version,\n        files: [],\n      }\n      let basePath = \"\"\n      makeResults = makeResults.map((result) => {\n        result.artifacts = result.artifacts\n          .map((artifact) => {\n            if (artifactRegex.test(artifact)) {\n              if (!basePath) {\n                basePath = path.dirname(artifact)\n              }\n              const newArtifact = `${path.dirname(artifact)}/${\n                result.packageJSON.productName\n              }-${result.packageJSON.version}-${\n                platformNamesMap[result.platform]\n              }-${result.arch}${path.extname(artifact)}`\n              fs.renameSync(artifact, newArtifact)\n\n              try {\n                const fileData = fs.readFileSync(newArtifact)\n                const hash = crypto.createHash(\"sha512\").update(fileData).digest(\"base64\")\n                const { size } = fs.statSync(newArtifact)\n\n                yml.files.push({\n                  url: path.basename(newArtifact),\n                  sha512: hash,\n                  size,\n                })\n              } catch {\n                console.error(`Failed to hash ${newArtifact}`)\n              }\n              return newArtifact\n            } else if (!artifact.endsWith(\".tmp\")) {\n              return artifact\n            } else {\n              return null\n            }\n          })\n          .filter((artifact) => artifact !== null)\n        return result\n      })\n      yml.releaseDate = new Date().toISOString()\n\n      if (makeResults[0]?.platform && ymlMapsMap[makeResults[0].platform] && basePath) {\n        const ymlPath = path.join(basePath, ymlMapsMap[makeResults[0].platform])\n\n        const ymlStr = yaml.dump(yml, {\n          lineWidth: -1,\n        })\n        fs.writeFileSync(ymlPath, ymlStr)\n\n        makeResults.push({\n          artifacts: [ymlPath],\n          platform: makeResults[0]!.platform,\n          arch: makeResults[0]!.arch,\n          packageJSON: makeResults[0]!.packageJSON,\n        })\n      }\n\n      return makeResults\n    },\n  },\n}\n\nexport default config\n"
  },
  {
    "path": "apps/desktop/layer/main/export.ts",
    "content": "// Export types for renderer to use\nexport type { IpcServices } from \"./src/ipc\"\n"
  },
  {
    "path": "apps/desktop/layer/main/global.d.ts",
    "content": "import \"../../types/vite\"\n\ndeclare global {\n  const GIT_COMMIT_HASH: string\n}\nexport {}\n"
  },
  {
    "path": "apps/desktop/layer/main/package.json",
    "content": "{\n  \"name\": \"@follow/electron-main\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"author\": \"Folo Team\",\n  \"license\": \"GPL-3.0-only\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/follow\",\n    \"type\": \"git\"\n  },\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/export.d.ts\",\n      \"import\": \"./dist/export.js\"\n    }\n  },\n  \"types\": \"./dist/export.d.ts\",\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"test\": \"vitest\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@electron-toolkit/preload\": \"3.0.2\",\n    \"@electron-toolkit/utils\": \"4.0.0\",\n    \"@eneris/push-receiver\": \"4.3.0\",\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow-app/readability\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"builder-util-runtime\": \"9.5.1\",\n    \"electron-context-menu\": \"4.1.1\",\n    \"electron-ipc-decorator\": \"0.2.0\",\n    \"electron-log\": \"5.4.3\",\n    \"electron-squirrel-startup\": \"1.0.1\",\n    \"electron-store\": \"11.0.2\",\n    \"electron-updater\": \"6.7.3\",\n    \"es-toolkit\": \"1.44.0\",\n    \"font-list\": \"2.0.2\",\n    \"i18next\": \"25.8.6\",\n    \"js-yaml\": \"4.1.1\",\n    \"ky\": \"1.14.3\",\n    \"linkedom\": \"0.18.12\",\n    \"lowdb\": \"7.0.1\",\n    \"msedge-tts\": \"2.0.4\",\n    \"node-machine-id\": \"1.1.12\",\n    \"ofetch\": \"1.5.1\",\n    \"pathe\": \"2.0.3\",\n    \"semver\": \"7.7.4\",\n    \"tar\": \"7.5.7\",\n    \"vscode-languagedetection\": \"npm:@vscode/vscode-languagedetection@1.0.22\"\n  },\n  \"devDependencies\": {\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@types/js-yaml\": \"4.0.9\",\n    \"@types/node\": \"25.2.3\",\n    \"electron\": \"38.3.0\",\n    \"electron-devtools-installer\": \"4.0.0\",\n    \"typescript\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/preload/index.d.ts",
    "content": "import type { ElectronAPI } from \"@electron-toolkit/preload\"\n\ndeclare global {\n  interface Window {\n    electron?: ElectronAPI\n    api?: { canWindowBlur: boolean }\n    platform: NodeJS.Platform\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/preload/index.ts",
    "content": "import os from \"node:os\"\nimport { platform } from \"node:process\"\n\nimport { electronAPI } from \"@electron-toolkit/preload\"\nimport { clipboard, contextBridge } from \"electron\"\n\nexport const isMacOS = platform === \"darwin\"\n\nexport const isWindows = platform === \"win32\"\n\nexport const isLinux = platform === \"linux\"\n\n/**\n * @see https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information\n * Windows 11 buildNumber starts from 22000.\n */\nconst detectingWindows11 = () => {\n  if (!isWindows) return false\n\n  const release = os.release()\n  const majorVersion = Number.parseInt(release.split(\".\")[0]!)\n  const buildNumber = Number.parseInt(release.split(\".\")[2]!)\n\n  return majorVersion === 10 && buildNumber >= 22000\n}\n\nexport const isWindows11 = detectingWindows11()\n\n// Custom APIs for renderer\nconst api = {\n  canWindowBlur: process.platform === \"darwin\" || (process.platform === \"win32\" && isWindows11),\n  isWindowsStore: Boolean((process as typeof process & { windowsStore?: boolean }).windowsStore),\n}\n\n// Use `contextBridge` APIs to expose Electron APIs to\n// renderer only if context isolation is enabled, otherwise\n// just add to the DOM global.\nif (process.contextIsolated) {\n  try {\n    contextBridge.exposeInMainWorld(\"electron\", electronAPI)\n    contextBridge.exposeInMainWorld(\"api\", api)\n    contextBridge.exposeInMainWorld(\"platform\", process.platform)\n  } catch (error) {\n    console.error(error)\n  }\n} else {\n  // @ts-ignore (define in dts)\n  window.electron = electronAPI\n  // @ts-ignore (define in dts)\n  window.api = api\n  // @ts-ignore (define in dts)\n  window.platform = process.platform\n\n  Object.defineProperty(window.navigator, \"clipboard\", {\n    get: () => {\n      return clipboard\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/@types/constants.ts",
    "content": "const langs = [\"en\", \"zh-CN\", \"zh-TW\", \"ja\"] as const\nexport const currentSupportedLanguages = [...langs].sort() as string[]\nexport type MainSupportedLanguages = (typeof langs)[number]\n\nexport const ns = [\"native\"] as const\nexport const defaultNS = \"native\" as const\n"
  },
  {
    "path": "apps/desktop/layer/main/src/@types/i18next.d.ts",
    "content": "import type { defaultNS, ns } from \"./constants\"\nimport type { resources } from \"./resources\"\n\ndeclare module \"i18next\" {\n  interface CustomTypeOptions {\n    ns: typeof ns\n    resources: (typeof resources)[\"en\"]\n    defaultNS: typeof defaultNS\n    // if you see an error like: \"Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz\"\n    // set returnNull to false (and also in the i18next init options)\n    // returnNull: false;\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/@types/resources.ts",
    "content": "import en from \"@locales/native/en.json\"\nimport ja from \"@locales/native/ja.json\"\nimport zhCn from \"@locales/native/zh-CN.json\"\nimport zhTw from \"@locales/native/zh-TW.json\"\n\nimport type { MainSupportedLanguages, ns } from \"./constants\"\n\nexport const resources = {\n  en: {\n    native: en,\n  },\n  \"zh-CN\": {\n    native: zhCn,\n  },\n\n  \"zh-TW\": {\n    native: zhTw,\n  },\n  ja: {\n    native: ja,\n  },\n} satisfies Record<MainSupportedLanguages, Record<(typeof ns)[number], Record<string, string>>>\n"
  },
  {
    "path": "apps/desktop/layer/main/src/before-bootstrap.ts",
    "content": "import { app, protocol } from \"electron\"\nimport path from \"pathe\"\n\nconst e2eUserDataDir = process.env.FOLO_E2E_USER_DATA_DIR\n\nif (e2eUserDataDir) {\n  app.setPath(\"userData\", e2eUserDataDir)\n} else if (import.meta.env.DEV) {\n  app.setPath(\"userData\", path.join(app.getPath(\"appData\"), \"Folo(dev)\"))\n}\n\nprotocol.registerSchemesAsPrivileged([\n  {\n    scheme: \"app\",\n    privileges: {\n      standard: true,\n      bypassCSP: true,\n      supportFetchAPI: true,\n      secure: true,\n    },\n  },\n])\n"
  },
  {
    "path": "apps/desktop/layer/main/src/bootstrap.ts",
    "content": "import { app } from \"electron\"\nimport squirrelStartup from \"electron-squirrel-startup\"\n\nimport { DEVICE_ID } from \"./constants/system\"\nimport { BootstrapManager } from \"./manager/bootstrap\"\n\nconsole.info(\"[main] device id:\", DEVICE_ID)\nif (squirrelStartup) {\n  app.quit()\n}\n\nBootstrapManager.start()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/constants/app.ts",
    "content": "import { app } from \"electron\"\nimport path from \"pathe\"\n\nexport const UNREAD_BACKGROUND_POLLING_INTERVAL = 1000 * 60 * 5\n\nexport const HOTUPDATE_RENDER_ENTRY_DIR = path.resolve(app.getPath(\"userData\"), \"render\")\n\nexport const GITHUB_OWNER = process.env.GITHUB_OWNER || \"RSSNext\"\nexport const GITHUB_REPO = process.env.GITHUB_REPO || \"follow\"\n\n// https://github.com/electron/electron/issues/25081\nexport const START_IN_TRAY_ARGS = \"--start-in-tray\"\n\nexport const BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN = \"better-auth.session_token\"\n"
  },
  {
    "path": "apps/desktop/layer/main/src/constants/system.ts",
    "content": "import { machineIdSync } from \"node-machine-id\"\n\nexport const DEVICE_ID = machineIdSync()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/env.ts",
    "content": "import os from \"node:os\"\n\nimport { DEV } from \"@follow/shared/constants\"\n\nexport const channel: \"development\" | \"beta\" | \"alpha\" | \"stable\" = DEV ? \"development\" : \"stable\"\n\nconst { platform } = process\nexport const isMacOS = platform === \"darwin\"\nexport const isMAS = process.mas\n\nexport const isWindows = platform === \"win32\"\n\nexport const isLinux = platform === \"linux\"\n\n/**\n * @see https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information\n * Windows 11 buildNumber starts from 22000.\n */\nconst detectingWindows11 = () => {\n  if (!isWindows) return false\n\n  const release = os.release()\n  const majorVersion = Number.parseInt(release.split(\".\")[0]!)\n  const buildNumber = Number.parseInt(release.split(\".\")[2]!)\n\n  return majorVersion === 10 && buildNumber >= 22000\n}\n\nexport const isWindows11 = detectingWindows11()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/helper.ts",
    "content": "import { fileURLToPath, pathToFileURL } from \"node:url\"\n\nimport { MODE, ModeEnum } from \"@follow/shared/constants\"\nimport path from \"pathe\"\n\nimport { isMacOS, isWindows } from \"./env\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\nconst iconMap = {\n  [ModeEnum.production]: path.join(__dirname, \"../../resources/icon.png\"),\n  [ModeEnum.development]: path.join(__dirname, \"../../resources/icon-dev.png\"),\n  [ModeEnum.staging]: path.join(__dirname, \"../../resources/icon-staging.png\"),\n}\nexport const getIconPath = () => iconMap[MODE]\nexport const getTrayIconPath = () => {\n  if (isMacOS) {\n    return path.join(__dirname, \"../../resources/icon-tray.png\")\n  }\n  if (isWindows) {\n    // https://www.electronjs.org/docs/latest/api/tray#:~:text=Windows,best%20visual%20effects.\n    return MODE === ModeEnum.staging\n      ? path.join(__dirname, \"../../resources/icon-tray-staging.ico\")\n      : path.join(__dirname, \"../../resources/icon-tray.ico\")\n  }\n  return getIconPath()\n}\n\nexport const filePathToAppUrl = (filePath: string) => {\n  return `app://folo.is${pathToFileURL(filePath).pathname}`\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/index.ts",
    "content": "import \"./before-bootstrap\"\nimport \"./bootstrap\"\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/index.ts",
    "content": "import type { MergeIpcService } from \"electron-ipc-decorator\"\nimport { createServices } from \"electron-ipc-decorator\"\n\nimport { AppService } from \"./services/app\"\nimport { AuthService } from \"./services/auth\"\nimport { CliService } from \"./services/cli\"\nimport { DebugService } from \"./services/debug\"\nimport { DockService } from \"./services/dock\"\nimport { IntegrationService } from \"./services/integration\"\nimport { MenuService } from \"./services/menu\"\nimport { ReaderService } from \"./services/reader\"\nimport { SettingService } from \"./services/setting\"\n\n// Initialize all services\nconst services = createServices([\n  AppService,\n  AuthService,\n  CliService,\n  DebugService,\n  DockService,\n  MenuService,\n  ReaderService,\n  SettingService,\n  IntegrationService,\n])\n// Extract method types automatically from services\nexport type IpcServices = MergeIpcService<typeof services>\n\n// Initialize all services (this will register all IPC handlers)\nexport function initializeIpcServices() {\n  // Services are already initialized in the services constant above\n  console.info(\"IPC services initialized\")\n  void services\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/app.ts",
    "content": "import fsp from \"node:fs/promises\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport { DEV } from \"@follow/shared/constants\"\nimport { app, BrowserWindow, clipboard, dialog, shell } from \"electron\"\nimport type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\nimport path from \"pathe\"\n\nimport { START_IN_TRAY_ARGS } from \"~/constants/app\"\nimport { getCacheSize } from \"~/lib/cleaner\"\nimport { i18n } from \"~/lib/i18n\"\nimport { store, StoreKey } from \"~/lib/store\"\nimport { registerAppTray } from \"~/lib/tray\"\nimport { logger, revealLogFile } from \"~/logger\"\nimport { AppManager } from \"~/manager/app\"\nimport { WindowManager } from \"~/manager/window\"\nimport { cleanupOldRender, loadDynamicRenderEntry } from \"~/updater/hot-updater\"\n\nimport { downloadFile } from \"../../lib/download\"\nimport { checkForAppUpdates, quitAndInstall } from \"../../updater\"\n\ninterface WindowActionInput {\n  action: \"close\" | \"minimize\" | \"maximum\"\n}\n\ninterface SearchInput {\n  text: string\n  options: Electron.FindInPageOptions\n}\n\ninterface Sender extends Electron.WebContents {\n  getOwnerBrowserWindow: () => Electron.BrowserWindow | null\n}\n\nexport class AppService extends IpcService {\n  static override readonly groupName = \"app\"\n\n  @IpcMethod()\n  getAppVersion(): string {\n    return app.getVersion()\n  }\n\n  @IpcMethod()\n  async checkForUpdates(): Promise<{ hasUpdate: boolean; error?: string }> {\n    return checkForAppUpdates()\n  }\n\n  @IpcMethod()\n  switchAppLocale(context: IpcContext, input: string): void {\n    i18n.changeLanguage(input)\n    AppManager.registerMenuAndContextMenu()\n    registerAppTray()\n\n    app.commandLine.appendSwitch(\"lang\", input)\n  }\n\n  @IpcMethod()\n  rendererUpdateReload(): void {\n    const __dirname = fileURLToPath(new URL(\".\", import.meta.url))\n    const allWindows = BrowserWindow.getAllWindows()\n    const dynamicRenderEntry = loadDynamicRenderEntry()\n\n    const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, \"../renderer/index.html\")\n    logger.info(\"appLoadEntry\", appLoadEntry)\n    const mainWindow = WindowManager.getMainWindow()\n\n    for (const window of allWindows) {\n      if (window === mainWindow) {\n        if (DEV) {\n          logger.verbose(\"[rendererUpdateReload]: skip reload in dev\")\n          break\n        }\n        window.loadFile(appLoadEntry)\n      } else window.destroy()\n    }\n\n    setTimeout(() => {\n      cleanupOldRender()\n    }, 1000)\n  }\n\n  @IpcMethod()\n  async openExternal(_context: IpcContext, url: string): Promise<void> {\n    if (!url) return\n\n    await shell.openExternal(url)\n  }\n\n  @IpcMethod()\n  windowAction(context: IpcContext, input: WindowActionInput): void {\n    if (context.sender.getType() === \"window\") {\n      const window: BrowserWindow | null = (context.sender as Sender).getOwnerBrowserWindow()\n\n      if (!window) return\n      switch (input.action) {\n        case \"close\": {\n          window.close()\n          break\n        }\n        case \"minimize\": {\n          window.minimize()\n          break\n        }\n        case \"maximum\": {\n          if (window.isMaximized()) {\n            window.unmaximize()\n          } else {\n            window.maximize()\n          }\n          break\n        }\n      }\n    }\n  }\n\n  @IpcMethod()\n  quitAndInstall(_context: IpcContext): void {\n    quitAndInstall()\n  }\n\n  @IpcMethod()\n  readClipboard(_context: IpcContext): string {\n    return clipboard.readText()\n  }\n\n  @IpcMethod()\n  async search(context: IpcContext, input: SearchInput): Promise<Electron.Result | null> {\n    const { sender: webContents } = context\n\n    const { promise, resolve } = Promise.withResolvers<Electron.Result | null>()\n\n    let requestId = -1\n    webContents.once(\"found-in-page\", (_, result) => {\n      resolve(result.requestId === requestId ? result : null)\n    })\n    requestId = webContents.findInPage(input.text, input.options)\n    return promise\n  }\n\n  @IpcMethod()\n  clearSearch(context: IpcContext): void {\n    context.sender.stopFindInPage(\"keepSelection\")\n  }\n\n  @IpcMethod()\n  async download(context: IpcContext, input: string): Promise<void> {\n    const result = await dialog.showSaveDialog({\n      defaultPath: input.split(\"/\").pop(),\n    })\n    if (result.canceled) return\n\n    try {\n      await downloadFile(input, result.filePath)\n\n      const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()\n      if (senderWindow) {\n        callWindowExpose(senderWindow).toast.success(\"Download success!\", {\n          duration: 1000,\n        })\n      }\n    } catch (err) {\n      const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()\n      if (senderWindow) {\n        callWindowExpose(senderWindow).toast.error(\"Download failed!\", {\n          duration: 1000,\n        })\n      }\n      throw err\n    }\n  }\n\n  @IpcMethod()\n  getAppPath(_context: IpcContext): string {\n    return app.getAppPath()\n  }\n\n  @IpcMethod()\n  resolveAppAsarPath(context: IpcContext, input: string): string {\n    if (input.startsWith(\"file://\")) {\n      input = fileURLToPath(input)\n    }\n\n    if (path.isAbsolute(input)) {\n      return input\n    }\n\n    return path.join(app.getAppPath(), input)\n  }\n\n  @IpcMethod()\n  readyToShowMainWindow(_context: IpcContext) {\n    const shouldShowWindow =\n      !app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS)\n    if (shouldShowWindow) {\n      const window = WindowManager.getMainWindow()\n      if (window) window.show()\n    }\n  }\n\n  @IpcMethod()\n  openCacheFolder(_context: IpcContext): void {\n    const dir = path.join(app.getPath(\"userData\"), \"cache\")\n    shell.openPath(dir)\n  }\n\n  @IpcMethod()\n  getCacheLimit(_context: IpcContext): number {\n    return store.get(StoreKey.CacheSizeLimit) || 0\n  }\n\n  @IpcMethod()\n  async clearCache(_context: IpcContext): Promise<void> {\n    const cachePath = path.join(app.getPath(\"userData\"), \"cache\", \"Cache_Data\")\n    if (process.platform === \"win32\") {\n      // Request elevation on Windows\n\n      try {\n        // Create a bat file to delete cache with elevated privileges\n        const batPath = path.join(app.getPath(\"temp\"), \"clear_cache.bat\")\n        await fsp.writeFile(batPath, `@echo off\\nrd /s /q \"${cachePath}\"\\ndel \"%~f0\"`, \"utf-8\")\n\n        // Execute the bat file with admin privileges\n        await shell.openPath(batPath)\n        return\n      } catch (err) {\n        logger.error(\"Failed to clear cache with elevation\", { error: err })\n      }\n    }\n    await fsp.rm(cachePath, { recursive: true, force: true }).catch(() => {\n      logger.error(\"Failed to clear cache\")\n    })\n  }\n\n  @IpcMethod()\n  limitCacheSize(_context: IpcContext, input: number): void {\n    if (input === 0) {\n      store.delete(StoreKey.CacheSizeLimit)\n    } else {\n      store.set(StoreKey.CacheSizeLimit, input)\n    }\n  }\n\n  @IpcMethod()\n  revealLogFile(_context: IpcContext) {\n    return revealLogFile()\n  }\n\n  @IpcMethod()\n  getCacheSize(_context: IpcContext) {\n    return getCacheSize()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/auth.ts",
    "content": "import { env } from \"@follow/shared/env.desktop\"\nimport { createDesktopAPIHeaders } from \"@follow/utils/headers\"\nimport PKG from \"@pkg\"\nimport type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\n\nimport { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from \"~/constants/app\"\nimport { WindowManager } from \"~/manager/window\"\n\nimport { getSessionTokenFromCookies, syncSessionToCliConfig } from \"../../lib/cli-session-sync\"\nimport { deleteNotificationsToken, updateNotificationsToken } from \"../../lib/user\"\nimport { logger } from \"../../logger\"\n\nexport class AuthService extends IpcService {\n  static override readonly groupName = \"auth\"\n\n  private async applySessionToken(token: string): Promise<void> {\n    const mainWindow = WindowManager.getMainWindow()\n    if (!mainWindow || !token) {\n      return\n    }\n\n    const apiURL = env.VITE_API_URL\n    const url = new URL(apiURL)\n    const isSecure =\n      url.protocol === \"https:\" || url.hostname === \"localhost\" || url.hostname === \"127.0.0.1\"\n    const isLocalhost = url.hostname === \"localhost\" || url.hostname === \"127.0.0.1\"\n    const cookieNames = [\n      BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,\n      ...(isSecure && !isLocalhost ? [\"__Secure-better-auth.session_token\"] : []),\n    ]\n\n    await Promise.all(\n      cookieNames.map((name) =>\n        mainWindow.webContents.session.cookies.set({\n          url: apiURL,\n          name,\n          value: token,\n          ...(isLocalhost ? {} : { domain: url.hostname }),\n          path: \"/\",\n          httpOnly: true,\n          secure: isSecure,\n          sameSite: \"no_restriction\",\n          expirationDate: new Date().setDate(new Date().getDate() + 30),\n        }),\n      ),\n    )\n  }\n\n  private async clearSessionToken(): Promise<void> {\n    const mainWindow = WindowManager.getMainWindow()\n    if (!mainWindow) {\n      return\n    }\n\n    const { session } = mainWindow.webContents\n    const apiURL = env.VITE_API_URL\n    await Promise.allSettled([\n      session.cookies.remove(apiURL, BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN),\n      session.cookies.remove(apiURL, \"__Secure-better-auth.session_token\"),\n      session.cookies.remove(apiURL, \"better-auth.last_used_login_method\"),\n    ])\n  }\n\n  private async requestCredentialAuth(\n    path: \"/sign-in/email\" | \"/sign-up/email\",\n    payload: Record<string, unknown>,\n    headers?: Record<string, string>,\n  ) {\n    const response = await fetch(`${env.VITE_API_URL}/better-auth${path}`, {\n      method: \"POST\",\n      headers: {\n        \"content-type\": \"application/json\",\n        ...createDesktopAPIHeaders({ version: PKG.version }),\n        ...headers,\n      },\n      body: JSON.stringify(payload),\n    })\n\n    const data = (await response\n      .json()\n      .catch(async () => ({ message: await response.text() }))) as Record<string, unknown>\n\n    const setCookie = response.headers.get(\"set-cookie\") || \"\"\n    const sessionCookieMatch = setCookie.match(/better-auth\\.session_token=([^;]+)/)\n    const sessionToken = sessionCookieMatch?.[1] ?? null\n    const token = typeof data.token === \"string\" ? data.token : null\n    const persistedSessionToken = sessionToken ?? token\n    if (response.ok && persistedSessionToken) {\n      await this.applySessionToken(persistedSessionToken)\n    }\n\n    if (sessionToken) {\n      data.sessionToken = sessionToken\n    }\n\n    return {\n      data,\n      error: response.ok\n        ? null\n        : {\n            message: typeof data.message === \"string\" ? data.message : response.statusText,\n            status: response.status,\n          },\n    }\n  }\n\n  @IpcMethod()\n  async sessionChanged(_context: IpcContext): Promise<void> {\n    await updateNotificationsToken()\n\n    // Sync the current desktop session to the npm CLI login.\n    const token = await getSessionTokenFromCookies()\n    await syncSessionToCliConfig(token).catch((err) => {\n      logger.error(\"Failed to sync session to CLI config:\", err)\n    })\n  }\n\n  @IpcMethod()\n  async signOut(_context: IpcContext): Promise<void> {\n    await deleteNotificationsToken()\n\n    // Clear the synced CLI login on sign out.\n    await syncSessionToCliConfig().catch((err) => {\n      logger.error(\"Failed to clear CLI config token:\", err)\n    })\n  }\n\n  @IpcMethod()\n  async signOutRemote(_context: IpcContext, token?: string): Promise<void> {\n    await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, {\n      method: \"POST\",\n      headers: {\n        ...createDesktopAPIHeaders({ version: PKG.version }),\n        ...(token\n          ? {\n              Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,\n            }\n          : {}),\n      },\n    }).catch(() => {})\n\n    await this.clearSessionToken()\n  }\n\n  @IpcMethod()\n  async signInWithCredential(\n    _context: IpcContext,\n    payload: { email: string; password: string; headers?: Record<string, string> },\n  ) {\n    return this.requestCredentialAuth(\n      \"/sign-in/email\",\n      {\n        email: payload.email,\n        password: payload.password,\n      },\n      payload.headers,\n    )\n  }\n\n  @IpcMethod()\n  async signUpWithCredential(\n    _context: IpcContext,\n    payload: {\n      email: string\n      password: string\n      name: string\n      callbackURL: string\n      headers?: Record<string, string>\n    },\n  ) {\n    return this.requestCredentialAuth(\n      \"/sign-up/email\",\n      {\n        email: payload.email,\n        password: payload.password,\n        name: payload.name,\n        callbackURL: payload.callbackURL,\n      },\n      payload.headers,\n    )\n  }\n\n  @IpcMethod()\n  async setSessionToken(_context: IpcContext, token: string): Promise<void> {\n    await this.applySessionToken(token)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/cli.ts",
    "content": "import type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\n\nimport {\n  CLI_NPM_PACKAGE_NAME,\n  getCliConfigPath,\n  getCliInstallCommand,\n  getCliLoginCommand,\n  getSessionTokenFromCookies,\n  isCliRunnerAvailable,\n  readCliConfig,\n  syncSessionToCliConfig,\n} from \"../../lib/cli-session-sync\"\n\nexport interface CliInstallStatus {\n  connected: boolean\n  configPath: string\n  hasDesktopSession: boolean\n  installCommand: string\n  loginCommand: string\n  npxAvailable: boolean\n  packageName: string\n}\n\nexport class CliService extends IpcService {\n  static override readonly groupName = \"cli\"\n\n  @IpcMethod()\n  async getInstallStatus(_context: IpcContext): Promise<CliInstallStatus> {\n    const [config, npxAvailable, desktopToken] = await Promise.all([\n      readCliConfig(),\n      isCliRunnerAvailable(),\n      getSessionTokenFromCookies(),\n    ])\n\n    return {\n      connected: Boolean(config.token),\n      configPath: getCliConfigPath(),\n      hasDesktopSession: Boolean(desktopToken),\n      installCommand: getCliInstallCommand(),\n      loginCommand: getCliLoginCommand(),\n      npxAvailable,\n      packageName: CLI_NPM_PACKAGE_NAME,\n    }\n  }\n\n  @IpcMethod()\n  async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {\n    try {\n      if (!(await isCliRunnerAvailable())) {\n        return { success: false, error: \"npx is not available. Install Node.js and npm first.\" }\n      }\n\n      const token = await getSessionTokenFromCookies()\n      if (!token) {\n        return { success: false, error: \"Sign in to Folo Desktop first.\" }\n      }\n\n      await syncSessionToCliConfig(token)\n      return { success: true }\n    } catch (error) {\n      return {\n        success: false,\n        error: error instanceof Error ? error.message : \"Failed to sync CLI login\",\n      }\n    }\n  }\n\n  @IpcMethod()\n  async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {\n    try {\n      await syncSessionToCliConfig()\n      return { success: true }\n    } catch (error) {\n      return {\n        success: false,\n        error: error instanceof Error ? error.message : \"Failed to clear CLI login\",\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/debug.ts",
    "content": "import type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\n\ninterface InspectElementInput {\n  x: number\n  y: number\n}\n\nexport class DebugService extends IpcService {\n  static override readonly groupName = \"debug\"\n\n  @IpcMethod()\n  inspectElement(context: IpcContext, input: InspectElementInput): void {\n    context.sender.inspectElement(input.x, input.y)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/dock.ts",
    "content": "import type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\n\nimport { UNREAD_BACKGROUND_POLLING_INTERVAL } from \"../../constants/app\"\nimport { apiClient } from \"../../lib/api-client\"\nimport { setDockCount } from \"../../lib/dock\"\n\nclass PollingManager {\n  private abortController: AbortController | null = null\n  private isPolling = false\n\n  async startPolling(pollingFn: () => Promise<void>, interval: number): Promise<void> {\n    if (this.isPolling) {\n      return // Already polling, prevent duplicate instances\n    }\n\n    this.isPolling = true\n    this.abortController = new AbortController()\n\n    try {\n      while (!this.abortController.signal.aborted) {\n        await pollingFn()\n\n        // Use AbortSignal with sleep for proper cancellation\n        await this.sleepWithAbortSignal(interval, this.abortController.signal)\n      }\n    } catch (error) {\n      if (error instanceof Error && error.name !== \"AbortError\") {\n        console.error(\"Polling error:\", error)\n      }\n    } finally {\n      this.isPolling = false\n      this.abortController = null\n    }\n  }\n\n  stopPolling(): void {\n    if (this.abortController) {\n      this.abortController.abort()\n    }\n  }\n\n  get active(): boolean {\n    return this.isPolling\n  }\n\n  private async sleepWithAbortSignal(ms: number, signal: AbortSignal): Promise<void> {\n    return new Promise((resolve, reject) => {\n      const timeoutId = setTimeout(resolve, ms)\n\n      signal.addEventListener(\"abort\", () => {\n        clearTimeout(timeoutId)\n        reject(new DOMException(\"Aborted\", \"AbortError\"))\n      })\n    })\n  }\n}\n\nexport class DockService extends IpcService {\n  private unreadPollingManager = new PollingManager()\n\n  static override readonly groupName = \"dock\"\n\n  @IpcMethod()\n  async pollingUpdateUnreadCount(): Promise<void> {\n    await this.unreadPollingManager.startPolling(\n      () => this.updateUnreadCount(),\n      UNREAD_BACKGROUND_POLLING_INTERVAL,\n    )\n  }\n\n  @IpcMethod()\n  async cancelPollingUpdateUnreadCount(): Promise<void> {\n    this.unreadPollingManager.stopPolling()\n  }\n\n  @IpcMethod()\n  async updateUnreadCount(): Promise<void> {\n    const res = await apiClient.reads.getTotalCount()\n\n    setDockCount(res.data.count)\n  }\n\n  @IpcMethod()\n  setDockBadge(_context: IpcContext, count: number): void {\n    setDockCount(count)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/integration.ts",
    "content": "import { existsSync } from \"node:fs\"\nimport fsp from \"node:fs/promises\"\n\nimport { shell } from \"electron\"\nimport type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\nimport path from \"pathe\"\n\nimport { store } from \"~/lib/store\"\nimport { logger } from \"~/logger\"\n\n// Taken from https://github.com/rollup/rollup/blob/4f69d33af3b2ec9320c43c9e6c65ea23a02bdde3/src/utils/sanitizeFileName.ts\n// https://datatracker.ietf.org/doc/html/rfc2396\n// eslint-disable-next-line no-control-regex\nconst INVALID_CHAR_REGEX = /[\\u0000-\\u001F\"#$%&*+,:;<=>?[\\]^`{|}\\u007F]/g\nconst DRIVE_LETTER_REGEX = /^[a-z]:/i\n\nfunction sanitizeFileName(name: string): string {\n  const match = DRIVE_LETTER_REGEX.exec(name)\n  const driveLetter = match ? match[0] : \"\"\n\n  // A `:` is only allowed as part of a windows drive letter (ex: C:\\foo)\n  // Otherwise, avoid them because they can refer to NTFS alternate data streams.\n  return driveLetter + name.slice(driveLetter.length).replaceAll(INVALID_CHAR_REGEX, \"_\")\n}\n\n// Input types\ninterface SaveToEagleInput {\n  url: string\n  mediaUrls: string[]\n}\n\ninterface LoginToQBittorrentInput {\n  host: string\n  username: string\n  password: string\n}\n\ninterface CheckQBittorrentAuthInput {\n  host: string\n}\n\ninterface AddMagnetInput {\n  host: string\n  urls: string[]\n}\n\ninterface CustomFetchInput {\n  url: string\n  method: string\n  headers: Record<string, string>\n  body?: string\n  timeout?: number\n}\n\nexport class IntegrationService extends IpcService {\n  static override readonly groupName = \"integration\"\n\n  @IpcMethod()\n  async saveToObsidian(\n    context: IpcContext,\n    input: {\n      url: string\n      title: string\n      content: string\n      author: string\n      publishedAt: string\n      vaultPath: string\n    },\n  ) {\n    try {\n      const { url, title, content, author, publishedAt, vaultPath } = input\n\n      const fileName = `${sanitizeFileName(title || publishedAt)\n        .trim()\n        .slice(0, 20)}.md`\n      const filePath = path.join(vaultPath, fileName)\n      const exists = existsSync(filePath)\n      if (exists) {\n        return { success: false, error: \"File already exists\" }\n      }\n\n      const markdown = `---\nurl: ${url}\nauthor: ${author}\npublishedAt: ${publishedAt}\n---\n\n# ${title}\n\n${content}\n`\n\n      await fsp.writeFile(filePath, markdown, \"utf-8\")\n      return { success: true }\n    } catch (error) {\n      console.error(\"Failed to save to Obsidian:\", error)\n      const errorMessage = error instanceof Error ? error.message : String(error)\n      return { success: false, error: errorMessage }\n    }\n  }\n\n  @IpcMethod()\n  async saveToEagle(context: IpcContext, input: SaveToEagleInput): Promise<any> {\n    try {\n      const res = await fetch(\"http://localhost:41595/api/item/addFromURLs\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          items: input.mediaUrls?.map((media) => ({\n            url: media,\n            website: input.url,\n            headers: {\n              referer: input.url,\n            },\n          })),\n        }),\n      })\n      return await res.json()\n    } catch {\n      return null\n    }\n  }\n\n  @IpcMethod()\n  async loginToQBittorrent(context: IpcContext, input: LoginToQBittorrentInput) {\n    const { host, username, password } = input\n\n    const existingSID = store.get(\"qbittorrentSID\")\n    if (existingSID) {\n      const errorMessage = await this.checkQBittorrentAuth(context, { host })\n      if (!errorMessage) {\n        return\n      }\n    }\n\n    const res = await fetch(`${host}/api/v2/auth/login`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,\n    })\n\n    if (!res.ok) {\n      return `Failed to log in to qBittorrent: ${await res.text()}`\n    }\n\n    const cookies = res.headers.get(\"set-cookie\") || \"\"\n    const match = cookies.match(/SID=([^;]+)/)\n    if (!match || !match[1]) {\n      return \"Failed to get SID from qBittorrent\"\n    }\n\n    store.set(\"qbittorrentSID\", match[1])\n    return\n  }\n\n  async checkQBittorrentAuth(context: IpcContext, input: CheckQBittorrentAuthInput) {\n    const { host } = input\n    const sid = store.get(\"qbittorrentSID\")\n    if (!sid) {\n      return \"Not logged in to qBittorrent\"\n    }\n    const res = await fetch(`${host}/api/v2/auth/check`, {\n      method: \"GET\",\n      headers: {\n        Cookie: `SID=${sid}`,\n      },\n      credentials: \"omit\",\n    })\n\n    if (!res.ok) {\n      return await res.text()\n    }\n  }\n\n  @IpcMethod()\n  async addMagnet(context: IpcContext, input: AddMagnetInput) {\n    const { host, urls } = input\n    const sid = store.get(\"qbittorrentSID\")\n    if (!sid) {\n      return \"Not logged in to qBittorrent\"\n    }\n    const res = await fetch(`${host}/api/v2/torrents/add`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        Cookie: `SID=${sid}`,\n      },\n      credentials: \"omit\",\n      body: `urls=${encodeURIComponent(urls.join(\"\\n\"))}`,\n    })\n\n    if (!res.ok) {\n      const text = await res.text()\n      return `Failed to add magnet links: ${text}`\n    }\n\n    // eslint-disable-next-line no-console\n    console.log(`Added magnet links to qBittorrent: ${urls.join(\", \")}`)\n  }\n\n  @IpcMethod()\n  async customFetch(context: IpcContext, input: CustomFetchInput) {\n    const requestId = Math.random().toString(36).slice(2, 8)\n    const { url, method, headers, body, timeout = 10_000 } = input\n\n    // Log request start\n    logger.info(`[CustomFetch:${requestId}] Starting request`, {\n      url: url.replaceAll(/(\\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>\n        // Mask potential sensitive query parameters\n        key.toLowerCase().includes(\"token\") ||\n        key.toLowerCase().includes(\"key\") ||\n        key.toLowerCase().includes(\"password\")\n          ? `${prefix}${key}=***`\n          : `${prefix}${key}=${value}`,\n      ),\n      method,\n      timeout,\n      hasBody: !!body,\n      bodyLength: body?.length || 0,\n      headerCount: Object.keys(headers || {}).length,\n    })\n\n    // Log request headers (mask sensitive headers)\n    const safeHeaders = { ...headers }\n    Object.keys(safeHeaders).forEach((key) => {\n      if (\n        key.toLowerCase().includes(\"authorization\") ||\n        key.toLowerCase().includes(\"token\") ||\n        key.toLowerCase().includes(\"key\")\n      ) {\n        safeHeaders[key] = \"***\"\n      }\n    })\n    logger.debug(`[CustomFetch:${requestId}] Request headers`, { headers: safeHeaders })\n\n    // Log request body (truncated for large bodies)\n    if (body) {\n      const truncatedBody =\n        body.length > 500\n          ? `${body.slice(0, 500)}... [truncated, total: ${body.length} chars]`\n          : body\n      logger.debug(`[CustomFetch:${requestId}] Request body`, { body: truncatedBody })\n    }\n\n    const startTime = Date.now()\n\n    try {\n      const controller = new AbortController()\n      const timeoutId = setTimeout(() => {\n        logger.warn(`[CustomFetch:${requestId}] Request timeout triggered after ${timeout}ms`)\n        controller.abort()\n      }, timeout)\n\n      logger.debug(`[CustomFetch:${requestId}] Sending request...`)\n\n      const response = await fetch(url, {\n        method,\n        headers,\n        body: body && [\"POST\", \"PUT\", \"PATCH\"].includes(method.toUpperCase()) ? body : undefined,\n        signal: controller.signal,\n      })\n\n      clearTimeout(timeoutId)\n      const duration = Date.now() - startTime\n\n      // Log response info\n      logger.info(`[CustomFetch:${requestId}] Request completed`, {\n        status: response.status,\n        statusText: response.statusText,\n        ok: response.ok,\n        duration: `${duration}ms`,\n      })\n\n      // Convert response headers to plain object\n      const responseHeaders: Record<string, string> = {}\n      response.headers.forEach((value, key) => {\n        responseHeaders[key] = value\n      })\n\n      logger.debug(`[CustomFetch:${requestId}] Response headers`, {\n        headers: responseHeaders,\n        contentType: responseHeaders[\"content-type\"],\n        contentLength: responseHeaders[\"content-length\"],\n      })\n\n      // Get response text\n      const text = await response.text()\n      const responseSize = text.length\n\n      logger.debug(`[CustomFetch:${requestId}] Response body received`, {\n        size: `${responseSize} chars`,\n        preview: text.length > 200 ? `${text.slice(0, 200)}...` : text,\n      })\n\n      // Try to parse as JSON, fallback to text\n      let data: any\n      try {\n        data = JSON.parse(text)\n        logger.debug(`[CustomFetch:${requestId}] Response successfully parsed as JSON`)\n      } catch {\n        data = text\n        logger.debug(`[CustomFetch:${requestId}] Response kept as text (not valid JSON)`)\n      }\n\n      const result = {\n        ok: response.ok,\n        status: response.status,\n        statusText: response.statusText,\n        headers: responseHeaders,\n        data,\n        text,\n      }\n\n      logger.info(`[CustomFetch:${requestId}] Request successful`, {\n        finalStatus: result.ok ? \"success\" : \"http_error\",\n        responseSize: `${responseSize} chars`,\n        totalDuration: `${Date.now() - startTime}ms`,\n      })\n\n      return result\n    } catch (error) {\n      const duration = Date.now() - startTime\n\n      if (error instanceof Error && error.name === \"AbortError\") {\n        logger.error(`[CustomFetch:${requestId}] Request timeout`, {\n          duration: `${duration}ms`,\n          timeout: `${timeout}ms`,\n          url: url.split(\"?\")[0], // Remove query params for privacy\n        })\n        throw new Error(`Request timeout after ${timeout}ms`)\n      }\n\n      logger.error(`[CustomFetch:${requestId}] Request failed`, {\n        error: error instanceof Error ? error.message : String(error),\n        errorName: error instanceof Error ? error.name : \"Unknown\",\n        duration: `${duration}ms`,\n        url: url.split(\"?\")[0], // Remove query params for privacy\n      })\n\n      throw error\n    }\n  }\n\n  @IpcMethod()\n  async openURLScheme(context: IpcContext, scheme: string) {\n    const requestId = Math.random().toString(36).slice(2, 8)\n\n    try {\n      // Validate URL scheme format\n      if (!scheme.includes(\"://\")) {\n        throw new Error(\"Invalid URL scheme format. Must include protocol (e.g., 'app://')\")\n      }\n\n      // Log URL scheme execution (mask sensitive data)\n      const safeScheme = scheme.replaceAll(/(\\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>\n        // Mask potential sensitive query parameters\n        key.toLowerCase().includes(\"token\") ||\n        key.toLowerCase().includes(\"key\") ||\n        key.toLowerCase().includes(\"password\")\n          ? `${prefix}${key}=***`\n          : `${prefix}${key}=${value}`,\n      )\n\n      logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {\n        scheme: safeScheme,\n        protocol: scheme.split(\"://\")[0],\n      })\n\n      // Use Electron's shell.openExternal to open URL scheme\n      // This will trigger the system's default handler for the scheme\n      await shell.openExternal(scheme)\n\n      logger.info(`[URLScheme:${requestId}] URL scheme opened successfully`)\n\n      return { success: true }\n    } catch (error) {\n      logger.error(`[URLScheme:${requestId}] Failed to open URL scheme`, {\n        error: error instanceof Error ? error.message : String(error),\n        scheme: scheme.split(\"://\")[0], // Only log protocol for privacy\n      })\n\n      throw error\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/menu.ts",
    "content": "import type { MenuItemConstructorOptions, MessageBoxOptions } from \"electron\"\nimport { dialog, Menu, ShareMenu } from \"electron\"\nimport type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\n\ntype SerializableMenuItem = Omit<MenuItemConstructorOptions, \"click\" | \"submenu\"> & {\n  submenu?: SerializableMenuItem[]\n}\n\ninterface ShowContextMenuInput {\n  items: SerializableMenuItem[]\n}\n\ninterface ShowConfirmDialogInput {\n  title: string\n  message: string\n  options?: Partial<MessageBoxOptions>\n}\n\nexport class MenuService extends IpcService {\n  static override readonly groupName = \"menu\"\n\n  private normalizeMenuItems(\n    items: SerializableMenuItem[],\n    context: IpcContext,\n    path: number[] = [],\n  ): MenuItemConstructorOptions[] {\n    return items.map((item, index) => {\n      const curPath = [...path, index]\n      return {\n        ...item,\n        click() {\n          context.sender.send(\"menu-click\", {\n            id: item.id,\n            path: curPath,\n          })\n        },\n        submenu: item.submenu ? this.normalizeMenuItems(item.submenu, context, curPath) : undefined,\n      }\n    })\n  }\n\n  @IpcMethod()\n  async showContextMenu(context: IpcContext, input: ShowContextMenuInput): Promise<void> {\n    const defer = Promise.withResolvers<void>()\n    const normalizedMenuItems = this.normalizeMenuItems(input.items, context)\n\n    const menu = Menu.buildFromTemplate(normalizedMenuItems)\n    menu.popup({\n      callback: () => defer.resolve(),\n    })\n    return defer.promise\n  }\n\n  @IpcMethod()\n  async showConfirmDialog(_context: IpcContext, input: ShowConfirmDialogInput): Promise<boolean> {\n    const result = await dialog.showMessageBox({\n      message: input.title,\n      detail: input.message,\n      buttons: [\"Confirm\", \"Cancel\"],\n      ...input.options,\n    })\n    return result.response === 0\n  }\n\n  @IpcMethod()\n  async showShareMenu(context: IpcContext, input: string): Promise<void> {\n    const menu = new ShareMenu({\n      urls: [input],\n    })\n\n    menu.popup({\n      callback: () => {\n        context.sender.send(\"menu-closed\")\n      },\n    })\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/reader.ts",
    "content": "import fs from \"node:fs\"\n\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport { readability } from \"@follow-app/readability\"\nimport { app, BrowserWindow } from \"electron\"\nimport type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\nimport { MsEdgeTTS, OUTPUT_FORMAT } from \"msedge-tts\"\nimport path from \"pathe\"\nimport type { ModelResult } from \"vscode-languagedetection\"\n\nimport { detectCodeStringLanguage } from \"../../modules/language-detection\"\n\nconst tts = new MsEdgeTTS()\n\ninterface ReadabilityInput {\n  url: string\n  html?: string\n}\n\ninterface TtsInput {\n  id: string\n  text: string\n  voice: string\n}\n\ninterface DetectCodeStringLanguageInput {\n  codeString: string\n}\n\nexport class ReaderService extends IpcService {\n  static override readonly groupName = \"reader\"\n\n  @IpcMethod()\n  async readability(_context: IpcContext, input: ReadabilityInput) {\n    const { url } = input\n\n    if (!url) {\n      return null\n    }\n    const result = await readability(url)\n\n    return result\n  }\n\n  @IpcMethod()\n  async tts(context: IpcContext, input: TtsInput): Promise<string | null> {\n    const { id, text, voice } = input\n    if (!text) {\n      return null\n    }\n\n    const window = BrowserWindow.fromWebContents(context.sender)\n    if (!window) return null\n\n    try {\n      await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, {})\n    } catch (error: unknown) {\n      console.error(\"Failed to set voice\", error)\n      if (error instanceof Error) {\n        callWindowExpose(window).toast.error(error.message, {\n          duration: 1000,\n        })\n      } else {\n        callWindowExpose(window).toast.error(\"Failed to set voice\", {\n          duration: 1000,\n        })\n      }\n      return null\n    }\n\n    const dirPath = path.join(app.getPath(\"userData\"), \"Cache\", \"tts\", id)\n    const possibleFilePathList = [\"mp3\", \"webm\"].map((ext) => {\n      return path.join(dirPath, `audio.${ext}`)\n    })\n    const filePath = possibleFilePathList.find((p) => fs.existsSync(p))\n    if (filePath) {\n      return filePath\n    } else {\n      fs.mkdirSync(dirPath, { recursive: true })\n      const { audioFilePath } = await tts.toFile(dirPath, text)\n      return audioFilePath\n    }\n  }\n\n  @IpcMethod()\n  async getVoices(context: IpcContext) {\n    const window = BrowserWindow.fromWebContents(context.sender)\n    try {\n      const voices = await tts.getVoices()\n      return voices\n    } catch (error) {\n      console.error(\"Failed to get voices\", error)\n      if (!window) return\n      if (error instanceof Error) {\n        void callWindowExpose(window).toast.error(error.message, { duration: 1000 })\n        return\n      }\n      callWindowExpose(window).toast.error(\"Failed to get voices\", { duration: 1000 })\n    }\n  }\n\n  @IpcMethod()\n  async detectCodeStringLanguage(\n    _context: IpcContext,\n    input: DetectCodeStringLanguageInput,\n  ): Promise<ModelResult | undefined> {\n    const { codeString } = input\n    const languages = detectCodeStringLanguage(codeString)\n\n    let finalLanguage: ModelResult | undefined\n    for await (const language of languages) {\n      if (!finalLanguage) {\n        finalLanguage = language\n        continue\n      }\n      if (language.confidence > finalLanguage.confidence) {\n        finalLanguage = language\n      }\n    }\n\n    return finalLanguage\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/ipc/services/setting.ts",
    "content": "import { createRequire } from \"node:module\"\n\nimport { app, nativeTheme } from \"electron\"\nimport type { IpcContext } from \"electron-ipc-decorator\"\nimport { IpcMethod, IpcService } from \"electron-ipc-decorator\"\n\nimport { WindowManager } from \"~/manager/window\"\n\nimport { setProxyConfig, updateProxy } from \"../../lib/proxy\"\nimport { store } from \"../../lib/store\"\nimport { getTrayConfig, setTrayConfig } from \"../../lib/tray\"\n\nconst require = createRequire(import.meta.url)\n\ninterface SetLoginItemSettingsInput {\n  openAtLogin: boolean\n  openAsHidden?: boolean\n  path?: string\n  args?: string[]\n}\n\nexport class SettingService extends IpcService {\n  static override readonly groupName = \"setting\"\n\n  @IpcMethod()\n  getLoginItemSettings(_context: IpcContext): Electron.LoginItemSettings {\n    return app.getLoginItemSettings()\n  }\n\n  @IpcMethod()\n  setLoginItemSettings(_context: IpcContext, input: SetLoginItemSettingsInput): void {\n    app.setLoginItemSettings(input)\n  }\n\n  @IpcMethod()\n  openSettingWindow(_context: IpcContext): void {\n    WindowManager.showSetting()\n  }\n\n  @IpcMethod()\n  async getSystemFonts(_context: IpcContext): Promise<string[]> {\n    const fonts = await require(\"font-list\").getFonts()\n    return fonts.map((font: string) => font.replaceAll('\"', \"\"))\n  }\n\n  @IpcMethod()\n  getAppearance(_context: IpcContext): \"light\" | \"dark\" | \"system\" {\n    return nativeTheme.themeSource\n  }\n\n  @IpcMethod()\n  setAppearance(_context: IpcContext, appearance: \"light\" | \"dark\" | \"system\"): void {\n    nativeTheme.themeSource = appearance\n    store.set(\"appearance\", appearance)\n  }\n\n  @IpcMethod()\n  getMinimizeToTray(_context: IpcContext): boolean {\n    return getTrayConfig()\n  }\n\n  @IpcMethod()\n  setMinimizeToTray(_context: IpcContext, minimize: boolean): void {\n    setTrayConfig(minimize)\n  }\n\n  @IpcMethod()\n  getProxyConfig(_context: IpcContext) {\n    const proxy = store.get(\"proxy\")\n    return proxy ?? undefined\n  }\n\n  @IpcMethod()\n  setProxyConfig(_context: IpcContext, config: string) {\n    const result = setProxyConfig(config)\n    updateProxy()\n    return result\n  }\n\n  @IpcMethod()\n  getMessagingToken(_context: IpcContext): string | null {\n    return store.get(\"notifications-credentials\") as string | null\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/api-client.ts",
    "content": "import { env } from \"@follow/shared/env.desktop\"\nimport { createDesktopAPIHeaders } from \"@follow/utils/headers\"\nimport { FollowClient } from \"@follow-app/client-sdk\"\nimport PKG, { mainHash, version as appVersion } from \"@pkg\"\nimport { gte } from \"semver\"\n\nimport { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from \"~/constants/app\"\nimport { WindowManager } from \"~/manager/window\"\nimport { getCurrentRendererManifest } from \"~/updater/hot-updater\"\n\nimport { logger } from \"../logger\"\n\nexport const followClient = new FollowClient({\n  credentials: \"include\",\n  timeout: 10000,\n\n  baseURL: env.VITE_API_URL,\n  fetch: async (input, options = {}) =>\n    fetch(input.toString(), {\n      ...options,\n      cache: \"no-store\",\n    }),\n})\n\nexport const apiClient = followClient.api\n\nfollowClient.addRequestInterceptor(async (ctx) => {\n  const { options } = ctx\n  const header = options.headers || {}\n\n  const apiHeader = createDesktopAPIHeaders({ version: PKG.version })\n  const rendererManifest = getCurrentRendererManifest()\n  const rendererVersion = gte(rendererManifest?.version ?? appVersion, appVersion)\n    ? (rendererManifest?.version ?? appVersion)\n    : appVersion\n\n  // Get cookies for authentication\n  const window = WindowManager.getMainWindow()\n  const cookies = await window?.webContents.session.cookies.get({\n    domain: new URL(env.VITE_API_URL).hostname,\n  })\n  const sessionCookie = cookies?.find((cookie) =>\n    cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN),\n  )\n  const headerCookie = sessionCookie ? `${sessionCookie.name}=${sessionCookie.value}` : \"\"\n  const userAgent = window?.webContents.getUserAgent() || `Folo/${PKG.version}`\n\n  options.headers = {\n    ...header,\n    ...apiHeader,\n    Cookie: headerCookie,\n    \"User-Agent\": userAgent,\n\n    \"X-Follow-Main-Hash\": mainHash,\n    \"X-Follow-Renderer-Version\": rendererVersion,\n    \"X-Follow-App-Version\": appVersion,\n    \"X-Follow-Platform\": process.platform,\n  }\n  return ctx\n})\nfollowClient.addResponseInterceptor(({ response }) => {\n  logger.info(`API Response: ${response.status} ${response.statusText}`)\n  return response\n})\n\nfollowClient.addErrorInterceptor(async ({ response, error }) => {\n  if (!response) {\n    logger.error(\"API Request failed - no response\", error)\n    return error\n  }\n})\n\nfollowClient.addResponseInterceptor(async ({ response }) => {\n  // Handle specific error cases if needed in main process\n  if (response.status === 401) {\n    logger.warn(\"Authentication failed in main process\")\n  }\n\n  try {\n    await response.clone().json()\n  } catch (error) {\n    logger.error(\"API Error details:\", error)\n  }\n\n  return response\n})\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/auth-cookie-migration.ts",
    "content": "import type { Cookie, CookiesSetDetails, Session } from \"electron\"\n\nimport { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from \"~/constants/app\"\n\nimport { logger } from \"../logger\"\n\nconst LEGACY_PROD_API_URL = \"https://api.follow.is\"\nconst BETTER_AUTH_SESSION_DATA_COOKIE_NAME = \"better-auth.session_data\"\n\nconst isBetterAuthSessionTokenCookie = (cookieName: string) => {\n  return cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN)\n}\n\nconst isBetterAuthSessionCookie = (cookieName: string) => {\n  return (\n    cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN) ||\n    cookieName.includes(BETTER_AUTH_SESSION_DATA_COOKIE_NAME)\n  )\n}\n\nconst toCookieSetDetails = (cookie: Cookie, url: string, domain: string): CookiesSetDetails => {\n  const details: CookiesSetDetails = {\n    url,\n    name: cookie.name,\n    value: cookie.value,\n    domain,\n    path: cookie.path,\n    secure: cookie.secure,\n    httpOnly: cookie.httpOnly,\n    sameSite: cookie.sameSite,\n  }\n\n  if (!cookie.session && cookie.expirationDate) {\n    details.expirationDate = cookie.expirationDate\n  }\n\n  return details\n}\n\nexport const migrateAuthCookiesToNewApiDomain = async (\n  cookieSession: Session,\n  options: {\n    currentApiURL: string\n    legacyApiURL?: string\n  },\n) => {\n  const legacyApiURL = options.legacyApiURL ?? LEGACY_PROD_API_URL\n  if (!options.currentApiURL || options.currentApiURL === legacyApiURL) {\n    return\n  }\n\n  const currentHost = new URL(options.currentApiURL).hostname\n  const legacyHost = new URL(legacyApiURL).hostname\n\n  if (currentHost === legacyHost) {\n    return\n  }\n\n  const currentDomainCookies = await cookieSession.cookies.get({\n    domain: currentHost,\n  })\n  const hasCurrentDomainSessionTokenCookie = currentDomainCookies.some((cookie) =>\n    isBetterAuthSessionTokenCookie(cookie.name),\n  )\n  if (hasCurrentDomainSessionTokenCookie) {\n    return\n  }\n\n  const legacyDomainCookies = await cookieSession.cookies.get({\n    domain: legacyHost,\n  })\n  const legacySessionCookies = legacyDomainCookies.filter((cookie) =>\n    isBetterAuthSessionCookie(cookie.name),\n  )\n\n  if (legacySessionCookies.length === 0) {\n    return\n  }\n\n  await Promise.all(\n    legacySessionCookies.map((cookie) => {\n      return cookieSession.cookies.set(\n        toCookieSetDetails(cookie, options.currentApiURL, currentHost),\n      )\n    }),\n  )\n\n  logger.info(\n    `Migrated ${legacySessionCookies.length} auth cookie(s) from ${legacyHost} to ${currentHost}`,\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/cleaner.ts",
    "content": "import { statSync } from \"node:fs\"\nimport fsp from \"node:fs/promises\"\n\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport { app, dialog } from \"electron\"\nimport path from \"pathe\"\n\nimport { getIconPath } from \"~/helper\"\nimport { logger } from \"~/logger\"\nimport { WindowManager } from \"~/manager/window\"\n\nimport { t } from \"./i18n\"\nimport { store, StoreKey } from \"./store\"\n\nconst getFolderSize = async (dir: string): Promise<number> => {\n  try {\n    const files = await fsp.readdir(dir, { withFileTypes: true })\n    const sizes = await Promise.all(\n      files.map(async (file) => {\n        const filePath = path.join(dir, file.name)\n\n        if (file.isSymbolicLink()) {\n          return 0\n        }\n\n        if (file.isDirectory()) {\n          return await getFolderSize(filePath)\n        }\n\n        if (file.isFile()) {\n          try {\n            const { size } = await fsp.stat(filePath)\n            return size\n          } catch {\n            return 0\n          }\n        }\n        return 0\n      }),\n    )\n    return sizes.reduce((acc, size) => acc + size, 0)\n  } catch {\n    return 0\n  }\n}\n\nexport const clearAllDataAndConfirm = async () => {\n  const win = WindowManager.getMainWindow()\n  if (!win) return\n\n  // Dialog to confirm\n  const result = await dialog.showMessageBox({\n    type: \"warning\",\n    icon: getIconPath(),\n    message: t(\"dialog.clearAllData\"),\n    buttons: [t(\"dialog.yes\"), t(\"dialog.no\")],\n    cancelId: 1,\n  })\n\n  if (result.response === 1) {\n    return\n  }\n  return clearAllData()\n}\n\nexport const clearAllData = async () => {\n  const win = WindowManager.getMainWindow()\n  if (!win) return\n  const ses = win.webContents.session\n  const caller = callWindowExpose(win)\n\n  try {\n    await ses.clearCache()\n\n    await ses.clearStorageData({\n      storages: [\n        \"websql\",\n        \"filesystem\",\n        \"indexdb\",\n        \"localstorage\",\n        \"shadercache\",\n        \"websql\",\n        \"serviceworkers\",\n        \"cookies\",\n      ],\n    })\n\n    caller.toast.success(\"App data reset successfully\")\n\n    // reload the app\n    win.reload()\n  } catch (error: any) {\n    caller.toast.error(`Error resetting app data: ${error.message}`)\n  }\n}\n\nexport const getCacheSize = async () => {\n  const cachePath = path.join(app.getPath(\"userData\"), \"cache\")\n\n  // Size is in bytes\n  const sizeInBytes = await getFolderSize(cachePath).catch((error) => {\n    logger.error(error)\n  })\n  return sizeInBytes || 0\n}\n\nconst getCachedFilesRecursive = async (dir: string, result: string[] = []) => {\n  const files = await fsp.readdir(dir)\n\n  for (const file of files) {\n    const filePath = path.join(dir, file)\n    const stat = await fsp.stat(filePath)\n    if (stat.isDirectory()) {\n      const files = await getCachedFilesRecursive(filePath)\n      result.push(...files)\n    } else {\n      result.push(filePath)\n    }\n  }\n  return result\n}\n\nlet timer: any = null\n\nexport const clearCacheCronJob = () => {\n  if (timer) {\n    timer = clearInterval(timer)\n  }\n  timer = setInterval(\n    async () => {\n      const hasLimit = store.get(StoreKey.CacheSizeLimit)\n\n      if (!hasLimit) {\n        return\n      }\n\n      const cacheSize = await getCacheSize()\n\n      const limitByteSize = hasLimit * 1024 * 1024\n      if (cacheSize > limitByteSize) {\n        const shouldCleanSize = cacheSize - limitByteSize - 1024 * 1024 * 50 // 50MB\n\n        const cachePath = path.join(app.getPath(\"userData\"), \"cache\")\n        const files = await getCachedFilesRecursive(cachePath)\n        // Sort by last modified\n        files.sort((a, b) => {\n          const aStat = statSync(a)\n          const bStat = statSync(b)\n          return bStat.mtime.getTime() - aStat.mtime.getTime()\n        })\n\n        let cleanedSize = 0\n        for (const file of files) {\n          try {\n            const fileSize = statSync(file).size\n            await fsp.rm(file, { force: true })\n            cleanedSize += fileSize\n            if (cleanedSize >= shouldCleanSize) {\n              logger.info(`Cleaned ${cleanedSize} bytes cache`)\n              break\n            }\n          } catch (error) {\n            logger.error(`Failed to delete cache file ${file}:`, error)\n          }\n        }\n      }\n    },\n    10 * 60 * 1000,\n  ) // 10 min\n\n  return () => {\n    if (!timer) return\n    timer = clearInterval(timer)\n  }\n}\n\nexport const checkAndCleanCodeCache = async () => {\n  const cachePath = path.join(app.getPath(\"userData\"), \"Code Cache\")\n\n  const size = await getFolderSize(cachePath).catch((error) => {\n    logger.error(error)\n  })\n\n  if (!size) return\n\n  const threshold = 1024 * 1024 * 100 // 100MB\n  if (size > threshold) {\n    await fsp\n      .rm(cachePath, { force: true, recursive: true })\n      .then(() => {\n        logger.info(`Cleaned ${size} bytes code cache`)\n      })\n      .catch((error) => {\n        logger.error(`clean code cache failed: ${error.message}`)\n      })\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/cli-session-sync.ts",
    "content": "import { execFile } from \"node:child_process\"\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { homedir } from \"node:os\"\nimport { promisify } from \"node:util\"\n\nimport { env } from \"@follow/shared/env.desktop\"\nimport { join } from \"pathe\"\n\nimport { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from \"~/constants/app\"\nimport { WindowManager } from \"~/manager/window\"\n\nimport { logger } from \"../logger\"\n\nconst execFileAsync = promisify(execFile)\nexport const CLI_NPM_PACKAGE_NAME = \"folocli\"\nconst CLI_NPX_PACKAGE_SPEC = `${CLI_NPM_PACKAGE_NAME}@latest`\nconst CLI_CONFIG_DIR = join(homedir(), \".folo\")\nconst CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, \"config.json\")\nconst getNpxCommand = () => (process.platform === \"win32\" ? \"npx.cmd\" : \"npx\")\n\nexport interface CliConfig {\n  token?: string\n  apiUrl?: string\n}\n\nexport const readCliConfig = async (): Promise<CliConfig> => {\n  try {\n    const raw = await readFile(CLI_CONFIG_PATH, \"utf8\")\n    return JSON.parse(raw) as CliConfig\n  } catch {\n    return {}\n  }\n}\n\nconst writeCliConfig = async (config: CliConfig): Promise<void> => {\n  await mkdir(CLI_CONFIG_DIR, { recursive: true })\n  await writeFile(CLI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\\n`, \"utf8\")\n}\n\nexport const getCliConfigPath = () => CLI_CONFIG_PATH\n\nexport const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help`\n\nexport const getCliLoginCommand = () =>\n  `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <session-token>`\n\nconst runCliCommand = async (args: string[]) => {\n  await execFileAsync(getNpxCommand(), [\"--yes\", CLI_NPX_PACKAGE_SPEC, ...args], {\n    windowsHide: true,\n    timeout: 120_000,\n    maxBuffer: 1024 * 1024,\n  })\n}\n\nexport const isCliRunnerAvailable = async (): Promise<boolean> => {\n  try {\n    await execFileAsync(getNpxCommand(), [\"--version\"], {\n      windowsHide: true,\n      timeout: 10_000,\n      maxBuffer: 128 * 1024,\n    })\n    return true\n  } catch {\n    return false\n  }\n}\n\nconst clearCliConfigToken = async () => {\n  const config = await readCliConfig()\n  if (!config.token) {\n    return\n  }\n\n  delete config.token\n  await writeCliConfig(config)\n}\n\nexport const getSessionTokenFromCookies = async (): Promise<string | undefined> => {\n  const window = WindowManager.getMainWindow()\n  if (!window) return undefined\n\n  const cookies = await window.webContents.session.cookies.get({\n    domain: new URL(env.VITE_API_URL).hostname,\n  })\n\n  const sessionCookie = cookies.find((cookie) =>\n    cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN),\n  )\n\n  return sessionCookie?.value\n}\n\nexport const syncSessionToCliConfig = async (token?: string): Promise<void> => {\n  if (token) {\n    const config = await readCliConfig()\n    if (config.token === token && config.apiUrl === env.VITE_API_URL) {\n      return\n    }\n\n    if (!(await isCliRunnerAvailable())) {\n      throw new Error(\"npx is not available\")\n    }\n\n    await runCliCommand([\"login\", \"--token\", token, \"--api-url\", env.VITE_API_URL])\n    logger.info(\"CLI login synced via npx\")\n    return\n  }\n\n  if (await isCliRunnerAvailable()) {\n    try {\n      await runCliCommand([\"logout\"])\n      logger.info(\"CLI login cleared via npx\")\n      return\n    } catch (error) {\n      logger.error(\n        \"Failed to clear CLI login via npx, falling back to local config cleanup:\",\n        error,\n      )\n    }\n  }\n\n  await clearCliConfigToken()\n  logger.info(\"CLI login cleared from local config\")\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/dock.ts",
    "content": "import { app } from \"electron\"\n\nimport { isWindows } from \"../env\"\n\nexport const setDockCount = (input: number) => {\n  // TODO use Electron Overlay API\n  if (isWindows) return\n  if (app.dock) {\n    app.dock.setBadge(input === 0 ? \"\" : input < 10000 ? input.toString() : \"9999+\")\n  } else {\n    app.setBadgeCount(input)\n  }\n}\n\nexport const getDockCount = () => {\n  if (isWindows) return null\n  if (app.dock) {\n    return app.dock.getBadge()\n  } else {\n    return app.getBadgeCount()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/download.ts",
    "content": "import { createHash } from \"node:crypto\"\nimport { createWriteStream } from \"node:fs\"\nimport { mkdir } from \"node:fs/promises\"\nimport { pipeline } from \"node:stream\"\nimport { promisify } from \"node:util\"\n\nimport ky from \"ky\"\nimport path from \"pathe\"\n\nconst streamPipeline = promisify(pipeline)\n\nexport interface DownloadOptions {\n  url: string\n  outputPath: string\n  expectedHash?: string\n  onProgress?: (downloadedSize: number, totalSize: number, percentage: number) => void\n  onLog?: (message: string) => void\n}\n\nexport async function downloadFile(url: string, dest: string) {\n  const res = await fetch(url)\n\n  // Check whether it responds successfully.\n  if (!res.ok) {\n    throw new Error(`Failed to fetch ${url}: ${res.statusText}`)\n  }\n  if (!res.body) {\n    throw new Error(`Failed to get response body`)\n  }\n  await streamPipeline(res.body as any, createWriteStream(dest))\n}\n\nexport async function downloadFileWithProgress(options: DownloadOptions): Promise<boolean> {\n  const { url, outputPath, expectedHash, onProgress, onLog } = options\n\n  try {\n    // Create download directory\n    await mkdir(path.dirname(outputPath), { recursive: true })\n\n    let lastProgressTime = Date.now()\n    const sha256 = expectedHash ? createHash(\"sha256\") : null\n\n    onLog?.(`Starting download: ${path.basename(outputPath)}`)\n\n    // Use ky with onDownloadProgress\n    const response = await ky.get(url, {\n      onDownloadProgress: (progress) => {\n        const now = Date.now()\n        // Call progress callback every 500ms to avoid spam\n        if (now - lastProgressTime > 500 || progress.percent === 1) {\n          const percentage = progress.percent * 100\n          const downloadedMB = (progress.transferredBytes / 1024 / 1024).toFixed(2)\n          const totalMB = (progress.totalBytes / 1024 / 1024).toFixed(2)\n\n          onLog?.(`Download progress: ${percentage.toFixed(1)}% (${downloadedMB}/${totalMB} MB)`)\n\n          // Call progress callback if provided\n          if (onProgress) {\n            onProgress(progress.transferredBytes, progress.totalBytes, percentage)\n          }\n\n          lastProgressTime = now\n        }\n      },\n    })\n\n    if (!response.ok) {\n      onLog?.(`Failed to download file: ${response.status} ${response.statusText}`)\n      return false\n    }\n\n    // Get the response as array buffer\n    const arrayBuffer = await response.arrayBuffer()\n    const buffer = Buffer.from(arrayBuffer)\n\n    // Verify hash if provided\n    if (expectedHash && sha256) {\n      sha256.update(buffer)\n      const hash = sha256.digest(\"hex\")\n      if (hash !== expectedHash) {\n        onLog?.(`Hash verification failed. Expected: ${expectedHash}, Got: ${hash}`)\n        return false\n      }\n      onLog?.(\"Hash verification passed\")\n    }\n\n    // Write to file\n    const writeStream = createWriteStream(outputPath)\n\n    return new Promise<boolean>((resolve) => {\n      writeStream.on(\"error\", (error) => {\n        onLog?.(`Write stream error: ${error}`)\n        resolve(false)\n      })\n\n      writeStream.on(\"finish\", () => {\n        onLog?.(`Download completed: ${outputPath}`)\n        resolve(true)\n      })\n\n      writeStream.end(buffer)\n    })\n  } catch (error) {\n    onLog?.(`Download error: ${error}`)\n    return false\n  }\n}\n\n// async function testDownload() {\n//   console.info(\"Testing ky onDownloadProgress implementation...\")\n\n//   const result = await downloadFileWithProgress({\n//     url: \"https://github.com/Innei/Follow/releases/download/desktop/v1.2.5/manifest.yml\",\n//     outputPath: path.resolve(os.tmpdir(), \"follow-render-update\", \"manifest.yml\"),\n//     onLog(message) {\n//       console.info(`[LOG] ${message}`)\n//     },\n//   })\n\n//   console.info(`Download result: ${result}`)\n// }\n\n// testDownload().catch(console.error)\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/i18n.ts",
    "content": "import i18next from \"i18next\"\n\nimport { resources } from \"../@types/resources\"\n\nexport const defaultNS = \"native\"\n\nexport const i18n = i18next.createInstance() as typeof i18next\n\ni18n.init({\n  fallbackLng: {\n    default: [\"en\"],\n    \"zh-TW\": [\"zh-CN\", \"en\"],\n  },\n  defaultNS,\n  resources,\n})\n\nexport const { t } = i18n\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/proxy.test.ts",
    "content": "import { session } from \"electron\"\nimport type { Mock } from \"vitest\"\nimport { beforeEach, describe, expect, it, vi } from \"vitest\"\n\nimport { logger } from \"../logger\"\nimport { getProxyConfig, setProxyConfig, updateProxy } from \"./proxy\"\nimport { store } from \"./store\"\n\nvi.mock(\"electron\", () => ({\n  session: {\n    defaultSession: {\n      setProxy: vi.fn(),\n    },\n  },\n}))\n\nvi.mock(\"./store\", () => ({\n  store: {\n    set: vi.fn(),\n    get: vi.fn(),\n    delete: vi.fn(),\n  },\n}))\n\nvi.mock(\"../logger\", () => ({\n  logger: {\n    log: vi.fn(),\n  },\n}))\n\ndescribe(\"proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  describe(\"setProxyConfig\", () => {\n    it(\"should set proxy config and return true for valid proxy\", () => {\n      const proxy = \"http://localhost:8080\"\n      const result = setProxyConfig(proxy)\n      expect(store.set).toHaveBeenCalledWith(\"proxy\", \"http://localhost:8080\")\n      expect(result).toBe(true)\n    })\n\n    it(\"should set sock proxy config\", () => {\n      const proxy = \"socks://localhost:8080\"\n      const result = setProxyConfig(proxy)\n      expect(store.set).toHaveBeenCalledWith(\"proxy\", \"socks://localhost:8080\")\n      expect(result).toBe(true)\n    })\n\n    it(\"should handle default port\", () => {\n      // https://github.com/RSSNext/Follow/issues/1197\n      const proxy = \"http://example.com:80\"\n      const result = setProxyConfig(proxy)\n      expect(store.set).toHaveBeenCalledWith(\"proxy\", \"http://example.com\")\n      expect(result).toBe(true)\n    })\n\n    it(\"should return false for invalid proxy\", () => {\n      const proxy = \"invalid-proxy\"\n      const result = setProxyConfig(proxy)\n      expect(store.delete).toHaveBeenCalledWith(\"proxy\")\n      expect(result).toBe(false)\n    })\n  })\n\n  describe(\"getProxyConfig\", () => {\n    it(\"should return normalized proxy config if set\", () => {\n      ;(store.get as Mock).mockReturnValue(\"http://localhost:8080\")\n      const result = getProxyConfig()\n      expect(result).toBe(\"http://localhost:8080\")\n    })\n\n    it(\"should compatible dirty data\", () => {\n      ;(store.get as Mock).mockReturnValue(\"http://localhost:8080,direct://\")\n      const result = getProxyConfig()\n      expect(result).toBe(\"http://localhost:8080\")\n    })\n  })\n\n  describe(\"updateProxy\", () => {\n    it(\"should set system proxy mode if no proxy config is set\", () => {\n      ;(store.get as Mock).mockReturnValue(\"\")\n      updateProxy()\n      expect(session.defaultSession.setProxy).toHaveBeenCalledWith({ mode: \"system\" })\n    })\n\n    it(\"should set proxy rules if proxy config is set\", () => {\n      ;(store.get as Mock).mockReturnValue(\"http://localhost:8080\")\n      updateProxy()\n      expect(logger.log).toHaveBeenCalledWith(\"Loading proxy: http://localhost:8080,direct://\")\n      expect(session.defaultSession.setProxy).toHaveBeenCalledWith({\n        proxyRules: \"http://localhost:8080,direct://\",\n        proxyBypassRules: \"<local>\",\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/proxy.ts",
    "content": "import { session } from \"electron\"\nimport { ProxyAgent, setGlobalDispatcher } from \"undici\"\n\nimport { logger } from \"../logger\"\nimport { store } from \"./store\"\n\n// Sets up the proxy configuration for the app.\n//\n// See https://www.electronjs.org/docs/latest/api/session#sessetproxyconfig\n// for more information about the proxy API.\n//\n// The open-source project [poooi/poi](https://github.com/poooi/poi) is doing well in proxy configuration\n// refer the following files for more details:\n//\n// https://github.com/poooi/poi/blob/5741d0d02c0a08626dd53196b094223457014491/lib/proxy.ts#L36\n// https://github.com/poooi/poi/blob/5741d0d02c0a08626dd53196b094223457014491/views/components/settings/network/index.es\n\nexport const setProxyConfig = (inputProxy: string) => {\n  const proxyUri = normalizeProxyUri(inputProxy)\n  if (!proxyUri) {\n    store.delete(\"proxy\")\n    return false\n  }\n  store.set(\"proxy\", proxyUri)\n  return true\n}\n\nexport const getProxyConfig = () => {\n  const proxyConfig = store.get(\"proxy\")\n  if (!proxyConfig) {\n    return\n  }\n  const proxyUri = normalizeProxyUri(proxyConfig)\n  return proxyUri\n}\n\nconst URL_SCHEME = new Set([\"http:\", \"https:\", \"ftp:\", \"socks:\", \"socks4:\", \"socks5:\"])\n\nconst normalizeProxyUri = (userProxy: string) => {\n  if (!userProxy) {\n    return\n  }\n  // Only use the first proxy if there are multiple urls\n  const firstInput = userProxy.split(\",\")[0]!\n\n  try {\n    const proxyUrl = new URL(firstInput)\n    if (!URL_SCHEME.has(proxyUrl.protocol) || !proxyUrl.hostname) {\n      return\n    }\n    // There are multiple ways to specify a proxy in Electron,\n    // but for security reasons, we only support simple proxy URLs for now.\n    return `${proxyUrl.protocol}//${proxyUrl.hostname}${proxyUrl.port ? `:${proxyUrl.port}` : \"\"}`\n  } catch {\n    return\n  }\n}\n\nconst BYPASS_RULES = [\"<local>\"].join(\";\")\n\nexport const updateProxy = () => {\n  const proxyUri = getProxyConfig()\n  if (!proxyUri) {\n    session.defaultSession.setProxy({\n      // Note that the system mode is different from setting no proxy configuration.\n      // In the latter case, Electron falls back to the system settings only if no command-line options influence the proxy configuration.\n      mode: \"system\",\n    })\n    return\n  }\n  const proxyRules = [\n    proxyUri,\n    // Failing over to using no proxy if the proxy is unavailable\n    \"direct://\",\n  ].join(\",\")\n\n  logger.log(`Loading proxy: ${proxyRules}`)\n  session.defaultSession.setProxy({\n    proxyRules,\n    proxyBypassRules: BYPASS_RULES,\n  })\n\n  // https://github.com/nodejs/undici/issues/2224\n  // Error occurred in handler for 'setProxyConfig': InvalidArgumentError: Invalid URL protocol: the URL must start with `http:` or `https:`.\n  const { protocol } = new URL(proxyUri)\n  if (protocol !== \"http:\" && protocol !== \"https:\") {\n    // undici doesn't support socks proxy\n    logger.warn(\"undici only supports http and https proxy, skipping undici proxy setup\")\n    return\n  }\n  // Currently, Session.setProxy is not working for native fetch, which is used by readability.\n  // So we need to set proxy for native fetch manually, refer to https://stackoverflow.com/a/76503362/14676508\n  const dispatcher = new ProxyAgent({ uri: new URL(proxyUri).toString() })\n  setGlobalDispatcher(dispatcher)\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/router.ts",
    "content": "import { callWindowExpose } from \"@follow/shared/bridge\"\nimport { extractElectronWindowOptions } from \"@follow/shared/electron\"\nimport type { BrowserWindow } from \"electron/main\"\n\nimport { logger } from \"~/logger\"\nimport { WindowManager } from \"~/manager/window\"\n\nexport const handleUrlRouting = (url: string) => {\n  const options = extractElectronWindowOptions(url)\n\n  // For example, the url is \"follow://add?id=123&type=list&url=https://example.com\"\n  const doubleSlash = url.indexOf(\"://\")\n  if (doubleSlash === -1) {\n    logger.error(\"url routing error: no protocol found\", url)\n    return\n  }\n  // Remove the protocol\n  // For example, the uri is \"/add?id=123&type=list&url=https://example.com\"\n  const uri = url.slice(doubleSlash + 2)\n  try {\n    const { pathname, searchParams } = new URL(uri, \"https://follow.dev\")\n\n    const pathnameTrimmed = pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname\n\n    switch (pathnameTrimmed) {\n      case \"/add\": {\n        callMainWindow(url, (mainWindow) => {\n          const caller = callWindowExpose(mainWindow)\n\n          const id = searchParams.get(\"id\") ?? undefined\n          const isList = searchParams.get(\"type\") === \"list\"\n          const urlParam = searchParams.get(\"url\") ?? undefined\n          if (!id && !urlParam) return\n          caller.follow({ isList, id, url: urlParam })\n        })\n        return\n      }\n      case \"/discover\": {\n        callMainWindow(url, (mainWindow) => {\n          const caller = callWindowExpose(mainWindow)\n\n          const route = searchParams.get(\"route\") ?? undefined\n\n          if (!route) return\n          caller.rsshubRoute(route)\n        })\n        return\n      }\n      case \"/feed\": {\n        callMainWindow(url, (mainWindow) => {\n          const caller = callWindowExpose(mainWindow)\n\n          const id = searchParams.get(\"id\") ?? undefined\n          const view = searchParams.get(\"view\") ?? \"0\"\n          if (!id) return\n          caller.goToFeed({\n            id,\n            view: Number.parseInt(view, 10),\n          })\n        })\n        return\n      }\n      case \"/list\": {\n        callMainWindow(url, (mainWindow) => {\n          const caller = callWindowExpose(mainWindow)\n\n          const id = searchParams.get(\"id\") ?? undefined\n          const view = searchParams.get(\"view\") ?? \"0\"\n          if (!id) return\n          caller.goToList({\n            id,\n            view: Number.parseInt(view, 10),\n          })\n        })\n        return\n      }\n      case \"/refresh\": {\n        callMainWindow(url, (mainWindow) => {\n          const caller = callWindowExpose(mainWindow)\n          caller.refreshSession()\n        })\n        return\n      }\n      case \"/\": {\n        callMainWindow(url, (mainWindow) => {\n          mainWindow.restore()\n          mainWindow.focus()\n        })\n        return\n      }\n\n      default: {\n        const { height, resizable = true, width } = options || {}\n        WindowManager.createWindow({\n          extraPath: `#${uri}`,\n          width: width ?? 800,\n          height: height ?? 700,\n          minWidth: 600,\n          minHeight: 600,\n          resizable,\n        })\n        return\n      }\n    }\n  } catch (err) {\n    logger.error(\"routing error:\", err)\n  }\n}\n\nconst callMainWindow = (url: string, fn: (mainWindow: BrowserWindow) => any) => {\n  const mainWindow = WindowManager.getMainWindow()\n  if (!mainWindow) {\n    WindowManager.createMainWindow()\n\n    return handleUrlRouting(url)\n  }\n  mainWindow.restore()\n  mainWindow.focus()\n  fn(mainWindow)\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/store.ts",
    "content": "import type { Credentials } from \"@eneris/push-receiver/dist/types\"\nimport Store from \"electron-store\"\n\n// @keep-sorted\ntype StoreData = {\n  \"notifications-credentials\"?: Credentials | null\n  \"notifications-persistent-ids\"?: string[] | null\n  appearance?: \"light\" | \"dark\" | \"system\" | null\n  cacheSizeLimit?: number | null\n  minimizeToTray?: boolean | null\n  proxy?: string | null\n  qbittorrentSID?: string | null\n  user?: string | null\n  windowState?: {\n    height: number\n    width: number\n    x: number\n    y: number\n  } | null\n}\nexport const store = new Store<StoreData>({ name: \"db\" })\n\nexport enum StoreKey {\n  CacheSizeLimit = \"cacheSizeLimit\",\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/tray.ts",
    "content": "import { name } from \"@pkg\"\nimport { app, Menu, nativeImage, Tray } from \"electron\"\n\nimport { isMacOS, isMAS, isWindows } from \"~/env\"\nimport { getTrayIconPath } from \"~/helper\"\nimport { logger, revealLogFile } from \"~/logger\"\nimport { WindowManager } from \"~/manager/window\"\nimport { checkForAppUpdates } from \"~/updater\"\n\nimport { getDockCount } from \"./dock\"\nimport { t } from \"./i18n\"\nimport { store } from \"./store\"\n\n// https://www.electronjs.org/docs/latest/tutorial/tray\n\nlet tray: Tray | null = null\n\nconst getTrayContextMenu = () => {\n  const count = getDockCount()\n  return Menu.buildFromTemplate([\n    ...(count\n      ? [\n          {\n            label: `${t(\"menu.unread\")} ${count}`,\n            enabled: false,\n          },\n        ]\n      : []),\n    {\n      label: t(\"menu.open\", { name }),\n      click: showWindow,\n    },\n    {\n      label: t(\"menu.help\"),\n      submenu: [\n        {\n          label: t(\"menu.reload\"),\n          click: () => {\n            const mainWindow = WindowManager.getMainWindowOrCreate()\n            mainWindow.webContents.reload()\n          },\n        },\n        {\n          label: t(\"menu.toggleDevTools\"),\n          click: () => {\n            const mainWindow = WindowManager.getMainWindowOrCreate()\n            mainWindow.webContents.toggleDevTools()\n          },\n        },\n        {\n          label: t(\"menu.openLogFile\"),\n          click: async () => {\n            await revealLogFile()\n          },\n        },\n        ...(!isMAS\n          ? [\n              {\n                label: t(\"menu.checkForUpdates\"),\n                click: async () => {\n                  showWindow()\n                  await checkForAppUpdates()\n                },\n              },\n            ]\n          : []),\n      ],\n    },\n    {\n      label: t(\"menu.quit\", { name }),\n      click: () => {\n        logger.info(\"Quit app from tray\")\n        app.quit()\n      },\n    },\n  ])\n}\nexport const registerAppTray = () => {\n  if (!getTrayConfig()) return\n  if (tray) {\n    destroyAppTray()\n  }\n\n  const icon = nativeImage.createFromPath(getTrayIconPath())\n  // See https://stackoverflow.com/questions/41664208/electron-tray-icon-change-depending-on-dark-theme/41998326#41998326\n  const trayIcon = isMacOS ? icon.resize({ width: 16 }) : icon\n  trayIcon.setTemplateImage(true)\n  tray = new Tray(trayIcon)\n\n  tray.setContextMenu(getTrayContextMenu())\n  tray.setToolTip(app.getName())\n  tray.on(\"mouse-enter\", () => {\n    tray?.setContextMenu(getTrayContextMenu())\n  })\n  if (isWindows) {\n    tray.on(\"click\", showWindow)\n  }\n}\n\nconst showWindow = () => {\n  const mainWindow = WindowManager.getMainWindowOrCreate()\n  if (mainWindow.isMinimized()) {\n    mainWindow.restore()\n  } else {\n    mainWindow.show()\n  }\n}\n\nconst destroyAppTray = () => {\n  if (tray) {\n    tray.destroy()\n    tray = null\n  }\n}\n\nconst DEFAULT_MINIMIZE_TO_TRAY = false\n\nexport const getTrayConfig = () => store.get(\"minimizeToTray\") ?? DEFAULT_MINIMIZE_TO_TRAY\n\nexport const setTrayConfig = (input: boolean) => {\n  store.set(\"minimizeToTray\", input)\n  if (input) {\n    registerAppTray()\n  } else {\n    destroyAppTray()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/user.ts",
    "content": "import type { Credentials } from \"@eneris/push-receiver/dist/types\"\n\nimport { isLinux, isMacOS, isWindows } from \"~/env\"\nimport { logger } from \"~/logger\"\n\nimport { apiClient } from \"./api-client\"\nimport { store } from \"./store\"\n\nconst notificationChannel = isMacOS\n  ? \"macos\"\n  : isWindows\n    ? \"windows\"\n    : isLinux\n      ? \"linux\"\n      : \"desktop\"\n\nexport const updateNotificationsToken = async (newCredentials?: Credentials) => {\n  if (newCredentials) {\n    store.set(\"notifications-credentials\", newCredentials)\n  }\n  const credentials = newCredentials || store.get(\"notifications-credentials\")\n  if (credentials?.fcm?.token) {\n    try {\n      await apiClient.messaging.createToken({\n        token: credentials.fcm.token,\n        channel: notificationChannel,\n      })\n      logger.info(\"updateNotificationsToken success: \", credentials.fcm.token)\n    } catch (error) {\n      logger.error(\"updateNotificationsToken error: \", error)\n    }\n  }\n}\n\nexport const deleteNotificationsToken = async () => {\n  await apiClient.messaging.deleteToken({\n    channel: notificationChannel,\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/lib/utils.ts",
    "content": "import type { BrowserWindow } from \"electron\"\n\nexport const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n// To solve the vibrancy losing issue when leaving full screen mode\n// @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157\nexport function refreshBound(window: BrowserWindow, timeout = 0) {\n  setTimeout(() => {\n    // FIXME: workaround for theme bug in full screen mode\n    const size = window?.getSize()\n    window?.setSize(size[0]! + 1, size[1]! + 1)\n    window?.setSize(size[0]!, size[1]!)\n  }, timeout)\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/logger.ts",
    "content": "import { app, shell } from \"electron\"\nimport log from \"electron-log\"\n\nexport const logger = log.scope(\"main\")\nlog.initialize()\n\nexport function getLogFilePath() {\n  return log.transports.file.getFile().path\n}\n\nexport async function revealLogFile() {\n  const filePath = getLogFilePath()\n  return await shell.openPath(filePath)\n}\n\napp.on(\"before-quit\", () => {\n  logger.info(\"App is quitting\")\n  log.transports.console.level = false\n})\n\napp.on(\"will-quit\", () => {\n  logger.info(\"App will quit\")\n})\n"
  },
  {
    "path": "apps/desktop/layer/main/src/manager/app.ts",
    "content": "import { PushReceiver } from \"@eneris/push-receiver\"\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport { APP_PROTOCOL, DEV, LEGACY_APP_PROTOCOL } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { app, nativeTheme, Notification, shell } from \"electron\"\nimport contextMenu from \"electron-context-menu\"\nimport path from \"pathe\"\n\nimport { WindowManager } from \"~/manager/window\"\n\nimport { getIconPath } from \"../helper\"\nimport { initializeIpcServices } from \"../ipc\"\nimport { checkAndCleanCodeCache, clearCacheCronJob } from \"../lib/cleaner\"\nimport { getSessionTokenFromCookies, syncSessionToCliConfig } from \"../lib/cli-session-sync\"\nimport { t } from \"../lib/i18n\"\nimport { updateProxy } from \"../lib/proxy\"\nimport { store } from \"../lib/store\"\nimport { registerAppTray } from \"../lib/tray\"\nimport { updateNotificationsToken } from \"../lib/user\"\nimport { logger } from \"../logger\"\nimport { registerAppMenu } from \"../menu\"\nimport { registerUpdater } from \"../updater\"\nimport { LifecycleManager } from \"./lifecycle\"\n\nclass AppManagerStatic {\n  private static instance: AppManagerStatic\n\n  public static getInstance(): AppManagerStatic {\n    if (!AppManagerStatic.instance) {\n      AppManagerStatic.instance = new AppManagerStatic()\n    }\n    return AppManagerStatic.instance\n  }\n\n  public init() {\n    initializeIpcServices()\n    LifecycleManager.onReady(this.onReady.bind(this))\n  }\n\n  private onReady() {\n    this.registerProtocols()\n    this.setupAppVisuals()\n    this.setupSystemConfigs()\n    this.runCronJobs()\n    this.registerMenuAndContextMenu()\n    this.registerPushNotifications()\n\n    updateProxy()\n    registerUpdater()\n    registerAppTray()\n\n    // Sync the desktop session to the npm CLI after cookies are ready.\n    setTimeout(async () => {\n      try {\n        const token = await getSessionTokenFromCookies()\n        if (token) {\n          await syncSessionToCliConfig(token)\n        }\n      } catch (err) {\n        logger.error(\"Failed to sync session to CLI on startup:\", err)\n      }\n    }, 5000)\n  }\n\n  private registerProtocols() {\n    const protocols = [LEGACY_APP_PROTOCOL, APP_PROTOCOL]\n\n    for (const protocolName of protocols) {\n      if (process.defaultApp) {\n        if (process.argv.length >= 2) {\n          app.setAsDefaultProtocolClient(protocolName, process.execPath, [\n            path.resolve(process.argv[1]!),\n          ])\n        }\n      } else {\n        app.setAsDefaultProtocolClient(protocolName)\n      }\n    }\n  }\n\n  private setupAppVisuals() {\n    if (app.dock) {\n      app.dock.setIcon(getIconPath())\n    }\n  }\n\n  private setupSystemConfigs() {\n    const appearance = store.get(\"appearance\")\n    if (appearance && [\"light\", \"dark\", \"system\"].includes(appearance)) {\n      nativeTheme.themeSource = appearance\n    }\n  }\n\n  private runCronJobs() {\n    clearCacheCronJob()\n    checkAndCleanCodeCache()\n  }\n\n  private async registerPushNotifications() {\n    if (!env.VITE_FIREBASE_CONFIG) {\n      return\n    }\n\n    const credentialsKey = \"notifications-credentials\"\n    const persistentIdsKey = \"notifications-persistent-ids\"\n    const credentials = store.get(credentialsKey)\n    const persistentIds = store.get(persistentIdsKey)\n\n    const instance = new PushReceiver({\n      debug: true,\n      firebase: JSON.parse(env.VITE_FIREBASE_CONFIG),\n      persistentIds: persistentIds || [],\n      credentials: credentials || undefined,\n      bundleId: \"is.follow\",\n      chromeId: \"is.follow\",\n    })\n    logger.info(\n      `PushReceiver initialized with credentials ${JSON.stringify(credentials)} and firebase config ${\n        env.VITE_FIREBASE_CONFIG\n      }`,\n    )\n\n    instance.onReady(() => {\n      logger.info(\"PushReceiver ready\")\n    })\n\n    instance.onCredentialsChanged(({ newCredentials }) => {\n      logger.info(`PushReceiver credentials changed to ${newCredentials?.fcm?.token}`)\n      updateNotificationsToken(newCredentials)\n    })\n\n    instance.onNotification((notification) => {\n      logger.info(\n        `PushReceiver received notification: ${JSON.stringify(notification.message.data)}`,\n      )\n      const { data } = notification.message\n      if (!data) {\n        return\n      }\n      switch (data.type) {\n        case \"new-entry\": {\n          const notification = new Notification({\n            title: data.title as string,\n            body: data.description as string,\n          })\n          notification.on(\"click\", () => {\n            const mainWindow = WindowManager.getMainWindowOrCreate()\n            mainWindow.restore()\n            mainWindow.focus()\n            const handlers = callWindowExpose(mainWindow)\n            handlers.navigateEntry({\n              feedId: data.feedId as string,\n              entryId: data.entryId as string,\n              view: Number.parseInt(data.view as string),\n            })\n          })\n          notification.show()\n          break\n        }\n        default: {\n          break\n        }\n      }\n      store.set(persistentIdsKey, instance.persistentIds)\n    })\n\n    try {\n      await instance.connect()\n    } catch (error) {\n      logger.error(`PushReceiver error: ${error instanceof Error ? error.stack : error}`)\n    }\n\n    logger.info(\"PushReceiver connected\")\n  }\n\n  private contextMenuDisposer?: () => void\n  public registerMenuAndContextMenu() {\n    registerAppMenu()\n    if (this.contextMenuDisposer) {\n      this.contextMenuDisposer()\n    }\n\n    this.contextMenuDisposer = contextMenu({\n      showSaveImageAs: true,\n      showCopyLink: true,\n      showCopyImageAddress: true,\n      showCopyImage: true,\n      showInspectElement: DEV,\n      showSelectAll: true,\n      showCopyVideoAddress: true,\n      showSaveVideoAs: true,\n\n      labels: {\n        saveImageAs: t(\"contextMenu.saveImageAs\"),\n        copyLink: t(\"contextMenu.copyLink\"),\n        copyImageAddress: t(\"contextMenu.copyImageAddress\"),\n        copyImage: t(\"contextMenu.copyImage\"),\n        copyVideoAddress: t(\"contextMenu.copyVideoAddress\"),\n        saveVideoAs: t(\"contextMenu.saveVideoAs\"),\n        inspect: t(\"contextMenu.inspect\"),\n        copy: t(\"contextMenu.copy\"),\n        cut: t(\"contextMenu.cut\"),\n        paste: t(\"contextMenu.paste\"),\n        saveImage: t(\"contextMenu.saveImage\"),\n        saveVideo: t(\"contextMenu.saveVideo\"),\n        selectAll: t(\"contextMenu.selectAll\"),\n        services: t(\"contextMenu.services\"),\n        searchWithGoogle: t(\"contextMenu.searchWithGoogle\"),\n        learnSpelling: t(\"contextMenu.learnSpelling\"),\n        lookUpSelection: t(\"contextMenu.lookUpSelection\"),\n        saveLinkAs: t(\"contextMenu.saveLinkAs\"),\n      },\n\n      prepend: (_defaultActions, params) => {\n        return [\n          {\n            label: t(\"contextMenu.openImageInBrowser\"),\n            visible: params.mediaType === \"image\",\n            click: () => {\n              shell.openExternal(params.srcURL)\n            },\n          },\n          {\n            label: t(\"contextMenu.openLinkInBrowser\"),\n            visible: params.linkURL !== \"\",\n            click: () => {\n              shell.openExternal(params.linkURL)\n            },\n          },\n          {\n            role: \"undo\",\n            label: t(\"menu.undo\"),\n            accelerator: \"CmdOrCtrl+Z\",\n            visible: params.isEditable,\n          },\n          {\n            role: \"redo\",\n            label: t(\"menu.redo\"),\n            accelerator: \"CmdOrCtrl+Shift+Z\",\n            visible: params.isEditable,\n          },\n        ]\n      },\n    })\n  }\n}\n\nexport const AppManager = AppManagerStatic.getInstance()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/manager/bootstrap.ts",
    "content": "import { rmSync } from \"node:fs\"\n\nimport { electronApp, optimizer } from \"@electron-toolkit/utils\"\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport { DEV, LEGACY_APP_PROTOCOL } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { createBuildSafeHeaders } from \"@follow/utils/headers\"\nimport { IMAGE_PROXY_URL } from \"@follow/utils/img-proxy\"\nimport { parse } from \"cookie-es\"\nimport { app, BrowserWindow, net, protocol, session } from \"electron\"\nimport { join } from \"pathe\"\n\nimport { WindowManager } from \"~/manager/window\"\n\nimport { isMacOS } from \"../env\"\nimport { migrateAuthCookiesToNewApiDomain } from \"../lib/auth-cookie-migration\"\nimport { handleUrlRouting } from \"../lib/router\"\nimport { store } from \"../lib/store\"\nimport { updateNotificationsToken } from \"../lib/user\"\nimport { logger } from \"../logger\"\nimport { cleanupOldRender } from \"../updater/hot-updater\"\nimport { AppManager } from \"./app\"\n\nconst apiURL = process.env[\"VITE_API_URL\"] || import.meta.env.VITE_API_URL\nconst buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [\n  IMAGE_PROXY_URL,\n  env.VITE_API_URL,\n  \"https://readwise.io\",\n])\n\nexport class BootstrapManager {\n  public static start() {\n    AppManager.init()\n\n    const gotTheLock = app.requestSingleInstanceLock()\n    if (!gotTheLock) {\n      app.quit()\n      return\n    }\n\n    this.registerAppEvents()\n  }\n\n  private static registerAppEvents() {\n    app.on(\"second-instance\", (_, commandLine) => {\n      const mainWindow = WindowManager.getMainWindow()\n      if (mainWindow) {\n        if (mainWindow.isMinimized()) mainWindow.restore()\n        mainWindow.show()\n      }\n\n      const url = commandLine.pop()\n      if (url) {\n        this.handleOpen(url)\n      }\n    })\n\n    app.whenReady().then(async () => {\n      protocol.handle(\"app\", (request) => {\n        try {\n          const urlObj = new URL(request.url)\n          return net.fetch(`file://${urlObj.pathname}`)\n        } catch {\n          logger.error(\"app protocol error\", request.url)\n          return new Response(\"Not found\", { status: 404 })\n        }\n      })\n\n      app.on(\"browser-window-created\", (_, window) => {\n        optimizer.watchWindowShortcuts(window)\n      })\n\n      electronApp.setAppUserModelId(`re.${LEGACY_APP_PROTOCOL}`)\n\n      session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {\n        details.requestHeaders = buildSafeHeaders({\n          url: details.url,\n          headers: details.requestHeaders,\n        })\n\n        callback({ cancel: false, requestHeaders: details.requestHeaders })\n      })\n\n      await migrateAuthCookiesToNewApiDomain(session.defaultSession, {\n        currentApiURL: env.VITE_API_URL,\n      })\n\n      // Bypass CORS for PostHog analytics\n      session.defaultSession.webRequest.onHeadersReceived((details, callback) => {\n        const url = new URL(details.url)\n\n        if (url.hostname === \"us.i.posthog.com\") {\n          const responseHeaders = details.responseHeaders || {}\n\n          responseHeaders[\"access-control-allow-origin\"] = [\"*\"]\n          responseHeaders[\"access-control-allow-methods\"] = [\n            \"GET\",\n            \"POST\",\n            \"PUT\",\n            \"DELETE\",\n            \"OPTIONS\",\n          ]\n          responseHeaders[\"access-control-allow-headers\"] = [\"*\"]\n          responseHeaders[\"access-control-allow-credentials\"] = [\"true\"]\n\n          callback({\n            cancel: false,\n            responseHeaders,\n          })\n        } else {\n          callback({ cancel: false })\n        }\n      })\n\n      WindowManager.getMainWindowOrCreate()\n\n      app.on(\"open-url\", (_, url) => {\n        const mainWindow = WindowManager.getMainWindowOrCreate()\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          if (mainWindow.isMinimized()) mainWindow.restore()\n          mainWindow.focus()\n        }\n        url && this.handleOpen(url)\n      })\n\n      if (DEV) {\n        this.installDevTools()\n      }\n    })\n\n    app.on(\"before-quit\", async () => {\n      const window = WindowManager.getMainWindow()\n      if (!window || window.isDestroyed()) return\n      const bounds = window.getBounds()\n\n      store.set(WindowManager.windowStateStoreKey, {\n        width: bounds.width,\n        height: bounds.height,\n        x: bounds.x,\n        y: bounds.y,\n      })\n      await session.defaultSession.cookies.flushStore()\n      await cleanupOldRender()\n    })\n\n    app.on(\"window-all-closed\", () => {\n      if (!isMacOS) {\n        app.quit()\n      }\n    })\n\n    app.on(\"before-quit\", () => {\n      const windows = BrowserWindow.getAllWindows()\n      windows.forEach((window) => window.destroy())\n\n      if (import.meta.env.DEV) {\n        const cacheDir = join(app.getPath(\"userData\"), \"Cache\")\n        const codeCacheDir = join(app.getPath(\"userData\"), \"Code Cache\")\n\n        rmSync(cacheDir, { recursive: true, force: true })\n        rmSync(codeCacheDir, { recursive: true, force: true })\n      }\n    })\n  }\n\n  private static installDevTools() {\n    import(\"electron-devtools-installer\").then(\n      ({ default: installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS }) => {\n        ;[\n          REDUX_DEVTOOLS,\n          REACT_DEVELOPER_TOOLS,\n          { id: \"acndjpgkpaclldomagafnognkcgjignd\" },\n        ].forEach((extension) => {\n          installExtension(extension, {\n            loadExtensionOptions: { allowFileAccess: true },\n          })\n            .then((extension) => console.info(`Added Extension:  ${extension.name}`))\n            .catch((err) => console.info(\"An error occurred:\", err))\n        })\n\n        session.defaultSession.getAllExtensions().forEach((e) => {\n          session.defaultSession.loadExtension(e.path)\n        })\n      },\n    )\n  }\n\n  private static async handleOpen(url: string) {\n    const mainWindow = WindowManager.getMainWindow()\n    if (!mainWindow) return\n\n    const isValid = URL.canParse(url)\n    if (!isValid) return\n    const urlObj = new URL(url)\n\n    if (urlObj.hostname === \"auth\" || urlObj.pathname === \"//auth\") {\n      const token = urlObj.searchParams.get(\"token\")\n\n      if (token) {\n        await callWindowExpose(mainWindow).applyOneTimeToken(token)\n      } else {\n        const ck = urlObj.searchParams.get(\"ck\")\n        const userId = urlObj.searchParams.get(\"userId\")\n\n        if (ck && apiURL) {\n          const cookie = parse(atob(ck), { decode: (value) => value })\n          Object.keys(cookie).forEach(async (name) => {\n            const value = cookie[name]!\n            await mainWindow.webContents.session.cookies.set({\n              url: apiURL,\n              name,\n              value,\n              secure: true,\n              httpOnly: true,\n              domain: new URL(apiURL).hostname,\n              sameSite: \"no_restriction\",\n              expirationDate: new Date().setDate(new Date().getDate() + 30),\n            })\n          })\n\n          if (userId) {\n            await callWindowExpose(mainWindow).clearIfLoginOtherAccount(userId)\n          }\n          mainWindow.reload()\n\n          updateNotificationsToken()\n        }\n      }\n    } else {\n      handleUrlRouting(url)\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/manager/lifecycle.ts",
    "content": "import { app } from \"electron\"\n\nimport { WindowManager } from \"~/manager/window\"\n\nclass LifecycleManagerStatic {\n  private static instance: LifecycleManagerStatic\n\n  private constructor() {\n    this.registerListeners()\n  }\n\n  public static getInstance(): LifecycleManagerStatic {\n    if (!LifecycleManagerStatic.instance) {\n      LifecycleManagerStatic.instance = new LifecycleManagerStatic()\n    }\n    return LifecycleManagerStatic.instance\n  }\n\n  private registerListeners() {\n    app.on(\"window-all-closed\", this.onWindowAllClosed.bind(this))\n    app.on(\"activate\", this.onActivate.bind(this))\n  }\n\n  private onWindowAllClosed() {\n    if (process.platform !== \"darwin\") {\n      app.quit()\n    }\n  }\n\n  private onActivate() {\n    const mainWindow = WindowManager.getMainWindowOrCreate()\n    mainWindow.show()\n    mainWindow.focus()\n  }\n\n  public onReady(callback: () => void) {\n    if (app.isReady()) {\n      callback()\n    } else {\n      app.on(\"ready\", callback)\n    }\n  }\n}\n\nexport const LifecycleManager = LifecycleManagerStatic.getInstance()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/manager/window.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport { is } from \"@electron-toolkit/utils\"\nimport { LEGACY_APP_PROTOCOL } from \"@follow/shared\"\nimport { callWindowExpose, WindowState } from \"@follow/shared/bridge\"\nimport { APP_PROTOCOL, DEV } from \"@follow/shared/constants\"\nimport type { BrowserWindowConstructorOptions } from \"electron\"\nimport { BrowserWindow, screen, shell } from \"electron\"\nimport type { Event } from \"electron/main\"\nimport path from \"pathe\"\n\nimport { isMacOS, isWindows, isWindows11 } from \"~/env\"\nimport { filePathToAppUrl, getIconPath } from \"~/helper\"\nimport { t } from \"~/lib/i18n\"\nimport { store } from \"~/lib/store\"\nimport { getTrayConfig } from \"~/lib/tray\"\nimport { refreshBound } from \"~/lib/utils\"\nimport { logger } from \"~/logger\"\nimport { loadDynamicRenderEntry } from \"~/updater/hot-updater\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\n\nclass WindowManagerStatic {\n  static get mainWindowDefaultSize() {\n    const primaryDisplay = screen.getPrimaryDisplay()\n    const { workAreaSize } = primaryDisplay\n    return {\n      height: workAreaSize.height,\n      width: workAreaSize.width,\n    }\n  }\n\n  // Window configuration properties for better DX\n  private readonly config = {\n    windowStateStoreKey: \"windowState\",\n    minWindowSize: {\n      width: 1024,\n      height: 500,\n    },\n    macOSTrafficLight: {\n      x: 18,\n      y: 18,\n    },\n    refreshBoundDelay: 1000,\n    devToolsFont: {\n      family:\n        'consolas, operator mono, Cascadia Code, OperatorMonoSSmLig Nerd Font, \"Agave Nerd Font\", \"Cascadia Code PL\", monospace',\n      size: \"13px\",\n    },\n    ignoreProtocols: [\n      \"http\",\n      \"https\",\n      LEGACY_APP_PROTOCOL,\n      APP_PROTOCOL,\n      \"file\",\n      \"code\",\n      \"cursor\",\n      \"app\",\n    ] as const,\n    vibrancy: {\n      macOS: {\n        type: \"sidebar\" as const,\n        state: \"followWindow\" as const,\n      },\n    },\n    windowPreferences: {\n      preloadScript: path.join(__dirname, \"../preload/index.mjs\"),\n    },\n  } as const\n\n  readonly windowStateStoreKey = this.config.windowStateStoreKey\n\n  private windows = {\n    mainWindow: null as BrowserWindow | null,\n  }\n\n  private bindEvents(window: BrowserWindow) {\n    window.on(\"leave-html-full-screen\", () => {\n      // To solve the vibrancy losing issue when leaving full screen mode\n      // @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157\n      refreshBound(window)\n      refreshBound(window, this.config.refreshBoundDelay)\n    })\n\n    const parseProtocol = (url: string) => {\n      try {\n        return new URL(url).protocol.slice(0, -1)\n      } catch {\n        logger.warn(\"Blocked external URL with invalid format\", { url })\n        return null\n      }\n    }\n\n    const isIgnoredProtocol = (\n      protocol: string,\n    ): protocol is (typeof this.config.ignoreProtocols)[number] => {\n      return this.config.ignoreProtocols.includes(\n        protocol as (typeof this.config.ignoreProtocols)[number],\n      )\n    }\n\n    const confirmAndOpenExternalProtocol = async (url: string) => {\n      const caller = callWindowExpose(window)\n      const confirm = await caller.dialog.ask({\n        title: t(\"dialog.openExternalApp.title\"),\n        message: t(\"dialog.openExternalApp.message\", {\n          url,\n          interpolation: { escapeValue: false },\n        }),\n        confirmText: t(\"dialog.open\"),\n        cancelText: t(\"dialog.cancel\"),\n      })\n      if (!confirm) {\n        return\n      }\n      void shell.openExternal(url)\n    }\n\n    window.webContents.setWindowOpenHandler((details) => {\n      const protocol = parseProtocol(details.url)\n      if (!protocol) {\n        return { action: \"deny\" }\n      }\n\n      if (protocol === \"http\" || protocol === \"https\") {\n        void shell.openExternal(details.url)\n        return { action: \"deny\" }\n      }\n\n      if (isIgnoredProtocol(protocol)) {\n        logger.warn(\"Blocked window.open for ignored protocol\", {\n          protocol,\n          url: details.url,\n        })\n        return { action: \"deny\" }\n      }\n\n      void confirmAndOpenExternalProtocol(details.url)\n      return { action: \"deny\" }\n    })\n\n    const handleExternalProtocol = async (e: Event, url: string) => {\n      const protocol = parseProtocol(url)\n      if (!protocol) {\n        e.preventDefault()\n        return\n      }\n\n      if (isIgnoredProtocol(protocol)) {\n        return\n      }\n      e.preventDefault()\n\n      await confirmAndOpenExternalProtocol(url)\n    }\n\n    // Handle main window external links\n    window.webContents.on(\"will-navigate\", (e, url) => {\n      void handleExternalProtocol(e, url)\n    })\n\n    // Handle webview external links\n    window.webContents.on(\"did-attach-webview\", (_, webContents) => {\n      webContents.on(\"will-navigate\", (e, url) => {\n        void handleExternalProtocol(e, url)\n      })\n    })\n\n    if (isWindows) {\n      // Change the default font-family and font-size of the devtools.\n      // Make it consistent with Chrome on Windows, instead of SimSun.\n      // ref: [[Feature Request]: Add possibility to change DevTools font · Issue #42055 · electron/electron](https://github.com/electron/electron/issues/42055)\n      window.webContents.on(\"devtools-opened\", () => {\n        this.setupDevToolsFont(window)\n      })\n    }\n\n    this.bindWindowStateEvents(window)\n  }\n\n  private setupDevToolsFont(window: BrowserWindow) {\n    // source-code-font: For code such as Elements panel\n    // monospace-font: For sidebar such as Event Listener Panel\n    const css = `:root {--devtool-font-family: ${this.config.devToolsFont.family};--source-code-font-family:var(--devtool-font-family);--source-code-font-size: ${this.config.devToolsFont.size};--monospace-font-family: var(--devtool-font-family);--monospace-font-size: ${this.config.devToolsFont.size};}`\n    const js = `\n      const overriddenStyle = document.createElement('style');\n      overriddenStyle.innerHTML = '${css.replaceAll(\"\\n\", \" \")}';\n      document.body.append(overriddenStyle);\n      document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));\n      addStyleToAutoComplete();\n      const observer = new MutationObserver((mutationList, observer) => {\n          for (const mutation of mutationList) {\n              if (mutation.type === 'childList') {\n                  for (let i = 0; i < mutation.addedNodes.length; i++) {\n                      const item = mutation.addedNodes[i];\n                      if (item instanceof HTMLElement && item.classList.contains('editor-tooltip-host')) {\n                          addStyleToAutoComplete();\n                      }\n                  }\n              }\n          }\n      });\n      observer.observe(document.body, {childList: true});\n      function addStyleToAutoComplete() {\n          document.querySelectorAll('.editor-tooltip-host').forEach(element => {\n              if (element.shadowRoot && element.shadowRoot.querySelectorAll('[data-key=\"overridden-dev-tools-font\"]').length === 0) {\n                  const overriddenStyle = document.createElement('style');\n                  overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');\n                  overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';\n                  element.shadowRoot.append(overriddenStyle);\n              }\n          });\n      }\n    `\n    window.webContents.devToolsWebContents?.executeJavaScript(js)\n  }\n\n  private bindWindowStateEvents(window: BrowserWindow) {\n    // async render and main state\n    window.on(\"maximize\", async () => {\n      const caller = callWindowExpose(window)\n      await caller.setWindowState(WindowState.MAXIMIZED)\n    })\n\n    window.on(\"unmaximize\", async () => {\n      const caller = callWindowExpose(window)\n      await caller.setWindowState(WindowState.NORMAL)\n    })\n\n    window.on(\"minimize\", async () => {\n      const caller = callWindowExpose(window)\n      await caller.setWindowState(WindowState.MINIMIZED)\n    })\n\n    window.on(\"restore\", async () => {\n      const caller = callWindowExpose(window)\n      await caller.setWindowState(WindowState.NORMAL)\n    })\n  }\n\n  private bindMainWindowCloseHandlers(window: BrowserWindow) {\n    window.on(\"close\", () => {\n      if (isWindows11) {\n        const windowStoreKey = Symbol.for(\"maximized\")\n        if (window[windowStoreKey]) {\n          const stored = window[windowStoreKey]\n          store.set(this.windowStateStoreKey, {\n            width: stored.size[0],\n            height: stored.size[1],\n            x: stored.position[0],\n            y: stored.position[1],\n          })\n\n          return\n        }\n      }\n\n      const bounds = window.getBounds()\n      store.set(this.windowStateStoreKey, {\n        width: bounds.width,\n        height: bounds.height,\n        x: bounds.x,\n        y: bounds.y,\n      })\n    })\n\n    window.on(\"close\", (event) => {\n      const minimizeToTray = getTrayConfig()\n      if (isMacOS || minimizeToTray) {\n        event.preventDefault()\n        if (window.isFullScreen()) {\n          window.once(\"leave-full-screen\", () => {\n            window.hide()\n          })\n          window.setFullScreen(false)\n        } else {\n          window.hide()\n        }\n\n        const caller = callWindowExpose(window)\n        caller.onWindowClose()\n      } else {\n        this.windows.mainWindow = null\n      }\n    })\n  }\n\n  private getPlatformSpecificWindowConfig(): Partial<BrowserWindowConstructorOptions> {\n    const { platform } = process\n\n    switch (platform) {\n      case \"darwin\": {\n        return {\n          titleBarStyle: \"hiddenInset\",\n          trafficLightPosition: {\n            x: this.config.macOSTrafficLight.x,\n            y: this.config.macOSTrafficLight.y,\n          },\n          vibrancy: this.config.vibrancy.macOS.type,\n          visualEffectState: this.config.vibrancy.macOS.state,\n          transparent: true,\n        }\n      }\n\n      case \"win32\": {\n        return {\n          icon: getIconPath(),\n          titleBarStyle: \"hidden\",\n          // Electron material bug, comment this for now\n          // backgroundMaterial: isWindows11 ? \"mica\" : undefined,\n          frame: true,\n        }\n      }\n\n      default: {\n        return {\n          icon: getIconPath(),\n        }\n      }\n    }\n  }\n\n  createWindow = (\n    options: {\n      extraPath?: string\n      height: number\n      width: number\n    } & BrowserWindowConstructorOptions,\n  ) => {\n    const { extraPath, height, width, ...configs } = options\n\n    const baseWindowConfig: Electron.BrowserWindowConstructorOptions = {\n      width,\n      height,\n      show: false,\n      resizable: configs?.resizable ?? true,\n      autoHideMenuBar: true,\n      alwaysOnTop: false,\n      webPreferences: {\n        preload: this.config.windowPreferences.preloadScript,\n        sandbox: false,\n        webviewTag: true,\n        webSecurity: !DEV,\n        nodeIntegration: true,\n        contextIsolation: false,\n      },\n      ...this.getPlatformSpecificWindowConfig(),\n    }\n\n    // Create the browser window.\n    const window = new BrowserWindow({\n      ...baseWindowConfig,\n      ...configs,\n    })\n\n    this.bindEvents(window)\n\n    // HMR for renderer base on electron-vite cli.\n    // Load the remote URL for development or the local html file for production.\n    if (is.dev && process.env[\"ELECTRON_RENDERER_URL\"]) {\n      window.loadURL(process.env[\"ELECTRON_RENDERER_URL\"] + (options?.extraPath || \"\"))\n      logger.log(process.env[\"ELECTRON_RENDERER_URL\"] + (options?.extraPath || \"\"))\n    } else {\n      // Production entry\n      const dynamicRenderEntry = loadDynamicRenderEntry()\n      if (dynamicRenderEntry) logger.info(\"load dynamic render entry\", dynamicRenderEntry)\n      const appLoadFileEntry =\n        dynamicRenderEntry || path.resolve(__dirname, \"../renderer/index.html\")\n\n      const appLoadEntry = `${filePathToAppUrl(appLoadFileEntry)}${options?.extraPath || \"\"}`\n\n      window.loadURL(appLoadEntry)\n      logger.log(\"load URL\", appLoadEntry)\n    }\n\n    return window\n  }\n\n  private ensureWindowBoundsInScreen(windowState?: {\n    width?: number\n    height?: number\n    x?: number\n    y?: number\n  }) {\n    const primaryDisplay = screen.getPrimaryDisplay()\n    const { workArea } = primaryDisplay\n\n    const maxWidth = workArea.width\n    const maxHeight = workArea.height\n\n    const defaultSize = WindowManagerStatic.mainWindowDefaultSize\n\n    const width = windowState?.width ? Math.min(windowState.width, maxWidth) : defaultSize.width\n    const height = windowState?.height\n      ? Math.min(windowState.height, maxHeight)\n      : defaultSize.height\n\n    const ensureInBounds = (value: number, min: number, max: number): number => {\n      return Math.max(min, Math.min(value, max))\n    }\n\n    const x =\n      windowState?.x !== undefined\n        ? ensureInBounds(windowState.x, workArea.x, workArea.x + workArea.width - width)\n        : undefined\n\n    const y =\n      windowState?.y !== undefined\n        ? ensureInBounds(windowState.y, workArea.y, workArea.y + workArea.height - height)\n        : undefined\n\n    return { width, height, x, y, maxWidth, maxHeight }\n  }\n\n  createMainWindow = () => {\n    const windowState = store.get(this.windowStateStoreKey) as\n      | {\n          width?: number\n          height?: number\n          x?: number\n          y?: number\n        }\n      | undefined\n    const { width, height, x, y, maxWidth, maxHeight } =\n      this.ensureWindowBoundsInScreen(windowState)\n\n    const window = this.createWindow({\n      width,\n      height,\n      x,\n      y,\n      minWidth: Math.min(this.config.minWindowSize.width, maxWidth),\n      minHeight: Math.min(this.config.minWindowSize.height, maxHeight),\n    })\n\n    this.bindMainWindowCloseHandlers(window)\n\n    this.windows.mainWindow = window\n\n    return window\n  }\n\n  showSetting = (path?: string) => {\n    // We need to open the setting modal in the main window when the main window exists,\n    // if we open a new window then the state between the two windows will be out of sync.\n    if (this.windows.mainWindow) {\n      if (this.windows.mainWindow.isMinimized()) {\n        this.windows.mainWindow.restore()\n      }\n      this.windows.mainWindow.show()\n\n      callWindowExpose(this.windows.mainWindow).showSetting(path)\n      return\n    } else {\n      this.windows.mainWindow = this.createMainWindow()\n      this.windows.mainWindow.show()\n      callWindowExpose(this.windows.mainWindow).showSetting(path)\n    }\n  }\n\n  getMainWindow = () => this.windows.mainWindow\n\n  getMainWindowOrCreate = () => {\n    if (!this.windows.mainWindow) {\n      return this.createMainWindow()\n    }\n    return this.windows.mainWindow\n  }\n\n  destroyMainWindow = () => {\n    this.windows.mainWindow?.destroy()\n    this.windows.mainWindow = null\n  }\n}\n\nexport const WindowManager = new WindowManagerStatic()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/menu.ts",
    "content": "import { callWindowExpose } from \"@follow/shared/bridge\"\nimport { DEV } from \"@follow/shared/constants\"\nimport { dispatchEventOnWindow } from \"@follow/shared/event\"\nimport { name } from \"@pkg\"\nimport type { BrowserWindow, MenuItem, MenuItemConstructorOptions } from \"electron\"\nimport { Menu } from \"electron\"\n\nimport { isMacOS, isMAS } from \"./env\"\nimport { clearAllDataAndConfirm } from \"./lib/cleaner\"\nimport { t } from \"./lib/i18n\"\nimport { revealLogFile } from \"./logger\"\nimport { WindowManager } from \"./manager/window\"\nimport { checkForAppUpdates, quitAndInstall } from \"./updater\"\n\nexport const registerAppMenu = () => {\n  const menus: Array<MenuItemConstructorOptions | MenuItem> = [\n    ...(isMacOS\n      ? ([\n          {\n            label: name,\n            submenu: [\n              {\n                type: \"normal\",\n                label: t(\"menu.about\", { name }),\n                click: () => {\n                  WindowManager.showSetting(\"about\")\n                },\n              },\n              { type: \"separator\" },\n              {\n                label: t(\"menu.settings\"),\n                accelerator: \"CmdOrCtrl+,\",\n                click: () => WindowManager.showSetting(),\n              },\n              { type: \"separator\" },\n              { role: \"services\", label: t(\"menu.services\") },\n              { type: \"separator\" },\n              { role: \"hide\", label: t(\"menu.hide\", { name }) },\n              { role: \"hideOthers\", label: t(\"menu.hideOthers\") },\n              { type: \"separator\" },\n              {\n                label: t(\"menu.clearAllData\"),\n                click: clearAllDataAndConfirm,\n              },\n              { role: \"quit\", label: t(\"menu.quit\", { name }) },\n            ],\n          },\n        ] as MenuItemConstructorOptions[])\n      : []),\n\n    {\n      role: \"fileMenu\",\n      label: t(\"menu.file\"),\n      submenu: [\n        {\n          type: \"normal\",\n          label: t(\"menu.quickAdd\"),\n          accelerator: \"CmdOrCtrl+N\",\n          click: () => {\n            const mainWindow = WindowManager.getMainWindow()\n            if (!mainWindow) return\n            mainWindow.show()\n            const caller = callWindowExpose(mainWindow)\n            caller.quickAdd()\n          },\n        },\n\n        {\n          type: \"normal\",\n          label: t(\"menu.discover\"),\n          accelerator: \"CmdOrCtrl+T\",\n          click: () => {\n            const mainWindow = WindowManager.getMainWindow()\n            if (!mainWindow) return\n            mainWindow.show()\n\n            const caller = callWindowExpose(mainWindow)\n            caller.goToDiscover()\n          },\n        },\n\n        { type: \"separator\" },\n        { role: \"close\", label: t(\"menu.close\") },\n      ],\n    },\n    {\n      label: t(\"menu.edit\"),\n      submenu: [\n        { role: \"undo\", label: t(\"menu.undo\") },\n        { role: \"redo\", label: t(\"menu.redo\") },\n        { type: \"separator\" },\n        { role: \"cut\", label: t(\"menu.cut\") },\n        { role: \"copy\", label: t(\"menu.copy\") },\n        { role: \"paste\", label: t(\"menu.paste\") },\n        { type: \"separator\" },\n        {\n          type: \"normal\",\n          label: t(\"menu.search\"),\n          accelerator: \"CmdOrCtrl+F\",\n          click(_e, window) {\n            if (!window) return\n            dispatchEventOnWindow(window as BrowserWindow, \"OpenSearch\")\n          },\n        },\n        ...((isMacOS\n          ? [\n              { role: \"pasteAndMatchStyle\", label: t(\"menu.pasteAndMatchStyle\") },\n              { role: \"delete\", label: t(\"menu.delete\") },\n              { role: \"selectAll\", label: t(\"menu.selectAll\") },\n              { type: \"separator\" },\n              {\n                label: t(\"menu.speech\"),\n                submenu: [\n                  { role: \"startSpeaking\", label: t(\"menu.startSpeaking\") },\n                  { role: \"stopSpeaking\", label: t(\"menu.stopSpeaking\") },\n                ],\n              },\n            ]\n          : [\n              { role: \"delete\", label: t(\"menu.delete\") },\n              { type: \"separator\" },\n              { role: \"selectAll\", label: t(\"menu.selectAll\") },\n            ]) as MenuItemConstructorOptions[]),\n      ],\n    },\n    {\n      role: \"viewMenu\",\n      label: t(\"menu.view\"),\n      submenu: [\n        { role: \"reload\", label: t(\"menu.reload\") },\n        { role: \"forceReload\", label: t(\"menu.forceReload\") },\n        { role: \"toggleDevTools\", label: t(\"menu.toggleDevTools\") },\n        { type: \"separator\" },\n\n        { role: \"togglefullscreen\", label: t(\"menu.toggleFullScreen\") },\n      ],\n    },\n    {\n      role: \"windowMenu\",\n      label: t(\"menu.window\"),\n      submenu: [\n        {\n          role: \"minimize\",\n          label: t(\"menu.minimize\"),\n        },\n        {\n          role: \"zoom\",\n          label: t(\"menu.zoom\"),\n        },\n        {\n          type: \"separator\",\n        },\n        {\n          role: \"front\",\n          label: t(\"menu.front\"),\n        },\n        {\n          label: \"Always on top\",\n          type: \"checkbox\",\n          checked: WindowManager.getMainWindow()?.isAlwaysOnTop(),\n          click: () => {\n            const mainWindow = WindowManager.getMainWindow()\n            if (!mainWindow) return\n            mainWindow.setAlwaysOnTop(!mainWindow.isAlwaysOnTop())\n            registerAppMenu()\n          },\n        },\n      ],\n    },\n    {\n      role: \"help\",\n      label: t(\"menu.help\"),\n      submenu: [\n        {\n          label: t(\"menu.openLogFile\"),\n          click: async () => {\n            await revealLogFile()\n          },\n        },\n        ...(!isMAS\n          ? [\n              {\n                label: t(\"menu.checkForUpdates\"),\n                click: async () => {\n                  WindowManager.getMainWindow()?.show()\n                  await checkForAppUpdates()\n                },\n              },\n            ]\n          : []),\n      ],\n    },\n  ]\n\n  if (DEV) {\n    menus.push({\n      label: t(\"menu.debug\"),\n      submenu: [\n        {\n          label: t(\"menu.followReleases\"),\n          click: () => {\n            WindowManager.createWindow({\n              extraPath: `#add?url=${encodeURIComponent(\n                \"https://github.com/RSSNext/follow/releases.atom\",\n              )}`,\n              width: 800,\n              height: 600,\n            })\n          },\n        },\n        {\n          type: \"normal\",\n          label: t(\"menu.quitAndInstallUpdate\"),\n          click() {\n            quitAndInstall()\n          },\n        },\n      ],\n    })\n  }\n  Menu.setApplicationMenu(Menu.buildFromTemplate(menus))\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/modules/language-detection/index.ts",
    "content": "// @see https://github.dev/microsoft/vscode/blob/main/src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker.ts\nimport { createRequire } from \"node:module\"\n\nimport type { ModelResult } from \"vscode-languagedetection\"\nimport type vsld from \"vscode-languagedetection\"\n\nimport { logger } from \"~/logger\"\n\nconst expectedRelativeConfidence = 0.1\nconst positiveConfidenceCorrectionBucket1 = 0.05\nconst positiveConfidenceCorrectionBucket2 = 0.025\nconst negativeConfidenceCorrection = 0.5\n\nconst adjustLanguageConfidence = (modelResult: vsld.ModelResult): vsld.ModelResult => {\n  switch (modelResult.languageId) {\n    // For the following languages, we increase the confidence because\n    // these are commonly used languages in VS Code and supported\n    // by the model.\n    case \"js\":\n    case \"html\":\n    case \"json\":\n    case \"ts\":\n    case \"css\":\n    case \"py\":\n    case \"xml\":\n    case \"php\": {\n      modelResult.confidence += positiveConfidenceCorrectionBucket1\n      break\n    }\n    // case 'yaml': // YAML has been know to cause incorrect language detection because the language is pretty simple. We don't want to increase the confidence for this.\n    case \"cpp\":\n    case \"sh\":\n    case \"java\":\n    case \"cs\":\n    case \"c\": {\n      modelResult.confidence += positiveConfidenceCorrectionBucket2\n      break\n    }\n\n    // For the following languages, we need to be extra confident that the language is correct because\n    // we've had issues like #131912 that caused incorrect guesses. To enforce this, we subtract the\n    // negativeConfidenceCorrection from the confidence.\n\n    // languages that are provided by default in VS Code\n    case \"bat\":\n    case \"ini\":\n    case \"makefile\":\n    case \"sql\":\n    case \"csv\":\n    case \"toml\": {\n      // Other considerations for negativeConfidenceCorrection that\n      // aren't built in but suportted by the model include:\n      // * Assembly, TeX - These languages didn't have clear language modes in the community\n      // * Markdown, Dockerfile - These languages are simple but they embed other languages\n      modelResult.confidence -= negativeConfidenceCorrection\n      break\n    }\n\n    default: {\n      break\n    }\n  }\n  return modelResult\n}\nconst require = createRequire(import.meta.url)\n\nexport const detectCodeStringLanguage = async function* (codeString: string) {\n  const { ModelOperations } =\n    require(\"vscode-languagedetection\") as typeof import(\"vscode-languagedetection\")\n  const modelOperations = new ModelOperations()\n  const modelResults = await modelOperations.runModel(codeString)\n  if (modelResults.length === 0) {\n    logger.debug(\"no model results\", codeString)\n    return\n  }\n  const firstModelResult = adjustLanguageConfidence(modelResults[0]!)\n  if (firstModelResult.confidence < expectedRelativeConfidence) {\n    logger.debug(\"first model result confidence is less than expected relative confidence\")\n    return\n  }\n\n  const possibleLanguages: ModelResult[] = [firstModelResult]\n\n  for (let current of modelResults) {\n    if (current === firstModelResult) {\n      continue\n    }\n\n    current = adjustLanguageConfidence(current)\n    const currentHighest = possibleLanguages.at(-1)\n    if (!currentHighest) {\n      logger.debug(\"no current highest\")\n      continue\n    }\n\n    if (currentHighest.confidence - current.confidence >= expectedRelativeConfidence) {\n      while (possibleLanguages.length > 0) {\n        yield possibleLanguages.shift()!\n      }\n      if (current.confidence > expectedRelativeConfidence) {\n        possibleLanguages.push(current)\n        continue\n      }\n      return\n    } else {\n      if (current.confidence > expectedRelativeConfidence) {\n        possibleLanguages.push(current)\n        continue\n      }\n      return\n    }\n  }\n\n  return possibleLanguages\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/shims/utf-8-validate.cjs",
    "content": "\"use strict\"\n\nconst { isUtf8 } = require(\"node:buffer\")\n\nmodule.exports = function isValidUTF8(buffer) {\n  if (typeof isUtf8 === \"function\") {\n    return isUtf8(buffer)\n  }\n\n  try {\n    new TextDecoder(\"utf-8\", { fatal: true }).decode(buffer)\n    return true\n  } catch {\n    return false\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/api.ts",
    "content": "import type {\n  DistributionStatusPayload,\n  GetLatestReleaseQuery,\n  LatestReleasePayload,\n} from \"@follow-app/client-sdk\"\n\nimport { apiClient } from \"~/lib/api-client\"\n\nlet cachedLatestRelease: LatestReleasePayload | null = null\n\nexport const getUpdateInfo = async (\n  query: GetLatestReleaseQuery = {},\n): Promise<LatestReleasePayload> => {\n  const response = await apiClient.updates.getLatestRelease(query)\n  cachedLatestRelease = response.data\n  return cachedLatestRelease\n}\n\nexport const getDistributionUpdateInfo = async (): Promise<DistributionStatusPayload | null> => {\n  const distribution = process.mas ? \"mas\" : process.windowsStore ? \"mss\" : undefined\n  if (!distribution) {\n    return null\n  }\n  const response = await apiClient.updates.getDistributionStatus({ distribution })\n  return response.data\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/configs.ts",
    "content": "import { DEV, MICROSOFT_STORE_BUILD, MODE, ModeEnum } from \"@follow/shared/constants\"\n\nconst isStoreDistribution = Boolean(process.mas || MICROSOFT_STORE_BUILD)\n\nexport const appUpdaterConfig = {\n  // Disable renderer hot update will trigger app update when available\n  enableRenderHotUpdate: !DEV && MODE !== ModeEnum.staging,\n  enableCoreUpdate: !isStoreDistribution,\n\n  // Disable app update will also disable renderer hot update and core update\n  enableAppUpdate: true,\n  enableDistributionStoreUpdate: isStoreDistribution,\n\n  app: {\n    autoCheckUpdate: true,\n    autoDownloadUpdate: true,\n    checkUpdateInterval: 15 * 60 * 1000,\n  },\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/follow-update-provider.ts",
    "content": "import { URL } from \"node:url\"\n\nimport type {\n  AppUpdate,\n  LatestReleasePayload,\n  PlatformUpdate,\n  PlatformUpdateFile,\n} from \"@follow-app/client-sdk\"\nimport type { UpdateFileInfo, UpdateInfo } from \"builder-util-runtime\"\nimport { newError } from \"builder-util-runtime\"\nimport type { AppUpdater } from \"electron-updater\"\nimport type { ProviderRuntimeOptions } from \"electron-updater/out/providers/Provider\"\nimport { Provider } from \"electron-updater/out/providers/Provider\"\nimport type { ResolvedUpdateFileInfo } from \"electron-updater/out/types\"\n\nimport { logger } from \"../logger\"\nimport { getUpdateInfo } from \"./api\"\n\ninterface FollowProviderOptions {\n  provider: \"custom\"\n}\n\ntype FollowProviderContext = {\n  payload: LatestReleasePayload\n  platform: PlatformUpdate\n}\n\nexport class FollowUpdateProvider extends Provider<UpdateInfo> {\n  private static context: FollowProviderContext | null = null\n\n  static setContext(context: FollowProviderContext) {\n    FollowUpdateProvider.context = context\n  }\n\n  static clearContext() {\n    FollowUpdateProvider.context = null\n  }\n\n  static getContext() {\n    return FollowUpdateProvider.context\n  }\n\n  constructor(\n    _options: FollowProviderOptions,\n    _updater: AppUpdater,\n    runtimeOptions: ProviderRuntimeOptions,\n  ) {\n    super(runtimeOptions)\n  }\n\n  async getLatestVersion(): Promise<UpdateInfo> {\n    const context = await this.ensureContext()\n    return this.buildUpdateInfo(context)\n  }\n\n  resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {\n    return updateInfo.files.map((file) => ({\n      info: file,\n      url: new URL(file.url),\n    }))\n  }\n\n  private buildUpdateInfo(context: FollowProviderContext): UpdateInfo {\n    const { payload, platform } = context\n    const files = this.mapFiles(platform.files)\n\n    if (files.length === 0) {\n      throw newError(\n        `No downloadable files found for platform ${platform.platform}`,\n        \"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND\",\n      )\n    }\n\n    const primaryFile = files[0]\n    if (!primaryFile) {\n      throw newError(\n        `Platform ${platform.platform} provides no downloadable file`,\n        \"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND\",\n      )\n    }\n\n    let primaryPath = primaryFile.url\n    const primaryUrl = this.safeParseUrl(primaryFile.url)\n    if (primaryUrl) {\n      const filename = primaryUrl.pathname.split(\"/\").pop()\n      if (filename) {\n        primaryPath = filename\n      }\n    }\n\n    const { release } = payload\n\n    return {\n      version: platform.version,\n      files,\n      path: primaryPath,\n      sha512: primaryFile.sha512,\n      releaseName: release.name,\n      releaseNotes: release.body,\n      releaseDate: platform.releaseDate || release.publishedAt || new Date().toISOString(),\n    }\n  }\n\n  private mapFiles(files: PlatformUpdate[\"files\"]): UpdateFileInfo[] {\n    if (!files) return []\n\n    return files\n      .map((file) => this.mapFile(file))\n      .filter((file): file is UpdateFileInfo => file !== null)\n  }\n\n  private mapFile(file: PlatformUpdateFile): UpdateFileInfo | null {\n    if (!file.downloadUrl || !file.sha512) {\n      logger.warn(\"Skip platform file without downloadUrl or sha512\", file)\n      return null\n    }\n\n    const mapped: UpdateFileInfo = {\n      url: file.downloadUrl,\n      sha512: file.sha512,\n    }\n\n    if (typeof file.size === \"number\") {\n      mapped.size = file.size\n    }\n\n    return mapped\n  }\n\n  private safeParseUrl(value: string): URL | null {\n    try {\n      return new URL(value)\n    } catch (error) {\n      logger.debug?.(\"Unable to parse update file URL\", error)\n      return null\n    }\n  }\n\n  private async ensureContext(): Promise<FollowProviderContext> {\n    const context = FollowUpdateProvider.getContext()\n    if (context) return context\n\n    const fetched = await this.fetchContext()\n    FollowUpdateProvider.setContext(fetched)\n    return fetched\n  }\n\n  private async fetchContext(): Promise<FollowProviderContext> {\n    const payload = await getUpdateInfo({})\n    const { decision } = payload\n\n    if (!decision || decision.type !== \"app\" || !decision.app) {\n      throw newError(\n        \"No app update metadata available from provider\",\n        \"ERR_UPDATER_NO_PUBLISHED_VERSIONS\",\n      )\n    }\n\n    const platform = this.pickPlatform(decision.app)\n    if (!platform) {\n      throw newError(\n        `No matching platform update for ${process.platform}/${process.arch}`,\n        \"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND\",\n      )\n    }\n\n    return { payload, platform }\n  }\n\n  private pickPlatform(appDecision: AppUpdate): PlatformUpdate | null {\n    const platforms = appDecision.platforms ?? []\n    const selected = appDecision.selectedPlatform\n    if (selected) {\n      return selected\n    }\n\n    const candidates = this.resolvePlatformCandidates()\n    const matched = platforms.find((platform) =>\n      candidates.includes(platform.platform.toLowerCase()),\n    )\n\n    return matched ?? platforms[0] ?? null\n  }\n\n  private resolvePlatformCandidates(): string[] {\n    const base = new Set<string>()\n    base.add(process.platform)\n    base.add(`${process.platform}-${process.arch}`)\n    base.add(process.arch)\n\n    if (process.platform === \"darwin\") {\n      base.add(\"mac\")\n      base.add(\"macos\")\n    }\n\n    if (process.platform === \"win32\") {\n      base.add(\"windows\")\n      base.add(\"win\")\n    }\n\n    return Array.from(base).map((value) => value.toLowerCase())\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/hot-updater.ts",
    "content": "import { existsSync, readFileSync } from \"node:fs\"\nimport { mkdir, readdir, rename, rm, stat, writeFile } from \"node:fs/promises\"\nimport os from \"node:os\"\n\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport type { LatestReleasePayload, RendererUpdate } from \"@follow-app/client-sdk\"\nimport { mainHash, version as appVersion } from \"@pkg\"\nimport log from \"electron-log\"\nimport { dump, load } from \"js-yaml\"\nimport path from \"pathe\"\nimport { x } from \"tar\"\n\nimport { HOTUPDATE_RENDER_ENTRY_DIR } from \"~/constants/app\"\nimport { downloadFileWithProgress } from \"~/lib/download\"\nimport { WindowManager } from \"~/manager/window\"\n\nimport { appUpdaterConfig } from \"./configs\"\n\ndeclare const GIT_COMMIT_HASH: string | undefined\n\nexport type RendererManifest = RendererUpdate & {\n  downloadUrl: string\n  downloadedAt?: string\n}\n\nexport enum RendererEligibilityStatus {\n  NoManifest,\n  RequiresFullAppUpdate,\n  AlreadyCurrent,\n  Eligible,\n}\n\nexport interface RendererEligibilityResult {\n  status: RendererEligibilityStatus\n  manifest?: RendererManifest\n  reason?: string\n}\n\nclass RendererHotUpdater {\n  private readonly logger = log.scope(\"updater:renderer\")\n  private readonly tempDir = path.resolve(os.tmpdir(), \"follow-render-update\")\n  private readonly manifestPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, \"manifest.yml\")\n\n  extractManifest(payload: LatestReleasePayload | null): RendererManifest | null {\n    if (!payload) return null\n\n    const { decision } = payload\n    if (!decision || decision.type !== \"renderer\") {\n      return null\n    }\n\n    return this.toManifest(decision.renderer)\n  }\n\n  extractManifestFromRendererUpdate(renderer: RendererUpdate | null): RendererManifest | null {\n    return this.toManifest(renderer)\n  }\n\n  evaluateManifest(manifest: RendererManifest | null): RendererEligibilityResult {\n    if (!manifest) {\n      return { status: RendererEligibilityStatus.NoManifest }\n    }\n\n    if (manifest.mainHash && manifest.mainHash !== mainHash) {\n      return {\n        status: RendererEligibilityStatus.RequiresFullAppUpdate,\n        manifest,\n        reason: `Renderer payload requires main hash ${manifest.mainHash}, current main hash is ${mainHash}`,\n      }\n    }\n\n    if (manifest.version === appVersion) {\n      return {\n        status: RendererEligibilityStatus.AlreadyCurrent,\n        reason: \"Renderer version matches current app version\",\n      }\n    }\n\n    if (manifest.commit && GIT_COMMIT_HASH && manifest.commit === GIT_COMMIT_HASH) {\n      return {\n        status: RendererEligibilityStatus.AlreadyCurrent,\n        reason: \"Renderer commit matches current main commit\",\n      }\n    }\n\n    const installedManifest = this.getCurrentManifest()\n    if (installedManifest) {\n      if (installedManifest.version === manifest.version) {\n        return {\n          status: RendererEligibilityStatus.AlreadyCurrent,\n          reason: \"Installed renderer manifest already at target version\",\n        }\n      }\n\n      if (\n        installedManifest.commit &&\n        manifest.commit &&\n        installedManifest.commit === manifest.commit\n      ) {\n        return {\n          status: RendererEligibilityStatus.AlreadyCurrent,\n          reason: \"Installed renderer manifest commit matches target commit\",\n        }\n      }\n    }\n\n    return {\n      status: RendererEligibilityStatus.Eligible,\n      manifest,\n    }\n  }\n\n  private toManifest(renderer: RendererUpdate | null): RendererManifest | null {\n    if (!renderer) {\n      this.logger.debug(\"Renderer decision payload missing renderer field\")\n      return null\n    }\n\n    if (!renderer.downloadUrl) {\n      this.logger.warn(\"Renderer decision missing downloadUrl, skip renderer hot update\")\n      return null\n    }\n\n    if (!renderer.filename) {\n      this.logger.warn(\"Renderer decision missing filename, skip renderer hot update\")\n      return null\n    }\n\n    if (!renderer.hash) {\n      this.logger.warn(\"Renderer decision missing hash, skip renderer hot update\")\n      return null\n    }\n\n    return {\n      ...renderer,\n      downloadUrl: renderer.downloadUrl,\n    }\n  }\n\n  async applyManifest(manifest: RendererManifest): Promise<void> {\n    if (!appUpdaterConfig.enableRenderHotUpdate) {\n      this.logger.info(\"Renderer hot update skipped because it is disabled in config\")\n      return\n    }\n\n    const archivePath = await this.downloadArchive(manifest)\n\n    await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })\n    this.logger.info(`Extracting renderer bundle to ${HOTUPDATE_RENDER_ENTRY_DIR}`)\n\n    await x({\n      f: archivePath,\n      cwd: HOTUPDATE_RENDER_ENTRY_DIR,\n    })\n\n    const extractedDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, \"renderer\")\n    const targetDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version)\n\n    const extractedStats = await stat(extractedDir).catch(() => null)\n    if (!extractedStats) {\n      throw new Error(`Extracted renderer directory not found at ${extractedDir}`)\n    }\n\n    await rm(targetDir, { recursive: true, force: true })\n    await rename(extractedDir, targetDir)\n\n    await this.writeManifest({ ...manifest, downloadedAt: new Date().toISOString() })\n\n    try {\n      await rm(archivePath, { force: true })\n    } catch (error) {\n      this.logger.warn(\"Failed to clean renderer archive\", error)\n    }\n\n    this.logger.info(`Renderer hot update applied successfully: ${manifest.version}`)\n\n    const mainWindow = WindowManager.getMainWindow()\n    if (mainWindow) {\n      callWindowExpose(mainWindow).readyToUpdate()\n    }\n  }\n\n  getCurrentManifest(): RendererManifest | null {\n    if (!existsSync(this.manifestPath)) {\n      return null\n    }\n\n    try {\n      const content = readFileSync(this.manifestPath, \"utf-8\")\n      const parsed = load(content)\n      if (parsed && typeof parsed === \"object\") {\n        return parsed as RendererManifest\n      }\n    } catch (error) {\n      this.logger.warn(\"Failed to read renderer manifest from disk\", error)\n    }\n\n    return null\n  }\n\n  async cleanup(): Promise<void> {\n    const manifest = this.getCurrentManifest()\n    if (!manifest) {\n      await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true })\n      return\n    }\n\n    const keepDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version)\n    let entries: string[] = []\n\n    try {\n      entries = await readdir(HOTUPDATE_RENDER_ENTRY_DIR)\n    } catch (error) {\n      this.logger.warn(\"Failed to read renderer directory for cleanup\", error)\n      return\n    }\n\n    await Promise.all(\n      entries.map(async (entryName) => {\n        const entryPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, entryName)\n        const entryStat = await stat(entryPath).catch(() => null)\n        if (!entryStat?.isDirectory()) return\n        if (entryPath === keepDir) return\n        await rm(entryPath, { recursive: true, force: true })\n      }),\n    )\n  }\n\n  loadDynamicEntry() {\n    if (!appUpdaterConfig.enableRenderHotUpdate) return\n\n    const manifest = this.getCurrentManifest()\n    if (!manifest) return\n    if (manifest.mainHash && manifest.mainHash !== mainHash) return\n\n    const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version)\n    const entryFile = path.resolve(dir, \"index.html\")\n    if (!existsSync(entryFile)) return\n\n    return entryFile\n  }\n\n  private async downloadArchive(manifest: RendererManifest) {\n    const archivePath = path.resolve(this.tempDir, manifest.filename)\n\n    this.logger.info(\n      `Downloading renderer bundle ${manifest.filename} from ${manifest.downloadUrl}`,\n    )\n\n    const success = await downloadFileWithProgress({\n      url: manifest.downloadUrl,\n      outputPath: archivePath,\n      expectedHash: manifest.hash,\n      onLog: (message) => this.logger.info(message),\n    })\n\n    if (!success) {\n      throw new Error(\"Failed to download renderer bundle\")\n    }\n\n    return archivePath\n  }\n\n  private async writeManifest(manifest: RendererManifest) {\n    await writeFile(this.manifestPath, dump(manifest), \"utf-8\")\n  }\n}\n\nexport const rendererUpdater = new RendererHotUpdater()\n\nexport const getCurrentRendererManifest = () => rendererUpdater.getCurrentManifest()\n\nexport const cleanupOldRenderer = async () => {\n  await rendererUpdater.cleanup()\n}\n\nexport const cleanupOldRender = cleanupOldRenderer\n\nexport const loadDynamicRenderEntry = () => rendererUpdater.loadDynamicEntry()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/index.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport { callWindowExpose } from \"@follow/shared/bridge\"\nimport { DEV } from \"@follow/shared/constants\"\nimport type {\n  DistributionStatusPayload,\n  LatestReleasePayload,\n  PlatformUpdate,\n  RendererUpdate,\n} from \"@follow-app/client-sdk\"\nimport { mainHash, version as appVersion } from \"@pkg\"\nimport log from \"electron-log\"\nimport type { AppUpdater } from \"electron-updater\"\nimport { autoUpdater as defaultAutoUpdater } from \"electron-updater\"\nimport { join } from \"pathe\"\nimport { gt, valid as isValidSemver } from \"semver\"\n\nimport { WindowManager } from \"~/manager/window\"\nimport type { RendererManifest } from \"~/updater/hot-updater\"\nimport { RendererEligibilityStatus, rendererUpdater } from \"~/updater/hot-updater\"\n\nimport { channel, isWindows } from \"../env\"\nimport { getDistributionUpdateInfo, getUpdateInfo } from \"./api\"\nimport { appUpdaterConfig } from \"./configs\"\nimport { FollowUpdateProvider } from \"./follow-update-provider\"\nimport { WindowsUpdater } from \"./windows-updater\"\n\nconst logger = log.scope(\"app-updater\")\ntype UpdateCheckOptions = {\n  refresh?: boolean\n}\n\ntype UpdateCheckResult = {\n  hasUpdate: boolean\n  error?: string\n}\n\nclass FollowUpdater {\n  private readonly disabled: boolean\n  private checkingUpdate = false\n  private downloadingUpdate = false\n\n  private pollingTimer: NodeJS.Timeout | null = null\n\n  constructor(\n    private readonly autoUpdater: AppUpdater,\n    private readonly renderer = rendererUpdater,\n  ) {\n    this.disabled = !appUpdaterConfig.enableAppUpdate\n  }\n\n  register() {\n    if (this.disabled) {\n      logger.info(\"App auto-update disabled; updater not registered\")\n      return\n    }\n\n    this.autoUpdater.autoDownload = false\n    this.autoUpdater.allowPrerelease = channel !== \"stable\"\n    this.autoUpdater.autoInstallOnAppQuit = true\n    this.autoUpdater.autoRunAppAfterInstall = true\n    this.autoUpdater.forceDevUpdateConfig = DEV\n\n    if (import.meta.env.DEV) {\n      const __dirname = fileURLToPath(new URL(\".\", import.meta.url))\n      this.autoUpdater.updateConfigPath = join(__dirname, \"../../dev-only/dev-app-update.yml\")\n    }\n\n    this.autoUpdater.setFeedURL({\n      provider: \"custom\",\n      updateProvider: FollowUpdateProvider,\n    })\n\n    this.registerAutoUpdaterEvents()\n\n    if (appUpdaterConfig.app.autoCheckUpdate) {\n      logger.info(\"Initial update check, mainHash:\", mainHash)\n      void this.checkForUpdates().catch((error) =>\n        logger.error(\"Initial update check failed\", error),\n      )\n    }\n\n    if (this.pollingTimer) {\n      clearInterval(this.pollingTimer)\n    }\n\n    const updatePollingHandler = async () => {\n      if (!appUpdaterConfig.app.autoCheckUpdate) {\n        return\n      }\n\n      void this.checkForUpdates().catch((error) => {\n        logger.error(\"Scheduled update check failed\", error)\n      })\n    }\n    updatePollingHandler()\n    this.pollingTimer = setInterval(updatePollingHandler, appUpdaterConfig.app.checkUpdateInterval)\n  }\n\n  async checkForUpdates(options: UpdateCheckOptions = {}): Promise<UpdateCheckResult> {\n    if (this.disabled) {\n      return { hasUpdate: false }\n    }\n\n    if (this.checkingUpdate) {\n      logger.info(\"Update check already in progress, skipping\")\n      return { hasUpdate: false }\n    }\n\n    this.checkingUpdate = true\n\n    try {\n      if (appUpdaterConfig.enableDistributionStoreUpdate) {\n        logger.info(\"Distribution store update enabled, checking for distribution update\")\n        return this.handleDistributionAppDecision()\n      }\n\n      const payload = await getUpdateInfo(options.refresh ? { refresh: true } : {})\n\n      return this.handleDirectAppDecision(payload)\n    } catch (error) {\n      logger.error(\"Failed to check for updates\", error)\n      return { hasUpdate: false, error: error instanceof Error ? error.message : \"Unknown error\" }\n    } finally {\n      this.checkingUpdate = false\n    }\n  }\n\n  async handleDirectAppDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> {\n    const { decision } = payload\n\n    if (!decision || decision.type === \"none\") {\n      logger.info(\"Update decision: none\")\n      return { hasUpdate: false }\n    }\n\n    if (decision.type === \"renderer\") {\n      logger.info(\"Update decision: renderer\")\n      return await this.handleRendererDecision(payload)\n    }\n\n    if (decision.type === \"app\") {\n      logger.info(\"Update decision: app\")\n      return await this.handleAppDecision(payload)\n    }\n\n    logger.warn(\"Unknown update decision type\", { type: decision.type })\n    return { hasUpdate: false }\n  }\n\n  async downloadAppUpdate(): Promise<void> {\n    if (this.disabled || this.downloadingUpdate) {\n      return\n    }\n\n    this.downloadingUpdate = true\n\n    try {\n      await this.autoUpdater.downloadUpdate()\n      logger.info(\"App update download requested\")\n    } catch (error) {\n      this.downloadingUpdate = false\n      logger.error(\"Failed to download app update\", error)\n      throw error\n    }\n  }\n\n  quitAndInstall() {\n    const mainWindow = WindowManager.getMainWindow()\n    logger.info(\"Quit and install triggered\", { windowId: mainWindow?.id })\n    WindowManager.destroyMainWindow()\n\n    setTimeout(() => {\n      logger.info(\"Main window closed, quitting to install update\")\n      this.autoUpdater.quitAndInstall()\n    }, 1000)\n  }\n\n  private resolvePlatformCandidates() {\n    const base = new Set<string>()\n    base.add(process.platform)\n    base.add(`${process.platform}-${process.arch}`)\n    base.add(process.arch)\n\n    if (process.platform === \"darwin\") {\n      base.add(\"mac\")\n      base.add(\"macos\")\n    }\n\n    if (process.platform === \"win32\") {\n      base.add(\"windows\")\n      base.add(\"win\")\n    }\n\n    return Array.from(base).map((value) => value.toLowerCase())\n  }\n\n  private pickPlatformUpdate(\n    platforms: PlatformUpdate[] | null | undefined,\n    selected?: PlatformUpdate | null,\n  ): PlatformUpdate | null {\n    if (!platforms || platforms.length === 0) {\n      return null\n    }\n\n    if (selected) {\n      return selected\n    }\n\n    const candidates = this.resolvePlatformCandidates()\n\n    const matched = platforms.find((platform) =>\n      candidates.includes(platform.platform.toLowerCase()),\n    )\n\n    return matched ?? platforms[0] ?? null\n  }\n\n  private async handleAppDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> {\n    const appDecision = payload.decision.app\n\n    if (!appUpdaterConfig.enableCoreUpdate) {\n      logger.info(\"Core app update disabled by configuration\")\n      return { hasUpdate: false }\n    }\n\n    if (!appDecision) {\n      logger.warn(\"App update decision missing app payload\")\n      return { hasUpdate: false, error: \"App update metadata unavailable\" }\n    }\n\n    const platformUpdate = this.pickPlatformUpdate(\n      appDecision.platforms,\n      appDecision.selectedPlatform,\n    )\n    if (!platformUpdate) {\n      logger.warn(\"No matching platform update found\", {\n        platform: process.platform,\n        arch: process.arch,\n      })\n      return { hasUpdate: false, error: \"No installer available for this platform\" }\n    }\n\n    FollowUpdateProvider.setContext({ payload, platform: platformUpdate })\n    logger.info(\"FollowUpdateProvider context set\", { platform: platformUpdate.platform })\n\n    try {\n      await this.autoUpdater.checkForUpdates()\n    } catch (error) {\n      logger.warn(\n        \"autoUpdater.checkForUpdates failed after preparing FollowUpdateProvider context\",\n        error,\n      )\n      return {\n        hasUpdate: false,\n        error: error instanceof Error ? error.message : \"Failed to check app update\",\n      }\n    } finally {\n      FollowUpdateProvider.clearContext()\n    }\n\n    return { hasUpdate: true }\n  }\n\n  private async handleDistributionAppDecision(): Promise<UpdateCheckResult> {\n    try {\n      if (!appUpdaterConfig.enableDistributionStoreUpdate) {\n        return { hasUpdate: false }\n      }\n\n      const info = await getDistributionUpdateInfo()\n\n      if (!info) {\n        logger.info(\n          \"Distribution update info unavailable for current build, falling back to direct app decision\",\n        )\n        const payload = await getUpdateInfo()\n        return this.handleDirectAppDecision(payload)\n      }\n\n      const rendererResult = await this.tryDistributionRendererUpdate(info.rendererUpdate)\n      if (rendererResult) {\n        return rendererResult\n      }\n\n      if (!this.shouldPromptDistributionStoreUpdate(info)) {\n        logger.info(\"Distribution update does not require store action\")\n        return { hasUpdate: false }\n      }\n\n      logger.info(\"Distribution store update required\")\n      return await this.notifyDistributionUpdate(info)\n    } catch (error) {\n      logger.error(\"Failed to handle distribution app update\", error)\n      return {\n        hasUpdate: false,\n        error: error instanceof Error ? error.message : \"Failed to handle distribution update\",\n      }\n    }\n  }\n\n  private async tryDistributionRendererUpdate(\n    renderer: RendererUpdate | null,\n  ): Promise<UpdateCheckResult | null> {\n    if (!renderer) {\n      return null\n    }\n\n    if (!appUpdaterConfig.enableRenderHotUpdate) {\n      logger.info(\"Renderer hot update disabled for distribution build\")\n      return null\n    }\n\n    const manifest = this.renderer.extractManifestFromRendererUpdate(renderer)\n    if (!manifest) {\n      logger.warn(\"Distribution renderer update missing manifest\")\n      return null\n    }\n\n    const eligibility = this.renderer.evaluateManifest(manifest)\n\n    switch (eligibility.status) {\n      case RendererEligibilityStatus.NoManifest: {\n        if (eligibility.reason) {\n          logger.warn(\"Distribution renderer update missing manifest data\", {\n            reason: eligibility.reason,\n          })\n        }\n        return null\n      }\n\n      case RendererEligibilityStatus.AlreadyCurrent: {\n        if (eligibility.reason) {\n          logger.info(eligibility.reason)\n        }\n        return { hasUpdate: false }\n      }\n\n      case RendererEligibilityStatus.RequiresFullAppUpdate: {\n        logger.info(\n          eligibility.reason ??\n            \"Renderer payload requires main process update, delegating to distribution store flow\",\n        )\n        return null\n      }\n\n      case RendererEligibilityStatus.Eligible: {\n        const manifestToApply = eligibility.manifest as RendererManifest | undefined\n        if (!manifestToApply) {\n          logger.warn(\"Distribution renderer update missing manifest payload\")\n          return null\n        }\n\n        try {\n          await this.renderer.applyManifest(manifestToApply)\n          return { hasUpdate: true }\n        } catch (error) {\n          logger.error(\"Renderer hot update failed for distribution build\", error)\n          return {\n            hasUpdate: false,\n            error: error instanceof Error ? error.message : \"Renderer hot update failed\",\n          }\n        }\n      }\n\n      default: {\n        return null\n      }\n    }\n  }\n\n  private shouldPromptDistributionStoreUpdate(info: DistributionStatusPayload): boolean {\n    if (!info.storeUrl) {\n      logger.info(\"Distribution store update skipped: missing store URL\", {\n        distribution: info.distribution,\n      })\n      return false\n    }\n\n    const { storeVersion } = info\n\n    const currentVersion = appVersion\n\n    if (!storeVersion) {\n      logger.info(\"Distribution store update skipped: missing store version\")\n      return false\n    }\n\n    if (!currentVersion) {\n      return true\n    }\n\n    const storeValid = isValidSemver(storeVersion)\n    const currentValid = isValidSemver(currentVersion)\n\n    if (storeValid && currentValid) {\n      const needsUpdate = gt(storeVersion, currentVersion)\n      if (!needsUpdate) {\n        logger.info(\"Distribution store version matches current version\", {\n          storeVersion,\n          currentVersion,\n        })\n      }\n      return needsUpdate\n    }\n\n    if (storeVersion === currentVersion) {\n      logger.info(\"Distribution store version identical to current version\", {\n        storeVersion,\n        currentVersion,\n      })\n      return false\n    }\n\n    return true\n  }\n\n  private async notifyDistributionUpdate(\n    info: DistributionStatusPayload,\n  ): Promise<UpdateCheckResult> {\n    const mainWindow = WindowManager.getMainWindow()\n    if (!mainWindow) {\n      logger.warn(\"Main window unavailable when notifying distribution update\")\n      return { hasUpdate: true }\n    }\n\n    if (!info.storeUrl) {\n      logger.warn(\"Distribution update missing store URL\", {\n        distribution: info.distribution,\n      })\n      return { hasUpdate: false }\n    }\n\n    await callWindowExpose(mainWindow).distributionUpdateAvailable({\n      distribution: info.distribution,\n      storeUrl: info.storeUrl,\n      storeVersion: info.storeVersion ?? null,\n      currentVersion: appVersion,\n    })\n\n    return { hasUpdate: true }\n  }\n\n  private async handleRendererDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> {\n    if (!appUpdaterConfig.enableRenderHotUpdate) {\n      logger.info(\"Renderer hot update disabled; falling back to app decision if present\")\n      if (payload.decision.app) {\n        return this.handleAppDecision(payload)\n      }\n      return { hasUpdate: false }\n    }\n\n    const manifest = this.renderer.extractManifest(payload)\n    const eligibility = this.renderer.evaluateManifest(manifest)\n\n    switch (eligibility.status) {\n      case RendererEligibilityStatus.NoManifest: {\n        return { hasUpdate: false, error: eligibility.reason }\n      }\n\n      case RendererEligibilityStatus.AlreadyCurrent: {\n        if (eligibility.reason) {\n          logger.info(eligibility.reason)\n        }\n        return { hasUpdate: false }\n      }\n\n      case RendererEligibilityStatus.RequiresFullAppUpdate: {\n        logger.info(\n          eligibility.reason,\n\n          \"Renderer payload requires main process update, delegating to app updater\",\n        )\n        if (payload.decision.app) {\n          return this.handleAppDecision(payload)\n        }\n        logger.warn(\"Renderer update requested full app upgrade but no app payload provided\")\n        return { hasUpdate: false, error: \"Renderer update requires full app upgrade\" }\n      }\n\n      case RendererEligibilityStatus.Eligible: {\n        const manifestToApply = eligibility.manifest as RendererManifest | undefined\n        if (!manifestToApply) {\n          return { hasUpdate: false }\n        }\n\n        try {\n          await this.renderer.applyManifest(manifestToApply)\n          return { hasUpdate: true }\n        } catch (error) {\n          logger.error(\"Renderer hot update failed\", error)\n          return {\n            hasUpdate: false,\n            error: error instanceof Error ? error.message : \"Renderer hot update failed\",\n          }\n        }\n      }\n\n      default: {\n        return { hasUpdate: false }\n      }\n    }\n  }\n\n  private registerAutoUpdaterEvents() {\n    this.autoUpdater.on(\"checking-for-update\", () => {\n      logger.info(\"autoUpdater: checking for update\")\n    })\n\n    this.autoUpdater.on(\"update-available\", (info) => {\n      logger.info(\"autoUpdater: update available\", info)\n\n      if (appUpdaterConfig.app.autoDownloadUpdate && appUpdaterConfig.enableCoreUpdate) {\n        void this.downloadAppUpdate().catch((error) =>\n          logger.error(\"Automatic download failed\", error),\n        )\n      }\n    })\n\n    this.autoUpdater.on(\"update-not-available\", (info) => {\n      logger.info(\"autoUpdater: update not available\", info)\n    })\n\n    this.autoUpdater.on(\"download-progress\", (progress) => {\n      logger.info(`autoUpdater: download progress ${progress.percent.toFixed(2)}%`)\n    })\n\n    this.autoUpdater.on(\"update-downloaded\", (ev) => {\n      this.downloadingUpdate = false\n      logger.info(\"autoUpdater: update downloaded\", ev.downloadedFile, ev.version)\n\n      const mainWindow = WindowManager.getMainWindow()\n      if (!mainWindow) return\n\n      callWindowExpose(mainWindow).updateDownloaded()\n    })\n\n    this.autoUpdater.on(\"error\", (error) => {\n      logger.error(\"autoUpdater: error\", error)\n    })\n  }\n}\n\nconst autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater\nconst followUpdater = new FollowUpdater(autoUpdater)\n\nexport const registerUpdater = () => {\n  followUpdater.register()\n}\n\nexport const checkForAppUpdates = (options: UpdateCheckOptions = {}) =>\n  followUpdater.checkForUpdates(options)\n\nexport const quitAndInstall = () => followUpdater.quitAndInstall()\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/logger.ts",
    "content": "import log from \"electron-log\"\n\n/**\n * Logger for updater module with scoped prefix\n * All logs are prefixed with [Updater] for easy identification\n */\nexport const updaterLogger = log.scope(\"updater\")\n\n/**\n * Logger specifically for GitHub provider operations\n */\nexport const githubProviderLogger = log.scope(\"updater:github\")\n\n/**\n * Helper to log object properties in a formatted way\n */\nexport function logObject(logger: typeof updaterLogger, prefix: string, obj: Record<string, any>) {\n  logger.info(`${prefix}:`)\n  for (const [key, value] of Object.entries(obj)) {\n    logger.info(`  ${key}: ${value}`)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/src/updater/windows-updater.ts",
    "content": "import { app } from \"electron\"\nimport { NsisUpdater } from \"electron-updater\"\nimport { DownloadedUpdateHelper } from \"electron-updater/out/DownloadedUpdateHelper\"\n\nexport class WindowsUpdater extends NsisUpdater {\n  protected override downloadedUpdateHelper: DownloadedUpdateHelper = new DownloadedUpdateHelper(\n    app.getPath(\"sessionData\"),\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/tsconfig.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.node.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"dist\",\n    \"types\": [\"electron-vite/node\", \"@follow/types/vite\", \"@follow/types/global\"],\n    \"moduleResolution\": \"Bundler\",\n    \"noImplicitReturns\": false,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true,\n    \"experimentalDecorators\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@pkg\": [\"../../package.json\"],\n      \"@locales/*\": [\"../../../../locales/*\"],\n      \"~/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/main/vitest.config.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport tsconfigPath from \"vite-tsconfig-paths\"\nimport { defineProject } from \"vitest/config\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\n\nexport default defineProject({\n  root: \"./\",\n  test: {\n    globals: true,\n    environment: \"node\",\n  },\n\n  plugins: [\n    tsconfigPath({\n      projects: [\"./tsconfig.json\"],\n    }),\n  ],\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/debug_proxy.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <title>Debug Proxy</title>\n\n    <script type=\"module\">\n      globalThis[\"__DEBUG_PROXY__\"] = true\n\n      const searchParams = new URLSearchParams(window.location.search)\n      const debugHost = searchParams.get(\"debug-host\")\n\n      const resetSessionStorage = () => {\n        sessionStorage.removeItem(\"debug-host\")\n      }\n\n      const resetParams = searchParams.get(\"reset\")\n      if (resetParams) {\n        resetSessionStorage()\n      }\n\n      const debugHostInSessionStorage = sessionStorage.getItem(\"debug-host\")\n\n      const host = debugHost || debugHostInSessionStorage || \"http://localhost:2233\"\n      if (debugHost) {\n        sessionStorage.setItem(\"debug-host\", debugHost)\n      }\n\n      const createRefreshRuntimeScript = `\n      import RefreshRuntime from \"${host}/@react-refresh\";\n      RefreshRuntime.injectIntoGlobalHook(window);\n      window.$RefreshReg$ = () => {};\n      window.$RefreshSig$ = () => (type) => type;\n      window.__vite_plugin_react_preamble_installed__ = true;\n      `\n      const $script = document.createElement(\"script\")\n      $script.innerHTML = createRefreshRuntimeScript\n      $script.type = \"module\"\n      document.head.append($script)\n\n      fetch(`${host}`)\n        .then((res) => res.text())\n        .then((html) => {\n          const parser = new DOMParser()\n          const doc = parser.parseFromString(html, \"text/html\")\n\n          const scripts = doc.querySelectorAll(\"script\")\n\n          scripts.forEach((script) => {\n            script.remove()\n          })\n\n          // header meta\n\n          const $meta = doc.head.querySelectorAll(\"meta\")\n          $meta.forEach((meta) => {\n            document.head.append(meta)\n          })\n\n          const $style = doc.head.querySelectorAll(\"style\")\n          $style.forEach((style) => {\n            document.head.append(style)\n          })\n\n          document.body.innerHTML = doc.body.innerHTML\n\n          scripts.forEach((script) => {\n            const $script = document.createElement(\"script\")\n            $script.type = \"module\"\n            $script.crossOrigin = script.crossOrigin\n\n            if (script.src) {\n              $script.src = new URL(\n                script.src.startsWith(\"http\") ? new URL(script.src).pathname : script.src,\n                host,\n              ).toString()\n            } else if (script.innerHTML) {\n              $script.innerHTML = script.innerHTML\n            } else {\n              return\n            }\n\n            document.body.append($script)\n          })\n        })\n\n      const injectScript = (apiUrl) => {\n        const upstreamOrigin = window.location.origin\n        const template = `function injectEnv(env2) {\n      for (const key in env2) {\n      if (env2[key] === void 0) continue;\n      globalThis[\"__followEnv\"] ??= {};\n      globalThis[\"__followEnv\"][key] = env2[key];\n      }\n      }\n      injectEnv({\"VITE_API_URL\":\"${apiUrl}\",\"VITE_EXTERNAL_API_URL\":\"${apiUrl}\",\"VITE_WEB_URL\":\"${upstreamOrigin}\"})`\n        const $script = document.createElement(\"script\")\n        $script.innerHTML = template\n        document.head.prepend($script)\n      }\n\n      injectScript(import.meta.env.VITE_API_URL)\n    </script>\n  </head>\n  <body></body>\n</html>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/global.d.ts",
    "content": "import type { ElectronAPI } from \"@electron-toolkit/preload\"\n\ndeclare global {\n  interface Window {\n    electron?: ElectronAPI\n    api?: { canWindowBlur: boolean; isWindowsStore: boolean }\n    platform: NodeJS.Platform\n  }\n  export const APP_NAME = \"Folo\"\n}\n\ndeclare module \"virtual:pwa-register/react\" {\n  import type { Dispatch, SetStateAction } from \"react\"\n  import type { RegisterSWOptions } from \"vite-plugin-pwa/types\"\n\n  export function useRegisterSW(options?: RegisterSWOptions): {\n    needRefresh: [boolean, Dispatch<SetStateAction<boolean>>]\n    offlineReady: [boolean, Dispatch<SetStateAction<boolean>>]\n    updateServiceWorker: (reloadPage?: boolean) => Promise<void>\n  }\n}\n\nexport {}\n\nexport { type RegisterSWOptions } from \"vite-plugin-pwa/types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"referrer\" content=\"no-referrer\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover\"\n    />\n\n    <meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#ffffff\" />\n    <meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#121212\" />\n    <!-- favicon -->\n    <link rel=\"icon\" href=\"/favicon.ico\" sizes=\"48x48\" type=\"image/x-icon\" />\n    <link rel=\"icon\" href=\"/icon.svg\" sizes=\"any\" type=\"image/svg+xml\" />\n    <!-- FireFox -->\n    <link rel=\"shortcut icon\" href=\"/favicon.ico\" type=\"image/x-icon\" />\n    <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon-180x180.png\" />\n\n    <title>Folo - AI Reader | Follow Everything</title>\n    <meta\n      name=\"description\"\n      content=\"Folo organizes content into one timeline, keeping you updated on what matters, noise-free. AI reader with translation, summary, and dynamic content support. Available on iOS, Android, macOS, Windows, and Linux.\"\n    />\n    <meta\n      name=\"keywords\"\n      content=\"AI reader, RSS reader, feed aggregator, AI, content curation, timeline, news reader, follow feeds, social media aggregator, distraction-free browsing, cross-platform, open source, Folo, Follow Everything, AI translation, AI summary, dynamic content, video feeds, audio feeds, image feeds, RSS subscription, feed management, news aggregator, content hub, information hub, RSSNext, electron app, mobile app, desktop app, web app, iOS app, Android app, macOS app, Windows app, Linux app, noise-free feeds, curated lists, share lists, explore collections, POWER token, ownership economy, tip creators, community-driven, GitHub trending\"\n    />\n    <meta name=\"author\" content=\"Follow Team\" />\n    <meta name=\"robots\" content=\"index, follow\" />\n    <meta name=\"language\" content=\"en\" />\n    <link rel=\"canonical\" href=\"https://app.folo.is\" />\n\n    <!-- Apple Meta Tags -->\n    <meta name=\"apple-itunes-app\" content=\"app-id=6739802604\" />\n    <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Folo\" />\n\n    <!-- OpenGraph Meta Tags -->\n    <meta property=\"og:type\" content=\"website\" />\n    <meta property=\"og:site_name\" content=\"Folo\" />\n    <meta property=\"og:title\" content=\"Folo - AI Reader | Follow Everything\" />\n    <meta property=\"og:url\" content=\"https://app.folo.is\" />\n    <meta property=\"og:image\" content=\"https://app.folo.is/og-image.png\" />\n    <meta property=\"og:image:alt\" content=\"Folo - AI Reader\" />\n    <meta\n      property=\"og:description\"\n      content=\"Organize content into one timeline, keeping you updated on what matters, noise-free. AI-powered features like translation, summary, and more. Share lists, explore collections, and enjoy distraction-free browsing.\"\n    />\n    <meta property=\"og:locale\" content=\"en_US\" />\n\n    <!-- Twitter Meta Tags -->\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n    <meta name=\"twitter:site\" content=\"@folo_is\" />\n    <meta name=\"twitter:creator\" content=\"@folo_is\" />\n    <meta name=\"twitter:title\" content=\"Folo - AI Reader | Follow Everything\" />\n    <meta\n      name=\"twitter:description\"\n      content=\"Folo organizes content into one timeline, keeping you updated on what matters, noise-free. AI-powered RSS reader with translation, summary, and dynamic content support. Available on iOS, Android, macOS, Windows, and Linux.\"\n    />\n    <meta name=\"twitter:image\" content=\"https://app.folo.is/og-image.png\" />\n    <meta name=\"twitter:image:alt\" content=\"Folo - AI Reader\" />\n\n    <!-- Structured Data -->\n    <script type=\"application/ld+json\">\n      {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"SoftwareApplication\",\n        \"name\": \"Folo\",\n        \"alternateName\": \"Follow Everything\",\n        \"description\": \"AI-driven, user-friendly RSS reader that organizes content into one timeline, keeping you updated on what matters, noise-free. Share lists, explore collections, and enjoy distraction-free browsing.\",\n        \"url\": \"https://app.folo.is\",\n        \"image\": \"https://app.folo.is/og-image.png\",\n        \"applicationCategory\": \"ProductivityApplication\",\n        \"operatingSystem\": [\"iOS\", \"Android\", \"macOS\", \"Windows\", \"Linux\", \"Web\"],\n        \"offers\": {\n          \"@type\": \"Offer\",\n          \"price\": \"0\",\n          \"priceCurrency\": \"USD\"\n        },\n        \"publisher\": {\n          \"@type\": \"Organization\",\n          \"name\": \"RSSNext\",\n          \"url\": \"https://github.com/RSSNext\"\n        },\n        \"downloadUrl\": [\n          \"https://apps.apple.com/us/app/folo-follow-everything/id6739802604\",\n          \"https://play.google.com/store/apps/details?id=is.follow\",\n          \"https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct\",\n          \"https://github.com/RSSNext/Folo/releases/latest\"\n        ],\n        \"screenshot\": [\n          \"https://github.com/user-attachments/assets/11dc7d21-f5d8-4e41-9269-24fc352aa02b\",\n          \"https://github.com/user-attachments/assets/37cf4f2f-4c5e-4775-86e8-2fa1a1b2ecf5\",\n          \"https://github.com/user-attachments/assets/d1379fd6-8767-476e-b0dc-d61753715e26\"\n        ],\n        \"featureList\": [\n          \"Customized Information Hub\",\n          \"AI-powered translation and summary\",\n          \"Dynamic content support (articles, videos, images, audio)\",\n          \"Distraction-free browsing\",\n          \"Cross-platform availability\",\n          \"Open source community-driven\"\n        ],\n        \"aggregateRating\": {\n          \"@type\": \"AggregateRating\",\n          \"ratingValue\": \"4.8\",\n          \"ratingCount\": \"1000\",\n          \"bestRating\": \"5\",\n          \"worstRating\": \"1\"\n        }\n      }\n    </script>\n    <!-- FOLLOW VITE BUILD INJECT -->\n    <!-- Check Browser Script Inject -->\n\n    <script>\n      function setTheme() {\n        let e = \"follow:color-mode\",\n          t = document.documentElement,\n          a = localStorage.getItem(e)\n        function h() {\n          return window.matchMedia\n            ? window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n              ? \"dark\"\n              : window.matchMedia(\"(prefers-color-scheme: light)\").matches\n                ? \"light\"\n                : void 0\n            : void 0\n        }\n        if (!a) {\n          t.dataset.theme = h() || \"light\"\n          return\n        }\n        switch ((a = JSON.parse(a))) {\n          case \"dark\":\n            t.dataset.theme = \"dark\"\n            break\n          case \"light\":\n            t.dataset.theme = \"light\"\n            break\n          case \"system\":\n            t.dataset.theme = h() || \"light\"\n        }\n      }\n      setTheme()\n      // Can not get window.electron so check userAgent\n      const isElectron = navigator.userAgent.includes(\"Electron\")\n      document.documentElement.dataset.buildType = isElectron ? \"electron\" : \"web\"\n    </script>\n    <script>\n      const isMobile = window.innerWidth < 1024\n      document.documentElement.dataset.viewport = isMobile ? \"mobile\" : \"desktop\"\n    </script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n\n    <div id=\"app-skeleton\" class=\"drag-region\">\n      <!-- Skeleton -->\n      <style>\n        [data-build-type]:not([data-build-type=\"electron\"]) #root {\n          background-color: hsl(var(--background));\n        }\n        html,\n        body {\n          height: 100%;\n          width: 100%;\n          margin: 0;\n          padding: 0;\n        }\n        #app-skeleton {\n          position: fixed;\n          inset: 0;\n          z-index: 1000;\n        }\n        [data-viewport=\"mobile\"] #app-skeleton {\n          display: none;\n        }\n        [data-theme=\"light\"] {\n          --background: 0 0% 100%;\n        }\n\n        [data-theme=\"dark\"] {\n          --background: 0 0% 7.1%;\n        }\n        .skeleton {\n          display: flex;\n          height: 100%;\n          width: 100%;\n        }\n\n        .sidebar {\n          width: 16rem;\n          flex-shrink: 0;\n          height: 100%;\n          background-color: hsl(var(--fo-sidebar));\n        }\n        [data-build-type=\"electron\"] .sidebar {\n          background-color: hsl(var(--fo-sidebar) / 0.3);\n        }\n        [data-build-type=\"electron\"][data-theme=\"dark\"] .sidebar {\n          background-color: hsl(var(--fo-sidebar) / 0.1);\n        }\n        .content {\n          flex-grow: 1;\n          width: 100%;\n          height: 100%;\n          background-color: hsl(var(--background));\n        }\n\n        [data-theme=\"light\"] {\n          --fo-sidebar: 240 1.6% 87.6%;\n        }\n        [data-theme=\"dark\"] {\n          --fo-sidebar: 30 1.7% 23.5%;\n        }\n\n        [data-build-type=\"web\"] {\n          --fo-sidebar: 240 4.8% 95.9%;\n        }\n\n        [data-build-type=\"web\"][data-theme=\"dark\"] {\n          --fo-sidebar: 220 8.1% 14.5%;\n        }\n      </style>\n      <div class=\"skeleton\">\n        <div class=\"sidebar\"></div>\n        <div class=\"content\"></div>\n      </div>\n    </div>\n\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/package.json",
    "content": "{\n  \"name\": \"@follow/web\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"main\": \"./dist/main/index.js\",\n  \"scripts\": {\n    \"build:web\": \"cd ../.. && pnpm build:web\",\n    \"db:generate\": \"pnpm --filter @follow/database run generate\",\n    \"dev\": \"cd ../.. && pnpm dev:web\",\n    \"dev:ssl\": \"cd ../.. && SSL=true pnpm dev:web\",\n    \"generate-pwa-assets\": \"pwa-assets-generator public/icon.svg\",\n    \"test\": \"vitest --typecheck\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@dnd-kit/core\": \"6.3.1\",\n    \"@dnd-kit/sortable\": \"10.0.0\",\n    \"@electron-toolkit/preload\": \"3.0.2\",\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/database\": \"workspace:*\",\n    \"@follow/electron-main\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/store\": \"workspace:*\",\n    \"@follow/tracker\": \"workspace:*\",\n    \"@fontsource/sn-pro\": \"5.2.6\",\n    \"@headlessui/react\": \"2.2.9\",\n    \"@hookform/resolvers\": \"5.2.2\",\n    \"@lexical/markdown\": \"0.40.0\",\n    \"@lexical/react\": \"0.40.0\",\n    \"@number-flow/react\": \"0.5.11\",\n    \"@radix-ui/react-avatar\": \"1.1.11\",\n    \"@radix-ui/react-context-menu\": \"2.2.16\",\n    \"@radix-ui/react-dialog\": \"1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"2.1.16\",\n    \"@radix-ui/react-hover-card\": \"1.1.15\",\n    \"@radix-ui/react-label\": \"2.1.8\",\n    \"@radix-ui/react-popover\": \"1.1.15\",\n    \"@radix-ui/react-slider\": \"1.3.6\",\n    \"@radix-ui/react-slot\": \"1.2.4\",\n    \"@shikijs/transformers\": \"3.22.0\",\n    \"@splinetool/react-spline\": \"4.1.0\",\n    \"@tanstack/query-sync-storage-persister\": \"5.90.22\",\n    \"@tanstack/react-query\": \"5.90.21\",\n    \"@tanstack/react-query-devtools\": \"5.91.3\",\n    \"@tanstack/react-query-persist-client\": \"5.90.22\",\n    \"@tanstack/react-virtual\": \"3.13.18\",\n    \"@toon-format/toon\": \"2.1.0\",\n    \"@use-gesture/react\": \"10.3.1\",\n    \"@welldone-software/why-did-you-render\": \"10.0.1\",\n    \"@xyflow/react\": \"12.10.0\",\n    \"@yornaath/batshit\": \"0.14.0\",\n    \"ai\": \"6.0.85\",\n    \"camelcase-keys\": \"10.0.2\",\n    \"chrono-node\": \"2.9.0\",\n    \"class-variance-authority\": \"0.7.1\",\n    \"clsx\": \"2.1.1\",\n    \"cmdk\": \"1.1.1\",\n    \"cookie-es\": \"2.0.0\",\n    \"dayjs\": \"1.11.19\",\n    \"dnum\": \"2.17.0\",\n    \"electron-ipc-decorator\": \"0.2.0\",\n    \"embla-carousel-react\": \"8.6.0\",\n    \"embla-carousel-wheel-gestures\": \"8.1.0\",\n    \"es-toolkit\": \"1.44.0\",\n    \"firebase\": \"12.9.0\",\n    \"foxact\": \"0.2.52\",\n    \"franc-min\": \"6.2.0\",\n    \"fuse.js\": \"7.1.0\",\n    \"hast-util-to-jsx-runtime\": \"2.3.6\",\n    \"hast-util-to-mdast\": \"10.1.2\",\n    \"i18next\": \"25.8.6\",\n    \"i18next-browser-languagedetector\": \"8.2.1\",\n    \"idb-keyval\": \"6.2.2\",\n    \"immer\": \"11.1.4\",\n    \"jotai\": \"2.17.1\",\n    \"lethargy\": \"1.0.9\",\n    \"lexical\": \"0.40.0\",\n    \"masonic\": \"4.1.0\",\n    \"mdast-util-gfm-table\": \"2.0.0\",\n    \"mdast-util-to-markdown\": \"2.1.2\",\n    \"motion\": \"12.34.0\",\n    \"nanoid\": \"5.1.6\",\n    \"ofetch\": \"1.5.1\",\n    \"plain-shiki\": \"0.3.2\",\n    \"re-resizable\": \"6.11.2\",\n    \"react\": \"19.0.0\",\n    \"react-blurhash\": \"0.3.0\",\n    \"react-dom\": \"19.0.0\",\n    \"react-error-boundary\": \"6.1.0\",\n    \"react-fast-compare\": \"3.2.2\",\n    \"react-fast-marquee\": \"1.6.5\",\n    \"react-google-recaptcha-v3\": \"1.11.0\",\n    \"react-hook-form\": \"7.71.1\",\n    \"react-hotkeys-hook\": \"5.2.4\",\n    \"react-i18next\": \"16.5.4\",\n    \"react-intersection-observer\": \"10.0.2\",\n    \"react-ios-pwa-prompt\": \"2.0.6\",\n    \"react-markdown\": \"10.1.0\",\n    \"react-qr-code\": \"2.0.18\",\n    \"react-resizable-layout\": \"npm:@innei/react-resizable-layout@0.7.3-fork.1\",\n    \"react-router\": \"7.13.0\",\n    \"react-selecto\": \"1.26.3\",\n    \"react-shadow\": \"20.6.0\",\n    \"react-zoom-pan-pinch\": \"3.7.0\",\n    \"rehype-raw\": \"7.0.0\",\n    \"shiki\": \"3.22.0\",\n    \"sonner\": \"2.0.7\",\n    \"tinykeys\": \"3.0.0\",\n    \"title-case\": \"4.3.2\",\n    \"tldts\": \"7.0.23\",\n    \"ufo\": \"1.6.3\",\n    \"use-context-selector\": \"2.0.0\",\n    \"use-sync-external-store\": \"1.6.0\",\n    \"usehooks-ts\": \"3.1.1\",\n    \"zod\": \"3.25.76\",\n    \"zustand\": \"5.0.11\"\n  },\n  \"devDependencies\": {\n    \"@follow/atoms\": \"workspace:*\",\n    \"@follow/components\": \"workspace:*\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/hooks\": \"workspace:*\",\n    \"@follow/logger\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"@folo-services/ai-tools\": \"catalog:\",\n    \"@types/node\": \"25.2.3\",\n    \"@vite-pwa/assets-generator\": \"1.0.2\",\n    \"fake-indexeddb\": \"6.2.5\",\n    \"happy-dom\": \"20.6.1\",\n    \"react-scan\": \"0.4.3\",\n    \"typescript\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/pwa-assets.config.ts",
    "content": "import type { Preset } from \"@vite-pwa/assets-generator/config\"\nimport { defineConfig } from \"@vite-pwa/assets-generator/config\"\n\nconst minimal2023Preset: Preset = {\n  transparent: {\n    sizes: [64, 192, 512],\n    favicons: [[48, \"favicon.ico\"]],\n    padding: 0.05,\n    // rgba(255, 92, 0, 1)\n    resizeOptions: {\n      fit: \"contain\",\n      background: {\n        r: 255,\n        g: 92,\n        b: 0,\n        alpha: 1,\n      },\n    },\n  },\n  maskable: {\n    sizes: [512],\n    padding: 0,\n    resizeOptions: {\n      fit: \"contain\",\n      background: {\n        r: 255,\n        g: 92,\n        b: 0,\n        alpha: 1,\n      },\n    },\n  },\n  apple: {\n    sizes: [180],\n    padding: 0,\n    resizeOptions: {\n      fit: \"contain\",\n      background: {\n        r: 255,\n        g: 92,\n        b: 0,\n        alpha: 1,\n      },\n    },\n  },\n}\n\nexport default defineConfig({\n  headLinkOptions: {\n    preset: \"2023\",\n  },\n  preset: minimal2023Preset,\n  images: [\"public/logo.svg\"],\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/setup-file.ts",
    "content": "// @ts-nocheck\nimport \"fake-indexeddb/auto\"\n\nimport { enableMapSet } from \"immer\"\n\nglobalThis.window = {\n  location: new URL(\"https://example.com\"),\n  __dbIsReady: true,\n  addEventListener: () => {},\n  get navigator() {\n    return globalThis.navigator\n  },\n}\n\nif (!globalThis.navigator) {\n  globalThis.navigator = {\n    onLine: true,\n    userAgent: \"node\",\n  }\n}\nenableMapSet()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/@types/constants.ts",
    "content": "// DONT EDIT THIS FILE MANUALLY\nconst langs = [\"en\", \"zh-CN\", \"zh-TW\", \"ja\", \"fr-FR\"] as const\nexport const currentSupportedLanguages = langs as readonly string[]\nexport type RendererSupportedLanguages = (typeof langs)[number]\n\nexport const dayjsLocaleImportMap = {\n  en: [\"en\", () => import(\"dayjs/locale/en\")],\n  [\"zh-CN\"]: [\"zh-cn\", () => import(\"dayjs/locale/zh-cn\")],\n  [\"ja\"]: [\"ja\", () => import(\"dayjs/locale/ja\")],\n  [\"zh-TW\"]: [\"zh-tw\", () => import(\"dayjs/locale/zh-tw\")],\n  [\"fr-FR\"]: [\"fr\", () => import(\"dayjs/locale/fr\")],\n}\nexport const ns = [\"common\", \"lang\", \"errors\", \"app\", \"settings\", \"shortcuts\", \"ai\"] as const\nexport const defaultNS = \"app\" as const\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/@types/default-resource.electron.ts",
    "content": "// DONT EDIT THIS FILE MANUALLY\nimport ai_en from \"@locales/ai/en.json\"\nimport ai_frFR from \"@locales/ai/fr-FR.json\"\nimport ai_ja from \"@locales/ai/ja.json\"\nimport en from \"@locales/app/en.json\"\nimport app_frFR from \"@locales/app/fr-FR.json\"\nimport app_ja from \"@locales/app/ja.json\"\nimport app_zhCN from \"@locales/app/zh-CN.json\"\nimport app_zhTW from \"@locales/app/zh-TW.json\"\nimport common_en from \"@locales/common/en.json\"\nimport common_frFR from \"@locales/common/fr-FR.json\"\nimport common_ja from \"@locales/common/ja.json\"\nimport common_zhCN from \"@locales/common/zh-CN.json\"\nimport common_zhTW from \"@locales/common/zh-TW.json\"\nimport errors_en from \"@locales/errors/en.json\"\nimport errors_frFR from \"@locales/errors/fr-FR.json\"\nimport errors_ja from \"@locales/errors/ja.json\"\nimport errors_zhCN from \"@locales/errors/zh-CN.json\"\nimport errors_zhTW from \"@locales/errors/zh-TW.json\"\nimport lang_en from \"@locales/lang/en.json\"\nimport lang_frFR from \"@locales/lang/fr-FR.json\"\nimport lang_ja from \"@locales/lang/ja.json\"\nimport lang_zhCN from \"@locales/lang/zh-CN.json\"\nimport lang_zhTW from \"@locales/lang/zh-TW.json\"\nimport settings_en from \"@locales/settings/en.json\"\nimport settings_frFR from \"@locales/settings/fr-FR.json\"\nimport settings_ja from \"@locales/settings/ja.json\"\nimport settings_zhCN from \"@locales/settings/zh-CN.json\"\nimport settings_zhTW from \"@locales/settings/zh-TW.json\"\nimport shortcuts_en from \"@locales/shortcuts/en.json\"\nimport shortcuts_frFR from \"@locales/shortcuts/fr-FR.json\"\nimport shortcuts_ja from \"@locales/shortcuts/ja.json\"\nimport shortcuts_zhCN from \"@locales/shortcuts/zh-CN.json\"\nimport shortcuts_zhTW from \"@locales/shortcuts/zh-TW.json\"\n\nimport type { ns, RendererSupportedLanguages } from \"./constants\"\n\n/**\n * This file is the language resource that is loaded in full when the app is initialized.\n * In electron, we can load all the language resources synchronously.\n */\nexport const defaultResources = {\n  en: {\n    app: en,\n    lang: lang_en,\n    common: common_en,\n    settings: settings_en,\n    shortcuts: shortcuts_en,\n    errors: errors_en,\n    ai: ai_en,\n  },\n  \"zh-CN\": {\n    app: app_zhCN,\n    lang: lang_zhCN,\n    common: common_zhCN,\n    settings: settings_zhCN,\n    shortcuts: shortcuts_zhCN,\n    errors: errors_zhCN,\n    ai: ai_en, // Fallback to English until Chinese translation is available\n  },\n\n  ja: {\n    app: app_ja,\n    lang: lang_ja,\n    common: common_ja,\n    settings: settings_ja,\n    shortcuts: shortcuts_ja,\n    errors: errors_ja,\n    ai: ai_ja,\n  },\n  \"zh-TW\": {\n    app: app_zhTW,\n    lang: lang_zhTW,\n    common: common_zhTW,\n    settings: settings_zhTW,\n    shortcuts: shortcuts_zhTW,\n    errors: errors_zhTW,\n    ai: ai_en, // Fallback to English until Traditional Chinese translation is available\n  },\n  \"fr-FR\": {\n    app: app_frFR,\n    lang: lang_frFR,\n    common: common_frFR,\n    settings: settings_frFR,\n    shortcuts: shortcuts_frFR,\n    errors: errors_frFR,\n    ai: ai_frFR,\n  },\n} satisfies Record<\n  RendererSupportedLanguages,\n  Partial<Record<(typeof ns)[number], Record<string, string>>>\n>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/@types/default-resource.ts",
    "content": "// DONT EDIT THIS FILE MANUALLY\nimport ai_en from \"@locales/ai/en.json\"\nimport en from \"@locales/app/en.json\"\nimport common_en from \"@locales/common/en.json\"\nimport common_frFR from \"@locales/common/fr-FR.json\"\nimport common_ja from \"@locales/common/ja.json\"\nimport common_zhCN from \"@locales/common/zh-CN.json\"\nimport common_zhTW from \"@locales/common/zh-TW.json\"\nimport errors_en from \"@locales/errors/en.json\"\nimport lang_en from \"@locales/lang/en.json\"\nimport lang_frFR from \"@locales/lang/fr-FR.json\"\nimport lang_ja from \"@locales/lang/ja.json\"\nimport lang_zhCN from \"@locales/lang/zh-CN.json\"\nimport lang_zhTW from \"@locales/lang/zh-TW.json\"\nimport settings_en from \"@locales/settings/en.json\"\nimport shortcuts_en from \"@locales/shortcuts/en.json\"\n\nimport type { ns, RendererSupportedLanguages } from \"./constants\"\n\n/**\n * This file is the language resource that is loaded in full when the app is initialized.\n * When switching languages, the app will automatically download the required language resources,\n * we will not load all the language resources to minimize the first screen loading time of the app.\n * Generally, we only load english resources synchronously by default.\n * In addition, we attach common resources for other languages, and the size of the common resources must be controlled.\n */\nexport const defaultResources = {\n  en: {\n    app: en,\n    lang: lang_en,\n    common: common_en,\n    settings: settings_en,\n    shortcuts: shortcuts_en,\n    errors: errors_en,\n    ai: ai_en,\n  },\n  \"zh-CN\": {\n    lang: lang_zhCN,\n    common: common_zhCN,\n  },\n\n  ja: {\n    lang: lang_ja,\n    common: common_ja,\n  },\n  \"zh-TW\": { lang: lang_zhTW, common: common_zhTW },\n  \"fr-FR\": { lang: lang_frFR, common: common_frFR },\n} satisfies Record<\n  RendererSupportedLanguages,\n  Partial<Record<(typeof ns)[number], Record<string, string>>>\n>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/@types/i18next.d.ts",
    "content": "import type { defaultNS, ns } from \"./constants\"\nimport type { defaultResources as resources } from \"./default-resource\"\n\ndeclare module \"i18next\" {\n  interface CustomTypeOptions {\n    // ns: [\"app\", \"common\", \"external\", \"lang\", \"settings\", \"shortcuts\"]\n    ns: typeof ns\n    resources: (typeof resources)[\"en\"]\n    defaultNS: typeof defaultNS\n    // if you see an error like: \"Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz\"\n    // set returnNull to false (and also in the i18next init options)\n    // returnNull: false;\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/App.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { tracker } from \"@follow/tracker\"\nimport { nextFrame } from \"@follow/utils\"\nimport { cn, getOS } from \"@follow/utils/utils\"\nimport { useEffect, useLayoutEffect, useRef } from \"react\"\nimport { Outlet } from \"react-router\"\n\nimport { useAppIsReady } from \"./atoms/app\"\nimport { useUISettingKey } from \"./atoms/settings/ui\"\nimport { applyAfterReadyCallbacks } from \"./initialize/queue\"\nimport { removeAppSkeleton } from \"./lib/app\"\nimport { ipcServices } from \"./lib/client\"\nimport { appLog } from \"./lib/log\"\nimport { Titlebar } from \"./modules/app/Titlebar\"\nimport { RootProviders } from \"./providers/root-providers\"\n\nfunction App() {\n  const windowsElectron = IN_ELECTRON && getOS() === \"Windows\"\n  return (\n    <RootProviders>\n      {IN_ELECTRON && (\n        <div\n          className={cn(\n            \"drag-region fixed inset-x-0 top-0 h-12 shrink-0\",\n            windowsElectron && \"pointer-events-none z-[9999]\",\n          )}\n          aria-hidden\n        >\n          {windowsElectron && <Titlebar />}\n        </div>\n      )}\n\n      <AppLayer />\n    </RootProviders>\n  )\n}\n\nconst AppLayer = () => {\n  const appIsReady = useAppIsReady()\n\n  const onceReady = useRef(false)\n  useLayoutEffect(() => {\n    if (appIsReady && !onceReady.current) {\n      onceReady.current = true\n      ipcServices?.app.readyToShowMainWindow()\n      nextFrame(removeAppSkeleton)\n    }\n  }, [appIsReady])\n\n  useEffect(() => {\n    const doneTime = Math.trunc(performance.now())\n    tracker.uiRenderInit(doneTime)\n    appLog(\"App is ready\", `${doneTime}ms`)\n    applyAfterReadyCallbacks()\n\n    if (isMobile()) {\n      const handler = (e: MouseEvent) => {\n        e.preventDefault()\n      }\n      document.addEventListener(\"contextmenu\", handler)\n\n      return () => {\n        document.removeEventListener(\"contextmenu\", handler)\n      }\n    }\n  }, [appIsReady])\n\n  return appIsReady ? <Outlet /> : <AppSkeleton />\n}\n\nconst AppSkeleton = () => {\n  const feedColWidth = useUISettingKey(\"feedColWidth\")\n  return (\n    <div className=\"flex size-full\">\n      <div\n        className=\"h-full shrink-0 bg-sidebar\"\n        style={{\n          width: `${feedColWidth}px`,\n        }}\n      />\n    </div>\n  )\n}\n\nexport { App as Component }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/ai-summary.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nimport { useGeneralSettingKey } from \"./settings/general\"\n\nexport const [, , useShowAISummaryOnce, , getShowAISummaryOnce, setShowAISummaryOnce] =\n  createAtomHooks(atom<boolean>(false))\n\nexport const toggleShowAISummaryOnce = () => setShowAISummaryOnce((prev) => !prev)\nexport const enableShowAISummaryOnce = () => setShowAISummaryOnce(true)\nexport const disableShowAISummaryOnce = () => setShowAISummaryOnce(false)\n\nexport const useShowAISummaryAuto = (settings?: boolean | null) => {\n  return useGeneralSettingKey(\"summary\") || !!settings\n}\n\nexport const useShowAISummary = (settings?: boolean | null) => {\n  const showAISummaryAuto = useShowAISummaryAuto(settings)\n  const showAISummaryOnce = useShowAISummaryOnce()\n  return showAISummaryAuto || showAISummaryOnce || !!settings\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/ai-translation.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nimport { useGeneralSettingKey } from \"./settings/general\"\n\n// NOTE: We have three levels of settings can enable AI translation or Summary:\n// 1. General setting, which is the global settings for all entries.\n// 2. Action setting, which is defined in an action and applied to specific entries.\n// 3. Toolbar control, which is a temporary setting for the current entry.\n//\n// When general setting or action setting is enabled, we should hide the toolbar control, which can save some space.\n//\n// Different from AI summary, AI translation also can show up in the entry list, which should only be controlled by the General setting or Action setting.\n\nexport const [, , useShowAITranslationOnce, , getShowAITranslationOnce, setShowAITranslationOnce] =\n  createAtomHooks(atom<boolean>(false))\n\nexport const toggleShowAITranslationOnce = () => setShowAITranslationOnce((prev) => !prev)\nexport const enableShowAITranslationOnce = () => setShowAITranslationOnce(true)\nexport const disableShowAITranslationOnce = () => setShowAITranslationOnce(false)\n\nexport const useShowAITranslationAuto = (settings?: boolean | null) => {\n  return useGeneralSettingKey(\"translation\") || !!settings\n}\n\nexport const useShowAITranslation = (settings?: boolean | null) => {\n  const showAITranslationAuto = useShowAITranslationAuto(settings)\n  const showAITranslationOnce = useShowAITranslationOnce()\n  return showAITranslationAuto || showAITranslationOnce\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/app.ts",
    "content": "import { WindowState } from \"@follow/shared/bridge\"\nimport { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport const [, , useAppIsReady, , appIsReady, setAppIsReady] = createAtomHooks(atom(false))\nexport const [, , useAppMessagingToken, , appMessagingToken, setAppMessagingToken] =\n  createAtomHooks(atom<string | null>(null))\n\nexport const [, , useAppSearchOpen, , , setAppSearchOpen] = createAtomHooks(atom(false))\n\n// For electron\nexport const [, , useWindowState, , windowState, setWindowState] = createAtomHooks(\n  atom<WindowState>(WindowState.NORMAL),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/context-menu.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { getOS, transformShortcut } from \"@follow/utils/utils\"\nimport { atom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { ipcServices } from \"~/lib/client\"\nimport { createAtomHooks } from \"~/lib/jotai\"\nimport type { ElectronMenuItem } from \"~/lib/native-menu\"\nimport { showElectronContextMenu } from \"~/lib/native-menu\"\n\n// Atom\n\ntype ContextMenuState =\n  | { open: false }\n  | {\n      open: true\n      position: { x: number; y: number }\n      menuItems: FollowMenuItem[]\n      // Just for abort callback\n      // Also can be optimized by using the `atomWithListeners`\n      abortController: AbortController\n    }\n\nexport const [contextMenuAtom, useContextMenuState, useContextMenuValue, useSetContextMenu] =\n  createAtomHooks(atom<ContextMenuState>({ open: false }))\n\nconst useShowWebContextMenu = () => {\n  const setContextMenu = useSetContextMenu()\n\n  const showWebContextMenu = useCallback(\n    async (menuItems: Array<FollowMenuItem>, e: MouseEvent | React.MouseEvent) => {\n      const abortController = new AbortController()\n      const resolvers = Promise.withResolvers<void>()\n      setContextMenu({\n        open: true,\n        position: { x: e.clientX, y: e.clientY },\n        menuItems,\n        abortController,\n      })\n\n      abortController.signal.addEventListener(\"abort\", () => {\n        resolvers.resolve()\n      })\n      return resolvers.promise\n    },\n    [setContextMenu],\n  )\n\n  return showWebContextMenu\n}\n\n// Menu\n\nexport type FollowMenuItem = MenuItemText | MenuItemSeparator\n\nexport type MenuItemInput = MenuItemText | MenuItemSeparator | NilValue\n\nfunction sortShortcutsString(shortcut: string) {\n  const order = [\"Shift\", \"Ctrl\", \"Meta\", \"Alt\"]\n  const nextShortcut = transformShortcut(shortcut)\n\n  const arr = nextShortcut.split(\"+\")\n\n  const sortedModifiers = arr\n    .filter((key) => order.includes(key))\n    .sort((a, b) => order.indexOf(a) - order.indexOf(b))\n\n  const otherKeys = arr.filter((key) => !order.includes(key))\n\n  return [...sortedModifiers, ...otherKeys].join(\"+\")\n}\n\nfunction filterNullableMenuItems(items: MenuItemInput[]): FollowMenuItem[] {\n  return items\n    .filter((item) => item !== null && item !== undefined && item !== false && item !== \"\")\n    .filter((item) => !item.hide)\n    .map((item) => {\n      if (item instanceof MenuItemSeparator) {\n        return MENU_ITEM_SEPARATOR\n      }\n\n      if (item.submenu && item.submenu.length > 0) {\n        return item.extend({\n          submenu: filterNullableMenuItems(item.submenu),\n        })\n      }\n\n      return item\n    })\n}\n\n// MenuItem must have at least one of label, role or type\nfunction transformMenuItemsForNative(nextItems: FollowMenuItem[]): ElectronMenuItem[] {\n  return nextItems.map((item) => {\n    if (item instanceof MenuItemSeparator) {\n      return { type: \"separator\" }\n    }\n    return {\n      type: typeof item.checked === \"boolean\" ? \"checkbox\" : undefined,\n      label: item.label,\n      click: item.click,\n      enabled:\n        (!item.disabled && item.click !== undefined) || (!!item.submenu && item.submenu.length > 0),\n      accelerator: item.shortcut?.replace(\"$mod\", \"CmdOrCtrl\"),\n      checked: typeof item.checked === \"boolean\" ? item.checked : undefined,\n      submenu:\n        item.submenu.length > 0\n          ? transformMenuItemsForNative(filterNullableMenuItems(item.submenu))\n          : undefined,\n    }\n  })\n}\n\nfunction withDebugMenu(menuItems: Array<FollowMenuItem>, e: MouseEvent | React.MouseEvent) {\n  if (import.meta.env.DEV && e) {\n    menuItems.push(\n      MENU_ITEM_SEPARATOR,\n      new MenuItemText({\n        label: \"Inspect Element\",\n        click: () => {\n          ipcServices?.debug.inspectElement({\n            x: e.pageX,\n            y: e.pageY,\n          })\n        },\n      }),\n    )\n  }\n  return menuItems\n}\n\nexport enum MenuItemType {\n  Separator,\n  Action,\n}\n\nexport const useShowContextMenu = () => {\n  const showWebContextMenu = useShowWebContextMenu()\n  const { withLoginGuard } = useRequireLogin()\n\n  const guardMenuItems = useCallback(\n    (items: FollowMenuItem[]): FollowMenuItem[] =>\n      items.map((item) => {\n        if (item instanceof MenuItemSeparator) {\n          return item\n        }\n\n        const nextSubmenu = item.submenu.length > 0 ? guardMenuItems(item.submenu) : item.submenu\n        let nextItem = nextSubmenu !== item.submenu ? item.extend({ submenu: nextSubmenu }) : item\n\n        if (item.requiresLogin) {\n          nextItem = nextItem.extend({\n            click: withLoginGuard(nextItem.click),\n          })\n        }\n\n        return nextItem\n      }),\n    [withLoginGuard],\n  )\n\n  const showContextMenu = useCallback(\n    async (inputMenu: Array<MenuItemInput>, e: MouseEvent | React.MouseEvent) => {\n      const menuItems = guardMenuItems(filterNullableMenuItems(inputMenu))\n      // only show native menu on macOS electron, because in other platform, the native ui is not good\n      if (IN_ELECTRON && getOS() === \"macOS\") {\n        withDebugMenu(menuItems, e)\n        await showElectronContextMenu(transformMenuItemsForNative(menuItems))\n        return\n      }\n      await showWebContextMenu(menuItems, e)\n    },\n    [guardMenuItems, showWebContextMenu],\n  )\n\n  return showContextMenu\n}\n\nexport class MenuItemSeparator {\n  readonly type = MenuItemType.Separator\n  constructor(public hide = false) {}\n  static default = new MenuItemSeparator()\n}\n\nconst noop = () => void 0\nexport type BaseMenuItemTextConfig = {\n  label: string\n  click?: () => void\n  /** only work in web app */\n  icon?: React.ReactNode\n  shortcut?: string\n  disabled?: boolean\n  checked?: boolean\n  supportMultipleSelection?: boolean\n  requiresLogin?: boolean\n}\n\nexport class BaseMenuItemText {\n  readonly type = MenuItemType.Action\n\n  private __sortedShortcut: string | null = null\n\n  constructor(private configs: BaseMenuItemTextConfig) {\n    this.__sortedShortcut = this.configs.shortcut\n      ? sortShortcutsString(this.configs.shortcut)\n      : null\n  }\n\n  public get label() {\n    return this.configs.label\n  }\n\n  public get click() {\n    return this.configs.click?.bind(this.configs) || noop\n  }\n\n  public get onClick() {\n    return this.click\n  }\n  public get icon() {\n    return this.configs.icon\n  }\n\n  public get shortcut() {\n    return this.__sortedShortcut\n  }\n\n  public get disabled() {\n    return this.configs.disabled || false\n  }\n\n  public get checked() {\n    return this.configs.checked\n  }\n\n  public get supportMultipleSelection() {\n    return this.configs.supportMultipleSelection\n  }\n\n  public get requiresLogin() {\n    return this.configs.requiresLogin || false\n  }\n}\n\nexport type MenuItemTextConfig = Prettify<\n  BaseMenuItemTextConfig & {\n    hide?: boolean\n    submenu?: MenuItemInput[]\n  }\n>\n\nexport class MenuItemText extends BaseMenuItemText {\n  protected __submenu: FollowMenuItem[]\n  constructor(protected config: MenuItemTextConfig) {\n    super(config)\n\n    this.__submenu = this.config.submenu ? filterNullableMenuItems(this.config.submenu) : []\n  }\n\n  public get submenu() {\n    return this.__submenu\n  }\n\n  public get hide() {\n    return this.config.hide || false\n  }\n\n  extend(config: Partial<MenuItemTextConfig>) {\n    return new MenuItemText({\n      ...this.config,\n      ...config,\n    })\n  }\n}\nexport const MENU_ITEM_SEPARATOR = MenuItemSeparator.default\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/corner-player.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { atomWithStorage } from \"jotai/utils\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\ntype CornerPlayerAtomValue = {\n  show: boolean\n  type?: \"audio\"\n  entryId?: string\n  url?: string\n}\n\nconst cornerPlayerInitialValue: CornerPlayerAtomValue = {\n  show: false,\n}\n\nexport const [\n  cornerPlayerAtom,\n  useCornerPlayerAtom,\n  useCornerPlayerAtomValue,\n  useSetCornerPlayerAtom,\n  getCornerPlayerAtomValue,\n  setCornerPlayerAtomValue,\n] = createAtomHooks<CornerPlayerAtomValue>(\n  atomWithStorage(getStorageNS(\"corner-player\"), cornerPlayerInitialValue, undefined, {\n    getOnInit: true,\n  }),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/debug-feature.ts",
    "content": "import { createAtomHooks } from \"@follow/utils/jotai\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { atomWithStorage } from \"jotai/utils\"\n\n// Shape: { __override?: boolean, [featureKey: string]: boolean }\nexport const [\n  ,\n  ,\n  useDebugFeatureValue,\n  useSetDebugFeatureValue,\n  getDebugFeatureValue,\n  setDebugFeatureValue,\n] = createAtomHooks(atomWithStorage<Record<string, unknown>>(getStorageNS(\"debug-feature\"), {}))\n\nexport { useDebugFeatureValue as useDebugFeatures }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/dom.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport const [, , useMainContainerElement, , getMainContainerElement, setMainContainerElement] =\n  createAtomHooks(atom<HTMLElement | null>(null))\n\nexport const [, , useRootContainerElement, , getRootContainerElement, setRootContainerElement] =\n  createAtomHooks(atom<HTMLElement | null>(null))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/lang.ts",
    "content": "import { atom } from \"jotai\"\n\nexport const langLoadingLockMapAtom = atom({} as Record<string, boolean>)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/network.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport enum NetworkStatus {\n  ONLINE,\n  OFFLINE,\n}\n\nexport const [, , useNetworkStatus, , getNetworkStatus, setNetworkStatus] = createAtomHooks(\n  atom(navigator.onLine ? NetworkStatus.ONLINE : NetworkStatus.OFFLINE),\n)\n\nexport const [, , useApiStatus, , getApiStatus, setApiStatus] = createAtomHooks(\n  atom(NetworkStatus.ONLINE),\n)\n\nexport const subscribeNetworkStatus = () => {\n  const handleOnline = () => setNetworkStatus(NetworkStatus.ONLINE)\n  const handleOffline = () => setNetworkStatus(NetworkStatus.OFFLINE)\n\n  window.addEventListener(\"online\", handleOnline)\n  window.addEventListener(\"offline\", handleOffline)\n\n  setNetworkStatus(navigator.onLine ? NetworkStatus.ONLINE : NetworkStatus.OFFLINE)\n\n  return () => {\n    window.removeEventListener(\"online\", handleOnline)\n    window.removeEventListener(\"offline\", handleOffline)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/player.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { parseSafeUrl } from \"@follow/utils/utils\"\nimport { noop } from \"foxact/noop\"\nimport { atomWithStorage, createJSONStorage } from \"jotai/utils\"\nimport type { SyncStorage } from \"jotai/vanilla/utils/atomWithStorage\"\n\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { createAtomHooks } from \"~/lib/jotai\"\n\ntype PlayerAtomValue = {\n  show: boolean\n  type?: \"audio\"\n  entryId?: string\n  src?: string\n  status?: \"playing\" | \"paused\" | \"loading\"\n  duration?: number\n  currentTime?: number\n  isMute?: boolean\n  volume?: number\n  playbackRate?: number\n  /** the listId from the route to indicate that the audio is triggered from a list */\n  listId?: string\n  isStream?: boolean\n}\n\nconst playerInitialValue: PlayerAtomValue = {\n  show: false,\n  volume: 0.8,\n  duration: 0,\n  playbackRate: 1,\n  isStream: false,\n}\n\nconst jsonStorage = createJSONStorage<PlayerAtomValue>()\nlet hydrationDone = false\nconst patchedLocalStorage: SyncStorage<PlayerAtomValue> = {\n  setItem: jsonStorage.setItem,\n  getItem: (key, initialValue) => {\n    const value = jsonStorage.getItem(key, initialValue)\n    if (value.isStream) {\n      return playerInitialValue\n    }\n    if (value && !hydrationDone) {\n      // patch status to `paused` when hydration\n      value.status = \"paused\"\n      value.isStream = false\n      hydrationDone = true\n    }\n    return value\n  },\n  removeItem: jsonStorage.removeItem,\n}\nexport const [\n  ,\n  ,\n  useAudioPlayerAtomValue,\n  useAudioSetPlayerAtom,\n  getAudioPlayerAtomValue,\n  setAudioPlayerAtomValue,\n  useAudioPlayerAtomSelector,\n] = createAtomHooks<PlayerAtomValue>(\n  atomWithStorage(getStorageNS(\"player\"), playerInitialValue, patchedLocalStorage, {\n    getOnInit: true,\n  }),\n)\n\nexport const AudioPlayer = {\n  audio: new Audio(),\n  currentTimeTimer: null as ReturnType<typeof setInterval> | null,\n\n  __currentActionId: 0,\n  get() {\n    return getAudioPlayerAtomValue()\n  },\n  mount(v: Omit<PlayerAtomValue, \"show\" | \"status\" | \"playedSeconds\" | \"duration\">) {\n    const curV = getAudioPlayerAtomValue()\n    if (!v.src || (curV.src === v.src && curV.status === \"playing\")) {\n      return\n    }\n\n    const routeParams = getRouteParams()\n\n    setAudioPlayerAtomValue({\n      ...curV,\n      ...v,\n      status: \"loading\",\n      show: true,\n      listId: routeParams.listId,\n      isStream: false,\n    })\n    const currentUrl = parseSafeUrl(this.audio.src)?.toString() ?? this.audio.src\n    const newUrl = parseSafeUrl(v.src)?.toString() ?? v.src\n\n    // It seems that audio load from local file has some limitations, i think reset the audio should be fine here\n    if (currentUrl !== newUrl || newUrl.startsWith(\"file://\")) {\n      this.audio.src = v.src\n      this.audio.currentTime = v.currentTime ?? curV.currentTime ?? 0\n    }\n    this.audio.volume = curV.volume ?? 0.8\n    this.audio.playbackRate = curV.playbackRate ?? 1\n\n    this.currentTimeTimer && clearInterval(this.currentTimeTimer)\n    this.currentTimeTimer = setInterval(() => {\n      setAudioPlayerAtomValue({\n        ...getAudioPlayerAtomValue(),\n        currentTime: this.audio.currentTime,\n      })\n    }, 1000)\n\n    this.audio.onloadedmetadata = () => {\n      if (Number.isNaN(this.audio.duration) || this.audio.duration === Infinity) {\n        this.audio.currentTime = 0\n      }\n    }\n\n    const currentActionId = this.__currentActionId\n    return this.audio\n      .play()\n      .then(() => {\n        if (currentActionId !== this.__currentActionId) return\n        setAudioPlayerAtomValue({\n          ...getAudioPlayerAtomValue(),\n          status: \"playing\",\n          duration: this.audio.duration === Infinity ? 0 : this.audio.duration,\n        })\n      })\n      .catch(noop)\n  },\n  teardown() {\n    this.currentTimeTimer && clearInterval(this.currentTimeTimer)\n    this.audio.pause()\n  },\n  play() {\n    ++this.__currentActionId\n    const curV = getAudioPlayerAtomValue()\n\n    if (curV.isStream) {\n      void this.audio.play().catch(noop)\n      setAudioPlayerAtomValue({\n        ...curV,\n        status: \"playing\",\n      })\n      return\n    }\n\n    this.mount(curV)\n  },\n  pause() {\n    ++this.__currentActionId\n    const curV = getAudioPlayerAtomValue()\n    if (curV.status === \"paused\") {\n      return\n    }\n\n    setAudioPlayerAtomValue({\n      ...curV,\n      status: \"paused\",\n      currentTime: this.audio.currentTime,\n    })\n    this.teardown()\n    return\n  },\n  togglePlayAndPause() {\n    const curV = getAudioPlayerAtomValue()\n    if (curV.isStream) {\n      if (curV.status === \"playing\") {\n        return this.pause()\n      }\n      if (curV.status === \"paused\") {\n        return this.play()\n      }\n      return this.pause()\n    }\n    if (curV.status === \"playing\") {\n      return this.pause()\n    } else if (curV.status === \"paused\") {\n      return this.mount(curV)\n    } else {\n      return this.pause()\n    }\n  },\n  close() {\n    setAudioPlayerAtomValue({\n      ...getAudioPlayerAtomValue(),\n      show: false,\n      status: \"paused\",\n      isStream: false,\n    })\n\n    this.teardown()\n  },\n  seek(time: number) {\n    if (getAudioPlayerAtomValue().isStream) {\n      return\n    }\n    this.audio.currentTime = time\n    setAudioPlayerAtomValue({\n      ...getAudioPlayerAtomValue(),\n      currentTime: time,\n    })\n  },\n  setPlaybackRate(speed: number) {\n    if (getAudioPlayerAtomValue().isStream) {\n      return\n    }\n    this.audio.playbackRate = speed\n    setAudioPlayerAtomValue({\n      ...getAudioPlayerAtomValue(),\n      playbackRate: speed,\n    })\n  },\n  back(time: number) {\n    if (getAudioPlayerAtomValue().isStream) {\n      return\n    }\n    this.seek(Math.max(this.audio.currentTime - time, 0))\n  },\n  forward(time: number) {\n    if (getAudioPlayerAtomValue().isStream) {\n      return\n    }\n    this.seek(Math.min(this.audio.currentTime + time, this.audio.duration))\n  },\n  toggleMute() {\n    this.audio.muted = !this.audio.muted\n    setAudioPlayerAtomValue({\n      ...getAudioPlayerAtomValue(),\n      isMute: this.audio.muted,\n    })\n  },\n  setVolume(volume: number) {\n    this.audio.volume = volume\n    setAudioPlayerAtomValue({\n      ...getAudioPlayerAtomValue(),\n      volume,\n    })\n  },\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/popover.ts",
    "content": "import type { PopoverContentProps } from \"@radix-ui/react-popover\"\nimport { atom } from \"jotai\"\nimport type { ReactNode } from \"react\"\n\nimport { createAtomHooks, jotaiStore } from \"~/lib/jotai\"\n\n// Atom\n\nexport interface PopoverProps extends Omit<PopoverContentProps, \"children\"> {\n  /** Custom z-index for popover */\n  zIndex?: number\n  /** Whether the popover should close when clicked outside */\n  modal?: boolean\n}\n\ntype PopoverState =\n  | { open: false }\n  | {\n      open: true\n      position: { x: number; y: number }\n      content: ReactNode\n      props?: PopoverProps\n      // Just for abort callback\n      abortController: AbortController\n    }\n\nexport const [popoverAtom, usePopoverState, usePopoverValue, useSetPopover] = createAtomHooks(\n  atom<PopoverState>({ open: false }),\n)\n\nexport const showPopover = (\n  mouseXY: { x: number; y: number },\n  element: ReactNode,\n  props?: PopoverProps,\n) => {\n  jotaiStore.set(popoverAtom, {\n    open: true,\n    position: mouseXY,\n    content: element,\n    props,\n    abortController: new AbortController(),\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/preview.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport const [, , , , previewBackPath, setPreviewBackPath] = createAtomHooks(atom<string>())\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/readability.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nconst mergeObjectSetter =\n  <T>(setter: (prev: T) => void, getter: () => T) =>\n  (value: Partial<T>) =>\n    setter({ ...getter(), ...value })\n\nexport enum ReadabilityStatus {\n  INITIAL = 1,\n  WAITING = 2,\n  SUCCESS = 3,\n  FAILURE = 4,\n}\nexport const [\n  ,\n  ,\n  useReadabilityStatus,\n  ,\n  getReadabilityStatus,\n  __setReadabilityStatus,\n  useReadabilityStatusSelector,\n] = createAtomHooks(atom<Record<string, ReadabilityStatus>>({}))\nexport const setReadabilityStatus = mergeObjectSetter(__setReadabilityStatus, getReadabilityStatus)\n\nexport const useEntryIsInReadability = (entryId?: string) =>\n  useReadabilityStatusSelector(\n    (map) => (entryId ? (map[entryId] ? isInReadability(map[entryId]) : false) : false),\n    [entryId],\n  )\n\nexport const useEntryIsInReadabilitySuccess = (entryId?: string) =>\n  useReadabilityStatusSelector(\n    (map) => (entryId ? map[entryId] === ReadabilityStatus.SUCCESS : false),\n    [entryId],\n  )\n\nexport const useEntryInReadabilityStatus = (entryId?: string) =>\n  useReadabilityStatusSelector(\n    (map) => (entryId ? map[entryId] || ReadabilityStatus.INITIAL : ReadabilityStatus.INITIAL),\n    [entryId],\n  )\n\nexport const isInReadability = (status: ReadabilityStatus) =>\n  status !== ReadabilityStatus.INITIAL && !!status\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/server-configs.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport type { ExtractResponseData, GetStatusConfigsResponse } from \"@follow-app/client-sdk\"\nimport PKG from \"@pkg\"\nimport { atomWithStorage } from \"jotai/utils\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks(\n  atomWithStorage<Nullable<ExtractResponseData<GetStatusConfigsResponse>>>(\n    getStorageNS(\"server-configs\"),\n    null,\n    undefined,\n    {\n      getOnInit: true,\n    },\n  ),\n)\n\nexport type ServerConfigs = ExtractResponseData<GetStatusConfigsResponse>\nexport type PaymentPlan = ServerConfigs[\"PAYMENT_PLAN_LIST\"][number]\nexport type PaymentFeature = PaymentPlan[\"limit\"]\n\nexport const useIsInMASReview = () => {\n  const serverConfigs = useServerConfigs()\n  return (\n    typeof process !== \"undefined\" &&\n    process.mas &&\n    serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version\n  )\n}\n\nexport const getIsInMASReview = () => {\n  const serverConfigs = getServerConfigs()\n  return (\n    typeof process !== \"undefined\" &&\n    process.mas &&\n    serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version\n  )\n}\n\nexport const useIsPaymentEnabled = () => {\n  const serverConfigs = useServerConfigs()\n  const isInMASReview = useIsInMASReview()\n  return !isInMASReview && serverConfigs?.PAYMENT_ENABLED\n}\n\nexport const getIsPaymentEnabled = () => {\n  const serverConfigs = getServerConfigs()\n  const isInMASReview = getIsInMASReview()\n  return !isInMASReview && serverConfigs?.PAYMENT_ENABLED\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/settings/ai.ts",
    "content": "import { createSettingAtom } from \"@follow/atoms/helper/setting.js\"\nimport { defaultAISettings } from \"@follow/shared/settings/defaults\"\nimport type {\n  AISettings,\n  AIShortcut,\n  AIShortcutTarget,\n  MCPService,\n} from \"@follow/shared/settings/interface\"\nimport { DEFAULT_SHORTCUT_TARGETS } from \"@follow/shared/settings/interface\"\nimport { jotaiStore } from \"@follow/utils\"\nimport type { ExtractResponseData, GetStatusConfigsResponse } from \"@follow-app/client-sdk\"\nimport { clamp } from \"es-toolkit\"\nimport { atom, useAtomValue } from \"jotai\"\n\nimport { getFeature } from \"~/hooks/biz/useFeature\"\n\nexport interface WebAISettings extends AISettings {\n  panelStyle: AIChatPanelStyle\n  showSplineButton: boolean\n}\n\ntype ServerShortcutConfig = ExtractResponseData<GetStatusConfigsResponse>[\"AI_SHORTCUTS\"][number]\n\nconst FALLBACK_SHORTCUT_ICON = \"i-mgc-hotkey-cute-re\"\nconst VALID_SHORTCUT_TARGETS = new Set<AIShortcutTarget>(DEFAULT_SHORTCUT_TARGETS)\n\nconst isValidShortcutTarget = (target: string): target is AIShortcutTarget =>\n  VALID_SHORTCUT_TARGETS.has(target as AIShortcutTarget)\n\nconst sanitizeShortcutTargets = (targets?: readonly string[]): AIShortcutTarget[] => {\n  if (!targets || targets.length === 0) {\n    return [...DEFAULT_SHORTCUT_TARGETS]\n  }\n\n  const filtered = targets.filter(isValidShortcutTarget) as AIShortcutTarget[]\n  return filtered.length > 0 ? [...filtered] : [...DEFAULT_SHORTCUT_TARGETS]\n}\n\nconst normalizeShortcut = (shortcut: AIShortcut): AIShortcut => {\n  return {\n    ...shortcut,\n    displayTargets: sanitizeShortcutTargets(shortcut.displayTargets),\n    enabled: typeof shortcut.enabled === \"boolean\" ? shortcut.enabled : true,\n  }\n}\n\nconst normalizeShortcuts = (shortcuts: readonly AIShortcut[] | undefined): AIShortcut[] =>\n  (shortcuts ?? []).map((shortcut) => normalizeShortcut({ ...shortcut }))\n\nconst mergeWithServerShortcuts = (\n  localShortcuts: readonly AIShortcut[],\n  serverShortcuts: readonly ServerShortcutConfig[],\n): AIShortcut[] => {\n  const normalizedLocal = normalizeShortcuts(localShortcuts)\n  if (serverShortcuts.length === 0) {\n    return normalizedLocal\n  }\n\n  const serverShortcutMap = new Map<string, ServerShortcutConfig>()\n  serverShortcuts.forEach((shortcut) => {\n    serverShortcutMap.set(shortcut.id, shortcut)\n  })\n\n  const seenServerShortcutIds = new Set<string>()\n  const mergedShortcuts: AIShortcut[] = []\n\n  normalizedLocal.forEach((shortcut) => {\n    const serverShortcut = serverShortcutMap.get(shortcut.id)\n    if (!serverShortcut) {\n      mergedShortcuts.push(shortcut)\n      return\n    }\n\n    seenServerShortcutIds.add(serverShortcut.id)\n    const shouldClearPrompt = shortcut.prompt === serverShortcut.defaultPrompt\n\n    mergedShortcuts.push({\n      ...shortcut,\n      name: shortcut.name || serverShortcut.name,\n      prompt: shouldClearPrompt ? \"\" : shortcut.prompt,\n      defaultPrompt: serverShortcut.defaultPrompt,\n      displayTargets: sanitizeShortcutTargets(\n        shortcut.displayTargets || serverShortcut.displayTargets,\n      ),\n    })\n  })\n\n  serverShortcuts.forEach((serverShortcut) => {\n    if (seenServerShortcutIds.has(serverShortcut.id)) return\n\n    mergedShortcuts.push({\n      id: serverShortcut.id,\n      name: serverShortcut.name,\n      prompt: \"\",\n      defaultPrompt: serverShortcut.defaultPrompt,\n      enabled: true,\n      icon: FALLBACK_SHORTCUT_ICON,\n      displayTargets: sanitizeShortcutTargets(serverShortcut.displayTargets),\n    })\n  })\n\n  return mergedShortcuts\n}\n\nexport const getShortcutEffectivePrompt = (shortcut: AIShortcut): string => {\n  return shortcut.prompt || shortcut.defaultPrompt || \"\"\n}\n\nexport const isServerShortcut = (shortcut: AIShortcut) => !!shortcut.defaultPrompt\n\nexport const createDefaultSettings = (): WebAISettings => ({\n  ...defaultAISettings,\n  shortcuts: normalizeShortcuts(defaultAISettings.shortcuts),\n  panelStyle: AIChatPanelStyle.Floating,\n  showSplineButton: true,\n})\n\nexport const {\n  useSettingKey: useAISettingKey,\n  useSettingSelector: useAISettingSelector,\n  setSetting: setAISetting,\n  clearSettings: clearAISettings,\n  initializeDefaultSettings,\n  getSettings: getAISettings,\n  useSettingValue: useAISettingValue,\n  settingAtom: __aiSettingAtom,\n} = createSettingAtom(\"ai\", createDefaultSettings)\nexport const aiServerSyncWhiteListKeys = []\n\nexport const syncServerShortcuts = (\n  serverShortcuts: readonly ServerShortcutConfig[] | null | undefined,\n) => {\n  const storedShortcuts = getAISettings().shortcuts ?? []\n  const serverShortcutList = Array.isArray(serverShortcuts) ? serverShortcuts : []\n  const mergedShortcuts = mergeWithServerShortcuts(storedShortcuts, serverShortcutList)\n\n  setAISetting(\"shortcuts\", mergedShortcuts)\n}\n\n////////// AI Panel Style\nexport enum AIChatPanelStyle {\n  Fixed = \"fixed\",\n  Floating = \"floating\",\n}\n\nexport const useAIChatPanelStyle = () => useAISettingKey(\"panelStyle\")\nexport const setAIChatPanelStyle = (style: AIChatPanelStyle) => {\n  setAISetting(\"panelStyle\", style)\n}\nexport const getAIChatPanelStyle = () => getAISettings().panelStyle\n\n// Floating panel state atoms\ninterface FloatingPanelState {\n  width: number\n  height: number\n  x: number\n  y: number\n}\n\nconst DEFAULT_FLOATING_PANEL_WIDTH = 500\nconst DEFAULT_FLOATING_PANEL_HEIGHT = clamp(window.innerHeight * 0.9, 600, 1000)\nconst DEFAULT_FLOATING_PANEL_X = window.innerWidth - DEFAULT_FLOATING_PANEL_WIDTH - 20\nconst DEFAULT_FLOATING_PANEL_Y = window.innerHeight - DEFAULT_FLOATING_PANEL_HEIGHT - 20\n\nconst defaultFloatingPanelState: FloatingPanelState = {\n  width: DEFAULT_FLOATING_PANEL_WIDTH,\n  height: DEFAULT_FLOATING_PANEL_HEIGHT,\n  x: DEFAULT_FLOATING_PANEL_X,\n  y: DEFAULT_FLOATING_PANEL_Y,\n}\n\nconst floatingPanelStateAtom = atom<FloatingPanelState>(defaultFloatingPanelState)\n\nexport const useFloatingPanelState = () => useAtomValue(floatingPanelStateAtom)\nexport const setFloatingPanelState = (state: Partial<FloatingPanelState>) => {\n  const currentState = jotaiStore.get(floatingPanelStateAtom)\n  jotaiStore.set(floatingPanelStateAtom, { ...currentState, ...state })\n}\nexport const getFloatingPanelState = () => jotaiStore.get(floatingPanelStateAtom)\n\n////////// AI Panel Visibility\n\nconst aiPanelVisibilityAtom = atom<boolean>(false)\nexport const useAIPanelVisibility = () => useAtomValue(aiPanelVisibilityAtom)\nexport const setAIPanelVisibility = (visibility: boolean) => {\n  const aiEnabled = getFeature(\"ai\")\n  if (aiEnabled) {\n    jotaiStore.set(aiPanelVisibilityAtom, visibility)\n  }\n}\nexport const getAIPanelVisibility = () => jotaiStore.get(aiPanelVisibilityAtom)\n\n////////// MCP Services\nexport const useMCPEnabled = () => useAISettingKey(\"mcpEnabled\")\nexport const setMCPEnabled = (enabled: boolean) => {\n  setAISetting(\"mcpEnabled\", enabled)\n}\n\nexport const useMCPServices = () => useAISettingKey(\"mcpServices\")\nexport const addMCPService = (service: Omit<MCPService, \"id\">) => {\n  const services = getAISettings().mcpServices\n  const newService = {\n    ...service,\n    id: Date.now().toString(),\n  }\n  setAISetting(\"mcpServices\", [...services, newService])\n  return newService.id\n}\n\nexport const updateMCPService = (id: string, updates: Partial<MCPService>) => {\n  const services = getAISettings().mcpServices\n  const updatedServices = services.map((service) =>\n    service.id === id ? { ...service, ...updates } : service,\n  )\n  setAISetting(\"mcpServices\", updatedServices)\n}\n\nexport const removeMCPService = (id: string) => {\n  const services = getAISettings().mcpServices\n  const filteredServices = services.filter((service) => service.id !== id)\n  setAISetting(\"mcpServices\", filteredServices)\n}\n\n//// Enhance Init Ai Settings\nexport const initializeDefaultAISettings = () => {\n  initializeDefaultSettings()\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/settings/general.ts",
    "content": "import { createSettingAtom } from \"@follow/atoms/helper/setting.js\"\nimport { defaultGeneralSettings } from \"@follow/shared/settings/defaults\"\nimport { hookEnhancedSettings as baseHookEnhancedSettings } from \"@follow/shared/settings/hook\"\nimport type { GeneralSettings } from \"@follow/shared/settings/interface\"\nimport type { SupportedLanguages } from \"@follow-app/client-sdk\"\n\nimport { jotaiStore } from \"~/lib/jotai\"\nimport { getDefaultLanguage } from \"~/lib/language\"\n\nexport const DEFAULT_ACTION_LANGUAGE = \"default\"\n\nexport const createDefaultGeneralSettings = (): GeneralSettings => ({\n  ...defaultGeneralSettings,\n  language: getDefaultLanguage(),\n})\n\nconst {\n  useSettingKey: useGeneralSettingKeyInternal,\n  useSettingSelector: useGeneralSettingSelectorInternal,\n  useSettingKeys: useGeneralSettingKeysInternal,\n  setSetting: setGeneralSetting,\n  clearSettings: clearGeneralSettings,\n  initializeDefaultSettings: initializeDefaultGeneralSettings,\n  getSettings: getGeneralSettingsInternal,\n  useSettingValue: useGeneralSettingValueInternal,\n\n  settingAtom: __generalSettingAtom,\n} = createSettingAtom(\"general\", createDefaultGeneralSettings)\nexport const hookEnhancedSettings = <\n  T1 extends (key: any) => any,\n  T2 extends (selector: (s: any) => any) => any,\n  T3 extends (keys: any) => any,\n  T4 extends () => any,\n  T5 extends () => any,\n>(\n  useSettingKey: T1,\n  useSettingSelector: T2,\n  useSettingKeys: T3,\n  getSettings: T4,\n  useSettingValue: T5,\n\n  enhancedSettingKeys: Set<string>,\n  defaultSettings: Record<string, any>,\n): [T1, T2, T3, T4, T5] => {\n  return baseHookEnhancedSettings(\n    useSettingKey,\n    useSettingSelector,\n    useSettingKeys,\n    getSettings,\n    useSettingValue,\n\n    enhancedSettingKeys,\n    defaultSettings,\n    {\n      useEnhancedEnabled: () => useGeneralSettingKeyInternal(\"enhancedSettings\"),\n      getEnhancedEnabled: () => jotaiStore.get(__generalSettingAtom).enhancedSettings,\n    },\n  )\n}\n\nexport function useActionLanguage() {\n  const actionLanguage = useGeneralSettingSelectorInternal((s) => s.actionLanguage)\n  const language = useGeneralSettingSelectorInternal((s) => s.language)\n  return (\n    actionLanguage === DEFAULT_ACTION_LANGUAGE ? language : actionLanguage\n  ) as SupportedLanguages\n}\n\nexport function getActionLanguage() {\n  const { actionLanguage, language } = getGeneralSettingsInternal()\n  return (\n    actionLanguage === DEFAULT_ACTION_LANGUAGE ? language : actionLanguage\n  ) as SupportedLanguages\n}\n\nexport function useHideAllReadSubscriptions() {\n  const hideAllReadSubscriptions = useGeneralSettingKey(\"hideAllReadSubscriptions\")\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  return hideAllReadSubscriptions && unreadOnly\n}\n\nexport const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [\n  \"appLaunchOnStartup\",\n  \"sendAnonymousData\",\n  \"language\",\n  \"voice\",\n]\n\nexport const enhancedGeneralSettingKeys = new Set<keyof GeneralSettings>([\n  \"groupByDate\",\n  \"autoExpandLongSocialMedia\",\n])\n\nconst [\n  useGeneralSettingKey,\n  useGeneralSettingSelector,\n  useGeneralSettingKeys,\n  getGeneralSettings,\n  useGeneralSettingValue,\n] = hookEnhancedSettings(\n  useGeneralSettingKeyInternal,\n  useGeneralSettingSelectorInternal,\n  useGeneralSettingKeysInternal,\n  getGeneralSettingsInternal,\n  useGeneralSettingValueInternal,\n\n  enhancedGeneralSettingKeys,\n  defaultGeneralSettings,\n)\nexport {\n  __generalSettingAtom,\n  clearGeneralSettings,\n  getGeneralSettings,\n  initializeDefaultGeneralSettings,\n  setGeneralSetting,\n  useGeneralSettingKey,\n  useGeneralSettingKeys,\n  useGeneralSettingSelector,\n  useGeneralSettingValue,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/settings/integration.ts",
    "content": "import { createSettingAtom } from \"@follow/atoms/helper/setting.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { defaultIntegrationSettings } from \"@follow/shared/settings/defaults\"\nimport type { IntegrationSettings } from \"@follow/shared/settings/interface\"\n\nexport const createDefaultSettings = (): IntegrationSettings => {\n  const defaultSettings = { ...defaultIntegrationSettings }\n\n  // Only include useBrowserFetch setting in Electron environment\n  if (!IN_ELECTRON) {\n    // Remove useBrowserFetch setting for non-Electron environments\n    const { useBrowserFetch, ...settingsWithoutBrowserFetch } = defaultSettings\n    return settingsWithoutBrowserFetch as IntegrationSettings\n  }\n\n  // Check if we have stored settings that might need migration\n  const storedSettings = (() => {\n    try {\n      const stored = localStorage.getItem(\"follow:integration\")\n      return stored ? JSON.parse(stored) : null\n    } catch {\n      return null\n    }\n  })()\n\n  if (storedSettings?.customIntegration) {\n    return {\n      ...defaultSettings,\n      ...storedSettings,\n    }\n  }\n\n  return defaultSettings\n}\n\nexport const {\n  useSettingKey: useIntegrationSettingKey,\n  useSettingSelector: useIntegrationSettingSelector,\n  setSetting: setIntegrationSetting,\n  clearSettings: clearIntegrationSettings,\n  initializeDefaultSettings: initializeDefaultIntegrationSettings,\n  getSettings: getIntegrationSettings,\n  useSettingValue: useIntegrationSettingValue,\n} = createSettingAtom(\"integration\", createDefaultSettings)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/settings/ui.ts",
    "content": "import { createSettingAtom } from \"@follow/atoms/helper/setting.js\"\nimport { defaultUISettings } from \"@follow/shared/settings/defaults\"\nimport type { UISettings } from \"@follow/shared/settings/interface\"\n\nimport { getDefaultLanguage } from \"~/lib/language\"\nimport { DEFAULT_ACTION_ORDER } from \"~/modules/customize-toolbar/constant\"\n\nimport { hookEnhancedSettings } from \"./general\"\n\nexport const createDefaultUISettings = (): UISettings => ({\n  ...defaultUISettings,\n  // Action Order\n  toolbarOrder: DEFAULT_ACTION_ORDER,\n  // Discover\n  discoverLanguage: getDefaultLanguage().startsWith(\"zh\") ? \"all\" : \"eng\",\n  accentColor: \"orange\",\n})\n\nconst {\n  useSettingKey: useUISettingKeyInternal,\n  useSettingSelector: useUISettingSelectorInternal,\n  useSettingKeys: useUISettingKeysInternal,\n  setSetting: setUISetting,\n  clearSettings: clearUISettings,\n  initializeDefaultSettings: initializeDefaultUISettings,\n  getSettings: getUISettingsInternal,\n  useSettingValue: useUISettingValueInternal,\n  settingAtom: __uiSettingAtom,\n} = createSettingAtom(\"ui\", createDefaultUISettings)\n\nexport const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [\n  \"uiFontFamily\",\n  \"readerFontFamily\",\n  \"opaqueSidebar\",\n  \"accentColor\",\n  // \"customCSS\",\n]\n\nexport const enhancedUISettingKeys = new Set<keyof UISettings>([\n  \"hideExtraBadge\",\n  \"codeHighlightThemeLight\",\n  \"codeHighlightThemeDark\",\n  \"dateFormat\",\n  \"readerRenderInlineStyle\",\n  \"modalOverlay\",\n  \"reduceMotion\",\n  \"usePointerCursor\",\n  \"opaqueSidebar\",\n])\n\nconst [useUISettingKey, useUISettingSelector, useUISettingKeys, getUISettings, useUISettingValue] =\n  hookEnhancedSettings(\n    useUISettingKeyInternal,\n    useUISettingSelectorInternal,\n    useUISettingKeysInternal,\n    getUISettingsInternal,\n    useUISettingValueInternal,\n\n    enhancedUISettingKeys,\n    defaultUISettings,\n  )\nexport {\n  __uiSettingAtom,\n  clearUISettings,\n  getUISettings,\n  initializeDefaultUISettings,\n  setUISetting,\n  useUISettingKey,\n  useUISettingKeys,\n  useUISettingSelector,\n  useUISettingValue,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/sidebar.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nconst [\n  ,\n  ,\n  internal_useSubscriptionColumnShow,\n  ,\n  internal_getSubscriptionShow,\n  setTimelineColumnShow,\n] = createAtomHooks(atom(true))\n\nexport const useSubscriptionColumnShow = internal_useSubscriptionColumnShow\n\nexport const getSubscriptionColumnShow = internal_getSubscriptionShow\n\nexport { setTimelineColumnShow }\n\nexport const [\n  ,\n  ,\n  useSubscriptionColumnTempShow,\n  ,\n  getSubscriptionColumnTempShow,\n  setSubscriptionColumnTempShow,\n] = createAtomHooks(atom(false))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/source-content.tsx",
    "content": "import { atom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { createAtomHooks } from \"~/lib/jotai\"\nimport { SourceContentView } from \"~/modules/entry-content/components/SourceContentView\"\n\nexport const [, , useShowSourceContent, , getShowSourceContent, setShowSourceContent] =\n  createAtomHooks(atom<boolean>(false))\n\nexport const toggleShowSourceContent = () => setShowSourceContent(!getShowSourceContent())\nexport const enableShowSourceContent = () => setShowSourceContent(true)\nexport const resetShowSourceContent = () => setShowSourceContent(false)\n\nexport const useSourceContentModal = () => {\n  const { present } = useModalStack()\n\n  return useCallback(\n    ({ title, src }: { title?: string; src: string }) => {\n      present({\n        id: src,\n        title,\n        content: () => <SourceContentView src={src} />,\n        resizeable: true,\n        clickOutsideToDismiss: true,\n        max: true,\n      })\n    },\n    [present],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/updater.ts",
    "content": "import type { StoreDistribution } from \"@follow-app/client-sdk\"\nimport { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport type UpdaterStatus = \"ready\"\ntype UpdaterStatusKind = \"app\" | \"renderer\" | \"pwa\" | \"distribution\"\n\ntype BaseUpdaterStatus<T extends UpdaterStatusKind> = {\n  type: T\n  status: UpdaterStatus\n  finishUpdate?: () => void\n}\n\ntype AppUpdaterStatus = BaseUpdaterStatus<\"app\">\n\ntype RendererUpdaterStatus = BaseUpdaterStatus<\"renderer\">\n\ntype PwaUpdaterStatus = BaseUpdaterStatus<\"pwa\">\n\ntype DistributionUpdaterStatus = BaseUpdaterStatus<\"distribution\"> & {\n  distribution: StoreDistribution\n  storeUrl: string\n  storeVersion: string | null\n  currentVersion: string | null\n}\n\nexport type UpdaterStatusAtom =\n  | AppUpdaterStatus\n  | RendererUpdaterStatus\n  | PwaUpdaterStatus\n  | DistributionUpdaterStatus\n  | null\nexport const [, , useUpdaterStatus, , getUpdaterStatus, setUpdaterStatus] = createAtomHooks(\n  atom(null as UpdaterStatusAtom),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/atoms/user.ts",
    "content": "import { atom } from \"jotai\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] =\n  createAtomHooks(atom<boolean>(false))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/AppErrorBoundary.tsx",
    "content": "import type { FC, PropsWithChildren } from \"react\"\nimport { createElement, Suspense, useCallback } from \"react\"\n\nimport { getErrorFallback } from \"../errors\"\nimport type { ErrorComponentType } from \"../errors/enum\"\nimport PageErrorFallback from \"../errors/PageError\"\nimport type { FallbackRender } from \"./ErrorBoundary\"\nimport { ErrorBoundary } from \"./ErrorBoundary\"\n\nexport interface AppErrorBoundaryProps extends PropsWithChildren {\n  height?: number | string\n  errorType: ErrorComponentType\n}\n\nexport const AppErrorBoundary: FC<\n  Omit<AppErrorBoundaryProps, \"errorType\"> & {\n    errorType: ErrorComponentType[] | ErrorComponentType\n  }\n> = ({ errorType, children }) => {\n  if (Array.isArray(errorType)) {\n    return (\n      <>\n        {errorType.reduceRight(\n          (acc, type) => (\n            <AppErrorBoundaryItem key={type} errorType={type}>\n              {acc}\n            </AppErrorBoundaryItem>\n          ),\n          children,\n        )}\n      </>\n    )\n  }\n\n  return <AppErrorBoundaryItem errorType={errorType}>{children}</AppErrorBoundaryItem>\n}\n\ntype ErrorFallbackProps = Parameters<FallbackRender>[\"0\"]\nexport type AppErrorFallbackProps = ErrorFallbackProps & {}\nconst AppErrorBoundaryItem: FC<AppErrorBoundaryProps> = ({ errorType, children }) => {\n  const fallbackRender = useCallback(\n    (fallbackProps: ErrorFallbackProps) => {\n      const errorElement = getErrorFallback(errorType)\n      if (!errorElement) {\n        return <PageErrorFallback {...fallbackProps} />\n      }\n      return <Suspense>{createElement(getErrorFallback(errorType), fallbackProps)}</Suspense>\n    },\n    [errorType],\n  )\n\n  return <ErrorBoundary fallback={fallbackRender}>{children}</ErrorBoundary>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ErrorBoundary.tsx",
    "content": "import { tracker } from \"@follow/tracker\"\nimport type { PropsWithChildren, ReactNode } from \"react\"\nimport type { FallbackProps } from \"react-error-boundary\"\nimport { ErrorBoundary as ReactErrorBoundary } from \"react-error-boundary\"\n\nexport type ErrorFallbackProps = Omit<FallbackProps, \"resetErrorBoundary\"> &\n  FallbackProps & {\n    resetError: () => void\n  }\nexport type FallbackRender = (props: ErrorFallbackProps) => ReactNode\n\ninterface ErrorBoundaryProps extends PropsWithChildren {\n  fallback?: FallbackRender\n  fallbackRender?: FallbackRender\n  handled?: boolean\n  beforeCapture?: (scope: unknown, error: unknown) => unknown\n}\n\nconst emptyFallback: FallbackRender = () => null\n\nexport const ErrorBoundary = ({\n  children,\n  fallback,\n  fallbackRender,\n  beforeCapture,\n}: ErrorBoundaryProps) => {\n  const renderFallback = fallbackRender ?? fallback ?? emptyFallback\n\n  const handleError = (rawError: unknown, info: { componentStack?: string | null }) => {\n    const error = rawError instanceof Error ? rawError : new Error(String(rawError))\n\n    if (beforeCapture?.(info, error) === false) {\n      return\n    }\n\n    void tracker.manager.captureException(error, {\n      source: \"desktop_error_boundary\",\n      component_stack: info.componentStack,\n    })\n  }\n\n  return (\n    <ReactErrorBoundary\n      onError={handleError}\n      fallbackRender={(props) =>\n        renderFallback({\n          ...props,\n          resetError: props.resetErrorBoundary,\n        })\n      }\n    >\n      {children}\n    </ReactErrorBoundary>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ErrorElement.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { tracker } from \"@follow/tracker\"\nimport { useEffect, useRef } from \"react\"\nimport { isRouteErrorResponse, useNavigate, useRouteError } from \"react-router\"\nimport { toast } from \"sonner\"\n\nimport { removeAppSkeleton } from \"~/lib/app\"\nimport { attachOpenInEditor } from \"~/lib/dev\"\nimport { getNewIssueUrl } from \"~/lib/issues\"\nimport { clearLocalPersistStoreData } from \"~/store/utils/clear\"\n\nimport { PoweredByFooter } from \"./PoweredByFooter\"\n\nexport function ErrorElement() {\n  const error = useRouteError()\n  const navigate = useNavigate()\n  const message = isRouteErrorResponse(error)\n    ? `${error.status} ${error.statusText}`\n    : error instanceof Error\n      ? error.message\n      : JSON.stringify(error)\n  const stack = error instanceof Error ? error.stack : null\n\n  useEffect(() => {\n    removeAppSkeleton()\n  }, [])\n\n  useEffect(() => {\n    console.error(\"Error handled by React Router default ErrorBoundary:\", error)\n    void tracker.manager.captureException(error, {\n      source: \"desktop_router_error_element\",\n    })\n  }, [error])\n\n  const reloadRef = useRef(false)\n  if (\n    message.startsWith(\"Failed to fetch dynamically imported module\") &&\n    window.sessionStorage.getItem(\"reload\") !== \"1\"\n  ) {\n    if (reloadRef.current) return null\n    toast.info(\"Web app has been updated so it needs to be reloaded.\")\n    window.sessionStorage.setItem(\"reload\", \"1\")\n    window.location.reload()\n    reloadRef.current = true\n    return null\n  }\n\n  return (\n    <div className=\"m-auto flex min-h-full max-w-prose select-text flex-col p-8 pt-24\">\n      <div className=\"drag-region fixed inset-x-0 top-0 h-12\" />\n      <div className=\"center flex flex-col\">\n        <i className=\"i-mgc-bug-cute-re size-12 text-red-400\" />\n        <h2 className=\"mb-4 mt-12 text-2xl\">Sorry, {APP_NAME} has encountered an error</h2>\n      </div>\n      <h3 className=\"text-xl\">{message}</h3>\n      {import.meta.env.DEV && stack ? (\n        <pre className=\"mt-4 max-h-48 cursor-text overflow-auto whitespace-pre-line rounded-md bg-red-50 p-4 text-left font-mono text-sm text-red-600\">\n          {attachOpenInEditor(stack)}\n        </pre>\n      ) : null}\n\n      <p className=\"my-8\">\n        {APP_NAME} has a temporary problem, click the button below to try reloading the app or\n        another solution?\n      </p>\n\n      <div className=\"center gap-4\">\n        <Button\n          variant=\"outline\"\n          onClick={() => {\n            clearLocalPersistStoreData()\n            window.location.href = \"/\"\n          }}\n        >\n          Reset Local Database\n        </Button>\n        <Button\n          onClick={() => {\n            navigate(\"/\")\n            window.location.reload()\n          }}\n        >\n          Reload\n        </Button>\n      </div>\n\n      <FeedbackIssue message={message} stack={stack} error={error as Error} />\n      <div className=\"grow\" />\n\n      <PoweredByFooter />\n    </div>\n  )\n}\n\nexport const FeedbackIssue = (_props: {\n  message: string\n  stack: string | null | undefined\n  error?: unknown\n}) => (\n  <p className=\"mt-8\">\n    Still having this issue? Please give feedback in GitHub, thanks!\n    <a\n      className=\"ml-2 cursor-pointer text-accent duration-200 hover:text-accent/90\"\n      href={getNewIssueUrl({\n        // error: error instanceof Error ? error : undefined,\n        // title: `Error: ${message}`,\n        // body: [\"### Error\", \"\", message, \"\", \"### Stack\", \"\", \"```\", stack, \"```\"].join(\"\\n\"),\n        // label: \"bug\",\n        template: \"bug_report.yml\",\n      })}\n      target=\"_blank\"\n      rel=\"noreferrer\"\n    >\n      Submit Issue\n    </a>\n  </p>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ErrorTooltip.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport dayjs from \"dayjs\"\nimport { useTranslation } from \"react-i18next\"\n\nexport function ErrorTooltip({\n  errorAt,\n  errorMessage,\n  children,\n  showWhenError = false,\n}: {\n  errorMessage?: string | null\n  errorAt?: string | null\n  children: React.ReactNode\n  showWhenError?: boolean\n}) {\n  const { t } = useTranslation()\n  if (!errorAt || !errorMessage) {\n    return showWhenError ? children : null\n  }\n  return (\n    <Tooltip delayDuration={300}>\n      <TooltipTrigger asChild>{children}</TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent>\n          <div className=\"flex items-center gap-1\">\n            <i className=\"i-mgc-time-cute-re\" />\n            {t(\"feed_item.error_since\")}{\" \"}\n            {dayjs.duration(dayjs(errorAt).diff(dayjs(), \"minute\"), \"minute\").humanize(true)}\n          </div>\n          {!!errorMessage && (\n            <div className=\"flex items-center gap-1\">\n              <i className=\"i-mgc-bug-cute-re\" />\n              {errorMessage}\n            </div>\n          )}\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ExPromise.tsx",
    "content": "import { useLayoutEffect, useState } from \"react\"\nimport type { JSX } from \"react/jsx-runtime\"\n\nconst NOT_RESOLVED = Symbol(\"NOT_RESOLVED\")\nexport const ExPromise = <T,>({\n  children,\n  promise,\n}: {\n  promise: Promise<T>\n  children: (value: T) => JSX.Element\n}) => {\n  // use() is a hook that returns the value of the promise, but in react 19\n\n  const [value, setValue] = useState<T | symbol>(NOT_RESOLVED)\n  useLayoutEffect(() => {\n    promise.then(setValue)\n  }, [promise])\n\n  return value === NOT_RESOLVED ? null : children(value as T)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/Focusable.tsx",
    "content": "import type { FocusableProps } from \"@follow/components/common/Focusable/Focusable.js\"\nimport { Focusable as FocusableComponent } from \"@follow/components/common/Focusable/Focusable.js\"\n\nimport { FloatingLayerScope, HotkeyScope } from \"~/constants\"\n\ninterface BizFocusableProps extends Omit<FocusableProps, \"scope\"> {\n  scope: HotkeyScope\n}\nexport const Focusable = FocusableComponent as Component<\n  Prettify<BizFocusableProps> &\n    React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>\n>\n\nexport const FocusablePresets = {\n  isNotFloatingLayerScope: (v: Set<string>) => !FloatingLayerScope.some((s) => v.has(s)),\n  isSubscriptionList: (scope: Set<string>) => {\n    return (\n      scope.has(HotkeyScope.SubscriptionList) || (scope.has(HotkeyScope.Home) && scope.size === 1)\n    )\n  },\n\n  isSubscriptionOrTimeline: (v: Set<string>) => {\n    return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0\n  },\n  isTimeline: (v) => v.has(HotkeyScope.Timeline) && !v.has(HotkeyScope.EntryRender),\n  isEntryRender: (v) => v.has(HotkeyScope.EntryRender),\n  isAIChat: (v) => v.has(HotkeyScope.AIChat),\n} satisfies Record<string, (v: Set<string>) => boolean>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/Fragment.tsx",
    "content": "import type { FC, ReactNode } from \"react\"\nimport { Fragment } from \"react\"\n\nexport const SafeFragment: FC<{ children: ReactNode }> = ({ children, ..._rest }) => (\n  <Fragment>{children}</Fragment>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ImpressionTracker.tsx",
    "content": "import type { AllTrackers, TrackerPoints } from \"@follow/tracker\"\nimport { tracker } from \"@follow/tracker\"\nimport { memo, useState } from \"react\"\nimport { useInView } from \"react-intersection-observer\"\n\ntype ImpressionProps<T extends AllTrackers> = {\n  event: T\n  onTrack?: () => any\n  // @ts-expect-error FIXME\n  properties?: Parameters<TrackerPoints[T]>\n  children: React.ReactNode\n}\n\nexport function ImpressionView<T extends keyof typeof tracker>(\n  props: ImpressionProps<T> & { shouldTrack?: boolean },\n) {\n  const { shouldTrack = true, ...rest } = props\n  if (!shouldTrack) {\n    return <>{props.children}</>\n  }\n  return <MemoImpressionViewImpl {...rest} />\n}\n\nfunction ImpressionViewImpl<T extends keyof typeof tracker>(props: ImpressionProps<T>) {\n  const [impression, setImpression] = useState(false)\n\n  const { ref } = useInView({\n    initialInView: false,\n    triggerOnce: true,\n    onChange(inView) {\n      if (!inView) {\n        return\n      }\n      setImpression(true)\n\n      // @ts-expect-error\n      tracker[props.event]?.apply(null, props.properties)\n      props.onTrack?.()\n    },\n  })\n\n  return (\n    <>\n      {props.children}\n      {!impression && <span ref={ref} />}\n    </>\n  )\n}\nconst MemoImpressionViewImpl = memo(ImpressionViewImpl)\nMemoImpressionViewImpl.displayName = \"ImpressionView\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/LCPEndDetector.tsx",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { atom } from \"jotai\"\nimport { useEffect } from \"react\"\n\nconst LCPEndAtom = atom(false)\n\n/**\n * To skip page transition when first load, improve LCP\n */\nexport const LCPEndDetector = () => {\n  useEffect(() => {\n    let hasEnded = false\n\n    const timeoutIds: Array<ReturnType<typeof setTimeout>> = []\n    const rafIds: number[] = []\n    const idleCallbackIds: number[] = []\n\n    const scheduleRaf = (cb: FrameRequestCallback) => {\n      if (typeof window !== \"undefined\" && window.requestAnimationFrame) {\n        const id = window.requestAnimationFrame(cb)\n        rafIds.push(id)\n      } else {\n        const id = setTimeout(() => cb(performance.now()), 16)\n        timeoutIds.push(id)\n      }\n    }\n\n    const markEnded = () => {\n      if (hasEnded) return\n      hasEnded = true\n\n      // Defer to ensure layout/paint and initial CSS transitions settle\n      scheduleRaf(() => {\n        scheduleRaf(() => {\n          // Prefer idle if available to avoid jank\n          const ric = (typeof window !== \"undefined\" && (window as any).requestIdleCallback) as\n            | ((cb: () => void, opts?: { timeout?: number }) => number)\n            | undefined\n          if (ric) {\n            const id = ric(() => jotaiStore.set(LCPEndAtom, true), {\n              timeout: 200,\n            }) as unknown as number\n            idleCallbackIds.push(id)\n          } else {\n            const id = setTimeout(() => jotaiStore.set(LCPEndAtom, true), 0)\n            timeoutIds.push(id)\n          }\n        })\n      })\n    }\n\n    // If PerformanceObserver for LCP is available, prefer it\n    const supportsPO = typeof PerformanceObserver !== \"undefined\"\n    let po: PerformanceObserver | undefined\n\n    const onHidden = () => markEnded()\n    const onVisibilityChange = () => {\n      if (document.visibilityState === \"hidden\") onHidden()\n    }\n    const onPageHide = onHidden\n    const onLoad = () => markEnded()\n\n    let safetyTimer: ReturnType<typeof setTimeout> | undefined\n    let fallbackEndTimer: ReturnType<typeof setTimeout> | undefined\n\n    if (supportsPO) {\n      try {\n        po = new PerformanceObserver((list) => {\n          const entries = list.getEntries()\n          if (entries && entries.length > 0) {\n            // Any LCP entry indicates a meaningful paint; we can mark end soon\n            markEnded()\n          }\n        })\n        // buffered: true ensures we get entries that occurred before observer creation\n        po.observe({ type: \"largest-contentful-paint\", buffered: true } as PerformanceObserverInit)\n      } catch {\n        // Ignore observer errors and rely on fallback\n      }\n\n      // When the page is hidden or unloaded, LCP is finalized\n      window.addEventListener(\"visibilitychange\", onVisibilityChange, { once: true })\n      window.addEventListener(\"pagehide\", onPageHide, { once: true })\n      window.addEventListener(\"load\", onLoad, { once: true })\n\n      // Absolute safety net: if nothing fires, end after 3s\n      safetyTimer = setTimeout(() => markEnded(), 3000)\n      timeoutIds.push(safetyTimer)\n    } else {\n      // Ultimate fallback for environments without PO\n      fallbackEndTimer = setTimeout(() => {\n        jotaiStore.set(LCPEndAtom, true)\n      }, 2000)\n      timeoutIds.push(fallbackEndTimer)\n    }\n\n    return () => {\n      if (po) po.disconnect()\n      const caf = typeof window !== \"undefined\" && window.cancelAnimationFrame\n      if (caf) {\n        rafIds.forEach((id) => (window.cancelAnimationFrame as (h: number) => void)(id))\n      }\n      const cic = (typeof window !== \"undefined\" && (window as any).cancelIdleCallback) as\n        | ((id: number) => void)\n        | undefined\n      if (cic) idleCallbackIds.forEach((id) => cic(id))\n      timeoutIds.forEach((id) => clearTimeout(id))\n      if (safetyTimer) clearTimeout(safetyTimer)\n      if (fallbackEndTimer) clearTimeout(fallbackEndTimer)\n\n      window.removeEventListener(\"visibilitychange\", onVisibilityChange)\n      window.removeEventListener(\"pagehide\", onPageHide)\n      window.removeEventListener(\"load\", onLoad)\n    }\n  }, [])\n  return null\n}\n\n// eslint-disable-next-line react-refresh/only-export-components\nexport const isLCPEnded = () => jotaiStore.get(LCPEndAtom)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/LoadMoreIndicator.tsx",
    "content": "import { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { useInView } from \"react-intersection-observer\"\n\nexport const LoadMoreIndicator: Component<{\n  onLoading: () => void\n}> = ({ onLoading, children, className }) => {\n  const { ref } = useInView({\n    rootMargin: \"1px\",\n    onChange(inView) {\n      if (inView) onLoading()\n    },\n  })\n  return (\n    <div className={className} ref={ref}>\n      {children ?? <LoadingCircle size=\"small\" />}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/LoadRemixAsyncComponent.tsx",
    "content": "import { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport type { FC, ReactNode } from \"react\"\nimport { createElement, useEffect, useState } from \"react\"\n\nexport const LoadRemixAsyncComponent: FC<{\n  loader: () => Promise<any>\n  Header: FC<{ loader: () => any; [key: string]: any }>\n}> = ({ loader, Header }) => {\n  const [loading, setLoading] = useState(true)\n\n  const [Component, setComponent] = useState<{ c: () => ReactNode }>({\n    c: () => null,\n  })\n\n  useEffect(() => {\n    let isUnmounted = false\n    setLoading(true)\n    loader()\n      .then((module) => {\n        if (!module.Component) {\n          return\n        }\n        if (isUnmounted) return\n\n        const { loader } = module\n        setComponent({\n          c: () => (\n            <>\n              <Header loader={loader} />\n              <module.Component />\n            </>\n          ),\n        })\n      })\n      .finally(() => {\n        setLoading(false)\n      })\n    return () => {\n      isUnmounted = true\n    }\n  }, [Header, loader])\n\n  if (loading) {\n    return (\n      <div className=\"center absolute inset-0 h-full\">\n        <LoadingCircle size=\"large\" />\n      </div>\n    )\n  }\n\n  return createElement(Component.c)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/Motion.tsx",
    "content": "import type { TargetAndTransition } from \"motion/react\"\nimport { m as M } from \"motion/react\"\nimport { createElement } from \"react\"\n\nimport { useReduceMotion } from \"~/hooks/biz/useReduceMotion\"\n\nimport { isLCPEnded } from \"./LCPEndDetector\"\n\ntype WithLCPOptimization<P> = P & { lcpOptimization?: boolean }\n// Narrow exported proxy type so each motion component accepts `lcpOptimization`\nexport type MotionProxy = {\n  [K in keyof typeof M]: (typeof M)[K] extends React.ComponentType<infer P>\n    ? React.ComponentType<WithLCPOptimization<P>>\n    : (typeof M)[K]\n}\n\nconst cacheMap = new Map<string, any>()\nexport const m: MotionProxy = new Proxy(M, {\n  get(target, p: string) {\n    const Component = target[p]\n\n    if (cacheMap.has(p)) {\n      return cacheMap.get(p)\n    }\n    const MotionComponent = ({ ref, lcpOptimization, ...props }) => {\n      const shouldReduceMotion = useReduceMotion()\n      const nextProps = { ...props }\n      if (shouldReduceMotion) {\n        if (props.exit) {\n          nextProps.exit = {\n            opacity: 0,\n            transition: (props.exit as TargetAndTransition).transition,\n          }\n        }\n\n        if (props.initial) {\n          nextProps.initial = {\n            opacity: 0,\n          }\n        }\n        nextProps.animate = {\n          opacity: 1,\n        }\n      }\n\n      // Disable initial animation before hydration ends to optimize LCP\n      if (lcpOptimization && !isLCPEnded()) {\n        nextProps.initial = false\n      }\n\n      return createElement(Component, { ...nextProps, ref })\n    }\n\n    cacheMap.set(p, MotionComponent)\n\n    return MotionComponent\n  },\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/NotFound.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { ELECTRON_BUILD } from \"@follow/shared/constants\"\nimport { useEffect } from \"react\"\nimport type { Location } from \"react-router\"\nimport { Navigate, useLocation, useNavigate } from \"react-router\"\n\nimport { useSyncTheme } from \"~/hooks/common\"\nimport { removeAppSkeleton } from \"~/lib/app\"\n\nimport { PoweredByFooter } from \"./PoweredByFooter\"\n\nclass AccessNotFoundError extends Error {\n  constructor(\n    message: string,\n    public path: string,\n    public location: Location<any>,\n  ) {\n    super(message)\n    this.name = \"AccessNotFoundError\"\n  }\n\n  override toString() {\n    return `${this.name}: ${this.message} at ${this.path}`\n  }\n}\nexport const NotFound = () => {\n  const location = useLocation()\n  useSyncTheme()\n\n  useEffect(() => {\n    if (!ELECTRON_BUILD) {\n      return\n    }\n    console.error(\n      new AccessNotFoundError(\n        \"Electron app got to a 404 page, this should not happen\",\n        location.pathname,\n        location,\n      ),\n    )\n  }, [location])\n\n  useEffect(() => {\n    removeAppSkeleton()\n  }, [])\n  const navigate = useNavigate()\n\n  if (location.pathname.endsWith(\"/index.html\")) {\n    return <Navigate to=\"/\" />\n  }\n\n  return (\n    <div className=\"prose center m-auto size-full flex-col dark:prose-invert\">\n      <main className=\"flex grow flex-col items-center justify-center\">\n        <div className=\"center mb-8 flex\">\n          <Logo className=\"size-20\" />\n        </div>\n        <p className=\"font-semibold\">\n          You have come to a desert of knowledge where there is nothing.\n        </p>\n        <p>\n          Current path: <code>{location.pathname}</code>\n        </p>\n\n        <p>\n          <Button onClick={() => navigate(\"/\")}>Back to Home</Button>\n        </p>\n      </main>\n\n      <PoweredByFooter className=\"center -mt-12 flex gap-2 py-8\" />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/PoweredByFooter.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport pkg from \"@pkg\"\n\nexport const PoweredByFooter: Component = ({ className }) => (\n  <footer className={cn(\"center mt-12 flex gap-2\", className)}>\n    {new Date().getFullYear()}\n    <Logo className=\"size-5\" />{\" \"}\n    <a\n      href={pkg.homepage}\n      className=\"cursor-pointer font-bold text-orange-500 no-underline\"\n      target=\"_blank\"\n      rel=\"noreferrer\"\n    >\n      <Folo className=\"size-6\" />\n    </a>\n  </footer>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ProviderComposer.tsx",
    "content": "\"use client\"\n\nimport type { JSX } from \"react\"\nimport { cloneElement } from \"react\"\n\nexport const ProviderComposer: Component<{\n  contexts: JSX.Element[]\n}> = ({ contexts, children }) =>\n  contexts.reduceRight(\n    (kids: any, parent: any) => cloneElement(parent, { children: kids }),\n    children,\n  )\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ReloadPrompt.tsx",
    "content": "import { useEffect } from \"react\"\nimport { useRegisterSW } from \"virtual:pwa-register/react\"\n\nimport { setUpdaterStatus } from \"~/atoms/updater\"\n\n// check for updates every hour\nconst period = 60 * 60 * 1000\n\nexport function ReloadPrompt() {\n  const {\n    needRefresh: [needRefresh],\n    updateServiceWorker,\n  } = useRegisterSW({\n    onRegisteredSW(swUrl, r) {\n      if (period <= 0) return\n      if (r?.active?.state === \"activated\") {\n        registerPeriodicSync(period, swUrl, r)\n      } else if (r?.installing) {\n        r.installing.addEventListener(\"statechange\", (e) => {\n          const sw = e.target as ServiceWorker\n          if (sw.state === \"activated\") registerPeriodicSync(period, swUrl, r)\n        })\n      }\n    },\n  })\n\n  useEffect(() => {\n    if (needRefresh) {\n      setUpdaterStatus({\n        type: \"pwa\",\n        status: \"ready\",\n        finishUpdate: () => {\n          updateServiceWorker(true)\n        },\n      })\n    }\n  }, [needRefresh, updateServiceWorker])\n\n  return null\n}\n\n/**\n * This function will register a periodic sync check every hour, you can modify the interval as needed.\n */\nfunction registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) {\n  if (period <= 0) return\n\n  setInterval(async () => {\n    if (\"onLine\" in navigator && !navigator.onLine) return\n\n    const resp = await fetch(swUrl, {\n      cache: \"no-store\",\n      headers: {\n        cache: \"no-store\",\n        \"cache-control\": \"no-cache\",\n      },\n    })\n\n    if (resp?.status === 200) await r.update()\n  }, period)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/ShadowDOM.tsx",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.js\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { getAccentColorValue } from \"@follow/shared/settings/constants\"\nimport { hexToHslString } from \"@follow/utils\"\nimport { nanoid } from \"nanoid\"\nimport type { FC, PropsWithChildren, ReactNode } from \"react\"\nimport { createContext, createElement, use, useLayoutEffect, useMemo, useState } from \"react\"\nimport root from \"react-shadow\"\n\nimport { useUISettingKeys } from \"~/atoms/settings/ui\"\nimport { useReduceMotion } from \"~/hooks/biz/useReduceMotion\"\nimport type { TextSelectionEvent } from \"~/lib/simple-text-selection\"\nimport { addTextSelectionListener } from \"~/lib/simple-text-selection\"\n\nconst ShadowDOMContext = createContext(false)\n\nconst weakMapElementKey = new WeakMap<HTMLStyleElement | HTMLLinkElement, string>()\nconst cloneStylesElement = () => {\n  const $styles = document.head.querySelectorAll(\"style\").values()\n  const reactNodes = [] as ReactNode[]\n\n  for (const $style of $styles) {\n    let key = weakMapElementKey.get($style)\n\n    if (!key) {\n      key = nanoid(8)\n\n      weakMapElementKey.set($style, key)\n    }\n\n    reactNodes.push(\n      createElement(MemoedDangerousHTMLStyle, {\n        key,\n        children: $style.innerHTML,\n      }),\n    )\n\n    const styles = getLinkedStaticStyleSheets()\n\n    for (const style of styles) {\n      let key = weakMapElementKey.get(style.ref)\n      if (!key) {\n        key = nanoid(8)\n        weakMapElementKey.set(style.ref, key)\n      }\n\n      reactNodes.push(\n        createElement(MemoedDangerousHTMLStyle, {\n          key,\n          children: style.cssText,\n          [\"data-href\"]: style.ref.href,\n        }),\n      )\n    }\n  }\n\n  return reactNodes\n}\nexport const ShadowDOM: FC<\n  PropsWithChildren<React.HTMLProps<HTMLElement>> & {\n    injectHostStyles?: boolean\n    textSelectionEnabled?: boolean\n    onTextSelect?: (event: TextSelectionEvent) => void\n    onSelectionClear?: () => void\n  }\n> & {\n  useIsShadowDOM: () => boolean\n} = (props) => {\n  const {\n    injectHostStyles = true,\n    textSelectionEnabled = false,\n    onTextSelect,\n    onSelectionClear,\n    ...rest\n  } = props\n\n  const [stylesElements, setStylesElements] = useState<ReactNode[]>(() =>\n    injectHostStyles ? cloneStylesElement() : [],\n  )\n\n  const [el, setEl] = useState<{ shadowRoot: ShadowRoot } | null>(null)\n\n  useLayoutEffect(() => {\n    if (!el) return\n    const { shadowRoot } = el\n\n    if (!textSelectionEnabled || !shadowRoot || !onTextSelect) return\n\n    return addTextSelectionListener(shadowRoot, onTextSelect, onSelectionClear)\n  }, [textSelectionEnabled, onTextSelect, onSelectionClear, el])\n\n  useLayoutEffect(() => {\n    if (!injectHostStyles) return\n    const mutationObserver = new MutationObserver(() => {\n      setStylesElements(cloneStylesElement())\n    })\n    mutationObserver.observe(document.head, {\n      childList: true,\n      subtree: true,\n    })\n\n    return () => {\n      mutationObserver.disconnect()\n    }\n  }, [injectHostStyles])\n\n  const dark = useIsDark()\n\n  const reduceMotion = useReduceMotion()\n  const [uiFont, usePointerCursor, accentColor] = useUISettingKeys([\n    \"uiFontFamily\",\n    \"usePointerCursor\",\n    \"accentColor\",\n  ])\n\n  return (\n    // @ts-expect-error\n    <root.div {...rest} ref={setEl}>\n      <ShadowDOMContext value={true}>\n        <div\n          style={useMemo(\n            () => ({\n              fontFamily: `${uiFont},\"SN Pro\", system-ui, sans-serif`,\n              \"--pointer\": usePointerCursor ? \"pointer\" : \"default\",\n              \"--fo-a\": hexToHslString(getAccentColorValue(accentColor)[dark ? \"dark\" : \"light\"]),\n            }),\n            [uiFont, usePointerCursor, accentColor, dark],\n          )}\n          id=\"shadow-html\"\n          data-motion-reduce={reduceMotion}\n          data-theme={dark ? \"dark\" : \"light\"}\n          className=\"font-theme\"\n        >\n          {injectHostStyles ? stylesElements : null}\n          {props.children}\n        </div>\n      </ShadowDOMContext>\n    </root.div>\n  )\n}\n\nShadowDOM.useIsShadowDOM = () => use(ShadowDOMContext)\n\nconst cacheCssTextMap = {} as Record<string, string>\n\nfunction getLinkedStaticStyleSheets() {\n  const $links = document.head\n    .querySelectorAll(\"link[rel=stylesheet]\")\n    .values() as unknown as HTMLLinkElement[]\n\n  const styleSheetMap = new WeakMap<Element | ProcessingInstruction, CSSStyleSheet>()\n\n  const cssArray = [] as { cssText: string; ref: HTMLLinkElement }[]\n\n  for (const sheet of document.styleSheets) {\n    if (!sheet.href) continue\n    if (!sheet.ownerNode) continue\n    styleSheetMap.set(sheet.ownerNode, sheet)\n  }\n\n  for (const $link of $links) {\n    const sheet = styleSheetMap.get($link)\n    if (!sheet) continue\n    if (!sheet.href) continue\n    const hasCache = cacheCssTextMap[sheet.href]\n    if (!hasCache) {\n      if (!sheet.href) continue\n      try {\n        const rules = sheet.cssRules || sheet.rules\n        let cssText = \"\"\n        for (const rule of rules) {\n          cssText += rule.cssText\n        }\n        cacheCssTextMap[sheet.href] = cssText\n      } catch (err) {\n        console.error(\"Failed to get cssText for\", sheet.href, err)\n      }\n    }\n\n    cssArray.push({\n      cssText: cacheCssTextMap[sheet.href]!,\n      ref: $link,\n    })\n  }\n\n  return cssArray\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/SharePanel.tsx",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { ipcServices } from \"~/lib/client\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\n\ninterface SharePanelProps {\n  entryId: string\n}\n\ninterface ShareOption {\n  id: string\n  label: string\n  icon: string\n  action: () => Promise<void> | void\n  color?: string\n  bgColor?: string\n}\n\ninterface SocialShareOption {\n  id: string\n  label: string\n  icon: string\n  url: string\n  color: string\n  bgColor: string\n}\n\nconst socialOptions: SocialShareOption[] = [\n  {\n    id: \"twitter\",\n    label: \"X\",\n    icon: tw`i-mgc-social-x-cute-re`,\n    url: \"https://x.com/intent/tweet?text={text}&url={url}\",\n    color: \"text-white\",\n    bgColor: \"bg-black\",\n  },\n  {\n    id: \"facebook\",\n    label: \"Facebook\",\n    icon: tw`i-mgc-facebook-cute-re`,\n    url: \"https://www.facebook.com/sharer/sharer.php?u={url}\",\n    color: \"text-white\",\n    bgColor: \"bg-[#1877F2]\",\n  },\n  {\n    id: \"telegram\",\n    label: \"Telegram\",\n    icon: tw`i-mgc-telegram-cute-re`,\n    url: \"https://t.me/share/url?url={url}&text={text}\",\n    color: \"text-white\",\n    bgColor: \"bg-[#0088CC]\",\n  },\n  {\n    id: \"weibo\",\n    label: \"微博\",\n    icon: tw`i-mgc-weibo-cute-re`,\n    url: \"https://service.weibo.com/share/share.php?url={url}&title={text}\",\n    color: \"text-white\",\n    bgColor: \"bg-[#E6162D]\",\n  },\n]\n\nconst getShareUrl = (entryId: string) => {\n  const entry = getEntry(entryId)\n  if (!entry) return \"\"\n\n  // Temporarily use the original link\n  return entry.url!\n  // const params = getRouteParams()\n\n  // let subscriptionId = \"all\"\n\n  // if (params.feedId) {\n  //   subscriptionId = params.feedId\n  // } else if (params.inboxId) {\n  //   subscriptionId = params.inboxId\n  // } else if (params.listId) {\n  //   subscriptionId = params.listId\n  // }\n\n  // return UrlBuilder.shareEntry(entryId, {\n  //   view: params.view,\n  //   subscriptionId,\n  // })\n}\n\nexport const SharePanel = ({ entryId }: SharePanelProps) => {\n  const { t } = useTranslation()\n\n  const generateShareContent = useCallback(\n    (entry: ReturnType<typeof getEntry>) => {\n      if (!entry) return null\n\n      const { title, description } = entry\n      const shareUrl = getShareUrl(entryId)\n\n      // Limit text to 50 characters with ellipsis\n      const truncateText = (text: string, maxLength = 50) => {\n        return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text\n      }\n\n      const shareTitle = `${title || t(\"share.default_title\")} - Folo`\n      const baseText = description || title || t(\"share.default_description\")\n      const truncatedText = truncateText(baseText)\n      const shareText = `${truncatedText} | ${t(\"share.discover_more\")}`\n\n      return {\n        title: shareTitle,\n        text: shareText,\n        url: shareUrl,\n      }\n    },\n    [entryId, t],\n  )\n\n  const handleNativeShare = useCallback(async () => {\n    const entry = getEntry(entryId)\n    const shareContent = generateShareContent(entry)\n\n    if (!shareContent) return\n\n    try {\n      if (IN_ELECTRON) {\n        // Use Electron's share menu\n        await ipcServices?.menu.showShareMenu(shareContent.url)\n      } else if (navigator.share) {\n        // Use Web Share API\n        await navigator.share({\n          title: shareContent.title,\n          text: shareContent.text,\n          url: shareContent.url,\n        })\n      } else {\n        // Fallback to copying link\n        await copyToClipboard(shareContent.url)\n        toast.success(t(\"share.link_copied\"))\n      }\n    } catch {\n      // If sharing fails, copy link as fallback\n      try {\n        await copyToClipboard(shareContent.url)\n        toast.success(t(\"share.link_copied\"))\n      } catch {\n        toast.error(t(\"share.copy_failed\"))\n      }\n    }\n  }, [entryId, generateShareContent, t])\n\n  const handleCopyLink = useCallback(async () => {\n    const shareUrl = getShareUrl(entryId)\n    try {\n      await copyToClipboard(shareUrl)\n      toast.success(t(\"share.link_copied\"))\n    } catch {\n      toast.error(t(\"share.copy_failed\"))\n    }\n  }, [entryId, t])\n\n  const handleSocialShare = useCallback(\n    (shareUrlTemplate: string) => {\n      const entry = getEntry(entryId)\n      const shareContent = generateShareContent(entry)\n\n      if (!shareContent) return\n\n      const encodedUrl = encodeURIComponent(shareContent.url)\n      const shareTitle = encodeURIComponent(shareContent.title)\n      const shareText = encodeURIComponent(shareContent.text)\n\n      const finalUrl = shareUrlTemplate\n        .replace(\"{url}\", encodedUrl)\n        .replace(\"{title}\", shareTitle)\n        .replace(\"{text}\", shareText)\n\n      window.open(finalUrl, \"_blank\", \"width=600,height=400\")\n    },\n    [entryId, generateShareContent],\n  )\n\n  const actionOptions: ShareOption[] = [\n    ...(IN_ELECTRON || (typeof navigator !== \"undefined\" && \"share\" in navigator)\n      ? [\n          {\n            id: \"native-share\",\n            label: t(\"share.system_share\"),\n            icon: \"i-mgc-share-forward-cute-re\",\n            action: handleNativeShare,\n            color: \"text-blue-500\",\n          },\n        ]\n      : []),\n    {\n      id: \"copy-link\",\n      label: t(\"share.copy_link\"),\n      icon: \"i-mgc-link-cute-re\",\n      action: handleCopyLink,\n    },\n  ]\n\n  return (\n    <div className=\"pointer-events-auto max-w-[400px] px-2\">\n      <div className=\"mb-4 flex flex-col text-center\">\n        <h3 className=\"mb-2 mt-1 font-semibold text-text\">{t(\"share.title\")}</h3>\n        {(() => {\n          const entry = getEntry(entryId)\n          const title = entry?.title\n          return title ? (\n            <p className=\"mt-1 min-w-0 text-wrap text-left text-sm font-medium text-text-secondary\">\n              {title}\n            </p>\n          ) : null\n        })()}\n      </div>\n\n      <div className=\"mb-6\">\n        <div className=\"mb-3\">\n          <h4 className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">\n            {t(\"share.social_media\")}\n          </h4>\n        </div>\n        <div className=\"flex items-center gap-4\">\n          {socialOptions.map((option) => (\n            <button\n              key={option.id}\n              type=\"button\"\n              className=\"group flex flex-col items-center gap-2\"\n              onClick={() => handleSocialShare(option.url)}\n            >\n              <div\n                className={cn(\n                  \"flex size-12 items-center justify-center rounded-full transition-all duration-200\",\n                  option.bgColor,\n                  \"group-hover:scale-110 group-active:scale-95\",\n                  \"shadow-lg\",\n                )}\n              >\n                <i className={cn(option.icon, \"size-5\", option.color)} />\n              </div>\n              <span className=\"text-xs font-medium text-text-secondary\">{option.label}</span>\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div>\n        <div className=\"mb-3\">\n          <h4 className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">\n            {t(\"share.actions\")}\n          </h4>\n        </div>\n        <div className=\"flex flex-col gap-1\">\n          {actionOptions.map((option) => (\n            <button\n              key={option.id}\n              type=\"button\"\n              className={cn(\n                \"relative flex cursor-button select-none items-center rounded-lg\",\n                \"text-sm outline-none transition-all duration-200\",\n                \"hover:bg-fill-secondary/80 active:bg-fill-secondary\",\n                \"group\",\n              )}\n              onClick={() => option.action()}\n            >\n              <div className=\"flex items-center gap-2\">\n                <div\n                  className={cn(\n                    \"flex size-7 items-center justify-center rounded-full\",\n                    \"bg-fill-tertiary/80 group-hover:bg-fill-tertiary\",\n                    \"transition-colors duration-200\",\n                  )}\n                >\n                  <i\n                    className={cn(option.icon, \"size-3.5\", option.color || \"text-text-secondary\")}\n                  />\n                </div>\n                <span className=\"text-xs font-medium text-text\">{option.label}</span>\n              </div>\n            </button>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/common/withAppErrorBoundary.tsx",
    "content": "import type { FC } from \"react\"\nimport { createElement } from \"react\"\n\nimport type { ErrorComponentType } from \"../errors/enum\"\nimport { AppErrorBoundary } from \"./AppErrorBoundary\"\n\ninterface WithErrorBoundaryOptions {\n  errorType: ErrorComponentType | ErrorComponentType[]\n  height?: number | string\n}\n\n/**\n * Higher-order component that wraps a component with AppErrorBoundary\n * @param Component - The component to wrap with ErrorBoundary\n * @param options - Configuration options for the ErrorBoundary wrapper\n * @returns A new component wrapped with ErrorBoundary\n */\nexport function withAppErrorBoundary<P extends object>(\n  Component: FC<P>,\n  options: WithErrorBoundaryOptions,\n): FC<P> {\n  const { errorType, height } = options\n\n  const WrappedComponent = (props: P) => {\n    return createElement(AppErrorBoundary, { errorType, height }, createElement(Component, props))\n  }\n\n  WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || \"Component\"})`\n\n  return WrappedComponent as FC<P>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/EntryNotFound.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FC } from \"react\"\nimport { useNavigate } from \"react-router\"\n\nimport { CustomSafeError } from \"../../errors/CustomSafeError\"\nimport type { AppErrorFallbackProps } from \"../common/AppErrorBoundary\"\nimport { useResetErrorWhenRouteChange } from \"./helper\"\n\nconst EntryNotFoundErrorFallback: FC<AppErrorFallbackProps> = ({ resetError, error }) => {\n  if (!(error instanceof EntryNotFound)) {\n    throw error\n  }\n\n  useResetErrorWhenRouteChange(resetError)\n  const navigate = useNavigate()\n  return (\n    <div className=\"flex w-full flex-1 flex-col items-center justify-center rounded-md bg-theme-background p-2\">\n      <div className=\"center m-auto flex max-w-prose flex-col gap-4 text-center\">\n        <div className=\"center mb-8 flex\">\n          <Logo className=\"size-20\" />\n        </div>\n        <p className=\"font-semibold\">\n          The entry you're looking for could not be found. It may have been removed or the URL is\n          incorrect.\n        </p>\n\n        <div className=\"center mt-12 gap-4\">\n          <Button\n            variant=\"outline\"\n            onClick={() => {\n              navigate(\"/\")\n              setTimeout(() => {\n                resetError()\n              }, 100)\n            }}\n          >\n            Back\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\nexport default EntryNotFoundErrorFallback\n\nexport class EntryNotFound extends CustomSafeError {\n  constructor() {\n    super(\"Entry 404\")\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/FeedNotFound.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FC } from \"react\"\nimport { useNavigate } from \"react-router\"\n\nimport { CustomSafeError } from \"../../errors/CustomSafeError\"\nimport type { AppErrorFallbackProps } from \"../common/AppErrorBoundary\"\nimport { useResetErrorWhenRouteChange } from \"./helper\"\n\nconst FeedNotFoundErrorFallback: FC<AppErrorFallbackProps> = ({ resetError, error }) => {\n  if (!(error instanceof FeedNotFound)) {\n    throw error\n  }\n\n  useResetErrorWhenRouteChange(resetError)\n  const navigate = useNavigate()\n  return (\n    <div className=\"flex w-full flex-col items-center justify-center rounded-md bg-theme-background p-2\">\n      <div className=\"center m-auto flex max-w-prose flex-col gap-4 text-center\">\n        <div className=\"center mb-8 flex\">\n          <Logo className=\"size-20\" />\n        </div>\n        <p className=\"font-semibold\">\n          There is no feed with the given ID. Please check the URL and retry.\n        </p>\n\n        <div className=\"center mt-12 gap-4\">\n          <Button\n            variant=\"outline\"\n            onClick={() => {\n              navigate(\"/\")\n              setTimeout(() => {\n                resetError()\n              }, 100)\n            }}\n          >\n            Back\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\nexport default FeedNotFoundErrorFallback\n\nexport class FeedNotFound extends CustomSafeError {\n  constructor() {\n    super(\"Feed 404\")\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/ModalError.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FC } from \"react\"\n\nimport { attachOpenInEditor } from \"~/lib/dev\"\n\nimport type { AppErrorFallbackProps } from \"../common/AppErrorBoundary\"\nimport { FeedbackIssue } from \"../common/ErrorElement\"\nimport { m } from \"../common/Motion\"\nimport { useCurrentModal } from \"../ui/modal/stacked/hooks\"\nimport { parseError } from \"./helper\"\n\nconst ModalErrorFallback: FC<AppErrorFallbackProps> = (props) => {\n  const { message, stack } = parseError(props.error)\n  const modal = useCurrentModal()\n  return (\n    <m.div\n      className=\"flex flex-col items-center justify-center rounded-md bg-theme-background p-2\"\n      exit={{\n        opacity: 0,\n        scale: 0.9,\n      }}\n    >\n      <div className=\"m-auto max-w-prose text-center\">\n        <div className=\"mb-4\">\n          <i className=\"i-mgc-bug-cute-re text-4xl text-red-500\" />\n        </div>\n        <div className=\"text-lg font-bold\">{message}</div>\n        {import.meta.env.DEV && stack ? (\n          <pre className=\"mt-4 max-h-48 cursor-text select-text overflow-auto whitespace-pre-line rounded-md bg-red-50 p-4 text-left font-mono text-sm text-red-600\">\n            {attachOpenInEditor(stack)}\n          </pre>\n        ) : null}\n\n        <p className=\"my-8\">\n          {APP_NAME} has a temporary problem, click the button below to try reloading the app or\n          another solution?\n        </p>\n\n        <div className=\"center gap-4\">\n          <Button onClick={() => modal.dismiss()} variant=\"outline\">\n            Close Modal\n          </Button>\n          <Button onClick={() => window.location.reload()} variant=\"outline\">\n            Reload\n          </Button>\n        </div>\n\n        <FeedbackIssue message={message!} stack={stack} error={props.error} />\n      </div>\n    </m.div>\n  )\n}\nexport default ModalErrorFallback\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/PageError.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FC } from \"react\"\n\nimport { attachOpenInEditor } from \"~/lib/dev\"\n\nimport type { AppErrorFallbackProps } from \"../common/AppErrorBoundary\"\nimport { FeedbackIssue } from \"../common/ErrorElement\"\nimport { parseError, useResetErrorWhenRouteChange } from \"./helper\"\n\nconst PageErrorFallback: FC<AppErrorFallbackProps> = (props) => {\n  const { message, stack } = parseError(props.error)\n  useResetErrorWhenRouteChange(props.resetError)\n  return (\n    <div className=\"pointer-events-auto flex w-full flex-col items-center justify-center rounded-md bg-theme-background p-2\">\n      <div className=\"m-auto max-w-prose text-center\">\n        <div className=\"mb-4\">\n          <i className=\"i-mgc-bug-cute-re text-4xl text-red-500\" />\n        </div>\n        <div className=\"text-lg font-bold\">{message}</div>\n        {import.meta.env.DEV && stack ? (\n          <pre className=\"mt-4 max-h-48 cursor-text select-text overflow-auto whitespace-pre-line rounded-md bg-red-50 p-4 text-left font-mono text-sm text-red-600\">\n            {attachOpenInEditor(stack)}\n          </pre>\n        ) : null}\n\n        <p className=\"my-8\">\n          {APP_NAME} has a temporary problem, click the button below to try reloading the app or\n          another solution?\n        </p>\n\n        <div className=\"center gap-4\">\n          <Button onClick={() => props.resetError()} variant=\"primary\">\n            Retry\n          </Button>\n\n          <Button onClick={() => window.location.reload()} variant=\"outline\">\n            Reload\n          </Button>\n        </div>\n\n        <FeedbackIssue message={message!} stack={stack} error={props.error} />\n      </div>\n    </div>\n  )\n}\n\nexport default PageErrorFallback\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/RSSHubError.tsx",
    "content": "import type { FC } from \"react\"\n\nimport { attachOpenInEditor } from \"~/lib/dev\"\n\nimport type { AppErrorFallbackProps } from \"../common/AppErrorBoundary\"\nimport { FeedbackIssue } from \"../common/ErrorElement\"\nimport { parseError } from \"./helper\"\n\nconst RSSHubErrorFallback: FC<AppErrorFallbackProps> = (props) => {\n  const { message, stack } = parseError(props.error)\n\n  return (\n    <div className=\"flex flex-col items-center justify-center\">\n      <div className=\"m-auto max-w-prose text-center\">\n        <p className=\"center my-3 gap-2 font-bold\">\n          <i className=\"i-mgc-bug-cute-re text-red-500\" />\n          RSSHub has a temporary problem, please contact the our team.\n        </p>\n        <div className=\"text-lg\">{message}</div>\n        {import.meta.env.DEV && stack ? (\n          <pre className=\"mt-4 max-h-48 cursor-text overflow-auto whitespace-pre-line rounded-md bg-red-50 p-4 text-left font-mono text-sm text-red-600\">\n            {attachOpenInEditor(stack)}\n          </pre>\n        ) : null}\n\n        <FeedbackIssue message={message!} stack={stack} error={props.error} />\n      </div>\n    </div>\n  )\n}\nexport default RSSHubErrorFallback\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/enum.ts",
    "content": "export enum ErrorComponentType {\n  Modal = \"Modal\",\n  Page = \"Page\",\n\n  // Feed\n  FeedFoundCanBeFollow = \"FeedFoundCanBeFollow\",\n  FeedNotFound = \"FeedNotFound\",\n  // Section\n  RSSHubDiscoverError = \"RSSHubDiscoverError\",\n  EntryNotFound = \"EntryNotFound\",\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/helper.ts",
    "content": "import type { FC } from \"react\"\nimport { createElement, useEffect, useRef } from \"react\"\nimport { useLocation } from \"react-router\"\n\nimport type { AppErrorFallbackProps } from \"../common/AppErrorBoundary\"\n\nexport const parseError = (error: unknown): { message?: string; stack?: string } => {\n  if (error instanceof Error) {\n    return {\n      message: error.message,\n      stack: error.stack,\n    }\n  } else {\n    return {\n      message: String(error),\n      stack: undefined,\n    }\n  }\n}\n\nexport const useResetErrorWhenRouteChange = (resetError: () => void) => {\n  const location = useLocation()\n  const currentPathnameRef = useRef(location.pathname)\n  const onceRef = useRef(false)\n  useEffect(() => {\n    if (onceRef.current) {\n      return\n    }\n    if (currentPathnameRef.current !== location.pathname) {\n      resetError()\n      onceRef.current = true\n    }\n  }, [location.pathname])\n}\n\nexport const withErrorGrand = <T extends Error, S extends new (...args: any[]) => T>(\n  error: S,\n  Component: FC<AppErrorFallbackProps>,\n): FC<AppErrorFallbackProps> => {\n  return (props: AppErrorFallbackProps) => {\n    if (!(props.error instanceof error)) {\n      throw error\n    }\n\n    return createElement(Component, props)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/errors/index.ts",
    "content": "import { lazy } from \"react\"\n\nimport { ErrorComponentType } from \"./enum\"\n\nconst ErrorFallbackMap = {\n  [ErrorComponentType.Modal]: lazy(() => import(\"./ModalError\")),\n  [ErrorComponentType.Page]: lazy(() => import(\"./PageError\")),\n  [ErrorComponentType.FeedNotFound]: lazy(() => import(\"./FeedNotFound\")),\n  [ErrorComponentType.RSSHubDiscoverError]: lazy(() => import(\"./RSSHubError\")),\n  [ErrorComponentType.EntryNotFound]: lazy(() => import(\"./EntryNotFound\")),\n}\n\nexport const getErrorFallback = (type: ErrorComponentType) => ErrorFallbackMap[type]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/mobile/button.tsx",
    "content": "import { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { cn } from \"@follow/utils/utils\"\n\nexport const HeaderTopReturnBackButton: Component<{ to?: string }> = ({ className, to }) => (\n  <MotionButtonBase\n    onClick={() => window.history.returnBack(to)}\n    className={cn(\"center size-8\", className)}\n  >\n    <i className=\"i-mingcute-left-line size-6\" />\n\n    <span className=\"sr-only\">Back</span>\n  </MotionButtonBase>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/ai-summary-card/AISummaryCardBase.tsx",
    "content": "import { AutoResizeHeight } from \"@follow/components/ui/auto-resize-height/index.js\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { FollowAPIError } from \"@follow-app/client-sdk\"\nimport type { ReactNode } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useIsPaymentEnabled } from \"~/atoms/server-configs\"\nimport { CopyButton } from \"~/components/ui/button/CopyButton\"\nimport { Markdown } from \"~/components/ui/markdown/Markdown\"\nimport { useFeature } from \"~/hooks/biz/useFeature\"\nimport { useSettingModal } from \"~/modules/settings/modal/useSettingModal\"\n\ninterface AISummaryCardBaseProps {\n  /** Summary content to display */\n  content?: string | null\n  /** Whether the summary is currently loading */\n  isLoading?: boolean\n  /** Additional className for the container */\n  className?: string\n  /** Custom header content (replaces default AI Summary header) */\n  headerContent?: ReactNode\n  /** Additional content to render below the summary */\n  footerContent?: ReactNode\n  /** Custom loading state component */\n  loadingComponent?: ReactNode\n  /** Title text for the AI Summary header */\n  title?: string\n  /** Whether to show the copy button */\n  showCopyButton?: boolean\n  /** Whether to show the Ask AI button when there's content */\n  showAskAIButton?: boolean\n  /** Callback when Ask AI button is clicked */\n  onAskAI?: () => void\n\n  error?: Error | null\n}\n\nconst DefaultLoadingState = () => (\n  <div className=\"space-y-2\">\n    <div className=\"h-3 w-full animate-pulse rounded-lg bg-material-ultra-thick\" />\n    <div className=\"h-3 w-[92%] animate-pulse rounded-lg bg-material-ultra-thick\" />\n    <div className=\"h-3 w-[85%] animate-pulse rounded-lg bg-material-ultra-thick\" />\n  </div>\n)\n\nconst DefaultEmptyState = ({\n  message,\n  shouldSuggestUpgrade,\n}: {\n  message: string\n  shouldSuggestUpgrade?: boolean\n}) => {\n  const settingModalPresent = useSettingModal()\n  const { t } = useTranslation(\"app\")\n\n  if (shouldSuggestUpgrade) {\n    return (\n      <button\n        type=\"button\"\n        onClick={() => settingModalPresent(\"plan\")}\n        className=\"group/upgrade relative flex items-start gap-3 text-left\"\n      >\n        {/* Icon with glow */}\n        <div className=\"relative flex-shrink-0\">\n          <div className=\"center relative size-9 rounded-lg bg-gradient-to-br from-purple-500 to-blue-500\">\n            <i className=\"i-mgc-power-mono text-xl text-white\" />\n          </div>\n        </div>\n\n        <div className=\"flex-1 space-y-2\">\n          {/* Title */}\n          <h3 className=\"text-sm font-medium leading-snug text-text\">{message}</h3>\n\n          {/* Description */}\n          <p className=\"text-xs leading-relaxed text-text-tertiary\">\n            {t(\"ai.summary_upgrade_required_description\")}\n          </p>\n\n          {/* CTA */}\n          <div className=\"flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400\">\n            <span>{t(\"ai.summary_upgrade_view_plans\")}</span>\n            <i className=\"i-mgc-right-cute-re text-sm\" />\n          </div>\n        </div>\n      </button>\n    )\n  }\n\n  return (\n    <div className=\"text-center\">\n      <p className=\"text-sm text-text-secondary\">{message}</p>\n    </div>\n  )\n}\n\nexport const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({\n  content,\n  isLoading = false,\n  className,\n  headerContent,\n  footerContent,\n  loadingComponent,\n  title = \"AI Summary\",\n  showCopyButton = true,\n  showAskAIButton = false,\n  onAskAI,\n  error,\n}) => {\n  const { t } = useTranslation(\"app\")\n  const aiEnabled = useFeature(\"ai\")\n\n  const hasContent = !isLoading && content\n  const shouldSuggestUpgrade =\n    useIsPaymentEnabled() && error instanceof FollowAPIError ? error.status === 402 : undefined\n\n  return (\n    <div\n      className={cn(\n        \"group relative overflow-hidden rounded-2xl border p-5 shadow-sm backdrop-blur-xl transition-shadow duration-300\",\n        \"border-purple-200/30 bg-gradient-to-b from-purple-50/30 via-white/50 to-blue-50/20\",\n        \"dark:border-purple-800/30 dark:from-purple-950/30 dark:via-neutral-900/50 dark:to-blue-950/20\",\n        \"hover:shadow-md hover:shadow-purple-100/20 dark:hover:shadow-purple-900/10\",\n\n        isLoading &&\n          \"before:absolute before:inset-0 before:-z-10 before:animate-[pulse_2s_cubic-bezier(0.4,0,0.6,1)_infinite] before:bg-gradient-to-r before:from-purple-100/0 before:via-purple-300/10 before:to-purple-100/0 dark:before:from-purple-900/0 dark:before:via-purple-600/10 dark:before:to-purple-900/0\",\n        className,\n      )}\n    >\n      {/* Animated background gradient */}\n      <div\n        className={cn(\n          \"absolute inset-0 -z-10 bg-gradient-to-br opacity-40\",\n          \"from-purple-100/30 via-transparent to-blue-100/30\",\n          \"dark:from-purple-900/30 dark:to-blue-900/30\",\n          isLoading && \"animate-[glow_4s_ease-in-out_infinite]\",\n        )}\n      />\n\n      {/* Subtle shine effect on hover */}\n      <div className=\"absolute inset-0 -z-10 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100 dark:via-white/5\" />\n\n      {/* Header */}\n      <div className=\"flex items-center justify-between\">\n        {headerContent || (\n          <div className=\"flex items-center gap-3\">\n            {/* Glowing AI icon */}\n            <div className=\"center relative\">\n              <i\n                className={cn(\n                  \"i-mgc-ai-cute-re text-lg\",\n                  isLoading\n                    ? \"text-purple-500/70 dark:text-purple-400/70\"\n                    : \"text-purple-600 dark:text-purple-400\",\n                )}\n              />\n              <div\n                className={cn(\n                  \"absolute inset-0 rounded-full blur-sm\",\n                  isLoading\n                    ? \"animate-[pulse_2s_infinite] bg-purple-400/30 dark:bg-purple-500/30\"\n                    : \"animate-pulse bg-purple-400/20 dark:bg-purple-500/20\",\n                )}\n              />\n            </div>\n            <span\n              className={cn(\n                \"bg-gradient-to-r bg-clip-text font-medium text-transparent\",\n                isLoading\n                  ? \"from-purple-500/70 to-blue-500/70 dark:from-purple-400/70 dark:to-blue-400/70\"\n                  : \"from-purple-600 to-blue-600 dark:from-purple-400 dark:to-blue-400\",\n              )}\n            >\n              {title}\n            </span>\n          </div>\n        )}\n\n        <div className=\"flex items-center gap-2\">\n          {aiEnabled && showAskAIButton && hasContent && onAskAI && (\n            <MotionButtonBase\n              onClick={onAskAI}\n              className={cn(\n                \"flex h-7 items-center gap-1.5 rounded-lg px-3 text-sm font-medium\",\n                \"bg-gradient-to-r from-purple-500/10 to-blue-500/10\",\n                \"border border-purple-200/30 dark:border-purple-800/30\",\n                \"text-purple-600 dark:text-purple-400\",\n                \"hover:from-purple-500/20 hover:to-blue-500/20\",\n                \"hover:border-purple-300/50 dark:hover:border-purple-700/50\",\n                \"transition-all duration-200\",\n                \"backdrop-blur-sm\",\n                \"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100\",\n              )}\n            >\n              <i className=\"i-mgc-ai-cute-re text-base\" />\n              <span>Ask AI</span>\n            </MotionButtonBase>\n          )}\n\n          {showCopyButton && hasContent && (\n            <CopyButton\n              value={content}\n              variant=\"outline\"\n              className={cn(\n                \"!bg-white/10 !text-purple-600 dark:!text-purple-400\",\n                \"hover:!bg-white/20 dark:hover:!bg-neutral-800/30\",\n                \"!border-purple-200/30 dark:!border-purple-800/30\",\n                \"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100\",\n                \"backdrop-blur-sm\",\n              )}\n            />\n          )}\n        </div>\n      </div>\n\n      {/* Content */}\n      <AutoResizeHeight className=\"mt-4 text-sm leading-relaxed text-neutral-700 dark:text-neutral-300\">\n        {isLoading ? (\n          loadingComponent || <DefaultLoadingState />\n        ) : hasContent ? (\n          <Markdown className=\"prose-sm max-w-none prose-p:m-0\">{String(content)}</Markdown>\n        ) : shouldSuggestUpgrade ? (\n          <DefaultEmptyState\n            message={t(\"ai.summary_upgrade_required_title\")}\n            shouldSuggestUpgrade\n          />\n        ) : (\n          <DefaultEmptyState message={t(\"ai.summary_not_available\")} />\n        )}\n      </AutoResizeHeight>\n\n      {footerContent}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/ai-summary-card/index.ts",
    "content": "export { AISummaryCardBase } from \"./AISummaryCardBase\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/auto-completion/AutoCompletion.tsx",
    "content": "import { Input } from \"@follow/components/ui/input/index.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.jsx\"\nimport { useCorrectZIndex } from \"@follow/components/ui/z-index/ctx.js\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from \"@headlessui/react\"\nimport Fuse from \"fuse.js\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { Fragment, memo, useCallback, useEffect, useState } from \"react\"\n\nexport type Suggestion = {\n  name: string\n  value: string\n}\nexport interface AutocompleteProps extends React.InputHTMLAttributes<HTMLInputElement> {\n  suggestions: Suggestion[]\n  renderSuggestion?: (suggestion: Suggestion) => any\n\n  onSuggestionSelected: (suggestion: NoInfer<Suggestion> | null) => void\n\n  // classnames\n\n  searchKeys?: string[]\n  maxHeight?: number\n}\n\nconst defaultSearchKeys = [\"name\", \"value\"]\nconst defaultRenderSuggestion = (suggestion: any) => suggestion?.name\nexport const Autocomplete = ({\n  ref: forwardedRef,\n  suggestions,\n  renderSuggestion = defaultRenderSuggestion,\n  onSuggestionSelected,\n  maxHeight,\n  value,\n  searchKeys = defaultSearchKeys,\n  defaultValue,\n  ...inputProps\n}: AutocompleteProps & { ref?: React.Ref<HTMLInputElement | null> }) => {\n  const [selectedOptions, setSelectedOptions] = useState<NoInfer<Suggestion> | null>(\n    () => suggestions.find((suggestion) => suggestion.value === value) || null,\n  )\n\n  const [filterableSuggestions, setFilterableSuggestions] = useState(suggestions)\n\n  const doFilter = useCallback(() => {\n    const fuse = new Fuse(suggestions, {\n      keys: searchKeys,\n    })\n\n    const trimInputValue = (value as string)?.trim()\n\n    if (!trimInputValue) return setFilterableSuggestions(suggestions)\n\n    const results = fuse.search(trimInputValue)\n\n    setFilterableSuggestions(results.map((result) => result.item))\n  }, [suggestions, value, searchKeys])\n  useEffect(() => {\n    doFilter()\n  }, [doFilter])\n\n  const zIndex = useCorrectZIndex(9)\n  return (\n    <Combobox\n      as=\"div\"\n      immediate\n      value={selectedOptions}\n      onChange={(suggestion) => {\n        setSelectedOptions(suggestion)\n        onSuggestionSelected(suggestion)\n      }}\n    >\n      {({ open }) => {\n        return (\n          <Fragment>\n            <ComboboxInput\n              ref={forwardedRef}\n              as={Input}\n              autoComplete=\"off\"\n              aria-label=\"Select Category\"\n              displayValue={renderSuggestion}\n              value={value}\n              {...inputProps}\n            />\n            <AnimatePresence>\n              {open && filterableSuggestions.length > 0 && (\n                <RootPortal>\n                  <ComboboxOptions\n                    portal\n                    static\n                    as={m.div}\n                    initial={{ opacity: 0, scale: 0.98 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={{ opacity: 0, scale: 0.98 }}\n                    anchor=\"bottom\"\n                    style={{ zIndex }}\n                    onWheel={stopPropagation}\n                    className={cn(\n                      \"pointer-events-auto bg-material-medium text-text backdrop-blur-background\",\n                      \"shadow-context-menu min-w-32 overflow-hidden rounded-[6px] border p-1\",\n                      \"text-body motion-scale-in-75 motion-duration-150 lg:animate-none\",\n                      \"w-[var(--input-width)] empty:invisible\",\n                    )}\n                  >\n                    <div style={{ maxHeight }}>\n                      {filterableSuggestions.map((suggestion) => (\n                        <MemoizedComboboxOption key={suggestion.value} suggestion={suggestion} />\n                      ))}\n                    </div>\n                  </ComboboxOptions>\n                </RootPortal>\n              )}\n            </AnimatePresence>\n          </Fragment>\n        )\n      }}\n    </Combobox>\n  )\n}\n\nconst MemoizedComboboxOption = memo(({ suggestion }: { suggestion: Suggestion }) => {\n  return (\n    <ComboboxOption\n      key={suggestion.value}\n      value={suggestion}\n      className={cn(\n        \"cursor-menu focus:bg-theme-selection-active focus:text-theme-selection-foreground\",\n        \"data-[focus]:bg-theme-selection-hover data-[focus]:text-theme-selection-foreground\",\n        \"relative flex select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none\",\n        \"h-[28px]\",\n      )}\n    >\n      {suggestion.name}\n    </ComboboxOption>\n  )\n})\n\nAutocomplete.displayName = \"Autocomplete\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/auto-completion/index.ts",
    "content": "export * from \"./AutoCompletion\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx",
    "content": "import { SYSTEM_CAN_UNDER_BLUR_WINDOW } from \"@follow/shared/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport type * as React from \"react\"\nimport type { ComponentPropsWithoutRef, ElementType } from \"react\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\n\ntype Props<T extends ElementType = \"div\"> = {\n  as?: T\n  ref?: React.Ref<HTMLElement>\n} & ComponentPropsWithoutRef<T>\n\nconst MacOSVibrancy = <T extends ElementType = \"div\">({ children, as, ...rest }: Props<T>) => {\n  const Component = as || \"div\"\n  return <Component {...rest}>{children}</Component>\n}\n\nconst Noop = <T extends ElementType = \"div\">({ children, className, as, ...rest }: Props<T>) => {\n  const Component = as || \"div\"\n  return (\n    <Component className={cn(\"bg-sidebar\", className)} {...rest}>\n      {children}\n    </Component>\n  )\n}\n\nexport const WindowUnderBlur = SYSTEM_CAN_UNDER_BLUR_WINDOW\n  ? <T extends ElementType = \"div\">(props: Props<T>) => {\n      const opaqueSidebar = useUISettingKey(\"opaqueSidebar\")\n      if (opaqueSidebar) {\n        return <Noop {...props} />\n      }\n\n      if (!window.electron) {\n        return <Noop {...props} />\n      }\n      switch (window.electron.process.platform) {\n        case \"darwin\": {\n          return <MacOSVibrancy {...props} />\n        }\n        case \"win32\": {\n          return <Noop {...props} />\n        }\n        default: {\n          return <Noop {...props} />\n        }\n      }\n    }\n  : Noop\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/background/index.ts",
    "content": "export * from \"./WindowUnderBlur\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/button/AnimatedCommandButton.tsx",
    "content": "import { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\nimport type { HTMLMotionProps, Variants } from \"motion/react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { cloneElement, useRef, useState } from \"react\"\n\nconst animatedCommandButtonVariants = cva(\n  [\"center pointer-events-auto flex text-xs\", \"rounded-md p-1.5 duration-200\"],\n  {\n    variants: {\n      variant: {\n        solid: [\"border-accent/5 bg-accent/80 text-white border backdrop-blur\"],\n        outline: [\"text-accent hover:bg-material-ultra-thick\"],\n        ghost: [\n          \"border-accent/5 bg-accent/80 text-accent border backdrop-blur\",\n          \"bg-theme-item-active hover:bg-theme-item-hover\",\n        ],\n      },\n    },\n    defaultVariants: {\n      variant: \"solid\",\n    },\n  },\n)\n\ninterface AnimatedCommandButtonProps extends VariantProps<typeof animatedCommandButtonVariants> {\n  icon: React.JSX.Element\n}\n\nconst iconVariants: Variants = {\n  initial: {\n    opacity: 1,\n    scale: 1,\n  },\n  animate: {\n    opacity: 1,\n    scale: 1,\n  },\n  exit: {\n    opacity: 0,\n    scale: 0,\n  },\n}\n\nexport const AnimatedCommandButton: FC<AnimatedCommandButtonProps & HTMLMotionProps<\"button\">> = ({\n  icon,\n  className,\n  style,\n  variant,\n  ...props\n}) => {\n  const [pressed, setPressed] = useState(false)\n  const timerRef = useRef<any>(undefined)\n\n  return (\n    <MotionButtonBase\n      type=\"button\"\n      className={cn(animatedCommandButtonVariants({ variant }), className)}\n      onClick={useTypeScriptHappyCallback(\n        (e) => {\n          setPressed(true)\n          props.onClick?.(e)\n          timerRef.current = setTimeout(() => {\n            setPressed(false)\n          }, 2000)\n        },\n        [props.onClick],\n      )}\n      style={style}\n    >\n      <AnimatePresence mode=\"wait\">\n        {pressed ? (\n          <m.i key=\"copied\" className=\"i-mgc-check-filled size-4\" {...iconVariants} />\n        ) : (\n          cloneElement(icon, {\n            className: cn(icon.props.className, \"size-4\"),\n            ...iconVariants,\n          })\n        )}\n      </AnimatePresence>\n    </MotionButtonBase>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/button/CommandActionButton.tsx",
    "content": "import type { ActionButtonProps } from \"@follow/components/ui/button/action-button.js\"\nimport { ActionButton } from \"@follow/components/ui/button/action-button.js\"\n\nimport { useCommand } from \"~/modules/command/hooks/use-command\"\nimport type { FollowCommandId } from \"~/modules/command/types\"\n\nexport interface CommandActionButtonProps extends ActionButtonProps {\n  commandId: FollowCommandId\n  onClick: () => void\n}\nexport const CommandActionButton = ({\n  ref,\n  ...props\n}: CommandActionButtonProps & { ref?: React.Ref<HTMLButtonElement | null> }) => {\n  const { commandId, ...rest } = props\n  const command = useCommand(commandId)\n  if (!command) return null\n  const { icon, label } = command\n\n  return (\n    <ActionButton\n      ref={ref}\n      {...rest}\n      data-command-id={commandId}\n      data-testid={`command-action-${commandId.replaceAll(\":\", \"-\")}`}\n      tooltip={label.title}\n      tooltipDescription={label.description}\n      icon={icon}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/button/CopyButton.tsx",
    "content": "import { useCallback, useRef } from \"react\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\n\nimport { AnimatedCommandButton } from \"./AnimatedCommandButton\"\n\nexport const CopyButton: Component<{\n  value: string\n  style?: React.CSSProperties\n  variant?: \"solid\" | \"outline\" | \"ghost\"\n}> = ({ value, className, style, variant = \"solid\" }) => {\n  const copiedTimerRef = useRef<any>(undefined)\n  const handleCopy = useCallback(() => {\n    copyToClipboard(value)\n\n    clearTimeout(copiedTimerRef.current)\n  }, [value])\n  return (\n    <AnimatedCommandButton\n      className={className}\n      style={style}\n      variant={variant}\n      icon={<m.i className=\"i-mgc-copy-2-cute-re size-4\" />}\n      onClick={handleCopy}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/button/GlassButton.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { cva } from \"class-variance-authority\"\nimport { m } from \"motion/react\"\nimport type { FC, ReactNode } from \"react\"\n\nexport interface GlassButtonProps {\n  description?: string\n  onClick?: () => void\n  className?: string\n  testId?: string\n  children: ReactNode\n  /**\n   * Custom animation variants for hover and tap states\n   */\n  hoverScale?: number\n  tapScale?: number\n  /**\n   * Size variant\n   */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /**\n   * Color theme\n   */\n  theme?: \"light\" | \"dark\" | \"auto\"\n  /**\n   * Visual variant\n   */\n  variant?: \"glass\" | \"flat\"\n}\n\nconst glassButtonVariants = cva(\n  [\n    // Base styles - perfect 1:1 circle\n    \"pointer-events-auto relative flex items-center justify-center rounded-full\",\n    \"transition-all duration-300 ease-out no-drag-region\",\n  ],\n  {\n    variants: {\n      size: {\n        sm: \"size-8 text-sm\",\n        md: \"size-10 text-lg\",\n        lg: \"size-12 text-xl\",\n      },\n      theme: {\n        light: [\"text-gray-700 hover:text-gray-900\"],\n        dark: [\"text-white hover:text-white\"],\n        auto: [\"text-text hover:text-text-vibrant\"],\n      },\n      variant: {\n        glass: [\"backdrop-blur-md border shadow-lg\"],\n        flat: [\"border shadow-sm hover:shadow-md\"],\n      },\n    },\n    compoundVariants: [\n      // Glass variant themes\n      {\n        variant: \"glass\",\n        theme: \"light\",\n        className: [\n          \"bg-material-thin hover:bg-material-medium\",\n          \"border-gray/30 hover:border-gray/40\",\n          \"shadow-gray/30\",\n        ],\n      },\n      {\n        variant: \"glass\",\n        theme: \"dark\",\n        className: [\n          \"bg-material-ultra-thin hover:bg-material-thin\",\n          \"border-gray/10 hover:border-gray/20\",\n          \"shadow-black/25\",\n        ],\n      },\n      {\n        variant: \"glass\",\n        theme: \"auto\",\n        className: [\n          \"bg-material-thin hover:bg-material-medium\",\n          \"border-gray/30 hover:border-gray/40\",\n          \"shadow-gray/30\",\n        ],\n      },\n      // Flat variant themes\n      {\n        variant: \"flat\",\n        theme: \"light\",\n        className: [\n          \"bg-white/80 hover:bg-white/90\",\n          \"border-gray/20 hover:border-gray/30\",\n          // Subtle shadow color for clearer hover feedback\n          \"shadow-gray/10 hover:shadow-gray/25\",\n        ],\n      },\n      {\n        variant: \"flat\",\n        theme: \"dark\",\n        className: [\n          \"bg-fill-secondary hover:bg-fill-tertiary\",\n          \"border-gray/20 hover:border-gray/30\",\n          \"shadow-black/10 hover:shadow-black/25\",\n        ],\n      },\n      {\n        variant: \"flat\",\n        theme: \"auto\",\n        className: [\n          \"bg-white/80 hover:bg-white/90 dark:bg-fill-secondary dark:hover:bg-fill-tertiary\",\n          \"border-gray/20 hover:border-gray/30\",\n          \"shadow-gray/10 hover:shadow-gray/25 dark:shadow-black/10 dark:hover:shadow-black/25\",\n        ],\n      },\n    ],\n    defaultVariants: {\n      size: \"md\",\n      theme: \"auto\",\n      variant: \"glass\",\n    },\n  },\n)\n\nconst glassOverlayVariants = cva(\n  \"absolute inset-0 rounded-full bg-gradient-to-t opacity-0 transition-opacity duration-300 hover:opacity-100\",\n  {\n    variants: {\n      theme: {\n        light: \"from-material-opaque/10 to-material-opaque/30\",\n        dark: \"from-material-opaque/5 to-material-opaque/20\",\n        auto: \"from-material-opaque/10 to-material-opaque/30\",\n      },\n    },\n    defaultVariants: {\n      theme: \"auto\",\n    },\n  },\n)\n\nconst glassInnerShadowVariants = cva(\"absolute inset-0 rounded-full shadow-inner\", {\n  variants: {\n    theme: {\n      light: \"shadow-gray/20\",\n      dark: \"shadow-black/10\",\n      auto: \"shadow-gray/20 dark:shadow-black/10\",\n    },\n  },\n  defaultVariants: {\n    theme: \"auto\",\n  },\n})\n\nexport const GlassButton: FC<GlassButtonProps> = ({\n  description,\n  onClick,\n  className,\n  testId,\n  children,\n  hoverScale = 1.1,\n  tapScale = 0.95,\n  size = \"md\",\n  theme = \"auto\",\n  variant = \"flat\",\n}) => {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <m.button\n          data-testid={testId}\n          type=\"button\"\n          onClick={(e) => {\n            e.stopPropagation()\n            onClick?.()\n          }}\n          className={cn(glassButtonVariants({ size, theme, variant }), className)}\n          initial={{ scale: 1 }}\n          whileHover={\n            variant === \"flat\"\n              ? undefined\n              : {\n                  scale: hoverScale,\n                }\n          }\n          whileTap={{ scale: tapScale }}\n          transition={Spring.presets.snappy}\n        >\n          {/* Glass effect overlay - only for glass variant */}\n          {variant === \"glass\" && <div className={glassOverlayVariants({ theme })} />}\n\n          {/* Icon container */}\n          <div className=\"center relative z-10 flex\">{children}</div>\n\n          {/* Subtle inner shadow for depth - only for glass variant */}\n          {variant === \"glass\" && <div className={glassInnerShadowVariants({ theme })} />}\n        </m.button>\n      </TooltipTrigger>\n      {description && (\n        <TooltipPortal>\n          <TooltipContent>{description}</TooltipContent>\n        </TooltipPortal>\n      )}\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/button/HeaderActionButton.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport type { ReactNode } from \"react\"\n\ninterface HeaderActionButtonProps {\n  children: ReactNode\n  onClick?: () => void\n  disabled?: boolean\n  loading?: boolean\n  variant?: \"primary\" | \"accent\" | \"neutral\"\n  className?: string\n  icon?: string\n  iconClassName?: string\n  \"data-testid\"?: string\n}\n\nexport const HeaderActionButton = ({\n  children,\n  onClick,\n  disabled = false,\n  loading = false,\n  variant = \"neutral\",\n  className,\n  icon,\n  iconClassName,\n  \"data-testid\": testId,\n}: HeaderActionButtonProps) => {\n  const getVariantStyles = () => {\n    if (disabled) {\n      return [\n        \"text-text-tertiary cursor-not-allowed opacity-50\",\n        \"bg-fill-quaternary border border-transparent\",\n      ]\n    }\n\n    switch (variant) {\n      case \"primary\": {\n        return [\n          \"bg-blue/10 text-blue hover:bg-blue/20\",\n          \"border border-blue/20 hover:border-blue/30\",\n          \"active:bg-blue/30 active:scale-95\",\n        ]\n      }\n      case \"accent\": {\n        return [\n          \"bg-accent/10 text-accent hover:bg-accent/20\",\n          \"border border-accent/20 hover:border-accent/30\",\n          \"active:bg-accent/30 active:scale-95\",\n        ]\n      }\n      default: {\n        return [\n          \"bg-fill/10 text-text hover:bg-fill/20\",\n          \"border border-fill/20 hover:border-fill/30\",\n          \"active:bg-fill/30 active:scale-95\",\n        ]\n      }\n    }\n  }\n\n  const iconClass = loading ? \"i-mgc-loading-3-cute-re animate-spin duration-500\" : icon\n\n  return (\n    <m.button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled || loading}\n      className={cn(\n        \"group no-drag-region relative flex items-center gap-2 rounded-lg px-3 py-2\",\n        \"text-sm font-medium transition-all duration-200\",\n        ...getVariantStyles(),\n        className,\n      )}\n      data-testid={testId}\n    >\n      {iconClass && (\n        <i className={cn(\"size-4 transition-all duration-200\", iconClass, iconClassName)} />\n      )}\n      <span className=\"font-medium\">{children}</span>\n    </m.button>\n  )\n}\n\ninterface HeaderActionGroupProps {\n  children: ReactNode\n  className?: string\n}\n\nexport const HeaderActionGroup = ({ children, className }: HeaderActionGroupProps) => {\n  return <div className={cn(\"flex items-center gap-2\", className)}>{children}</div>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/constants/index.tsx",
    "content": "const LanguageAlias = {\n  ts: \"typescript\",\n  js: \"javascript\",\n\n  tsx: \"typescriptreact\",\n  jsx: \"javascriptreact\",\n  md: \"markdown\",\n}\n\nconst languageToIconMap = {\n  javascriptreact: <i className=\"i-simple-icons-react\" />,\n  typescriptreact: <i className=\"i-simple-icons-react\" />,\n  javascript: <i className=\"i-simple-icons-javascript\" />,\n  typescript: <i className=\"i-simple-icons-typescript\" />,\n  html: <i className=\"i-simple-icons-html5\" />,\n  css: <i className=\"i-simple-icons-css3\" />,\n  markdown: <i className=\"i-simple-icons-markdown\" />,\n  json: <i className=\"i-simple-icons-json\" />,\n  yaml: <i className=\"i-simple-icons-yaml\" />,\n  bash: <i className=\"i-simple-icons-shell\" />,\n}\n\nexport const getLanguageIcon = (language?: string) => {\n  if (!language) return null\n\n  const alias = LanguageAlias[language]\n  if (alias) {\n    return languageToIconMap[alias]\n  }\n\n  return languageToIconMap[language]\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/index.ts",
    "content": "export * from \"./shiki\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx",
    "content": "import { ELECTRON_BUILD } from \"@follow/shared/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useIsomorphicLayoutEffect } from \"foxact/use-isomorphic-layout-effect\"\nimport type { FC } from \"react\"\nimport { memo, useInsertionEffect, useMemo, useRef, useState } from \"react\"\nimport type {\n  BundledLanguage,\n  BundledTheme,\n  DynamicImportLanguageRegistration,\n  DynamicImportThemeRegistration,\n} from \"shiki\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { ipcServices } from \"~/lib/client\"\n\nimport { CopyButton } from \"../../button/CopyButton\"\nimport { getLanguageIcon } from \"../constants\"\nimport { useShikiDefaultTheme } from \"./hooks\"\nimport { shiki, shikiTransformers } from \"./shared\"\nimport styles from \"./shiki.module.css\"\n\nexport interface ShikiProps {\n  language: string | undefined\n  code: string\n\n  attrs?: string\n  className?: string\n\n  transparent?: boolean\n  showCopy?: boolean\n\n  theme?: string\n}\n\nlet langModule: Record<BundledLanguage, DynamicImportLanguageRegistration> | null = null\nlet themeModule: Record<BundledTheme, DynamicImportThemeRegistration> | null = null\nlet bundledLanguagesKeysSet: Set<string> | null = null\n\nexport const ShikiHighLighter: FC<ShikiProps> = (props) => {\n  const { code, language, className, theme: overrideTheme } = props\n  const [currentLanguage, setCurrentLanguage] = useState(language || \"plaintext\")\n\n  const guessCodeLanguage = useUISettingKey(\"guessCodeLanguage\")\n  useInsertionEffect(() => {\n    if (!guessCodeLanguage) return\n    if (language || !ELECTRON_BUILD) return\n\n    if (!bundledLanguagesKeysSet) {\n      import(\"shiki/langs\")\n        .then(({ bundledLanguages }) => {\n          langModule = bundledLanguages\n          bundledLanguagesKeysSet = new Set(Object.keys(bundledLanguages))\n        })\n        .then(guessLanguage)\n    } else {\n      guessLanguage()\n    }\n\n    function guessLanguage() {\n      return ipcServices?.reader.detectCodeStringLanguage({ codeString: code }).then((result) => {\n        if (!result) {\n          return\n        }\n        if (bundledLanguagesKeysSet?.has(result.languageId)) {\n          setCurrentLanguage(result.languageId)\n        }\n      })\n    }\n  }, [guessCodeLanguage])\n\n  const loadThemesRef = useRef([] as string[])\n  const loadLanguagesRef = useRef([] as string[])\n\n  const [loaded, setLoaded] = useState(false)\n\n  const codeTheme = useShikiDefaultTheme(overrideTheme)\n\n  useIsomorphicLayoutEffect(() => {\n    let isMounted = true\n    setLoaded(false)\n\n    async function loadShikiLanguage(language: string, languageModule: any) {\n      if (!shiki) return\n      if (!shiki.getLoadedLanguages().includes(language)) {\n        await shiki.loadLanguage(await languageModule())\n      }\n    }\n    async function loadShikiTheme(theme: string, themeModule: any) {\n      if (!shiki) return\n      if (!shiki.getLoadedThemes().includes(theme)) {\n        await shiki.loadTheme(await themeModule())\n      }\n    }\n\n    async function register() {\n      if (!currentLanguage || !codeTheme) return\n\n      const [{ bundledLanguages }, { bundledThemes }] =\n        langModule && themeModule\n          ? [\n              {\n                bundledLanguages: langModule,\n              },\n              { bundledThemes: themeModule },\n            ]\n          : await Promise.all([import(\"shiki/langs\"), import(\"shiki/themes\")])\n\n      langModule = bundledLanguages\n      themeModule = bundledThemes\n\n      if (\n        currentLanguage &&\n        loadLanguagesRef.current.includes(currentLanguage) &&\n        codeTheme &&\n        loadThemesRef.current.includes(codeTheme)\n      ) {\n        return\n      }\n      return Promise.all([\n        (async () => {\n          if (currentLanguage) {\n            const importFn = (bundledLanguages as any)[currentLanguage]\n            if (!importFn) return\n            await loadShikiLanguage(currentLanguage || \"\", importFn)\n            loadLanguagesRef.current.push(currentLanguage)\n          }\n        })(),\n        (async () => {\n          if (codeTheme) {\n            const importFn = (bundledThemes as any)[codeTheme]\n            if (!importFn) return\n            await loadShikiTheme(codeTheme || \"\", importFn)\n            loadThemesRef.current.push(codeTheme)\n          }\n        })(),\n      ])\n    }\n    register().then(() => {\n      if (isMounted) {\n        setLoaded(true)\n      }\n    })\n    return () => {\n      isMounted = false\n    }\n  }, [codeTheme, currentLanguage])\n\n  if (!loaded) {\n    return (\n      <pre className={cn(\"bg-transparent\", className)}>\n        <code>{code}</code>\n      </pre>\n    )\n  }\n  return <ShikiCode {...props} language={currentLanguage} codeTheme={codeTheme} />\n}\n\nexport const MemoizedShikiCode = memo(ShikiHighLighter)\nconst ShikiCode: FC<\n  ShikiProps & {\n    codeTheme: string\n  }\n> = ({ code, language, codeTheme, className, transparent, showCopy = true }) => {\n  const rendered = useMemo(() => {\n    try {\n      return shiki.codeToHtml(code, {\n        lang: language!,\n        themes: {\n          dark: codeTheme,\n          light: codeTheme,\n        },\n        transformers: shikiTransformers,\n      })\n    } catch {\n      return null\n    }\n  }, [code, language, codeTheme])\n\n  if (!rendered) {\n    return (\n      <pre className={className}>\n        <code>{code}</code>\n      </pre>\n    )\n  }\n\n  return (\n    <div\n      className={cn(\n        \"group relative my-4 overflow-hidden rounded-lg border backdrop-blur-sm\",\n        styles[\"shiki-wrapper\"],\n        transparent ? styles[\"transparent\"] : null,\n        className,\n      )}\n      style={{\n        borderColor: \"hsl(var(--fo-a) / 0.3)\",\n        backgroundColor: \"hsl(var(--fo-background) / 0.6)\",\n      }}\n    >\n      {/* Inner subtle glow */}\n      <div\n        className=\"pointer-events-none absolute inset-0 opacity-50\"\n        style={{\n          background: \"radial-gradient(circle at 50% 0%, hsl(var(--fo-a) / 0.03), transparent 50%)\",\n        }}\n      />\n\n      {/* Compact Header */}\n      <div\n        className=\"relative flex items-center justify-between border-b py-0 pl-3 pr-1\"\n        style={{\n          borderColor: \"hsl(var(--fo-a) / 0.3)\",\n          backgroundColor: \"hsl(var(--fo-a) / 0.05)\",\n        }}\n      >\n        {language === \"plaintext\" ? (\n          <div className=\"h-4\" />\n        ) : (\n          <div className=\"flex items-center gap-1.5 text-xs font-medium uppercase text-accent\">\n            <span className=\"center [&_svg]:size-3.5\">{getLanguageIcon(language)}</span>\n            <span>{language}</span>\n          </div>\n        )}\n\n        <CopyButton\n          variant=\"outline\"\n          value={code}\n          className={cn(\n            \"scale-90 !bg-transparent transition-opacity duration-200\",\n            showCopy ? \"opacity-100\" : \"pointer-events-none opacity-0\",\n          )}\n        />\n      </div>\n\n      {/* Code content */}\n      <div className=\"relative\">\n        <div\n          dangerouslySetInnerHTML={{ __html: rendered }}\n          data-language={language}\n          className=\"relative\"\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/hooks.ts",
    "content": "import { useIsDark } from \"@follow/hooks\"\n\nimport { useUISettingSelector } from \"~/atoms/settings/ui\"\n\nexport const useShikiDefaultTheme = (overrideTheme?: string) => {\n  const isDark = useIsDark()\n  const codeThemeLight = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeLight)\n  const codeThemeDark = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeDark)\n\n  return isDark ? codeThemeDark : codeThemeLight\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/index.ts",
    "content": "export * from \"./Shiki\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shared.ts",
    "content": "import {\n  transformerMetaHighlight,\n  transformerNotationDiff,\n  transformerNotationHighlight,\n} from \"@shikijs/transformers\"\nimport type { ShikiTransformer } from \"shiki\"\nimport { createHighlighterCoreSync, createJavaScriptRegexEngine } from \"shiki\"\n\nexport const shikiTransformers: ShikiTransformer[] = [\n  transformerMetaHighlight(),\n  transformerNotationDiff({ matchAlgorithm: \"v3\" }),\n  transformerNotationHighlight({ matchAlgorithm: \"v3\" }),\n]\n\nconst js = createJavaScriptRegexEngine()\nexport const shiki = createHighlighterCoreSync({\n  themes: [],\n  langs: [],\n  engine: js,\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shiki.module.css",
    "content": ".shiki-wrapper {\n  @apply overflow-hidden;\n\n  pre {\n    @apply bg-transparent;\n  }\n\n  &.transparent {\n    :global {\n      .shiki,\n      code {\n        @apply !bg-transparent;\n      }\n    }\n  }\n\n  :global {\n    .shiki {\n      @apply !m-0 !bg-transparent !px-0;\n\n      font-family:\n        \"OperatorMonoSSmLig Nerd Font\",\n        \"Cascadia Code PL\",\n        \"FantasqueSansMono Nerd Font\",\n        \"Operator Mono\",\n        JetBrainsMono,\n        \"Fira Code Retina\",\n        \"Fira Code\",\n        \"Consolas\",\n        Monaco,\n        \"Hannotate SC\",\n        monospace,\n        -apple-system,\n        system-ui,\n        sans-serif;\n    }\n\n    pre {\n      @apply !m-0 overflow-auto px-4 py-3;\n\n      font-size: 0.875em;\n      line-height: 1.6;\n\n      /* Custom scrollbar */\n      &::-webkit-scrollbar {\n        @apply h-1.5 w-1.5;\n      }\n\n      &::-webkit-scrollbar-track {\n        @apply bg-transparent;\n      }\n\n      &::-webkit-scrollbar-thumb {\n        @apply rounded-full bg-fill-tertiary transition-colors;\n\n        &:hover {\n          @apply bg-fill-secondary;\n        }\n      }\n    }\n\n    pre code {\n      @apply flex flex-col;\n    }\n\n    .line {\n      @apply block px-4 transition-colors duration-100;\n\n      & > span:last-child {\n        @apply mr-4;\n      }\n\n      /* Expand the row without content */\n      &::after {\n        content: \" \";\n      }\n\n      /* Subtle hover effect on lines */\n      &:hover {\n        @apply bg-fill/10;\n      }\n    }\n\n    .highlighted,\n    .diff {\n      @apply relative break-all;\n\n      &::before {\n        @apply absolute left-0 top-0 h-full w-0.5;\n        content: \"\";\n      }\n    }\n\n    .diff.add {\n      @apply bg-green/10;\n\n      &::before {\n        @apply bg-green;\n      }\n\n      &::after {\n        content: \" +\";\n        @apply absolute left-1.5 text-xs font-semibold text-green;\n      }\n    }\n\n    .diff.remove {\n      @apply bg-red/10;\n\n      &::before {\n        @apply bg-red;\n      }\n\n      &::after {\n        content: \" -\";\n        @apply absolute left-1.5 text-xs font-semibold text-red;\n      }\n    }\n\n    .highlighted {\n      @apply bg-accent/10;\n\n      &::before {\n        @apply bg-accent;\n      }\n    }\n  }\n\n  pre {\n    @apply rounded-none;\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/crop/AvatarUploadModal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { DropZone } from \"@follow/components/ui/drop-zone/index.js\"\nimport { useCallback, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\ninterface AvatarUploadModalProps {\n  onConfirm: (blob: Blob) => Promise<void>\n  onCancel: () => void\n  maxSizeKB?: number\n}\n\nexport const AvatarUploadModal = ({\n  onConfirm,\n  onCancel,\n  maxSizeKB = 300,\n}: AvatarUploadModalProps) => {\n  const { t } = useTranslation(\"settings\")\n  const [selectedImage, setSelectedImage] = useState<string | null>(null)\n  const [isProcessing, setIsProcessing] = useState(false)\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const imageRef = useRef<HTMLImageElement>(null)\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  // Crop settings\n  const [cropData, setCropData] = useState({\n    x: 0,\n    y: 0,\n    width: 400,\n    height: 400,\n  })\n  const [isDragging, setIsDragging] = useState(false)\n  const [resizeHandle, setResizeHandle] = useState<string | null>(null)\n  const [dragStart, setDragStart] = useState({\n    x: 0,\n    y: 0,\n    cropX: 0,\n    cropY: 0,\n    cropWidth: 0,\n    cropHeight: 0,\n  })\n\n  // Helper function: ensure the crop data is within the image boundaries and maintain the 1:1 ratio\n  const constrainCropData = useCallback(\n    (newCropData: typeof cropData, imageWidth: number, imageHeight: number) => {\n      const { x, y, width, height } = newCropData\n\n      // Ensure it's a square, use the larger value to avoid shrinking\n      const size = Math.max(width, height)\n\n      // Ensure the minimum size\n      const minSize = 50\n      let finalSize = Math.max(size, minSize)\n\n      // Ensure it's not out of bounds, if it is, shrink it to the appropriate size\n      const maxSize = Math.min(imageWidth, imageHeight)\n      finalSize = Math.min(finalSize, maxSize)\n\n      // Adjust the position to ensure it's within the boundaries\n      const maxX = imageWidth - finalSize\n      const maxY = imageHeight - finalSize\n\n      return {\n        x: Math.max(0, Math.min(x, maxX)),\n        y: Math.max(0, Math.min(y, maxY)),\n        width: finalSize,\n        height: finalSize,\n      }\n    },\n    [],\n  )\n\n  const handleFileSelect = useCallback(\n    (files: FileList) => {\n      const file = files[0]\n      if (!file) return\n\n      if (!file.type.startsWith(\"image/\")) {\n        toast.error(t(\"profile.avatar.invalidFileType\"))\n        return\n      }\n\n      if (file.size > maxSizeKB * 1024) {\n        toast.error(t(\"profile.avatar.fileTooLarge\", { size: `${maxSizeKB}KB` }))\n        return\n      }\n\n      const reader = new FileReader()\n      reader.onload = (e) => {\n        const result = e.target?.result as string\n        setSelectedImage(result)\n      }\n      reader.readAsDataURL(file)\n    },\n    [maxSizeKB, t],\n  )\n\n  const handleImageLoad = useCallback(() => {\n    if (imageRef.current) {\n      const img = imageRef.current\n      // Use the smaller side's 80% as the initial size\n      const maxSize = Math.min(img.naturalWidth, img.naturalHeight)\n      const size = maxSize * 0.8\n\n      const initialCropData = {\n        x: (img.naturalWidth - size) / 2,\n        y: (img.naturalHeight - size) / 2,\n        width: size,\n        height: size,\n      }\n      // Use the helper function to ensure the data is valid\n      const constrainedData = constrainCropData(\n        initialCropData,\n        img.naturalWidth,\n        img.naturalHeight,\n      )\n      setCropData(constrainedData)\n    }\n  }, [constrainCropData])\n\n  const handleCropMouseDown = useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault()\n      setIsDragging(true)\n      setDragStart({\n        x: e.clientX,\n        y: e.clientY,\n        cropX: cropData.x,\n        cropY: cropData.y,\n        cropWidth: cropData.width,\n        cropHeight: cropData.height,\n      })\n    },\n    [cropData],\n  )\n\n  const handleResizeMouseDown = useCallback(\n    (e: React.MouseEvent, handle: string) => {\n      e.preventDefault()\n      e.stopPropagation()\n      setResizeHandle(handle)\n      setDragStart({\n        x: e.clientX,\n        y: e.clientY,\n        cropX: cropData.x,\n        cropY: cropData.y,\n        cropWidth: cropData.width,\n        cropHeight: cropData.height,\n      })\n    },\n    [cropData],\n  )\n\n  const handleCropMouseMove = useCallback(\n    (e: React.MouseEvent) => {\n      if (!isDragging && !resizeHandle) return\n      e.preventDefault()\n\n      if (!imageRef.current || !containerRef.current) return\n\n      const img = imageRef.current\n      const container = containerRef.current\n      const containerRect = container.getBoundingClientRect()\n\n      // Calculate the actual display size and position of the image in the container\n      const containerWidth = containerRect.width\n      const containerHeight = containerRect.height\n      const imageAspectRatio = img.naturalWidth / img.naturalHeight\n      const containerAspectRatio = containerWidth / containerHeight\n\n      let displayWidth = 0,\n        displayHeight = 0\n\n      if (imageAspectRatio > containerAspectRatio) {\n        // The image is wider, use the container width\n        displayWidth = containerWidth\n        displayHeight = containerWidth / imageAspectRatio\n      } else {\n        // The image is taller, use the container height\n        displayHeight = containerHeight\n        displayWidth = containerHeight * imageAspectRatio\n      }\n\n      const scaleX = img.naturalWidth / displayWidth\n      const scaleY = img.naturalHeight / displayHeight\n\n      const deltaX = e.clientX - dragStart.x\n      const deltaY = e.clientY - dragStart.y\n\n      if (resizeHandle) {\n        const { cropX, cropY, cropWidth, cropHeight } = dragStart\n        let newX = cropX\n        let newY = cropY\n        let newWidth = cropWidth\n        let newHeight = cropHeight\n\n        if (resizeHandle.includes(\"r\")) newWidth += deltaX * scaleX\n        if (resizeHandle.includes(\"l\")) {\n          newWidth -= deltaX * scaleX\n          newX += deltaX * scaleX\n        }\n        if (resizeHandle.includes(\"b\")) newHeight += deltaY * scaleY\n        if (resizeHandle.includes(\"t\")) {\n          newHeight -= deltaY * scaleY\n          newY += deltaY * scaleY\n        }\n\n        // Keep the aspect ratio, use the larger change value\n        const size = Math.max(newWidth, newHeight)\n\n        // Update the coordinates based on the position of the resize handle\n        if (resizeHandle.includes(\"t\")) newY = cropY + cropHeight - size\n        if (resizeHandle.includes(\"l\")) newX = cropX + cropWidth - size\n\n        const newCropData = {\n          x: newX,\n          y: newY,\n          width: size,\n          height: size,\n        }\n\n        // Use the helper function to ensure the data is valid\n        const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)\n        setCropData(constrainedData)\n      } else if (isDragging) {\n        const newX = dragStart.cropX + deltaX * scaleX\n        const newY = dragStart.cropY + deltaY * scaleY\n\n        setCropData((prev) => {\n          const newCropData = {\n            ...prev,\n            x: newX,\n            y: newY,\n          }\n          return constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)\n        })\n      }\n    },\n    [isDragging, resizeHandle, dragStart, constrainCropData],\n  )\n\n  const handleCropMouseUp = useCallback(() => {\n    setIsDragging(false)\n    setResizeHandle(null)\n  }, [])\n\n  const cropImage = useCallback((): Promise<Blob> => {\n    return new Promise((resolve, reject) => {\n      if (!imageRef.current || !canvasRef.current) {\n        reject(new Error(\"Image or canvas not available\"))\n        return\n      }\n\n      const canvas = canvasRef.current\n      const ctx = canvas.getContext(\"2d\")\n      if (!ctx) {\n        reject(new Error(\"Canvas context not available\"))\n        return\n      }\n\n      const img = imageRef.current\n      canvas.width = 400\n      canvas.height = 400\n\n      ctx.drawImage(img, cropData.x, cropData.y, cropData.width, cropData.height, 0, 0, 400, 400)\n\n      canvas.toBlob(\n        (blob) => {\n          if (blob) {\n            resolve(blob)\n          } else {\n            reject(new Error(\"Failed to create blob\"))\n          }\n        },\n        \"image/jpeg\",\n        0.9,\n      )\n    })\n  }, [cropData])\n\n  // Preset functions\n  const handleFullImageCrop = useCallback(() => {\n    if (!imageRef.current) return\n\n    const img = imageRef.current\n    const size = Math.min(img.naturalWidth, img.naturalHeight)\n\n    const newCropData = {\n      x: (img.naturalWidth - size) / 2,\n      y: (img.naturalHeight - size) / 2,\n      width: size,\n      height: size,\n    }\n\n    const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)\n    setCropData(constrainedData)\n  }, [constrainCropData])\n\n  const handleCenterCrop = useCallback(() => {\n    if (!imageRef.current) return\n\n    const img = imageRef.current\n    const maxSize = Math.min(img.naturalWidth, img.naturalHeight)\n    const size = maxSize * 0.8\n\n    const newCropData = {\n      x: (img.naturalWidth - size) / 2,\n      y: (img.naturalHeight - size) / 2,\n      width: size,\n      height: size,\n    }\n\n    const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)\n    setCropData(constrainedData)\n  }, [constrainCropData])\n\n  const handleConfirm = useCallback(async () => {\n    if (!selectedImage) return\n\n    try {\n      setIsProcessing(true)\n      const blob = await cropImage()\n      await onConfirm(blob)\n    } catch (error) {\n      console.error(\"Error processing image:\", error)\n      toast.error(t(\"profile.avatar.processingError\"))\n    } finally {\n      setIsProcessing(false)\n    }\n  }, [selectedImage, cropImage, onConfirm, t])\n\n  const cropStyle = useMemo(() => {\n    if (!imageRef.current || !containerRef.current) return {}\n\n    const img = imageRef.current\n    const container = containerRef.current\n    const containerRect = container.getBoundingClientRect()\n\n    // Calculate the actual display size and position of the image in the container\n    const containerWidth = containerRect.width\n    const containerHeight = containerRect.height\n    const imageAspectRatio = img.naturalWidth / img.naturalHeight\n    const containerAspectRatio = containerWidth / containerHeight\n\n    let displayWidth = 0,\n      displayHeight = 0,\n      offsetX = 0,\n      offsetY = 0\n\n    if (imageAspectRatio > containerAspectRatio) {\n      // The image is wider, use the container width\n      displayWidth = containerWidth\n      displayHeight = containerWidth / imageAspectRatio\n      offsetX = 0\n      offsetY = (containerHeight - displayHeight) / 2\n    } else {\n      // The image is taller, use the container height\n      displayHeight = containerHeight\n      displayWidth = containerHeight * imageAspectRatio\n      offsetX = (containerWidth - displayWidth) / 2\n      offsetY = 0\n    }\n\n    // Calculate the scale ratio\n    const scaleX = displayWidth / img.naturalWidth\n    const scaleY = displayHeight / img.naturalHeight\n\n    return {\n      left: `${offsetX + cropData.x * scaleX}px`,\n      top: `${offsetY + cropData.y * scaleY}px`,\n      width: `${cropData.width * scaleX}px`,\n      height: `${cropData.height * scaleY}px`,\n    }\n  }, [cropData])\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      {!selectedImage ? (\n        <div className=\"aspect-square h-[400px] space-y-4\">\n          <DropZone\n            id=\"upload-avatar\"\n            onDrop={handleFileSelect}\n            accept=\"image/*\"\n            className=\"size-full\"\n          >\n            <div className=\"flex flex-col items-center gap-2 p-8\">\n              <i className=\"i-mgc-file-upload-cute-re text-4xl text-text-secondary\" />\n              <div className=\"text-center\">\n                <p className=\"text-sm font-medium\">{t(\"profile.avatar.dropZoneText\")}</p>\n                <p className=\"text-xs text-text-secondary\">{t(\"profile.avatar.dropZoneSubtext\")}</p>\n              </div>\n            </div>\n          </DropZone>\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          <div\n            ref={containerRef}\n            className=\"relative mx-auto size-[400px] select-none overflow-hidden rounded-lg border bg-gray-100 dark:bg-zinc-800\"\n            onMouseMove={handleCropMouseMove}\n            onMouseUp={handleCropMouseUp}\n            onMouseLeave={handleCropMouseUp}\n          >\n            <img\n              ref={imageRef}\n              src={selectedImage}\n              alt=\"Preview\"\n              className=\"size-full object-contain\"\n              draggable={false}\n              onLoad={handleImageLoad}\n            />\n\n            {/* Crop overlay */}\n            <div\n              className=\"absolute rounded-full\"\n              style={{\n                ...cropStyle,\n                boxShadow: \"0 0 0 9999px rgba(0, 0, 0, 0.3)\",\n              }}\n            />\n            <div\n              className=\"absolute\"\n              style={{\n                ...cropStyle,\n                boxShadow: \"0 0 0 9999px rgba(0, 0, 0, 0.3)\",\n              }}\n            >\n              <div className=\"size-full cursor-move\" onMouseDown={handleCropMouseDown}>\n                {/* Grid lines */}\n                <div className=\"absolute left-1/3 top-0 h-full w-px bg-material-medium-light\" />\n                <div className=\"absolute left-2/3 top-0 h-full w-px bg-material-medium-light\" />\n                <div className=\"absolute left-0 top-1/3 h-px w-full bg-material-medium-light\" />\n                <div className=\"absolute left-0 top-2/3 h-px w-full bg-material-medium-light\" />\n\n                {/* Resize handles */}\n                <div\n                  className=\"absolute -left-1 -top-1 size-3 cursor-nwse-resize rounded-full border-2 border-white bg-accent\"\n                  onMouseDown={(e) => handleResizeMouseDown(e, \"tl\")}\n                />\n                <div\n                  className=\"absolute -right-1 -top-1 size-3 cursor-nesw-resize rounded-full border-2 border-white bg-accent\"\n                  onMouseDown={(e) => handleResizeMouseDown(e, \"tr\")}\n                />\n                <div\n                  className=\"absolute -bottom-1 -left-1 size-3 cursor-nesw-resize rounded-full border-2 border-white bg-accent\"\n                  onMouseDown={(e) => handleResizeMouseDown(e, \"bl\")}\n                />\n                <div\n                  className=\"absolute -bottom-1 -right-1 size-3 cursor-nwse-resize rounded-full border-2 border-white bg-accent\"\n                  onMouseDown={(e) => handleResizeMouseDown(e, \"br\")}\n                />\n              </div>\n            </div>\n          </div>\n\n          <div className=\"text-center text-sm text-text-secondary\">\n            {t(\"profile.avatar.cropInstructions\")}\n          </div>\n        </div>\n      )}\n\n      <canvas ref={canvasRef} className=\"hidden\" />\n\n      <div className=\"flex justify-between gap-2\">\n        {selectedImage ? (\n          <div className=\"flex gap-2\">\n            <Button variant=\"outline\" onClick={handleFullImageCrop} size=\"sm\">\n              <i className=\"i-mgc-fullscreen-cute-re mr-1 text-sm\" />\n              Full Image\n            </Button>\n            <Button variant=\"outline\" onClick={handleCenterCrop} size=\"sm\">\n              <i className=\"i-mgc-round-cute-re mr-1 text-sm\" />\n              Center Crop\n            </Button>\n          </div>\n        ) : (\n          <div className=\"flex-1\" />\n        )}\n        <div className=\"flex items-center gap-2\">\n          <Button variant=\"outline\" onClick={onCancel}>\n            {t(\"words.cancel\", { ns: \"common\" })}\n          </Button>\n          <Button onClick={handleConfirm} disabled={!selectedImage} isLoading={isProcessing}>\n            {t(\"words.confirm\", { ns: \"common\" })}\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/datetime/index.tsx",
    "content": "import { getUpdateInterval } from \"@follow/components/ui/datetime/utils.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport dayjs from \"dayjs\"\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useGeneralSettingSelector } from \"~/atoms/settings/general\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\n\nexport { RelativeTime } from \"@follow/components/ui/datetime/index.js\"\nexport const RelativeDay = ({ date }: { date: Date }) => {\n  const { t } = useTranslation(\"common\")\n  const language = useGeneralSettingSelector((s) => s.language)\n\n  const dateFormatValue = useUISettingKey(\"dateFormat\")\n  const formatTemplateString = \"lll\"\n  const dateFormat = dateFormatValue === \"default\" ? formatTemplateString : dateFormatValue\n\n  const formatDateString = useCallback(\n    (date: Date) => {\n      const now = new Date()\n\n      // Remove the time part for comparison\n      const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n      const inputDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())\n\n      const diffTime = nowDate.getTime() - inputDate.getTime()\n      const diffDays = diffTime / (1000 * 3600 * 24)\n\n      if (diffDays === 0) {\n        return t(\"time.today\")\n      } else if (diffDays === 1) {\n        return t(\"time.yesterday\")\n      } else {\n        return dayjs(date).format(\"ll\")\n      }\n    },\n    [t],\n  )\n\n  const timerRef = useRef<any>(null)\n  const [dateString, setDateString] = useState<string>(() => formatDateString(date))\n\n  useEffect(() => {\n    const updateInterval = getUpdateInterval(date, 3)\n\n    if (updateInterval !== null) {\n      timerRef.current = setTimeout(() => {\n        setDateString(formatDateString(date))\n      }, updateInterval)\n    }\n    setDateString(formatDateString(date))\n\n    return () => {\n      timerRef.current = clearTimeout(timerRef.current)\n    }\n  }, [date, formatDateString, language])\n\n  const formated = useMemo(() => dayjs(date).format(dateFormat), [dateFormat, date])\n\n  if (formated === dateString) {\n    return <>{dateString}</>\n  }\n  return (\n    <Tooltip>\n      <TooltipTrigger onFocusCapture={stopPropagation}>{dateString}</TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent>{formated}</TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx",
    "content": "import { useSetGlobalFocusableScope } from \"@follow/components/common/Focusable/hooks.js\"\nimport { Divider } from \"@follow/components/ui/divider/Divider.js\"\nimport { Kbd } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport * as React from \"react\"\n\nimport { HotkeyScope } from \"~/constants\"\n\nconst styles = {\n  content: {\n    backgroundImage:\n      \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n    boxShadow:\n      \"0 6px 20px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px hsl(var(--fo-a) / 0.04), 0 1px 3px rgba(0, 0, 0, 0.03)\",\n  } as React.CSSProperties,\n  innerGlow: {\n    background:\n      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.01), transparent, hsl(var(--fo-a) / 0.01))\",\n  } as React.CSSProperties,\n}\n\nconst DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {\n  const setGlobalFocusableScope = useSetGlobalFocusableScope()\n  return (\n    <DropdownMenuPrimitive.Root\n      {...props}\n      onOpenChange={useTypeScriptHappyCallback(\n        (open) => {\n          if (open) {\n            setGlobalFocusableScope(HotkeyScope.DropdownMenu, \"append\")\n          } else {\n            setGlobalFocusableScope(HotkeyScope.DropdownMenu, \"remove\")\n          }\n\n          props.onOpenChange?.(open)\n        },\n        [props.onOpenChange],\n      )}\n    />\n  )\n}\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nconst DropdownMenuSubTrigger = ({\n  ref,\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger> | null>\n}) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none focus:bg-accent/30 data-[state=open]:bg-accent/30\",\n      inset && \"pl-8\",\n      \"center gap-2\",\n      className,\n      props.disabled && \"cursor-not-allowed opacity-30\",\n    )}\n    {...props}\n  >\n    {children}\n    <i className=\"i-mingcute-right-line -mr-1 ml-auto size-3.5\" />\n  </DropdownMenuPrimitive.SubTrigger>\n)\nDropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName\n\nconst DropdownMenuSubContent = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.SubContent> | null>\n}) => (\n  <RootPortal>\n    <DropdownMenuPrimitive.SubContent\n      ref={ref}\n      className={cn(\n        \"text-body text-text\",\n        \"min-w-32 overflow-hidden\",\n        \"rounded-[6px] p-1\",\n        \"backdrop-blur-2xl\",\n        \"z-[61]\",\n        \"relative\",\n        \"dark:border dark:border-border/50\",\n        className,\n      )}\n      style={styles.content}\n      {...props}\n    >\n      {/* Inner glow layer */}\n      <div\n        className=\"pointer-events-none absolute inset-0 rounded-[6px]\"\n        style={styles.innerGlow}\n      />\n      {/* Content wrapper */}\n      <div className=\"relative\">{props.children}</div>\n    </DropdownMenuPrimitive.SubContent>\n  </RootPortal>\n)\nDropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName\n\nconst DropdownMenuContent = ({\n  ref,\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Content> | null>\n}) => {\n  return (\n    <RootPortal>\n      <DropdownMenuPrimitive.Content\n        ref={ref}\n        sideOffset={sideOffset}\n        className={cn(\n          \"z-[60] min-w-32 overflow-hidden rounded-[6px] p-1 text-text\",\n          \"backdrop-blur-2xl\",\n          \"text-body motion-scale-in-75 motion-duration-150 lg:animate-none\",\n          \"relative\",\n          \"dark:border dark:border-border/50\",\n          className,\n        )}\n        style={styles.content}\n        {...props}\n      >\n        {/* Inner glow layer */}\n        <div\n          className=\"pointer-events-none absolute inset-0 rounded-[6px]\"\n          style={styles.innerGlow}\n        />\n        {/* Content wrapper */}\n        <div className=\"relative\">{props.children}</div>\n      </DropdownMenuPrimitive.Content>\n    </RootPortal>\n  )\n}\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName\n\nconst DropdownMenuItem = ({\n  ref,\n  className,\n  inset,\n  icon,\n  active,\n\n  shortcut,\n  checked,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  icon?: React.ReactNode | ((props?: { isActive?: boolean }) => React.ReactNode)\n  active?: boolean\n\n  shortcut?: string\n  checked?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Item> | null> }) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      \"focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background/accent-9/1\",\n      \"h-[28px]\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  >\n    {!!icon && (\n      <span className=\"mr-1.5 inline-flex size-4 items-center justify-center\">\n        {typeof icon === \"function\" ? icon({ isActive: active }) : icon}\n      </span>\n    )}\n    {props.children}\n\n    {/* Justify Fill */}\n    {!!icon && <span className=\"ml-1.5 size-4\" />}\n    {!!shortcut && (\n      <>\n        <span className=\"ml-4\" />\n        <Kbd wrapButton={false} className=\"ml-auto\">\n          {shortcut}\n        </Kbd>\n      </>\n    )}\n    {checked && !shortcut && (\n      <>\n        <span className=\"ml-4\" />\n        <span className=\"ml-auto inline-flex size-4 items-center justify-center\">\n          <i className=\"i-mgc-check-filled size-3\" />\n        </span>\n      </>\n    )}\n  </DropdownMenuPrimitive.Item>\n)\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName\n\nconst DropdownMenuCheckboxItem = ({\n  ref,\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem> | null>\n}) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-checkbox select-none items-center rounded-[5px] px-8 py-1.5 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      \"focus-within:outline-transparent\",\n      \"h-[28px]\",\n      className,\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator asChild>\n        <i className=\"i-mgc-check-filled size-3\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n)\nDropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName\n\nconst DropdownMenuLabel = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Label> | null> }) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 font-semibold text-text\", inset && \"pl-8\", className)}\n    {...props}\n  />\n)\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName\n\nconst DropdownMenuSeparator = ({\n  ref,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Separator> | null>\n}) => (\n  <DropdownMenuPrimitive.Separator\n    className=\"mx-2 my-1 h-px backdrop-blur-background\"\n    asChild\n    ref={ref}\n    {...props}\n  >\n    <Divider />\n  </DropdownMenuPrimitive.Separator>\n)\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName\n\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n  <span className={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)} {...props} />\n)\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\"\n\nexport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuPortal,\n  DropdownMenuRadioGroup,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/fab/FABContainer.tsx",
    "content": "import { RootPortal } from \"@follow/components/ui/portal/index.jsx\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport { atom, useAtomValue } from \"jotai\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport { AnimatePresence } from \"motion/react\"\nimport type * as React from \"react\"\nimport type { FC, JSX, PropsWithChildren, ReactNode } from \"react\"\nimport { useId } from \"react\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { jotaiStore } from \"~/lib/jotai\"\n\nconst fabContainerElementAtom = atom(null as HTMLDivElement | null)\n\nexport interface FABConfig {\n  id: string\n  icon: JSX.Element\n  onClick: () => void\n}\n\nexport const FABBase: FC<\n  PropsWithChildren<\n    {\n      id: string\n      show?: boolean\n      children: JSX.Element\n      ref?: React.Ref<HTMLButtonElement>\n    } & HTMLMotionProps<\"button\">\n  >\n> = (props) => {\n  const { children, show = true, ref, ...extra } = props\n  const { className, ...rest } = extra\n\n  return (\n    <AnimatePresence>\n      {show && (\n        <m.button\n          type=\"button\"\n          initial={{ scale: 0, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          exit={{ scale: 0, opacity: 0 }}\n          transition={{\n            duration: 0.2,\n            ease: \"easeInOut\",\n          }}\n          ref={ref}\n          aria-label=\"Floating action button\"\n          className={cn(\n            \"mt-2 flex items-center justify-center\",\n            \"size-9 text-lg md:text-base\",\n            \"outline-accent hover:opacity-100 focus:opacity-100 focus:outline-none\",\n            \"relative rounded-xl border border-transparent bg-background hover:border-border\",\n            \"group duration-200\",\n            className,\n          )}\n          {...rest}\n        >\n          <div className=\"shadow-perfect pointer-events-none absolute inset-0 rounded-xl border border-border/50 shadow-xl duration-200 group-hover:opacity-0\" />\n          {children}\n        </m.button>\n      )}\n    </AnimatePresence>\n  )\n}\n\nexport const FABPortable: FC<\n  PropsWithChildren<{\n    children: React.JSX.Element\n    onClick: () => void\n    show?: boolean\n    ref?: React.Ref<HTMLButtonElement>\n  }>\n> = (props) => {\n  const { onClick, children, show = true, ref } = props\n  const id = useId()\n  const portalElement = useAtomValue(fabContainerElementAtom)\n\n  if (!portalElement) return null\n\n  return (\n    <RootPortal to={portalElement}>\n      <FABBase ref={ref} id={id} show={show} onClick={onClick}>\n        {children}\n      </FABBase>\n    </RootPortal>\n  )\n}\n\nexport const FABContainer = (props: { children?: ReactNode }) => {\n  return (\n    <div\n      ref={useTypeScriptHappyCallback((ref) => jotaiStore.set(fabContainerElementAtom, ref), [])}\n      data-testid=\"fab-container\"\n      data-hide-print\n      className={clsx(\n        \"fixed bottom-[calc(2rem+env(safe-area-inset-bottom))] left-[calc(100vw-3rem-1rem)] z-[9] flex flex-col\",\n        \"transition-transform duration-300 ease-in-out\",\n      )}\n    >\n      {props.children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/fab/index.ts",
    "content": "export * from \"./FABContainer\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/hover-preview/EntryPreviewCard.tsx",
    "content": "import {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@follow/components/ui/hover-card/index.js\"\nimport { useEntry, usePrefetchEntryDetail } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { feedIconSelector } from \"@follow/store/feed/selectors\"\nimport { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\n\ninterface EntryPreviewCardProps {\n  entryId: string\n  children: React.ReactNode\n  className?: string\n  onNavigate?: (entryId: string) => void\n}\n\nexport const EntryPreviewCard: React.FC<EntryPreviewCardProps> = ({\n  entryId,\n  children,\n  className,\n  onNavigate,\n}) => {\n  // Prefetch entry details on hover\n  usePrefetchEntryDetail(entryId)\n\n  const entry = useEntry(entryId, (state) => {\n    if (!state) return null\n    return {\n      title: state.title,\n      description: state.description,\n      author: state.author,\n      publishedAt: state.publishedAt,\n      feedId: state.feedId,\n      url: state.url,\n    }\n  })\n\n  const feed = useFeedById(entry?.feedId, feedIconSelector)\n\n  if (!entry || !feed) {\n    return <>{children}</>\n  }\n\n  return (\n    <HoverCard openDelay={300} closeDelay={100}>\n      <HoverCardTrigger asChild>{children}</HoverCardTrigger>\n      <HoverCardContent className=\"w-80 p-0\" side=\"top\">\n        <m.div\n          initial={{ opacity: 0, scale: 0.95 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={{ duration: 0.15 }}\n          className={cn(\"overflow-hidden\", className)}\n        >\n          {/* Header */}\n          <div className=\"border-b border-border bg-fill-tertiary p-3\">\n            <a\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              href={feed.siteUrl || feed.url}\n              className=\"flex items-center gap-2\"\n            >\n              <FeedIcon target={feed} size={16} />\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"line-clamp-1 text-sm font-medium text-text\">{feed.title}</div>\n                <span className=\"line-clamp-1 text-xs text-text-tertiary\">{feed.url}</span>\n              </div>\n            </a>\n          </div>\n\n          {/* Content */}\n          <div className=\"p-3\">\n            <div className=\"space-y-2\">\n              <a\n                href={entry.url ?? \"#\"}\n                onClick={(e) => {\n                  e.preventDefault()\n                  onNavigate?.(entryId)\n                }}\n                className=\"contents\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n              >\n                <h3 className=\"line-clamp-2 text-sm font-semibold text-text\">{entry.title}</h3>\n\n                {entry.description && (\n                  <p className=\"line-clamp-3 text-xs text-text-secondary\">{entry.description}</p>\n                )}\n              </a>\n\n              <div className=\"flex items-center justify-between pt-2\">\n                <div className=\"text-xs text-text-tertiary\">\n                  {entry.author && <span>by {entry.author}</span>}\n                </div>\n                <div className=\"shrink-0 self-start text-xs text-text-tertiary\">\n                  <RelativeTime date={entry.publishedAt} />\n                </div>\n              </div>\n            </div>\n          </div>\n        </m.div>\n      </HoverCardContent>\n    </HoverCard>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/hover-preview/FeedPreviewCard.tsx",
    "content": "import {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@follow/components/ui/hover-card/index.js\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { feedIconSelector } from \"@follow/store/feed/selectors\"\nimport { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\n\ninterface FeedPreviewCardProps {\n  feedId: string\n  children: React.ReactNode\n  className?: string\n\n  onNavigate?: (feedId: string) => void\n}\n\nexport const FeedPreviewCard: React.FC<FeedPreviewCardProps> = ({\n  feedId,\n  children,\n  className,\n  onNavigate,\n}) => {\n  const feed = useFeedById(feedId, feedIconSelector)\n\n  if (!feed) {\n    return <>{children}</>\n  }\n\n  return (\n    <HoverCard openDelay={300} closeDelay={100}>\n      <HoverCardTrigger asChild>{children}</HoverCardTrigger>\n      <HoverCardContent className=\"w-80 p-0\" side=\"top\">\n        <m.div\n          initial={{ opacity: 0, scale: 0.95 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={{ duration: 0.15 }}\n          className={cn(\"overflow-hidden\", className)}\n        >\n          {/* Header */}\n          <a\n            className=\"p-4\"\n            href={feed.url ?? \"#\"}\n            onClick={(e) => {\n              e.preventDefault()\n              onNavigate?.(feedId)\n            }}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            <div className=\"flex items-start gap-3 pl-4\">\n              <FeedIcon target={feed} size={32} className=\"shrink-0\" />\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex items-center gap-2\">\n                  <h3 className=\"line-clamp-1 text-sm font-semibold text-text\">{feed.title}</h3>\n                </div>\n              </div>\n            </div>\n          </a>\n        </m.div>\n      </HoverCardContent>\n    </HoverCard>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/hover-preview/index.ts",
    "content": "export { EntryPreviewCard } from \"./EntryPreviewCard\"\nexport { FeedPreviewCard } from \"./FeedPreviewCard\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/KeyRecorder.tsx",
    "content": "import { useReplaceGlobalFocusableScope } from \"@follow/components/common/Focusable/hooks.js\"\nimport { KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { sortShortcutKeys } from \"@follow/utils/utils\"\nimport type { FC, RefObject, SVGProps } from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useOnClickOutside } from \"usehooks-ts\"\n\nimport { HotkeyScope } from \"~/constants\"\n\nexport interface KeyRecorderProps {\n  onChange: (keys: string[] | null) => void\n  onBlur: () => void\n}\n\nexport const KeyRecorder: FC<KeyRecorderProps> = ({ onChange, onBlur }) => {\n  const { t } = useTranslation(\"shortcuts\")\n  const { currentKeys } = useShortcutRecorder()\n  const setGlobalScope = useReplaceGlobalFocusableScope()\n\n  const ref = useRef<HTMLDivElement>(null)\n  useEffect(() => {\n    const { rollback } = setGlobalScope(HotkeyScope.Recording)\n    if (ref.current) {\n      ref.current.focus()\n    }\n    return () => {\n      rollback()\n    }\n  }, [setGlobalScope])\n  useOnClickOutside(ref as RefObject<HTMLElement>, () => {\n    if (currentKeys.length > 0) {\n      onChange(currentKeys)\n    }\n    onBlur()\n  })\n  return (\n    <div\n      className=\"relative flex size-full items-center justify-center px-1 text-xs text-text-secondary\"\n      tabIndex={-1}\n      role=\"textbox\"\n      ref={ref}\n    >\n      {currentKeys.length > 0 ? (\n        <div className=\"pr-4\">\n          <KbdCombined kbdProps={{ wrapButton: false }} joint={false}>\n            {currentKeys.join(\"+\")}\n          </KbdCombined>\n        </div>\n      ) : (\n        <span className=\"pr-4 text-text-secondary\">{t(\"settings.shortcuts.press_to_record\")}</span>\n      )}\n      <Tooltip delayDuration={0}>\n        <TooltipTrigger asChild>\n          <button\n            type=\"button\"\n            className=\"absolute inset-y-0 -right-1 z-[1] flex items-center justify-center px-1 hover:text-text\"\n            onClick={(e) => {\n              e.stopPropagation()\n              if (currentKeys.length === 0) {\n                onChange(null)\n              } else {\n                onBlur()\n              }\n            }}\n          >\n            {currentKeys.length > 0 ? (\n              <FamiconsArrowUndoCircle className=\"size-4\" />\n            ) : (\n              <i className=\"i-mingcute-close-circle-fill size-4\" />\n            )}\n          </button>\n        </TooltipTrigger>\n        <TooltipContent>\n          {currentKeys.length > 0 ? t(\"settings.shortcuts.undo\") : t(\"settings.shortcuts.reset\")}\n        </TooltipContent>\n      </Tooltip>\n    </div>\n  )\n}\n\nfunction FamiconsArrowUndoCircle(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"1em\"\n      height=\"1em\"\n      viewBox=\"0 0 512 512\"\n      {...props}\n    >\n      {/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */}\n      <path\n        fill=\"currentColor\"\n        d=\"M256 48C141.13 48 48 141.13 48 256s93.13 208 208 208s208-93.13 208-208S370.87 48 256 48m97.67 281.1c-24.07-25.21-51.51-38.68-108.58-38.68v37.32a8.32 8.32 0 0 1-14.05 6L146.58 254a8.2 8.2 0 0 1 0-11.94L231 162.29a8.32 8.32 0 0 1 14.05 6v37.32c88.73 0 117.42 55.64 122.87 117.09c.73 7.72-8.85 12.05-14.25 6.4\"\n      />\n    </svg>\n  )\n}\n\nconst MODIFIER_KEYS_MAP = {\n  Control: \"Control\",\n  Alt: \"Alt\",\n  Shift: \"Shift\",\n  Meta: \"Meta\",\n} as const\n\nconst MODIFIER_KEYS_SET = new Set<string>(Object.values(MODIFIER_KEYS_MAP))\n\nconst F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/\n\nconst useShortcutRecorder = () => {\n  const [currentKeys, setCurrentKeys] = useState<string[]>([])\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      event.preventDefault()\n      event.stopPropagation()\n      event.stopImmediatePropagation()\n\n      const { altKey, ctrlKey, metaKey, shiftKey, key: eventKey } = event\n\n      let mainKeyPressed = eventKey\n\n      if (mainKeyPressed.length === 1 && mainKeyPressed >= \"a\" && mainKeyPressed <= \"z\") {\n        mainKeyPressed = mainKeyPressed.toUpperCase()\n      } else if (mainKeyPressed === \" \") {\n        mainKeyPressed = \"Space\"\n      }\n\n      const pressedKeysSet = new Set<string>()\n\n      if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)\n      if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta)\n      if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control)\n      if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt)\n\n      // If mainKeyPressed (from event.key) is not a modifier key, add it as the main key.\n      // If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is \"Shift\"),\n      // it has already been handled and added to pressedKeysSet by the above if (shiftKey) logic,\n      // so we don't need to add it again here.\n      if (!MODIFIER_KEYS_SET.has(mainKeyPressed)) {\n        pressedKeysSet.add(mainKeyPressed)\n      }\n\n      const currentCombination = Array.from(pressedKeysSet)\n\n      // --- Start validation rules ---\n      const nonModifierKeysInCombo = currentCombination.filter((key) => !MODIFIER_KEYS_SET.has(key))\n\n      // Rule 2: Pure modifier key combinations are not allowed (e.g., just Shift, or Ctrl+Alt)\n      if (nonModifierKeysInCombo.length === 0) {\n        // When only modifier keys are pressed, currentCombination will still contain these modifiers.\n        // For example, pressing only Shift, currentCombination is [\"Shift\"]\n        // Here we don't update the state, indicating this is an invalid recording.\n        // You can provide temporary UI feedback here, e.g.: \"Recording: Shift\"\n        console.info(\n          \"Recording (invalid - modifiers only):\",\n          sortShortcutKeys(currentCombination).join(\" + \"),\n        )\n        return\n      }\n\n      // Typically shortcuts have only one \"main\" function key (e.g., Ctrl+A, Shift+F1)\n      // If multiple non-modifier keys are detected (e.g., theoretically user pressing A and B simultaneously),\n      // this is usually not a standard shortcut recording scenario\n      // This check is mainly for code robustness, as `keydown` events typically focus on one main key at a time.\n      if (nonModifierKeysInCombo.length > 1) {\n        console.warn(\n          \"Recording (invalid - multiple main keys, this shouldn't normally happen):\",\n          sortShortcutKeys(currentCombination).join(\" + \"),\n        )\n\n        return\n      }\n\n      const primaryKey = nonModifierKeysInCombo[0]\n\n      // Rule 3: Fn keys (F1-F12) can be single keys or modifier+Fn key combinations\n      if (F_KEY_REGEX.test(primaryKey ?? \"\")) {\n        setCurrentKeys(sortShortcutKeys(currentCombination))\n        return\n      }\n\n      // Rule 1: Single \"ASCII\" main keys are allowed (here referring to all non-modifier, non-F keys)\n      // Examples: A, 1, Space, Enter, ArrowUp, etc. They can be used alone or with modifiers.\n      // For these keys, as long as they're not pure modifier combinations, they're considered valid.\n      setCurrentKeys(sortShortcutKeys(currentCombination))\n    }\n\n    window.addEventListener(\"keydown\", handleKeyDown)\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown)\n    }\n  }, [setCurrentKeys])\n  return { currentKeys }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/index.ts",
    "content": "export * from \"./KeyRecorder\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/HTML.tsx",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.js\"\nimport katexStyle from \"katex/dist/katex.min.css?raw\"\nimport {\n  createElement,\n  Fragment,\n  memo,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useState,\n} from \"react\"\nimport type { JSX } from \"react/jsx-runtime\"\n\nimport { ENTRY_CONTENT_RENDER_CONTAINER_ID } from \"~/constants/dom\"\nimport { parseHtml } from \"~/lib/parse-html\"\nimport { useWrappedElementSize } from \"~/providers/wrapped-element-provider\"\n\nimport { MediaContainerWidthProvider } from \"../media/MediaContainerWidthProvider\"\nimport type { MediaInfoRecord } from \"../media/MediaInfoRecord\"\nimport { MediaInfoRecordProvider } from \"../media/MediaInfoRecordProvider\"\nimport { MarkdownRenderContainerRefContext } from \"./context\"\n\nexport type HTMLProps<A extends keyof JSX.IntrinsicElements = \"div\"> = {\n  children: string | null | undefined\n  as: A\n\n  accessory?: React.ReactNode\n  noMedia?: boolean\n  mediaInfo?: Nullable<MediaInfoRecord>\n} & JSX.IntrinsicElements[A] &\n  Partial<{\n    renderInlineStyle: boolean\n  }>\nconst HTMLImpl = <A extends keyof JSX.IntrinsicElements = \"div\">(props: HTMLProps<A>) => {\n  const {\n    children,\n    renderInlineStyle,\n    as = \"div\",\n    accessory,\n    noMedia,\n    mediaInfo,\n    ref,\n    ...rest\n  } = props\n  const [remarkOptions, setRemarkOptions] = useState({\n    renderInlineStyle,\n    noMedia,\n  })\n  const [shouldForceReMountKey, setShouldForceReMountKey] = useState(0)\n\n  useEffect(() => {\n    setRemarkOptions((options) => {\n      if (JSON.stringify(options) === JSON.stringify({ renderInlineStyle, noMedia })) {\n        return options\n      }\n\n      setShouldForceReMountKey((key) => key + 1)\n      return { ...options, renderInlineStyle, noMedia }\n    })\n  }, [renderInlineStyle, noMedia])\n\n  const [refElement, setRefElement] = useState<HTMLElement | null>(null)\n  useImperativeHandle(ref as any, () => refElement)\n\n  const markdownElement = useMemo(\n    () =>\n      children &&\n      parseHtml(children, {\n        ...remarkOptions,\n      }).toContent(),\n    [children, remarkOptions],\n  )\n\n  const { w: containerWidth } = useWrappedElementSize()\n\n  if (!markdownElement) return null\n  return (\n    <MarkdownRenderContainerRefContext value={refElement}>\n      <MediaContainerWidthProvider width={containerWidth}>\n        <MediaInfoRecordProvider mediaInfo={mediaInfo}>\n          <MemoedDangerousHTMLStyle>{katexStyle}</MemoedDangerousHTMLStyle>\n          {createElement(\n            as,\n            {\n              ...rest,\n              id: ENTRY_CONTENT_RENDER_CONTAINER_ID,\n              ref: setRefElement,\n            },\n            markdownElement,\n          )}\n        </MediaInfoRecordProvider>\n      </MediaContainerWidthProvider>\n      {!!accessory && <Fragment key={shouldForceReMountKey}>{accessory}</Fragment>}\n    </MarkdownRenderContainerRefContext>\n  )\n}\n\nexport const HTML = memo(HTMLImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/Markdown.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { useMemo, useState } from \"react\"\n\nimport type { RemarkOptions } from \"~/lib/parse-markdown\"\nimport { parseMarkdown } from \"~/lib/parse-markdown\"\n\nimport { MarkdownRenderContainerRefContext } from \"./context\"\n\nexport const Markdown: Component<\n  {\n    children: string\n  } & Partial<RemarkOptions>\n> = ({ children, components, className, applyMiddleware }) => {\n  const stableRemarkOptions = useState({ components, applyMiddleware })[0]\n\n  const markdownElement = useMemo(\n    () => parseMarkdown(children, { ...stableRemarkOptions }).content,\n    [children, stableRemarkOptions],\n  )\n  const [refElement, setRefElement] = useState<HTMLElement | null>(null)\n\n  return (\n    <MarkdownRenderContainerRefContext value={refElement}>\n      <article\n        className={cn(\n          \"prose relative cursor-auto select-text dark:prose-invert prose-th:text-left\",\n          className,\n        )}\n        ref={setRefElement}\n      >\n        {markdownElement}\n      </article>\n    </MarkdownRenderContainerRefContext>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/components/Toc.tsx",
    "content": "import { useViewport } from \"@follow/components/hooks/useViewport.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as HoverCard from \"@radix-ui/react-hover-card\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { memo, use, useCallback, useEffect, useImperativeHandle, useRef, useState } from \"react\"\n\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport {\n  useWrappedElementPosition,\n  useWrappedElementSize,\n} from \"~/providers/wrapped-element-provider\"\n\nimport { MarkdownRenderContainerRefContext } from \"../context\"\nimport { useScrollTracking, useTocItems } from \"./hooks\"\nimport type { TocItemProps } from \"./TocItem\"\nimport { TocItem } from \"./TocItem\"\n\nexport interface ITocItem {\n  depth: number\n  title: string\n  anchorId: string\n  index: number\n  $heading: HTMLHeadingElement\n}\n\nexport interface TocProps {\n  onItemClick?: (index: number, $el: HTMLElement | null, anchorId: string) => void\n}\n\nconst WiderTocStyle = {\n  width: 200,\n} satisfies React.CSSProperties\nexport interface TocRef {\n  refreshItems: () => void\n}\nexport const Toc = ({\n  ref,\n  className,\n  onItemClick,\n}: ComponentType<TocProps> & { ref?: React.Ref<TocRef | null> }) => {\n  const markdownElement = use(MarkdownRenderContainerRefContext)\n  const { toc, rootDepth, refreshItems } = useTocItems(markdownElement)\n  const { currentScrollRange, handleScrollTo } = useScrollTracking(toc, {\n    onItemClick,\n  })\n\n  useImperativeHandle(\n    ref,\n    useCallback(() => {\n      return {\n        refreshItems,\n      }\n    }, [refreshItems]),\n  )\n\n  const renderContentElementPosition = useWrappedElementPosition()\n  const renderContentElementSize = useWrappedElementSize()\n  const entryContentInWideMode = false\n  const shouldShowTitle = useViewport((v) => {\n    if (!entryContentInWideMode) return false\n    const { w } = v\n    const xAxis = renderContentElementPosition.x + renderContentElementSize.w\n\n    return w - xAxis > WiderTocStyle.width + 50\n  })\n\n  if (toc.length === 0) return null\n\n  return shouldShowTitle ? (\n    <TocContainer\n      className={className}\n      toc={toc}\n      rootDepth={rootDepth}\n      currentScrollRange={currentScrollRange}\n      handleScrollTo={handleScrollTo}\n    />\n  ) : (\n    <TocHoverCard\n      className={className}\n      toc={toc}\n      rootDepth={rootDepth}\n      currentScrollRange={currentScrollRange}\n      handleScrollTo={handleScrollTo}\n    />\n  )\n}\n\nconst TocContainer: React.FC<TocContainerProps> = ({\n  className,\n  toc,\n  rootDepth,\n  currentScrollRange,\n  handleScrollTo,\n}) => {\n  return (\n    <div\n      className={cn(\n        \"group relative overflow-auto opacity-60 duration-200 scrollbar-none group-hover:opacity-100\",\n        \"flex flex-col\",\n        className,\n      )}\n      style={WiderTocStyle}\n    >\n      {toc.map((heading, index) => (\n        <MemoedItem\n          variant=\"title-line\"\n          heading={heading}\n          key={heading.anchorId}\n          rootDepth={rootDepth}\n          onClick={handleScrollTo}\n          isScrollOut={index < currentScrollRange[0]}\n          range={index === currentScrollRange[0] ? currentScrollRange[1] : 0}\n        />\n      ))}\n    </div>\n  )\n}\n\nconst TocHoverCard: React.FC<TocHoverCardProps> = ({\n  className,\n  toc,\n  rootDepth,\n  currentScrollRange,\n  handleScrollTo,\n}) => {\n  const [hoverShow, setHoverShow] = useState(false)\n\n  return (\n    <div className=\"flex grow flex-col scroll-smooth px-2 scrollbar-none\">\n      <HoverCard.Root openDelay={100} open={hoverShow} onOpenChange={setHoverShow}>\n        <HoverCard.Trigger asChild>\n          <div\n            className={cn(\n              \"group overflow-auto opacity-60 duration-200 scrollbar-none group-hover:opacity-100\",\n              className,\n            )}\n          >\n            {toc.map((heading, index) => (\n              <MemoedItem\n                heading={heading}\n                key={heading.anchorId}\n                rootDepth={rootDepth}\n                onClick={handleScrollTo}\n                isScrollOut={index < currentScrollRange[0]}\n                range={index === currentScrollRange[0] ? currentScrollRange[1] : 0}\n              />\n            ))}\n          </div>\n        </HoverCard.Trigger>\n        <HoverCard.Portal forceMount>\n          <div>\n            <AnimatePresence>\n              {hoverShow && (\n                <HoverCard.Content side=\"left\" align=\"start\" asChild>\n                  <m.ul\n                    initial={{ opacity: 0, x: 110 }}\n                    animate={{ opacity: 1, x: 100 }}\n                    exit={{ opacity: 0, x: 110, transition: { duration: 0.1 } }}\n                    transition={{ duration: 0.5, type: \"spring\" }}\n                    className={cn(\n                      \"relative z-10 -mt-1 rounded-xl border\",\n                      \"px-3 py-1 text-xs\",\n                      \"shadow-context-menu bg-material-ultra-thick backdrop-blur-background\",\n                      \"max-h-[calc(100svh-4rem)] overflow-auto scrollbar-none\",\n                    )}\n                  >\n                    {toc.map((heading, index) => (\n                      <li\n                        key={heading.anchorId}\n                        className=\"flex w-full items-center\"\n                        style={{ paddingLeft: `${(heading.depth - rootDepth) * 12}px` }}\n                      >\n                        <button\n                          tabIndex={1}\n                          className={cn(\n                            \"group flex w-full cursor-pointer justify-between py-1\",\n                            index === currentScrollRange[0] ? \"text-accent\" : \"\",\n                          )}\n                          type=\"button\"\n                          onClick={() => {\n                            handleScrollTo(index, heading.$heading, heading.anchorId)\n                            nextFrame(() => {\n                              EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, {\n                                highlightBoundary: false,\n                              })\n                            })\n                          }}\n                        >\n                          <EllipsisHorizontalTextWithTooltip className=\"max-w-prose select-none truncate duration-200 group-hover:text-accent/80\">\n                            {heading.title}\n                          </EllipsisHorizontalTextWithTooltip>\n\n                          <span className=\"ml-4 select-none text-[8px] opacity-50\">\n                            H{heading.depth}\n                          </span>\n                        </button>\n                      </li>\n                    ))}\n                  </m.ul>\n                </HoverCard.Content>\n              )}\n            </AnimatePresence>\n          </div>\n        </HoverCard.Portal>\n      </HoverCard.Root>\n    </div>\n  )\n}\n\nconst MemoedItem = memo<TocItemProps>((props) => {\n  const {\n    // active,\n    range,\n    ...rest\n  } = props\n  const active = range > 0\n\n  const itemRef = useRef<HTMLElement>(null)\n\n  useEffect(() => {\n    if (!active) return\n\n    const $item = itemRef.current\n    if (!$item) return\n    const $container = $item.parentElement\n    if (!$container) return\n\n    const containerHeight = $container.clientHeight\n    const itemHeight = $item.clientHeight\n    const itemOffsetTop = $item.offsetTop\n    const { scrollTop } = $container\n\n    const itemTop = itemOffsetTop - scrollTop\n    const itemBottom = itemTop + itemHeight\n    if (itemTop < 0 || itemBottom > containerHeight) {\n      $container.scrollTop = itemOffsetTop - containerHeight / 2 + itemHeight / 2\n    }\n  }, [active])\n\n  return <TocItem range={range} {...rest} />\n})\nMemoedItem.displayName = \"MemoedItem\"\n\n// Types\ninterface TocContainerProps {\n  className?: string\n  toc: ITocItem[]\n  rootDepth: number\n  currentScrollRange: [number, number]\n  handleScrollTo: (i: number, $el: HTMLElement | null, anchorId: string) => void\n}\n\ninterface TocHoverCardProps extends TocContainerProps {}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/components/TocItem.tsx",
    "content": "import { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport type { FC, MouseEvent } from \"react\"\nimport { memo, useCallback, useRef } from \"react\"\n\nexport interface ITocItem {\n  depth: number\n  title: string\n  anchorId: string\n  index: number\n\n  $heading: HTMLHeadingElement\n}\n\nexport interface TocItemProps {\n  heading: ITocItem\n  // active: boolean\n  rootDepth: number\n  onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void\n\n  isScrollOut: boolean\n  range: number\n  variant?: \"line\" | \"title-line\"\n}\n\nexport const TocItem: FC<TocItemProps> = memo((props) => {\n  const { onClick, heading, isScrollOut, range, variant = \"line\", rootDepth } = props\n  const { $heading, anchorId, depth, index, title } = heading\n\n  const $ref = useRef<HTMLButtonElement>(null)\n\n  const isTitleLine = variant === \"title-line\"\n  return (\n    <button\n      type=\"button\"\n      ref={$ref}\n      data-index={index}\n      className={cn(\"cursor-pointer\", isTitleLine && \"relative flex w-full flex-col\")}\n      style={\n        isTitleLine\n          ? {\n              paddingLeft: `${(depth - rootDepth) * 12}px`,\n            }\n          : {\n              lineHeight: \"24px\",\n            }\n      }\n      data-depth={depth}\n      onClick={useCallback(\n        (e: MouseEvent) => {\n          e.preventDefault()\n\n          onClick?.(index, $heading, anchorId)\n        },\n        [anchorId, onClick, index, $heading],\n      )}\n      title={title}\n    >\n      {isTitleLine && (\n        <EllipsisHorizontalTextWithTooltip\n          className={clsx(\n            \"w-full min-w-0 truncate text-left text-xs\",\n            range\n              ? \"text-zinc-900 dark:text-zinc-300\"\n              : \"text-zinc-500 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300\",\n          )}\n        >\n          {title}\n        </EllipsisHorizontalTextWithTooltip>\n      )}\n      <span\n        style={{\n          width: widthMap[depth],\n        }}\n        data-active={!!range}\n        className={cn(\n          \"relative inline-block rounded-full\",\n          \"bg-zinc-100 duration-200\",\n          isScrollOut && \"bg-zinc-400/80\",\n\n          \"dark:bg-zinc-800/80\",\n          isScrollOut && \"dark:bg-zinc-700\",\n          !!range && \"!bg-zinc-400/50 dark:!bg-zinc-600\",\n          \"overflow-hidden\",\n\n          isTitleLine\n            ? `my-1 h-1 duration-200 ${range ? \"mb-3\" : \"mb-0.5\"} bg-transparent dark:bg-transparent`\n            : \"h-1.5 hover:!bg-zinc-400 dark:hover:!bg-zinc-600\",\n        )}\n      >\n        <span\n          className=\"absolute inset-y-0 left-0 z-[1] ml-[-12px] rounded-full bg-zinc-600 duration-75 ease-linear dark:bg-zinc-400\"\n          style={{\n            width: `calc(${range * 100}% + 12px)`,\n          }}\n        />\n      </span>\n    </button>\n  )\n})\n\nconst widthMap = {\n  1: 72 - 6,\n  2: 60 - 6,\n  3: 48 - 6,\n  4: 36 - 6,\n  5: 24 - 6,\n  6: 12 - 6,\n}\n\nTocItem.displayName = \"TocItem\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/components/hooks.tsx",
    "content": "import { getViewport } from \"@follow/components/hooks/useViewport.js\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { getElementTop } from \"@follow/utils/dom\"\nimport { springScrollToElement } from \"@follow/utils/scroller\"\nimport { throttle } from \"es-toolkit/compat\"\nimport { useStore } from \"jotai\"\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport type { ITocItem, TocProps } from \"./Toc\"\n\n// Hooks\nexport const useTocItems = (markdownElement: HTMLElement | null) => {\n  const queryToCItems = useCallback(\n    (): HTMLHeadingElement[] =>\n      Array.from(markdownElement?.querySelectorAll(\"h1, h2, h3, h4, h5, h6\") || []),\n    [markdownElement],\n  )\n  const [$headings, setHeadings] = useState(queryToCItems)\n\n  useEffect(() => {\n    setHeadings(queryToCItems())\n  }, [markdownElement, queryToCItems])\n\n  const toc: ITocItem[] = useMemo(\n    () =>\n      Array.from($headings).map((el, idx) => {\n        const depth = +el.tagName.slice(1)\n        const elClone = el.cloneNode(true) as HTMLElement\n        const title = elClone.textContent || \"\"\n        const index = idx\n\n        return {\n          depth,\n          index: Number.isNaN(index) ? -1 : index,\n          title,\n          anchorId: el.dataset.rid || \"\",\n          $heading: el,\n        }\n      }),\n    [$headings],\n  )\n\n  const rootDepth = useMemo(\n    () =>\n      toc?.length\n        ? (toc.reduce(\n            (d: number, cur) => Math.min(d, cur.depth),\n            toc[0]?.depth || 0,\n          ) as any as number)\n        : 0,\n    [toc],\n  )\n\n  return {\n    toc,\n    rootDepth,\n    refreshItems: useCallback(() => {\n      setHeadings(queryToCItems())\n    }, [queryToCItems]),\n  }\n}\n\ntype DebouncedFuncLeading<T extends (..._args: any[]) => any> = T & {\n  cancel: () => void\n  flush: () => void\n}\n\nexport const useScrollTracking = (\n  toc: ITocItem[],\n  options: Pick<TocProps, \"onItemClick\"> & {\n    useWindowScroll?: boolean\n  },\n) => {\n  const _scrollContainerElement = useScrollViewElement()\n  const scrollContainerElement = options.useWindowScroll ? document : _scrollContainerElement\n  const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number])\n\n  const headingTopsRef = useRef<number[]>([])\n  const [headingTopsVersion, setHeadingTopsVersion] = useState(0)\n  const throttleCallerRef = useRef<DebouncedFuncLeading<() => void>>(undefined)\n  const store = useStore()\n\n  useLayoutEffect(() => {\n    if (!scrollContainerElement || toc.length === 0) {\n      headingTopsRef.current = []\n      setHeadingTopsVersion((v) => v + 1)\n      return\n    }\n\n    const scrollContainerTop =\n      scrollContainerElement === document ? 0 : getElementTop(scrollContainerElement as HTMLElement)\n\n    const tops = toc.map(({ $heading }) => {\n      const elementTop = getElementTop($heading)\n      const top = elementTop - scrollContainerTop\n      return top\n    })\n\n    headingTopsRef.current = tops\n    setHeadingTopsVersion((v) => v + 1)\n  }, [toc, scrollContainerElement])\n\n  useEffect(() => {\n    if (!scrollContainerElement || toc.length === 0) return\n\n    const handler = throttle(() => {\n      const storeViewport = getViewport(store)\n      const winHeight = storeViewport.h\n      const headingTops = headingTopsRef.current\n\n      if (headingTops.length === 0) return\n\n      const scrollTop =\n        scrollContainerElement === document\n          ? document.documentElement.scrollTop\n          : (scrollContainerElement as HTMLElement).scrollTop\n\n      const activationLine = scrollTop + winHeight / 3\n\n      let activeIndex = -1\n      for (let i = headingTops.length - 1; i >= 0; i--) {\n        if (activationLine >= headingTops[i]!) {\n          activeIndex = i\n          break\n        }\n      }\n\n      if (activeIndex === -1) {\n        setCurrentScrollRange([-1, 0])\n      } else if (activeIndex === headingTops.length - 1) {\n        const lastHeadingTop = headingTops[activeIndex]!\n        const contentEnd =\n          scrollContainerElement === document\n            ? document.documentElement.scrollHeight\n            : (scrollContainerElement as HTMLElement).scrollHeight\n\n        const total = contentEnd - lastHeadingTop\n        const current = activationLine - lastHeadingTop\n        const progress = Math.min(1, Math.max(0, total > 0 ? current / total : 0))\n        setCurrentScrollRange([activeIndex, progress])\n      } else {\n        const currentHeadingTop = headingTops[activeIndex]!\n        const nextHeadingTop = headingTops[activeIndex + 1]!\n        const total = nextHeadingTop - currentHeadingTop\n        const current = activationLine - currentHeadingTop\n        const progress = Math.min(1, Math.max(0, total > 0 ? current / total : 0))\n        setCurrentScrollRange([activeIndex, progress])\n      }\n    }, 100)\n\n    throttleCallerRef.current = handler\n\n    handler()\n\n    scrollContainerElement.addEventListener(\"scroll\", handler, { passive: true })\n\n    return () => {\n      scrollContainerElement.removeEventListener(\"scroll\", handler)\n      handler.cancel()\n    }\n  }, [scrollContainerElement, store, toc, headingTopsVersion])\n\n  const handleScrollTo = useEventCallback(\n    (i: number, $el: HTMLElement | null, _anchorId: string) => {\n      options.onItemClick?.(i, $el, _anchorId)\n      if ($el && scrollContainerElement) {\n        springScrollToElement(\n          $el,\n          -100,\n          scrollContainerElement === document ? undefined : (scrollContainerElement as HTMLElement),\n        ).then(() => {\n          throttleCallerRef.current?.cancel()\n          setTimeout(() => {\n            throttleCallerRef.current?.()\n          }, 50)\n        })\n      }\n    },\n  )\n\n  return { currentScrollRange, handleScrollTo }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/context.tsx",
    "content": "import { createContext as reactCreateContext } from \"react\"\nimport { createContext } from \"use-context-selector\"\n\nimport type { MarkdownImage, MarkdownRenderActions } from \"./types\"\n\nexport const MarkdownRenderContainerRefContext = reactCreateContext<HTMLElement | null>(null)\n\nexport const MarkdownImageRecordContext = createContext<Record<string, MarkdownImage>>({})\n\nexport const MarkdownRenderActionContext = reactCreateContext<MarkdownRenderActions>({\n  transformUrl: (url) => url ?? \"\",\n  isAudio: () => false,\n  ensureAndRenderTimeStamp: () => false,\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockErrorBoundary.tsx",
    "content": "import { tracker } from \"@follow/tracker\"\nimport { useEffect } from \"react\"\n\nexport const BlockError = (props: { error: any; message: string }) => {\n  useEffect(() => {\n    console.error(props.error)\n    void tracker.manager.captureException(props.error, {\n      source: \"desktop_markdown_block_error\",\n      message: props.message,\n    })\n  }, [props.error, props.message])\n  return (\n    <div className=\"center flex min-h-12 flex-col rounded bg-red py-4 text-sm text-white\">\n      {props.message}\n\n      <pre className=\"m-0 bg-transparent\">{props.error?.message}</pre>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { use } from \"react\"\nimport { useContextSelector } from \"use-context-selector\"\n\nimport { useWrappedElementSize } from \"~/providers/wrapped-element-provider\"\n\nimport { Media } from \"../../media/Media\"\nimport { MarkdownImageRecordContext, MarkdownRenderActionContext } from \"../context\"\n\nexport const MarkdownBlockImage = (\n  props: React.ImgHTMLAttributes<HTMLImageElement> & {\n    proxy?: {\n      width: number\n      height: number\n    }\n  },\n) => {\n  const size = useWrappedElementSize()\n\n  const { transformUrl } = use(MarkdownRenderActionContext)\n  const src = transformUrl(props.src)\n\n  const media = useContextSelector(MarkdownImageRecordContext, (record) =>\n    props.src ? record[props.src] : null,\n  )\n\n  return (\n    <Media\n      type=\"photo\"\n      {...props}\n      loading=\"lazy\"\n      src={src}\n      height={media?.height || props.height}\n      width={media?.width || props.width}\n      blurhash={media?.blurhash}\n      mediaContainerClassName={cn(\n        \"rounded\",\n        size.w < Number.parseInt(props.width as string) && \"w-full\",\n      )}\n      showFallback\n      popper\n      className=\"my-8 flex justify-center\"\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/Heading.tsx",
    "content": "import { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { springScrollToElement } from \"@follow/utils/scroller\"\nimport { cn } from \"@follow/utils/utils\"\nimport { use, useId, useRef } from \"react\"\n\nimport { MarkdownRenderContainerRefContext } from \"../context\"\n\nexport const createHeadingRenderer =\n  (level: number) =>\n  (\n    props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>,\n  ) => {\n    const rid = useId()\n\n    const As = `h${level}` as any\n    const { node, ...rest } = props as any\n\n    const scroller = useScrollViewElement()\n    const renderContainer = use(MarkdownRenderContainerRefContext)\n    const ref = useRef<HTMLHeadingElement>(null)\n\n    return (\n      <As ref={ref} {...rest} data-rid={rid} className={cn(rest.className, \"group relative\")}>\n        {rest.children}\n        <span\n          className={cn(\n            \"cursor-pointer select-none text-accent opacity-0 transition-opacity duration-200 group-hover:opacity-100\",\n            \"relative ml-2\",\n            \"@2xl:absolute @2xl:left-[-1.5em] @2xl:top-0 @2xl:opacity-0\",\n          )}\n          aria-hidden\n          onClick={() => {\n            if (!renderContainer) return\n\n            springScrollToElement(\n              renderContainer.querySelector(`[data-rid=\"${rid}\"]`)!,\n              -100,\n              scroller!,\n            )\n          }}\n        >\n          <i className=\"i-mingcute-hashtag-line invisible\" />\n          <span className=\"center absolute inset-0\">\n            <i className=\"i-mingcute-hashtag-line\" />\n          </span>\n        </span>\n      </As>\n    )\n  }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/InlineImage.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { use } from \"react\"\nimport { useContextSelector } from \"use-context-selector\"\n\nimport { Media } from \"../../media/Media\"\nimport { MarkdownImageRecordContext, MarkdownRenderActionContext } from \"../context\"\n\nexport const MarkdownInlineImage = (\n  props: React.ImgHTMLAttributes<HTMLImageElement> & {\n    proxy?: {\n      width: number\n      height: number\n    }\n  },\n) => {\n  const { transformUrl } = use(MarkdownRenderActionContext)\n  const populatedUrl = transformUrl(props.src)\n  const media = useContextSelector(MarkdownImageRecordContext, (record) =>\n    props.src ? record[props.src] : null,\n  )\n\n  return (\n    <Media\n      type=\"photo\"\n      {...props}\n      loading=\"lazy\"\n      src={populatedUrl}\n      height={media?.height || props.height}\n      width={media?.width || props.width}\n      blurhash={media?.blurhash}\n      mediaContainerClassName={cn(\"inline max-w-full rounded-md\")}\n      popper\n      showFallback\n      inline\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/MarkdownLink.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { LinkProps } from \"@follow/components/ui/link/LinkWithTooltip.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { useCorrectZIndex } from \"@follow/components/ui/z-index/ctx.js\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { feedSyncServices } from \"@follow/store/feed/store\"\nimport { cn, parseSafeUrl, stopPropagation } from \"@follow/utils\"\nimport type { MouseEvent } from \"react\"\nimport { use, useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { navigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\n\nimport { MarkdownRenderActionContext } from \"../context\"\n\nexport const MarkdownLink: Component<LinkProps> = (props) => {\n  const { transformUrl, isAudio, ensureAndRenderTimeStamp } = use(MarkdownRenderActionContext)\n  const { t } = useTranslation()\n\n  const populatedFullHref = transformUrl(props.href)\n  const shareFeedInfo = parseShareFeedInfo(populatedFullHref)\n\n  const handleCopyLink = useCallback(async () => {\n    try {\n      if (!populatedFullHref) {\n        throw new Error(\"No URL to copy\")\n      }\n      await copyToClipboard(populatedFullHref)\n      toast.success(t(\"share.link_copied\"))\n    } catch {\n      toast.error(t(\"share.copy_failed\"))\n    }\n  }, [populatedFullHref, t])\n\n  const handleClickLink = useCallback(\n    async (event: MouseEvent<HTMLAnchorElement>) => {\n      stopPropagation(event)\n\n      if (!shareFeedInfo) {\n        return\n      }\n      event.preventDefault()\n\n      const view = await resolveShareFeedView(shareFeedInfo)\n      navigateEntry({\n        feedId: shareFeedInfo.id,\n        entryId: null,\n        view,\n      })\n    },\n    [shareFeedInfo],\n  )\n\n  const parseTimeStamp = isAudio(populatedFullHref)\n  const zIndex = useCorrectZIndex(0)\n  if (parseTimeStamp) {\n    const childrenText = props.children\n\n    if (typeof childrenText === \"string\") {\n      const renderer = ensureAndRenderTimeStamp(childrenText)\n      if (renderer) return renderer\n    }\n  }\n\n  return (\n    <Tooltip delayDuration={0}>\n      <TooltipTrigger asChild>\n        <a\n          draggable=\"false\"\n          className={cn(\n            \"follow-link--underline font-semibold text-text no-underline\",\n            props.className,\n          )}\n          href={populatedFullHref}\n          title={props.title}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          onClick={handleClickLink}\n        >\n          {props.children}\n\n          {typeof props.children === \"string\" && (\n            <i className=\"i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70\" />\n          )}\n        </a>\n      </TooltipTrigger>\n      {!!populatedFullHref && (\n        <TooltipPortal>\n          <TooltipContent align=\"start\" className=\"break-all\" style={{ zIndex }} side=\"bottom\">\n            <a\n              className=\"follow-link--underline\"\n              href={populatedFullHref}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              {populatedFullHref}\n            </a>\n\n            <Button\n              onClick={handleCopyLink}\n              buttonClassName=\"ml-1 p-1 cursor-link\"\n              variant={\"ghost\"}\n              aria-label={t(\"share.copy_link\")}\n            >\n              <i className=\"i-mgc-copy-2-cute-re size-3\" />\n            </Button>\n          </TooltipContent>\n        </TooltipPortal>\n      )}\n    </Tooltip>\n  )\n}\n\nconst parseShareFeedInfo = (href?: string) => {\n  if (!href) return null\n\n  const baseUrl = parseSafeUrl(env.VITE_WEB_URL)\n  if (!baseUrl) return null\n\n  let parsedUrl: URL\n  try {\n    parsedUrl = new URL(href, baseUrl)\n  } catch {\n    return null\n  }\n\n  if (parsedUrl.host !== baseUrl.host) return null\n\n  const pathParts = parsedUrl.pathname.split(\"/\").filter(Boolean)\n  if (pathParts.length !== 3 || pathParts[0] !== \"share\" || pathParts[1] !== \"feeds\") {\n    return null\n  }\n\n  const viewParam = parsedUrl.searchParams.get(\"view\")\n  const view = viewParam ? Number.parseInt(viewParam, 10) : undefined\n\n  return {\n    id: pathParts[2]!,\n    view: Number.isNaN(view) ? undefined : view,\n  }\n}\n\nconst resolveShareFeedView = async (info: { id: string; view?: number }) => {\n  if (typeof info.view === \"number\") {\n    return info.view\n  }\n\n  const data = await feedSyncServices.fetchFeedById({ id: info.id }).catch(() => {})\n  const analyticsView = data?.analytics?.view\n  if (typeof analyticsView === \"number\") {\n    return analyticsView\n  }\n\n  return 0\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/MarkdownP.tsx",
    "content": "import * as React from \"react\"\n\nimport { MarkdownRenderActionContext } from \"../context\"\nimport { IsInParagraphContext } from \"./ctx\"\n\nexport const MarkdownP: Component<\n  React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>\n> = ({ children, ...props }) => {\n  const { isAudio, ensureAndRenderTimeStamp } = React.use(MarkdownRenderActionContext)\n  const parseTimeline = isAudio()\n  if (parseTimeline && typeof children === \"string\") {\n    const renderer = ensureAndRenderTimeStamp(children)\n    if (renderer) return <p>{renderer}</p>\n  }\n\n  if (parseTimeline && Array.isArray(children)) {\n    return (\n      <p>\n        {children.map((child, index) => {\n          if (typeof child === \"string\") {\n            const renderer = ensureAndRenderTimeStamp(child)\n            if (renderer) return <React.Fragment key={index}>{renderer}</React.Fragment>\n          }\n          return <React.Fragment key={index}>{child}</React.Fragment>\n        })}\n      </p>\n    )\n  }\n\n  return (\n    <p {...props}>\n      <IsInParagraphContext value={true}>{children}</IsInParagraphContext>\n    </p>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/ctx.tsx",
    "content": "import { createContext, use } from \"react\"\n\n/**\n * @internal\n */\nexport const IsInParagraphContext = createContext<boolean>(false)\n\nexport const useIsInParagraphContext = () => {\n  return use(IsInParagraphContext)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/renderers/index.ts",
    "content": "export * from \"./BlockImage\"\nexport * from \"./MarkdownLink\"\nexport * from \"./MarkdownP\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/markdown/types.ts",
    "content": "export type MarkdownImage = {\n  url: string\n  width?: number | undefined\n  height?: number | undefined\n  preview_image_url?: string | undefined\n  blurhash?: string | undefined\n}\n\nexport interface MarkdownRenderActions {\n  transformUrl: (url?: string) => string | undefined\n  isAudio: (url?: string) => boolean\n  ensureAndRenderTimeStamp: (children: string) => React.ReactNode\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/Media.tsx",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useForceUpdate } from \"motion/react\"\nimport type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from \"react\"\nimport * as React from \"react\"\nimport { memo, use, useEffect, useMemo, useRef, useState } from \"react\"\nimport { Blurhash, BlurhashCanvas } from \"react-blurhash\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useGetImageProxyUrl } from \"~/lib/img-proxy\"\nimport { saveImageDimensionsToDb } from \"~/store/image/db\"\n\nimport { ErrorBoundary } from \"../../common/ErrorBoundary\"\nimport { useMediaContainerWidth, usePreviewMedia } from \"./hooks\"\nimport { MediaInfoRecordContext } from \"./MediaInfoRecordContext\"\nimport type { VideoPlayerRef } from \"./VideoPlayer\"\nimport { VideoPlayer } from \"./VideoPlayer\"\n\ntype BaseProps = {\n  mediaContainerClassName?: string\n  showFallback?: boolean\n  thumbnail?: boolean\n  blurhash?: string\n  inline?: boolean\n  fitContent?: boolean\n  fitContainer?: boolean\n  videoClassName?: string\n}\n\nconst isImageLoadedSet = new Set<string>()\nexport type MediaProps = BaseProps &\n  (\n    | (ImgHTMLAttributes<HTMLImageElement> & {\n        proxy?: {\n          width: number\n          height: number\n        }\n        preferOrigin?: boolean\n        popper?: boolean\n        type: \"photo\"\n        previewImageUrl?: string\n        cacheDimensions?: boolean\n      })\n    | (VideoHTMLAttributes<HTMLVideoElement> & {\n        proxy?: {\n          width: number\n          height: number\n        }\n        preferOrigin?: boolean\n        popper?: boolean\n        type: \"video\"\n        previewImageUrl?: string\n      })\n  )\n\nconst MediaImpl: FC<MediaProps> = ({\n  className,\n  proxy,\n  preferOrigin,\n  popper = false,\n  mediaContainerClassName,\n  thumbnail,\n  ...props\n}) => {\n  const {\n    src,\n    style,\n    type,\n    previewImageUrl,\n    showFallback,\n    blurhash,\n    height,\n    width,\n    inline,\n    fitContent,\n    fitContainer,\n    videoClassName,\n    ...rest\n  } = props\n  const getImageProxyUrl = useGetImageProxyUrl()\n\n  const ctxMediaInfo = use(MediaInfoRecordContext)\n  const ctxHeight = ctxMediaInfo[src!]?.height\n  const ctxWidth = ctxMediaInfo[src!]?.width\n\n  const finalHeight = height || ctxHeight\n  const finalWidth = width || ctxWidth\n\n  // Define the list of available image sources, sorted by priority\n  const imageSources = useMemo(() => {\n    if (!src) return []\n\n    const sources: Array<{ url: string; type: \"proxy\" | \"origin\" }> = []\n\n    // Determine priority based on preferences\n    if (proxy && !preferOrigin) {\n      // Use proxy first\n      sources.push(\n        {\n          url: getImageProxyUrl({\n            url: src,\n            width: proxy.width || 0,\n            height: proxy.height || 0,\n          }),\n          type: \"proxy\",\n        },\n        { url: src, type: \"origin\" },\n      )\n    } else {\n      // Use original URL first\n      sources.push({ url: src, type: \"origin\" })\n      if (proxy) {\n        sources.push({\n          url: getImageProxyUrl({\n            url: src,\n            width: proxy.width || 0,\n            height: proxy.height || 0,\n          }),\n          type: \"proxy\",\n        })\n      }\n    }\n\n    return sources\n  }, [src, proxy, preferOrigin, getImageProxyUrl])\n\n  const [currentSourceIndex, setCurrentSourceIndex] = useState(0)\n  const [isError, setIsError] = useState(false)\n  const [mediaLoadState, setMediaLoadState] = useState<\"loading\" | \"loaded\">(\"loading\")\n\n  const currentSource = imageSources[currentSourceIndex]\n  const imgSrc = currentSource?.url || src\n\n  const previewImageSrc = useMemo(() => {\n    if (!previewImageUrl) return\n\n    // Use the same proxy strategy for preview images\n    if (proxy && currentSource?.type === \"proxy\") {\n      return getImageProxyUrl({\n        url: previewImageUrl,\n        width: proxy.width || 0,\n        height: proxy.height || 0,\n      })\n    }\n    return previewImageUrl\n  }, [previewImageUrl, proxy, currentSource?.type, getImageProxyUrl])\n\n  // When image source list changes, reset to the first source\n  const prevImageSources = useRef(imageSources)\n  useEffect(() => {\n    if (prevImageSources.current !== imageSources && imageSources.length > 0) {\n      prevImageSources.current = imageSources\n      setCurrentSourceIndex(0)\n      setIsError(false)\n    }\n  }, [imageSources])\n\n  // When image source changes, reset loading state\n  const prevImgSrc = useRef(imgSrc)\n  useEffect(() => {\n    if (prevImgSrc.current !== imgSrc) {\n      prevImgSrc.current = imgSrc\n      setMediaLoadState(imgSrc && isImageLoadedSet.has(imgSrc) ? \"loaded\" : \"loading\")\n    }\n  }, [imgSrc])\n\n  const errorHandle: React.ReactEventHandler<HTMLImageElement> = useEventCallback((e) => {\n    const nextIndex = currentSourceIndex + 1\n\n    if (import.meta.env.DEV) {\n      console.info(\n        `[Media Error] Failed to load image source ${currentSourceIndex + 1}/${imageSources.length}`,\n        {\n          failedSrc: imgSrc,\n          originalSrc: src,\n          error: e,\n          willRetry: nextIndex < imageSources.length,\n          nextSource: imageSources[nextIndex]?.url,\n        },\n      )\n    }\n\n    if (nextIndex < imageSources.length) {\n      //  Try next available image source\n      setCurrentSourceIndex(nextIndex)\n      setMediaLoadState(\"loading\")\n    } else {\n      // All sources failed, mark as error state\n      setIsError(true)\n\n      if (import.meta.env.DEV) {\n        console.error(`[Media Error] All image sources failed for: ${src}`, {\n          allSources: imageSources,\n          originalSrc: src,\n        })\n      }\n    }\n  })\n\n  const previewMedia = usePreviewMedia()\n  const handleClick = useEventCallback((e: React.MouseEvent) => {\n    e.preventDefault()\n    if (popper && src) {\n      const width = Number.parseInt(props.width as string)\n      const height = Number.parseInt(props.height as string)\n      previewMedia(\n        [\n          {\n            url: src,\n            type,\n            fallbackUrl: imgSrc,\n            blurhash: props.blurhash,\n            width: width || undefined,\n            height: height || undefined,\n          },\n        ],\n        0,\n      )\n    }\n    props.onClick?.(e as any)\n  })\n  const handleOnLoad: React.ReactEventHandler<HTMLImageElement> = useEventCallback((e) => {\n    setMediaLoadState(\"loaded\")\n    rest.onLoad?.(e as any)\n\n    if (import.meta.env.DEV) {\n      console.info(`[Media Success] Image loaded successfully`, {\n        src: imgSrc,\n        originalSrc: src,\n        sourceType: currentSource?.type,\n        sourceIndex: currentSourceIndex + 1,\n        totalSources: imageSources.length,\n        dimensions: {\n          width: e.currentTarget.naturalWidth,\n          height: e.currentTarget.naturalHeight,\n          ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight,\n        },\n        loadTime: performance.now(),\n      })\n    }\n\n    if (imgSrc) {\n      isImageLoadedSet.add(imgSrc)\n    }\n    if (\"cacheDimensions\" in props && props.cacheDimensions && src) {\n      saveImageDimensionsToDb(src, {\n        src,\n        width: e.currentTarget.naturalWidth,\n        height: e.currentTarget.naturalHeight,\n        ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight,\n        blurhash: props.blurhash,\n      })\n    }\n  })\n\n  const containerWidth = useMediaContainerWidth()\n\n  const InnerContent = useMemo(() => {\n    switch (type) {\n      case \"photo\": {\n        // @ts-expect-error\n        const { cacheDimensions, ...props } = rest\n        return (\n          <img\n            height={finalHeight}\n            width={finalWidth}\n            {...(props as ImgHTMLAttributes<HTMLImageElement>)}\n            onError={errorHandle}\n            className={cn(\n              \"size-full object-contain\",\n              inline && \"inline size-auto align-sub\",\n              popper && \"cursor-zoom-in\",\n              \"duration-200\",\n              mediaLoadState === \"loaded\" ? \"opacity-100\" : \"opacity-0\",\n              \"!my-0\",\n              mediaContainerClassName,\n            )}\n            src={imgSrc}\n            onLoad={handleOnLoad}\n            onClick={handleClick}\n          />\n        )\n      }\n      case \"video\": {\n        return (\n          <span\n            className={cn(\n              \"center\",\n              !(finalWidth || finalHeight) && \"size-full\",\n              \"relative cursor-card object-cover\",\n              mediaContainerClassName,\n            )}\n            onClick={handleClick}\n          >\n            <VideoPreview\n              src={src!}\n              previewImageUrl={previewImageSrc}\n              thumbnail={thumbnail}\n              videoClassName={videoClassName}\n            />\n          </span>\n        )\n      }\n      default: {\n        return null\n      }\n    }\n  }, [\n    type,\n    rest,\n    finalHeight,\n    finalWidth,\n    errorHandle,\n    inline,\n    popper,\n    mediaLoadState,\n    mediaContainerClassName,\n    imgSrc,\n    handleOnLoad,\n    handleClick,\n    src,\n    previewImageSrc,\n    thumbnail,\n    videoClassName,\n  ])\n\n  if (!type || !src) return null\n\n  if (isError) {\n    if (showFallback) {\n      return (\n        <FallbackMedia\n          mediaContainerClassName={mediaContainerClassName}\n          className={className}\n          style={style}\n          {...props}\n        />\n      )\n    } else {\n      return (\n        <div\n          className={cn(\"relative overflow-hidden rounded\", className)}\n          data-state={mediaLoadState}\n          style={props.style}\n        >\n          <span\n            className={cn(\n              \"relative inline-block max-w-full bg-material-ultra-thick\",\n              mediaContainerClassName,\n            )}\n            style={{\n              aspectRatio:\n                props.height && props.width ? `${props.width} / ${props.height}` : undefined,\n              width: props.width ? `${props.width}px` : \"100%\",\n            }}\n          >\n            {props.blurhash && (\n              <ErrorBoundary>\n                <span\n                  className={cn(\n                    \"absolute inset-0 overflow-hidden rounded\",\n                    mediaLoadState === \"loaded\" && \"animate-out fade-out-0 fill-mode-forwards\",\n                  )}\n                >\n                  <BlurhashCanvas hash={props.blurhash} className=\"size-full\" />\n                </span>\n              </ErrorBoundary>\n            )}\n          </span>\n        </div>\n      )\n    }\n  }\n\n  return (\n    <span\n      data-state={type !== \"video\" ? mediaLoadState : undefined}\n      data-media-debug={\n        import.meta.env.DEV\n          ? JSON.stringify({\n              originalSrc: src,\n              currentSrc: imgSrc,\n              sourceType: currentSource?.type,\n              sourceIndex: currentSourceIndex,\n              totalSources: imageSources.length,\n              isError,\n              mediaLoadState,\n            })\n          : undefined\n      }\n      className={cn(\"relative overflow-hidden rounded\", inline ? \"inline\" : \"block\", className)}\n      style={style}\n    >\n      {!!props.width && !!props.height && !!containerWidth ? (\n        <AspectRatio\n          width={Number.parseInt(props.width as string)}\n          height={Number.parseInt(props.height as string)}\n          containerWidth={containerWidth}\n          fitContent={fitContent}\n          fitContainer={fitContainer}\n        >\n          <div\n            className={cn(\n              \"absolute inset-0 flex items-center justify-center overflow-hidden rounded\",\n              mediaLoadState === \"loaded\" && \"animate-out fade-out-0 fill-mode-forwards\",\n            )}\n            data-blurhash={blurhash}\n          >\n            {blurhash ? (\n              <Blurhash hash={blurhash} width=\"100%\" height=\"100%\" />\n            ) : (\n              <div className=\"size-full bg-border\" />\n            )}\n          </div>\n          <div className=\"absolute inset-0 flex items-center justify-center overflow-hidden rounded\">\n            {InnerContent}\n          </div>\n        </AspectRatio>\n      ) : (\n        InnerContent\n      )}\n    </span>\n  )\n}\n\nexport const Media: FC<MediaProps> = memo((props) => <MediaImpl {...props} key={props.src} />)\n\nconst FallbackMedia: FC<MediaProps> = ({ type, mediaContainerClassName, className, ...props }) => (\n  <div className={className} style={props.style}>\n    <div\n      className={cn(\n        \"size-full\",\n        \"center rounded bg-material-ultra-thick\",\n        \"not-prose !flex max-h-full flex-col space-y-1 p-4 @container\",\n        mediaContainerClassName,\n      )}\n    >\n      <div className=\"hidden @sm:hidden @md:contents\">\n        <i className=\"i-mgc-close-cute-re text-xl text-red\" />\n        <p>Media loaded failed</p>\n        <div className=\"space-x-1 break-all px-4 text-sm\">\n          Go to{\" \"}\n          <a href={props.src} target=\"_blank\" rel=\"noreferrer\" className=\"follow-link--underline\">\n            original media url\n          </a>\n          <i className=\"i-mgc-external-link-cute-re translate-y-0.5\" />\n        </div>\n      </div>\n    </div>\n  </div>\n)\n\nconst AspectRatio = ({\n  width,\n  height,\n  containerWidth,\n  children,\n  style,\n  fitContent,\n  fitContainer,\n  ...props\n}: {\n  width: number\n  height: number\n  containerWidth?: number\n  children: React.ReactNode\n  style?: React.CSSProperties\n  /**\n   * If `fit` is true, the content width may be increased to fit the container width\n   */\n  fitContent?: boolean\n  fitContainer?: boolean\n  [key: string]: any\n}) => {\n  const scaleFactor =\n    containerWidth && width\n      ? fitContent\n        ? containerWidth / width\n        : Math.min(1, containerWidth / width)\n      : 1\n\n  const scaledWidth = width ? width * scaleFactor : undefined\n  const scaledHeight = height ? height * scaleFactor : undefined\n\n  return (\n    <div\n      style={{\n        position: \"relative\",\n        width: fitContainer ? \"100%\" : scaledWidth ? `${scaledWidth}px` : \"100%\",\n        height: fitContainer ? \"100%\" : scaledHeight ? `${scaledHeight}px` : \"auto\",\n        ...style,\n      }}\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n\nconst VideoPreview: FC<{\n  src: string\n  previewImageUrl?: string\n  thumbnail?: boolean\n  videoClassName?: string\n}> = ({ src, previewImageUrl, thumbnail = false, videoClassName }) => {\n  const [isInitVideoPlayer, setIsInitVideoPlayer] = useState(!previewImageUrl)\n\n  const [videoRef, setVideoRef] = useState<VideoPlayerRef | null>(null)\n  const isPaused = videoRef ? videoRef?.getState().paused : true\n  const [forceUpdate] = useForceUpdate()\n  return (\n    <div\n      className=\"size-full\"\n      onMouseEnter={() => {\n        videoRef?.controls.play()?.then(forceUpdate)\n      }}\n      onMouseLeave={() => {\n        videoRef?.controls.pause()\n        nextFrame(forceUpdate)\n      }}\n    >\n      {!isInitVideoPlayer ? (\n        <img\n          src={previewImageUrl}\n          className={cn(\"size-full object-cover\", videoClassName)}\n          onMouseEnter={() => {\n            setIsInitVideoPlayer(true)\n          }}\n        />\n      ) : (\n        <VideoPlayer\n          variant={thumbnail ? \"thumbnail\" : \"preview\"}\n          controls={false}\n          src={src}\n          poster={previewImageUrl}\n          ref={setVideoRef}\n          muted\n          className={cn(\"not-prose relative size-full object-cover\", videoClassName)}\n        />\n      )}\n\n      <div\n        className={cn(\n          \"absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-3xl text-white/80 duration-200\",\n          isPaused ? \"opacity-100\" : \"opacity-0\",\n        )}\n      >\n        <i className=\"i-mgc-play-cute-fi\" />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/MediaContainerWidthContext.tsx",
    "content": "import { createContext } from \"react\"\n\nexport const MediaContainerWidthContext = createContext<number>(0)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/MediaContainerWidthProvider.tsx",
    "content": "import { MediaContainerWidthContext } from \"./MediaContainerWidthContext\"\n\nexport const MediaContainerWidthProvider = ({\n  children,\n  width,\n}: {\n  children: React.ReactNode\n  width: number\n}) => {\n  return <MediaContainerWidthContext value={width}>{children}</MediaContainerWidthContext>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/MediaInfoRecord.tsx",
    "content": "export type MediaInfoRecord = Record<string, { width?: number; height?: number }>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/MediaInfoRecordContext.tsx",
    "content": "import { createContext } from \"react\"\n\nimport type { MediaInfoRecord } from \"./MediaInfoRecord\"\n\nexport const MediaInfoRecordContext = createContext<MediaInfoRecord>({})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/MediaInfoRecordProvider.tsx",
    "content": "import type { MediaInfoRecord } from \"./MediaInfoRecord\"\nimport { MediaInfoRecordContext } from \"./MediaInfoRecordContext\"\n\nconst noop = {} as const\nexport const MediaInfoRecordProvider = ({\n  children,\n  mediaInfo,\n}: {\n  children: React.ReactNode\n  mediaInfo?: Nullable<MediaInfoRecord>\n}) => {\n  return <MediaInfoRecordContext value={mediaInfo || noop}>{children}</MediaInfoRecordContext>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { EntryMedia } from \"@follow-app/client-sdk\"\nimport useEmblaCarousel from \"embla-carousel-react\"\nimport { WheelGesturesPlugin } from \"embla-carousel-wheel-gestures\"\nimport { useAnimationControls } from \"motion/react\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { Fragment, use, useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { ReactZoomPanPinchRef, ReactZoomPanPinchState } from \"react-zoom-pan-pinch\"\nimport { TransformComponent, TransformWrapper } from \"react-zoom-pan-pinch\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\nimport { COPY_MAP } from \"~/constants\"\nimport { ipcServices } from \"~/lib/client\"\nimport { useReplaceImgUrlIfNeed } from \"~/lib/img-proxy\"\n\nimport { useCurrentModal } from \"../modal/stacked/hooks\"\nimport type { VideoPlayerRef } from \"./VideoPlayer\"\nimport { VideoPlayer } from \"./VideoPlayer\"\n\n// Calculate the dynamic scale value and offset\nconst calculateDragTransforms = (x: number, y: number) => {\n  // Minimum scale to 0.7, maximum keep 1.0\n  const maxDistance = 300\n  const dragDistance = Math.hypot(x, y)\n  const progress = Math.min(dragDistance / maxDistance, 1)\n  const scale = 1 - progress * 0.3 // From 1.0 to 0.7\n\n  // Calculate the opacity, minimum to 0.5\n  const opacity = 1 - progress * 0.5\n\n  return { scale, opacity, x, y }\n}\n\n// Framer Motion variants\nconst modalVariants = {\n  initial: { scale: 0.94, opacity: 0 },\n  visible: { scale: 1, opacity: 1, x: 0, y: 0 },\n  exit: { scale: 0.94, opacity: 0 },\n  closing: (dragOffset: { x: number; y: number }) => ({\n    scale: 0.3,\n    x: dragOffset.x,\n    y: dragOffset.y,\n    opacity: 0,\n  }),\n}\n\nconst PreviewWrapperDragContext = React.createContext<{\n  isDragging: boolean\n  lastDragEndAt: number\n}>({ isDragging: false, lastDragEndAt: 0 })\n\nconst Wrapper: FC<{\n  src: string\n  children:\n    | [React.ReactNode, React.ReactNode | undefined]\n    | React.ReactNode\n    | ((\n        onZoomChange: (isZoomed: boolean) => void,\n      ) => [React.ReactNode, React.ReactNode | undefined] | React.ReactNode)\n  className?: string\n  onZoomChange?: (isZoomed: boolean) => void\n  canDragClose?: boolean\n}> = ({ children, src, onZoomChange, canDragClose = true }) => {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const { dismiss } = useCurrentModal()\n  const controls = useAnimationControls()\n\n  // Drag close state\n  const [isImageZoomed, setIsImageZoomed] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const [lastDragEndAt, setLastDragEndAt] = useState(0)\n  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })\n\n  // Combined zoom change callback\n  const handleZoomChange = useCallback(\n    (isZoomed: boolean) => {\n      setIsImageZoomed(isZoomed)\n      onZoomChange?.(isZoomed)\n    },\n    [onZoomChange],\n  )\n\n  const renderedChildren = typeof children === \"function\" ? children(handleZoomChange) : children\n  const isArray = Array.isArray(renderedChildren)\n  const hasSideContent = isArray && !!renderedChildren[1]\n\n  const enableDragClose = !isImageZoomed && canDragClose\n\n  const handleDrag = useCallback(\n    (_: any, info: any) => {\n      if (!isDragging) return\n      const { offset } = info\n      setDragOffset(offset)\n\n      // Real-time update the transform when dragging\n      const dragTransforms = calculateDragTransforms(offset.x, offset.y)\n      controls.set({\n        scale: dragTransforms.scale,\n        x: offset.x * 0.3,\n        y: offset.y * 0.3,\n        opacity: dragTransforms.opacity,\n      })\n    },\n    [isDragging, controls],\n  )\n\n  const handleDragEnd = useCallback(\n    async (_: any, info: any) => {\n      const { offset, velocity } = info\n      // Calculate the drag distance and velocity\n      const dragDistance = Math.hypot(offset.x, offset.y)\n      const velocityDistance = Math.hypot(velocity.x, velocity.y)\n\n      // If the drag distance is greater than 100px or the overall drag distance is greater than 150px or the velocity is greater than 300, close the modal\n      const shouldClose =\n        offset.y > 100 || dragDistance > 150 || velocity.y > 300 || velocityDistance > 500\n\n      if (shouldClose) {\n        // Execute the closing animation\n        await controls.start(\"closing\", {\n          type: \"spring\",\n          stiffness: 400,\n          damping: 40,\n          duration: 0.3,\n        })\n        dismiss()\n      } else {\n        // Reset to normal state\n        setIsDragging(false)\n        setLastDragEndAt(performance.now())\n        setDragOffset({ x: 0, y: 0 })\n        controls.start(\"visible\", {\n          ...Spring.presets.snappy,\n        })\n      }\n    },\n    [controls, dismiss],\n  )\n\n  const handleDragStart = useCallback(() => {\n    setIsDragging(true)\n  }, [])\n\n  // Initialize the animation\n  useEffect(() => {\n    controls.start(\"visible\", {\n      ...Spring.presets.snappy,\n    })\n  }, [controls])\n\n  const dragCtxValue = useMemo(() => ({ isDragging, lastDragEndAt }), [isDragging, lastDragEndAt])\n  return (\n    <PreviewWrapperDragContext value={dragCtxValue}>\n      <div ref={containerRef} className=\"fixed inset-0\">\n        <HeaderActions src={src} />\n        <m.div\n          variants={modalVariants}\n          initial=\"initial\"\n          animate={controls}\n          exit=\"exit\"\n          custom={dragOffset}\n          className=\"flex size-full bg-material-medium-dark pt-[var(--fo-window-padding-top)] backdrop-blur\"\n          drag={enableDragClose}\n          dragConstraints={{ top: 0, bottom: 300, left: -200, right: 200 }}\n          dragElastic={{ top: 0, bottom: 0.3, left: 0.2, right: 0.2 }}\n          onDragStart={handleDragStart}\n          onDrag={handleDrag}\n          onDragEnd={handleDragEnd}\n          style={{\n            cursor: enableDragClose ? (isDragging ? \"grabbing\" : \"grab\") : \"default\",\n          }}\n        >\n          <div\n            className={cn(\n              \"group/left relative flex h-full w-0 grow overflow-hidden\",\n              hasSideContent ? \"min-w-96 items-center justify-center\" : \"\",\n            )}\n          >\n            {isArray ? renderedChildren[0] : renderedChildren}\n          </div>\n          {hasSideContent ? (\n            <div\n              className=\"box-border flex h-full w-[400px] min-w-0 shrink-0 flex-col bg-background px-2 pt-1\"\n              onClick={stopPropagation}\n            >\n              {isArray ? renderedChildren[1] : null}\n            </div>\n          ) : undefined}\n        </m.div>\n      </div>\n    </PreviewWrapperDragContext>\n  )\n}\n\nconst headerActionsVariants = {\n  initial: { opacity: 0, translateY: \"-20px\" },\n  animate: { opacity: 1, translateY: \"0px\" },\n  exit: { opacity: 0, translateY: \"-20px\" },\n}\nconst GLASS_BUTTON_CLASS = tw`group-hover/left:opacity-100 opacity-0`\nconst HeaderActions: FC<{\n  src: string\n}> = ({ src }) => {\n  const { t } = useTranslation([\"shortcuts\", \"common\"])\n\n  const { dismiss } = useCurrentModal()\n  return (\n    <m.div\n      className=\"pointer-events-none absolute inset-x-0 top-0 z-[100] flex h-16 items-center justify-end gap-2 px-3\"\n      variants={headerActionsVariants}\n      initial=\"initial\"\n      animate=\"animate\"\n      exit=\"exit\"\n      transition={Spring.presets.smooth}\n    >\n      <GlassButton\n        theme=\"dark\"\n        className={GLASS_BUTTON_CLASS}\n        description={t(COPY_MAP.OpenInBrowser())}\n        onClick={() => window.open(src)}\n      >\n        <i className=\"i-mgc-external-link-cute-re\" />\n      </GlassButton>\n      {IN_ELECTRON && (\n        <GlassButton\n          theme=\"dark\"\n          className={GLASS_BUTTON_CLASS}\n          description={t(\"common:words.download\")}\n          onClick={() => {\n            ipcServices?.app.download(src)\n          }}\n        >\n          <i className=\"i-mgc-download-2-cute-re\" />\n        </GlassButton>\n      )}\n\n      <GlassButton\n        theme=\"dark\"\n        description={t(\"common:words.close\")}\n        className={cn(\n          GLASS_BUTTON_CLASS,\n          \"ml-3 !border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50\",\n        )}\n        onClick={dismiss}\n      >\n        <i className=\"i-mgc-close-cute-re\" />\n      </GlassButton>\n    </m.div>\n  )\n}\n\nexport interface PreviewMediaProps extends EntryMedia {\n  fallbackUrl?: string\n}\nexport const PreviewMediaContent: FC<{\n  media: PreviewMediaProps[]\n  initialIndex?: number\n  children?: React.ReactNode\n  onZoomChange?: (isZoomed: boolean) => void\n}> = ({ media, initialIndex = 0, children, onZoomChange }) => {\n  const videoRefs = useRef<(VideoPlayerRef | null)[]>([])\n  const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [\n    WheelGesturesPlugin(),\n  ])\n  const [currentMedia, setCurrentMedia] = useState(media[initialIndex])\n\n  // This only to delay show\n  const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex)\n\n  useEffect(() => {\n    if (emblaApi) {\n      emblaApi.on(\"select\", () => {\n        const realIndex = emblaApi.selectedScrollSnap()\n        setCurrentMedia(media[realIndex])\n        setCurrentSlideIndex(realIndex)\n      })\n    }\n  }, [emblaApi, media])\n\n  const { ref } = useCurrentModal()\n\n  // Keyboard\n  useEffect(() => {\n    if (!emblaApi) return\n    const $container = ref.current\n    if (!$container) return\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"ArrowLeft\") emblaApi?.scrollPrev()\n      if (e.key === \"ArrowRight\") emblaApi?.scrollNext()\n    }\n    $container.addEventListener(\"keydown\", handleKeyDown)\n    return () => $container.removeEventListener(\"keydown\", handleKeyDown)\n  }, [emblaApi, ref])\n\n  const setVideoRef = useCallback((el: VideoPlayerRef | null, index: number) => {\n    videoRefs.current[index] = el\n  }, [])\n\n  // Pause all videos when slide change\n  // And play the current video if it's a video\n  useEffect(() => {\n    videoRefs.current.forEach((video) => {\n      video?.controls.pause()\n    })\n    const currentVideo = videoRefs.current[currentSlideIndex]\n    if (currentVideo) {\n      currentVideo.controls.play()\n    }\n  }, [currentSlideIndex])\n\n  if (media.length === 0) return null\n  if (media.length === 1) {\n    const src = media[0]!.url!\n    const { type } = media[0]!\n    const isVideo = type === \"video\"\n    return (\n      <Wrapper src={src} onZoomChange={onZoomChange} canDragClose={!isVideo}>\n        {(handleZoomChange) => [\n          <Fragment key={src}>\n            {isVideo ? (\n              <VideoPlayer\n                src={src}\n                controls\n                autoPlay\n                muted\n                className={cn(\"h-full w-auto object-contain\", !!children && \"rounded-l-xl\")}\n                onClick={stopPropagation}\n              />\n            ) : (\n              <FallbackableImage\n                fallbackUrl={media[0]!.fallbackUrl}\n                className=\"h-full w-auto object-contain\"\n                alt=\"cover\"\n                src={src}\n                height={media[0]!.height}\n                width={media[0]!.width}\n                blurhash={media[0]!.blurhash}\n                onZoomChange={handleZoomChange}\n              />\n            )}\n          </Fragment>,\n          children,\n        ]}\n      </Wrapper>\n    )\n  }\n  return (\n    <Wrapper src={currentMedia!.url} onZoomChange={onZoomChange} canDragClose={false}>\n      {(handleZoomChange) => [\n        <div key={\"left\"} className=\"group size-full overflow-hidden\" ref={emblaRef}>\n          <div className=\"flex size-full\">\n            {media.map((med, i) => (\n              <div className=\"mr-2 flex w-full flex-none items-center justify-center\" key={med.url}>\n                {med.type === \"video\" ? (\n                  <VideoPlayer\n                    ref={(el) => setVideoRef(el, i)}\n                    src={med.url}\n                    muted\n                    controls\n                    className=\"size-full object-contain\"\n                    onClick={(e) => e.stopPropagation()}\n                  />\n                ) : (\n                  <FallbackableImage\n                    fallbackUrl={med.fallbackUrl}\n                    className=\"size-full object-contain\"\n                    alt=\"cover\"\n                    src={med.url}\n                    loading=\"lazy\"\n                    height={med.height}\n                    width={med.width}\n                    blurhash={med.blurhash}\n                    onZoomChange={handleZoomChange}\n                  />\n                )}\n              </div>\n            ))}\n          </div>\n\n          {currentSlideIndex > 0 && (\n            <GlassButton\n              className={`absolute left-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:left-4 lg:size-10`}\n              onClick={() => {\n                emblaApi?.scrollPrev()\n              }}\n            >\n              <i className={`i-mingcute-left-line text-lg lg:text-xl`} />\n            </GlassButton>\n          )}\n\n          {currentSlideIndex < media.length - 1 && (\n            <GlassButton\n              className={`absolute right-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:right-4 lg:size-10`}\n              onClick={() => {\n                emblaApi?.scrollNext()\n              }}\n            >\n              <i className={`i-mingcute-right-line text-lg lg:text-xl`} />\n            </GlassButton>\n          )}\n        </div>,\n        children,\n      ]}\n    </Wrapper>\n  )\n}\n\nconst FallbackableImage: FC<\n  Omit<React.ImgHTMLAttributes<HTMLImageElement>, \"src\"> & {\n    src: string\n    containerClassName?: string\n    fallbackUrl?: string\n    blurhash?: string\n    onZoomChange?: (isZoomed: boolean) => void\n  }\n> = ({ src, fallbackUrl, containerClassName, onZoomChange, loading }) => {\n  const replaceImgUrlIfNeed = useReplaceImgUrlIfNeed()\n  const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src))\n  const [isAllError, setIsAllError] = useState(false)\n\n  const [isLoading, setIsLoading] = useState(true)\n\n  const [currentState, setCurrentState] = useState<\"proxy\" | \"origin\" | \"fallback\">(() =>\n    currentSrc === src ? \"origin\" : \"proxy\",\n  )\n\n  const handleError = useCallback(() => {\n    switch (currentState) {\n      case \"proxy\": {\n        if (currentSrc !== src) {\n          setCurrentSrc(src)\n          setCurrentState(\"origin\")\n        } else {\n          if (fallbackUrl) {\n            setCurrentSrc(fallbackUrl)\n            setCurrentState(\"fallback\")\n          }\n        }\n\n        break\n      }\n      case \"origin\": {\n        if (fallbackUrl) {\n          setCurrentSrc(fallbackUrl)\n          setCurrentState(\"fallback\")\n        } else {\n          setIsAllError(true)\n        }\n        break\n      }\n      case \"fallback\": {\n        setIsAllError(true)\n      }\n    }\n  }, [currentSrc, currentState, fallbackUrl, src])\n\n  return (\n    <div className={cn(\"relative size-full\", containerClassName)}>\n      {!isAllError && currentSrc && (\n        <DOMImageViewer\n          minZoom={1}\n          maxZoom={2}\n          src={currentSrc}\n          alt=\"preview\"\n          loading={loading}\n          highResLoaded={!isLoading}\n          onLoad={() => setIsLoading(false)}\n          onError={handleError}\n          onZoomChange={onZoomChange}\n        />\n      )}\n      {isAllError && (\n        <div\n          className=\"center pointer-events-none absolute inset-0 flex-col gap-3\"\n          onClick={stopPropagation}\n          tabIndex={-1}\n        >\n          <i className=\"i-mgc-close-cute-re text-[60px] text-red-400\" />\n\n          <span>Failed to load image</span>\n          <div className=\"center gap-2\">\n            <MotionButtonBase\n              className=\"pointer-events-auto underline underline-offset-4\"\n              onClick={() => {\n                setCurrentSrc(replaceImgUrlIfNeed(src))\n                setIsAllError(false)\n              }}\n            >\n              Retry\n            </MotionButtonBase>\n            or\n            <a\n              className=\"pointer-events-auto underline underline-offset-4\"\n              href={src}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              Visit Original\n            </a>\n          </div>\n        </div>\n      )}\n\n      {currentState === \"fallback\" && (\n        <div className=\"absolute bottom-8 left-1/2 mt-4 -translate-x-1/2 rounded-lg bg-material-thick px-3 py-2 text-center text-xs text-text opacity-70 backdrop-blur-background\">\n          <span>\n            This image is preview in low quality, because the original image is not available.\n          </span>\n          <br />\n          <span>\n            You can{\" \"}\n            <a\n              href={src}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"underline duration-200 hover:text-accent\"\n            >\n              visit the original image\n            </a>{\" \"}\n            if you want to see the full quality.\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n\nconst DOMImageViewer: FC<{\n  height?: number\n  width?: number\n  onZoomChange?: (isZoomed: boolean) => any\n  minZoom: number\n  maxZoom: number\n  src: string\n  alt: string\n  highResLoaded: boolean\n  loading?: \"lazy\" | \"eager\"\n  onLoad?: () => void\n  onError?: () => void\n}> = ({\n  height,\n  width,\n  onZoomChange,\n  minZoom,\n  maxZoom,\n  src,\n  alt,\n  highResLoaded,\n  loading = \"eager\",\n  onLoad,\n  onError,\n}) => {\n  const { isDragging: isModalDragging, lastDragEndAt } = use(PreviewWrapperDragContext)\n  const onTransformed = useCallback(\n    (ref: ReactZoomPanPinchRef, state: Omit<ReactZoomPanPinchState, \"previousScale\">) => {\n      const isZoomed = state.scale !== 1\n      onZoomChange?.(isZoomed)\n    },\n    [onZoomChange],\n  )\n  const transformRef = useRef<ReactZoomPanPinchRef>(null)\n\n  useEffect(() => {\n    if (transformRef.current) {\n      transformRef.current.resetTransform()\n    }\n  }, [src])\n\n  const { dismiss } = useCurrentModal()\n\n  return (\n    <div className=\"relative size-full\">\n      <TransformWrapper\n        ref={transformRef}\n        initialScale={1}\n        minScale={minZoom}\n        maxScale={maxZoom}\n        wheel={{\n          step: 0.1,\n        }}\n        pinch={{\n          step: 0.5,\n        }}\n        doubleClick={{\n          step: 2,\n          mode: \"toggle\",\n          animationTime: 200,\n          animationType: \"easeInOutCubic\",\n        }}\n        limitToBounds={true}\n        centerOnInit={true}\n        smooth={true}\n        centerZoomedOut\n        alignmentAnimation={{\n          sizeX: 0,\n          sizeY: 0,\n          velocityAlignmentTime: 0.2,\n        }}\n        velocityAnimation={{\n          sensitivity: 1,\n          animationTime: 0.2,\n        }}\n        onTransformed={onTransformed}\n      >\n        <TransformComponent\n          wrapperProps={{\n            onClick: (e) => {\n              if (\n                (e as React.MouseEvent).detail >= 2 ||\n                isModalDragging ||\n                performance.now() - lastDragEndAt < 150\n              ) {\n                stopPropagation(e)\n                return\n              }\n              const target = e.target as HTMLElement\n              // If click is not on the image container, treat it as overlay click and dismiss\n              if (!target.closest(\"[data-image-container]\")) {\n                dismiss()\n                return\n              }\n              stopPropagation(e)\n            },\n          }}\n          wrapperClass=\"!w-full !h-full !absolute !inset-0 cursor-default\"\n          contentClass=\"!w-full !h-full flex items-center justify-center\"\n        >\n          <div\n            className=\"relative inline-block h-full cursor-grab overflow-hidden\"\n            onClick={stopPropagation}\n            tabIndex={-1}\n            data-image-container\n          >\n            <img\n              height={height}\n              width={width}\n              src={src || undefined}\n              alt={alt}\n              className={cn(\n                \"mx-auto h-full object-contain\",\n                highResLoaded ? \"opacity-100\" : \"opacity-0\",\n              )}\n              draggable={false}\n              loading={loading}\n              decoding=\"async\"\n              onLoad={onLoad}\n              onClick={stopPropagation}\n              onError={onError}\n            />\n          </div>\n        </TransformComponent>\n      </TransformWrapper>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx",
    "content": "import type { MediaModel } from \"@follow/database/schemas/types\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport useEmblaCarousel from \"embla-carousel-react\"\nimport { WheelGesturesPlugin } from \"embla-carousel-wheel-gestures\"\nimport { uniqBy } from \"es-toolkit/compat\"\nimport { useCallback, useRef } from \"react\"\n\nimport { Media } from \"~/components/ui/media/Media\"\n\nconst defaultProxySize = {\n  width: 600,\n  height: 0,\n}\n\nexport function SwipeMedia({\n  media,\n  className,\n  imgClassName,\n  onPreview,\n  proxySize = defaultProxySize,\n  fitContainer,\n}: {\n  media?: MediaModel[] | null\n  className?: string\n  imgClassName?: string\n  onPreview?: (media: MediaModel[], index?: number) => void\n  proxySize?: {\n    width: number\n    height: number\n  } | null\n  fitContainer?: boolean\n}) {\n  const uniqMedia = media ? uniqBy(media, \"url\") : []\n\n  const hoverRef = useRef<HTMLDivElement>(null)\n\n  const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [WheelGesturesPlugin()])\n\n  const scrollPrev = useCallback(\n    (e) => {\n      e.preventDefault()\n      e.stopPropagation()\n      if (emblaApi) emblaApi.scrollPrev()\n    },\n    [emblaApi],\n  )\n\n  const scrollNext = useCallback(\n    (e) => {\n      e.preventDefault()\n      e.stopPropagation()\n      if (emblaApi) emblaApi.scrollNext()\n    },\n    [emblaApi],\n  )\n\n  if (!media) return null\n\n  return (\n    <div\n      ref={hoverRef}\n      className={cn(\n        \"relative flex w-full items-center overflow-hidden\",\n\n        className,\n      )}\n    >\n      {uniqMedia?.length ? (\n        <div ref={emblaRef} className=\"size-full overflow-hidden\">\n          <div className=\"flex size-full\">\n            {uniqMedia?.slice(0, 5).map((med, i) => (\n              <div className=\"mr-2 size-full flex-none\" key={med.url}>\n                <Media\n                  className=\"size-full rounded-none\"\n                  mediaContainerClassName={cn(\"object-cover\", imgClassName)}\n                  alt=\"cover\"\n                  cacheDimensions={med.type === \"photo\"}\n                  src={med.url}\n                  type={med.type}\n                  previewImageUrl={med.preview_image_url}\n                  loading=\"lazy\"\n                  proxy={proxySize || undefined}\n                  blurhash={med.blurhash}\n                  width={med.width}\n                  height={med.height}\n                  onClick={(e) => {\n                    if (onPreview) {\n                      e.stopPropagation()\n                      onPreview(uniqMedia, i)\n                    }\n                  }}\n                  showFallback={true}\n                  fitContent\n                  fitContainer={fitContainer}\n                />\n              </div>\n            ))}\n          </div>\n          {emblaApi?.canScrollPrev() && (\n            <button\n              type=\"button\"\n              className=\"center absolute left-2 top-1/2 size-8 -translate-y-1/2 rounded-full border border-border bg-material-medium text-white opacity-0 backdrop-blur-background duration-200 group-hover:opacity-100\"\n              onClick={scrollPrev}\n              onDoubleClick={stopPropagation}\n            >\n              <i className=\"i-mingcute-left-line\" />\n            </button>\n          )}\n          {emblaApi?.canScrollNext() && (\n            <button\n              type=\"button\"\n              className=\"center absolute right-2 top-1/2 size-8 -translate-y-1/2 rounded-full border border-border bg-material-medium text-white opacity-0 backdrop-blur-background duration-200 group-hover:opacity-100\"\n              onClick={scrollNext}\n              onDoubleClick={stopPropagation}\n            >\n              <i className=\"i-mingcute-right-line\" />\n            </button>\n          )}\n        </div>\n      ) : (\n        <div className=\"relative flex aspect-video w-full items-center overflow-hidden rounded-t-2xl border-b\">\n          <div className=\"flex size-full items-center justify-center p-3 text-center sm:transition-transform sm:duration-500 sm:ease-in-out sm:group-hover:scale-105\">\n            <div className=\"text-xl font-extrabold text-zinc-600\" />\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { ActionButton, MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport type { HTMLMediaState } from \"@follow/hooks\"\nimport { useRefValue, useVideo } from \"@follow/hooks\"\nimport { nextFrame, stopPropagation } from \"@follow/utils/dom\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport * as Slider from \"@radix-ui/react-slider\"\nimport { m, useDragControls, useSpring } from \"motion/react\"\nimport type { PropsWithChildren, RefObject } from \"react\"\nimport {\n  memo,\n  startTransition,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\nimport { useTranslation } from \"react-i18next\"\nimport { createContext, useContext, useContextSelector } from \"use-context-selector\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { AudioPlayer } from \"~/atoms/player\"\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { IconScaleTransition } from \"~/components/ux/transition/icon\"\nimport { HotkeyScope } from \"~/constants\"\n\nimport { VolumeSlider } from \"./VolumeSlider\"\n\ntype VideoPlayerProps = {\n  src: string\n\n  variant?: \"preview\" | \"player\" | \"thumbnail\"\n} & React.VideoHTMLAttributes<HTMLVideoElement> &\n  PropsWithChildren\nexport type VideoPlayerRef = {\n  getElement: () => HTMLVideoElement | null\n\n  getState: () => HTMLMediaState\n  controls: {\n    play: () => Promise<void> | undefined\n    pause: () => void\n    seek: (time: number) => void\n    volume: (volume: number) => void\n    mute: () => void\n    unmute: () => void\n  }\n\n  wrapperRef: RefObject<HTMLDivElement | null>\n}\n\ninterface VideoPlayerContextValue {\n  state: HTMLMediaState\n  controls: VideoPlayerRef[\"controls\"]\n  wrapperRef: RefObject<HTMLDivElement | null>\n  src: string\n  variant: \"preview\" | \"player\" | \"thumbnail\"\n}\nconst VideoPlayerContext = createContext<VideoPlayerContextValue>(null!)\nexport const VideoPlayer = ({\n  ref,\n  src,\n  className,\n  variant = \"player\",\n  ...rest\n}: VideoPlayerProps & {\n  ref?: React.Ref<VideoPlayerRef | null> | ((ref: VideoPlayerRef) => void)\n}) => {\n  const isPlayer = variant === \"player\"\n  const [clickToStatus, setClickToStatus] = useState(null as \"play\" | \"pause\" | null)\n\n  const scaleValue = useSpring(1, Spring.presets.smooth)\n  const opacityValue = useSpring(0, Spring.presets.smooth)\n  const handleClick = useEventCallback((e?: any) => {\n    if (!isPlayer) return\n    e?.stopPropagation()\n\n    if (state.playing) {\n      controls.pause()\n      setClickToStatus(\"pause\")\n    } else {\n      controls.play()\n      setClickToStatus(\"play\")\n    }\n\n    opacityValue.jump(1)\n    scaleValue.jump(1)\n\n    nextFrame(() => {\n      scaleValue.set(1.3)\n      opacityValue.set(0)\n    })\n  })\n\n  const [element, state, controls, videoRef] = useVideo({\n    src,\n    className,\n    playsInline: true,\n    ...rest,\n    controls: false,\n    onClick(e) {\n      rest.onClick?.(e)\n      handleClick(e)\n    },\n    muted: isPlayer ? false : true,\n    onDoubleClick(e) {\n      rest.onDoubleClick?.(e)\n      if (!isPlayer) return\n      e.preventDefault()\n      e.stopPropagation()\n      if (!document.fullscreenElement) {\n        wrapperRef.current?.requestFullscreen()\n      } else {\n        document.exitFullscreen()\n      }\n    },\n  })\n\n  useHotkeys(\"space\", (e) => {\n    e.preventDefault()\n    handleClick()\n  })\n\n  const stateRef = useRefValue(state)\n  const memoedControls = useState(controls)[0]\n  const wrapperRef = useRef<HTMLDivElement>(null)\n  useImperativeHandle(\n    ref,\n    () => ({\n      getElement: () => videoRef.current,\n      getState: () => stateRef.current,\n      controls: memoedControls,\n      wrapperRef,\n    }),\n\n    [stateRef, videoRef, memoedControls],\n  )\n\n  return (\n    <Focusable\n      className=\"group center relative size-full\"\n      ref={wrapperRef}\n      scope={HotkeyScope.VideoPlayer}\n    >\n      {element}\n      <div className=\"center pointer-events-none absolute inset-0\">\n        <m.div\n          className=\"center flex size-20 rounded-full bg-black p-3\"\n          style={{ scale: scaleValue, opacity: opacityValue }}\n        >\n          <i\n            className={cn(\n              \"size-8 text-white\",\n              clickToStatus === \"play\" ? \"i-mgc-play-cute-fi\" : \"i-mgc-pause-cute-fi\",\n            )}\n          />\n        </m.div>\n      </div>\n      {state.hasAudio && !state.muted && state.playing && <BizControlOutsideMedia />}\n      {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n      <VideoPlayerContext.Provider\n        value={useMemo(\n          () => ({ state, controls, wrapperRef, src, variant }),\n          [state, controls, src, variant],\n        )}\n      >\n        {variant === \"preview\" && state.hasAudio && <FloatMutedButton />}\n        {isPlayer && <ControlBar />}\n      </VideoPlayerContext.Provider>\n    </Focusable>\n  )\n}\nconst BizControlOutsideMedia = () => {\n  const currentAudioPlayerIsPlayRef = useMemo(() => AudioPlayer.get().status === \"playing\", [])\n  useEffect(() => {\n    if (currentAudioPlayerIsPlayRef) {\n      AudioPlayer.pause()\n    }\n\n    return () => {\n      if (currentAudioPlayerIsPlayRef) {\n        AudioPlayer.play()\n      }\n    }\n  }, [currentAudioPlayerIsPlayRef])\n\n  return null\n}\n\nconst FloatMutedButton = () => {\n  // eslint-disable-next-line @eslint-react/no-use-context\n  const ctx = useContext(VideoPlayerContext)\n  const isMuted = ctx.state.muted\n  return (\n    <MotionButtonBase\n      className=\"center absolute right-4 top-4 z-10 size-7 rounded-full bg-black/50 opacity-0 duration-200 group-hover:opacity-100\"\n      onClick={(e) => {\n        e.stopPropagation()\n        if (isMuted) {\n          ctx.controls.unmute()\n        } else {\n          ctx.controls.mute()\n        }\n      }}\n    >\n      <IconScaleTransition\n        className=\"size-4 text-white\"\n        icon1=\"i-mgc-volume-cute-re\"\n        icon2=\"i-mgc-volume-mute-cute-re\"\n        status={isMuted ? \"done\" : \"init\"}\n      />\n    </MotionButtonBase>\n  )\n}\n\nconst ControlBar = memo(() => {\n  const { t } = useTranslation()\n  const controls = useContextSelector(VideoPlayerContext, (v) => v.controls)\n  const isPaused = useContextSelector(VideoPlayerContext, (v) => v.state.paused)\n  const dragControls = useDragControls()\n\n  return (\n    <m.div\n      onClick={stopPropagation}\n      drag\n      dragListener={false}\n      dragControls={dragControls}\n      dragElastic={0}\n      dragMomentum={false}\n      dragConstraints={{ current: document.documentElement }}\n      className={cn(\n        \"absolute inset-x-2 bottom-2 h-8 rounded-2xl border border-border bg-material-thick backdrop-blur-background\",\n        \"flex items-center gap-3 px-3\",\n        \"mx-auto max-w-[80vw]\",\n      )}\n    >\n      {/* Drag Area */}\n      <div\n        onPointerDownCapture={dragControls.start.bind(dragControls)}\n        className=\"absolute inset-0 z-[1]\"\n      />\n\n      <ActionIcon\n        shortcut=\"Space\"\n        label={isPaused ? t(\"player.play\") : t(\"player.pause\")}\n        className=\"center relative flex\"\n        onClick={() => {\n          if (isPaused) {\n            controls.play()\n          } else {\n            controls.pause()\n          }\n        }}\n      >\n        <span className=\"center\">\n          <IconScaleTransition\n            status={isPaused ? \"init\" : \"done\"}\n            icon1=\"i-mgc-play-cute-fi\"\n            icon2=\"i-mgc-pause-cute-fi\"\n          />\n        </span>\n      </ActionIcon>\n\n      {/* Progress bar */}\n      <PlayProgressBar />\n\n      {/* Right Action */}\n      <m.div className=\"relative z-[1] flex items-center gap-1\">\n        <VolumeControl />\n        <DownloadVideo />\n        <FullScreenControl />\n      </m.div>\n    </m.div>\n  )\n})\n\nconst FullScreenControl = () => {\n  const { t } = useTranslation()\n  const ref = useContextSelector(VideoPlayerContext, (v) => v.wrapperRef)\n  const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement)\n\n  useEffect(() => {\n    const onFullScreenChange = () => {\n      setIsFullScreen(!!document.fullscreenElement)\n    }\n    document.addEventListener(\"fullscreenchange\", onFullScreenChange)\n    return () => {\n      document.removeEventListener(\"fullscreenchange\", onFullScreenChange)\n    }\n  }, [])\n\n  return (\n    <ActionIcon\n      label={isFullScreen ? t(\"player.exit_full_screen\") : t(\"player.full_screen\")}\n      shortcut=\"f\"\n      onClick={() => {\n        if (!ref.current) return\n\n        if (isFullScreen) {\n          document.exitFullscreen()\n        } else {\n          ref.current.requestFullscreen()\n        }\n      }}\n    >\n      {isFullScreen ? (\n        <i className=\"i-mgc-fullscreen-exit-cute-re\" />\n      ) : (\n        <i className=\"i-mgc-fullscreen-cute-re\" />\n      )}\n    </ActionIcon>\n  )\n}\n\nconst DownloadVideo = () => {\n  const { t } = useTranslation()\n  const src = useContextSelector(VideoPlayerContext, (v) => v.src)\n  const [isDownloading, setIsDownloading] = useState(false)\n  const download = useEventCallback(() => {\n    setIsDownloading(true)\n    fetch(src)\n      .then((res) => res.blob())\n      .then((blob) => {\n        const url = URL.createObjectURL(blob)\n        const a = document.createElement(\"a\")\n        a.href = url\n        a.download = src.split(\"/\").pop()!\n        a.click()\n        URL.revokeObjectURL(url)\n        setIsDownloading(false)\n      })\n  })\n\n  return (\n    <ActionIcon shortcut=\"d\" label={t(\"player.download\")} onClick={download}>\n      {isDownloading ? (\n        <i className=\"i-mgc-loading-3-cute-re animate-spin\" />\n      ) : (\n        <i className=\"i-mgc-download-2-cute-re\" />\n      )}\n    </ActionIcon>\n  )\n}\nconst VolumeControl = () => {\n  const { t } = useTranslation()\n  const hasAudio = useContextSelector(VideoPlayerContext, (v) => v.state.hasAudio)\n\n  const controls = useContextSelector(VideoPlayerContext, (v) => v.controls)\n  const volume = useContextSelector(VideoPlayerContext, (v) => v.state.volume)\n  const muted = useContextSelector(VideoPlayerContext, (v) => v.state.muted)\n  if (!hasAudio) return null\n  return (\n    <ActionIcon\n      label={<VolumeSlider onVolumeChange={controls.volume} volume={volume} />}\n      enableHoverableContent\n      onClick={() => {\n        if (muted) {\n          controls.unmute()\n        } else {\n          controls.mute()\n        }\n      }}\n    >\n      {muted ? (\n        <i className=\"i-mgc-volume-mute-cute-re\" title={t(\"player.unmute\")} />\n      ) : (\n        <i className=\"i-mgc-volume-cute-re\" title={t(\"player.mute\")} />\n      )}\n    </ActionIcon>\n  )\n}\n\nconst PlayProgressBar = () => {\n  // eslint-disable-next-line @eslint-react/no-use-context\n  const { state, controls } = useContext(VideoPlayerContext)\n  const [currentDragging, setCurrentDragging] = useState(false)\n  const [dragTime, setDragTime] = useState(0)\n\n  useHotkeys(\"left\", (e) => {\n    e.preventDefault()\n    controls.seek(state.time - 5)\n  })\n\n  useHotkeys(\"right\", (e) => {\n    e.preventDefault()\n    controls.seek(state.time + 5)\n  })\n  return (\n    <Slider.Root\n      className=\"relative z-[1] flex size-full items-center transition-all duration-200 ease-in-out\"\n      min={0}\n      max={state.duration}\n      step={0.01}\n      value={[currentDragging ? dragTime : state.time]}\n      onPointerDown={() => {\n        if (state.playing) {\n          controls.pause()\n        }\n        setDragTime(state.time)\n        setCurrentDragging(true)\n      }}\n      onValueChange={(value) => {\n        setDragTime(value[0]!)\n        startTransition(() => {\n          controls.seek(value[0]!)\n        })\n      }}\n      onValueCommit={() => {\n        controls.play()\n        setCurrentDragging(false)\n        controls.seek(dragTime)\n      }}\n    >\n      <Slider.Track className=\"relative h-1 w-full grow rounded bg-white dark:bg-neutral-800\">\n        <Slider.Range className=\"absolute h-1 rounded bg-zinc-500/40 dark:bg-neutral-600\" />\n      </Slider.Track>\n\n      {/* indicator */}\n      <Slider.Thumb\n        className=\"block h-3 w-[3px] rounded-[1px] bg-zinc-500 dark:bg-zinc-400\"\n        aria-label=\"Progress\"\n      />\n    </Slider.Root>\n  )\n}\n\nconst ActionIcon = ({\n  className,\n  onClick,\n  children,\n  shortcut,\n  label,\n  enableHoverableContent,\n}: {\n  className?: string\n  onClick?: () => void\n  label: React.ReactNode\n  children?: React.ReactNode\n  shortcut?: string\n  enableHoverableContent?: boolean\n}) => {\n  return (\n    <ActionButton\n      shortcutOnlyFocusWithIn\n      tooltipSide=\"top\"\n      className={clsx(\"z-[2] hover:bg-transparent\", className)}\n      onClick={onClick}\n      tooltip={label}\n      shortcut={shortcut}\n      enableHoverableContent={enableHoverableContent}\n    >\n      {children || <i className={className} />}\n    </ActionButton>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/VolumeSlider.tsx",
    "content": "import * as Slider from \"@radix-ui/react-slider\"\nimport type { FC } from \"react\"\n\nexport const VolumeSlider: FC<{\n  volume: number\n  onVolumeChange: (volume: number) => void\n}> = ({ onVolumeChange, volume }) => (\n  <Slider.Root\n    className=\"relative flex h-16 w-1 flex-col items-center rounded p-1\"\n    max={1}\n    step={0.01}\n    orientation=\"vertical\"\n    value={[volume ?? 0.8]}\n    onValueChange={(values) => {\n      onVolumeChange?.(values[0]!)\n    }}\n  >\n    <Slider.Track className=\"relative w-1 grow rounded bg-white dark:bg-neutral-800\">\n      <Slider.Range className=\"absolute w-full rounded bg-zinc-500/40 dark:bg-neutral-600\" />\n    </Slider.Track>\n\n    {/* indicator */}\n    <Slider.Thumb\n      className=\"block size-3 rounded-full bg-zinc-500 dark:bg-zinc-400\"\n      aria-label=\"Volume\"\n    />\n  </Slider.Root>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/media/hooks.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { use, useCallback } from \"react\"\n\nimport { PlainModal } from \"../modal/stacked/custom-modal\"\nimport { useModalStack } from \"../modal/stacked/hooks\"\nimport { MediaContainerWidthContext } from \"./MediaContainerWidthContext\"\nimport type { PreviewMediaProps } from \"./PreviewMediaContent\"\nimport { PreviewMediaContent } from \"./PreviewMediaContent\"\n\nexport const usePreviewMedia = (children?: React.ReactNode) => {\n  const { present } = useModalStack()\n  return useCallback(\n    (media?: PreviewMediaProps[], initialIndex = 0) => {\n      if (!media || media.length === 0) {\n        return\n      }\n      if (isMobile()) {\n        window.open(media[initialIndex]!.url)\n        return\n      }\n      present({\n        content: () => (\n          <PreviewMediaContent initialIndex={initialIndex} media={media}>\n            {children}\n          </PreviewMediaContent>\n        ),\n        autoFocus: false,\n        title: \"Media Preview\",\n        overlay: false,\n        overlayOptions: {\n          blur: false,\n          className: \"bg-transparent\",\n        },\n        CustomModalComponent: PlainModal,\n        clickOutsideToDismiss: false,\n      })\n    },\n    [children, present],\n  )\n}\n\nexport const useMediaContainerWidth = () => {\n  return use(MediaContainerWidthContext)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/components/close.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\n\nexport const FixedModalCloseButton: Component<{\n  onClick: () => void\n  className?: string\n}> = ({ onClick, className }) => {\n  const { t } = useTranslation(\"common\")\n  return (\n    <GlassButton\n      onClick={onClick}\n      className={cn(\n        \"!border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50\",\n        className,\n      )}\n      description={t(\"words.close\")}\n      size=\"md\"\n      variant=\"flat\"\n    >\n      <i className=\"i-mgc-close-cute-re text-lg\" />\n    </GlassButton>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/helper/useAsyncModal.tsx",
    "content": "/* eslint-disable react-refresh/only-export-components */\nimport { useOnce, useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport type { FC } from \"react\"\nimport { createContext, createElement, use } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport type { ModalActionsInternal } from \"~/components/ui/modal\"\nimport type { UseAsyncFetcher } from \"~/components/ui/modal/stacked/AsyncModalContent\"\nimport { AsyncModalContent } from \"~/components/ui/modal/stacked/AsyncModalContent\"\nimport { NoopChildren } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nexport type AsyncModalOptions<T> = {\n  id: string\n  title: ((data: T) => string) | string\n  icon?: (data: T) => React.ReactNode\n  useDataFetcher: () => UseAsyncFetcher<T>\n  content: FC<ModalActionsInternal & { data: T }>\n\n  // Modal options\n  overlay?: boolean\n  clickOutsideToDismiss?: boolean\n}\nconst AsyncModalContext = createContext<AsyncModalOptions<any>>(null!)\nexport const useAsyncModal = () => {\n  const { present } = useModalStack()\n\n  return useEventCallback(<T,>(options: AsyncModalOptions<T>) => {\n    present({\n      id: options.id,\n      content: () => (\n        <AsyncModalContext value={options}>\n          <LazyContent />\n        </AsyncModalContext>\n      ),\n      title: \"Loading...\",\n      CustomModalComponent: NoopChildren,\n      overlay: options.overlay,\n    })\n  })\n}\n\nconst LazyContent = () => {\n  const ctx = use(AsyncModalContext)\n\n  const queryResult = ctx.useDataFetcher()\n\n  return (\n    <AsyncModalContent\n      queryResult={queryResult}\n      renderContent={useTypeScriptHappyCallback(\n        (data) => (\n          <Presentable data={data} />\n        ),\n        [],\n      )}\n    />\n  )\n}\n\nconst Presentable: FC<{\n  data: any\n}> = ({ data }) => {\n  const { present, dismissTop } = useModalStack()\n  const ctx = use(AsyncModalContext)\n\n  useOnce(() => {\n    dismissTop()\n    present({\n      id: `presentable-${ctx.id}`,\n      content: (props) => createElement(ctx.content, { data, ...props }),\n      title: typeof ctx.title === \"function\" ? ctx.title(data) : ctx.title,\n      icon: ctx.icon?.(data),\n      clickOutsideToDismiss: ctx.clickOutsideToDismiss,\n    })\n  })\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/helper/useModalStackCalculationAndEffect.tsx",
    "content": "import { useAtomValue } from \"jotai\"\nimport { useEffect } from \"react\"\n\nimport { modalStackAtom } from \"../stacked/atom\"\n\nexport const useModalStackCalculationAndEffect = () => {\n  const stack = useAtomValue(modalStackAtom)\n  const topModalIndex = stack.findLastIndex((item) => item.modal)\n  const overlayIndex = stack.findLastIndex((item) => item.overlay || item.modal)\n  const overlayOptions = stack[overlayIndex]?.overlayOptions\n\n  const hasModalStack = stack.length > 0\n  const topModalIsNotSetAsAModal = topModalIndex !== stack.length - 1\n\n  useEffect(() => {\n    // NOTE: document.body is being used by radix's dismissable,\n    // and using that will cause radix to get the value of `none` as the store value,\n    // and then revert to `none` instead of `auto` after a modal dismiss.\n    document.documentElement.style.pointerEvents =\n      hasModalStack && !topModalIsNotSetAsAModal ? \"none\" : \"auto\"\n    document.documentElement.dataset.hasModal = hasModalStack.toString()\n  }, [hasModalStack, topModalIsNotSetAsAModal])\n\n  return {\n    overlayOptions,\n    topModalIndex,\n    hasModalStack,\n    topModalIsNotSetAsAModal,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/index.ts",
    "content": "export * from \"./stacked\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/inspire/InPeekModal.tsx",
    "content": "import { createContext, use } from \"react\"\n\nexport const InPeekModal = createContext(false)\nInPeekModal.displayName = \"InPeekModal\"\nexport const useInPeekModal = () => use(InPeekModal)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/inspire/PeekModal.tsx",
    "content": "import { getStableRouterNavigate } from \"@follow/components/atoms/route.js\"\nimport { RootPortalContext } from \"@follow/components/ui/portal/provider.js\"\nimport type { PropsWithChildren, ReactNode } from \"react\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\n\nimport { FixedModalCloseButton } from \"../components/close\"\nimport { useCurrentModal, useModalStack } from \"../stacked/hooks\"\nimport { InPeekModal } from \"./InPeekModal\"\n\ninterface PeekModalProps {\n  to?: string\n  rightActions?: {\n    onClick: () => void\n    label: string\n    icon: ReactNode\n  }[]\n}\n\nexport const PeekModal = (props: PropsWithChildren<PeekModalProps>) => {\n  const { dismissAll } = useModalStack()\n\n  const { to, children } = props\n  const { t } = useTranslation(\"common\")\n  const { dismiss } = useCurrentModal()\n  const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)\n\n  return (\n    <RootPortalContext value={rootRef as HTMLElement}>\n      <div\n        className=\"relative mx-auto mt-[10vh] max-w-full overflow-hidden px-2 scrollbar-none lg:max-w-[65rem] lg:p-0\"\n        ref={setRootRef}\n      >\n        <m.div\n          exit={{ opacity: 0, y: 50 }}\n          transition={{ duration: 0.2 }}\n          className=\"motion-preset-slide-up overflow-hidden motion-duration-200 motion-ease-spring-smooth scrollbar-none\"\n        >\n          <InPeekModal value={true}>{children}</InPeekModal>\n        </m.div>\n        <m.div\n          initial={true}\n          exit={{\n            opacity: 0,\n          }}\n          className=\"fixed right-4 flex items-center gap-4 safe-inset-top-4\"\n        >\n          {props.rightActions?.map((action) => (\n            <GlassButton\n              key={action.label}\n              onClick={action.onClick}\n              description={action.label}\n              size=\"md\"\n              variant=\"flat\"\n            >\n              {action.icon}\n            </GlassButton>\n          ))}\n          {!!to && (\n            <GlassButton\n              onClick={() => {\n                dismissAll()\n\n                getStableRouterNavigate()?.(to)\n              }}\n              description={t(\"words.expand\")}\n              size=\"md\"\n              variant=\"flat\"\n            >\n              <i className=\"i-mgc-fullscreen-2-cute-re text-lg\" />\n            </GlassButton>\n          )}\n          <FixedModalCloseButton onClick={dismiss} />\n        </m.div>\n      </div>\n    </RootPortalContext>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/AsyncModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { createErrorToaster } from \"~/lib/error-parser\"\n\nexport interface UseAsyncFetcher<T> {\n  data: Nullable<T>\n  error: Nullable<Error>\n  isLoading: boolean\n  refetch: () => void\n}\ninterface AsyncModalContentProps<T> {\n  queryResult: UseAsyncFetcher<T>\n  renderContent: (data: T) => React.ReactNode\n  loadingComponent?: React.ReactNode\n}\n\nfunction useCountdown(durationInSeconds: number): boolean {\n  const [isFinished, setIsFinished] = React.useState(false)\n\n  React.useEffect(() => {\n    const timer = setTimeout(() => {\n      setIsFinished(true)\n    }, durationInSeconds * 1000)\n\n    return () => clearTimeout(timer)\n  }, [durationInSeconds])\n\n  return isFinished\n}\n\nexport function AsyncModalContent<T>({\n  queryResult,\n  renderContent,\n  loadingComponent,\n}: AsyncModalContentProps<T>) {\n  const { data, isLoading, error, refetch } = queryResult\n  const { dismiss } = useCurrentModal()\n\n  const shouldShowCloseButton = useCountdown(2)\n  React.useEffect(() => {\n    if (error) {\n      createErrorToaster()(error)\n    }\n  }, [error])\n  const { t } = useTranslation(\"common\")\n\n  if (isLoading || !data) {\n    return (\n      loadingComponent || (\n        <div className=\"center absolute inset-0 flex-col gap-8\">\n          <LoadingCircle size=\"large\" />\n          <m.div\n            exit={{\n              opacity: 0,\n            }}\n            className=\"flex items-center gap-3\"\n            onFocusCapture={stopPropagation}\n          >\n            {!!error && (\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <Button\n                    onClick={() => refetch()}\n                    variant=\"outline\"\n                    buttonClassName=\"p-2 rounded-full\"\n                    layout\n                  >\n                    <i className=\"i-mgc-refresh-2-cute-re\" />\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent>{t(\"retry\")}</TooltipContent>\n              </Tooltip>\n            )}\n            {(shouldShowCloseButton || !!error) && (\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <Button\n                    onClick={dismiss}\n                    variant=\"outline\"\n                    buttonClassName=\"p-2 rounded-full\"\n                    layout\n                  >\n                    <i className=\"i-mgc-close-cute-re\" />\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent>{t(\"words.close\")}</TooltipContent>\n              </Tooltip>\n            )}\n          </m.div>\n        </div>\n      )\n    )\n  }\n\n  if (error) {\n    return null // Error is already handled by the toaster\n  }\n\n  return renderContent(data)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/atom.ts",
    "content": "import { atom } from \"jotai\"\n\nimport type { ModalProps } from \"./types\"\n\nexport const modalStackAtom = atom([] as (ModalProps & { id: string })[])\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/bus.ts",
    "content": "import { createEventBus } from \"@follow/utils/event-bus\"\n\nexport const ModalEventBus = createEventBus<{\n  DISMISS: ModalDisposeEvent\n  RE_PRESENT: ModalRePresentEvent\n}>()\n\nexport type ModalDisposeEvent = {\n  id: string\n}\n\nexport type ModalRePresentEvent = {\n  id: string\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/components.tsx",
    "content": "import { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useCurrentModal } from \"./hooks\"\n\nexport const ModalClose = () => {\n  const { dismiss } = useCurrentModal()\n  const { t } = useTranslation(\"common\")\n\n  return (\n    <MotionButtonBase\n      data-testid=\"modal-close\"\n      aria-label={t(\"words.close\")}\n      className=\"absolute right-6 top-6 flex size-8 items-center justify-center rounded-md duration-200 hover:bg-material-ultra-thick\"\n      onClick={dismiss}\n    >\n      <i className=\"i-mgc-close-cute-re block\" />\n    </MotionButtonBase>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/constants.ts",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport type { MotionProps, TargetAndTransition } from \"motion/react\"\n\nconst enterStyle: TargetAndTransition = {\n  opacity: 1,\n  // Draw the modal towards the viewer from depth\n  transformPerspective: 1200,\n  z: 0,\n}\nconst initialStyle: TargetAndTransition = {\n  opacity: 0,\n  transformPerspective: 1200,\n  z: -48,\n}\n\nexport const modalMontionConfig = {\n  initial: initialStyle,\n  animate: enterStyle,\n  exit: {\n    ...initialStyle,\n    transition: Spring.presets.smooth,\n  },\n  transition: Spring.presets.snappy,\n} satisfies MotionProps\n\n// Radix context menu z-index 999\nexport const MODAL_STACK_Z_INDEX = 1001\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/context.tsx",
    "content": "import type { FC, RefObject } from \"react\"\nimport { createContext as reactCreateContext } from \"react\"\nimport { createContext as createContextSelector } from \"use-context-selector\"\n\nimport type { ModalProps } from \"./types\"\n\nexport type CurrentModalContentProps = ModalActionsInternal & {\n  ref: RefObject<HTMLElement | null>\n  modalElementRef: RefObject<HTMLElement | null>\n}\n\nconst warnNoProvider = () => {\n  if (import.meta.env.DEV) {\n    console.error(\n      \"No ModalProvider found, please make sure to wrap your component with ModalProvider\",\n    )\n  }\n}\nconst defaultCtxValue: CurrentModalContentProps = {\n  dismiss: warnNoProvider,\n  setClickOutSideToDismiss: warnNoProvider,\n  ref: { current: null },\n  modalElementRef: { current: null },\n  getIndex: () => 0,\n}\n\nexport const CurrentModalContext = reactCreateContext<CurrentModalContentProps>(defaultCtxValue)\nexport const CurrentModalStateContext = createContextSelector<{\n  isTop: boolean\n  isInModal: boolean\n}>({\n  isTop: true,\n  isInModal: false,\n})\n\nexport type ModalContentComponent<T = object> = FC<ModalActionsInternal & T>\nexport type ModalActionsInternal = {\n  dismiss: () => void\n  setClickOutSideToDismiss: (value: boolean) => void\n  getIndex: () => number\n}\n\ntype Disposer = () => void\ntype PresentModalContextInternalFn = (props: ModalProps & { id?: string }) => Disposer\nexport const PresentModalContextInternal = reactCreateContext<PresentModalContextInternalFn>(() => {\n  warnNoProvider()\n  return () => {}\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/custom-modal.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { nextFrame, stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { m, useAnimationControls } from \"motion/react\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useEffect, useState } from \"react\"\nimport type { JSX } from \"react/jsx-runtime\"\n\nimport { ModalClose } from \"./components\"\nimport { useCurrentModal } from \"./hooks\"\n\nexport const PlainModal = ({ children }: PropsWithChildren) => children\n\nexport const PlainWithAnimationModal = ({ children }: PropsWithChildren) => {\n  return (\n    <m.div\n      initial={true}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0 }}\n      transition={Spring.presets.smooth}\n    >\n      {children}\n    </m.div>\n  )\n}\n\nexport { PlainModal as NoopChildren }\n\ntype ModalTemplateType = {\n  (props: PropsWithChildren<{ className?: string }>): JSX.Element\n  class: (className: string) => (props: PropsWithChildren<{ className?: string }>) => JSX.Element\n}\n\nexport const SlideUpModal: ModalTemplateType = (props) => {\n  const winHeight = useState(() => window.innerHeight)[0]\n  const { dismiss } = useCurrentModal()\n  return (\n    <div className={\"container center h-full\"} onPointerDown={dismiss} onClick={stopPropagation}>\n      <m.div\n        onPointerDown={stopPropagation}\n        tabIndex={-1}\n        exit={{\n          y: winHeight,\n          opacity: 0,\n        }}\n        onAnimationComplete={(definition) => {\n          if (definition === \"exit\") {\n            dismiss()\n          }\n        }}\n        className={cn(\n          \"relative flex flex-col items-center overflow-hidden rounded-xl border bg-theme-background p-8 pb-0\",\n          \"aspect-[7/9] w-[600px] max-w-full shadow lg:max-h-[calc(100vh-10rem)]\",\n          \"motion-preset-slide-up motion-duration-200 motion-ease-spring-smooth\",\n          props.className,\n        )}\n      >\n        {props.children}\n\n        <ModalClose />\n      </m.div>\n    </div>\n  )\n}\n\nSlideUpModal.class = (className: string) => {\n  return (props: ComponentType) => (\n    <SlideUpModal {...props} className={cn(props.className, className)} />\n  )\n}\n\nconst modalVariant = {\n  enter: {\n    x: 0,\n    opacity: 1,\n  },\n  initial: {\n    x: 700,\n    opacity: 0.9,\n  },\n  exit: {\n    x: 750,\n    opacity: 0,\n  },\n}\n\nexport const DrawerModalLayout: FC<PropsWithChildren> = ({ children }) => {\n  const { dismiss } = useCurrentModal()\n  const controller = useAnimationControls()\n  useEffect(() => {\n    nextFrame(() => controller.start(\"enter\"))\n  }, [controller])\n\n  return (\n    <div className={\"h-full\"} onPointerDown={dismiss} onClick={stopPropagation}>\n      <m.div\n        onPointerDown={stopPropagation}\n        tabIndex={-1}\n        initial=\"initial\"\n        animate={controller}\n        variants={modalVariant}\n        transition={Spring.presets.snappy}\n        onAnimationComplete={(definition) => {\n          if (definition === \"exit\") {\n            dismiss()\n          }\n        }}\n        exit=\"exit\"\n        layout=\"size\"\n        className={cn(\n          \"flex flex-col items-center overflow-hidden rounded-xl border bg-theme-background p-8 pb-0\",\n          \"shadow-drawer-to-left w-[60ch] max-w-full\",\n          \"fixed bottom-4 right-2 safe-inset-top-4\",\n        )}\n      >\n        {children}\n      </m.div>\n    </div>\n  )\n}\n\nexport const ScaleModal: ModalTemplateType = (props) => {\n  const { dismiss } = useCurrentModal()\n\n  return (\n    <div className={\"container center h-full\"} onPointerDown={dismiss} onClick={stopPropagation}>\n      <m.div\n        onPointerDown={stopPropagation}\n        transition={Spring.presets.snappy}\n        initial={{ transform: \"scale(0)\", opacity: 0 }}\n        animate={{ transform: \"scale(1)\", opacity: 1 }}\n        exit={{ transform: \"scale(0.6)\", opacity: 0 }}\n        className=\"relative\"\n      >\n        {props.children}\n      </m.div>\n    </div>\n  )\n}\n\nScaleModal.class = (className: string) => {\n  return (props: ComponentType) => (\n    <ScaleModal {...props} className={cn(props.className, className)} />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/declarative-modal.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { AnimatePresence } from \"motion/react\"\nimport type { FC, ReactNode } from \"react\"\nimport { useCallback, useEffect, useId, useMemo, useState } from \"react\"\n\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport { modalStackAtom } from \"./atom\"\nimport { ModalInternal } from \"./modal\"\nimport type { ModalProps } from \"./types\"\n\nexport interface DeclarativeModalProps extends Omit<ModalProps, \"content\"> {\n  open?: boolean\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  children?: ReactNode\n\n  id?: string\n}\n\nconst Noop = () => null\nconst DeclarativeModalImpl: FC<DeclarativeModalProps> = ({\n  open,\n  defaultOpen,\n  onOpenChange,\n  children,\n  ...rest\n}) => {\n  const index = useMemo(() => jotaiStore.get(modalStackAtom).length, [])\n  const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false)\n  const id = useId()\n  const item = useMemo(\n    () => ({\n      ...rest,\n      content: Noop,\n      id,\n      open: internalOpen,\n    }),\n    [id, internalOpen, rest],\n  )\n  const handleOpenChange = useCallback(\n    (open: boolean) => {\n      setInternalOpen(open)\n      onOpenChange?.(open)\n    },\n    [onOpenChange, setInternalOpen],\n  )\n  useEffect(() => {\n    if (open !== undefined && open !== internalOpen) {\n      setInternalOpen(open)\n    }\n  }, [open, internalOpen, setInternalOpen])\n  return (\n    <AnimatePresence>\n      {internalOpen && (\n        <ModalInternal isTop onClose={handleOpenChange} index={index} item={item}>\n          {children}\n        </ModalInternal>\n      )}\n    </AnimatePresence>\n  )\n}\n\nconst FooterAction: Component = ({ children, className }) => (\n  <div className={cn(\"mt-4 flex items-center justify-end gap-2\", className)}>{children}</div>\n)\n\nexport const DeclarativeModal = Object.assign(DeclarativeModalImpl, {\n  FooterAction,\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/helper.tsx",
    "content": "import type { Enable } from \"re-resizable\"\nimport type { Context, PropsWithChildren } from \"react\"\nimport { memo, use } from \"react\"\n\nexport const InjectContext = (Context: Context<any>) => {\n  const ctxValue = use(Context)\n  return memo(({ children }: PropsWithChildren) => <Context value={ctxValue}>{children}</Context>)\n}\n\nexport function resizableOnly(...positions: (keyof Enable)[]) {\n  const enable: Enable = {\n    top: false,\n    right: false,\n    bottom: false,\n    left: false,\n    topRight: false,\n    bottomRight: false,\n    bottomLeft: false,\n    topLeft: false,\n  }\n\n  for (const position of positions) {\n    enable[position] = true\n  }\n  return enable\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { atom, useAtomValue } from \"jotai\"\nimport type { DragControls } from \"motion/react\"\nimport type { ResizeCallback, ResizeStartCallback } from \"re-resizable\"\nimport { use, useDeferredValue, useState } from \"react\"\nimport { flushSync } from \"react-dom\"\nimport { useTranslation } from \"react-i18next\"\nimport { useContextSelector } from \"use-context-selector\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport { modalStackAtom } from \"./atom\"\nimport { ModalEventBus } from \"./bus\"\nimport {\n  CurrentModalContext,\n  CurrentModalStateContext,\n  PresentModalContextInternal,\n} from \"./context\"\nimport type { DialogInstance, ModalProps } from \"./types\"\n\nexport const modalIdToPropsMap = {} as Record<string, ModalProps>\nexport const useModalStack = () => {\n  const present = use(PresentModalContextInternal)\n\n  return {\n    present,\n    ...actions,\n  }\n}\nconst actions = {\n  getTopModalStack() {\n    return jotaiStore.get(modalStackAtom).at(-1)\n  },\n  getModalStackById(id: string) {\n    return jotaiStore.get(modalStackAtom).find((item) => item.id === id)\n  },\n  dismiss(id: string) {\n    ModalEventBus.dispatch(\"DISMISS\", {\n      id,\n    })\n  },\n  dismissTop() {\n    const topModal = actions.getTopModalStack()\n\n    if (!topModal) return\n    actions.dismiss(topModal.id)\n  },\n  dismissAll() {\n    const modalStack = jotaiStore.get(modalStackAtom)\n    modalStack.forEach((item) => actions.dismiss(item.id))\n  },\n}\n\nexport const useCurrentModal = () => use(CurrentModalContext)\n\nexport const useIsInModal = () => useContextSelector(CurrentModalStateContext, (v) => v.isInModal)\n\nexport const useResizeableModal = (\n  modalElementRef: React.RefObject<HTMLDivElement | null>,\n  {\n    enableResizeable,\n    dragControls,\n  }: {\n    enableResizeable: boolean\n    dragControls?: DragControls\n  },\n) => {\n  const [resizeableStyle, setResizeableStyle] = useState({} as React.CSSProperties)\n  const [isResizeable, setIsResizeable] = useState(false)\n  const [preferDragDir, setPreferDragDir] = useState<\"x\" | \"y\" | null>(null)\n\n  const relocateModal = useEventCallback(() => {\n    if (!enableResizeable) return\n    if (isResizeable) return\n    const $modalElement = modalElementRef.current\n    if (!$modalElement) return\n\n    const rect = $modalElement.getBoundingClientRect()\n    const { x, y } = rect\n\n    flushSync(() => {\n      setIsResizeable(true)\n      setResizeableStyle({\n        position: \"fixed\",\n        top: `${y}px`,\n        left: `${x}px`,\n      })\n    })\n  })\n  const handleResizeStart = useEventCallback(((e, dir) => {\n    if (!enableResizeable) return\n    relocateModal()\n\n    const hasTop = /top/i.test(dir)\n    const hasLeft = /left/i.test(dir)\n    if (hasTop || hasLeft) {\n      dragControls?.start(e as any)\n      if (hasTop && hasLeft) {\n        setPreferDragDir(null)\n      } else if (hasTop) {\n        setPreferDragDir(\"y\")\n      } else if (hasLeft) {\n        setPreferDragDir(\"x\")\n      }\n    }\n  }) satisfies ResizeStartCallback)\n  const handleResizeStop = useEventCallback((() => {\n    setPreferDragDir(null)\n  }) satisfies ResizeCallback)\n\n  return {\n    resizeableStyle,\n    isResizeable,\n    relocateModal,\n    handleResizeStart,\n    handleResizeStop,\n    preferDragDir,\n  }\n}\n\nexport const useIsTopModal = () => useContextSelector(CurrentModalStateContext, (v) => v.isTop)\n\nexport const useDialog = (): DialogInstance => {\n  const { present } = useModalStack()\n  const { t } = useTranslation()\n  return {\n    /**\n     * Show a confirmation dialog with different visual variants\n     * @param options.variant - Visual style variant:\n     *   - \"ask\" (default): Standard confirmation dialog\n     *   - \"warning\": Warning dialog with yellow icon and yellow confirm button\n     *   - \"danger\": Danger dialog with red icon and red confirm button\n     */\n    ask: useEventCallback((options) => {\n      const variant = options.variant || \"ask\"\n\n      // Variant-specific configuration\n      const variantConfig = {\n        ask: {\n          icon: null,\n          confirmVariant: \"primary\" as const,\n          confirmClassName: \"\",\n        },\n        warning: {\n          icon: <i className=\"i-mingcute-warning-fill size-5 text-yellow\" />,\n          confirmVariant: \"primary\" as const,\n          confirmClassName: \"bg-yellow-500\",\n        },\n        danger: {\n          icon: <i className=\"i-mingcute-warning-fill size-5 text-red\" />,\n          confirmVariant: \"primary\" as const,\n          confirmClassName: \"bg-red-500\",\n        },\n      }\n\n      const config = variantConfig[variant]\n\n      return new Promise<boolean>((resolve) => {\n        present({\n          title: (\n            <div className=\"flex items-center gap-2\">\n              {config.icon}\n              <span>{options.title}</span>\n            </div>\n          ),\n          content: ({ dismiss }) => (\n            <div className=\"flex max-w-[45ch] flex-col gap-3\">\n              <div className=\"whitespace-pre text-wrap\">{options.message}</div>\n\n              <div className=\"flex items-center justify-end gap-3\">\n                <Button\n                  variant=\"outline\"\n                  onClick={() => {\n                    options.onCancel?.()\n                    resolve(false)\n                    dismiss()\n                  }}\n                >\n                  {options.cancelText ?? t(\"words.cancel\", { ns: \"common\" })}\n                </Button>\n                <Button\n                  variant={config.confirmVariant}\n                  buttonClassName={config.confirmClassName}\n                  onClick={() => {\n                    options.onConfirm?.()\n                    resolve(true)\n                    dismiss()\n                  }}\n                >\n                  {options.confirmText ?? t(\"words.confirm\", { ns: \"common\" })}\n                </Button>\n              </div>\n            </div>\n          ),\n          canClose: true,\n          clickOutsideToDismiss: false,\n        })\n      })\n    }),\n  }\n}\n\nconst modalStackLengthAtom = atom((get) => get(modalStackAtom).length)\nexport const useHasModal = () => {\n  //  The keydown event of modal exit is triggered in the same loop,\n  //  leading to unexpected simultaneous responses to other hotkeys,\n  //  so deferredValue is added to delay the update\n  return useDeferredValue(useAtomValue(modalStackLengthAtom) > 0)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/index.ts",
    "content": "export * from \"./context\"\nexport * from \"./helper\"\n// NOTE: This one can easily cause a circular dependency\n// export * from \"./hooks\"\nexport * from \"./modal\"\nexport * from \"./provider\"\nexport * from \"./types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-animate.ts",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { useAnimationControls } from \"motion/react\"\nimport { useCallback, useEffect, useLayoutEffect, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { ModalEventBus } from \"../bus\"\nimport { modalMontionConfig } from \"../constants\"\n\nexport interface ModalAnimateControls {\n  animateController: ReturnType<typeof useAnimationControls>\n  playNoticeAnimation: () => void\n  playExitAnimation: () => Promise<void>\n  isClosing: boolean\n  readyToClose: () => void\n}\n\n/**\n * @internal\n * Hook for managing modal animations including enter, notice, and exit animations\n */\nexport const useModalAnimate = (isTop: boolean, modalId: string): ModalAnimateControls => {\n  const animateController = useAnimationControls()\n  const [isClosing, setIsClosing] = useState(false)\n  // Initial enter animation\n  useEffect(() => {\n    ModalEventBus.subscribe(\"RE_PRESENT\", (data) => {\n      if (data.id !== modalId) {\n        return\n      }\n      setIsClosing(false)\n      animateController.start(modalMontionConfig.animate)\n    })\n    nextFrame(() => {\n      animateController.start(modalMontionConfig.animate)\n    })\n  }, [animateController, modalId, setIsClosing])\n\n  // Notice animation for when modal can't be dismissed\n  const playNoticeAnimation = useCallback(() => {\n    animateController\n      .start({\n        z: 6,\n        transition: Spring.snappy(0.06),\n      })\n      .then(() => {\n        animateController.start({\n          z: 0,\n        })\n      })\n  }, [animateController])\n\n  // Stack position animation\n  useLayoutEffect(() => {\n    if (isTop) return\n    animateController.start({\n      z: -64,\n      rotateX: 2.5,\n      y: 8,\n    })\n    return () => {\n      try {\n        animateController.stop()\n        animateController.start({\n          z: 0,\n          rotateX: 0,\n          y: 0,\n        })\n      } catch {\n        /* empty */\n      }\n    }\n  }, [isTop, animateController])\n\n  // Exit animation\n  const playExitAnimation = useEventCallback(async () => {\n    await animateController.start(modalMontionConfig.exit)\n  })\n\n  return {\n    animateController,\n    playNoticeAnimation,\n    playExitAnimation,\n    isClosing,\n    readyToClose: useEventCallback(() => {\n      if (isClosing) return // Prevent multiple calls\n\n      setIsClosing(true)\n    }),\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-drag.ts",
    "content": "import { useDragControls } from \"motion/react\"\nimport type { PointerEventHandler, RefObject } from \"react\"\nimport { useCallback } from \"react\"\n\nimport { useResizeableModal } from \"../hooks\"\n\n/**\n * @internal\n */\nexport const useModalResizeAndDrag = (\n  modalElementRef: RefObject<HTMLDivElement | null>,\n  {\n    resizeable,\n    draggable,\n  }: {\n    resizeable: boolean\n    draggable: boolean\n  },\n) => {\n  const dragController = useDragControls()\n  const {\n    handleResizeStop,\n    handleResizeStart,\n    relocateModal,\n    preferDragDir,\n    isResizeable,\n    resizeableStyle,\n  } = useResizeableModal(modalElementRef, {\n    enableResizeable: resizeable,\n    dragControls: dragController,\n  })\n\n  const handleDrag: PointerEventHandler<HTMLDivElement> = useCallback(\n    (e) => {\n      if (draggable) {\n        dragController.start(e)\n      }\n    },\n    [dragController, draggable],\n  )\n\n  return {\n    handleDrag,\n    handleResizeStart,\n    handleResizeStop,\n    relocateModal,\n    preferDragDir,\n    isResizeable,\n    resizeableStyle,\n\n    dragController,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-select.ts",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport { useCallback, useRef } from \"react\"\n\n/**\n * @internal\n *\n * Handle select text in modal\n */\nexport const useModalSelect = () => {\n  const isSelectingRef = useRef(false)\n  const handleSelectStart = useCallback(() => {\n    isSelectingRef.current = true\n  }, [])\n  const handleDetectSelectEnd = useCallback(() => {\n    nextFrame(() => {\n      if (isSelectingRef.current) {\n        isSelectingRef.current = false\n      }\n    })\n  }, [])\n\n  return {\n    isSelectingRef,\n    handleSelectStart,\n    handleDetectSelectEnd,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/internal/use-subscriber.ts",
    "content": "import { useEffect } from \"react\"\n\nimport { ModalEventBus } from \"../bus\"\nimport type { ModalActionsInternal } from \"../context\"\n\n/** @internal */\nexport const useModalSubscriber = (id: string, ctx: ModalActionsInternal) => {\n  useEffect(() => {\n    return ModalEventBus.subscribe(\"DISMISS\", (data) => {\n      if (data.id === id) {\n        ctx.dismiss()\n      }\n    })\n  }, [ctx, id])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal-stack.tsx",
    "content": "import { useAtomValue } from \"jotai\"\nimport { AnimatePresence } from \"motion/react\"\n\nimport { useModalStackCalculationAndEffect } from \"../helper/useModalStackCalculationAndEffect\"\nimport { modalStackAtom } from \"./atom\"\nimport { useModalStack } from \"./hooks\"\nimport { ModalInternal } from \"./modal\"\n\nexport const ModalStack = () => {\n  const { present } = useModalStack()\n  window.presentModal = present\n\n  const stack = useAtomValue(modalStackAtom)\n\n  const { topModalIndex, overlayOptions } = useModalStackCalculationAndEffect()\n\n  return (\n    <AnimatePresence mode=\"popLayout\">\n      {stack.map((item, index) => (\n        <ModalInternal\n          key={item.id}\n          item={item}\n          index={index}\n          isTop={index === topModalIndex}\n          isBottom={index === 0}\n          overlayOptions={overlayOptions}\n        />\n      ))}\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx",
    "content": "import { RootPortalContext } from \"@follow/components/ui/portal/provider.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { ZIndexProvider } from \"@follow/components/ui/z-index/index.js\"\nimport { useRefValue } from \"@follow/hooks\"\nimport { ELECTRON_BUILD } from \"@follow/shared/constants\"\nimport { preventDefault, stopPropagation } from \"@follow/utils/dom\"\nimport { cn, getOS } from \"@follow/utils/utils\"\nimport * as Dialog from \"@radix-ui/react-dialog\"\nimport { produce } from \"immer\"\nimport { useAtomValue, useSetAtom } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport type { BoundingBox } from \"motion/react\"\nimport { Resizable } from \"re-resizable\"\nimport type { FC, PropsWithChildren, SyntheticEvent } from \"react\"\nimport {\n  createElement,\n  Fragment,\n  memo,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { AppErrorBoundary } from \"~/components/common/AppErrorBoundary\"\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { SafeFragment } from \"~/components/common/Fragment\"\nimport { m } from \"~/components/common/Motion\"\nimport { ErrorComponentType } from \"~/components/errors/enum\"\nimport { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, HotkeyScope } from \"~/constants\"\n\nimport { modalStackAtom } from \"./atom\"\nimport { MODAL_STACK_Z_INDEX, modalMontionConfig } from \"./constants\"\nimport type { CurrentModalContentProps, ModalActionsInternal } from \"./context\"\nimport { CurrentModalContext, CurrentModalStateContext } from \"./context\"\nimport { useModalAnimate } from \"./internal/use-animate\"\nimport { useModalResizeAndDrag } from \"./internal/use-drag\"\nimport { useModalSelect } from \"./internal/use-select\"\nimport { useModalSubscriber } from \"./internal/use-subscriber\"\nimport { ModalOverlay } from \"./overlay\"\nimport type { ModalOverlayOptions, ModalProps } from \"./types\"\n\nconst DragBar = ELECTRON_BUILD ? (\n  <span className=\"drag-region fixed left-0 right-36 top-0 h-8\" />\n) : null\nexport const ModalInternal = memo(function Modal({\n  ref,\n  item,\n  overlayOptions,\n  onClose: onPropsClose,\n  children,\n  isTop,\n  index,\n  isBottom,\n}: {\n  item: ModalProps & { id: string }\n  index: number\n\n  isTop?: boolean\n  isBottom?: boolean\n  overlayOptions?: ModalOverlayOptions\n  onClose?: (open: boolean) => void\n} & PropsWithChildren & { ref?: React.Ref<HTMLDivElement | null> }) {\n  const {\n    CustomModalComponent,\n    content,\n    title,\n    clickOutsideToDismiss,\n\n    modalClassName,\n    modalContainerClassName,\n    modalContentClassName,\n\n    wrapper: Wrapper = Fragment,\n    max,\n    icon,\n    canClose = true,\n\n    draggable = false,\n    resizeable = false,\n    resizeDefaultSize,\n    modal = true,\n    autoFocus = true,\n  } = item\n\n  const setStack = useSetAtom(modalStackAtom)\n\n  // Animation controls\n  const { animateController, playNoticeAnimation, playExitAnimation, isClosing, readyToClose } =\n    useModalAnimate(!!isTop, item.id)\n\n  // Simple dismiss logic\n  const close = useEventCallback(async (forceClose = false) => {\n    if (!canClose && !forceClose) return\n    readyToClose()\n    try {\n      if (CustomModalComponent) {\n        // Custom modals handle their own animation\n        setStack((p) => p.filter((modal) => modal.id !== item.id))\n      } else {\n        // Play exit animation then remove from stack\\\n        await playExitAnimation()\n        setStack((p) => p.filter((modal) => modal.id !== item.id))\n      }\n    } catch (error) {\n      // If animation fails, still remove from stack\n      console.warn(\"Modal animation failed:\", error)\n      setStack((p) => p.filter((modal) => modal.id !== item.id))\n    }\n\n    item.onClose?.()\n    onPropsClose?.(false)\n  })\n\n  const onClose = useCallback(\n    (open: boolean): void => {\n      if (!open) {\n        close()\n      }\n    },\n    [close],\n  )\n\n  const modalSettingOverlay = useUISettingKey(\"modalOverlay\")\n\n  const dismiss = useCallback(\n    (e: SyntheticEvent) => {\n      e.stopPropagation()\n      close(true)\n    },\n    [close],\n  )\n\n  const modalElementRef = useRef<HTMLDivElement | null>(null)\n  const {\n    handleDrag,\n    handleResizeStart,\n    handleResizeStop,\n    relocateModal,\n    preferDragDir,\n    isResizeable,\n    resizeableStyle,\n\n    dragController,\n  } = useModalResizeAndDrag(modalElementRef, {\n    resizeable,\n    draggable,\n  })\n\n  const getIndex = useEventCallback(() => index)\n  const [modalContentRef, setModalContentRef] = useState<HTMLDivElement | null>(null)\n  const ModalProps: ModalActionsInternal = useMemo(\n    () => ({\n      dismiss: close,\n      getIndex,\n      setClickOutSideToDismiss: (v) => {\n        setStack((state) =>\n          produce(state, (draft) => {\n            const model = draft.find((modal) => modal.id === item.id)\n            if (!model) return\n            if (model.clickOutsideToDismiss === v) return\n            model.clickOutsideToDismiss = v\n          }),\n        )\n      },\n    }),\n    [close, getIndex, item.id, setStack],\n  )\n  useModalSubscriber(item.id, ModalProps)\n\n  const ModalContextProps = useMemo<CurrentModalContentProps>(\n    () => ({\n      ...ModalProps,\n      ref: { current: modalContentRef },\n      modalElementRef,\n    }),\n    [ModalProps, modalContentRef],\n  )\n\n  const [edgeElementRef, setEdgeElementRef] = useState<HTMLDivElement | null>(null)\n\n  const finalChildren = useMemo(\n    () => (\n      <AppErrorBoundary errorType={ErrorComponentType.Modal}>\n        <RootPortalContext value={edgeElementRef as HTMLElement}>\n          {children ?? createElement(content, ModalProps)}\n        </RootPortalContext>\n      </AppErrorBoundary>\n    ),\n    [ModalProps, children, content, edgeElementRef],\n  )\n\n  useEffect(() => {\n    if (isClosing) {\n      // Radix dialog will block pointer events\n      document.body.style.pointerEvents = \"auto\"\n    }\n  }, [isClosing])\n\n  const modalStyle = resizeableStyle\n  const { handleSelectStart, handleDetectSelectEnd, isSelectingRef } = useModalSelect()\n  const handleClickOutsideToDismiss = useCallback(\n    (e: SyntheticEvent) => {\n      if (isSelectingRef.current) return\n\n      if (modal && clickOutsideToDismiss && canClose) {\n        dismiss(e)\n      } else if (modal) {\n        playNoticeAnimation()\n      }\n    },\n    [canClose, clickOutsideToDismiss, dismiss, modal, playNoticeAnimation, isSelectingRef],\n  )\n\n  const openAutoFocus = useCallback(\n    (event: Event) => {\n      if (!autoFocus) {\n        event.preventDefault()\n      }\n    },\n    [autoFocus],\n  )\n\n  const measureDragConstraints = useRef((constraints: BoundingBox) => {\n    if (getOS() === \"Windows\") {\n      return {\n        ...constraints,\n        top: constraints.top + ElECTRON_CUSTOM_TITLEBAR_HEIGHT,\n      }\n    }\n    return constraints\n  }).current\n\n  useImperativeHandle(ref, () => modalElementRef.current!)\n  const currentModalZIndex = MODAL_STACK_Z_INDEX + index * 2\n\n  const Overlay = (\n    <ModalOverlay\n      zIndex={currentModalZIndex - 1}\n      blur={overlayOptions?.blur}\n      hidden={item.overlay ? isClosing : !(modalSettingOverlay && isBottom) || isClosing}\n    />\n  )\n\n  const mutateableEdgeElementRef = useRefValue(edgeElementRef)\n\n  if (CustomModalComponent) {\n    return (\n      <Wrapper>\n        <Dialog.Root open onOpenChange={onClose} modal={modal}>\n          <Dialog.Portal>\n            {Overlay}\n            <Dialog.Content\n              ref={setModalContentRef}\n              asChild\n              aria-describedby={undefined}\n              onPointerDownOutside={preventDefault}\n              onOpenAutoFocus={openAutoFocus}\n            >\n              <Focusable\n                scope={HotkeyScope.Modal}\n                ref={setEdgeElementRef}\n                className={cn(\n                  \"no-drag-region fixed\",\n                  modal ? \"inset-0 overflow-auto\" : \"left-0 top-0\",\n                  isClosing ? \"!pointer-events-none\" : \"!pointer-events-auto\",\n                  modalContainerClassName,\n                )}\n                style={{\n                  zIndex: currentModalZIndex,\n                }}\n                onPointerUp={handleDetectSelectEnd}\n                onClick={handleClickOutsideToDismiss}\n                onFocus={stopPropagation}\n                tabIndex={-1}\n              >\n                <Dialog.DialogTitle className=\"sr-only\">{title}</Dialog.DialogTitle>\n                {DragBar}\n                <div\n                  className={cn(\"contents\", modalClassName, modalContentClassName)}\n                  onClick={stopPropagation}\n                  tabIndex={-1}\n                  ref={modalElementRef}\n                  onSelect={handleSelectStart}\n                  onKeyUp={handleDetectSelectEnd}\n                >\n                  <ModalContext modalContextProps={ModalContextProps} isTop={!!isTop}>\n                    <CustomModalComponent>{finalChildren}</CustomModalComponent>\n                  </ModalContext>\n                </div>\n              </Focusable>\n            </Dialog.Content>\n          </Dialog.Portal>\n        </Dialog.Root>\n      </Wrapper>\n    )\n  }\n\n  const ResizeSwitch = resizeable ? Resizable : SafeFragment\n\n  return (\n    <Wrapper>\n      <Dialog.Root modal={modal} open onOpenChange={onClose}>\n        <Dialog.Portal>\n          {Overlay}\n          <Dialog.Content\n            ref={setModalContentRef}\n            asChild\n            aria-describedby={undefined}\n            onPointerDownOutside={preventDefault}\n            onOpenAutoFocus={openAutoFocus}\n          >\n            <Focusable\n              scope={HotkeyScope.Modal}\n              ref={setEdgeElementRef}\n              onContextMenu={preventDefault}\n              className={cn(\n                \"fixed flex\",\n                modal ? \"inset-0 overflow-auto\" : \"left-0 top-0\",\n                isClosing && \"!pointer-events-none\",\n                modalContainerClassName,\n                !isResizeable && \"center\",\n              )}\n              onFocus={stopPropagation}\n              onPointerUp={handleDetectSelectEnd}\n              onClick={handleClickOutsideToDismiss}\n              style={{\n                zIndex: currentModalZIndex,\n                perspective: 1200,\n              }}\n              tabIndex={-1}\n            >\n              {DragBar}\n\n              <m.div\n                ref={modalElementRef}\n                style={{\n                  ...modalStyle,\n                  backgroundImage:\n                    \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n                  boxShadow:\n                    \"0 6px 20px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px hsl(var(--fo-a) / 0.04), 0 1px 3px rgba(0, 0, 0, 0.03)\",\n                }}\n                {...modalMontionConfig}\n                animate={animateController}\n                className={cn(\n                  \"relative flex flex-col overflow-hidden rounded-xl px-2 pt-1\",\n                  \"backdrop-blur-2xl [transform-style:preserve-3d]\",\n                  max ? \"h-[90vh] w-[90vw]\" : \"max-h-[90vh]\",\n                  \"dark:border dark:border-border/50\",\n                  modalClassName,\n                )}\n                tabIndex={-1}\n                onClick={stopPropagation}\n                onSelect={handleSelectStart}\n                onKeyUp={handleDetectSelectEnd}\n                drag={draggable && (preferDragDir || draggable)}\n                dragControls={dragController}\n                dragElastic={0}\n                dragListener={false}\n                dragMomentum={false}\n                dragConstraints={mutateableEdgeElementRef}\n                onMeasureDragConstraints={measureDragConstraints}\n                whileDrag={{\n                  cursor: \"grabbing\",\n                }}\n              >\n                <ResizeSwitch\n                  // enable={resizableOnly(\"bottomRight\")}\n                  onResizeStart={handleResizeStart}\n                  onResizeStop={handleResizeStop}\n                  defaultSize={resizeDefaultSize}\n                  className=\"relative z-10 flex grow flex-col\"\n                >\n                  <div className={\"relative flex flex-col\"}>\n                    <div className={\"flex items-center\"}>\n                      <Dialog.Title\n                        className=\"flex w-0 max-w-full grow items-center gap-2 px-2 pb-1 pt-2 text-base font-medium text-text\"\n                        onPointerDownCapture={handleDrag}\n                        onPointerDown={relocateModal}\n                      >\n                        {!!icon && <span className=\"center flex size-4\">{icon}</span>}\n                        <EllipsisHorizontalTextWithTooltip className=\"truncate\">\n                          <span>{title}</span>\n                        </EllipsisHorizontalTextWithTooltip>\n                      </Dialog.Title>\n                      {canClose && (\n                        <Dialog.DialogClose\n                          data-testid=\"modal-close\"\n                          className=\"center z-[2] -mr-1 rounded-lg p-2 text-text-secondary hover:bg-fill-quaternary hover:text-text\"\n                          tabIndex={1}\n                          onClick={close}\n                        >\n                          <i className=\"i-mgc-close-cute-re\" />\n                        </Dialog.DialogClose>\n                      )}\n                    </div>\n\n                    {(title || icon || canClose) && (\n                      <div className=\"mx-1 mt-1 h-px shrink-0 bg-border\" />\n                    )}\n                  </div>\n\n                  <div\n                    className={cn(\n                      \"-mx-2 min-h-0 shrink grow overflow-auto overflow-x-hidden px-4 pb-4 pt-3 text-sm text-text\",\n                      modalContentClassName,\n                    )}\n                  >\n                    <ModalContext modalContextProps={ModalContextProps} isTop={!!isTop}>\n                      {finalChildren}\n                    </ModalContext>\n                  </div>\n                </ResizeSwitch>\n              </m.div>\n            </Focusable>\n          </Dialog.Content>\n        </Dialog.Portal>\n      </Dialog.Root>\n    </Wrapper>\n  )\n})\n\nconst ModalContext: FC<\n  PropsWithChildren & {\n    modalContextProps: CurrentModalContentProps\n    isTop: boolean\n  }\n> = ({ modalContextProps, isTop, children }) => {\n  const { getIndex } = modalContextProps\n  const zIndex = useAtomValue(\n    useMemo(\n      () => selectAtom(modalStackAtom, (v) => v.length + MODAL_STACK_Z_INDEX + getIndex() + 1),\n      [getIndex],\n    ),\n  )\n\n  return (\n    <CurrentModalContext value={modalContextProps}>\n      {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n      <CurrentModalStateContext.Provider\n        value={useMemo(\n          () => ({\n            isTop: !!isTop,\n            isInModal: true,\n          }),\n          [isTop],\n        )}\n      >\n        <ZIndexProvider zIndex={zIndex}>{children}</ZIndexProvider>\n      </CurrentModalStateContext.Provider>\n    </CurrentModalContext>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/overlay.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as Dialog from \"@radix-ui/react-dialog\"\nimport { AnimatePresence } from \"motion/react\"\nimport type { FC } from \"react\"\n\nimport { m } from \"~/components/common/Motion\"\n\nexport const ModalOverlay: FC<{\n  ref?: React.Ref<HTMLDivElement | null>\n  zIndex?: number\n  blur?: boolean\n  className?: string\n  hidden?: boolean\n}> = ({ ref, zIndex, blur, className, hidden }) => (\n  <Dialog.Overlay>\n    <AnimatePresence>\n      {!hidden && (\n        <m.div\n          ref={ref}\n          id=\"modal-overlay\"\n          className={cn(\n            // NOTE: pointer-events-none is required, if remove this, when modal is closing, you can not click element behind the modal\n            \"!pointer-events-none fixed inset-0 rounded-[var(--fo-window-radius)] bg-material-ultra-thick\",\n            blur && \"backdrop-blur-sm\",\n            className,\n          )}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          style={{ zIndex }}\n        />\n      )}\n    </AnimatePresence>\n  </Dialog.Overlay>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/provider.tsx",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useId, useRef } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { getUISettings } from \"~/atoms/settings/ui\"\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport { modalStackAtom } from \"./atom\"\nimport { ModalEventBus } from \"./bus\"\nimport { PresentModalContextInternal } from \"./context\"\nimport { modalIdToPropsMap } from \"./hooks\"\nimport { ModalStack } from \"./modal-stack\"\nimport type { ModalProps } from \"./types\"\n\ndeclare global {\n  interface Window {\n    presentModal: (modal: ModalProps & { id?: string }) => void\n  }\n}\n\nexport const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => {\n  const id = useId()\n  const currentCount = useRef(0)\n  const presentModal = useEventCallback((props: ModalProps & { id?: string }) => {\n    const fallbackModelId = `${id}-${++currentCount.current}`\n    const modalId = props.id ?? fallbackModelId\n    const presentSync = (props: ModalProps & { id?: string }) => {\n      const currentStack = jotaiStore.get(modalStackAtom)\n\n      const existingModal = currentStack.find((item) => item.id === modalId)\n\n      if (existingModal) {\n        // Move to top\n        jotaiStore.set(modalStackAtom, (p) => {\n          const index = p.indexOf(existingModal)\n          return [...p.slice(0, index), ...p.slice(index + 1), existingModal]\n        })\n        ModalEventBus.dispatch(\"RE_PRESENT\", { id: modalId })\n      } else {\n        // NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.\n        // There is no need to inject `overlay` props, this is rendered responsively based on ui changes.\n        const uiSettings = getUISettings()\n        const modalConfig: Partial<ModalProps> = {\n          draggable: uiSettings.modalDraggable,\n          modal: true,\n        }\n        jotaiStore.set(modalStackAtom, (p) => {\n          const modalProps: ModalProps = {\n            ...modalConfig,\n            ...props,\n          }\n          modalIdToPropsMap[modalId] = modalProps\n          return p.concat({\n            id: modalId,\n            ...modalProps,\n          })\n        })\n      }\n    }\n\n    nextFrame(() => presentSync(props))\n\n    return () => {\n      jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))\n    }\n  })\n\n  return (\n    <PresentModalContextInternal value={presentModal}>\n      {children}\n      <ModalStack />\n    </PresentModalContextInternal>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/modal/stacked/types.tsx",
    "content": "import type { FC, PropsWithChildren, ReactNode } from \"react\"\n\nimport type { ModalActionsInternal } from \"./context\"\n\nexport interface ModalOverlayOptions {\n  blur?: boolean\n  className?: string\n}\nexport interface ModalProps {\n  title: ReactNode\n  icon?: ReactNode\n\n  CustomModalComponent?: FC<PropsWithChildren>\n\n  content: FC<ModalActionsInternal>\n  clickOutsideToDismiss?: boolean\n  modalClassName?: string\n  modalContainerClassName?: string\n  modalContentClassName?: string\n  max?: boolean\n\n  wrapper?: FC\n\n  overlay?: boolean\n  overlayOptions?: ModalOverlayOptions\n  draggable?: boolean\n  canClose?: boolean\n  resizeable?: boolean\n  resizeDefaultSize?: { width: number; height: number }\n\n  modal?: boolean\n\n  autoFocus?: boolean\n  onClose?: () => void\n}\n\nexport interface DialogInstance {\n  ask: (options: {\n    title: string\n    message: string\n    variant?: \"ask\" | \"warning\" | \"danger\"\n    onConfirm?: () => void\n    onCancel?: () => void\n    confirmText?: string\n    cancelText?: string\n  }) => Promise<boolean>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/paper/Paper.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { JSX } from \"react\"\n\nexport const Paper: Component<{\n  as?: keyof JSX.IntrinsicElements | Component\n}> = ({ children, className, as: As = \"main\" }) => (\n  <As\n    className={cn(\n      \"relative bg-background md:col-start-1 lg:col-auto\",\n      \"-m-4 p-[2rem_1rem] md:m-0 lg:p-[30px_45px]\",\n      \"rounded-lg border-border lg:border\",\n      \"shadow-perfect perfect-sm\",\n      \"min-w-0\",\n      \"print:!border-none print:!bg-transparent print:!shadow-none\",\n      className,\n    )}\n  >\n    {children}\n  </As>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/paper/index.ts",
    "content": "export * from \"./Paper\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/peek-modal/EntryModalPreview.tsx",
    "content": "import { usePrefetchEntryDetail } from \"@follow/store/entry/hooks\"\n\nimport { Paper } from \"~/components/ui/paper\"\nimport { EntryContentForPreview } from \"~/modules/entry-content/EntryContentForPreview\"\n\nexport const EntryModalPreview = ({ entryId }: { entryId: string }) => {\n  const { isPending } = usePrefetchEntryDetail(entryId)\n\n  return (\n    <Paper className=\"p-0 !pt-16 empty:hidden\">\n      {isPending ? (\n        <PeekModalSkeleton />\n      ) : (\n        <EntryContentForPreview\n          className=\"h-auto [&_#entry-action-header-bar]:!bg-transparent\"\n          entryId={entryId}\n        />\n      )}\n    </Paper>\n  )\n}\n\nconst PeekModalSkeleton = () => {\n  return (\n    <div className=\"animate-pulse p-5\">\n      <div className=\"mb-6 space-y-3\">\n        <div className=\"h-8 w-3/4 rounded-lg bg-fill\" />\n        <div className=\"flex items-center space-x-4\">\n          <div className=\"h-4 w-20 rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-16 rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-24 rounded bg-fill-secondary\" />\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        <div className=\"space-y-3\">\n          <div className=\"h-4 w-full rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-5/6 rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-4/5 rounded bg-fill-secondary\" />\n        </div>\n\n        <div className=\"space-y-3\">\n          <div className=\"h-4 w-full rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-3/4 rounded bg-fill-secondary\" />\n        </div>\n\n        <div className=\"my-6 h-48 w-full rounded-lg bg-fill\" />\n\n        <div className=\"space-y-3\">\n          <div className=\"h-4 w-full rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-5/6 rounded bg-fill-secondary\" />\n          <div className=\"h-4 w-2/3 rounded bg-fill-secondary\" />\n        </div>\n      </div>\n\n      <div className=\"mt-8 flex items-center space-x-3\">\n        <div className=\"h-8 w-16 rounded bg-fill\" />\n        <div className=\"h-8 w-20 rounded bg-fill\" />\n        <div className=\"h-8 w-16 rounded bg-fill\" />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/peek-modal/EntryMoreActions.tsx",
    "content": "import { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\n\nimport { MenuItemText } from \"~/atoms/context-menu\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useSortedEntryActions } from \"~/hooks/biz/useEntryActions\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { hasCommand } from \"~/modules/command/hooks/use-command\"\nimport { CommandDropdownMenuItem } from \"~/modules/entry-content/actions/more-actions\"\n\nexport const EntryMoreActions: FC<{ entryId: string }> = ({ entryId }) => {\n  const { view } = getRouteParams()\n  const { moreAction, mainAction } = useSortedEntryActions({ entryId, view })\n\n  const actionConfigs = useMemo(\n    () =>\n      [...moreAction, ...mainAction].filter(\n        (action) => action instanceof MenuItemText && hasCommand(action.id),\n      ),\n    [moreAction, mainAction],\n  )\n\n  const availableActions = useMemo(\n    () =>\n      actionConfigs.filter(\n        (item) => item instanceof MenuItemText && item.id !== COMMAND_ID.settings.customizeToolbar,\n      ),\n    [actionConfigs],\n  )\n\n  const extraAction = useMemo(\n    () =>\n      actionConfigs.filter(\n        (item) => item instanceof MenuItemText && item.id === COMMAND_ID.settings.customizeToolbar,\n      ),\n    [actionConfigs],\n  )\n\n  if (availableActions.length === 0 && extraAction.length === 0) {\n    return null\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <i className=\"i-mingcute-more-1-fill\" />\n      </DropdownMenuTrigger>\n      <RootPortal>\n        <DropdownMenuContent alignOffset={20} sideOffset={30}>\n          {availableActions.map((config) =>\n            config instanceof MenuItemText ? (\n              <CommandDropdownMenuItem\n                key={config.id}\n                commandId={config.id}\n                onClick={config.click!}\n                active={config.active}\n              />\n            ) : null,\n          )}\n        </DropdownMenuContent>\n      </RootPortal>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ui/peek-modal/EntryToastPreview.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { useEntry, usePrefetchEntryDetail } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { nextFrame, stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { Variant } from \"motion/react\"\nimport { m, useAnimationControls } from \"motion/react\"\nimport { useEffect } from \"react\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { usePreviewMedia } from \"~/components/ui/media/hooks\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport { StarIcon } from \"~/modules/entry-column/star-icon\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\n\nconst variants: Record<string, Variant> = {\n  enter: {\n    x: 0,\n    opacity: 1,\n  },\n  initial: {\n    x: 700,\n    opacity: 0.9,\n  },\n  exit: {\n    x: 750,\n    opacity: 0,\n  },\n}\n\nexport const EntryToastPreview = ({ entryId }: { entryId: string }) => {\n  usePrefetchEntryDetail(entryId)\n\n  const entry = useEntry(entryId, (state) => {\n    const { feedId } = state\n    const { author, authorAvatar, description, publishedAt } = state\n\n    const media = state.media || []\n    const firstPhotoUrl = media.find((a) => a.type === \"photo\")?.url\n    const iconEntry: FeedIconEntry = {\n      firstPhotoUrl,\n      authorAvatar,\n    }\n\n    return {\n      author,\n      description,\n      feedId,\n      iconEntry,\n      media,\n      publishedAt,\n    }\n  })\n  const isInCollection = useIsEntryStarred(entryId)\n\n  const feed = useFeedById(entry?.feedId)\n  const controller = useAnimationControls()\n\n  const isDisplay = !!entry && !!feed\n  useEffect(() => {\n    if (isDisplay) {\n      nextFrame(() => controller.start(\"enter\"))\n    }\n  }, [controller, isDisplay])\n\n  const previewMedia = usePreviewMedia()\n\n  if (!isDisplay) return null\n\n  return (\n    <m.div\n      tabIndex={-1}\n      initial=\"initial\"\n      animate={controller}\n      onPointerDown={stopPropagation}\n      onPointerDownCapture={stopPropagation}\n      variants={variants}\n      onWheel={stopPropagation}\n      transition={Spring.presets.snappy}\n      exit=\"exit\"\n      layout=\"size\"\n      className={cn(\n        \"shadow-perfect relative flex flex-col items-center rounded-xl border bg-theme-background p-8\",\n        \"mr-4 mt-4 max-h-[500px] w-[60ch] max-w-full overflow-auto\",\n      )}\n    >\n      <div className=\"flex w-full gap-3\">\n        <FeedIcon\n          fallback\n          className=\"mask-squircle mask\"\n          target={feed}\n          entry={entry.iconEntry}\n          size={36}\n        />\n        <div className=\"flex min-w-0 grow flex-col\">\n          <div className=\"w-[calc(100%-10rem)] space-x-1\">\n            <span className=\"font-semibold\">{entry.author}</span>\n            <span className=\"text-zinc-500\">·</span>\n            <span className=\"text-zinc-500\">\n              <RelativeTime date={entry.publishedAt} />\n            </span>\n          </div>\n          <div\n            className={cn(\n              \"relative mt-0.5 whitespace-pre-line text-base\",\n              isInCollection && \"pr-5\",\n            )}\n          >\n            <div\n              className={cn(\n                \"rounded-xl p-3 align-middle text-[15px]\",\n                \"rounded-tl-none bg-zinc-600/5 dark:bg-zinc-500/20\",\n                \"mt-1 -translate-x-3\",\n                \"break-words\",\n              )}\n            >\n              {entry.description}\n\n              {!!entry.media?.length && (\n                <div className=\"mt-1 flex w-full gap-2 overflow-x-auto\">\n                  {entry.media.map((media, i, mediaList) => (\n                    <Media\n                      key={media.url}\n                      src={media.url}\n                      type={media.type}\n                      previewImageUrl={media.preview_image_url}\n                      className=\"size-28 shrink-0 cursor-zoom-in\"\n                      loading=\"lazy\"\n                      proxy={{\n                        width: 224,\n                        height: 224,\n                      }}\n                      onClick={(e: React.MouseEvent) => {\n                        e.stopPropagation()\n                        previewMedia(mediaList, i)\n                      }}\n                    />\n                  ))}\n                </div>\n              )}\n            </div>\n            {isInCollection && <StarIcon />}\n          </div>\n\n          {/* End right column */}\n        </div>\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ux/pull-to-refresh/index.tsx",
    "content": "import { clsx } from \"@follow/utils/utils\"\nimport type { ReactNode } from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\n\nimport { ENTRY_COLUMN_LIST_SCROLLER_ID } from \"~/constants/dom\"\n\ninterface PullToRefreshProps {\n  children: ReactNode\n  onRefresh: () => Promise<any>\n  className?: string\n  scrollContainerSelector?: string\n}\nconst THRESHOLD = 80\nconst MAX_PULL_DISTANCE = 120\n\nexport function PullToRefresh({\n  children,\n  onRefresh,\n  className,\n  scrollContainerSelector = `#${ENTRY_COLUMN_LIST_SCROLLER_ID}`,\n}: PullToRefreshProps) {\n  const [startY, setStartY] = useState(0)\n  const [pulling, setPulling] = useState(false)\n  const [pullDistance, setPullDistance] = useState(0)\n  const [shouldAllowPull, setShouldAllowPull] = useState(false)\n\n  const [isRefreshing, setIsRefreshing] = useState(false)\n\n  useEffect(() => {\n    if (!pulling) {\n      setPullDistance(0)\n    }\n  }, [pulling])\n  const containerRef = useRef<HTMLDivElement>(null)\n  const contentRef = useRef<HTMLDivElement>(null)\n  useEffect(() => {\n    const element = containerRef.current\n    if (!element) return\n\n    const touchStartHandler = (e: TouchEvent) => {\n      const scrollContainer = contentRef.current?.querySelector(\n        scrollContainerSelector,\n      ) as HTMLElement\n      if (!scrollContainer) return\n\n      const touchY = e.touches[0]!.clientY\n      setStartY(touchY)\n\n      if (scrollContainer.scrollTop <= 0) {\n        setShouldAllowPull(true)\n      } else {\n        setShouldAllowPull(false)\n      }\n    }\n\n    const touchMoveHandler = (e: TouchEvent) => {\n      if (!shouldAllowPull || isRefreshing) return\n\n      const y = e.touches[0]!.clientY\n      const delta = y - startY\n\n      if (delta > 0) {\n        e.preventDefault()\n        setPulling(true)\n        setPullDistance(Math.min(delta, MAX_PULL_DISTANCE))\n      }\n    }\n\n    const touchEndHandler = async () => {\n      if (!pulling || isRefreshing) return\n\n      setPulling(false)\n\n      if (pullDistance >= THRESHOLD) {\n        const promise = onRefresh()\n        setIsRefreshing(true)\n\n        try {\n          await promise\n        } finally {\n          setIsRefreshing(false)\n          setPullDistance(0)\n        }\n      }\n    }\n\n    element.addEventListener(\"touchstart\", touchStartHandler, { passive: true })\n    element.addEventListener(\"touchmove\", touchMoveHandler, { passive: false })\n    element.addEventListener(\"touchend\", touchEndHandler, { passive: true })\n\n    return () => {\n      element.removeEventListener(\"touchstart\", touchStartHandler)\n      element.removeEventListener(\"touchmove\", touchMoveHandler)\n      element.removeEventListener(\"touchend\", touchEndHandler)\n    }\n  }, [\n    startY,\n    shouldAllowPull,\n    pulling,\n    pullDistance,\n    onRefresh,\n    scrollContainerSelector,\n    isRefreshing,\n  ])\n\n  // Calculate the actual pull-down distance\n  const actualPullDistance = isRefreshing\n    ? THRESHOLD // Stay at threshold position when refreshing\n    : pullDistance\n\n  // Calculate the pull-down progress (0-1)\n  const pullProgress = Math.max(Math.min(pullDistance / THRESHOLD - 0.2, 1), 0)\n  const SIZE = 24\n  const STROKE_WIDTH = 2\n  const RADIUS = (SIZE - STROKE_WIDTH) / 2\n  const CIRCUMFERENCE = 2 * Math.PI * RADIUS\n  const strokeDashoffset = CIRCUMFERENCE * (1 - pullProgress)\n\n  return (\n    <div ref={containerRef} className={clsx(\"relative touch-none\", className)}>\n      <div\n        className={clsx(\n          \"absolute inset-x-0 flex items-center justify-center\",\n          actualPullDistance > 0 ? \"opacity-100\" : \"opacity-0\",\n          !pulling && \"duration-200\",\n        )}\n        style={{\n          transform: `translateY(${actualPullDistance - 60}px)`,\n        }}\n      >\n        <svg\n          width={SIZE}\n          height={SIZE}\n          viewBox={`0 0 ${SIZE} ${SIZE}`}\n          className={clsx(isRefreshing ? \"animate-spin\" : \"\")}\n        >\n          <circle\n            cx={SIZE / 2}\n            cy={SIZE / 2}\n            r={RADIUS}\n            fill=\"none\"\n            strokeWidth={STROKE_WIDTH}\n            className=\"stroke-zinc-500/70\"\n            strokeDasharray={CIRCUMFERENCE}\n            strokeDashoffset={isRefreshing ? 20 : strokeDashoffset}\n            strokeLinecap=\"round\"\n          />\n        </svg>\n      </div>\n\n      {/* Content area */}\n      <div\n        ref={contentRef}\n        className={clsx(\"h-full will-change-transform\", !pulling ? \"transition-transform\" : \"\")}\n        style={{\n          transform: `translateY(${actualPullDistance}px)`,\n        }}\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/components/ux/transition/icon.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { TargetAndTransition, Transition } from \"motion/react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport * as React from \"react\"\nimport { cloneElement, useEffect, useState } from \"react\"\n\ntype TransitionType = {\n  initial: TargetAndTransition | boolean\n  animate: TargetAndTransition\n  exit: TargetAndTransition\n}\n\ntype IconTransitionProps = {\n  icon1: string | React.JSX.Element\n  icon2: string | React.JSX.Element\n  status: \"init\" | \"done\"\n  className?: string\n  icon1ClassName?: string\n  icon2ClassName?: string\n}\n\nconst createIconTransition =\n  (transitionType: TransitionType) =>\n  ({ icon1, icon2, status, className, icon1ClassName, icon2ClassName }: IconTransitionProps) => {\n    const [isMount, setIsMounted] = useState(false)\n    useEffect(() => {\n      setIsMounted(true)\n      return () => setIsMounted(false)\n    }, [])\n\n    const initial = isMount ? transitionType.initial : true\n    const { animate } = transitionType\n    const { exit } = transitionType\n\n    return (\n      <AnimatePresence mode=\"popLayout\">\n        {status === \"init\" ? (\n          typeof icon1 === \"string\" ? (\n            <m.i\n              className={cn(icon1ClassName, className, icon1)}\n              key=\"1\"\n              initial={initial}\n              animate={animate}\n              exit={exit}\n            />\n          ) : (\n            <m.span key=\"1\" initial={initial} animate={animate} exit={exit}>\n              {cloneElement(icon1, {\n                className: cn(icon1ClassName, className),\n              })}\n            </m.span>\n          )\n        ) : typeof icon2 === \"string\" ? (\n          <m.i\n            className={cn(icon2ClassName, className, icon2)}\n            key=\"2\"\n            initial={initial}\n            animate={animate}\n            exit={exit}\n          />\n        ) : (\n          <m.span key=\"2\" initial={initial} animate={animate} exit={exit}>\n            {cloneElement(icon2, {\n              className: cn(icon2ClassName, className),\n            })}\n          </m.span>\n        )}\n      </AnimatePresence>\n    )\n  }\n\nexport const IconScaleTransition = createIconTransition({\n  initial: { scale: 0 },\n  animate: { scale: 1 },\n  exit: { scale: 0 },\n})\n\nexport const IconOpacityTransition = createIconTransition({\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n})\n\nconst Presets = {\n  fade: {\n    initial: { opacity: 0 },\n    animate: { opacity: 1 },\n    exit: { opacity: 0 },\n    transition: Spring.presets.smooth,\n  },\n}\nexport const IconTransition = (\n  props: React.PropsWithChildren<{\n    animatedKey: string\n    initial?: TargetAndTransition\n    animate?: TargetAndTransition\n    exit?: TargetAndTransition\n    transition?: Transition\n\n    preset?: \"fade\"\n  }>,\n) => {\n  const preset = Presets[props.preset ?? \"fade\"]\n  return (\n    <AnimatePresence mode=\"popLayout\">\n      <m.span\n        key={props.animatedKey}\n        initial={props.initial ?? preset.initial}\n        animate={props.animate ?? preset.animate}\n        exit={props.exit ?? preset.exit}\n        transition={props.transition ?? preset.transition}\n      >\n        {props.children}\n      </m.span>\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/app.tsx",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\n\n/// Feed\nexport const FEED_COLLECTION_LIST = \"collections\"\n/// Local storage keys\nexport const QUERY_PERSIST_KEY = getStorageNS(\"REACT_QUERY_OFFLINE_CACHE\")\nexport const I18N_LOCALE_KEY = getStorageNS(\"I18N_LOCALE\")\n\n/// Route Keys\nexport const ROUTE_VIEW_ALL = \"all\"\nexport const ROUTE_FEED_PENDING = \"all\"\nexport const ROUTE_ENTRY_PENDING = \"pending\"\nexport const ROUTE_FEED_IN_FOLDER = \"folder-\"\nexport const ROUTE_FEED_IN_LIST = \"list-\"\nexport const ROUTE_FEED_IN_INBOX = \"inbox-\"\nexport const ROUTE_TIMELINE_OF_VIEW = \"view-\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/copy.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\n\nconst OpenInBrowser = (_t?: any) =>\n  IN_ELECTRON\n    ? tShortcuts(\"command.subscription.open_in_browser.title\")\n    : tShortcuts(\"command.subscription.open_in_tab.title\")\n\nexport const COPY_MAP = {\n  OpenInBrowser,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/dom.ts",
    "content": "export const ENTRY_CONTENT_RENDER_CONTAINER_ID = \"follow-entry-render\"\n\nexport const LOGO_MOBILE_ID = \"follow-logo-mobile\"\n\nexport const ENTRY_COLUMN_LIST_SCROLLER_ID = \"entry-column-scroller\"\n\nexport const APP_GRID_CONTAINER_ID = \"follow-app-grid-container\"\n\nexport const ROOT_CONTAINER_ID = \"follow-root-container\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/env.ts",
    "content": "import { env } from \"@follow/shared/env.desktop\"\n\nexport const WEB_URL = env.VITE_WEB_URL\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/hotkeys.ts",
    "content": "export enum HotkeyScope {\n  Home = \"home\",\n  Menu = \"menu\",\n  Modal = \"modal\",\n  DropdownMenu = \"dropdown-menu\",\n  Recording = \"recording\",\n\n  // Atom Scope\n  VideoPlayer = \"video-player\",\n  Timeline = \"timeline\",\n  EntryRender = \"entry-render\",\n  SubscriptionList = \"subscription-list\",\n  SubLayer = \"sub-layer\",\n  AIChat = \"ai-chat\",\n}\n\nexport const FloatingLayerScope = [\n  HotkeyScope.Modal,\n  HotkeyScope.DropdownMenu,\n  HotkeyScope.Menu,\n  HotkeyScope.Recording,\n] as const\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/index.ts",
    "content": "export * from \"./app\"\nexport * from \"./copy\"\nexport * from \"./hotkeys\"\nexport * from \"./ui\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/constants/ui.ts",
    "content": "export const ElECTRON_CUSTOM_TITLEBAR_HEIGHT = 30\n// export const ELECTRON_WINDOWS_RADIUS = 12\n\nexport const readableContentMaxWidthClassName = \"max-w-[clamp(45ch,60vw,65ch)]\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/env.d.ts",
    "content": "declare const APP_VERSION: string\ndeclare const APP_NAME: string\ndeclare const RELEASE_CHANNEL: string\ndeclare const I18N_COMPLETENESS_MAP: Record<string, number>\ndeclare const CHANGELOG_CONTENT: string\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/errors/CustomSafeError.ts",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\n\nimport { createErrorToaster } from \"~/lib/error-parser\"\n\nexport class CustomSafeError extends Error {\n  constructor(message: string, toast?: boolean) {\n    super(message)\n    if (toast) {\n      nextFrame(() => createErrorToaster(message)(this))\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/global.d.ts",
    "content": "import type { useTranslation } from \"react-i18next\"\n// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars\nconst { t } = useTranslation()\n// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars\nconst { t: settingsT } = useTranslation(\"settings\")\n// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars\nconst { t: shortcutsT } = useTranslation(\"shortcuts\")\n// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars\nconst { t: aiT } = useTranslation(\"ai\")\ndeclare global {\n  // BIZ ID\n  export type Id = string\n  export type FeedId = Id\n  export type EntryId = Id\n\n  export const SENTRY_RELEASE: { id: string }\n  export const APP_DEV_CWD: string\n  export const GIT_COMMIT_SHA: string\n  export const DEBUG: boolean\n  export const ELECTRON: boolean\n  export interface Window {\n    SENTRY_RELEASE: typeof SENTRY_RELEASE\n\n    ReactNativeWebView?: {\n      postMessage: (message: string) => void\n    }\n  }\n\n  export const FEATURES: {\n    WINDOW_UNDER_BLUR: boolean\n  }\n\n  export type I18nKeys = OmitStringType<Parameters<typeof t>[0]>\n  export type I18nKeysForSettings = OmitStringType<Parameters<typeof settingsT>[0]>\n  export type I18nKeysForShortcuts = OmitStringType<Parameters<typeof shortcutsT>[0]>\n  export type I18nKeysForAi = OmitStringType<Parameters<typeof aiT>[0]>\n\n  // MACROS\n\n  /**\n   * This function is a macro, will replace in the build stage.\n   */\n  export function tShortcuts(key: I18nKeysForShortcuts): I18nKeysForShortcuts\n  /**\n   * This function is a macro, will replace in the build stage.\n   */\n  export function tSettings(key: I18nKeysForSettings): I18nKeysForSettings\n  /**\n   * This function is a macro, will replace in the build stage.\n   */\n  export function t_(key: I18nKeys): I18nKeys\n}\n\nexport {}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\n\nimport { useRouteParamsSelector } from \"./useRouteParams\"\n\nconst selector = (state: EntryModel) => state.read\nexport function useEntryIsRead(entryId?: string) {\n  const entryRead = useEntry(entryId, selector)\n\n  const isLoggedIn = useIsLoggedIn()\n\n  return useRouteParamsSelector(\n    (params) => {\n      if (!isLoggedIn) return true\n      if (params.isCollection) {\n        return true\n      }\n      if (entryRead === undefined) return false\n      return entryRead\n    },\n    [entryRead, isLoggedIn],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts",
    "content": "import { checkIsEditableElement } from \"@follow/utils\"\nimport { useEffect } from \"react\"\nimport { tinykeys } from \"tinykeys\"\n\nimport type { MenuItemInput } from \"~/atoms/context-menu\"\nimport { MenuItemText } from \"~/atoms/context-menu\"\n\nexport const useContextMenuActionShortCutTrigger = (items: MenuItemInput[], when: boolean) => {\n  useEffect(() => {\n    if (!when) return\n\n    const actionMap = items.reduce(\n      (acc, item) => {\n        if (item instanceof MenuItemText) {\n          if (!item.shortcut) return acc\n          acc[item.shortcut] = (event: KeyboardEvent) => {\n            if (checkIsEditableElement(event.target as HTMLElement)) return\n            event.preventDefault()\n            event.stopPropagation()\n            if (item.disabled) return\n            if (item.hide) return\n            item.click()\n          }\n        }\n        return acc\n      },\n\n      {} as Record<string, (e: KeyboardEvent) => void>,\n    )\n\n    return tinykeys(window, actionMap)\n  }, [items, when])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useDiscoverRSSHubRoute.tsx",
    "content": "import { useEventCallback } from \"usehooks-ts\"\n\nimport { useAsyncModal } from \"~/components/ui/modal/helper/useAsyncModal\"\nimport { RecommendationContent } from \"~/modules/discover/RecommendationContent\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { discover } from \"~/queries/discover\"\n\nimport { useAuthQuery } from \"../common\"\n\nexport const useDiscoverRSSHubRouteModal = () => {\n  const present = useAsyncModal()\n\n  return useEventCallback((route: string) => {\n    const useDataFetcher = () => useAuthQuery(discover.rsshubRoute({ route }))\n    type ResponseType = Awaited<ReturnType<ReturnType<typeof useDataFetcher>[\"fn\"]>>\n    return present<ResponseType>({\n      id: `rsshub-discover-${route}`,\n      content: ({ data }: { data: ResponseType }) => (\n        <RecommendationContent routePrefix={data.prefix} route={data.route} />\n      ),\n      icon: (data: ResponseType) => (\n        <FeedIcon className=\"size-4\" size={16} siteUrl={`https://${data.url}`} />\n      ),\n      title: (data: ResponseType) => `${data.name} - ${data.route.name}`,\n\n      useDataFetcher,\n    })\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { FeedViewType, getView, UserRole } from \"@follow/constants\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { isOnboardingEntryUrl } from \"@follow/store/constants/onboarding\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { entrySyncServices } from \"@follow/store/entry/store\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { doesTextContainHTML } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\n\nimport { useShowAITranslationAuto, useShowAITranslationOnce } from \"~/atoms/ai-translation\"\nimport { MENU_ITEM_SEPARATOR, MenuItemSeparator, MenuItemText } from \"~/atoms/context-menu\"\nimport {\n  getReadabilityStatus,\n  ReadabilityStatus,\n  setReadabilityStatus,\n  useEntryIsInReadability,\n} from \"~/atoms/readability\"\nimport { useIntegrationSettingValue } from \"~/atoms/settings/integration\"\nimport { useShowSourceContent } from \"~/atoms/source-content\"\nimport { ipcServices } from \"~/lib/client\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { getCommand, useRunCommandFn } from \"~/modules/command/hooks/use-command\"\nimport { useCommandShortcuts } from \"~/modules/command/hooks/use-command-binding\"\nimport { isMutationCommandId } from \"~/modules/command/mutation-command-ids\"\nimport type { FollowCommandId, UnknownCommand } from \"~/modules/command/types\"\nimport { useToolbarOrderMap } from \"~/modules/customize-toolbar/hooks\"\n\nimport { useRouteParams } from \"./useRouteParams\"\n\nexport const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => {\n  const status = getReadabilityStatus()[id]\n  const isTurnOn = status !== ReadabilityStatus.INITIAL && !!status\n  if (isTurnOn) return\n  toggleEntryReadability({ id, url })\n}\n\nexport const toggleEntryReadability = async ({ id, url }: { id: string; url: string }) => {\n  const status = getReadabilityStatus()[id]\n  const isTurnOn = status !== ReadabilityStatus.INITIAL && !!status\n\n  if (!isTurnOn && url) {\n    setReadabilityStatus({\n      [id]: ReadabilityStatus.WAITING,\n    })\n    try {\n      await entrySyncServices.fetchEntryReadabilityContent(id, async () => {\n        const res = await ipcServices?.reader.readability({ url })\n        return res?.content\n      })\n\n      setReadabilityStatus({\n        [id]: ReadabilityStatus.SUCCESS,\n      })\n    } catch {\n      setReadabilityStatus({\n        [id]: ReadabilityStatus.FAILURE,\n      })\n    }\n  } else {\n    setReadabilityStatus({\n      [id]: ReadabilityStatus.INITIAL,\n    })\n  }\n}\n\ninterface EntryActionMenuItemConfig {\n  id: FollowCommandId\n  onClick: () => void\n  hide?: boolean\n  shortcut?: string\n  active?: boolean\n  disabled?: boolean\n  notice?: boolean\n  entryId: string\n  requiresLogin?: boolean\n}\n\nexport class EntryActionMenuItem extends MenuItemText {\n  protected privateConfig: EntryActionMenuItemConfig\n\n  constructor(config: EntryActionMenuItemConfig) {\n    const cmd = getCommand(config.id) || null\n    const requiresLogin = config.requiresLogin ?? isMutationCommandId(config.id)\n    super({\n      ...config,\n      label: cmd?.label.title || \"\",\n      click: () => config.onClick?.(),\n      hide: !cmd || config.hide,\n      requiresLogin,\n    })\n\n    this.privateConfig = {\n      ...config,\n      requiresLogin,\n    }\n  }\n\n  public get id() {\n    return this.privateConfig.id\n  }\n\n  public get active() {\n    return this.privateConfig.active\n  }\n\n  public get notice() {\n    return this.privateConfig.notice\n  }\n\n  public get entryId() {\n    return this.privateConfig.entryId\n  }\n\n  public override extend(config: Partial<EntryActionMenuItemConfig>) {\n    return new EntryActionMenuItem({\n      ...this.privateConfig,\n      ...config,\n    })\n  }\n}\n\nexport class EntryActionDropdownItem extends MenuItemText {\n  protected privateConfig: EntryActionMenuItemConfig\n  public children: EntryActionMenuItem[]\n\n  constructor(config: EntryActionMenuItemConfig & { children?: EntryActionMenuItem[] }) {\n    const cmd = getCommand(config.id) || null\n    const requiresLogin = config.requiresLogin ?? isMutationCommandId(config.id)\n    super({\n      ...config,\n      label: cmd?.label.title || \"\",\n      click: () => config.onClick?.(),\n      hide: !cmd || config.hide,\n      requiresLogin,\n    })\n\n    this.privateConfig = {\n      ...config,\n      requiresLogin,\n    }\n    this.children = config.children || []\n  }\n\n  public get id() {\n    return this.privateConfig.id\n  }\n\n  public get active() {\n    return this.privateConfig.active\n  }\n\n  public get notice() {\n    return this.privateConfig.notice\n  }\n\n  public get entryId() {\n    return this.privateConfig.entryId\n  }\n\n  public get hasChildren() {\n    return this.children.length > 0\n  }\n\n  public get enabledChildren() {\n    return this.children.filter((child) => !child.hide)\n  }\n\n  public addChild(child: EntryActionMenuItem) {\n    this.children.push(child)\n  }\n\n  public removeChild(childId: string) {\n    this.children = this.children.filter((child) => child.id !== childId)\n  }\n\n  public override extend(\n    config: Partial<EntryActionMenuItemConfig & { children?: EntryActionMenuItem[] }>,\n  ) {\n    return new EntryActionDropdownItem({\n      ...this.privateConfig,\n      ...config,\n      children: config.children || this.children,\n    })\n  }\n}\nexport type EntryActionItem = EntryActionMenuItem | EntryActionDropdownItem | MenuItemSeparator\n\nconst entrySelector = (state: EntryModel) => {\n  const content = state.content || \"\"\n  const hasContent = !!content\n  const doesContentContainsHTMLTags = doesTextContainHTML(content)\n\n  const { summary, translation, readability } = state.settings || {}\n\n  const media = state.media || []\n  const attachments = state.attachments || []\n  const images = media.filter((a) => a.type === \"photo\")\n  const imagesLength = images.length\n\n  return {\n    feedId: state.feedId,\n    inboxId: state.inboxHandle,\n    url: state.url,\n    publishedAt: state.publishedAt.toISOString(),\n    read: state.read,\n    summary,\n    translation,\n    readability,\n    hasContent,\n    doesContentContainsHTMLTags,\n    imagesLength,\n    hasBitTorrent: attachments.some((a) => a.mime_type === \"application/x-bittorrent\"),\n  }\n}\nexport const HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU: FollowCommandId[] = [\n  COMMAND_ID.entry.viewSourceContent,\n  COMMAND_ID.entry.copyTitle,\n  COMMAND_ID.entry.copyLink,\n  COMMAND_ID.entry.exportAsPDF,\n  COMMAND_ID.entry.imageGallery,\n  COMMAND_ID.entry.toggleAITranslation,\n  COMMAND_ID.entry.share,\n\n  COMMAND_ID.settings.customizeToolbar,\n  COMMAND_ID.entry.readability,\n  COMMAND_ID.entry.exportAsPDF,\n]\n\nexport const HIDE_ACTIONS_IN_ENTRY_TOOLBAR_ACTIONS: FollowCommandId[] = [\n  ...HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU,\n]\nexport const useEntryActions = ({ entryId, view }: { entryId: string; view: FeedViewType }) => {\n  const entry = useEntry(entryId, entrySelector)\n  const { isCollection, entryId: routeEntryId } = useRouteParams()\n  const isInCollection = useIsEntryStarred(entryId)\n  const isEntryInReadability = useEntryIsInReadability(entryId)\n\n  const feed = useFeedById(entry?.feedId, (feed) => {\n    return {\n      type: feed.type,\n      ownerUserId: feed.ownerUserId,\n      id: feed.id,\n      siteUrl: feed.siteUrl,\n    }\n  })\n\n  const isInbox = useIsInbox(entry?.inboxId)\n  const isShowSourceContent = useShowSourceContent()\n\n  const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)\n  const isShowAITranslationOnce = useShowAITranslationOnce()\n\n  const runCmdFn = useRunCommandFn()\n  const hasEntry = !!entry\n\n  const userRole = useUserRole()\n  const integrationSettings = useIntegrationSettingValue()\n\n  const shortcuts = useCommandShortcuts()\n\n  const isCurrentVisitEntry = routeEntryId === entryId\n  const isOnboardingEntry = isOnboardingEntryUrl(entry?.url)\n\n  const actionConfigs: EntryActionItem[] = useMemo(() => {\n    if (!hasEntry) return []\n\n    const configs: EntryActionItem[] = [\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToEagle,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToEagle, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToReadwise,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToReadwise, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToInstapaper,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToInstapaper, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToObsidian,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToObsidian, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToOutline,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToOutline, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToReadeck,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToReadeck, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToCubox,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToCubox, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToZotero,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToZotero, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.integration.saveToQBittorrent,\n        onClick: runCmdFn(COMMAND_ID.integration.saveToQBittorrent, [{ entryId }]),\n        hide: !IN_ELECTRON || !entry.hasBitTorrent,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.star,\n        onClick: runCmdFn(COMMAND_ID.entry.star, [{ entryId, view }]),\n        active: isInCollection,\n        shortcut: shortcuts[COMMAND_ID.entry.star],\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.copyTitle,\n        onClick: runCmdFn(COMMAND_ID.entry.copyTitle, [{ entryId }]),\n        shortcut: shortcuts[COMMAND_ID.entry.copyTitle],\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.copyLink,\n        onClick: runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId }]),\n        hide: !entry.url,\n        shortcut: shortcuts[COMMAND_ID.entry.copyLink],\n        disabled: isOnboardingEntry,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.exportAsPDF,\n        hide: !isCurrentVisitEntry,\n        onClick: runCmdFn(COMMAND_ID.entry.exportAsPDF, [{ entryId }]),\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.imageGallery,\n        hide: entry.imagesLength <= 5,\n        onClick: runCmdFn(COMMAND_ID.entry.imageGallery, [{ entryId }]),\n        disabled: isOnboardingEntry,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.openInBrowser,\n        hide: !entry.url,\n        onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]),\n        shortcut: shortcuts[COMMAND_ID.entry.openInBrowser],\n        disabled: isOnboardingEntry,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.viewSourceContent,\n        onClick: runCmdFn(COMMAND_ID.entry.viewSourceContent, [\n          { entryId, siteUrl: feed?.siteUrl },\n        ]),\n        hide: isMobile() || !entry.url,\n        active: isShowSourceContent,\n        disabled: isOnboardingEntry,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.toggleAITranslation,\n        onClick: runCmdFn(COMMAND_ID.entry.toggleAITranslation, []),\n        hide:\n          isShowAITranslationAuto ||\n          ([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(\n            view,\n          ),\n        active: isShowAITranslationOnce,\n        disabled: userRole === UserRole.Free || userRole === UserRole.Trial,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.read,\n        onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]),\n        hide: !!isCollection,\n        active: !!entry.read,\n        shortcut: shortcuts[COMMAND_ID.entry.read],\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.readAbove,\n        onClick: runCmdFn(COMMAND_ID.entry.readAbove, [{ publishedAt: entry.publishedAt }]),\n        hide: !!isCollection,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.readBelow,\n        onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.publishedAt }]),\n        hide: !!isCollection,\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.share,\n        onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]),\n        hide: !entry.url,\n        shortcut: shortcuts[COMMAND_ID.entry.share],\n        entryId,\n      }),\n      MENU_ITEM_SEPARATOR,\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.delete,\n        onClick: runCmdFn(COMMAND_ID.entry.delete, [{ entryId }]),\n        hide: !isInbox,\n        entryId,\n      }),\n\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.tts,\n        onClick: runCmdFn(COMMAND_ID.entry.tts, [{ entryId }]),\n        shortcut: shortcuts[COMMAND_ID.entry.tts],\n        entryId,\n      }),\n      new EntryActionMenuItem({\n        id: COMMAND_ID.entry.readability,\n        onClick: runCmdFn(COMMAND_ID.entry.readability, [{ entryId, entryUrl: entry.url! }]),\n        hide: !!entry.readability || (view && getView(view)?.wideMode) || !entry.url,\n        active: isEntryInReadability,\n        notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,\n        disabled: isOnboardingEntry,\n        entryId,\n      }),\n\n      // Custom Integration with sub-menu\n      ...(() => {\n        const customIntegrations = integrationSettings.customIntegration || []\n        const enabledIntegrations = customIntegrations.filter((integration) => integration.enabled)\n\n        if (!integrationSettings.enableCustomIntegration || enabledIntegrations.length === 0) {\n          return []\n        }\n\n        return [\n          new EntryActionDropdownItem({\n            id: COMMAND_ID.integration.custom,\n            onClick: runCmdFn(COMMAND_ID.integration.custom, [{ entryId }]),\n            entryId,\n            children: enabledIntegrations.map((integration) => {\n              const virtualId = `integration:custom:${integration.id}` as UnknownCommand[\"id\"]\n              return new EntryActionMenuItem({\n                id: virtualId,\n                onClick: () => {\n                  runCmdFn(virtualId, [{ entryId }])()\n                },\n                entryId,\n              })\n            }),\n          }),\n        ]\n      })(),\n    ].filter((config) => {\n      if (config === MENU_ITEM_SEPARATOR) {\n        return config\n      }\n\n      return !config.hide\n    })\n\n    return configs\n  }, [\n    hasEntry,\n    runCmdFn,\n    entryId,\n    entry?.hasBitTorrent,\n    entry?.url,\n    entry?.imagesLength,\n    entry?.publishedAt,\n    entry?.read,\n    entry?.readability,\n    entry?.doesContentContainsHTMLTags,\n    feed?.siteUrl,\n    isInbox,\n    shortcuts,\n    view,\n    isInCollection,\n    isCurrentVisitEntry,\n    isShowSourceContent,\n    userRole,\n    isShowAITranslationAuto,\n    isShowAITranslationOnce,\n    isCollection,\n    isEntryInReadability,\n    integrationSettings.customIntegration,\n    integrationSettings.enableCustomIntegration,\n    isOnboardingEntry,\n  ])\n\n  return actionConfigs\n}\n\nexport const useSortedEntryActions = ({\n  entryId,\n  view,\n}: {\n  entryId: string\n  view: FeedViewType\n}) => {\n  const entryActions = useEntryActions({ entryId, view })\n  const orderMap = useToolbarOrderMap()\n  const mainAction = useMemo(\n    () =>\n      entryActions\n        .filter((item) => {\n          if (item === MENU_ITEM_SEPARATOR || item instanceof MenuItemSeparator) {\n            return false\n          }\n          const order = orderMap.get(item.id)\n\n          if (!order) return false\n          return order.type === \"main\"\n        })\n        .sort((a, b) => {\n          if (a instanceof MenuItemSeparator || b instanceof MenuItemSeparator) {\n            return 0\n          }\n          const orderA = orderMap.get(a.id)?.order || 0\n          const orderB = orderMap.get(b.id)?.order || 0\n          return orderA - orderB\n        }),\n    [entryActions, orderMap],\n  )\n\n  const moreAction = useMemo(\n    () =>\n      entryActions\n        .filter((item) => {\n          if (item instanceof MenuItemSeparator) {\n            return false\n          }\n          const order = orderMap.get(item.id)\n          if (!order) return false\n          return order.type !== \"main\"\n        })\n        // .filter((item) => item.id !== COMMAND_ID.settings.customizeToolbar)\n        .sort((a, b) => {\n          if (a instanceof MenuItemSeparator || b instanceof MenuItemSeparator) {\n            return 0\n          }\n          const orderA = orderMap.get(a.id)?.order || Infinity\n          const orderB = orderMap.get(b.id)?.order || Infinity\n          return orderA - orderB\n        }),\n    [entryActions, orderMap],\n  )\n\n  return {\n    mainAction,\n    moreAction,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useEntryContextMenu.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useCallback, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  MENU_ITEM_SEPARATOR,\n  MenuItemSeparator,\n  MenuItemText,\n  useShowContextMenu,\n} from \"~/atoms/context-menu\"\nimport { HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU, useEntryActions } from \"~/hooks/biz/useEntryActions\"\nimport { useFeedActions } from \"~/hooks/biz/useFeedActions\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\n\nexport function useEntryContextMenu({\n  entryId,\n  view,\n  feedId,\n}: {\n  entryId: string\n  view: FeedViewType\n  feedId: string\n}) {\n  const { t } = useTranslation(\"common\")\n  const showContextMenu = useShowContextMenu()\n\n  const actionConfigs = useEntryActions({ entryId, view })\n  const feedItems = useFeedActions({ feedId, view, type: \"entryList\" })\n\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)\n\n  const buildMenuItems = useCallback(() => {\n    return [\n      ...actionConfigs.filter((item) => {\n        if (item instanceof MenuItemSeparator) return true\n\n        return !HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU.includes(item.id)\n      }),\n      MENU_ITEM_SEPARATOR,\n      ...feedItems.filter((item) => {\n        if (item instanceof MenuItemSeparator) return true\n\n        return item && !item.disabled\n      }),\n      MENU_ITEM_SEPARATOR,\n      new MenuItemText({\n        label: `${t(\"words.copy\")}${t(\"space\")}${t(\"words.entry\")} ${t(\"words.id\")}`,\n        click: () => copyToClipboard(entryId),\n      }),\n    ]\n  }, [actionConfigs, feedItems, t, entryId])\n\n  const contextMenuProps = useContextMenu({\n    onContextMenu: async (e) => {\n      const $target = e.target as HTMLElement\n      const selection = window.getSelection()\n      if (selection) {\n        const targetHasSelection =\n          selection?.toString().length > 0 && $target.contains(selection?.anchorNode)\n        if (targetHasSelection) {\n          e.stopPropagation()\n          return\n        }\n      }\n\n      e.preventDefault()\n      setIsContextMenuOpen(true)\n      await showContextMenu(buildMenuItems(), e)\n      setIsContextMenuOpen(false)\n    },\n  })\n\n  const openContextMenuAt = useCallback(\n    async (x: number, y: number) => {\n      const mouseEvent = new MouseEvent(\"contextmenu\", {\n        bubbles: true,\n        cancelable: true,\n        clientX: x,\n        clientY: y,\n      })\n      // Delegate to the same onContextMenu handler\n      // @ts-expect-error MouseEvent type alignment\n      await contextMenuProps.onContextMenu?.(mouseEvent)\n    },\n    [contextMenuProps],\n  )\n\n  return useMemo(\n    () => ({ contextMenuProps, isContextMenuOpen, openContextMenuAt }),\n    [contextMenuProps, isContextMenuOpen, openContextMenuAt],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts",
    "content": "import { getDebugFeatureValue, useDebugFeatureValue } from \"~/atoms/debug-feature\"\nimport { getServerConfigs, useServerConfigs } from \"~/atoms/server-configs\"\nimport { featureConfigMap } from \"~/lib/features\"\n\n// Define debug feature value structure\ninterface DebugFeatureValue {\n  __override?: boolean\n  [key: string]: boolean | undefined\n}\n\n// Define feature key type\nexport type FeatureKey = keyof typeof featureConfigMap\n\n/**\n * Core feature checking logic to avoid code duplication\n * @param feature - Feature key name\n * @param debugFeatureValue - Debug feature values\n * @param serverConfigs - Server configuration\n * @returns Whether the feature is enabled\n */\nconst checkFeatureEnabled = (\n  feature: FeatureKey,\n  debugFeatureValue: DebugFeatureValue,\n  serverConfigs: ReturnType<typeof getServerConfigs>,\n): boolean => {\n  const override = !!debugFeatureValue.__override\n\n  if (override) {\n    return !!debugFeatureValue[feature]\n  }\n\n  const serverConfigKey = featureConfigMap[feature]\n  return !!(serverConfigKey && serverConfigs?.[serverConfigKey])\n}\n\n/**\n * React Hook: Check if a specific feature is enabled\n * @param feature - Feature key name\n * @returns Whether the feature is enabled\n */\nexport const useFeature = (feature: FeatureKey): boolean => {\n  const debugFeatureValue = useDebugFeatureValue() as DebugFeatureValue\n  const serverConfigs = useServerConfigs()\n\n  return checkFeatureEnabled(feature, debugFeatureValue, serverConfigs)\n}\n\n/**\n * Non-Hook function: Check if a specific feature is enabled\n * @param feature - Feature key name\n * @returns Whether the feature is enabled\n */\nexport const getFeature = (feature: FeatureKey): boolean => {\n  const debugFeatureValue = getDebugFeatureValue() as DebugFeatureValue\n  const serverConfigs = getServerConfigs()\n\n  return checkFeatureEnabled(feature, debugFeatureValue, serverConfigs)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById, useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { useListById, useOwnedListByView } from \"@follow/store/list/hooks\"\nimport { listSyncServices } from \"@follow/store/list/store\"\nimport {\n  useCategoriesByView,\n  useSubscriptionByFeedId,\n  useSubscriptionsByFeedIds,\n} from \"@follow/store/subscription/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { isBizId } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport type { FollowMenuItem, MenuItemInput } from \"~/atoms/context-menu\"\nimport { MenuItemSeparator, MenuItemText } from \"~/atoms/context-menu\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { useFeedClaimModal } from \"~/modules/claim\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandShortcuts } from \"~/modules/command/hooks/use-command-binding\"\nimport { FeedForm } from \"~/modules/discover/FeedForm\"\nimport { InboxForm } from \"~/modules/discover/InboxForm\"\nimport { ListForm } from \"~/modules/discover/ListForm\"\nimport { useConfirmUnsubscribeSubscriptionModal } from \"~/modules/modal/hooks/useConfirmUnsubscribeSubscriptionModal\"\nimport { useCategoryCreationModal } from \"~/modules/settings/tabs/lists/hooks\"\nimport { ListCreationModalContent } from \"~/modules/settings/tabs/lists/modals\"\nimport { useResetFeed } from \"~/queries/feed\"\n\nimport { useBatchUpdateSubscription, useDeleteSubscription } from \"./useSubscriptionActions\"\n\nexport const useFeedActions = ({\n  feedId,\n  feedIds,\n  view,\n  type,\n}: {\n  feedId: string\n  feedIds?: string[]\n  view?: number\n  type?: \"feedList\" | \"entryList\"\n}) => {\n  const { t } = useTranslation()\n  const feed = useFeedById(feedId, (feed) => {\n    return {\n      type: feed.type,\n      ownerUserId: feed.ownerUserId,\n      id: feed.id,\n      url: feed.url,\n      siteUrl: feed.siteUrl,\n    }\n  })\n\n  const inbox = useInboxById(feedId)\n  const isInbox = !!inbox\n  const subscription = useSubscriptionByFeedId(feedId)\n\n  const subscriptions = useSubscriptionsByFeedIds(\n    useMemo(() => feedIds || [feedId], [feedId, feedIds]),\n  )\n  const { present } = useModalStack()\n  const presentDeleteSubscription = useConfirmUnsubscribeSubscriptionModal()\n  const deleteSubscription = useDeleteSubscription({})\n  const claimFeed = useFeedClaimModal()\n\n  const isEntryList = type === \"entryList\"\n\n  const { mutateAsync: addFeedToListMutation } = useAddFeedToFeedList()\n  const { mutateAsync: removeFeedFromListMutation } = useRemoveFeedFromFeedList()\n  const { mutateAsync: resetFeed } = useResetFeed()\n  const { mutate: addFeedsToCategoryMutation } = useBatchUpdateSubscription()\n  const presentCategoryCreationModal = useCategoryCreationModal()\n\n  const listByView = useOwnedListByView(view!)\n  const categories = useCategoriesByView(view!)\n\n  const isMultipleSelection = feedIds && feedIds.length > 1 && feedIds.includes(feedId)\n\n  const shortcuts = useCommandShortcuts()\n\n  const items = useMemo(() => {\n    const related = feed || inbox\n    if (!related) return []\n\n    const isFeedOwner = related.ownerUserId === whoami()?.id\n\n    const items: MenuItemInput[] = [\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.mark_all_as_read\"),\n        shortcut: shortcuts[COMMAND_ID.subscription.markAllAsRead],\n        disabled: isEntryList,\n        click: () => unreadSyncService.markFeedAsRead(isMultipleSelection ? feedIds : [feedId]),\n        supportMultipleSelection: true,\n        requiresLogin: true,\n      }),\n      new MenuItemSeparator(isEntryList),\n      new MenuItemText({\n        label: isEntryList ? t(\"sidebar.feed_actions.edit_feed\") : t(\"sidebar.feed_actions.edit\"),\n        shortcut: \"E\",\n        disabled: isInbox,\n        click: () => {\n          present({\n            modalContentClassName: \"overflow-visible\",\n            title: t(\"sidebar.feed_actions.edit_feed\"),\n            content: ({ dismiss }) => <FeedForm id={feedId} onSuccess={dismiss} />,\n          })\n        },\n        requiresLogin: true,\n      }),\n      new MenuItemText({\n        label: isMultipleSelection\n          ? t(\"sidebar.feed_actions.unfollow_feed_many\")\n          : isEntryList\n            ? t(\"sidebar.feed_actions.unfollow_feed\")\n            : t(\"sidebar.feed_actions.unfollow\"),\n        shortcut: \"$mod+Backspace\",\n        disabled: isInbox,\n        supportMultipleSelection: true,\n        click: () => {\n          if (isMultipleSelection) {\n            presentDeleteSubscription(feedIds)\n            return\n          }\n          deleteSubscription.mutate({ subscription })\n        },\n        requiresLogin: true,\n      }),\n      new MenuItemSeparator(isEntryList),\n      new MenuItemText({\n        label: t(\"sidebar.feed_column.context_menu.add_feeds_to_list\"),\n        disabled: isInbox,\n        supportMultipleSelection: true,\n        requiresLogin: true,\n        submenu: [\n          ...listByView.map((list) => {\n            const isIncluded = list.feedIds.includes(feedId)\n            return new MenuItemText({\n              label: list.title || \"\",\n              checked: isIncluded,\n              click() {\n                if (isMultipleSelection) {\n                  addFeedToListMutation({\n                    feedIds,\n                    listId: list.id,\n                  })\n                  return\n                }\n\n                if (!isIncluded) {\n                  addFeedToListMutation({\n                    feedId,\n                    listId: list.id,\n                  })\n                } else {\n                  removeFeedFromListMutation({\n                    feedId,\n                    listId: list.id,\n                  })\n                }\n              },\n              requiresLogin: true,\n            })\n          }),\n          listByView.length > 0 && new MenuItemSeparator(),\n          new MenuItemText({\n            label: t(\"sidebar.feed_actions.create_list\"),\n            icon: <i className=\"i-mgc-add-cute-re\" />,\n            click() {\n              present({\n                title: t(\"sidebar.feed_actions.create_list\"),\n                content: () => <ListCreationModalContent />,\n              })\n            },\n            requiresLogin: true,\n          }),\n        ],\n      }),\n      new MenuItemText({\n        label: t(\"sidebar.feed_column.context_menu.add_feeds_to_category\"),\n        disabled: isInbox,\n        supportMultipleSelection: true,\n        requiresLogin: true,\n        submenu: [\n          ...Array.from(categories.values()).map((category) => {\n            const isIncluded = isMultipleSelection\n              ? subscriptions.every((s) => s!.category === category)\n              : subscription?.category === category\n            return new MenuItemText({\n              label: category,\n              checked: isIncluded,\n              click() {\n                addFeedsToCategoryMutation({\n                  feedIdList: isMultipleSelection ? feedIds : [feedId],\n                  category: isIncluded ? null : category, // if already included, remove it\n                  view: view!,\n                })\n              },\n              requiresLogin: true,\n            })\n          }),\n          listByView.length > 0 && MenuItemSeparator.default,\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.create_category\"),\n            icon: <i className=\"i-mgc-add-cute-re\" />,\n            click() {\n              presentCategoryCreationModal(view!, isMultipleSelection ? feedIds : [feedId])\n            },\n            requiresLogin: true,\n          }),\n        ],\n      }),\n      !related.ownerUserId &&\n        !!isBizId(related.id) &&\n        related.type === \"feed\" &&\n        new MenuItemText({\n          label: isEntryList\n            ? t(\"sidebar.feed_actions.claim_feed\")\n            : t(\"sidebar.feed_actions.claim\"),\n          shortcut: \"C\",\n          click: () => {\n            claimFeed({ feedId })\n          },\n          disabled: isEntryList,\n          requiresLogin: true,\n        }),\n      ...(isFeedOwner\n        ? [\n            MenuItemSeparator.default,\n            new MenuItemText({\n              label: t(\"sidebar.feed_actions.feed_owned_by_you\"),\n              disabled: true,\n            }),\n            new MenuItemText({\n              label: t(\"sidebar.feed_actions.reset_feed\"),\n              click: () => {\n                resetFeed(feedId)\n              },\n              requiresLogin: true,\n            }),\n            MenuItemSeparator.default,\n          ]\n        : []),\n      new MenuItemSeparator(isEntryList),\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.open_feed_in_browser\", {\n          which: t(IN_ELECTRON ? \"words.browser\" : \"words.newTab\"),\n        }),\n        disabled: isEntryList,\n        shortcut: shortcuts[COMMAND_ID.subscription.openInBrowser],\n        click: () => window.open(UrlBuilder.shareFeed(feedId, view), \"_blank\"),\n      }),\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.open_site_in_browser\", {\n          which: t(IN_ELECTRON ? \"words.browser\" : \"words.newTab\"),\n        }),\n        shortcut: shortcuts[COMMAND_ID.subscription.openSiteInBrowser],\n        disabled: isEntryList,\n        click: () => {\n          const feed = getFeedById(feedId)\n          if (feed) {\n            \"siteUrl\" in feed && feed.siteUrl && window.open(feed.siteUrl, \"_blank\")\n          }\n        },\n      }),\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.copy_feed_id\"),\n        shortcut: \"$mod+Shift+C\",\n        disabled: isEntryList,\n        click: () => {\n          copyToClipboard(feedId)\n        },\n      }),\n    ]\n\n    return items.filter(\n      (item) =>\n        !isMultipleSelection ||\n        (typeof item === \"object\" &&\n          item !== null &&\n          \"supportMultipleSelection\" in item &&\n          item.supportMultipleSelection),\n    )\n  }, [\n    addFeedToListMutation,\n    addFeedsToCategoryMutation,\n    categories,\n    claimFeed,\n    deleteSubscription,\n    feed,\n    feedId,\n    feedIds,\n    inbox,\n    isEntryList,\n    isInbox,\n    isMultipleSelection,\n    listByView,\n    present,\n    presentCategoryCreationModal,\n    presentDeleteSubscription,\n    removeFeedFromListMutation,\n    resetFeed,\n    shortcuts,\n    subscription,\n    subscriptions,\n    t,\n    view,\n  ])\n\n  return items\n}\n\nexport const useListActions = ({ listId, view }: { listId: string; view?: FeedViewType }) => {\n  const { t } = useTranslation()\n  const list = useListById(listId)\n  const subscription = useSubscriptionByFeedId(listId)!\n\n  const { present } = useModalStack()\n  const { mutateAsync: deleteSubscription } = useDeleteSubscription({})\n\n  const shortcuts = useCommandShortcuts()\n\n  const items = useMemo(() => {\n    if (!list) return []\n\n    const items: MenuItemInput[] = [\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.mark_all_as_read\"),\n        shortcut: shortcuts[COMMAND_ID.subscription.markAllAsRead],\n        click: () => {\n          unreadSyncService.markFeedAsRead(list.feedIds)\n        },\n        requiresLogin: true,\n      }),\n      MenuItemSeparator.default,\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.edit\"),\n        shortcut: \"E\",\n        click: () => {\n          present({\n            title: t(\"sidebar.feed_actions.edit_list\"),\n            content: ({ dismiss }) => <ListForm id={listId} onSuccess={dismiss} />,\n          })\n        },\n        requiresLogin: true,\n      }),\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.unfollow\"),\n        shortcut: \"$mod+Backspace\",\n        click: () => deleteSubscription({ subscription }),\n        requiresLogin: true,\n      }),\n      MenuItemSeparator.default,\n      ...(list.ownerUserId === whoami()?.id\n        ? [\n            new MenuItemText({\n              label: t(\"sidebar.feed_actions.list_owned_by_you\"),\n              disabled: true,\n            }),\n            MenuItemSeparator.default,\n          ]\n        : []),\n\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.open_list_in_browser\", {\n          which: t(IN_ELECTRON ? \"words.browser\" : \"words.newTab\"),\n        }),\n        shortcut: shortcuts[COMMAND_ID.subscription.openInBrowser],\n        click: () => window.open(UrlBuilder.shareList(listId, view), \"_blank\"),\n      }),\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.copy_list_url\"),\n        shortcut: \"$mod+C\",\n        click: () => {\n          copyToClipboard(UrlBuilder.shareList(listId, view))\n        },\n      }),\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.copy_list_id\"),\n        shortcut: \"$mod+Shift+C\",\n        click: () => {\n          copyToClipboard(listId)\n        },\n      }),\n    ]\n\n    return items\n  }, [list, t, shortcuts, listId, present, deleteSubscription, subscription, view])\n\n  return items\n}\n\nexport const useInboxActions = ({ inboxId }: { inboxId: string }) => {\n  const { t } = useTranslation()\n  const isInbox = useIsInbox(inboxId)\n  const { present } = useModalStack()\n\n  const items = useMemo(() => {\n    if (!isInbox) return []\n\n    const items: FollowMenuItem[] = [\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.edit\"),\n        shortcut: \"E\",\n        click: () => {\n          present({\n            title: t(\"sidebar.feed_actions.edit_inbox\"),\n            content: () => <InboxForm asWidget id={inboxId} />,\n          })\n        },\n        requiresLogin: true,\n      }),\n      MenuItemSeparator.default,\n      new MenuItemText({\n        label: t(\"sidebar.feed_actions.copy_email_address\"),\n        shortcut: \"$mod+Shift+C\",\n        click: () => {\n          copyToClipboard(`${inboxId}${env.VITE_INBOXES_EMAIL}`)\n        },\n      }),\n    ]\n\n    return items\n  }, [isInbox, t, inboxId, present])\n\n  return { items }\n}\n\nexport const useAddFeedToFeedList = (options?: {\n  onSuccess?: () => void\n  onError?: () => void\n}) => {\n  const { t } = useTranslation(\"settings\")\n  return useMutation({\n    mutationFn: async (\n      payload: { feedId: string; listId: string } | { feedIds: string[]; listId: string },\n    ) => {\n      await listSyncServices.addFeedsToFeedList(payload)\n    },\n    onSuccess: () => {\n      toast.success(t(\"lists.feeds.add.success\"))\n\n      options?.onSuccess?.()\n    },\n    async onError() {\n      toast.error(t(\"lists.feeds.add.error\"))\n      options?.onError?.()\n    },\n  })\n}\n\nexport const useRemoveFeedFromFeedList = (options?: {\n  onSuccess: () => void\n  onError: () => void\n}) => {\n  const { t } = useTranslation(\"settings\")\n  return useMutation({\n    mutationFn: async (payload: { feedId: string; listId: string }) => {\n      await listSyncServices.removeFeedFromFeedList(payload)\n    },\n    onSuccess: () => {\n      toast.success(t(\"lists.feeds.delete.success\"))\n      options?.onSuccess?.()\n    },\n    async onError() {\n      toast.error(t(\"lists.feeds.delete.error\"))\n      options?.onError?.()\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx",
    "content": "import { getFeedByIdOrUrl } from \"@follow/store/feed/getter\"\nimport { getSubscriptionByFeedId } from \"@follow/store/subscription/getter\"\nimport { t } from \"i18next\"\nimport { useCallback } from \"react\"\nimport { useNavigate } from \"react-router\"\nimport { withoutTrailingSlash, withTrailingSlash } from \"ufo\"\n\nimport { previewBackPath } from \"~/atoms/preview\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport type { FeedFormDataValuesType } from \"~/modules/discover/FeedForm\"\nimport { FeedForm } from \"~/modules/discover/FeedForm\"\nimport type { ListFormDataValuesType } from \"~/modules/discover/ListForm\"\nimport { ListForm } from \"~/modules/discover/ListForm\"\n\nexport interface FollowOptions {\n  isList: boolean\n  id?: string\n  url?: string\n\n  onSuccess?: () => void\n  defaultValues?: Partial<ListFormDataValuesType> | Partial<FeedFormDataValuesType>\n}\nexport const useFollow = () => {\n  const { present } = useModalStack()\n  const navigate = useNavigate()\n\n  return useCallback(\n    (options?: FollowOptions) => {\n      // Some feeds redirect xxx.com/feed to xxx.com/feed/\n      // Try to get a valid feed, then we can check isFollowed correctly\n      const feed =\n        getFeedByIdOrUrl({ id: options?.id, url: withTrailingSlash(options?.url) }) ??\n        getFeedByIdOrUrl({ id: options?.id, url: withoutTrailingSlash(options?.url) })\n      const id = options?.id || feed?.id\n      const url = feed?.type === \"feed\" ? feed.url : options?.url\n      const subscription = getSubscriptionByFeedId(id)\n      const isFollowed = !!subscription\n\n      present({\n        title: `${isFollowed ? `${t(\"common:words.edit\")} ` : \"\"}${options?.isList ? t(\"words.lists\") : t(\"words.feeds\")}`,\n        modalContentClassName: \"overflow-visible\",\n        content: ({ dismiss }) => {\n          const onSuccess = () => {\n            options?.onSuccess?.()\n            // If it's a preview, navigate to the back path\n            const backPath = previewBackPath()\n            backPath && navigate(backPath)\n            dismiss()\n          }\n          return options?.isList ? (\n            <ListForm\n              id={options?.id}\n              defaultValues={options?.defaultValues as ListFormDataValuesType}\n              onSuccess={onSuccess}\n            />\n          ) : (\n            <FeedForm\n              id={id}\n              url={url}\n              defaultValues={options?.defaultValues as FeedFormDataValuesType}\n              onSuccess={onSuccess}\n            />\n          )\n        },\n      })\n    },\n    [present],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts",
    "content": "import { getReadonlyRoute, getStableRouterNavigate } from \"@follow/components/atoms/route.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { useSheetContext } from \"@follow/components/ui/sheet/context.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport { getSubscriptionByFeedId } from \"@follow/store/subscription/getter\"\nimport { tracker } from \"@follow/tracker\"\nimport { useCallback } from \"react\"\nimport { toast } from \"sonner\"\n\nimport { disableShowAISummaryOnce } from \"~/atoms/ai-summary\"\nimport { disableShowAITranslationOnce } from \"~/atoms/ai-translation\"\nimport { setPreviewBackPath } from \"~/atoms/preview\"\nimport { resetShowSourceContent } from \"~/atoms/source-content\"\nimport {\n  ROUTE_ENTRY_PENDING,\n  ROUTE_FEED_IN_FOLDER,\n  ROUTE_FEED_IN_INBOX,\n  ROUTE_FEED_IN_LIST,\n  ROUTE_FEED_PENDING,\n} from \"~/constants\"\n\nimport { getTimelineIdByView, useRouteParamsSelector } from \"./useRouteParams\"\n\nexport type NavigateEntryOptions = Partial<{\n  timelineId: string\n  feedId: string | null\n  entryId: string | null\n  view: FeedViewType\n  folderName: string | null\n  inboxId: string\n  listId: string\n  backPath: string\n}>\n/**\n * @description a hook to navigate to `feedId`, `entryId`, add search for `view`, `level`\n */\nexport const useNavigateEntry = () => {\n  const sheetContext = useSheetContext()\n  const isMobile = useMobile()\n  return useCallback(\n    (options: NavigateEntryOptions) => {\n      navigateEntry(options)\n      if (isMobile && sheetContext) {\n        sheetContext.dismiss()\n      }\n    },\n    [isMobile, sheetContext],\n  )\n}\n\ntype ParsedNavigateEntryOptions = {\n  feedId: string\n  timelineId: string\n  entryId: string\n}\n\nconst parseNavigateEntryOptions = (options: NavigateEntryOptions): ParsedNavigateEntryOptions => {\n  const { entryId, feedId, view, folderName, inboxId, listId, timelineId } = options || {}\n  const route = getReadonlyRoute()\n  const { params } = route\n  let finalFeedId = feedId || params.feedId || ROUTE_FEED_PENDING\n  let finalTimelineId = timelineId || params.timelineId || ROUTE_FEED_PENDING\n  const finalEntryId = entryId || ROUTE_ENTRY_PENDING\n  const subscription = getSubscriptionByFeedId(finalFeedId)\n  const finalView = typeof view === \"number\" ? view : subscription?.view\n\n  if (\"feedId\" in options && feedId === null) {\n    finalFeedId = ROUTE_FEED_PENDING\n  }\n\n  if (folderName) {\n    finalFeedId = `${ROUTE_FEED_IN_FOLDER}${folderName}`\n  }\n\n  if (listId) {\n    finalFeedId = `${ROUTE_FEED_IN_LIST}${listId}`\n  }\n\n  if (inboxId) {\n    finalFeedId = `${ROUTE_FEED_IN_INBOX}${inboxId}`\n  }\n\n  finalFeedId = encodeURIComponent(finalFeedId)\n\n  if (finalView !== undefined && !timelineId) {\n    finalTimelineId = getTimelineIdByView(finalView)\n  }\n\n  return {\n    feedId: finalFeedId,\n    timelineId: finalTimelineId,\n    entryId: finalEntryId,\n  }\n}\n\nexport function getNavigateEntryPath(options: NavigateEntryOptions | ParsedNavigateEntryOptions) {\n  if (\"feedId\" in options) {\n    return `/timeline/${options.timelineId}/${options.feedId}/${options.entryId}`\n  }\n\n  const { feedId, timelineId, entryId } = parseNavigateEntryOptions(options)\n\n  return `/timeline/${timelineId}/${feedId}/${entryId}`\n}\n\n/*\n * /timeline/:timelineId/:feedId/:entryId\n * timelineId: articles | social-media | view-1 (legacy) | ...\n * feedId: xxx, folder-xxx, list-xxx, inbox-xxx\n * entryId: xxx\n */\nexport const navigateEntry = (options: NavigateEntryOptions) => {\n  const parsedOptions = parseNavigateEntryOptions(options)\n  const path = getNavigateEntryPath(parsedOptions)\n  const { backPath } = options || {}\n  const route = getReadonlyRoute()\n  const currentPath = route.location.pathname + route.location.search\n  if (path === currentPath) return\n\n  if (backPath) {\n    setPreviewBackPath(backPath)\n  }\n\n  tracker.navigateEntry({\n    feedId: parsedOptions.feedId,\n    entryId: parsedOptions.entryId,\n    timelineId: parsedOptions.timelineId,\n  })\n\n  disableShowAISummaryOnce()\n  disableShowAITranslationOnce()\n  const sourceContent = getEntry(parsedOptions.entryId)?.settings?.sourceContent\n  if (!sourceContent) {\n    resetShowSourceContent()\n  }\n\n  const navigate = getStableRouterNavigate()\n\n  if (!navigate) {\n    const message =\n      \"Navigation is not available, maybe a mistake in the code, please report an issue. thx.\"\n    toast.error(message)\n    throw new Error(message, { cause: \"Navigation is not available\" })\n  }\n\n  return navigate?.(path)\n}\n\nexport const useBackHome = (timelineId?: string) => {\n  const navigate = useNavigateEntry()\n  const feedId = useRouteParamsSelector((state) => state.feedId)\n  const entryId = useRouteParamsSelector((state) => state.entryId)\n  const backToFeed =\n    entryId && feedId && entryId !== ROUTE_ENTRY_PENDING && feedId !== ROUTE_FEED_PENDING\n  const feedIdToNavigate = backToFeed ? feedId : null\n\n  return useCallback(\n    (overvideTimelineId?: string) => {\n      navigate({\n        feedId: feedIdToNavigate,\n        entryId: null,\n        timelineId: overvideTimelineId ?? timelineId,\n      })\n    },\n    [navigate, feedIdToNavigate, timelineId],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/usePeekModal.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { getSubscriptionById } from \"@follow/store/subscription/getter\"\nimport { useCallback } from \"react\"\n\nimport { disableShowAISummaryOnce } from \"~/atoms/ai-summary\"\nimport { disableShowAITranslationOnce } from \"~/atoms/ai-translation\"\nimport { resetShowSourceContent } from \"~/atoms/source-content\"\nimport { PeekModal } from \"~/components/ui/modal/inspire/PeekModal\"\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { EntryModalPreview } from \"~/components/ui/peek-modal/EntryModalPreview\"\nimport { EntryMoreActions } from \"~/components/ui/peek-modal/EntryMoreActions\"\nimport { EntryToastPreview } from \"~/components/ui/peek-modal/EntryToastPreview\"\nimport { getRouteParams, getTimelineIdByView } from \"~/hooks/biz/useRouteParams\"\n\nexport const usePeekModal = () => {\n  const { present } = useModalStack()\n  return useCallback(\n    (entryId: string, variant: \"toast\" | \"modal\") => {\n      const basePresentProps = {\n        clickOutsideToDismiss: true,\n        title: \"Entry Preview\",\n      }\n\n      if (variant === \"toast\") {\n        present({\n          ...basePresentProps,\n          CustomModalComponent: PlainModal,\n          content: () => <EntryToastPreview entryId={entryId} />,\n          overlay: false,\n          modal: false,\n          modalContainerClassName: \"right-0 left-[auto]\",\n        })\n      } else {\n        present({\n          ...basePresentProps,\n          autoFocus: false,\n          modalClassName:\n            \"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0\",\n\n          CustomModalComponent: ({ children }) => {\n            const feedId = useEntry(entryId, (state) => state.feedId)\n            const subscription = feedId ? getSubscriptionById(feedId) : undefined\n            const view = subscription?.view ?? getRouteParams().view\n            const timelineId = getTimelineIdByView(view)\n            return (\n              <PeekModal\n                rightActions={[\n                  {\n                    onClick: () => {},\n                    label: \"More Actions\",\n                    icon: <EntryMoreActions entryId={entryId} />,\n                  },\n                ]}\n                to={feedId ? `/timeline/${timelineId}/${feedId}/${entryId}` : undefined}\n              >\n                {children}\n              </PeekModal>\n            )\n          },\n          content: () => <EntryModalPreview entryId={entryId} />,\n          overlay: true,\n          onClose: () => {\n            disableShowAISummaryOnce()\n            disableShowAITranslationOnce()\n            resetShowSourceContent()\n          },\n        })\n      }\n    },\n    [present],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useProxySetting.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { ipcServices } from \"~/lib/client\"\n\nconst proxyAtom = atom(\"\")\n\nproxyAtom.onMount = (setAtom) => {\n  ipcServices?.setting.getProxyConfig().then((proxy) => {\n    setAtom(proxy || \"\")\n  })\n}\n\nexport const useProxyValue = () => useAtomValue(proxyAtom)\n\nexport const useSetProxy = () => {\n  const setProxy = useSetAtom(proxyAtom)\n  return useCallback(\n    (proxyString: string) => {\n      if (!IN_ELECTRON) {\n        return\n      }\n      setProxy(proxyString)\n      ipcServices?.setting.setProxyConfig(proxyString)\n    },\n    [setProxy],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useReduceMotion.ts",
    "content": "import { useReducedMotion } from \"motion/react\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\n\nexport const useReduceMotion = () => {\n  const appReduceMotion = useUISettingKey(\"reduceMotion\")\n  const reduceMotion = useReducedMotion()\n  return appReduceMotion || reduceMotion\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useRenderStyle.tsx",
    "content": "import { useMemo } from \"react\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\n\nexport function useRenderStyle({\n  baseFontSize = 16,\n  baseLineHeight = 1.75,\n}: { baseFontSize?: number; baseLineHeight?: number } = {}) {\n  const contentLineHeight = useUISettingKey(\"contentLineHeight\")\n  const contentFontSize = useUISettingKey(\"contentFontSize\")\n  const readerFontFamily = useUISettingKey(\"readerFontFamily\")\n\n  return useMemo(() => {\n    const css = {} as React.CSSProperties\n    if (readerFontFamily) {\n      css.fontFamily = readerFontFamily\n    }\n    if (contentLineHeight) {\n      css.lineHeight = contentLineHeight * (baseLineHeight / 1.5)\n    }\n    if (contentFontSize) {\n      css.fontSize = contentFontSize * (baseFontSize / 16)\n    }\n\n    return css\n  }, [readerFontFamily, contentLineHeight, contentFontSize, baseFontSize, baseLineHeight])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useRouteParams.ts",
    "content": "import {\n  getReadonlyRoute,\n  useReadonlyRoute,\n  useReadonlyRouteSelector,\n} from \"@follow/components/atoms/route.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { getListById } from \"@follow/store/list/getters\"\nimport { useMemo } from \"react\"\nimport type { Params } from \"react-router\"\nimport { useParams } from \"react-router\"\n\nimport {\n  FEED_COLLECTION_LIST,\n  ROUTE_ENTRY_PENDING,\n  ROUTE_FEED_IN_FOLDER,\n  ROUTE_FEED_IN_INBOX,\n  ROUTE_FEED_IN_LIST,\n  ROUTE_FEED_PENDING,\n  ROUTE_TIMELINE_OF_VIEW,\n  ROUTE_VIEW_ALL,\n} from \"~/constants\"\n\nexport const useRouteEntryId = () => {\n  const { entryId } = useParams()\n  return entryId\n}\n\nexport const useRouteFeedId = () => {\n  const { feedId } = useParams()\n  return feedId\n}\n\nexport interface BizRouteParams {\n  view: FeedViewType\n  entryId?: string\n  feedId?: string\n  isCollection: boolean\n  isAllFeeds: boolean\n  isPendingEntry: boolean\n  folderName?: string\n  inboxId?: string\n  listId?: string\n  timelineId?: string\n}\n\nconst VIEW_SLUG_BY_VIEW: Record<FeedViewType, string> = {\n  [FeedViewType.All]: ROUTE_VIEW_ALL,\n  [FeedViewType.Articles]: \"articles\",\n  [FeedViewType.SocialMedia]: \"social-media\",\n  [FeedViewType.Pictures]: \"pictures\",\n  [FeedViewType.Videos]: \"videos\",\n  [FeedViewType.Audios]: \"audios\",\n  [FeedViewType.Notifications]: \"notifications\",\n}\n\nconst VIEW_PARAM_ALIAS_MAP: Record<string, FeedViewType> = Object.entries(VIEW_SLUG_BY_VIEW).reduce(\n  (acc, [view, slug]) => {\n    if (slug === ROUTE_VIEW_ALL) return acc\n    const numericView = Number(view)\n    if (Number.isNaN(numericView)) return acc\n    acc[slug] = numericView as FeedViewType\n    return acc\n  },\n  {} as Record<string, FeedViewType>,\n)\n\nconst FEED_VIEW_VALUES = new Set<FeedViewType>(\n  Object.values(FeedViewType).filter((value): value is FeedViewType => typeof value === \"number\"),\n)\n\nconst isFeedViewTypeValue = (value: number): value is FeedViewType =>\n  Number.isInteger(value) && FEED_VIEW_VALUES.has(value as FeedViewType)\n\nexport const getTimelineIdByView = (view: FeedViewType) =>\n  VIEW_SLUG_BY_VIEW[view] ?? `${ROUTE_TIMELINE_OF_VIEW}${view}`\n\nexport function parseView(input: string | undefined): FeedViewType | undefined {\n  if (!input) return undefined\n\n  const normalizedInput = input.toLowerCase()\n\n  if (normalizedInput === ROUTE_VIEW_ALL) return FeedViewType.All\n\n  const aliasView = VIEW_PARAM_ALIAS_MAP[normalizedInput]\n  if (aliasView !== undefined) return aliasView\n\n  if (normalizedInput.startsWith(ROUTE_TIMELINE_OF_VIEW)) {\n    const view = Number.parseInt(normalizedInput.slice(ROUTE_TIMELINE_OF_VIEW.length), 10)\n    if (isFeedViewTypeValue(view)) {\n      return view\n    }\n  }\n\n  const numericView = Number.parseInt(normalizedInput, 10)\n\n  if (isFeedViewTypeValue(numericView)) {\n    return numericView\n  }\n}\n\nconst parseRouteParams = (params: Params<any>, _searchParams: URLSearchParams): BizRouteParams => {\n  const listId = params.feedId?.startsWith(ROUTE_FEED_IN_LIST)\n    ? params.feedId.slice(ROUTE_FEED_IN_LIST.length)\n    : undefined\n  const list = listId ? getListById(listId) : undefined\n\n  return {\n    view: parseView(params.timelineId) ?? list?.view ?? FeedViewType.Articles,\n    entryId: params.entryId || undefined,\n    feedId: params.feedId || undefined,\n    // alias\n    isCollection: params.feedId === FEED_COLLECTION_LIST,\n    isAllFeeds: params.feedId === ROUTE_FEED_PENDING,\n    isPendingEntry: params.entryId === ROUTE_ENTRY_PENDING,\n    folderName: params.feedId?.startsWith(ROUTE_FEED_IN_FOLDER)\n      ? params.feedId.slice(ROUTE_FEED_IN_FOLDER.length)\n      : undefined,\n    inboxId: params.feedId?.startsWith(ROUTE_FEED_IN_INBOX)\n      ? params.feedId.slice(ROUTE_FEED_IN_INBOX.length)\n      : undefined,\n    listId,\n    timelineId: params.timelineId,\n  }\n}\n\nexport const useRouteParams = () => {\n  const route = useReadonlyRoute()\n  return useMemo(\n    () => parseRouteParams(route.params, route.searchParams),\n    [route.params, route.searchParams],\n  )\n}\n\nconst noop = [] as any[]\n\nexport const useRouteParamsSelector = <T>(\n  selector: (params: BizRouteParams) => T,\n  deps = noop,\n): T =>\n  useReadonlyRouteSelector((route) => {\n    const { params, searchParams } = route\n\n    return selector(parseRouteParams(params, searchParams))\n  }, deps)\n\nexport const getRouteParams = () => {\n  const route = getReadonlyRoute()\n  const { params, searchParams } = route\n  return parseRouteParams(params, searchParams)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useShowEntryDetailsColumn.ts",
    "content": "import { getView } from \"@follow/constants\"\n\nimport { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\nexport const useShowEntryDetailsColumn = () => {\n  const { view } = useRouteParamsSelector((s) => ({\n    view: s.view,\n  }))\n  const aiPanelStyle = useAIChatPanelStyle()\n  const isAIPanelVisible = useAIPanelVisibility()\n\n  return (\n    !getView(view).wideMode && (aiPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible)\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx",
    "content": "import { Kbd } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport type { SubscriptionModel } from \"@follow/store/subscription/types\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { navigateEntry } from \"./useNavigateEntry\"\nimport { getRouteParams } from \"./useRouteParams\"\n\nexport const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void } = {}) => {\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: async ({\n      subscription,\n      feedIdList,\n    }: {\n      subscription?: SubscriptionModel\n      feedIdList?: string[]\n    }) => {\n      if (feedIdList) {\n        await subscriptionSyncService.unsubscribe(feedIdList)\n        toast.success(t(\"notify.unfollow_feed_many\"))\n        return\n      }\n\n      if (!subscription) return\n\n      subscriptionSyncService\n        .unsubscribe([subscription.feedId, subscription.listId])\n        .then(([feed]) => {\n          subscriptionSyncService.fetch()\n\n          if (!subscription) return\n          if (!feed) return\n          const undo = async () => {\n            await subscriptionSyncService.subscribe({\n              url: feed.type === \"feed\" ? feed.url : undefined,\n              listId: feed.type === \"list\" ? feed.id : undefined,\n              view: subscription.view,\n              category: subscription.category,\n              isPrivate: subscription.isPrivate,\n              feedId: feed.id,\n              title: feed.title,\n              hideFromTimeline: subscription.hideFromTimeline,\n            })\n\n            toast.dismiss(toastId)\n          }\n\n          const toastId = toast.warning(\"\", {\n            duration: 3000,\n            description: <UnfollowInfo title={feed.title!} undo={undo} />,\n            action: {\n              label: (\n                <span className={\"flex items-center gap-1 px-1\"}>\n                  {t(\"words.undo\")}\n                  <Kbd className=\"inline-flex items-center border border-border bg-transparent text-white\">\n                    $mod+Z\n                  </Kbd>\n                </span>\n              ),\n              onClick: undo,\n            },\n          })\n        })\n    },\n\n    onSuccess: (_) => {\n      onSuccess?.()\n    },\n    onMutate(variables) {\n      if (getRouteParams().feedId === variables.subscription?.feedId) {\n        navigateEntry({\n          feedId: null,\n          entryId: null,\n          view: getRouteParams().view,\n        })\n      }\n    },\n  })\n}\n\nconst UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => {\n  useHotkeys(\"ctrl+z,meta+z\", undo, {\n    preventDefault: true,\n  })\n  return (\n    <span className=\"font-medium text-text\">\n      <Trans\n        ns=\"app\"\n        i18nKey=\"notify.unfollow_feed\"\n        components={{\n          FeedItem: <i className=\"mr-px font-semibold\">{title}</i>,\n        }}\n      />\n    </span>\n  )\n}\n\nexport const useBatchUpdateSubscription = () => {\n  return useMutation({\n    mutationFn: async ({\n      feedIdList,\n      category,\n      view,\n    }: {\n      feedIdList: string[]\n      category?: string | null\n      view: number\n    }) => {\n      await subscriptionSyncService.batchUpdateSubscription({\n        category,\n        feedIds: feedIdList,\n        view,\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts",
    "content": "import { FeedViewType, getViewList } from \"@follow/constants\"\nimport type { UISettings } from \"@follow/shared/settings/interface\"\nimport { useSubscriptionStore } from \"@follow/store/subscription/store\"\nimport { useMemo } from \"react\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { ROUTE_VIEW_ALL } from \"~/constants/app\"\n\nimport { getTimelineIdByView, parseView } from \"./useRouteParams\"\n\nconst ALL_TIMELINE_IDS = getViewList({ includeAll: true }).map((view) =>\n  getTimelineIdByView(view.view),\n)\n\nconst normalizeTimelineId = (id: string) => {\n  const view = parseView(id)\n  return view !== undefined ? getTimelineIdByView(view) : id\n}\n\nconst filterKnownTimelineIds = (ids: string[]) => {\n  const seen = new Set<string>()\n  return ids.filter((id) => {\n    if (!ALL_TIMELINE_IDS.includes(id)) return false\n    if (seen.has(id)) return false\n    seen.add(id)\n    return true\n  })\n}\n\nexport const computeTimelineTabLists = ({\n  timelineTabs,\n  hasAudiosSubscription,\n  hasNotificationsSubscription,\n}: {\n  timelineTabs?: UISettings[\"timelineTabs\"]\n  hasAudiosSubscription: boolean\n  hasNotificationsSubscription: boolean\n}) => {\n  const savedVisible = filterKnownTimelineIds(\n    (timelineTabs?.visible ?? []).map(normalizeTimelineId),\n  )\n  const savedHidden = filterKnownTimelineIds((timelineTabs?.hidden ?? []).map(normalizeTimelineId))\n  const extras = ALL_TIMELINE_IDS.filter(\n    (id) => !savedVisible.includes(id) && !savedHidden.includes(id),\n  )\n\n  const isDefaultHidden = (id: string) => {\n    if (id === getTimelineIdByView(FeedViewType.Audios)) return !hasAudiosSubscription\n    if (id === getTimelineIdByView(FeedViewType.Notifications)) return !hasNotificationsSubscription\n    return false\n  }\n\n  const extraVisible = extras.filter((id) => !isDefaultHidden(id))\n  const extraHidden = extras.filter((id) => isDefaultHidden(id))\n\n  const allConfigured =\n    savedVisible.includes(ROUTE_VIEW_ALL) || savedHidden.includes(ROUTE_VIEW_ALL)\n\n  let nextVisible = [...savedVisible]\n\n  if (!allConfigured && extraVisible.includes(ROUTE_VIEW_ALL)) {\n    nextVisible = [ROUTE_VIEW_ALL, ...nextVisible]\n  }\n\n  nextVisible = [...nextVisible, ...extraVisible.filter((id) => id !== ROUTE_VIEW_ALL)]\n\n  const nextHidden = [...savedHidden, ...extraHidden].filter((id) => !nextVisible.includes(id))\n\n  return { visible: nextVisible, hidden: nextHidden }\n}\n\nexport const useTimelineList = (options?: {\n  visible?: boolean\n  hidden?: boolean\n  withAll?: boolean\n}) => {\n  const timelineTabs = useUISettingKey(\"timelineTabs\")\n  const hasAudiosSubscription = useSubscriptionStore(\n    (state) =>\n      state.feedIdByView[FeedViewType.Audios].size > 0 ||\n      state.listIdByView[FeedViewType.Audios].size > 0,\n  )\n  const hasNotificationsSubscription = useSubscriptionStore(\n    (state) =>\n      state.feedIdByView[FeedViewType.Notifications].size > 0 ||\n      state.listIdByView[FeedViewType.Notifications].size > 0,\n  )\n\n  const { visible, hidden } = useMemo(\n    () =>\n      computeTimelineTabLists({\n        timelineTabs,\n        hasAudiosSubscription,\n        hasNotificationsSubscription,\n      }),\n    [hasAudiosSubscription, hasNotificationsSubscription, timelineTabs],\n  )\n\n  return useMemo(() => {\n    let result: string[]\n    if (options?.visible) result = visible\n    else if (options?.hidden) result = hidden\n    else result = [...visible, ...hidden]\n\n    if (options?.withAll === false) {\n      result = result.filter((id) => id !== ROUTE_VIEW_ALL)\n    }\n\n    return result\n  }, [hidden, options?.hidden, options?.visible, options?.withAll, visible])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { ipcServices } from \"~/lib/client\"\n\nconst minimizeToTrayAtom = atom<boolean>(true)\n\nminimizeToTrayAtom.onMount = (setAtom) => {\n  const result = ipcServices?.setting.getMinimizeToTray()\n  Promise.resolve(result).then((proxy) => {\n    if (typeof proxy === \"boolean\") {\n      setAtom(proxy)\n    }\n  })\n}\n\nexport const useMinimizeToTrayValue = () => useAtomValue(minimizeToTrayAtom)\n\nexport const useSetMinimizeToTray = () => {\n  const setMinimizeToTray = useSetAtom(minimizeToTrayAtom)\n  return useCallback(\n    (value: boolean) => {\n      if (!IN_ELECTRON) return\n      setMinimizeToTray(value)\n      ipcServices?.setting.setMinimizeToTray(value)\n    },\n    [setMinimizeToTray],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/index.ts",
    "content": "export * from \"./useBizQuery\"\nexport * from \"./useContextMenu\"\nexport * from \"./useI18n\"\nexport * from \"./useLoginModal\"\nexport * from \"./usePreventOverscrollBounce\"\nexport * from \"./useRecaptchaToken\"\nexport * from \"./useRequireLogin\"\nexport * from \"./useSyncTheme\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useBizQuery.ts",
    "content": "import type { QueryKey, UseQueryOptions, UseQueryResult } from \"@tanstack/react-query\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport type { FetchError } from \"ofetch\"\n\nimport { useLoginModalShow } from \"~/atoms/user\"\nimport type { DefinedQuery } from \"~/lib/defineQuery\"\n\n// TODO split normal define query and infinite define query for better type checking\nexport type SafeReturnType<T> = T extends (...args: any[]) => infer R ? R : never\n\nexport type CombinedObject<T, U> = T & U\nexport function useAuthQuery<\n  TQuery extends DefinedQuery<QueryKey, any>,\n  TError = FetchError,\n  TQueryFnData = Awaited<ReturnType<TQuery[\"fn\"]>>,\n  TData = TQueryFnData,\n>(\n  query: TQuery,\n  options: Omit<UseQueryOptions<TQueryFnData, TError>, \"queryKey\" | \"queryFn\"> = {},\n): CombinedObject<UseQueryResult<TData, TError>, { key: TQuery[\"key\"]; fn: TQuery[\"fn\"] }> {\n  const authFail = useLoginModalShow()\n  // @ts-expect-error\n  return Object.assign(\n    {},\n    useQuery({\n      queryKey: query.key,\n      queryFn: query.fn,\n      enabled: !authFail && options.enabled !== false,\n      ...options,\n    }),\n    {\n      key: query.key,\n      fn: query.fn,\n    },\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useContextMenu.tsx",
    "content": "import { useLongPress } from \"@follow/hooks\"\n\ninterface UseContextMenuOptions {\n  onContextMenu: (e: React.MouseEvent) => void\n  onTouchStart?: (e: React.TouchEvent) => void\n  onTouchMove?: (e: React.TouchEvent) => void\n  onTouchEnd?: (e: React.TouchEvent) => void\n}\n\nexport const useContextMenu = ({\n  onContextMenu,\n  onTouchStart,\n  onTouchMove,\n  onTouchEnd,\n}: UseContextMenuOptions) => {\n  const props = useLongPress({\n    onLongPress: onContextMenu as any,\n    onTouchStart,\n    onTouchMove,\n    onTouchEnd,\n  })\n  return {\n    ...props,\n    onContextMenu,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useFeedSafeUrl.ts",
    "content": "import { isOnboardingEntryUrl } from \"@follow/store/constants/onboarding\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { resolveUrlWithBase } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\n\nexport const useFeedSafeUrl = (entryId: string) => {\n  const entry = useEntry(entryId, (state) => {\n    return {\n      feedId: state.feedId,\n      inboxId: state.inboxHandle,\n      url: state.url,\n      authorUrl: state.authorUrl,\n    }\n  })\n\n  const feed = useFeedById(entry?.feedId, (feed) => ({\n    type: feed?.type,\n    siteUrl: feed?.siteUrl,\n  }))\n  const isInbox = useIsInbox(entry?.inboxId)\n\n  return useMemo(() => {\n    if (isInbox) return entry?.authorUrl\n    const href = entry?.url\n    if (!href) return null\n\n    if (isOnboardingEntryUrl(href)) {\n      return null\n    }\n\n    if (href.startsWith(\"http\")) {\n      try {\n        const domain = new URL(href).hostname\n        if (domain === \"localhost\") return null\n      } catch {\n        return null\n      }\n\n      return href\n    }\n    const feedSiteUrl = feed?.type === \"feed\" ? feed?.siteUrl : null\n    if (feedSiteUrl) return resolveUrlWithBase(href, feedSiteUrl)\n    return href\n  }, [entry?.authorUrl, entry?.url, feed?.type, feed?.siteUrl, isInbox])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useI18n.ts",
    "content": "import { useMemo } from \"react\"\nimport type { FallbackNs, UseTranslationResponse } from \"react-i18next\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ns } from \"~/@types/constants\"\n\nconst allNameSpaces = ns\n\nexport function useI18n() {\n  const { t } = useTranslation()\n\n  return useMemo(() => {\n    const clonedT = t.bind(t)\n\n    for (const ns of allNameSpaces) {\n      clonedT[ns] = (key: any, options: Omit<Parameters<typeof t>[1], \"ns\"> = {}) => {\n        return t(key, { ns: ns as any, ...options })\n      }\n    }\n    return clonedT as typeof t & {\n      [K in (typeof allNameSpaces)[number]]: UseTranslationResponse<FallbackNs<K>, undefined>[\"t\"]\n    }\n  }, [t])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useLoginModal.tsx",
    "content": "import { useCallback } from \"react\"\n\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { LoginModalContent } from \"~/modules/auth/LoginModalContent\"\n\nexport const useLoginModal = () => {\n  const { present } = useModalStack()\n\n  return useCallback(() => {\n    present({\n      CustomModalComponent: PlainModal,\n      title: \"Login\",\n      id: \"login\",\n      content: () => <LoginModalContent runtime={window.electron ? \"app\" : \"browser\"} />,\n      clickOutsideToDismiss: true,\n    })\n  }, [present])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/usePreventOverscrollBounce.ts",
    "content": "import { useEffect } from \"react\"\n\nconst PREVENT_SPRING_CLASS = \"prevent-spring\"\n\n/**\n * Prevent overscroll bounce\n * @param enabled - Whether to enable the prevention of overscroll bounce, default is true\n */\nexport const usePreventOverscrollBounce = (enabled = true) => {\n  useEffect(() => {\n    if (!enabled) return\n\n    // If has style element, skip\n    if (document.querySelector(`#${PREVENT_SPRING_CLASS}`)) {\n      return\n    }\n\n    const styleElement = document.createElement(\"style\")\n    styleElement.id = PREVENT_SPRING_CLASS\n    styleElement.textContent = `\n      [data-${PREVENT_SPRING_CLASS}] {\n        overscroll-behavior: none !important;\n      }\n    `\n\n    document.head.append(styleElement)\n\n    document.documentElement.dataset.preventSpring = \"true\"\n    document.body.dataset.preventSpring = \"true\"\n\n    return () => {\n      delete document.documentElement.dataset.preventSpring\n      delete document.body.dataset.preventSpring\n      styleElement.remove()\n    }\n  }, [enabled])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useRecaptchaToken.ts",
    "content": "import { useCallback } from \"react\"\nimport { useGoogleReCaptcha } from \"react-google-recaptcha-v3\"\n\ntype FoloE2EWindow = Window &\n  typeof globalThis & {\n    __FOLO_E2E_RECAPTCHA_TOKEN__?: string\n  }\n\nexport const useRecaptchaToken = () => {\n  const { executeRecaptcha } = useGoogleReCaptcha()\n\n  return useCallback(\n    async (action: string) => {\n      const e2eToken = (window as FoloE2EWindow).__FOLO_E2E_RECAPTCHA_TOKEN__\n      if (e2eToken) {\n        return e2eToken\n      }\n\n      if (\n        navigator.webdriver ||\n        window.location.hostname === \"localhost\" ||\n        window.location.hostname === \"127.0.0.1\"\n      ) {\n        return \"e2e-token\"\n      }\n\n      if (!executeRecaptcha) {\n        return null\n      }\n\n      try {\n        return await executeRecaptcha(action)\n      } catch (error) {\n        console.error(\"Failed to execute reCAPTCHA\", error)\n        return null\n      }\n    },\n    [executeRecaptcha],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useRequireLogin.ts",
    "content": "import { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { useCallback } from \"react\"\n\nimport { useLoginModal } from \"./useLoginModal\"\n\nexport const useRequireLogin = () => {\n  const isLoggedIn = useIsLoggedIn()\n  const showLoginModal = useLoginModal()\n\n  const ensureLogin = useCallback(() => {\n    if (isLoggedIn) {\n      return true\n    }\n    showLoginModal()\n    return false\n  }, [isLoggedIn, showLoginModal])\n\n  const withLoginGuard = useCallback(\n    <T extends (...args: any[]) => unknown>(action: T) => {\n      if (!action) return action\n\n      return ((...args: Parameters<T>) => {\n        if (!ensureLogin()) {\n          return\n        }\n        return action(...args)\n      }) as T\n    },\n    [ensureLogin],\n  )\n\n  return {\n    isLoggedIn,\n    ensureLogin,\n    withLoginGuard,\n    showLoginModal,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/hooks/common/useSyncTheme.ts",
    "content": "import type { ColorMode } from \"@follow/hooks\"\nimport {\n  disableTransition,\n  internal_useSetTheme,\n  useDarkQuery,\n  useSyncThemeWebApp,\n} from \"@follow/hooks\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useCallback, useLayoutEffect } from \"react\"\n\nimport { ipcServices } from \"~/lib/client\"\n\nconst useSyncThemeElectron = () => {\n  const appIsDark = useDarkQuery()\n  const setTheme = internal_useSetTheme()\n  useLayoutEffect(() => {\n    let isMounted = true\n    ipcServices?.setting.getAppearance().then((appearance) => {\n      if (!isMounted) return\n      setTheme(appearance)\n      disableTransition([\"[role=switch]>*\"])()\n\n      document.documentElement.dataset.theme =\n        appearance === \"system\" ? (appIsDark ? \"dark\" : \"light\") : appearance\n    })\n    return () => {\n      isMounted = false\n    }\n  }, [appIsDark, setTheme])\n}\n\nexport const useSyncTheme = IN_ELECTRON ? useSyncThemeElectron : useSyncThemeWebApp\n\nexport const useSetTheme = () => {\n  const setTheme = internal_useSetTheme()\n  return useCallback(\n    (colorMode: ColorMode) => {\n      setTheme(colorMode)\n\n      if (IN_ELECTRON) {\n        ipcServices?.setting.setAppearance(colorMode)\n      }\n    },\n    [setTheme],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/i18n.ts",
    "content": "import { DEV } from \"@follow/shared/constants\"\nimport { Chain } from \"@follow/utils/chain\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport i18next from \"i18next\"\nimport { atom } from \"jotai\"\nimport { initReactI18next } from \"react-i18next\"\n\nimport { defaultNS, ns } from \"./@types/constants\"\nimport { defaultResources } from \"./@types/default-resource\"\nimport { getGeneralSettings } from \"./atoms/settings/general\"\nimport { jotaiStore } from \"./lib/jotai\"\n\nexport const i18nAtom = atom(i18next)\n\nexport const getI18n = () => jotaiStore.get(i18nAtom)\n\nexport const langChain = new Chain()\n\nexport class LocaleCache {\n  static shared = new LocaleCache()\n  private getKey(lang: string) {\n    return getStorageNS(`locale-${lang}`)\n  }\n  get(lang: string) {\n    const key = this.getKey(lang)\n    const cache = localStorage.getItem(key)\n    if (!cache) return null\n    return JSON.parse(cache)\n  }\n  set(lang: string) {\n    const key = this.getKey(lang)\n    const mergedResources = {} as any\n    for (const nsKey of ns) {\n      const nsResources = i18next.getResourceBundle(lang, nsKey)\n      mergedResources[nsKey] = nsResources\n    }\n    localStorage.setItem(key, JSON.stringify(mergedResources))\n  }\n}\n\nexport const fallbackLanguage = \"en\"\nexport const initI18n = async () => {\n  const i18next = jotaiStore.get(i18nAtom)\n\n  const lang = getGeneralSettings().language\n\n  const mergedResources = {\n    ...defaultResources,\n  }\n\n  let cache = null as any\n  if (!DEV) {\n    cache = LocaleCache.shared.get(lang)\n    if (cache) {\n      mergedResources[lang] = cache\n    }\n  }\n\n  await i18next.use(initReactI18next).init({\n    ns,\n    lng: cache ? lang : fallbackLanguage,\n    fallbackLng: {\n      default: [fallbackLanguage],\n      \"zh-TW\": [\"zh-CN\", fallbackLanguage],\n    },\n    defaultNS,\n    debug: import.meta.env.DEV,\n\n    resources: mergedResources,\n  })\n}\n\nif (import.meta.hot) {\n  import.meta.hot.on(\n    \"i18n-update\",\n    async ({ file, content }: { file: string; content: string }) => {\n      const resources = JSON.parse(content)\n      const i18next = jotaiStore.get(i18nAtom)\n\n      const nsName = file.match(/locales\\/(.+?)\\//)?.[1]\n\n      if (!nsName) return\n      const lang = file.split(\"/\").pop()?.replace(\".json\", \"\")\n      if (!lang) return\n      i18next.addResourceBundle(lang, nsName, resources, true, true)\n\n      console.info(\"reload\", lang, nsName)\n      await i18next.reloadResources(lang, nsName)\n\n      EventBus.dispatch(\"I18N_UPDATE\", \"\")\n    },\n  )\n}\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface CustomEvent {\n    I18N_UPDATE: string\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/analytics.ts",
    "content": "import { env } from \"@follow/shared/env.desktop\"\nimport { setFirebaseTracker, setPostHogTracker, tracker } from \"@follow/tracker\"\nimport { captureAttributionFromURL, getAttributionForAnalytics } from \"@follow/utils\"\nimport type { AuthSessionResponse } from \"@follow-app/client-sdk\"\nimport posthog from \"posthog-js\"\n\nimport { QUERY_PERSIST_KEY } from \"~/constants/app\"\n\nimport { ga4 } from \"../lib/ga4\"\n\nexport const initAnalytics = async () => {\n  // Capture attribution data from URL (preserves first attribution)\n  captureAttributionFromURL()\n\n  // Get attribution data for analytics\n  const attributionData = getAttributionForAnalytics()\n\n  tracker.manager.appendUserProperties({\n    build: ELECTRON ? \"electron\" : \"web\",\n    version: APP_VERSION,\n    hash: GIT_COMMIT_SHA,\n    language: navigator.language,\n    ...attributionData,\n  })\n\n  setFirebaseTracker(ga4)\n\n  setPostHogTracker(\n    posthog.init(env.VITE_POSTHOG_KEY, {\n      api_host: env.VITE_POSTHOG_HOST,\n      person_profiles: \"identified_only\",\n      defaults: \"2025-05-24\",\n      capture_exceptions: {\n        capture_unhandled_errors: true,\n        capture_unhandled_rejections: true,\n        capture_console_errors: false,\n      },\n    }),\n  )\n\n  let session: AuthSessionResponse | undefined\n  try {\n    const queryData = JSON.parse(window.localStorage.getItem(QUERY_PERSIST_KEY) ?? \"{}\")\n    session = queryData.clientState.queries.find(\n      (query: any) => query.queryHash === JSON.stringify([\"auth\", \"session\"]),\n    )?.state.data.data\n  } catch {\n    // do nothing\n  }\n  if (session?.user) {\n    tracker.identify(session.user)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts",
    "content": "import { callWindowExposeRenderer } from \"@follow/shared/bridge\"\nimport { detectIsEditableElement } from \"@follow/utils\"\n\ninterface ShortcutDefinition {\n  accelerator: string\n  action: () => void\n  inputBypass?: boolean\n}\n\nconst parseAccelerator = (\n  accelerator: string,\n): { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean } => {\n  const parts = accelerator.toLowerCase().split(\"+\")\n  return {\n    key: parts.at(-1) ?? \"\",\n    ctrl: parts.includes(\"ctrl\") || parts.includes(\"cmdorctrl\"),\n    meta: parts.includes(\"cmd\") || parts.includes(\"cmdorctrl\"),\n    shift: parts.includes(\"shift\"),\n  }\n}\n\nexport const registerAppGlobalShortcuts = () => {\n  // @see layer/main/menu.ts\n  const shortcuts: ShortcutDefinition[] = [\n    {\n      accelerator: \"CmdOrCtrl+,\",\n      action: () => window.router.showSettings(),\n      inputBypass: true,\n    },\n    {\n      accelerator: \"CmdOrCtrl+T\",\n      action: () => {\n        const caller = callWindowExposeRenderer()\n        caller.goToDiscover()\n      },\n    },\n    {\n      accelerator: \"CmdOrCtrl+N\",\n      action: () => {\n        const caller = callWindowExposeRenderer()\n        caller.quickAdd()\n      },\n    },\n  ]\n\n  const handleKeydown = (e: KeyboardEvent) => {\n    shortcuts.forEach(({ accelerator, action, inputBypass }) => {\n      // Prevent on input, textarea, [contenteditable]\n      if (!inputBypass && e.target instanceof HTMLElement && detectIsEditableElement(e.target)) {\n        return\n      }\n\n      const { key, ctrl, meta, shift } = parseAccelerator(accelerator)\n\n      const matchesKey = e.key.toLowerCase() === key.toLowerCase()\n      const matchesModifier = ctrl\n        ? e.ctrlKey || e.metaKey\n        : !ctrl && !e.ctrlKey && !meta && !e.metaKey\n      const matchesShift = shift ? e.shiftKey : !e.shiftKey\n\n      if (matchesKey && matchesModifier && matchesShift) {\n        action()\n        e.preventDefault()\n      }\n    })\n  }\n\n  document.addEventListener(\"keydown\", handleKeydown, true)\n\n  return () => {\n    document.removeEventListener(\"keydown\", handleKeydown, true)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/helper.ts",
    "content": "import { tracker } from \"@follow/tracker\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\n\nexport const setIntegrationIdentify = (user: AuthUser) => {\n  tracker.identify(user)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/history.ts",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport { jotaiStore } from \"@follow/utils/jotai\"\nimport { atom } from \"jotai\"\n\nimport { router } from \"~/router\"\n\nconst historyAtom = atom<string[]>([])\n\ndeclare global {\n  interface History {\n    stack: string[]\n\n    returnBack: (to?: string) => void\n\n    get isPop(): boolean\n  }\n}\n\nlet __isPop = false\nconst F = {} as { isPop: boolean }\nlet resetTimer: any = null\nObject.defineProperty(F, \"isPop\", {\n  get() {\n    return __isPop\n  },\n  set(value) {\n    if (!value) return\n\n    resetTimer && clearTimeout(resetTimer)\n    resetTimer = setTimeout(() => {\n      __isPop = false\n    }, 1200)\n\n    __isPop = true\n  },\n})\n\nexport const registerHistoryStack = () => {\n  const onPopState = (e: PopStateEvent) => {\n    F.isPop = true\n\n    const url = e.state?.url\n    if (url) {\n      jotaiStore.set(historyAtom, jotaiStore.get(historyAtom).slice(0, -1))\n    }\n  }\n  window.addEventListener(\"popstate\", onPopState)\n\n  const unsub = router.subscribe((e) => {\n    const url = e.location.pathname + e.location.search\n    jotaiStore.set(historyAtom, [...jotaiStore.get(historyAtom), url])\n  })\n\n  Object.defineProperty(window.history, \"stack\", {\n    get() {\n      return jotaiStore.get(historyAtom)\n    },\n    enumerable: false,\n  })\n\n  Object.defineProperty(window.history, \"returnBack\", {\n    value: (to?: string) => {\n      const stack = jotaiStore.get(historyAtom)\n      F.isPop = true\n      const last = stack.at(-1)\n\n      to = typeof to === \"string\" ? to : last\n\n      if (!last || last !== to) {\n        window.router.navigate(to ?? \"/\")\n\n        nextFrame(() => {\n          jotaiStore.set(historyAtom, [])\n        })\n      } else {\n        window.history.back()\n      }\n    },\n    enumerable: false,\n  })\n\n  Object.defineProperty(window.history, \"isPop\", {\n    get() {\n      return F.isPop\n    },\n    enumerable: false,\n  })\n\n  return () => {\n    window.removeEventListener(\"popstate\", onPopState)\n\n    unsub()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/index.ts",
    "content": "import { initializeDayjs } from \"@follow/components/dayjs\"\nimport { registerGlobalContext } from \"@follow/shared/bridge\"\nimport { DEV, ELECTRON_BUILD, IN_ELECTRON } from \"@follow/shared/constants\"\nimport { hydrateDatabaseToStore } from \"@follow/store/hydrate\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport { tracker } from \"@follow/tracker\"\nimport { repository } from \"@pkg\"\nimport { enableMapSet } from \"immer\"\n\nimport { initI18n } from \"~/i18n\"\nimport { hydrateSessionsFromLocalDb } from \"~/modules/ai-chat-session\"\nimport { settingSyncQueue } from \"~/modules/settings/helper/sync-queue\"\nimport { ElectronCloseEvent, ElectronShowEvent } from \"~/providers/invalidate-query-provider\"\n\nimport { subscribeNetworkStatus } from \"../atoms/network\"\nimport { appLog } from \"../lib/log\"\nimport { initAnalytics } from \"./analytics\"\nimport { registerHistoryStack } from \"./history\"\nimport { doMigration } from \"./migrates\"\nimport { initializeSettings } from \"./settings\"\n\ndeclare global {\n  interface Window {\n    version: string\n  }\n}\n\nexport const initializeApp = async () => {\n  appLog(`${APP_NAME}: Follow everything in one place`, repository.url)\n\n  const dataHydratedTime = await apm(\"hydrateDatabaseToStore\", () => {\n    return hydrateDatabaseToStore({\n      migrateDatabase: true,\n    })\n  })\n\n  if (DEV) {\n    const url = \"/favicon-dev.ico\"\n\n    // Change favicon\n    const $icon = document.head.querySelector(\"link[rel='icon']\")\n    if ($icon) {\n      $icon.setAttribute(\"href\", url)\n    } else {\n      const icon = document.createElement(\"link\")\n      icon.setAttribute(\"rel\", \"icon\")\n      icon.setAttribute(\"href\", url)\n      document.head.append(icon)\n    }\n  }\n\n  appLog(`Initialize ${APP_NAME}...`)\n  window.version = APP_VERSION\n\n  const now = Date.now()\n  initializeDayjs()\n  registerHistoryStack()\n\n  hydrateSessionsFromLocalDb()\n  // Set Environment\n  document.documentElement.dataset.buildType = ELECTRON_BUILD ? \"electron\" : \"web\"\n\n  // Register global context for electron\n  registerGlobalContext({\n    /**\n     * Electron app only\n     */\n    onWindowClose() {\n      document.dispatchEvent(new ElectronCloseEvent())\n    },\n    onWindowShow() {\n      document.dispatchEvent(new ElectronShowEvent())\n    },\n  })\n\n  apm(\"migration\", doMigration)\n\n  // Enable Map/Set in immer\n  enableMapSet()\n\n  subscribeNetworkStatus()\n\n  apm(\"initializeSettings\", initializeSettings)\n\n  await apm(\"i18n\", initI18n)\n  await apm(\"initAnalytics\", initAnalytics)\n\n  void apm(\"setting sync\", async () => {\n    await settingSyncQueue.init()\n\n    await userSyncService.whoami().catch(() => null)\n\n    if (!whoami()) {\n      return\n    }\n    await settingSyncQueue.syncLocal()\n  }).catch((error) => {\n    appLog(\"setting sync failed\", error)\n    void tracker.manager.captureException(error, {\n      module: \"setting_sync\",\n      stage: \"bootstrap\",\n    })\n  })\n\n  const loadingTime = Date.now() - now\n  appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`)\n\n  tracker.appInit({\n    electron: IN_ELECTRON,\n    loading_time: loadingTime,\n    data_hydrated_time: dataHydratedTime,\n    version: APP_VERSION,\n    rn: false,\n  })\n}\n\nconst apm = async (label: string, fn: () => Promise<any> | any) => {\n  const start = Date.now()\n  const result = await fn()\n  const end = Date.now()\n  appLog(`${label} took ${end - start}ms`)\n  return result\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/migrates/helper.ts",
    "content": "export interface DefineMigrationOptions {\n  version: string\n  migrate: () => void | Promise<void>\n}\nexport const defineMigration = (options: DefineMigrationOptions) => {\n  return options\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/migrates/index.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\n\nimport { appLog } from \"~/lib/log\"\n\nimport type { DefineMigrationOptions } from \"./helper\"\nimport { v1 } from \"./v/v1\"\n\nconst appVersionKey = getStorageNS(\"app_version\")\nconst migrationVersionKey = getStorageNS(\"migration_version\")\n\ndeclare global {\n  interface Window {\n    __app_is_upgraded__: boolean\n  }\n}\n\nconst getMigrationVersions = () => {\n  try {\n    const versions = localStorage.getItem(migrationVersionKey) || \"[]\"\n    return new Set(JSON.parse(versions))\n  } catch {\n    return new Set()\n  }\n}\nconst migrations: DefineMigrationOptions[] = [v1]\nexport const doMigration = async () => {\n  const migrationVersions = getMigrationVersions()\n\n  for (const migration of migrations) {\n    if (migrationVersions.has(migration.version)) continue\n\n    appLog(`Migrating ${migration.version}...`)\n    await migration.migrate()\n    migrationVersions.add(migration.version)\n  }\n\n  localStorage.setItem(migrationVersionKey, JSON.stringify(Array.from(migrationVersions)))\n\n  // AppVersion logic\n  const lastVersion = localStorage.getItem(appVersionKey) || APP_VERSION\n  localStorage.setItem(appVersionKey, APP_VERSION)\n\n  const lastVersionParts = lastVersion.split(\"-\")\n  const lastVersionMajorMinor = lastVersionParts[0]\n  const currentVersionMajorMinor = APP_VERSION.split(\"-\")[0]\n  if (lastVersion === APP_VERSION) return\n  if (lastVersionMajorMinor === currentVersionMajorMinor) return\n\n  window.__app_is_upgraded__ = true\n  appLog(`Upgrade from ${lastVersion} to ${APP_VERSION}`)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/migrates/v/v1.ts",
    "content": "import { isEqual } from \"es-toolkit/compat\"\n\nimport {\n  createDefaultGeneralSettings,\n  getGeneralSettings,\n  setGeneralSetting,\n} from \"~/atoms/settings/general\"\nimport { createDefaultUISettings, getUISettings } from \"~/atoms/settings/ui\"\n\nimport { defineMigration } from \"../helper\"\n\nfunction hasSettingsChanged(\n  currentSettings: Record<string, any>,\n  defaultSettings: Record<string, any>,\n): boolean {\n  for (const key in defaultSettings) {\n    const defaultValue = defaultSettings[key]\n    const currentValue = currentSettings[key]\n    if (currentValue === undefined) {\n      continue\n    }\n    if (!isEqual(defaultValue, currentValue)) {\n      return true\n    }\n  }\n  return false\n}\n\nexport const v1 = defineMigration({\n  version: \"v1\",\n  migrate: () => {\n    const settings = getGeneralSettings()\n    const uiSettings = getUISettings()\n\n    const enabledEnhancedSettings =\n      hasSettingsChanged(uiSettings, createDefaultUISettings()) ||\n      hasSettingsChanged(settings, createDefaultGeneralSettings())\n\n    setGeneralSetting(\"enhancedSettings\", enabledEnhancedSettings)\n  },\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/queue.ts",
    "content": "import { appIsReady } from \"~/atoms/app\"\n\nconst afterReadyCallbackQueue = [] as Array<() => void>\n\nexport const waitAppReady = (callback: () => void, delay = 0) => {\n  if (appIsReady()) {\n    delay ? callback() : setTimeout(callback, delay)\n  } else {\n    afterReadyCallbackQueue.push(callback)\n  }\n}\n\nexport const applyAfterReadyCallbacks = () => {\n  afterReadyCallbackQueue.forEach((callback) => callback())\n  afterReadyCallbackQueue.length = 0\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/initialize/settings.ts",
    "content": "import { initializeDefaultAISettings } from \"~/atoms/settings/ai\"\nimport { initializeDefaultGeneralSettings } from \"~/atoms/settings/general\"\nimport { initializeDefaultIntegrationSettings } from \"~/atoms/settings/integration\"\nimport { initializeDefaultUISettings } from \"~/atoms/settings/ui\"\n\nexport const initializeSettings = () => {\n  initializeDefaultUISettings()\n  initializeDefaultGeneralSettings()\n  initializeDefaultIntegrationSettings()\n  initializeDefaultAISettings()\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport { extractCodeFromHtml } from \"../parse-html\"\n\ndescribe(\"extractCodeFromHtml\", () => {\n  it(\"should extract code from div elements\", () => {\n    const htmlString = \"<div>line 1</div><div>line 2</div>\"\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toBe(\"line 1\\nline 2\\n\")\n  })\n\n  it(\"should extract code from span elements with line breaks\", () => {\n    const htmlString = \"<span><span>line 1</span></span><span><span>line 2</span></span>\"\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"line 1line 2\n      \"\n    `)\n  })\n\n  it(\"should extract code from span elements without line breaks\", () => {\n    const htmlString = \"<span><span>line 1</span></span><span><span>line 2</span></span>\"\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"line 1line 2\n      \"\n    `)\n  })\n\n  it(\"should return the entire text content if no specific structure is found\", () => {\n    const htmlString = \"plain text content\"\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toBe(\"plain text content\")\n  })\n\n  it(\"pre > code\", () => {\n    const htmlString = `<pre class=\"language-ts lang-ts\"><code class=\"language-ts lang-ts\">import i18next from &#x27;i18next&#x27; import { initReactI18next } from &#x27;react-i18next&#x27; import en from &#x27;@/locales/en.json&#x27; import zhCN from &#x27;@/locales/zh_CN.json&#x27; i18next.use(initReactI18next).init({ lng: &#x27;zh&#x27;, fallbackLng: &#x27;en&#x27;, resources: { en: { translation: en, }, zh: { translation: zhCN, }, }, })</code></pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(\n      `\"import i18next from 'i18next' import { initReactI18next } from 'react-i18next' import en from '@/locales/en.json' import zhCN from '@/locales/zh_CN.json' i18next.use(initReactI18next).init({ lng: 'zh', fallbackLng: 'en', resources: { en: { translation: en, }, zh: { translation: zhCN, }, }, })\"`,\n    )\n  })\n\n  it(\"pre > code 2\", () => {\n    const htmlString = `<pre><code class=\"language-ts\">import type { Namespace } from '@/types'; export const namespace: Namespace = { // ... }; </code></pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(\n      `\"import type { Namespace } from '@/types'; export const namespace: Namespace = { // ... }; \"`,\n    )\n  })\n\n  it(\"pre\", () => {\n    const htmlString = `<pre>@keyframes seed {\n    0%{--seed:0}1%{--seed:1}2%{--seed:2}3%{--seed:3}4%{--seed:4}5%{--seed:5}6%{--seed:6}7%{--seed:7}8%{--seed:8}9%{--seed:9}10%{--seed:10}11%{--seed:11}...95%{--seed:95}96%{--seed:96}97%{--seed:97}98%{--seed:98}99%{--seed:99}100%{--seed:100}\n}</pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\"@keyframes seed {\n    0%{--seed:0}1%{--seed:1}2%{--seed:2}3%{--seed:3}4%{--seed:4}5%{--seed:5}6%{--seed:6}7%{--seed:7}8%{--seed:8}9%{--seed:9}10%{--seed:10}11%{--seed:11}...95%{--seed:95}96%{--seed:96}97%{--seed:97}98%{--seed:98}99%{--seed:99}100%{--seed:100}\n}\"`)\n  })\n\n  it(\"pre 2\", () => {\n    const htmlString = `<pre>@property --seed {\n  syntax: \"&lt;integer&gt;\";\n  inherits: true;\n  initial-value: 0;\n}\n\n@keyframes seed {\n  from { --seed: 0; }\n  to { --seed: 100; }\n}</pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"@property --seed {\n        syntax: \"<integer>\";\n        inherits: true;\n        initial-value: 0;\n      }\n\n      @keyframes seed {\n        from { --seed: 0; }\n        to { --seed: 100; }\n      }\"\n    `)\n  })\n\n  it(\"pre > lang-*\", () => {\n    const htmlString = `<pre><code class=\"lang-javascript\">module.exports = {\n  output: &quot;standalone&quot;,\n}</code></pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"module.exports = {\n        output: \"standalone\",\n      }\"\n    `)\n  })\n\n  it(\"shiki render\", () => {\n    const htmlString = `<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\" style=\"width: 920px;\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">import</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> i18next </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">from</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'i18next'</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">import</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> { initReactI18next } </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">from</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'react-i18next'</span></span>\n<span class=\"line\" style=\"width: 920px;\"></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">import</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> en </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">from</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '@/locales/en.json'</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">import</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> zhCN </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">from</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '@/locales/zh_CN.json'</span></span>\n<span class=\"line\" style=\"width: 920px;\"></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">i18next.</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\">use</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(initReactI18next).</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\">init</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">({</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  lng: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">'zh'</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  fallbackLng: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">'en'</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  resources: {</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    en: {</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">      translation: en,</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    },</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    zh: {</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">      translation: zhCN,</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    },</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  },</span></span>\n<span class=\"line\" style=\"width: 920px;\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">})</span></span></code></pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"import i18next from 'i18next'\n      import { initReactI18next } from 'react-i18next'\n\n      import en from '@/locales/en.json'\n      import zhCN from '@/locales/zh_CN.json'\n\n      i18next.use(initReactI18next).init({\n        lng: 'zh',\n        fallbackLng: 'en',\n        resources: {\n          en: {\n            translation: en,\n          },\n          zh: {\n            translation: zhCN,\n          },\n        },\n      })\n      \"\n    `)\n  })\n\n  it(\"pre > code, without line wrapper\", () => {\n    const htmlString = `<pre class=\" language-dockerfile\"><code class=\" language-dockerfile\"><span class=\"token keyword\">FROM</span> node<span class=\"token punctuation\">:</span>20.15<span class=\"token punctuation\">-</span>alpine AS runner\n\n<span class=\"token keyword\">ENV</span> NODE_ENV production\n\n<span class=\"token comment\"># Create app directory</span>\n<span class=\"token keyword\">WORKDIR</span> /app\n\n<span class=\"token keyword\">RUN</span> addgroup <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>system <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>gid 1001 nodejs\n<span class=\"token keyword\">RUN</span> adduser <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>system <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>uid 1001 nextjs\n\n<span class=\"token keyword\">COPY</span> public /app/public\n\n<span class=\"token comment\"># Set the correct permission for prerender cache</span>\n<span class=\"token keyword\">RUN</span> mkdir .next\n<span class=\"token keyword\">RUN</span> chown nextjs<span class=\"token punctuation\">:</span>nodejs .next\n\n<span class=\"token comment\"># Automatically leverage output traces to reduce image size</span>\n<span class=\"token comment\"># https://nextjs.org/docs/advanced-features/output-file-tracing</span>\n<span class=\"token keyword\">COPY</span> <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>chown=nextjs<span class=\"token punctuation\">:</span>nodejs .next/standalone ./\n<span class=\"token keyword\">COPY</span> <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>chown=nextjs<span class=\"token punctuation\">:</span>nodejs .next/static .next/static\n\n<span class=\"token keyword\">USER</span> nextjs\n\n<span class=\"token keyword\">EXPOSE</span> 3000\n<span class=\"token keyword\">ENV</span> PORT 3000\n\n<span class=\"token keyword\">CMD</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"ls\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"-l\"</span><span class=\"token punctuation\">]</span>\n\n<span class=\"token comment\"># server.js is created by next build from the standalone output</span>\n<span class=\"token comment\"># https://nextjs.org/docs/pages/api-reference/next-config-js/output</span>\n<span class=\"token keyword\">CMD</span> HOSTNAME=<span class=\"token string\">\"0.0.0.0\"</span> node server.js</code></pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"FROM node:20.15-alpine AS runner\n\n      ENV NODE_ENV production\n\n      # Create app directory\n      WORKDIR /app\n\n      RUN addgroup --system --gid 1001 nodejs\n      RUN adduser --system --uid 1001 nextjs\n\n      COPY public /app/public\n\n      # Set the correct permission for prerender cache\n      RUN mkdir .next\n      RUN chown nextjs:nodejs .next\n\n      # Automatically leverage output traces to reduce image size\n      # https://nextjs.org/docs/advanced-features/output-file-tracing\n      COPY --chown=nextjs:nodejs .next/standalone ./\n      COPY --chown=nextjs:nodejs .next/static .next/static\n\n      USER nextjs\n\n      EXPOSE 3000\n      ENV PORT 3000\n\n      CMD [\"ls\", \"-l\"]\n\n      # server.js is created by next build from the standalone output\n      # https://nextjs.org/docs/pages/api-reference/next-config-js/output\n      CMD HOSTNAME=\"0.0.0.0\" node server.js\"\n    `)\n  })\n\n  it(\"hijs\", () => {\n    const htmlString = `<pre tabindex=\"0\" class=\"hljs language-css\"><a href=\"javascript:\" class=\"copy\" tabindex=\"0\" title=\"复制\"><svg class=\"icon-copy\"><use xlink:href=\"#icon-copy\"></use></svg></a><a href=\"javascript:\" class=\"beatuy revert\" tabindex=\"0\" title=\"还原\"><svg class=\"icon-palette\"><use xlink:href=\"#icon-palette\"></use></svg></a><span class=\"hljs-keyword\">@keyframes</span> seed {\n    <span class=\"hljs-number\">0%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">0</span>}<span class=\"hljs-number\">1%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">1</span>}<span class=\"hljs-number\">2%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">2</span>}<span class=\"hljs-number\">3%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">3</span>}<span class=\"hljs-number\">4%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">4</span>}<span class=\"hljs-number\">5%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">5</span>}<span class=\"hljs-number\">6%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">6</span>}<span class=\"hljs-number\">7%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">7</span>}<span class=\"hljs-number\">8%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">8</span>}<span class=\"hljs-number\">9%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">9</span>}<span class=\"hljs-number\">10%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">10</span>}<span class=\"hljs-number\">11%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">11</span>}...<span class=\"hljs-number\">95%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">95</span>}<span class=\"hljs-number\">96%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">96</span>}<span class=\"hljs-number\">97%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">97</span>}<span class=\"hljs-number\">98%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">98</span>}<span class=\"hljs-number\">99%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">99</span>}<span class=\"hljs-number\">100%</span>{<span class=\"hljs-attr\">--seed</span>:<span class=\"hljs-number\">100</span>}\n}</pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"@keyframes seed {\n          0%{--seed:0}1%{--seed:1}2%{--seed:2}3%{--seed:3}4%{--seed:4}5%{--seed:5}6%{--seed:6}7%{--seed:7}8%{--seed:8}9%{--seed:9}10%{--seed:10}11%{--seed:11}...95%{--seed:95}96%{--seed:96}97%{--seed:97}98%{--seed:98}99%{--seed:99}100%{--seed:100}\n      }\"\n    `)\n  })\n\n  it(\"hijs 2\", () => {\n    const htmlString = `<pre translate=\"no\" class=\"flat-scrollbar-normal not-prose rounded-md bg-[#F6F8FA] p-2 text-sm selection:bg-gray-300 selection:text-inherit dark:bg-[#0d1117] dark:selection:bg-gray-700\"><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title function_\">randInterval</span>(<span class=\"hljs-params\"></span>): <span class=\"hljs-built_in\">number</span> {\n  <span class=\"hljs-keyword\">return</span> <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">random</span>();\n}\n\n<span class=\"hljs-keyword\">function</span> <span class=\"hljs-title function_\">boxMuller</span>(<span class=\"hljs-params\">mu: <span class=\"hljs-built_in\">number</span>, sigma: <span class=\"hljs-built_in\">number</span></span>): [<span class=\"hljs-built_in\">number</span>, <span class=\"hljs-built_in\">number</span>] {\n  <span class=\"hljs-keyword\">const</span> u = <span class=\"hljs-title function_\">randInterval</span>();\n  <span class=\"hljs-keyword\">const</span> v = <span class=\"hljs-title function_\">randInterval</span>();\n  <span class=\"hljs-keyword\">const</span> x = <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">cos</span>(<span class=\"hljs-number\">2</span> * <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-property\">PI</span> * u) * <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">sqrt</span>(-<span class=\"hljs-number\">2</span> * <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">log</span>(v));\n  <span class=\"hljs-keyword\">const</span> y = <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">sin</span>(<span class=\"hljs-number\">2</span> * <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-property\">PI</span> * u) * <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">sqrt</span>(-<span class=\"hljs-number\">2</span> * <span class=\"hljs-title class_\">Math</span>.<span class=\"hljs-title function_\">log</span>(v));\n  <span class=\"hljs-keyword\">return</span> [x * sigma + mu, y * sigma + mu];\n}\n\n<span class=\"hljs-comment\">// Usage example</span>\n<span class=\"hljs-keyword\">const</span> [value1, value2] = <span class=\"hljs-title function_\">boxMuller</span>(<span class=\"hljs-number\">0</span>, <span class=\"hljs-number\">1</span>);\n<span class=\"hljs-variable language_\">console</span>.<span class=\"hljs-title function_\">log</span>(value1, value2);\n<span class=\"hljs-comment\">// Outputs two normally distributed random numbers</span>\n</code></pre>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"function randInterval(): number {\n        return Math.random();\n      }\n\n      function boxMuller(mu: number, sigma: number): [number, number] {\n        const u = randInterval();\n        const v = randInterval();\n        const x = Math.cos(2 * Math.PI * u) * Math.sqrt(-2 * Math.log(v));\n        const y = Math.sin(2 * Math.PI * u) * Math.sqrt(-2 * Math.log(v));\n        return [x * sigma + mu, y * sigma + mu];\n      }\n\n      // Usage example\n      const [value1, value2] = boxMuller(0, 1);\n      console.log(value1, value2);\n      // Outputs two normally distributed random numbers\n\n      \"\n    `)\n  })\n\n  it(\"hijs without wrapper\", () => {\n    const htmlString = `<pre><span>import</span> * <span>as</span> fs <span>from</span> <span>&quot;fs&quot;</span>;\n<span>import</span> * <span>as</span> plotly <span>from</span> <span>&quot;plotly.js-dist&quot;</span>;\n\n<span>// Generate random numbers following a normal distribution using Box-Muller</span>\n<span>function</span> <span>randInterval</span>(<span></span>): <span>number</span> {\n  <span>return</span> <span>Math</span>.<span>random</span>();\n}\n\n<span>function</span> <span>boxMuller</span>(<span>mu: <span>number</span>, sigma: <span>number</span></span>): [<span>number</span>, <span>number</span>] {\n  <span>const</span> u = <span>randInterval</span>();\n  <span>const</span> v = <span>randInterval</span>();\n  <span>const</span> x = <span>Math</span>.<span>cos</span>(<span>2</span> * <span>Math</span>.<span>PI</span> * u) * <span>Math</span>.<span>sqrt</span>(-<span>2</span> * <span>Math</span>.<span>log</span>(v));\n  <span>const</span> y = <span>Math</span>.<span>sin</span>(<span>2</span> * <span>Math</span>.<span>PI</span> * u) * <span>Math</span>.<span>sqrt</span>(-<span>2</span> * <span>Math</span>.<span>log</span>(v));\n  <span>return</span> [x * sigma + mu, y * sigma + mu];\n}\n\n<span>// Generate n normally distributed random numbers</span>\n<span>function</span> <span>generateNormalDistribution</span>(<span>\n  mu: <span>number</span>,\n  sigma: <span>number</span>,\n  n: <span>number</span>\n</span>): <span>number</span>[] {\n  <span>const</span> <span>values</span>: <span>number</span>[] = [];\n  <span>for</span> (<span>let</span> i = <span>0</span>; i &lt; n / <span>2</span>; i++) {\n    <span>const</span> [value1, value2] = <span>boxMuller</span>(mu, sigma);\n    values.<span>push</span>(value1, value2);\n  }\n  <span>return</span> values;\n}\n\n<span>// Generate the data</span>\n<span>const</span> mu = <span>0</span>;\n<span>const</span> sigma = <span>1</span>;\n<span>const</span> sampleSize = <span>10000</span>; <span>// Sample size</span>\n<span>const</span> values = <span>generateNormalDistribution</span>(mu, sigma, sampleSize);\n\n<span>// Calculate the mean and variance of the generated random numbers</span>\n<span>const</span> mean = values.<span>reduce</span>(<span>(<span>acc, val</span>) =&gt;</span> acc + val, <span>0</span>) / values.<span>length</span>;\n<span>const</span> variance =\n  values.<span>reduce</span>(<span>(<span>acc, val</span>) =&gt;</span> acc + (val - mean) ** <span>2</span>, <span>0</span>) / values.<span>length</span>;\n  </pre>\n`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(`\n      \"import * as fs from \"fs\";\n      import * as plotly from \"plotly.js-dist\";\n\n      // Generate random numbers following a normal distribution using Box-Muller\n      function randInterval(): number {\n        return Math.random();\n      }\n\n      function boxMuller(mu: number, sigma: number): [number, number] {\n        const u = randInterval();\n        const v = randInterval();\n        const x = Math.cos(2 * Math.PI * u) * Math.sqrt(-2 * Math.log(v));\n        const y = Math.sin(2 * Math.PI * u) * Math.sqrt(-2 * Math.log(v));\n        return [x * sigma + mu, y * sigma + mu];\n      }\n\n      // Generate n normally distributed random numbers\n      function generateNormalDistribution(\n        mu: number,\n        sigma: number,\n        n: number\n      ): number[] {\n        const values: number[] = [];\n        for (let i = 0; i < n / 2; i++) {\n          const [value1, value2] = boxMuller(mu, sigma);\n          values.push(value1, value2);\n        }\n        return values;\n      }\n\n      // Generate the data\n      const mu = 0;\n      const sigma = 1;\n      const sampleSize = 10000; // Sample size\n      const values = generateNormalDistribution(mu, sigma, sampleSize);\n\n      // Calculate the mean and variance of the generated random numbers\n      const mean = values.reduce((acc, val) => acc + val, 0) / values.length;\n      const variance =\n        values.reduce((acc, val) => acc + (val - mean) ** 2, 0) / values.length;\n        \n      \"\n    `)\n  })\n\n  it(\"no <code />\", () => {\n    const htmlString = `<span class=\"line\"><span class=\"keyword\">if</span> theme.<span class=\"property\">twikoo</span>.<span class=\"property\">enable</span> == <span class=\"literal\">true</span></span><br><span class=\"line\">  #tcomment</span><br><span class=\"line\">  <span class=\"title function_\">script</span>(src=<span class=\"string\">'https://registry.npmmirror.com/twikoo/1.6.39/files/dist/twikoo.all.min.js'</span>)</span><br><span class=\"line\">  script.</span><br><span class=\"line\">    twikoo.<span class=\"title function_\">init</span>({</span><br><span class=\"line\">      <span class=\"attr\">envId</span>: <span class=\"string\">'#{theme.twikoo.envId}'</span>,</span><br><span class=\"line\">      <span class=\"attr\">el</span>: <span class=\"string\">'#tcomment'</span>,</span><br><span class=\"line\">      <span class=\"attr\">region</span>: <span class=\"string\">'#{theme.twikoo.region}'</span>,</span><br><span class=\"line\">      <span class=\"attr\">path</span>: <span class=\"string\">'#{theme.twikoo.path}'</span>,</span><br><span class=\"line\">      <span class=\"attr\">onCommentLoaded</span>: <span class=\"keyword\">function</span> (<span class=\"params\"></span>) {</span><br><span class=\"line\">        <span class=\"keyword\">const</span> commentCountElement = <span class=\"variable language_\">document</span>.<span class=\"title function_\">querySelector</span>(<span class=\"string\">'.tk-comments-count'</span>);</span><br><span class=\"line\">        <span class=\"keyword\">const</span> targetElement = <span class=\"variable language_\">document</span>.<span class=\"title function_\">querySelector</span>(<span class=\"string\">'.waline-comment-count'</span>);</span><br><span class=\"line\">        <span class=\"keyword\">if</span> (commentCountElement) {</span><br><span class=\"line\">          <span class=\"keyword\">const</span> countSpan = commentCountElement.<span class=\"title function_\">querySelector</span>(<span class=\"string\">'span:first-child'</span>);</span><br><span class=\"line\">          <span class=\"keyword\">const</span> commentCount = <span class=\"built_in\">parseInt</span>(countSpan.<span class=\"property\">textContent</span>);</span><br><span class=\"line\">          targetElement.<span class=\"property\">textContent</span> = commentCount;</span><br><span class=\"line\">        } <span class=\"keyword\">else</span> {</span><br><span class=\"line\">          <span class=\"variable language_\">console</span>.<span class=\"title function_\">log</span>(<span class=\"string\">'未找到评论数量元素'</span>);</span><br><span class=\"line\">        }</span><br><span class=\"line\">      }</span><br><span class=\"line\">    })</span><br><span class=\"line\"></span><br>`\n    const result = extractCodeFromHtml(htmlString)\n\n    expect(result).toMatchInlineSnapshot(\n      `\"if theme.twikoo.enable == true  #tcomment  script(src='https://registry.npmmirror.com/twikoo/1.6.39/files/dist/twikoo.all.min.js')  script.    twikoo.init({      envId: '#{theme.twikoo.envId}',      el: '#tcomment',      region: '#{theme.twikoo.region}',      path: '#{theme.twikoo.path}',      onCommentLoaded: function () {        const commentCountElement = document.querySelector('.tk-comments-count');        const targetElement = document.querySelector('.waline-comment-count');        if (commentCountElement) {          const countSpan = commentCountElement.querySelector('span:first-child');          const commentCount = parseInt(countSpan.textContent);          targetElement.textContent = commentCount;        } else {          console.log('未找到评论数量元素');        }      }    })\"`,\n    )\n  })\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/api-client.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { userActions } from \"@follow/store/user/store\"\nimport { createDesktopAPIHeaders } from \"@follow/utils/headers\"\nimport { FollowClient } from \"@follow-app/client-sdk\"\nimport PKG from \"@pkg\"\n\nimport { NetworkStatus, setApiStatus } from \"~/atoms/network\"\nimport { setLoginModalShow } from \"~/atoms/user\"\n\nimport { getAuthSessionToken, getClientId, getSessionId } from \"./client-session\"\n\nexport const followClient = new FollowClient({\n  credentials: \"include\",\n  timeout: 30000,\n  baseURL: env.VITE_API_URL,\n  fetch: async (input, options = {}) =>\n    fetch(input.toString(), {\n      ...options,\n      cache: \"no-store\",\n    }),\n})\n\nexport const followApi = followClient.api\nfollowClient.addRequestInterceptor(async (ctx) => {\n  const { options } = ctx\n  const headers = new Headers(options.headers)\n  headers.set(\"X-Client-Id\", getClientId())\n  headers.set(\"X-Session-Id\", getSessionId())\n\n  const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null\n  if (authSessionToken && !headers.has(\"Cookie\") && !headers.has(\"cookie\")) {\n    headers.set(\n      \"Cookie\",\n      `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`,\n    )\n  }\n\n  const apiHeader = createDesktopAPIHeaders({ version: PKG.version })\n  Object.entries(apiHeader).forEach(([key, value]) => {\n    headers.set(key, value)\n  })\n\n  options.headers = Object.fromEntries(headers.entries())\n  return ctx\n})\n\nfollowClient.addResponseInterceptor(({ response }) => {\n  setApiStatus(NetworkStatus.ONLINE)\n  return response\n})\n\nfollowClient.addErrorInterceptor(async ({ error, response }) => {\n  // If api is down\n  if ((!response || response.status === 0) && navigator.onLine) {\n    setApiStatus(NetworkStatus.OFFLINE)\n  } else {\n    setApiStatus(NetworkStatus.ONLINE)\n  }\n\n  if (!response) {\n    return error\n  }\n\n  return error\n})\n\nfollowClient.addResponseInterceptor(async ({ response }) => {\n  if (response.status === 401) {\n    const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null\n    const shouldPromptForLogin =\n      response.url.includes(\"/better-auth/get-session\") || (!whoami() && !authSessionToken)\n\n    if (!shouldPromptForLogin) {\n      return response\n    }\n\n    // Or we can present LoginModal here.\n    // router.navigate(\"/login\")\n    // If any response status is 401, we can set auth fail. Maybe some bug, but if navigate to login page, had same issues\n    setLoginModalShow(true)\n    userActions.removeCurrentUser()\n  }\n  try {\n    const isJSON = response.headers.get(\"content-type\")?.includes(\"application/json\")\n    if (!isJSON) return response\n    const _json = await response.clone().json()\n\n    const isError = response.status >= 400\n    if (!isError) return response\n  } catch {\n    // ignore\n  }\n\n  return response\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/app.ts",
    "content": "export const removeAppSkeleton = () => {\n  try {\n    document.querySelector(\"#app-skeleton\")?.remove()\n  } catch {\n    // ignore\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/auth.ts",
    "content": "import { Auth } from \"@follow/shared/auth\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { createDesktopAPIHeaders } from \"@follow/utils/headers\"\nimport PKG from \"@pkg\"\n\nimport { getAuthSessionToken } from \"./client-session\"\n\nconst headers = createDesktopAPIHeaders({ version: PKG.version })\n\nconst auth = new Auth({\n  apiURL: env.VITE_API_URL,\n  webURL: env.VITE_WEB_URL,\n  fetchOptions: {\n    headers,\n    onRequest: (context) => {\n      const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null\n      if (authSessionToken) {\n        context.headers.set(\n          \"Cookie\",\n          `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`,\n        )\n      }\n    },\n  },\n})\n\nexport const { authClient } = auth\n\n// @keep-sorted\nexport const {\n  changeEmail,\n  changePassword,\n  deleteUserCustom,\n  getAccountInfo,\n  getProviders,\n  getSession,\n  linkSocial,\n  listAccounts,\n  oneTimeToken,\n  resetPassword,\n  sendVerificationEmail,\n  signIn,\n  signOut,\n  signUp,\n  subscription,\n  twoFactor,\n  unlinkAccount,\n  updateUser,\n} = auth.authClient\n\nexport const forgetPassword = auth.authClient.requestPasswordReset\n\nexport const { loginHandler } = auth\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/avatar-upload.ts",
    "content": "import { toast } from \"sonner\"\n\nimport { followApi } from \"./api-client\"\nimport { getFetchErrorMessage } from \"./error-parser\"\n\n/**\n * Upload avatar blob to server\n *\n * @param blob - The image blob to upload\n * @returns Promise<string> - The uploaded image URL\n */\nexport async function uploadAvatarBlob(blob: Blob): Promise<string> {\n  const { url } = await followApi.upload\n    .uploadAvatar({\n      file: blob,\n    })\n    .catch((err) => {\n      toast.error(getFetchErrorMessage(err))\n      throw err\n    })\n\n  return url\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/client-session.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { nanoid } from \"nanoid\"\n\nconst CLIENT_ID_KEY = getStorageNS(\"client_id\")\nconst SESSION_ID_KEY = getStorageNS(\"session_id\")\nconst AUTH_SESSION_TOKEN_KEY = getStorageNS(\"auth_session_token\")\n\nexport const getClientId = (): string => {\n  const clientId = localStorage.getItem(CLIENT_ID_KEY)\n  if (!clientId) {\n    const newClientId = nanoid()\n    localStorage.setItem(CLIENT_ID_KEY, newClientId)\n    return newClientId\n  }\n  return clientId\n}\n\nexport const getSessionId = (): string => {\n  const sessionId = sessionStorage.getItem(SESSION_ID_KEY)\n  if (!sessionId) {\n    const newSessionId = nanoid()\n    sessionStorage.setItem(SESSION_ID_KEY, newSessionId)\n    return newSessionId\n  }\n  return sessionId\n}\n\nexport const clearSessionId = (): void => {\n  sessionStorage.removeItem(SESSION_ID_KEY)\n}\n\nexport const clearClientId = (): void => {\n  localStorage.removeItem(CLIENT_ID_KEY)\n}\n\nexport const getAuthSessionToken = (): string | null => {\n  return localStorage.getItem(AUTH_SESSION_TOKEN_KEY)\n}\n\nexport const setAuthSessionToken = (token: string): void => {\n  localStorage.setItem(AUTH_SESSION_TOKEN_KEY, token)\n}\n\nexport const clearAuthSessionToken = (): void => {\n  localStorage.removeItem(AUTH_SESSION_TOKEN_KEY)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/client.ts",
    "content": "import type { IpcServices } from \"@follow/electron-main\"\nimport type { IpcRenderer } from \"electron\"\nimport { createIpcProxy } from \"electron-ipc-decorator/client\"\n\nexport const ipcServices = createIpcProxy<IpcServices>(\n  window.electron?.ipcRenderer as unknown as IpcRenderer,\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/clipboard.ts",
    "content": "import { toast } from \"sonner\"\n\nexport const copyToClipboard = async (content: string): Promise<void> => {\n  try {\n    await navigator.clipboard.writeText(content)\n  } catch (e) {\n    const message = \"Unable to copy to clipboard. Please ensure clipboard permissions are granted.\"\n    console.error(e)\n    toast.error(message)\n    throw new Error(message)\n  }\n}\n\nexport const readFromClipboard = async (): Promise<string> => {\n  try {\n    return await navigator.clipboard.readText()\n  } catch (e) {\n    const message =\n      \"Unable to read from clipboard. Please ensure clipboard permissions are granted.\"\n    toast.error(message)\n    console.error(e)\n    throw new Error(message)\n  }\n}\n\nexport const copyImageToClipboard = async (canvas: HTMLCanvasElement): Promise<void> => {\n  return new Promise((resolve, reject) => {\n    canvas.toBlob(async (blob) => {\n      if (!blob) {\n        const error = new Error(\"Failed to create image blob\")\n        reject(error)\n        return\n      }\n\n      try {\n        await navigator.clipboard.write([\n          new ClipboardItem({\n            [blob.type]: blob,\n          }),\n        ])\n        resolve()\n      } catch (e) {\n        const message =\n          \"Unable to copy image to clipboard. Please ensure clipboard permissions are granted.\"\n        console.error(e)\n        toast.error(message)\n        reject(new Error(message))\n      }\n    }, \"image/png\")\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/defineQuery.ts",
    "content": "import type { InfiniteData, QueryFunction, QueryKey } from \"@tanstack/react-query\"\nimport type { Draft, Producer } from \"immer\"\nimport { produce } from \"immer\"\n\nimport { queryClient } from \"./query-client\"\n\ntype ValidRecipeReturnDraftType<T> = ReturnType<Producer<T>>\n\nexport type DefinedQuery<TQueryKey extends QueryKey, TData> = Readonly<{\n  key: TQueryKey\n  fn: QueryFunction<TData>\n  rootKey?: QueryKey\n\n  cancel: (key?: (key: TQueryKey) => QueryKey) => Promise<void>\n  remove: (key?: (key: TQueryKey) => QueryKey) => Promise<void>\n\n  invalidate: (options?: {\n    keyExtractor?: (key: TQueryKey) => QueryKey\n    exact?: boolean\n  }) => Promise<void>\n  invalidateRoot: () => void\n\n  refetch: () => Promise<TData | undefined>\n  prefetch: () => Promise<TData | undefined>\n\n  setData: <Data = TData>(updater: (draft: Draft<Data>) => ValidRecipeReturnDraftType<Data>) => void\n  setInfiniteData: (\n    updater: (draft: Draft<InfiniteData<TData>>) => ValidRecipeReturnDraftType<InfiniteData<TData>>,\n  ) => void\n  getData: () => TData | undefined\n\n  optimisticUpdate: <Data = TData>(\n    updater: (draft: Draft<Data>) => ValidRecipeReturnDraftType<Data> | void,\n  ) => Promise<{\n    previousData: Awaited<Data> | undefined\n    restore: () => void\n    invalidate: () => void\n  }>\n\n  optimisticInfiniteUpdate: (\n    updater: (draft: Draft<InfiniteData<TData>>) => ValidRecipeReturnDraftType<InfiniteData<TData>>,\n  ) => Promise<{\n    previousData: Awaited<InfiniteData<TData>> | undefined\n    restore: () => void\n    invalidate: () => void\n  }>\n}>\n\nexport type DefinedQueryOptions<TData> = {\n  // shouldPersist?: boolean;\n  rootKey?: QueryKey\n\n  onCancel?: () => void | Promise<void>\n  onInvalidate?: () => void | Promise<void>\n  onInvalidateRoot?: () => void\n  onRefetch?: (data?: TData) => void\n  onRefetchRoot?: () => void\n  onOptimisticUpdate?: () => void\n  onOptimisticUpdateRestore?: () => void\n}\n\nexport function defineQuery<\n  TQueryKey extends QueryKey,\n  TQueryFn extends QueryFunction<unknown>,\n  TData = Awaited<ReturnType<TQueryFn>>,\n>(\n  key: TQueryKey,\n  fn: TQueryFn,\n  options?: DefinedQueryOptions<TData>,\n): DefinedQuery<TQueryKey, TData>\n\nexport function defineQuery<\n  TQueryKey extends QueryKey,\n  TQueryFn extends QueryFunction<any>,\n  TData = Awaited<ReturnType<TQueryFn>>,\n>(key: TQueryKey, fn: TQueryFn, options?: DefinedQueryOptions<TData>) {\n  const queryDefine: DefinedQuery<TQueryKey, TData> = {\n    key,\n    fn,\n    rootKey: options?.rootKey,\n\n    invalidateRoot: () => {\n      if (options?.rootKey) {\n        queryClient.invalidateQueries({\n          queryKey: options.rootKey,\n          refetchType: \"all\",\n        })\n        options?.onInvalidateRoot?.()\n      }\n    },\n    prefetch: async () => {\n      await queryClient.prefetchQuery({\n        queryKey: key,\n        queryFn: fn,\n      })\n      return queryClient.getQueryData<TData>(key)\n    },\n    cancel: async (keyExtactor) => {\n      const queryKey = typeof keyExtactor === \"function\" ? keyExtactor(key) : key\n      await queryClient.cancelQueries({\n        queryKey,\n      })\n      options?.onCancel?.()\n    },\n    remove: async (keyExtactor) => {\n      const queryKey = typeof keyExtactor === \"function\" ? keyExtactor(key) : key\n      queryClient.removeQueries({ queryKey })\n    },\n    invalidate: async (args) => {\n      const { keyExtractor, exact } = args || {}\n      const queryKey = typeof keyExtractor === \"function\" ? keyExtractor(key) : key\n\n      await queryClient.invalidateQueries({\n        queryKey,\n        refetchType: \"all\",\n        exact,\n      })\n      options?.onInvalidate?.()\n    },\n\n    refetch: async () => {\n      await queryClient.refetchQueries({\n        queryKey: key,\n      })\n      options?.onRefetch?.()\n      return queryClient.getQueryData<TData>(key)\n    },\n\n    setData: (updater) =>\n      queryClient.setQueryData<TData>(key, (old) => {\n        if (!old) return\n        if (typeof updater !== \"function\") return old\n\n        return produce(old, updater)\n      }),\n\n    setInfiniteData: (updater) => queryDefine.setData<InfiniteData<TData>>(updater),\n    getData: () => queryClient.getQueryData<TData>(key),\n\n    optimisticUpdate: async <Data = TData>(\n      updater: (draft: Draft<Data>) => ValidRecipeReturnDraftType<Data>,\n    ) => {\n      await queryClient.cancelQueries({\n        queryKey: key,\n      })\n      const previousData = await queryClient.getQueryData<Data>(key)\n      await queryClient.setQueryData<Data>(key, (old) => {\n        if (!old) return\n        if (typeof updater !== \"function\") return old\n\n        return produce(old, updater)\n      })\n      options?.onOptimisticUpdate?.()\n\n      return {\n        previousData,\n        restore: () => {\n          queryClient.setQueryData<Data>(key, previousData)\n          options?.onOptimisticUpdateRestore?.()\n        },\n        invalidate: () => {\n          queryClient.invalidateQueries({ queryKey: key, refetchType: \"all\" })\n          options?.onInvalidate?.()\n        },\n      }\n    },\n\n    optimisticInfiniteUpdate(updater) {\n      return queryDefine.optimisticUpdate<InfiniteData<TData>>(updater)\n    },\n  }\n\n  return Object.freeze(queryDefine)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/dev.tsx",
    "content": "export const attachOpenInEditor = (stack: string) => {\n  const lines = stack.split(\"\\n\")\n  return lines.map((line) => {\n    // A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)\n    // Find the `localhost` part and open the file in the editor\n    if (!line.includes(\"at \")) {\n      return line\n    }\n    const match = line.match(/http:\\/\\/localhost:\\d+\\/[^:]+:\\d+:\\d+/)\n\n    if (match) {\n      const [o] = match\n\n      // Find `@fs/`\n      // Like: `http://localhost:5173/@fs/Users/innei/git/work/rss3/follow/node_modules/.vite/deps/chunk-RPCDYKBN.js?v=757920f2:11548:26`\n      const realFsPath = o.split(\"@fs\")[1]\n\n      if (realFsPath) {\n        return (\n          // Delete `v=` hash, like `v=757920f2`\n          <div\n            className=\"cursor-pointer\"\n            key={line}\n            onClick={openInEditor.bind(null, realFsPath.replace(/\\?v=[a-f0-9]+/, \"\"))}\n          >\n            {line}\n          </div>\n        )\n      } else {\n        // at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)\n        const srcFsPath = o.split(\"/src\")[1]\n\n        if (srcFsPath) {\n          const fs = srcFsPath.replace(/\\?t=[a-f0-9]+/, \"\")\n\n          return (\n            <div\n              className=\"cursor-pointer\"\n              key={line}\n              onClick={openInEditor.bind(null, `${APP_DEV_CWD}/layer/renderer/src${fs}`)}\n            >\n              {line}\n            </div>\n          )\n        }\n      }\n    }\n\n    return line\n  })\n}\n// http://localhost:5173/src/App.tsx?t=1720527056591:41:9\nconst openInEditor = (file: string) => {\n  fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`)\n}\n\nexport const debugStack = () => {\n  try {\n    throw new Error(\"debug stack\")\n  } catch (e: any) {\n    console.error(e.stack)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/error-parser.ts",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { FollowAPIError } from \"@follow-app/client-sdk\"\nimport { t } from \"i18next\"\nimport { FetchError } from \"ofetch\"\nimport { createElement } from \"react\"\nimport type { ExternalToast } from \"sonner\"\nimport { toast } from \"sonner\"\n\nimport { getIsPaymentEnabled } from \"~/atoms/server-configs\"\nimport { CopyButton } from \"~/components/ui/button/CopyButton\"\nimport { Markdown } from \"~/components/ui/markdown/Markdown\"\nimport { DebugRegistry } from \"~/modules/debug/registry\"\n\nexport const getFetchErrorInfo = (\n  error: Error,\n): {\n  message: string\n  code?: number\n} => {\n  if (error instanceof FetchError) {\n    try {\n      const json = JSON.parse(error.response?._data)\n\n      const { reason, code, message } = json\n      const i18nKey = `errors:${code}` as any\n      const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)\n      return {\n        message: `${i18nMessage}${reason ? `: ${reason}` : \"\"}`,\n        code,\n      }\n    } catch {\n      return { message: error.message }\n    }\n  }\n\n  if (error instanceof FollowAPIError && error.code) {\n    const code = Number(error.code)\n    try {\n      const i18nKey = `errors:${code}` as any\n      const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)\n      return {\n        message: i18nMessage,\n        code,\n      }\n    } catch {\n      return { message: error.message }\n    }\n  }\n\n  return { message: error.message }\n}\n\nexport const getFetchErrorMessage = (error: Error) => {\n  const { message } = getFetchErrorInfo(error)\n  return message\n}\n\n/**\n * Just a wrapper around `toastFetchError` to create a function that can be used as a callback.\n */\nexport const createErrorToaster = (title?: string, toastOptions?: ExternalToast) => (err: Error) =>\n  toastFetchError(err, { title, ...toastOptions })\n\nexport const toastFetchError = (\n  error: Error,\n  { title: _title, ..._toastOptions }: ExternalToast & { title?: string } = {},\n) => {\n  let message = \"\"\n  let _reason = \"\"\n  let code: number | undefined\n\n  let status: number | undefined\n  if (error instanceof FetchError) {\n    try {\n      status = error.statusCode ? Number(error.statusCode) : undefined\n      const json =\n        typeof error.response?._data === \"string\"\n          ? JSON.parse(error.response?._data)\n          : error.response?._data\n\n      const { reason, code: _code, message: _message } = json\n      code = _code\n      message = _message\n\n      const tValue = t(`errors:${code}` as any)\n      const i18nMessage = tValue === code?.toString() ? message : tValue\n\n      message = i18nMessage\n\n      if (reason) {\n        _reason = reason\n      }\n    } catch {\n      message = error.message\n    }\n  }\n\n  if (error instanceof FollowAPIError) {\n    code = error.code ? Number(error.code) : undefined\n    status = error.status ? Number(error.status) : undefined\n    message = error.message\n  }\n\n  if (\"code\" in error && error.code) {\n    code = Number(error.code)\n    try {\n      const tValue = t(`errors:${code}` as any)\n      const i18nMessage = tValue === code?.toString() ? error.message : tValue\n      message = i18nMessage\n    } catch {\n      message = error.message\n    }\n  }\n\n  // 2fa errors are handled by the form\n  if (code === 4007 || code === 4008) {\n    return\n  }\n\n  const toastOptions: ExternalToast = {\n    ..._toastOptions,\n    classNames: {\n      toast: \"items-start bg-theme-background\",\n\n      content: \"w-full\",\n      ..._toastOptions.classNames,\n    },\n  }\n\n  if (!_reason) {\n    const title = _title || message || \"Unknown error occurred\"\n    toastOptions.description = _title ? message : \"\"\n    const isPaymentEnabled = getIsPaymentEnabled()\n    const needUpgradeError = status && isPaymentEnabled ? status === 402 : false\n    if (needUpgradeError) {\n      toastOptions.description = \"Please upgrade your plan.\"\n    }\n    return toast.error(title, {\n      ...toastOptions,\n      action: needUpgradeError\n        ? {\n            label: \"Upgrade\",\n            onClick: () => {\n              window.router.showSettings({ tab: \"plan\" })\n            },\n          }\n        : undefined,\n    })\n  } else {\n    return toast.error(message || _title, {\n      duration: 5000,\n      ...toastOptions,\n      description: createElement(\"div\", {}, [\n        createElement(CopyButton, {\n          className: cn(\n            \"relative z-[1] float-end -mt-1\",\n            \"border-transparent bg-theme-background text-text opacity-60 transition-opacity\",\n            \"hover:bg-material-ultra-thick hover:opacity-100 focus:border-text-tertiary\",\n          ),\n          key: \"copy\",\n          value: _reason,\n        }),\n        createElement(Markdown, {\n          key: \"reason\",\n          className: \"text-sm opacity-70 min-w-0 flex-1 mt-1\",\n          children: _reason,\n        }),\n      ]),\n    })\n  }\n}\nDebugRegistry.add(\"Simulate request error\", () => {\n  createErrorToaster(\n    \"Simulated request error\",\n    {},\n  )({\n    response: {\n      _data: JSON.stringify({\n        code: 1000,\n        message: \"Simulated request error\",\n        reason: \"Simulated reason\",\n      }),\n    },\n  } as any)\n})\n\nDebugRegistry.add(\"Simulate payment need upgrade error\", () => {\n  createErrorToaster(\n    \"Simulated payment need upgrade error\",\n    {},\n  )(\n    new FollowAPIError(\"Simulated payment need upgrade error\", 402, \"1111\", {\n      reason: \"Simulated reason\",\n    }),\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/export.ts",
    "content": "export const downloadJsonFile = (content: string, filename: string) => {\n  const blob = new Blob([content], { type: \"application/json\" })\n  const url = URL.createObjectURL(blob)\n  const link = document.createElement(\"a\")\n  link.href = url\n  link.download = filename\n  document.body.append(link)\n  link.click()\n  link.remove()\n  URL.revokeObjectURL(url)\n}\n\nexport const selectJsonFile = (): Promise<string> => {\n  return new Promise((resolve, reject) => {\n    const input = document.createElement(\"input\")\n    input.type = \"file\"\n    input.accept = \".json,application/json\"\n    input.onchange = async (event) => {\n      const file = (event.target as HTMLInputElement).files?.[0]\n      if (!file) {\n        reject(new Error(\"No file selected\"))\n        return\n      }\n\n      try {\n        const content = await file.text()\n        resolve(content)\n      } catch {\n        reject(new Error(\"Failed to read file\"))\n      }\n    }\n    input.click()\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/features.tsx",
    "content": "import type { ExtractResponseData, GetStatusConfigsResponse } from \"@follow-app/client-sdk\"\nimport type { FC } from \"react\"\n\nimport { useFeature } from \"~/hooks/biz/useFeature\"\n\nexport const featureConfigMap = {\n  ai: \"AI_CHAT_ENABLED\",\n} satisfies Record<string, keyof ExtractResponseData<GetStatusConfigsResponse>>\n\nexport const withFeature =\n  (feature: keyof typeof featureConfigMap) =>\n  <T extends object>(Component: FC<T>, FallbackComponent: FC<T>) => {\n    const WithFeature = ({ ...props }: T) => {\n      const isEnabled = useFeature(feature)\n\n      return isEnabled ? <Component {...props} /> : <FallbackComponent {...props} />\n    }\n\n    return WithFeature\n  }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/ga4.ts",
    "content": "import { whoami } from \"@follow/store/user/getters\"\n\nimport { getClientId, getSessionId } from \"~/lib/client-session\"\n\nimport { followClient } from \"./api-client\"\n\nclass Analytics4 {\n  private clientID: string\n  private sessionID: string\n  private userID: string | null = null\n  private userProperties: Record<string, { value: unknown }> | null = null\n\n  constructor(clientID: string = getClientId(), sessionID = getSessionId()) {\n    this.clientID = clientID\n    this.sessionID = sessionID\n  }\n\n  async setUserId(id: string) {\n    this.userID = id\n  }\n\n  async setUserProperties(upValue?: Record<string, unknown>) {\n    const userProperties = Object.entries(upValue || {}).reduce((acc, [key, value]) => {\n      acc[key] = {\n        value,\n      }\n      return acc\n    }, {})\n    this.userProperties = userProperties\n  }\n\n  async logEvent(eventName: string, params?: Record<string, unknown>): Promise<any> {\n    delete params?.__code\n    delete params?.__eventName\n\n    const payload = {\n      client_id: this.clientID,\n      user_id: this.userID,\n      events: [\n        {\n          name: eventName,\n          params: {\n            session_id: this.sessionID,\n            engagement_time_msec: 1000,\n            ...params,\n          },\n        },\n      ],\n      user_properties: this.userProperties,\n    }\n\n    if (whoami())\n      return followClient.api.data.sendAnalytics({\n        ...payload,\n      })\n  }\n}\n\nexport const ga4 = new Analytics4()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/img-proxy.ts",
    "content": "import { WEB_BUILD } from \"@follow/shared/constants\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport {\n  getImageProxyUrl as getImageProxyUrlUtils,\n  replaceImgUrlIfNeed as replaceImgUrlIfNeedUtils,\n} from \"@follow/utils/img-proxy\"\nimport { useCallback } from \"react\"\n\nimport { useServerConfigs } from \"~/atoms/server-configs\"\n\nexport const useCanUseImageProxy = () => {\n  const userRole = useUserRole()\n  const serverConfig = useServerConfigs()\n  const canUseProxy =\n    !userRole ||\n    serverConfig?.PAYMENT_PLAN_LIST.find((i) => i.role === userRole)?.limit.SECURE_IMAGE_PROXY\n  return canUseProxy\n}\n\nexport const useReplaceImgUrlIfNeed = () => {\n  const canUseProxy = useCanUseImageProxy()\n\n  return useCallback(\n    (url?: string) => {\n      return replaceImgUrlIfNeedUtils({\n        url,\n        inBrowser: WEB_BUILD,\n        canUseProxy,\n      })\n    },\n    [canUseProxy],\n  )\n}\n\nexport const useGetImageProxyUrl = () => {\n  const canUseProxy = useCanUseImageProxy()\n\n  return useCallback(\n    (params: Omit<Parameters<typeof getImageProxyUrlUtils>[0], \"canUseProxy\">) => {\n      return getImageProxyUrlUtils({\n        canUseProxy,\n        ...params,\n      })\n    },\n    [canUseProxy],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/issues.ts",
    "content": "import { getCurrentEnvironment } from \"@follow/utils/environment\"\nimport { repository } from \"@pkg\"\n\ninterface IssueOptions {\n  title: string\n  body: string\n  label: string\n  error?: Error\n  target: \"issue\" | \"discussion\"\n  category: string\n  template?: \"bug_report.yml\" | \"feature_request.yml\"\n}\n\nexport const getNewIssueUrl = ({\n  body,\n  label,\n  title,\n  error,\n  target = \"issue\",\n  category,\n  template,\n}: Partial<IssueOptions> = {}) => {\n  // @see https://docs.github.com/en/enterprise-cloud@latest/issues/tracking-your-work-with-issues/using-issues/creating-an-issue\n  const baseUrl =\n    target === \"discussion\" ? `${repository.url}/discussions/new` : `${repository.url}/issues/new`\n\n  const searchParams = new URLSearchParams()\n  if (category) searchParams.set(\"category\", category)\n\n  let nextBody = [body || \"\", \"\", ...getCurrentEnvironment()].join(\"\\n\")\n  if (label) searchParams.set(\"labels\", label)\n  if (title) searchParams.set(\"title\", title)\n\n  if (error && \"traceId\" in error && error.traceId) {\n    nextBody += `\\n\\n### Trace ID\\n${error.traceId}`\n  }\n\n  searchParams.set(\"body\", nextBody)\n  if (template) searchParams.set(\"template\", template)\n  return `${baseUrl}?${searchParams.toString()}`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/jotai.ts",
    "content": "export { createAtomAccessor, createAtomHooks, jotaiStore } from \"@follow/utils/jotai\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/language.ts",
    "content": "import LanguageDetector from \"i18next-browser-languagedetector\"\n\nimport { currentSupportedLanguages } from \"~/@types/constants\"\nimport { I18N_LOCALE_KEY } from \"~/constants\"\n\nlet defaultLanguage = \"en\"\nconst languageDetector = new LanguageDetector(null, {\n  order: [\"querystring\", \"localStorage\", \"navigator\"],\n  lookupQuerystring: \"lng\",\n  lookupLocalStorage: I18N_LOCALE_KEY,\n  caches: [\"localStorage\"],\n})\n\nconst userLang = languageDetector.detect()\nif (userLang) {\n  const firstUserLang = Array.isArray(userLang) ? userLang[0]! : userLang\n  const currentLang = currentSupportedLanguages.find((lang) => lang.includes(firstUserLang))\n  if (currentLang) {\n    defaultLanguage = currentLang\n  }\n}\n\nexport const getDefaultLanguage = () => {\n  return defaultLanguage\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/load-language.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { isEmptyObject } from \"@follow/utils/utils\"\nimport dayjs from \"dayjs\"\nimport i18next from \"i18next\"\nimport { toast } from \"sonner\"\n\nimport { currentSupportedLanguages, dayjsLocaleImportMap } from \"~/@types/constants\"\nimport { defaultResources } from \"~/@types/default-resource\"\nimport { i18nAtom, langChain, LocaleCache } from \"~/i18n\"\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport { ipcServices } from \"./client\"\nimport { appLog } from \"./log\"\n\nconst loadingLangLock = new Set<string>()\nconst loadedLangs = new Set<string>([\"en\"])\n\nexport const loadLanguageAndApply = async (lang: string) => {\n  const dayjsImport = dayjsLocaleImportMap[lang]\n\n  if (dayjsImport) {\n    const [locale, loader] = dayjsImport\n    loader().then(() => {\n      appLog(\"dayjs loaded: \", locale)\n      langChain.next(() => {\n        return dayjs.locale(locale)\n      })\n    })\n  }\n\n  ipcServices?.app.switchAppLocale(lang)\n\n  const { t } = jotaiStore.get(i18nAtom)\n  if (loadingLangLock.has(lang)) return\n  const isSupport = currentSupportedLanguages.includes(lang)\n  if (!isSupport) {\n    return\n  }\n  const loaded = loadedLangs.has(lang)\n\n  if (loaded) {\n    if (import.meta.env.DEV) {\n      EventBus.dispatch(\"I18N_UPDATE\", \"\")\n    }\n    return\n  }\n\n  loadingLangLock.add(lang)\n\n  if (import.meta.env.DEV) {\n    const nsGlobbyMap = import.meta.glob(\"@locales/*/*.json\")\n\n    const namespaces = Object.keys(defaultResources.en)\n\n    const res = await Promise.allSettled(\n      namespaces.map(async (ns) => {\n        const loader = nsGlobbyMap[`../../../../locales/${ns}/${lang}.json`]\n\n        if (!loader) return\n        const nsResources = await loader().then((m: any) => m.default)\n\n        i18next.addResourceBundle(lang, ns, nsResources, true, true)\n      }),\n    )\n\n    for (const r of res) {\n      if (r.status === \"rejected\") {\n        toast.error(`${t(\"common:tips.load-lng-error\")}: ${lang}`)\n        loadingLangLock.delete(lang)\n\n        return\n      }\n    }\n    EventBus.dispatch(\"I18N_UPDATE\", \"\")\n  } else {\n    if (ELECTRON) return\n    let importFilePath = \"\"\n\n    if (IN_ELECTRON) {\n      importFilePath =\n        (await ipcServices?.app.resolveAppAsarPath(`dist/renderer/locales/${lang}.js`)) || \"\"\n    } else {\n      importFilePath = `/locales/${lang}.js`\n    }\n    const res = await eval(`import('${importFilePath}')`)\n      .then((res: any) => res?.default || res)\n      .catch(() => {\n        toast.error(`${t(\"common:tips.load-lng-error\")}: ${lang}`)\n        loadingLangLock.delete(lang)\n        return {}\n      })\n\n    if (isEmptyObject(res)) {\n      return\n    }\n    for (const namespace in res) {\n      i18next.addResourceBundle(lang, namespace, res[namespace], true, true)\n    }\n  }\n\n  await i18next.reloadResources()\n\n  LocaleCache.shared.set(lang)\n  loadedLangs.add(lang)\n  loadingLangLock.delete(lang)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/log.ts",
    "content": "import { log } from \"@follow/logger\"\n\nexport const appLog = (...args: any[]) => {\n  if (ELECTRON) log(...args)\n  console.info(\n    `%c ${APP_NAME} %c`,\n    \"color: #fff; margin: 0; padding: 5px 0; background: #ff5c00; border-radius: 3px;\",\n    ...args.reduce((acc, cur) => {\n      acc.push(\"\", cur)\n      return acc\n    }, []),\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/native-menu.ts",
    "content": "import type { MenuItemConstructorOptions } from \"electron\"\n\nimport { ipcServices } from \"./client\"\n\nexport type ElectronMenuItem = Omit<MenuItemConstructorOptions, \"click\" | \"submenu\"> & {\n  click?: () => void\n  submenu?: ElectronMenuItem[]\n}\n\nexport const showElectronContextMenu = async (items: Array<ElectronMenuItem>) => {\n  if (!window.electron) throw new Error(\"electron is not available\")\n  const dispose = window.electron.ipcRenderer.on(\n    \"menu-click\",\n    (_, { path }: { path: number[] }) => {\n      const targetMenu = getMenuItemByPath(items, path)\n      if (targetMenu && typeof targetMenu.click === \"function\") {\n        targetMenu.click()\n      } else {\n        console.warn(`Menu item not found or click handler missing for path: ${path}`)\n      }\n    },\n  )\n  const itemsWithoutClick = removeClick(items)\n  await ipcServices?.menu.showContextMenu({ items: itemsWithoutClick })\n  dispose()\n}\n\nfunction getMenuItemByPath(\n  items: Array<ElectronMenuItem>,\n  path: number[],\n): ElectronMenuItem | null {\n  let current: ElectronMenuItem | null = null\n  let currentLevel = items\n\n  for (const index of path) {\n    if (index >= currentLevel.length) {\n      return null\n    }\n    current = currentLevel[index] || null\n    if (current?.submenu && path.indexOf(index) < path.length - 1) {\n      currentLevel = current.submenu\n    }\n  }\n\n  return current\n}\n\nfunction removeClick(items: Array<ElectronMenuItem>): Array<ElectronMenuItem> {\n  return items.map((item) => ({\n    ...item,\n    click: undefined,\n    submenu: item.submenu ? removeClick(item.submenu) : undefined,\n  }))\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/observe-resize.ts",
    "content": "/**\n * @see https://github.com/toeverything/AFFiNE/blob/98e35384a6f71bf64c668b8f13afcaf28c9b8e97/packages/frontend/component/src/utils/observe-resize.ts\n * @copyright AFFiNE\n */\ntype ObserveResize = {\n  callback: (entity: ResizeObserverEntry) => void\n  dispose: () => void\n}\n\nlet _resizeObserver: ResizeObserver | null = null\nconst elementsMap = new WeakMap<Element, Array<ObserveResize>>()\n\n// for debugging\n;(window as any)._resizeObserverElementsMap = elementsMap\n\n/**\n * @internal get or initialize the ResizeObserver instance\n */\nconst getResizeObserver = () =>\n  (_resizeObserver ??= new ResizeObserver((entries) => {\n    entries.forEach((entry) => {\n      const listeners = elementsMap.get(entry.target) ?? []\n      listeners.forEach(({ callback }) => callback(entry))\n    })\n  }))\n\n/**\n * @internal remove element's specific listener\n */\nconst removeListener = (element: Element, listener: ObserveResize) => {\n  if (!element) return\n  const listeners = elementsMap.get(element) ?? []\n  const observer = getResizeObserver()\n  // remove the listener from the element\n  if (listeners.includes(listener)) {\n    elementsMap.set(\n      element,\n      listeners.filter((l) => l !== listener),\n    )\n  }\n  // if no more listeners, unobserve the element\n  if (elementsMap.get(element)?.length === 0) {\n    observer.unobserve(element)\n    elementsMap.delete(element)\n  }\n}\n\n/**\n * A function to observe the resize of an element use global ResizeObserver.\n *\n * ```ts\n * useEffect(() => {\n *  const dispose1 = observeResize(elRef1.current, (entry) => {});\n *  const dispose2 = observeResize(elRef2.current, (entry) => {});\n *\n *  return () => {\n *   dispose1();\n *   dispose2();\n *  };\n * }, [])\n * ```\n * @return A function to dispose the observer.\n */\nexport const observeResize = (element: Element, callback: ObserveResize[\"callback\"]) => {\n  const observer = getResizeObserver()\n  if (!elementsMap.has(element)) {\n    observer.observe(element)\n  }\n  const prevListeners = elementsMap.get(element) ?? []\n  const listener = { callback, dispose: () => {} }\n  listener.dispose = () => removeListener(element, listener)\n\n  elementsMap.set(element, [...prevListeners, listener])\n\n  return listener.dispose\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/parse-html.ts",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.jsx\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.jsx\"\nimport { LazyKateX } from \"@follow/components/ui/katex/lazy.js\"\nimport { parseHtml as parseHtmlGeneral } from \"@follow/utils/html\"\nimport type { Element } from \"hast\"\nimport type { Components } from \"hast-util-to-jsx-runtime\"\nimport { createElement } from \"react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { ShadowDOM } from \"~/components/common/ShadowDOM\"\nimport { ShikiHighLighter } from \"~/components/ui/code-highlighter\"\nimport { MarkdownBlockImage, MarkdownLink, MarkdownP } from \"~/components/ui/markdown/renderers\"\nimport { useIsInParagraphContext } from \"~/components/ui/markdown/renderers/ctx\"\nimport { createHeadingRenderer } from \"~/components/ui/markdown/renderers/Heading\"\nimport { MarkdownInlineImage } from \"~/components/ui/markdown/renderers/InlineImage\"\nimport { Media } from \"~/components/ui/media/Media\"\n\nfunction markInlineImage(node?: Element) {\n  for (const item of node?.children ?? []) {\n    if (item.type === \"element\" && item.tagName === \"img\") {\n      ;(item.properties as any).inline = true\n    }\n  }\n}\n\nexport const parseHtml = (\n  content: string,\n  options?: Partial<{\n    renderInlineStyle: boolean\n    noMedia?: boolean\n  }>,\n) => {\n  return parseHtmlGeneral(content, {\n    ...options,\n    components: {\n      a: ({ node, ...props }) => {\n        // markInlineImage(node)\n        return createElement(MarkdownLink, { ...props, className: \"text-accent\" } as any)\n      },\n      img: Img,\n\n      h1: ({ ref, ...props }) => {\n        markInlineImage(props.node)\n        return createHeadingRenderer(1)(props)\n      },\n      h2: ({ ref, ...props }) => {\n        markInlineImage(props.node)\n        return createHeadingRenderer(2)(props)\n      },\n      h3: ({ ref, ...props }) => {\n        markInlineImage(props.node)\n        return createHeadingRenderer(3)(props)\n      },\n      h4: ({ ref, ...props }) => {\n        markInlineImage(props.node)\n        return createHeadingRenderer(4)(props)\n      },\n      h5: ({ ref, ...props }) => {\n        markInlineImage(props.node)\n        return createHeadingRenderer(5)(props)\n      },\n      h6: ({ ref, ...props }) => {\n        markInlineImage(props.node)\n        return createHeadingRenderer(6)(props)\n      },\n      style: Style,\n\n      video: ({ node, ...props }) => {\n        const sourceElement = Array.isArray(props.children)\n          ? props.children.find((i) => i.type === \"source\")\n          : // Children is only the source element\n            props.children &&\n              typeof props.children === \"object\" &&\n              \"type\" in props.children &&\n              props.children.type === \"source\"\n            ? props.children\n            : null\n\n        const src = props.src || sourceElement?.props.src\n        return createElement(Media, {\n          ...props,\n          popper: true,\n          type: \"video\",\n          previewImageUrl: props.poster,\n          src,\n        })\n      },\n\n      p: ({ node, ref, ...props }) => {\n        if (node?.children && node.children.length !== 1) {\n          for (const item of node.children) {\n            item.type === \"element\" &&\n              item.tagName === \"img\" &&\n              ((item.properties as any).inline = true)\n          }\n        }\n        return createElement(MarkdownP, props, props.children)\n      },\n      span: ({ node, ...props }) => {\n        markInlineImage(node)\n        return createElement(\"span\", props, props.children)\n      },\n      b: ({ node, ...props }) => {\n        markInlineImage(node)\n        return createElement(\"b\", props, props.children)\n      },\n      i: ({ node, ...props }) => {\n        markInlineImage(node)\n        return createElement(\"i\", props, props.children)\n      },\n      // @ts-ignore\n      math: Math,\n      hr: ({ node, ...props }) =>\n        createElement(\"hr\", {\n          ...props,\n          className: tw`scale-x-50`,\n        }),\n      input: ({ node, ...props }) => {\n        if (props.type === \"checkbox\") {\n          return createElement(Checkbox, {\n            ...props,\n            disabled: false,\n            className: tw`pointer-events-none mr-2`,\n          } as any)\n        }\n        return createElement(\"input\", props)\n      },\n      iframe: ({ node, ...props }) => {\n        const { width, height, src, ...rest } = props\n\n        // Apply security sandbox attributes and responsive styling\n        return createElement(\"iframe\", {\n          ...rest,\n          src,\n          width: width || \"100%\",\n          height: height || \"315\",\n          className: \"max-w-full rounded\",\n          sandbox: \"allow-scripts allow-same-origin allow-popups allow-forms\",\n          allowFullScreen: true,\n          loading: \"lazy\",\n          style: {\n            aspectRatio: width && height ? `${width} / ${height}` : \"16 / 9\",\n            ...rest.style,\n          },\n        })\n      },\n      pre: ({ node, ...props }) => {\n        if (!props.children) return null\n\n        let language = \"\"\n\n        let codeString = null as string | null\n        if (props.className?.includes(\"language-\")) {\n          language = props.className.replace(\"language-\", \"\")\n        }\n\n        if (typeof props.children !== \"object\") {\n          codeString = props.children.toString()\n        } else {\n          const propsChildren = props.children\n          const children = Array.isArray(propsChildren)\n            ? propsChildren.find((i) => i.type === \"code\")\n            : propsChildren\n\n          // Don't process not code block\n          if (!children) return createElement(\"pre\", props, props.children)\n\n          if (\n            \"type\" in children &&\n            children.type === \"code\" &&\n            children.props.className?.includes(\"language-\")\n          ) {\n            language = children.props.className.replace(\"language-\", \"\")\n          }\n          const code = (\"props\" in children && children.props.children) || children\n          if (!code) return null\n\n          try {\n            codeString = extractCodeFromHtml(renderToString(code))\n          } catch (error) {\n            console.error(\"Code Block Render Error\", error)\n            return createElement(\"pre\", props, props.children)\n          }\n        }\n\n        if (!codeString) return createElement(\"pre\", props, props.children)\n        // Workaround for Hugo's code block\n        // Code line number in Hugo will render inside <pre> tag\n        const isLineNumberInHugo = codeString.slice(0, 15).split(\" \").join(\"\").startsWith(\"1\\n2\\n3\")\n\n        return createElement(ShikiHighLighter, {\n          code: codeString.trimEnd(),\n          language: language.toLowerCase(),\n          showCopy: !isLineNumberInHugo,\n        })\n      },\n      figure: ({ node, ...props }) =>\n        createElement(\n          \"figure\",\n          {\n            className: \"max-w-full\",\n          },\n          props.children,\n        ),\n      table: ({ node, ...props }) =>\n        createElement(\n          \"div\",\n          {\n            className: \"w-full overflow-x-auto\",\n          },\n\n          createElement(\"table\", {\n            ...props,\n            className: tw`w-full my-0`,\n          }),\n        ),\n      td: ({ node, ...props }) =>\n        // Workaround for Hugo's table code\n        createElement(\"td\", { ...props, className: tw`p-0` }, props.children),\n    },\n  })\n}\n\nconst Img: Components[\"img\"] = ({ node, ...props }) => {\n  const nextProps = {\n    ...props,\n    preferOrigin: true,\n    proxy: { height: 0, width: 700 },\n  }\n  const widthPx = Number.parseInt(props.width as string)\n\n  return createElement(\n    node?.properties.inline && (Number.isNaN(widthPx) || widthPx < 600)\n      ? MarkdownInlineImage\n      : MarkdownBlockImage,\n    nextProps,\n  )\n}\n\nexport function extractCodeFromHtml(htmlString: string) {\n  const tempDiv = document.createElement(\"div\")\n  tempDiv.innerHTML = htmlString\n\n  const hasPre = tempDiv.querySelector(\"pre\")\n  if (!hasPre) {\n    tempDiv.innerHTML = `<pre><code>${htmlString}</code></pre>`\n  }\n\n  // 1. line break via <div />\n  const divElements = tempDiv.querySelectorAll(\"div\")\n\n  let code = \"\"\n\n  if (divElements.length > 0) {\n    divElements.forEach((div) => {\n      code += `${div.textContent}\\n`\n    })\n    return code\n  }\n\n  // 2. line wrapper like <span><span>...</span></span>\n  const spanElements = tempDiv.querySelectorAll(\"span > span\")\n\n  // 2.1 outside <span /> as a line break?\n\n  let spanAsLineBreak = false\n\n  if (tempDiv.children.length > 2) {\n    for (const node of tempDiv.children) {\n      const span = node as HTMLSpanElement\n      // 2.2 If the span has only one child and it's a line break, then span can be as a line break\n      spanAsLineBreak = span.children.length === 1 && span.childNodes.item(0).textContent === \"\\n\"\n      if (spanAsLineBreak) break\n    }\n  }\n\n  if (!spanAsLineBreak) {\n    const usingBr = tempDiv.querySelector(\"br\")\n    if (usingBr) {\n      spanAsLineBreak = true\n    }\n  }\n\n  if (spanElements.length > 0) {\n    for (const node of tempDiv.children) {\n      if (spanAsLineBreak) {\n        code += `${node.textContent}`\n      } else {\n        code += `${node.textContent}\\n`\n      }\n    }\n\n    return code\n  }\n\n  return tempDiv.textContent\n}\n\nconst Style: Components[\"style\"] = ({ node, ...props }) => {\n  const isShadowDOM = ShadowDOM.useIsShadowDOM()\n\n  if (isShadowDOM && typeof props.children === \"string\") {\n    return createElement(\n      MemoedDangerousHTMLStyle,\n      null,\n      props.children.replaceAll(/html|body/g, \"#shadow-html\"),\n    )\n  }\n  return null\n}\n\nconst Math = ({ node }) => {\n  const annotation = node.children.at(-1)\n\n  const isInParagraph = useIsInParagraphContext()\n  if (!annotation) return null\n  const latex = annotation.value\n\n  return createElement(LazyKateX, {\n    children: latex,\n    mode: isInParagraph ? \"inline\" : \"display\",\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/parse-markdown.ts",
    "content": "import type { RemarkOptions } from \"@follow/components/utils/parse-markdown.js\"\nimport { parseMarkdown as parseMarkdownImpl } from \"@follow/components/utils/parse-markdown.js\"\nimport { createElement } from \"react\"\n\nimport { MarkdownLink } from \"~/components/ui/markdown/renderers/MarkdownLink\"\nimport { VideoPlayer } from \"~/components/ui/media/VideoPlayer\"\n\nexport const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {\n  const videoExts = [\"mp4\", \"webm\"]\n  return parseMarkdownImpl(content, {\n    ...options,\n    components: {\n      a: ({ node, ...props }) => createElement(MarkdownLink, { ...props } as any),\n      img: ({ node, ...props }) => {\n        const { src } = props\n        try {\n          const url = new URL(src || \"\")\n          const path = url.pathname\n          const search = url.searchParams\n          const ratio = search.get(\"ratio\")\n\n          const isVideo = videoExts.some((ext) => path.endsWith(ext))\n          if (isVideo) {\n            if (ratio) {\n              return createElement(\n                \"div\",\n                {\n                  style: {\n                    width: \"100%\",\n                    paddingTop: `${(1 / Number(ratio)) * 100}%`,\n                    position: \"relative\",\n                  },\n                },\n                createElement(\n                  \"div\",\n                  {\n                    style: {\n                      position: \"absolute\",\n                      top: 0,\n                      left: 0,\n                      width: \"100%\",\n                      height: \"100%\",\n                    },\n                  },\n                  createElement(VideoPlayer, {\n                    src: src as string,\n                  }),\n                ),\n              )\n            }\n            return createElement(VideoPlayer, {\n              src: src as string,\n            })\n          }\n        } catch {\n          // ignore\n        }\n        return createElement(\"img\", { ...props } as any)\n      },\n      ...options?.components,\n    },\n  })\n}\n\nexport { type RemarkOptions } from \"@follow/components/utils/parse-markdown.js\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/parsers.ts",
    "content": "import { isTwitterUrl, isXUrl } from \"@follow/utils/link-parser\"\n\nexport const parseSocialMedia = (parsedUrl?: string | null) => {\n  if (!parsedUrl) return\n\n  const isX = isXUrl(parsedUrl).validate || isTwitterUrl(parsedUrl).validate\n\n  if (isX) {\n    return {\n      type: \"x\",\n      meta: {\n        handle: new URL(parsedUrl).pathname.split(\"/\").pop(),\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/query-client.ts",
    "content": "import { FollowAPIError } from \"@follow-app/client-sdk\"\nimport { createSyncStoragePersister } from \"@tanstack/query-sync-storage-persister\"\nimport type { OmitKeyof } from \"@tanstack/react-query\"\nimport { QueryClient } from \"@tanstack/react-query\"\nimport type { PersistQueryClientOptions } from \"@tanstack/react-query-persist-client\"\nimport { FetchError } from \"ofetch\"\n\nimport { QUERY_PERSIST_KEY } from \"../constants/app\"\n\nconst defaultStaleTime = 600_000 // 10min\nconst DO_NOT_RETRY_CODES = new Set([400, 401, 403, 404, 422, 402])\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      refetchOnWindowFocus: false,\n      retryDelay: 1000,\n      staleTime: defaultStaleTime,\n      retry(failureCount, error) {\n        if (\n          error instanceof FetchError &&\n          (error.statusCode === undefined || DO_NOT_RETRY_CODES.has(error.statusCode))\n        ) {\n          return false\n        }\n\n        if (error instanceof FollowAPIError && DO_NOT_RETRY_CODES.has(error.status)) {\n          return false\n        }\n\n        return !!(3 - failureCount)\n      },\n      // throwOnError: import.meta.env.DEV,\n    },\n  },\n})\nconst localStoragePersister = createSyncStoragePersister({\n  storage: window.localStorage,\n  key: QUERY_PERSIST_KEY,\n})\n\ndeclare module \"@tanstack/react-query\" {\n  interface Meta {\n    queryMeta: { persist?: boolean }\n  }\n\n  interface Register extends Meta {}\n}\n\nexport const persistConfig: OmitKeyof<PersistQueryClientOptions, \"queryClient\"> = {\n  persister: localStoragePersister,\n  // 7 day\n  maxAge: 7 * 24 * 60 * 60 * 1000,\n  dehydrateOptions: {\n    shouldDehydrateQuery: (query) => {\n      if (!query.meta?.persist) return false\n      const queryIsReadyForPersistence = query.state.status === \"success\"\n      if (queryIsReadyForPersistence) {\n        return (\n          !((query.state?.data as any)?.pages?.length > 1) && query.queryKey?.[0] !== \"check-eagle\"\n        )\n      } else {\n        return false\n      }\n    },\n  },\n}\n\nexport { queryClient }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/simple-text-selection.ts",
    "content": "/**\n * Simple text selection utilities for ShadowDOM\n */\n\nexport interface SelectionRect {\n  top: number\n  right: number\n  bottom: number\n  left: number\n  width: number\n  height: number\n}\n\nexport interface TextSelectionEvent {\n  selectedText: string\n  timestamp: number\n  rect: SelectionRect\n}\n\n/**\n * Add text selection listener to ShadowDOM container\n */\nexport function addTextSelectionListener(\n  shadowRoot: ShadowRoot,\n  onTextSelect: (event: TextSelectionEvent) => void,\n  onSelectionClear?: () => void,\n): () => void {\n  let debounceTimer: ReturnType<typeof setTimeout> | null = null\n\n  const handleSelectionChange = () => {\n    if (debounceTimer) clearTimeout(debounceTimer)\n\n    debounceTimer = setTimeout(() => {\n      const selection = (shadowRoot as unknown as Document).getSelection?.()\n      if (!selection) return\n\n      // Check if selection is within our shadow root\n      try {\n        const range = selection.getRangeAt(0)\n        if (!shadowRoot.contains(range.commonAncestorContainer)) return\n\n        if (!selection.isCollapsed) {\n          const selectedText = selection.toString().trim()\n          if (selectedText) {\n            onTextSelect({\n              selectedText,\n              timestamp: Date.now(),\n              rect: normalizeRect(range.getBoundingClientRect()),\n            })\n          }\n          return\n        }\n      } catch {\n        // Uncaught IndexSizeError: Failed to execute 'getRangeAt' on 'Selection': 0 is not a valid index.\n        return\n      }\n      onSelectionClear?.()\n    }, 200)\n  }\n\n  document.addEventListener(\"selectionchange\", handleSelectionChange)\n\n  return () => {\n    if (debounceTimer) clearTimeout(debounceTimer)\n    document.removeEventListener(\"selectionchange\", handleSelectionChange)\n  }\n}\n\nfunction normalizeRect(rect: DOMRect | DOMRectReadOnly): SelectionRect {\n  return {\n    top: rect.top,\n    right: rect.right,\n    bottom: rect.bottom,\n    left: rect.left,\n    width: rect.width,\n    height: rect.height,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/url-builder.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { UrlBuilder as UrlBuilderClass } from \"@follow/utils/url-builder\"\n\nimport { WEB_URL } from \"~/constants/env\"\n\nclass WebUrlBuilder extends UrlBuilderClass {\n  constructor() {\n    super(WEB_URL)\n  }\n\n  shareEntry(\n    id: string,\n    options?: {\n      view?: FeedViewType\n      subscriptionId?: string\n    },\n  ) {\n    const { view = FeedViewType.Articles, subscriptionId = \"all\" } = options || {}\n\n    return super.join(`timeline/view-${view}/${subscriptionId}/${id}`, { share: \"1\" })\n  }\n}\n\nexport const UrlBuilder = new WebUrlBuilder()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/lib/utils.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\n\nimport { getServerConfigs } from \"~/atoms/server-configs\"\n\nimport { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from \"../constants/app\"\n\nexport function getEntriesParams({\n  feedId,\n  inboxId,\n  listId,\n  view,\n}: {\n  feedId?: number | string\n  inboxId?: number | string\n  listId?: number | string\n  view?: number\n}) {\n  const params: {\n    feedId?: string\n    feedIdList?: string[]\n    isCollection?: boolean\n    withContent?: boolean\n    inboxId?: string\n    listId?: string\n  } = {}\n  if (inboxId) {\n    params.inboxId = `${inboxId}`\n  } else if (listId) {\n    params.listId = `${listId}`\n  } else if (feedId) {\n    if (feedId === FEED_COLLECTION_LIST) {\n      params.isCollection = true\n    } else if (feedId !== ROUTE_FEED_PENDING) {\n      if (feedId.toString().includes(\",\")) {\n        params.feedIdList = `${feedId}`.split(\",\")\n      } else {\n        params.feedId = `${feedId}`\n      }\n    }\n  }\n  if (view === FeedViewType.SocialMedia) {\n    params.withContent = true\n  }\n  return {\n    view,\n    ...params,\n  }\n}\n\nexport const getLevelMultiplier = (level: number) => {\n  if (level === 0) {\n    return 0.1\n  }\n  const serverConfigs = getServerConfigs()\n  if (!serverConfigs) {\n    return 1\n  }\n  const level1Range = serverConfigs?.LEVEL_PERCENTAGES[3]! - serverConfigs?.LEVEL_PERCENTAGES[2]!\n  const percentageIndex = serverConfigs.LEVEL_PERCENTAGES.length - level\n  let levelCurrentRange\n  if (percentageIndex - 1 < 0) {\n    levelCurrentRange = serverConfigs?.LEVEL_PERCENTAGES[percentageIndex]\n  } else {\n    levelCurrentRange =\n      serverConfigs?.LEVEL_PERCENTAGES[percentageIndex]! -\n      serverConfigs?.LEVEL_PERCENTAGES[percentageIndex - 1]!\n  }\n  const rangeMultiplier = levelCurrentRange / level1Range\n\n  const poolMultiplier =\n    serverConfigs?.DAILY_POWER_PERCENTAGES[level]! / serverConfigs?.DAILY_POWER_PERCENTAGES[1]!\n\n  return (poolMultiplier / rangeMultiplier).toFixed(0)\n}\n\nexport const getBlockchainExplorerUrl = () => {\n  const serverConfigs = getServerConfigs()\n\n  if (serverConfigs?.IS_RSS3_TESTNET) {\n    return `https://scan.testnet.rss3.io`\n  } else {\n    return `https://scan.rss3.io`\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/main.tsx",
    "content": "import \"./wdyr\"\nimport \"@follow/components/tailwind\"\nimport \"./styles/main.css\"\n\nimport { IN_ELECTRON, WEB_BUILD } from \"@follow/shared/constants\"\nimport { apiContext, authClientContext, queryClientContext } from \"@follow/store/context\"\nimport { getOS } from \"@follow/utils/utils\"\nimport * as React from \"react\"\nimport { flushSync } from \"react-dom\"\nimport ReactDOM from \"react-dom/client\"\nimport { RouterProvider } from \"react-router/dom\"\n\nimport { authClient } from \"~/lib/auth\"\n\nimport { setAppIsReady } from \"./atoms/app\"\nimport { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from \"./constants\"\nimport { initializeApp } from \"./initialize\"\nimport { registerAppGlobalShortcuts } from \"./initialize/global-shortcuts\"\nimport { followApi } from \"./lib/api-client\"\nimport { queryClient } from \"./lib/query-client\"\nimport { router } from \"./router\"\n\nauthClientContext.provide(authClient)\nqueryClientContext.provide(queryClient)\napiContext.provide(followApi)\n\ninitializeApp().finally(() => {\n  import(\"./push-notification\").then(({ registerWebPushNotifications }) => {\n    if (navigator.serviceWorker && WEB_BUILD) {\n      registerWebPushNotifications()\n    }\n  })\n\n  // eslint-disable-next-line @eslint-react/dom/no-flush-sync\n  flushSync(() => setAppIsReady(true))\n})\n\nconst $container = document.querySelector(\"#root\") as HTMLElement\n\nif (IN_ELECTRON) {\n  const os = getOS()\n\n  switch (os) {\n    case \"Windows\": {\n      document.body.style.cssText += `--fo-window-padding-top: ${ElECTRON_CUSTOM_TITLEBAR_HEIGHT}px;`\n      break\n    }\n    case \"macOS\": {\n      document.body.style.cssText += `--fo-macos-traffic-light-width: 80px; --fo-macos-traffic-light-height: 30px;`\n      break\n    }\n  }\n  document.documentElement.dataset.os = getOS()\n} else {\n  registerAppGlobalShortcuts()\n}\n\nReactDOM.createRoot($container).render(\n  <React.StrictMode>\n    <RouterProvider router={router} />\n  </React.StrictMode>,\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/action-setting.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingWithIcon } from \"@follow/components/ui/loading/index.jsx\"\nimport * as ScrollArea from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport {\n  useActionRules,\n  useIsActionDataDirty,\n  usePrefetchActions,\n  useUpdateActionsMutation,\n} from \"@follow/store/action/hooks\"\nimport type { ActionItem } from \"@follow/store/action/store\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport { nextFrame } from \"@follow/utils\"\nimport { JsonObfuscatedCodec } from \"@follow/utils/json-codec\"\nimport { cn } from \"@follow/utils/utils\"\nimport { repository } from \"@pkg\"\nimport { useQueryClient } from \"@tanstack/react-query\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useBlocker } from \"react-router\"\nimport { toast } from \"sonner\"\n\nimport { MenuItemText, useShowContextMenu } from \"~/atoms/context-menu\"\nimport { HeaderActionButton, HeaderActionGroup } from \"~/components/ui/button/HeaderActionButton\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu.js\"\nimport { useDialog } from \"~/components/ui/modal/stacked/hooks\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\nimport { getI18n } from \"~/i18n\"\nimport { copyToClipboard, readFromClipboard } from \"~/lib/clipboard\"\nimport { toastFetchError } from \"~/lib/error-parser\"\nimport { downloadJsonFile, selectJsonFile } from \"~/lib/export\"\nimport { RuleCard } from \"~/modules/action/rule-card\"\nimport {\n  buildActionSummary,\n  buildConditionSummary,\n  getRuleDisplayName,\n} from \"~/modules/action/rule-summary\"\n\nimport { useSetSubViewRightView } from \"../app-layout/subview/hooks\"\nimport { generateExportFilename } from \"./utils\"\n\nconst EmptyActionPlaceholder = ({ onCreateRule }: { onCreateRule: () => void }) => {\n  const { t } = useTranslation([\"settings\", \"common\"])\n\n  return (\n    <div className=\"flex min-h-96 w-full items-center justify-center py-10\">\n      <div className=\"flex w-full max-w-xl flex-col items-center gap-6 rounded-3xl border border-fill-secondary bg-material-ultra-thin px-8 py-10 text-center shadow-sm\">\n        <div className=\"flex size-16 items-center justify-center rounded-2xl border border-fill-secondary bg-fill-quinary\">\n          <i className=\"i-mgc-magic-2-cute-re size-8 text-text-secondary\" />\n        </div>\n\n        <div className=\"space-y-2\">\n          <h2 className=\"text-lg font-semibold text-text\">\n            {t(\"actions.action_card.empty.title\")}\n          </h2>\n          <p className=\"max-w-sm text-sm text-text-secondary\">\n            {t(\"actions.action_card.empty.description\")}\n          </p>\n        </div>\n\n        <div className=\"flex flex-wrap items-center justify-center gap-3\">\n          <Button onClick={onCreateRule}>\n            <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n            {t(\"actions.action_card.empty.cta\")}\n          </Button>\n          <a\n            href={`${repository.url}/wiki/Actions`}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-fill-secondary hover:text-text\"\n          >\n            <i className=\"i-mgc-book-6-cute-re size-4\" />\n            <span>{t(\"words.documentation\", { ns: \"common\" })}</span>\n          </a>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport const ActionSetting = () => {\n  const actions = useActionRules()\n  const { t } = useTranslation(\"settings\")\n\n  const [selectedRuleIndex, setSelectedRuleIndex] = useState(0)\n  const actionQuery = usePrefetchActions()\n\n  useEffect(() => {\n    if (actions.length === 0) {\n      setSelectedRuleIndex(0)\n      return\n    }\n\n    if (selectedRuleIndex > actions.length - 1) {\n      setSelectedRuleIndex(actions.length - 1)\n    }\n  }, [actions.length, selectedRuleIndex])\n\n  if (actionQuery.isPending) {\n    return (\n      <LoadingWithIcon\n        className=\"flex h-64 items-center justify-center\"\n        icon={<i className=\"i-mgc-magic-2-cute-re\" />}\n        size=\"large\"\n      />\n    )\n  }\n\n  const hasActions = actions.length > 0\n\n  const handleCreateRule = () => {\n    const nextIndex = actions.length\n    actionActions.addRule((number) => t(\"actions.actionName\", { number }))\n    setSelectedRuleIndex(nextIndex)\n  }\n\n  return (\n    <>\n      <ActionButtonGroup onCreateRule={handleCreateRule} />\n      <ShareImportSection />\n      {hasActions ? (\n        <div className=\"flex min-h-0 w-full flex-1 flex-col @[960px]:absolute @[960px]:inset-x-0 @[960px]:bottom-0 @[960px]:top-12\">\n          <div className=\"hidden h-full flex-1 @[960px]:flex @[960px]:h-0 @[960px]:overflow-hidden @[960px]:rounded-lg @[960px]:border @[960px]:border-fill-secondary\">\n            <RuleList\n              selectedIndex={selectedRuleIndex}\n              onSelect={setSelectedRuleIndex}\n              onDelete={(deletedIndex) => {\n                // Adjust selectedRuleIndex when a rule is deleted\n                if (deletedIndex === selectedRuleIndex) {\n                  // If deleting the selected rule, select the previous one or 0\n                  setSelectedRuleIndex(Math.max(0, deletedIndex - 1))\n                } else if (deletedIndex < selectedRuleIndex) {\n                  // If deleting a rule before the selected one, shift the index down\n                  setSelectedRuleIndex(selectedRuleIndex - 1)\n                }\n              }}\n            />\n            <div className=\"flex flex-1 border-l border-fill-secondary\">\n              <RuleCard index={selectedRuleIndex} mode=\"detail\" />\n            </div>\n          </div>\n          <div className=\"flex flex-col gap-3 @[960px]:hidden\">\n            {actions.map((_, actionIdx) => (\n              <RuleCard\n                key={actionIdx}\n                index={actionIdx}\n                mode=\"compact\"\n                defaultOpen={actionIdx === selectedRuleIndex}\n                onOpenChange={(open) => {\n                  if (open) {\n                    setSelectedRuleIndex(actionIdx)\n                  }\n                }}\n              />\n            ))}\n          </div>\n        </div>\n      ) : (\n        <EmptyActionPlaceholder onCreateRule={handleCreateRule} />\n      )}\n    </>\n  )\n}\n\nconst ShareImportSection = () => {\n  const { t } = useTranslation(\"settings\")\n  const actionLength = useActionRules((actions) => actions.length)\n  const hasActions = actionLength > 0\n\n  const handleExport = useCallback(() => {\n    try {\n      const jsonData = actionActions.exportRules()\n      const filename = generateExportFilename()\n      downloadJsonFile(jsonData, filename)\n      toast.success(`Action rules exported successfully as ${filename}`)\n    } catch {\n      toast.error(\"Failed to export action rules\")\n    }\n  }, [])\n\n  const handleImport = useCallback(async () => {\n    try {\n      const jsonData = await selectJsonFile()\n      const result = actionActions.importRules(jsonData)\n\n      if (result.success) {\n        toast.success(result.message)\n      } else {\n        toast.error(result.message)\n      }\n    } catch (error) {\n      if (error instanceof Error && error.message === \"No file selected\") {\n        return\n      }\n      toast.error(\"Failed to import action rules\")\n    }\n  }, [])\n\n  const foloPrefix = \"folo:actions#\"\n  const handleCopyToClipboard = useCallback(async () => {\n    try {\n      const jsonData = actionActions.exportRules()\n      const codecData = JsonObfuscatedCodec.encode(jsonData)\n      await copyToClipboard(`${foloPrefix}${codecData}`)\n      toast.success(\"Action rules copied to clipboard\")\n    } catch (error) {\n      toast.error(\"Failed to copy action rules to clipboard\")\n      console.error(error)\n    }\n  }, [foloPrefix])\n\n  const handleImportFromClipboard = useCallback(async () => {\n    try {\n      const clipboardData = await readFromClipboard()\n      if (!clipboardData.startsWith(foloPrefix)) {\n        toast.error(\"Invalid clipboard data\")\n        return\n      }\n      const codecData = clipboardData.slice(foloPrefix.length)\n      const jsonData = JsonObfuscatedCodec.decode(codecData)\n      const result = actionActions.importRules(jsonData)\n\n      if (result.success) {\n        toast.success(result.message)\n      } else {\n        toast.error(result.message)\n      }\n    } catch (error) {\n      if (error instanceof Error && error.message.includes(\"clipboard\")) {\n        toast.error(error.message)\n      } else {\n        toast.error(\"Failed to import from clipboard\")\n      }\n      console.error(error)\n    }\n  }, [foloPrefix])\n\n  return (\n    <div className=\"mb-4 flex justify-end\">\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            buttonClassName=\"size-9 p-0\"\n            aria-label={\n              hasActions\n                ? t(\"actions.action_card.summary.share\")\n                : t(\"actions.action_card.summary.import\")\n            }\n          >\n            <i\n              className={cn(\n                \"size-4\",\n                hasActions ? \"i-mgc-share-forward-cute-re\" : \"i-mgc-file-import-cute-re\",\n              )}\n            />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\" className=\"w-56\">\n          <DropdownMenuItem onClick={handleExport} disabled={!hasActions}>\n            <i className=\"i-mgc-download-2-cute-re mr-3 size-4\" />\n            {t(\"actions.action_card.summary.export\")}\n          </DropdownMenuItem>\n          <DropdownMenuItem onClick={handleImport}>\n            <i className=\"i-mgc-file-upload-cute-re mr-3 size-4\" />\n            {t(\"actions.action_card.summary.import_file\")}\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuItem onClick={handleCopyToClipboard} disabled={!hasActions}>\n            <i className=\"i-mgc-copy-2-cute-re mr-3 size-4\" />\n            {t(\"actions.action_card.summary.copy\")}\n          </DropdownMenuItem>\n          <DropdownMenuItem onClick={handleImportFromClipboard}>\n            <i className=\"i-mgc-paste-cute-re mr-3 size-4\" />\n            {t(\"actions.action_card.summary.import_clipboard\")}\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  )\n}\n\nconst RuleList = ({\n  selectedIndex,\n  onSelect,\n  onDelete,\n}: {\n  selectedIndex: number\n  onSelect: (index: number) => void\n  onDelete: (index: number) => void\n}) => {\n  const rules = useActionRules()\n  const { t } = useTranslation(\"settings\")\n  const ruleCount = useActionRules((s) => s.length)\n  const mutation = useUpdateActionsMutation()\n  const { ask } = useDialog()\n  const showContextMenu = useShowContextMenu()\n\n  const handleDeleteRule = useCallback(\n    (index: number) => {\n      if (ruleCount === 1) {\n        ask({\n          title: t(\"actions.action_card.summary.delete_title\"),\n          variant: \"danger\",\n          message: t(\"actions.action_card.summary.delete_message\"),\n          onConfirm: () => {\n            actionActions.deleteRule(index)\n            onDelete(index)\n            nextFrame(() => {\n              mutation.mutate()\n            })\n          },\n        })\n      } else {\n        actionActions.deleteRule(index)\n        onDelete(index)\n      }\n    },\n    [ruleCount, ask, t, mutation, onDelete],\n  )\n\n  if (rules.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"flex w-[260px] shrink-0 flex-col\">\n      <ScrollArea.ScrollArea rootClassName=\"h-full\" viewportClassName=\"h-full\">\n        <div className=\"flex flex-col\">\n          {rules.map((rule, index) => (\n            <RuleListItem\n              key={rule.index ?? index}\n              rule={rule}\n              index={index}\n              isActive={index === selectedIndex}\n              onSelect={onSelect}\n              handleDelete={handleDeleteRule}\n              showContextMenu={showContextMenu}\n            />\n          ))}\n        </div>\n      </ScrollArea.ScrollArea>\n    </div>\n  )\n}\n\nconst RuleListItem = ({\n  rule,\n  index,\n  isActive,\n  onSelect,\n  handleDelete,\n  showContextMenu,\n}: {\n  rule: ActionItem\n  index: number\n  isActive: boolean\n  onSelect: (index: number) => void\n  handleDelete: (index: number) => void\n  showContextMenu: ReturnType<typeof useShowContextMenu>\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const displayName = getRuleDisplayName(rule, index, t)\n  const whenSummary = buildConditionSummary(rule, t)\n  const actionSummary = buildActionSummary(rule, t)\n\n  const contextMenuProps = useContextMenu({\n    onContextMenu: async (e) => {\n      e.preventDefault()\n      await showContextMenu(\n        [\n          new MenuItemText({\n            label: t(\"actions.action_card.summary.delete\"),\n            icon: <i className=\"i-mgc-delete-2-cute-re\" />,\n            click: () => handleDelete(index),\n            requiresLogin: true,\n          }),\n        ],\n        e,\n      )\n    },\n  })\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => onSelect(index)}\n      {...contextMenuProps}\n      className={cn(\n        \"flex flex-col gap-1 border-b border-fill-tertiary px-4 py-3 text-left transition-all last:border-b-0\",\n        isActive ? \"bg-fill-quaternary\" : \"hover:bg-fill-quinary\",\n      )}\n    >\n      <span className=\"text-sm font-medium text-text\">{displayName}</span>\n      <span className=\"line-clamp-2 text-xs text-text-secondary\">{whenSummary}</span>\n      <span className=\"line-clamp-1 text-xs text-text-secondary\">{actionSummary}</span>\n    </button>\n  )\n}\n\nconst ActionButtonGroup = ({ onCreateRule }: { onCreateRule: () => void }) => {\n  const queryClient = useQueryClient()\n  const actionLength = useActionRules((actions) => actions.length)\n  const isDirty = useIsActionDataDirty()\n  const { t } = useTranslation(\"settings\")\n\n  useUnSavedBlocker(isDirty)\n\n  const mutation = useUpdateActionsMutation({\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: [\"entries\"],\n      })\n      toast(t(\"actions.saveSuccess\"))\n    },\n    onError: (error) => {\n      toastFetchError(error)\n    },\n  })\n\n  const hasActions = actionLength > 0\n\n  const setRightView = useSetSubViewRightView()\n  useEffect(() => {\n    setRightView(\n      <HeaderActionGroup>\n        <HeaderActionButton variant=\"primary\" icon=\"i-mingcute-add-line\" onClick={onCreateRule}>\n          {t(\"actions.newRule\")}\n        </HeaderActionButton>\n\n        {hasActions && (\n          <HeaderActionButton\n            variant=\"accent\"\n            icon=\"i-mgc-check-circle-cute-re\"\n            disabled={!isDirty}\n            loading={mutation.isPending}\n            onClick={() => mutation.mutate()}\n          >\n            {mutation.isPending ? \"Saving...\" : t(\"actions.save\")}\n          </HeaderActionButton>\n        )}\n      </HeaderActionGroup>,\n    )\n    return () => {\n      setRightView(null)\n    }\n  }, [setRightView, actionLength, hasActions, isDirty, mutation, onCreateRule, t])\n\n  return null\n}\n\nconst useUnSavedBlocker = (isDirty: boolean) => {\n  const navigationBlocker = useBlocker(({ currentLocation, nextLocation }) => {\n    return isDirty && currentLocation.pathname !== nextLocation.pathname\n  })\n\n  const isRouterPromptOpenRef = useRef(false)\n  const { ask } = useDialog()\n  useEffect(() => {\n    if (navigationBlocker.state !== \"blocked\") {\n      isRouterPromptOpenRef.current = false\n      return\n    }\n    if (isRouterPromptOpenRef.current) {\n      return\n    }\n    isRouterPromptOpenRef.current = true\n    const { t } = getI18n()\n    ask({\n      title: t(\"common:words.unsaved_changes\"),\n      message: t(\"settings:actions.navigate.prompt\"),\n      variant: \"ask\",\n      onConfirm: () => navigationBlocker.proceed(),\n    })\n  }, [ask, navigationBlocker])\n\n  useEffect(() => {\n    const handleBeforeUnload = (event: BeforeUnloadEvent) => {\n      const hasUnsavedChanges = isDirty\n      if (!hasUnsavedChanges) {\n        return\n      }\n      event.preventDefault()\n      event.returnValue = \"\"\n    }\n    window.addEventListener(\"beforeunload\", handleBeforeUnload)\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload)\n    }\n  }, [isDirty])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/constants.tsx",
    "content": "import { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { ACTION_LANGUAGE_MAP } from \"@follow/shared/language\"\nimport type { ActionAction } from \"@follow/store/action/constant\"\nimport { availableActionMap as availableActionMapOriginal } from \"@follow/store/action/constant\"\nimport type { ActionId } from \"@follow-app/client-sdk\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { defaultResources } from \"~/@types/default-resource\"\nimport {\n  DEFAULT_ACTION_LANGUAGE,\n  setGeneralSetting,\n  useGeneralSettingKey,\n} from \"~/atoms/settings/general\"\n\nimport { setTranslationCache } from \"../entry-content/atoms\"\n\nexport const availableActionMap: typeof availableActionMapOriginal = {\n  ...availableActionMapOriginal,\n  summary: {\n    ...availableActionMapOriginal.summary,\n    prefixElement: <AiTargetLanguageSelector />,\n  },\n  translation: {\n    ...availableActionMapOriginal.translation,\n    prefixElement: <AiTargetLanguageSelector />,\n  },\n} as Record<ActionId, ActionAction>\n\nfunction AiTargetLanguageSelector() {\n  const { t } = useTranslation(\"settings\")\n  const actionLanguage = useGeneralSettingKey(\"actionLanguage\")\n\n  return (\n    <ResponsiveSelect\n      size=\"sm\"\n      triggerClassName=\"w-48\"\n      defaultValue={actionLanguage}\n      value={actionLanguage}\n      onValueChange={(value) => {\n        setGeneralSetting(\"actionLanguage\", value)\n        setTranslationCache({})\n      }}\n      items={[\n        { label: t(\"general.action_language.default\"), value: DEFAULT_ACTION_LANGUAGE },\n        ...Object.values(ACTION_LANGUAGE_MAP).map((item) => ({\n          label: defaultResources[item.value].lang.name,\n          value: item.value,\n        })),\n      ]}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/rule-card.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport * as ScrollArea from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport { useActionRule, useActionRules, useUpdateActionsMutation } from \"@follow/store/action/hooks\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport { nextFrame } from \"@follow/utils\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useDialog } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { buildActionSummary, buildConditionSummary, getRuleDisplayName } from \"./rule-summary\"\nimport { ThenSection } from \"./then-section\"\nimport { WhenSection } from \"./when-section\"\n\ntype RuleCardProps = {\n  index: number\n  mode?: \"detail\" | \"compact\"\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n}\n\nexport const RuleCard = ({\n  index,\n  mode = \"detail\",\n  defaultOpen = false,\n  onOpenChange,\n}: RuleCardProps) => {\n  if (mode === \"compact\") {\n    return <CompactRuleCard index={index} defaultOpen={defaultOpen} onOpenChange={onOpenChange} />\n  }\n\n  return (\n    <div className=\"group/rule flex size-full flex-col @container\">\n      <div className=\"shrink-0 border-b border-fill-tertiary px-5 py-4\">\n        <RuleCardToolbar index={index} />\n      </div>\n      <ScrollArea.ScrollArea rootClassName=\"flex-1\" viewportClassName=\"h-full\">\n        <div className=\"p-5\">\n          <RuleCardContent index={index} />\n        </div>\n      </ScrollArea.ScrollArea>\n    </div>\n  )\n}\n\nconst RuleCardContent = ({ index }: { index: number }) => {\n  return (\n    <div className=\"flex flex-col gap-6 @[900px]:grid @[900px]:grid-cols-2 @[900px]:items-start @[900px]:gap-6\">\n      <WhenSection index={index} />\n      <ThenSection index={index} />\n    </div>\n  )\n}\n\nconst CompactRuleCard = ({\n  index,\n  defaultOpen,\n  onOpenChange,\n}: {\n  index: number\n  defaultOpen: boolean\n  onOpenChange?: (open: boolean) => void\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const rule = useActionRule(index)\n  const disabled = useActionRule(index, (a) => a.result.disabled)\n  const [open, setOpen] = useState(defaultOpen)\n\n  useEffect(() => {\n    setOpen(defaultOpen)\n  }, [defaultOpen])\n\n  const toggle = () => {\n    setOpen((prev) => {\n      const next = !prev\n      onOpenChange?.(next)\n      return next\n    })\n  }\n\n  const displayName = getRuleDisplayName(rule, index, t)\n  const whenSummary = buildConditionSummary(rule, t)\n  const actionSummary = buildActionSummary(rule, t)\n\n  return (\n    <div className=\"overflow-hidden rounded-lg border border-fill-secondary bg-transparent\">\n      <button\n        type=\"button\"\n        onClick={toggle}\n        className=\"flex w-full items-start justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-fill-quinary\"\n      >\n        <div className=\"flex flex-col gap-1\">\n          <span className=\"text-sm font-semibold text-text\">{displayName}</span>\n          <span className=\"line-clamp-1 text-xs text-text-tertiary\">{whenSummary}</span>\n          <span className=\"line-clamp-1 text-xs text-text-tertiary\">{actionSummary}</span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {disabled && (\n            <span className=\"rounded-md border border-fill-secondary bg-fill-quaternary px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-text-secondary\">\n              {t(\"actions.action_card.summary.disabled\")}\n            </span>\n          )}\n          <i\n            className={cn(\n              \"size-4 text-text-tertiary transition-transform\",\n              open ? \"i-mingcute-arrow-up-line\" : \"i-mingcute-arrow-down-line\",\n            )}\n          />\n        </div>\n      </button>\n      {open ? (\n        <div className=\"space-y-4 border-t border-fill-tertiary bg-transparent p-4\">\n          <RuleCardToolbar index={index} />\n          <RuleCardContent index={index} />\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nconst RuleCardToolbar = ({ index }: { index: number }) => {\n  const { t } = useTranslation(\"settings\")\n  const name = useActionRule(index, (a) => a.name)\n  const disabled = useActionRule(index, (a) => a.result.disabled)\n  const ruleCount = useActionRules((s) => s.length)\n  const mutation = useUpdateActionsMutation()\n  const { ask } = useDialog()\n\n  const handleDelete = () => {\n    if (ruleCount === 1) {\n      ask({\n        title: t(\"actions.action_card.summary.delete_title\"),\n        variant: \"danger\",\n        message: t(\"actions.action_card.summary.delete_message\"),\n        onConfirm: () => {\n          actionActions.deleteRule(index)\n          nextFrame(() => {\n            mutation.mutate()\n          })\n        },\n      })\n    } else {\n      actionActions.deleteRule(index)\n    }\n  }\n\n  return (\n    <div className={\"flex w-full flex-wrap items-center gap-3\"}>\n      <Input\n        value={name}\n        placeholder={t(\"actions.action_card.name\")}\n        className=\"h-9 min-w-[160px] flex-1 bg-transparent px-3 text-base font-semibold shadow-none ring-0 focus-visible:ring-0\"\n        onChange={(e) => {\n          actionActions.patchRule(index, { name: e.target.value })\n        }}\n      />\n      <div className=\"flex items-center gap-3\">\n        <Switch\n          checked={!disabled}\n          onCheckedChange={(checked) => {\n            actionActions.patchRule(index, {\n              result: { disabled: !checked },\n            })\n          }}\n          aria-label={t(\"actions.action_card.summary.toggle\")}\n        />\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          aria-label={t(\"actions.action_card.summary.delete\")}\n          buttonClassName=\"size-8 p-0\"\n          onClick={handleDelete}\n        >\n          <i className=\"i-mgc-delete-2-cute-re\" />\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/rule-summary.ts",
    "content": "import { filterFieldOptions, filterOperatorOptions } from \"@follow/store/action/constant\"\nimport type { ActionItem } from \"@follow/store/action/store\"\nimport type { TFunction } from \"i18next\"\n\nimport { availableActionMap } from \"./constants\"\n\nconst fieldLabelMap = new Map(filterFieldOptions.map((option) => [option.value, option.label]))\nconst operatorLabelMap = new Map(\n  filterOperatorOptions.map((option) => [option.value, option.label]),\n)\n\nexport const getRuleDisplayName = (\n  rule: ActionItem | undefined,\n  index: number,\n  t: TFunction<\"settings\">,\n) => {\n  const fallback = t(\"actions.actionName\", { number: index + 1 })\n  if (!rule) return fallback\n  const trimmedName = rule.name?.trim()\n  return trimmedName && trimmedName.length > 0 ? trimmedName : fallback\n}\n\nexport const buildConditionSummary = (rule: ActionItem | undefined, t: TFunction<\"settings\">) => {\n  if (!rule || rule.condition.length === 0) {\n    return t(\"actions.action_card.all\")\n  }\n\n  const andSeparator = ` ${t(\"actions.action_card.and\")} `\n  const orSeparator = ` ${t(\"actions.action_card.or\")} `\n\n  const groups = rule.condition\n    .map((group) => {\n      const groupParts = group\n        .map((condition) => {\n          const fieldLabelKey = condition.field ? fieldLabelMap.get(condition.field) : undefined\n          const operatorLabelKey = condition.operator\n            ? operatorLabelMap.get(condition.operator)\n            : undefined\n          const fieldLabel = fieldLabelKey ? t(fieldLabelKey) : undefined\n          const operatorLabel = operatorLabelKey ? t(operatorLabelKey) : undefined\n          const { value } = condition\n\n          const valueText =\n            typeof value === \"string\" && value.trim().length > 0 ? value : value?.toString() || \"\"\n\n          const parts = [fieldLabel, operatorLabel, valueText].filter(Boolean)\n          return parts.join(\" \")\n        })\n        .filter((part) => part.length > 0)\n\n      return groupParts.join(andSeparator)\n    })\n    .filter((group) => group.length > 0)\n\n  if (groups.length === 0) {\n    return t(\"actions.action_card.all\")\n  }\n\n  return groups.join(orSeparator)\n}\n\nexport const buildActionSummary = (rule: ActionItem | undefined, t: TFunction<\"settings\">) => {\n  if (!rule) {\n    return t(\"actions.action_card.summary.no_actions\")\n  }\n\n  if (rule.result?.disabled) {\n    return t(\"actions.action_card.summary.disabled\")\n  }\n\n  const labels = Object.values(availableActionMap)\n    .filter((action) => {\n      const value = rule.result?.[action.value as keyof typeof rule.result]\n      if (Array.isArray(value)) {\n        return value.length > 0\n      }\n\n      if (typeof value === \"object\" && value !== null) {\n        return Object.keys(value).length > 0\n      }\n\n      return Boolean(value)\n    })\n    .map((action) => t(action.label))\n\n  if (labels.length === 0) {\n    return t(\"actions.action_card.summary.no_actions\")\n  }\n\n  return labels.join(\" + \")\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/then-section.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport type { ActionAction } from \"@follow/store/action/constant\"\nimport { useActionRule } from \"@follow/store/action/hooks\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ActionId } from \"@follow-app/client-sdk\"\nimport { merge } from \"es-toolkit/compat\"\nimport type { ReactNode } from \"react\"\nimport { Fragment, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu.js\"\n\nimport { useSettingModal } from \"../settings/modal/useSettingModal\"\nimport { availableActionMap } from \"./constants\"\n\ntype ThenSectionProps = {\n  index: number\n  variant?: \"detail\" | \"compact\"\n}\n\nexport const ThenSection = ({ index, variant: _variant = \"detail\" }: ThenSectionProps) => {\n  const { t } = useTranslation(\"settings\")\n  const result = useActionRule(index, (a) => a.result)\n\n  const rewriteRules = useActionRule(index, (a) => a.result.rewriteRules)\n  const webhooks = useActionRule(index, (a) => a.result.webhooks)\n  const settingModalPresent = useSettingModal()\n\n  const disabled = useActionRule(index, (a) => a.result.disabled)\n\n  const availableActions = useMemo(() => {\n    const extendedAvailableActionMap: Record<\n      ActionId,\n      ActionAction & {\n        config?: () => ReactNode\n      }\n    > = merge(availableActionMap, {\n      rewriteRules: {\n        config: () => (\n          <div className=\"flex flex-col gap-3\">\n            {!rewriteRules || rewriteRules.length === 0 ? (\n              <button\n                type=\"button\"\n                disabled={disabled}\n                className=\"flex items-center justify-between rounded-lg bg-fill-quaternary px-4 py-3 text-xs text-text-tertiary transition-colors hover:bg-fill-tertiary hover:text-text disabled:opacity-50\"\n                onClick={() => {\n                  actionActions.addRewriteRule(index)\n                }}\n              >\n                <span>{t(\"actions.action_card.rewrite_rules\")}</span>\n                <i className=\"i-mgc-add-cute-re\" />\n              </button>\n            ) : (\n              <div className=\"flex flex-col gap-3\">\n                {rewriteRules.map((rule, rewriteIdx) => {\n                  const change = (key: \"from\" | \"to\", value: string) => {\n                    actionActions.updateRewriteRule({\n                      index,\n                      rewriteRuleIndex: rewriteIdx,\n                      key,\n                      value,\n                    })\n                  }\n                  return (\n                    <div\n                      key={rewriteIdx}\n                      className=\"flex flex-col gap-3 rounded-lg bg-fill-quaternary p-4\"\n                    >\n                      <div className=\"grid gap-3 @[520px]:grid-cols-2\">\n                        <label className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">\n                          {t(\"actions.action_card.from\")}\n                          <Input\n                            disabled={disabled}\n                            value={rule.from}\n                            className=\"mt-2 h-9\"\n                            onChange={(event) => change(\"from\", event.target.value)}\n                          />\n                        </label>\n                        <label className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">\n                          {t(\"actions.action_card.to\")}\n                          <Input\n                            disabled={disabled}\n                            value={rule.to}\n                            className=\"mt-2 h-9\"\n                            onChange={(event) => change(\"to\", event.target.value)}\n                          />\n                        </label>\n                      </div>\n                      <div className=\"flex items-center justify-end gap-2\">\n                        <IconButton\n                          icon=\"i-mgc-add-cute-re\"\n                          ariaLabel={t(\"actions.action_card.add\")}\n                          disabled={disabled}\n                          onClick={() => {\n                            actionActions.addRewriteRule(index)\n                          }}\n                        />\n                        <IconButton\n                          icon=\"i-mgc-delete-2-cute-re\"\n                          className=\"hover:text-red\"\n                          ariaLabel={t(\"actions.action_card.summary.delete\")}\n                          disabled={disabled}\n                          onClick={() => {\n                            actionActions.deleteRewriteRule(index, rewriteIdx)\n                          }}\n                        />\n                      </div>\n                    </div>\n                  )\n                })}\n              </div>\n            )}\n          </div>\n        ),\n      },\n      webhooks: {\n        config: () => (\n          <div className=\"flex flex-col gap-3\">\n            {!webhooks || webhooks.length === 0 ? (\n              <button\n                type=\"button\"\n                disabled={disabled}\n                className=\"flex items-center justify-between rounded-lg bg-fill-quaternary px-4 py-3 text-xs text-text-tertiary transition-colors hover:bg-fill-tertiary hover:text-text disabled:opacity-50\"\n                onClick={() => {\n                  actionActions.addWebhook(index)\n                }}\n              >\n                <span>{t(\"actions.action_card.webhooks\")}</span>\n                <i className=\"i-mgc-add-cute-re\" />\n              </button>\n            ) : (\n              <div className=\"flex flex-col gap-3\">\n                {webhooks.map((webhook, webhookIdx) => {\n                  return (\n                    <div\n                      key={webhookIdx}\n                      className=\"flex flex-col gap-2 rounded-lg bg-fill-quaternary p-4\"\n                    >\n                      <Input\n                        disabled={disabled}\n                        value={webhook}\n                        className=\"h-9\"\n                        placeholder=\"https://\"\n                        onChange={(event) => {\n                          actionActions.updateWebhook({\n                            index,\n                            webhookIndex: webhookIdx,\n                            value: event.target.value,\n                          })\n                        }}\n                      />\n                      <div className=\"flex items-center justify-end gap-2\">\n                        <IconButton\n                          icon=\"i-mgc-add-cute-re\"\n                          ariaLabel={t(\"actions.action_card.add\")}\n                          disabled={disabled}\n                          onClick={() => {\n                            actionActions.addWebhook(index)\n                          }}\n                        />\n                        <IconButton\n                          icon=\"i-mgc-delete-2-cute-re\"\n                          className=\"hover:text-red\"\n                          ariaLabel={t(\"actions.action_card.summary.delete\")}\n                          disabled={disabled}\n                          onClick={() => {\n                            actionActions.deleteWebhook(index, webhookIdx)\n                          }}\n                        />\n                      </div>\n                    </div>\n                  )\n                })}\n              </div>\n            )}\n          </div>\n        ),\n      },\n    })\n    return Object.values(extendedAvailableActionMap)\n  }, [disabled, index, rewriteRules, t, webhooks])\n\n  const enabledActions = useMemo(() => {\n    if (!result) return []\n\n    // Get the order of actions from the result object (insertion order)\n    const resultKeys = Object.keys(result).filter((key) => result[key as ActionId])\n\n    // Sort availableActions based on the order in result object\n    return resultKeys\n      .map((key) => availableActions.find((action) => action.value === key))\n      .filter((action): action is NonNullable<typeof action> => !!action)\n  }, [availableActions, result])\n  const notEnabledActions = useMemo(\n    () => availableActions.filter((action) => !result?.[action.value]),\n    [availableActions, result],\n  )\n\n  return (\n    <section className=\"flex flex-col gap-4\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <span className=\"text-xs font-semibold uppercase tracking-wide text-text-secondary\">\n          {t(\"actions.action_card.then_do\")}\n        </span>\n        {enabledActions.length > 0 && (\n          <span className=\"text-xs text-text-secondary\">\n            {t(\"actions.action_card.summary.action_count\", { count: enabledActions.length })}\n          </span>\n        )}\n      </div>\n\n      <div className=\"relative flex flex-col\">\n        {enabledActions.length === 0 ? (\n          <div className=\"flex flex-col items-center gap-3 rounded-lg bg-fill-quaternary px-4 py-6\">\n            <span className=\"text-xs text-text-secondary\">\n              {t(\"actions.action_card.summary.no_actions\")}\n            </span>\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild disabled={disabled}>\n                <Button variant=\"outline\" size=\"sm\" buttonClassName=\"border-dashed\">\n                  <i className=\"i-mgc-add-cute-re mr-2\" />\n                  {t(\"actions.action_card.add\")}\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent className=\"w-60\">\n                {notEnabledActions.map((action) => {\n                  return (\n                    <DropdownMenuItem\n                      key={action.label}\n                      onClick={() => {\n                        if (action.onEnable) {\n                          action.onEnable(index)\n                        } else {\n                          actionActions.patchRule(index, { result: { [action.value]: true } })\n                        }\n                      }}\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <i className={action.iconClassname} />\n                        {t(action.label)}\n                      </div>\n                    </DropdownMenuItem>\n                  )\n                })}\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        ) : (\n          <>\n            {enabledActions.map((action, actionIndex) => {\n              const isLast = actionIndex === enabledActions.length - 1\n              return (\n                <Fragment key={action.label}>\n                  <div className=\"relative flex gap-4\">\n                    {/* Connection line and icon */}\n                    <div className=\"relative flex flex-col items-center pt-0.5\">\n                      <div className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-material-ultra-thick text-text shadow-sm\">\n                        <i className={cn(action.iconClassname, \"text-base\")} />\n                      </div>\n                      {!isLast && (\n                        <div className=\"absolute left-1/2 top-9 h-[calc(100%+0.75rem)] w-0.5 -translate-x-1/2 bg-gradient-to-b from-fill-secondary to-transparent\" />\n                      )}\n                    </div>\n\n                    {/* Action content */}\n                    <div className=\"flex min-w-0 flex-1 flex-col gap-3 pb-6 pt-0.5\">\n                      <div className=\"flex items-center gap-4\">\n                        {/* Label */}\n                        <span className=\"flex h-9 items-center text-sm font-medium text-text\">\n                          {t(action.label)}\n                        </span>\n\n                        {/* Spacer */}\n                        <div className=\"flex-1\" />\n\n                        {/* Value selector or prefix element */}\n                        {action.prefixElement && (\n                          <div className=\"text-xs\">{action.prefixElement}</div>\n                        )}\n\n                        {/* Settings button */}\n                        {action.settingsPath && (\n                          <Button\n                            variant=\"outline\"\n                            size=\"sm\"\n                            buttonClassName=\"rounded-lg\"\n                            onClick={() => {\n                              settingModalPresent(action.settingsPath)\n                            }}\n                          >\n                            {t(\"actions.action_card.settings\")}\n                          </Button>\n                        )}\n\n                        {/* Delete button */}\n                        <IconButton\n                          icon=\"i-mgc-delete-2-cute-re\"\n                          ariaLabel={t(\"actions.action_card.summary.delete\")}\n                          disabled={disabled}\n                          className=\"hover:text-red\"\n                          onClick={() => {\n                            actionActions.deleteRuleAction(index, action.value)\n                          }}\n                        />\n                      </div>\n                      {action.config && (\n                        <div className=\"rounded-lg bg-fill-quinary p-4\">{action.config()}</div>\n                      )}\n                    </div>\n                  </div>\n                </Fragment>\n              )\n            })}\n            <div className=\"relative flex gap-4\">\n              <div className=\"size-9 shrink-0\" />\n              <div className=\"pb-2\">\n                <DropdownMenu>\n                  <DropdownMenuTrigger asChild disabled={disabled}>\n                    <Button variant=\"outline\" size=\"sm\" buttonClassName=\"border-dashed\">\n                      <i className=\"i-mgc-add-cute-re mr-2\" />\n                      {t(\"actions.action_card.add\")}\n                    </Button>\n                  </DropdownMenuTrigger>\n                  <DropdownMenuContent align=\"start\" className=\"w-60\">\n                    {notEnabledActions.map((action) => {\n                      return (\n                        <DropdownMenuItem\n                          key={action.label}\n                          onClick={() => {\n                            if (action.onEnable) {\n                              action.onEnable(index)\n                            } else {\n                              actionActions.patchRule(index, { result: { [action.value]: true } })\n                            }\n                          }}\n                        >\n                          <div className=\"flex items-center gap-2\">\n                            <i className={action.iconClassname} />\n                            {t(action.label)}\n                          </div>\n                        </DropdownMenuItem>\n                      )\n                    })}\n                  </DropdownMenuContent>\n                </DropdownMenu>\n              </div>\n            </div>\n          </>\n        )}\n      </div>\n    </section>\n  )\n}\n\nconst IconButton = ({\n  icon,\n  onClick,\n  ariaLabel,\n  disabled,\n  className,\n}: {\n  icon: string\n  onClick: () => void\n  ariaLabel: string\n  disabled?: boolean\n  className?: string\n}) => {\n  return (\n    <button\n      type=\"button\"\n      aria-label={ariaLabel}\n      disabled={disabled}\n      className={cn(\n        \"flex size-6 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-fill-quaternary hover:text-text disabled:opacity-50\",\n        className,\n      )}\n      onClick={onClick}\n    >\n      <i className={icon} />\n    </button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/utils.ts",
    "content": "export const generateExportFilename = () => {\n  const now = new Date()\n  const dateStr = now.toISOString().split(\"T\")[0] // YYYY-MM-DD\n  const timeStr = now.toTimeString().split(\" \")[0]?.replaceAll(\":\", \"-\") // HH-MM-SS\n  return `follow-actions-${dateStr}-${timeStr}.json`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/action/when-section.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@follow/components/ui/select/index.jsx\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { filterFieldOptions, filterOperatorOptions } from \"@follow/store/action/constant\"\nimport { useActionRule } from \"@follow/store/action/hooks\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport type { ActionFeedField, ActionOperation } from \"@follow-app/client-sdk\"\nimport { Fragment } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ViewSelectContent } from \"~/modules/feed/view-select-content\"\n\ntype WhenSectionProps = {\n  index: number\n}\n\nexport const WhenSection = ({ index }: WhenSectionProps) => {\n  const { t } = useTranslation(\"settings\")\n\n  const disabled = useActionRule(index, (a) => a.result.disabled)\n  const condition = useActionRule(index, (a) => a.condition)\n\n  const mode = condition.length > 0 ? \"filter\" : \"all\"\n\n  const handleModeChange = (value: \"all\" | \"filter\") => {\n    if (value === \"all\" && condition.length > 0) {\n      actionActions.toggleRuleFilter(index)\n    }\n\n    if (value === \"filter\" && condition.length === 0) {\n      actionActions.toggleRuleFilter(index)\n    }\n  }\n\n  return (\n    <section className=\"flex flex-col gap-4\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <span className=\"text-xs font-semibold uppercase tracking-wide text-text-secondary\">\n          {t(\"actions.action_card.when_feeds_match\")}\n        </span>\n        <SegmentGroup\n          value={mode}\n          onValueChanged={(value) => handleModeChange(value as \"all\" | \"filter\")}\n        >\n          <SegmentItem value=\"all\" label={t(\"actions.action_card.all\")} />\n          <SegmentItem value=\"filter\" label={t(\"actions.action_card.custom_filters\")} />\n        </SegmentGroup>\n      </div>\n\n      {mode === \"filter\" && (\n        <div className=\"flex flex-col gap-4\">\n          {condition.map((orConditions, orConditionIdx) => {\n            return (\n              <Fragment key={orConditionIdx}>\n                <div className=\"flex flex-col gap-3 rounded-lg border border-dashed border-border bg-transparent p-4\">\n                  {orConditions.map((item, conditionIdx) => {\n                    const actionConditionIndex = {\n                      ruleIndex: index,\n                      groupIndex: orConditionIdx,\n                      conditionIndex: conditionIdx,\n                    }\n\n                    const change = (key: string, value: string | number) => {\n                      actionActions.pathCondition(actionConditionIndex, {\n                        [key]: value,\n                      })\n                    }\n\n                    const type =\n                      filterFieldOptions.find((option) => option.value === item.field)?.type ||\n                      \"text\"\n\n                    return (\n                      <div key={conditionIdx} className=\"flex flex-col gap-2\">\n                        <div className=\"flex flex-col gap-2 rounded-lg border border-fill-secondary bg-transparent p-3 @[800px]:flex-row @[800px]:items-center\">\n                          <ResponsiveSelect\n                            placeholder=\"Select Field\"\n                            disabled={disabled}\n                            value={item.field}\n                            onValueChange={(value) => change(\"field\", value as ActionFeedField)}\n                            items={filterFieldOptions.map((option) => ({\n                              ...option,\n                              label: t(option.label),\n                            }))}\n                            triggerClassName=\"h-9 min-w-[160px] @[800px]:flex-1\"\n                          />\n                          <OperationSelect\n                            type={type}\n                            disabled={disabled}\n                            value={item.operator}\n                            onValueChange={(value) => change(\"operator\", value)}\n                          />\n                          <ValueInput\n                            type={type}\n                            value={item.value}\n                            onChange={(value) => change(\"value\", value)}\n                            disabled={disabled}\n                          />\n                          <button\n                            type=\"button\"\n                            aria-label={t(\"actions.action_card.summary.delete\")}\n                            className=\"flex size-9 shrink-0 items-center justify-center self-end rounded-lg border border-fill-secondary bg-transparent text-text-secondary transition-colors hover:border-fill hover:bg-fill-quinary hover:text-text disabled:opacity-50 @[800px]:self-center\"\n                            disabled={disabled}\n                            onClick={() => {\n                              actionActions.deleteConditionItem(actionConditionIndex)\n                            }}\n                          >\n                            <i className=\"i-mgc-delete-2-cute-re\" />\n                          </button>\n                        </div>\n                        {conditionIdx !== orConditions.length - 1 && (\n                          <div className=\"flex items-center text-xs text-text-secondary\">\n                            <span className=\"rounded-md border border-border bg-material-medium px-2 py-0.5 font-bold uppercase tracking-wide\">\n                              {t(\"actions.action_card.and\")}\n                            </span>\n                          </div>\n                        )}\n                      </div>\n                    )\n                  })}\n                  <Button\n                    variant=\"outline\"\n                    size=\"sm\"\n                    buttonClassName=\"w-fit border-dashed border-border \"\n                    disabled={disabled}\n                    onClick={() => {\n                      actionActions.addConditionItem({\n                        ruleIndex: index,\n                        groupIndex: orConditionIdx,\n                      })\n                    }}\n                  >\n                    <i className=\"i-mgc-add-cute-re mr-2\" />\n                    {t(\"actions.action_card.and\")}\n                  </Button>\n                </div>\n                {orConditionIdx !== condition.length - 1 && (\n                  <div className=\"flex items-center justify-center\">\n                    <span className=\"rounded-md border border-border bg-material-medium px-3 py-1 text-xs font-semibold uppercase tracking-wide text-text-secondary\">\n                      {t(\"actions.action_card.or\")}\n                    </span>\n                  </div>\n                )}\n              </Fragment>\n            )\n          })}\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            buttonClassName=\"w-fit border-dashed border-border \"\n            onClick={() => {\n              actionActions.addConditionGroup({ ruleIndex: index })\n            }}\n            disabled={disabled}\n          >\n            <i className=\"i-mgc-add-cute-re mr-2\" />\n            {t(\"actions.action_card.or\")}\n          </Button>\n        </div>\n      )}\n    </section>\n  )\n}\n\nconst OperationSelect = ({\n  type,\n  value,\n  onValueChange,\n  disabled,\n}: {\n  type: \"text\" | \"number\" | \"view\" | \"status\"\n  value?: ActionOperation\n  onValueChange?: (value: ActionOperation) => void\n  disabled?: boolean\n}) => {\n  const { t } = useTranslation(\"settings\")\n\n  const options = filterOperatorOptions\n    .filter((option) => option.types.includes(type))\n    .map((option) => ({\n      ...option,\n      label: t(option.label),\n    }))\n  if (options.length === 1 && value === undefined) {\n    onValueChange?.(options[0]!.value as ActionOperation)\n  }\n  return (\n    <ResponsiveSelect\n      placeholder=\"Select Operation\"\n      disabled={disabled}\n      value={value}\n      onValueChange={(nextValue) => onValueChange?.(nextValue as ActionOperation)}\n      items={options}\n      triggerClassName=\"h-9 min-w-[150px]\"\n    />\n  )\n}\n\nconst ValueInput = ({\n  type,\n  value,\n  onChange,\n  disabled,\n}: {\n  type: string\n  value?: string | number\n  onChange: (value: string | number) => void\n  disabled?: boolean\n}) => {\n  switch (type) {\n    case \"view\": {\n      return (\n        <Select\n          disabled={disabled}\n          onValueChange={(nextValue) => onChange(nextValue)}\n          value={value as string | undefined}\n        >\n          <CommonSelectTrigger />\n          <ViewSelectContent />\n        </Select>\n      )\n    }\n    case \"status\": {\n      if (value === undefined) {\n        onChange(\"collected\")\n      }\n      return (\n        <Select\n          disabled={disabled}\n          onValueChange={(nextValue) => onChange(nextValue)}\n          value={value as string | undefined}\n        >\n          <CommonSelectTrigger />\n          <SelectContent>\n            <SelectItem value=\"collected\">Collected</SelectItem>\n            <SelectItem value=\"read\">Read</SelectItem>\n          </SelectContent>\n        </Select>\n      )\n    }\n    case \"number\": {\n      return (\n        <Input\n          disabled={disabled}\n          type=\"number\"\n          value={value}\n          className=\"h-9\"\n          onChange={(event) => onChange(event.target.value)}\n        />\n      )\n    }\n    default: {\n      return (\n        <Input\n          disabled={disabled}\n          value={value as string | undefined}\n          className=\"h-9\"\n          onChange={(event) => onChange(event.target.value)}\n        />\n      )\n    }\n  }\n}\n\nconst CommonSelectTrigger = () => (\n  <SelectTrigger className=\"h-9 min-w-[150px]\">\n    <SelectValue />\n  </SelectTrigger>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/atoms/session.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { atom } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\n// Edit state management for messages\nexport const [, , useEditingMessageId, useSetEditingMessageId, , setEditingMessageId] =\n  createAtomHooks(atom<string | null>(null))\n\n// AI Model persistence\ninterface AIModelState {\n  selectedModel: string | null\n}\n\nconst aiModelInitialState: AIModelState = {\n  selectedModel: null,\n}\n\nexport const [, , useAIModelState, useSetAIModelState, getAIModelState, setAIModelState] =\n  createAtomHooks<AIModelState>(\n    atomWithStorage(getStorageNS(\"ai-chat-model\"), aiModelInitialState, undefined, {\n      getOnInit: true,\n    }),\n  )\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/3d-models/AISpline.ts",
    "content": "import { cn } from \"@follow/utils\"\nimport { createElement, lazy, Suspense } from \"react\"\n\nimport { ErrorBoundary } from \"~/components/common/ErrorBoundary\"\n\nconst AISplineLoader = lazy(() =>\n  import(\"./AISplineLoader\").then((res) => ({ default: res.AISplineLoader })),\n)\nexport const AISpline = ({ className }: { className?: string }) => {\n  return createElement(\n    ErrorBoundary,\n    {\n      handled: true,\n    },\n    createElement(\n      Suspense,\n      {\n        fallback: createElement(\"div\", { className: cn(\"size-20 mx-auto\", className) }),\n      },\n      createElement(AISplineLoader, { className }),\n    ),\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/3d-models/AISplineLoader.tsx",
    "content": "import { clamp, cn } from \"@follow/utils\"\nimport Spline from \"@splinetool/react-spline\"\nimport { useCallback, useRef } from \"react\"\n\nconst resolvedAIIconUrl = \"https://assets.folo.is/ai2.splinecode\"\n\nexport const AISplineLoader = ({ className }: { className?: string }) => {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const headRef = useRef<any>(null)\n\n  // Angle conversion function: degrees to radians\n  const degToRad = (degrees: number) => degrees * (Math.PI / 180)\n\n  // Calculate the angle the head should look at\n  const calculateHeadRotation = useCallback(\n    (mouseX: number, mouseY: number, containerRect: DOMRect) => {\n      const containerCenterX = containerRect.left + containerRect.width / 2\n      const containerCenterY = containerRect.top + containerRect.height / 2\n\n      // Calculate mouse position relative to container center (-1 to 1)\n      const relativeX = (mouseX - containerCenterX) / (window.innerWidth / 2)\n      const relativeY = (mouseY - containerCenterY) / (window.innerHeight / 2)\n\n      // Clamp range\n      const clampedX = Math.max(-1, Math.min(1, relativeX))\n      const clampedY = Math.max(-1, Math.min(1, relativeY))\n\n      // Calculate head rotation angle based on relative position\n      // Y-axis rotation (left-right): -70 to 70 degrees\n      const headRotationY = clampedX * 20\n\n      // X-axis rotation (up-down): -60 to 60 degrees\n      const headRotationX = clampedY * 20\n\n      return {\n        x: degToRad(headRotationX),\n        y: degToRad(headRotationY),\n      }\n    },\n    [],\n  )\n\n  const handleLoad = useCallback(\n    (app: any) => {\n      const head = app.findObjectByName(\"Folo Character_V3\")\n\n      if (!head) {\n        console.warn(\"Cannot find Head or Body object\")\n        return\n      }\n\n      headRef.current = head\n\n      const onMove = (e: MouseEvent) => {\n        if (!containerRef.current || !headRef.current) return\n\n        const containerRect = containerRef.current.getBoundingClientRect()\n\n        // Calculate head rotation\n        const headRotation = calculateHeadRotation(e.clientX, e.clientY, containerRect)\n        headRef.current.rotation.x = clamp(headRotation.x, -0.5, 0.5)\n        headRef.current.rotation.y = clamp(headRotation.y, -0.5, 0.5)\n      }\n\n      // Reset to default position when mouse leaves\n      const onMouseLeave = () => {\n        if (!headRef.current) return\n\n        // Smooth transition back to default position\n        const resetAnimation = () => {\n          if (!headRef.current) return\n\n          const currentHeadX = headRef.current.rotation.x\n          const currentHeadY = headRef.current.rotation.y\n\n          // Simple linear interpolation to smoothly return rotation to 0\n          headRef.current.rotation.x = currentHeadX * 0.9\n          headRef.current.rotation.y = currentHeadY * 0.9\n\n          // Continue animation if not fully returned to 0\n          if (Math.abs(currentHeadX) > 0.01 || Math.abs(currentHeadY) > 0.01) {\n            requestAnimationFrame(resetAnimation)\n          } else {\n            // Complete reset to 0\n            headRef.current.rotation.x = 0\n            headRef.current.rotation.y = 0\n          }\n        }\n\n        resetAnimation()\n      }\n\n      const onClick = () => {\n        app.emitEvent(\"mouseDown\", \"Folo Character_V3\")\n      }\n      onClick()\n\n      window.addEventListener(\"pointermove\", onMove)\n      document.addEventListener(\"mouseleave\", onMouseLeave)\n      window.addEventListener(\"click\", onClick)\n\n      return () => {\n        window.removeEventListener(\"pointermove\", onMove)\n        document.removeEventListener(\"mouseleave\", onMouseLeave)\n        window.removeEventListener(\"click\", onClick)\n      }\n    },\n    [calculateHeadRotation],\n  )\n\n  return (\n    <div ref={containerRef} className={cn(\"size-16\", className)}>\n      <Spline scene={resolvedAIIconUrl} onLoad={handleLoad} className=\"size-full\" />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/MentionButton.tsx",
    "content": "import { $createTextNode, $getSelection, $isRangeSelection } from \"lexical\"\nimport { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { AIPanelRefsContext } from \"~/modules/ai-chat/store/AIChatContext\"\n\nimport { MentionDropdown } from \"../../editor/plugins/mention/components/MentionDropdown\"\nimport { useMentionSearchService } from \"../../editor/plugins/mention/hooks/useMentionSearchService\"\nimport { $createMentionNode } from \"../../editor/plugins/mention/MentionNode\"\nimport type { MentionData } from \"../../editor/plugins/mention/types\"\n\n/**\n * Button component that triggers a mention dropdown for manual context selection\n * Allows users to add mentions (@feed, @entry, @date, etc.) to the input field\n */\nexport const MentionButton: Component = memo(() => {\n  const atButtonRef = useRef<HTMLButtonElement>(null)\n  const [isMentionDropdownVisible, setIsMentionDropdownVisible] = useState(false)\n  const [query, setQuery] = useState(\"\")\n  const [suggestions, setSuggestions] = useState<MentionData[]>([])\n  const [selectedIndex, setSelectedIndex] = useState(0)\n  const [isLoading, setIsLoading] = useState(false)\n\n  const aiPanelRefs = use(AIPanelRefsContext)\n  const { searchMentions } = useMentionSearchService()\n\n  const handleAtButtonClick = useCallback(() => {\n    setIsMentionDropdownVisible(true)\n    setQuery(\"\")\n  }, [])\n\n  // Search mentions when query changes\n  useEffect(() => {\n    if (!isMentionDropdownVisible) return\n\n    const performSearch = async () => {\n      setIsLoading(true)\n      try {\n        const results = await searchMentions(query)\n        setSuggestions(results)\n        setSelectedIndex(0)\n      } finally {\n        setIsLoading(false)\n      }\n    }\n\n    void performSearch()\n  }, [query, searchMentions, isMentionDropdownVisible])\n\n  const handleMentionSelect = useCallback(\n    (mention: MentionData) => {\n      // Get the editor from the context\n      const editorRef = aiPanelRefs.inputRef.current\n      if (!editorRef) return\n\n      const editor = editorRef.getEditor()\n      if (!editor) return\n\n      // Insert mention node at current cursor position\n      editor.update(() => {\n        const selection = $getSelection()\n        if (!$isRangeSelection(selection) || !selection.isCollapsed()) return\n\n        // Create and insert mention node\n        const mentionNode = $createMentionNode(mention)\n        selection.insertNodes([mentionNode])\n\n        // Add a space after the mention\n        const spaceNode = $createTextNode(\" \")\n        selection.insertNodes([spaceNode])\n      })\n\n      // Focus back on the editor\n      editor.focus()\n      setIsMentionDropdownVisible(false)\n    },\n    [aiPanelRefs],\n  )\n\n  const handleMentionDropdownClose = useCallback(() => {\n    setIsMentionDropdownVisible(false)\n    setQuery(\"\")\n  }, [])\n\n  // Calculate dropdown props\n  const dropdownProps = useMemo(() => {\n    if (!isMentionDropdownVisible) return null\n\n    return {\n      isVisible: true,\n      suggestions,\n      selectedIndex,\n      isLoading,\n      onSetSelectIndex: setSelectedIndex,\n      onSelect: handleMentionSelect,\n      onClose: handleMentionDropdownClose,\n      query,\n      anchor: atButtonRef.current,\n      showSearchInput: true,\n      onQueryChange: setQuery,\n    }\n  }, [\n    isMentionDropdownVisible,\n    suggestions,\n    selectedIndex,\n    isLoading,\n    handleMentionSelect,\n    handleMentionDropdownClose,\n    query,\n  ])\n\n  return (\n    <>\n      <button\n        ref={atButtonRef}\n        type=\"button\"\n        onClick={handleAtButtonClick}\n        className=\"flex size-7 items-center justify-center rounded-md border border-border bg-material-medium text-text-secondary transition-colors hover:bg-material-thin hover:text-text-secondary\"\n        title=\"Add Context\"\n      >\n        <i className=\"i-mgc-at-cute-re size-3.5\" />\n      </button>\n\n      {dropdownProps ? (\n        <Suspense fallback={null}>\n          <MentionDropdown {...dropdownProps} />\n        </Suspense>\n      ) : null}\n    </>\n  )\n})\n\nMentionButton.displayName = \"MentionButton\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/ContextBlock.tsx",
    "content": "import { getView } from \"@follow/constants\"\nimport { useSubscriptionsByFeedIds } from \"@follow/store/subscription/hooks\"\nimport type { SubscriptionModel } from \"@follow/store/subscription/types\"\nimport { getDefaultCategory } from \"@follow/store/subscription/utils\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport type { FC, ReactNode } from \"react\"\nimport { memo, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport { useContextBlockPresentation } from \"~/modules/ai-chat/components/message/useContextBlockPresentation\"\nimport { useChatBlockActions } from \"~/modules/ai-chat/store/hooks\"\nimport type { AbstractValueContextBlock, AIChatContextBlock } from \"~/modules/ai-chat/store/types\"\n\nimport { FeedTitle } from \"./TitleComponents\"\n\nconst BlockContainer: FC<{\n  icon: string | null | undefined\n  label?: string\n  onRemove?: () => void\n  disabled?: boolean\n  onDisableClick?: () => void\n  content: ReactNode\n  readOnly?: boolean\n  className?: string\n}> = memo(({ icon, label, onRemove, content, disabled, onDisableClick, readOnly, className }) => {\n  const isStringContent = typeof content === \"string\"\n\n  return (\n    <div\n      className={clsx(\n        \"group relative flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2\",\n        \"border border-border bg-fill-quaternary\",\n        disabled && \"cursor-pointer border-dashed italic opacity-50\",\n        className,\n      )}\n      onClick={() => {\n        if (disabled) {\n          onDisableClick?.()\n        }\n      }}\n    >\n      <div\n        className={clsx(\n          \"min-w-0\",\n          !readOnly &&\n            !disabled &&\n            \"group-hover:[mask-image:linear-gradient(to_right,black_0%,black_calc(100%-3rem),rgba(0,0,0,0.8)_calc(100%-2rem),rgba(0,0,0,0.3)_calc(100%-1rem),transparent_100%)]\",\n        )}\n      >\n        <div className=\"flex min-w-0 flex-1 items-center gap-1.5\">\n          <div className=\"flex items-center gap-1\">\n            {icon && <i className={cn(\"size-3.5 flex-shrink-0\", icon)} />}\n            {label && <span className=\"text-xs font-medium text-text-tertiary\">{label}</span>}\n          </div>\n\n          {isStringContent ? (\n            <span className=\"min-w-0 flex-1 truncate text-xs text-text\">{content}</span>\n          ) : (\n            <div className=\"min-w-0 flex-1 truncate text-xs text-text\">{content}</div>\n          )}\n        </div>\n      </div>\n\n      {onRemove && !disabled && !readOnly && (\n        <button\n          type=\"button\"\n          onClick={onRemove}\n          className=\"absolute inset-y-0 right-1 flex-shrink-0 cursor-button text-text/90 opacity-0 transition-all ease-in hover:text-text group-hover:opacity-100\"\n        >\n          <i className=\"i-mgc-close-cute-re size-3\" />\n        </button>\n      )}\n    </div>\n  )\n})\nBlockContainer.displayName = \"ContextBlockContainer\"\n\ntype MainViewBlock = AbstractValueContextBlock<\"mainView\">\ntype MainFeedBlock = AbstractValueContextBlock<\"mainFeed\">\ntype UnreadOnlyBlock = AbstractValueContextBlock<\"unreadOnly\">\n\nexport const CombinedContextBlock: FC<{\n  viewBlock?: MainViewBlock\n  feedBlock?: MainFeedBlock\n  unreadOnlyBlock?: UnreadOnlyBlock\n  readOnly?: boolean\n}> = memo(({ viewBlock, feedBlock, unreadOnlyBlock, readOnly = false }) => {\n  const { t } = useTranslation(\"common\")\n  const blockActions = useChatBlockActions()\n\n  const viewIcon = viewBlock && getView(Number(viewBlock.value))?.icon.props.className\n  const feedIcon = feedBlock && \"i-mgc-rss-cute-fi\"\n\n  const normalizedFeedIds = useMemo(() => {\n    if (!feedBlock?.value) {\n      return []\n    }\n\n    return feedBlock.value\n      .split(\",\")\n      .map((id) => id.trim())\n      .filter((id) => id && !id.startsWith(ROUTE_FEED_IN_FOLDER))\n  }, [feedBlock?.value])\n\n  const feedSubscriptions = useSubscriptionsByFeedIds(normalizedFeedIds)\n\n  const sharedFeedCategory = useMemo(() => {\n    if (normalizedFeedIds.length <= 1) {\n      return null\n    }\n\n    const relevantSubscriptions = feedSubscriptions.filter(\n      (subscription): subscription is SubscriptionModel =>\n        !!subscription && subscription.type === \"feed\" && !!subscription.feedId,\n    )\n\n    if (relevantSubscriptions.length !== normalizedFeedIds.length) {\n      return null\n    }\n\n    const categories = relevantSubscriptions.map((subscription) => {\n      const category = subscription.category || getDefaultCategory(subscription)\n      return category?.trim() || null\n    })\n\n    const firstCategory = categories[0]\n    if (!firstCategory) {\n      return null\n    }\n\n    return categories.every((category) => category === firstCategory) ? firstCategory : null\n  }, [feedSubscriptions, normalizedFeedIds])\n\n  const handleRemove = () => {\n    viewBlock && blockActions.toggleBlockDisabled(viewBlock.id, true)\n    feedBlock && blockActions.toggleBlockDisabled(feedBlock.id, true)\n    unreadOnlyBlock && blockActions.removeBlock(unreadOnlyBlock.id)\n  }\n\n  const handleEnable = () => {\n    viewBlock && blockActions.toggleBlockDisabled(viewBlock.id, false)\n    feedBlock && blockActions.toggleBlockDisabled(feedBlock.id, false)\n  }\n\n  // Determine what to display\n  const displayContent = feedBlock ? (\n    <span className=\"flex items-center gap-1\">\n      {sharedFeedCategory ? (\n        <span className=\"min-w-0 truncate\" title={sharedFeedCategory}>\n          {sharedFeedCategory}\n        </span>\n      ) : (\n        <FeedTitle\n          feedId={feedBlock.value}\n          fallback={feedBlock.value}\n          className=\"min-w-0 truncate\"\n        />\n      )}\n      {unreadOnlyBlock && <i className=\"i-mgc-round-cute-fi size-3 shrink-0\" title=\"Unread Only\" />}\n    </span>\n  ) : (\n    <span className=\"flex items-center gap-1\">\n      {(() => {\n        if (!viewBlock) return null\n        const viewName = getView(Number(viewBlock.value))?.name\n        return viewName ? t(viewName) : viewBlock.value\n      })()}\n      {unreadOnlyBlock && <i className=\"i-mgc-round-cute-fi size-3\" title=\"Unread Only\" />}\n    </span>\n  )\n\n  return (\n    <BlockContainer\n      icon={viewIcon || feedIcon}\n      disabled={viewBlock?.disabled || feedBlock?.disabled || unreadOnlyBlock?.disabled}\n      onRemove={!readOnly ? handleRemove : undefined}\n      onDisableClick={!readOnly ? handleEnable : undefined}\n      content={displayContent}\n      readOnly={readOnly}\n    />\n  )\n})\nCombinedContextBlock.displayName = \"CombinedContextBlock\"\n\nexport const ContextBlock: FC<{ block: AIChatContextBlock; readOnly?: boolean }> = memo(\n  ({ block, readOnly }) => {\n    const blockActions = useChatBlockActions()\n\n    const { icon, label, displayContent } = useContextBlockPresentation(block)\n\n    return (\n      <BlockContainer\n        icon={icon}\n        label={label}\n        disabled={block.disabled}\n        onRemove={() => {\n          if (block.type === \"mainEntry\") {\n            blockActions.toggleBlockDisabled(block.id, true)\n          } else {\n            blockActions.removeBlock(block.id)\n          }\n        }}\n        onDisableClick={() => {\n          if (block.type === \"mainEntry\") {\n            blockActions.toggleBlockDisabled(block.id, false)\n          }\n        }}\n        content={displayContent}\n        readOnly={readOnly}\n      />\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/TitleComponents.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedsByIds } from \"@follow/store/feed/hooks\"\nimport type { FC } from \"react\"\n\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\n\nexport const EntryTitle: FC<{ entryId?: string; fallback: string }> = ({ entryId, fallback }) => {\n  const entryTitle = useEntry(entryId!, (e) => e?.title)\n\n  if (!entryId || !entryTitle) {\n    return <span className=\"text-text-tertiary\">{fallback}</span>\n  }\n\n  return <span>{entryTitle}</span>\n}\n\nexport const FeedTitle: FC<{ feedId?: string; fallback: string; className?: string }> = ({\n  feedId,\n  fallback,\n  className,\n}) => {\n  const category = feedId?.startsWith(ROUTE_FEED_IN_FOLDER)\n    ? feedId.slice(ROUTE_FEED_IN_FOLDER.length)\n    : undefined\n  const finalFeedIds = feedId?.split(\",\").map((id) => id.trim())\n  const feeds = useFeedsByIds(finalFeedIds, (feed) => ({ title: feed?.title }))\n  const feedTitles = feeds.map((feed) => feed.title).join(\", \")\n\n  if (!feedId || !feedTitles) {\n    if (category) {\n      return <span className={className}>{category}</span>\n    }\n\n    return <span className={`text-text-tertiary ${className}`}>{fallback}</span>\n  }\n\n  return <span className={className}>{feedTitles}</span>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/index.ts",
    "content": "export * from \"./ContextBlock\"\nexport * from \"./TitleComponents\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/index.ts",
    "content": "export * from \"./blocks\"\nexport * from \"./menus\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/menus/ShortcutsMenuContent.tsx",
    "content": "import type { AIShortcut } from \"@follow/shared/settings/interface\"\nimport type { FC } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { getShortcutEffectivePrompt } from \"~/atoms/settings/ai\"\nimport {\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\n\ninterface ShortcutsMenuContentProps {\n  shortcuts: AIShortcut[]\n  context: \"list\" | \"entry\"\n  onSendShortcut?: (prompt: string) => void\n}\n\nexport const ShortcutsMenuContent: FC<ShortcutsMenuContentProps> = ({\n  shortcuts,\n  context,\n  onSendShortcut,\n}) => {\n  const showSettingModal = useSettingModal()\n  const { t } = useTranslation(\"ai\")\n  const enabledShortcuts = shortcuts.filter((shortcut) => shortcut.enabled)\n  const emptyMessage =\n    context === \"entry\"\n      ? t(\"shortcuts.context_menu.empty.entry\")\n      : t(\"shortcuts.context_menu.empty.list\")\n\n  return (\n    <DropdownMenuContent align=\"start\">\n      {enabledShortcuts.length === 0 ? (\n        <div className=\"p-3 text-center text-xs text-text-tertiary\">{emptyMessage}</div>\n      ) : (\n        enabledShortcuts.map((shortcut) => (\n          <DropdownMenuItem\n            key={shortcut.id}\n            onClick={() => onSendShortcut?.(getShortcutEffectivePrompt(shortcut))}\n          >\n            <i className=\"i-mgc-magic-2-cute-re mr-1.5 size-3.5\" />\n            <span className=\"truncate\">{shortcut.name}</span>\n          </DropdownMenuItem>\n        ))\n      )}\n      <DropdownMenuSeparator />\n      <DropdownMenuItem\n        onClick={() => {\n          showSettingModal(\"ai\")\n        }}\n      >\n        <i className=\"i-mgc-settings-7-cute-re mr-1.5 size-3.5\" />\n        <span>{t(\"shortcuts.manage\")}</span>\n      </DropdownMenuItem>\n    </DropdownMenuContent>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/menus/index.ts",
    "content": "export * from \"./ShortcutsMenuContent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIChainOfThought.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport type { CollapseCssRef } from \"@follow/components/ui/collapse/CollapseCss.js\"\nimport { CollapseCss, CollapseCssGroup } from \"@follow/components/ui/collapse/CollapseCss.js\"\nimport { ShinyText } from \"@follow/components/ui/shiny-text/ShinyText.js\"\nimport { cn } from \"@follow/utils\"\nimport type { BizUITools } from \"@folo-services/ai-tools\"\nimport type { ReasoningUIPart, ToolUIPart } from \"ai\"\nimport { isStaticToolUIPart } from \"ai\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { ToolInvocationComponent } from \"../message/ToolInvocationComponent\"\nimport { AIReasoningPart } from \"./AIReasoningPart\"\n\nexport type ChainReasoningPart = ReasoningUIPart | ToolUIPart<BizUITools>\ninterface AIChainOfThoughtProps {\n  groups: ReadonlyArray<ChainReasoningPart>\n  isStreaming?: boolean\n  className?: string\n}\nexport const AIChainOfThought: React.FC<AIChainOfThoughtProps> = React.memo(\n  ({ groups, isStreaming, className }) => {\n    const collapseId = React.useId()\n\n    const collapseRef = React.useRef<CollapseCssRef>(null)\n\n    const currentChainReasoningIsFinished = React.useMemo(() => {\n      let allDone = true\n      for (const part of groups) {\n        if (isStaticToolUIPart(part)) {\n          continue\n        }\n        if (part.state !== \"done\") {\n          allDone = false\n          break\n        }\n      }\n\n      return allDone\n    }, [groups])\n    const currentReasoningTitle = React.useMemo(() => {\n      if (!isStreaming) return null\n\n      const lastPart = groups.at?.(-1)\n\n      if (!lastPart) return null\n\n      if (isStaticToolUIPart(lastPart)) {\n        return `Calling [${lastPart.type.replace(\"tool-\", \"\")}]`\n      }\n\n      const lastPartText = lastPart.text\n      return extractHeading(lastPartText)\n    }, [groups, isStreaming])\n\n    React.useEffect(() => {\n      if (currentChainReasoningIsFinished) collapseRef.current?.setIsOpened(false)\n    }, [collapseRef, currentChainReasoningIsFinished])\n\n    if (!groups || groups.length === 0) return null\n\n    return (\n      <div\n        className={cn(\n          \"w-[calc(var(--ai-chat-message-container-width,65ch))] min-w-0 border-border text-left\",\n          className,\n        )}\n      >\n        <CollapseCssGroup>\n          <CollapseCss\n            ref={collapseRef}\n            hideArrow\n            collapseId={collapseId}\n            defaultOpen={!currentChainReasoningIsFinished}\n            title={\n              <div className=\"group flex h-6 w-[calc(var(--ai-chat-message-container-width,65ch))] min-w-0 flex-1 items-center py-0\">\n                <div className=\"flex items-center gap-2 text-xs\">\n                  <span className=\"text-text-secondary\">\n                    {!currentChainReasoningIsFinished ? (\n                      <span className=\"flex items-center gap-2\">\n                        Thinking:{\" \"}\n                        <span className=\"min-w-0 truncate\">\n                          <AnimatePresence initial={false} mode=\"popLayout\">\n                            <m.span\n                              key={currentReasoningTitle ?? \"empty\"}\n                              initial={{ opacity: 0, y: 6, filter: \"blur(4px)\" }}\n                              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                              exit={{ opacity: 0, y: -6, filter: \"blur(4px)\" }}\n                              transition={Spring.presets.smooth}\n                              className=\"inline-block\"\n                            >\n                              <ShinyText className=\"font-medium\">\n                                {currentReasoningTitle ?? \"\"}\n                              </ShinyText>\n                            </m.span>\n                          </AnimatePresence>\n                        </span>\n                      </span>\n                    ) : (\n                      \"Finished Thinking\"\n                    )}\n                  </span>\n                </div>\n                <div className=\"ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100\">\n                  <i className=\"i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90\" />\n                </div>\n              </div>\n            }\n            className=\"group w-full border-none\"\n            contentClassName=\"pb-2 pt-1\"\n          >\n            <div className=\"relative\">\n              <div aria-hidden className=\"absolute inset-y-2 left-2 border-l border-fill\" />\n              {groups.map((part, index) => {\n                const innerCollapseId = `${collapseId}-${index}`\n                if (isStaticToolUIPart(part)) {\n                  return (\n                    <ToolInvocationComponent variant=\"loose\" key={innerCollapseId} part={part} />\n                  )\n                }\n                const mergedText = part.text\n\n                const title = extractHeading(part.text)\n                const groupStreaming = part.state === \"streaming\"\n\n                return (\n                  <div key={innerCollapseId} className=\"relative pb-3 pl-8 last:pb-0\">\n                    <div aria-hidden className={\"absolute left-2 top-2 size-2 -translate-x-1/2\"}>\n                      <i className=\"i-mgc-brain-cute-re absolute top-1/2 -translate-x-1/4 -translate-y-1/2\" />\n                    </div>\n\n                    <AIInnerReasoningPart\n                      title={title}\n                      text={mergedText}\n                      groupStreaming={groupStreaming}\n                    />\n                  </div>\n                )\n              })}\n            </div>\n          </CollapseCss>\n        </CollapseCssGroup>\n      </div>\n    )\n  },\n)\n\nconst AIInnerReasoningPart: React.FC<{\n  title: string | undefined\n  text: string\n  groupStreaming: boolean\n}> = React.memo(({ title, text, groupStreaming }) => {\n  const id = React.useId()\n  const collapseRef = React.useRef<CollapseCssRef>(null)\n\n  React.useEffect(() => {\n    collapseRef.current?.setIsOpened(groupStreaming)\n  }, [groupStreaming, collapseRef])\n\n  return (\n    <CollapseCss\n      ref={collapseRef}\n      hideArrow\n      collapseId={id}\n      defaultOpen\n      title={\n        <div className=\"group/inner flex h-6 min-w-0 flex-1 items-center py-0\">\n          <div className=\"flex items-center gap-2 text-xs text-text-secondary\">\n            {title && !groupStreaming ? (\n              <span className=\"truncate\">\n                {\"Reason: \"}\n                <span className=\"font-medium text-text\">{title}</span>\n              </span>\n            ) : (\n              <span>{groupStreaming ? \"Reasoning...\" : \"Reasoning\"}</span>\n            )}\n          </div>\n          <div className=\"ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover/inner:opacity-100\">\n            <i className=\"i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]/inner:rotate-90\" />\n          </div>\n        </div>\n      }\n      className=\"group/inner w-full border-none\"\n    >\n      <AIReasoningPart text={text} isStreaming={groupStreaming} />\n    </CollapseCss>\n  )\n})\n\nAIChainOfThought.displayName = \"AIChainOfThought\"\n\nconst extractHeading = (text?: string): string | undefined => {\n  if (!text) return\n  const lines = text.split(/\\r?\\n/)\n  let lastHeading: string | undefined\n  for (const raw of lines) {\n    const line = raw.trim()\n    if (!line) continue\n    if (line.startsWith(\"#\")) {\n      let idx = 0\n      while (idx < line.length && line.charAt(idx) === \"#\") idx++\n      let content = line.slice(idx).trim()\n      while (content.endsWith(\"#\")) content = content.slice(0, -1).trim()\n      if (content) lastHeading = content\n      continue\n    }\n    if (line.startsWith(\"**\") && line.endsWith(\"**\") && line.length > 4) {\n      const content = line.slice(2, -2).trim()\n      if (content) lastHeading = content\n      continue\n    }\n  }\n  return lastHeading\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIDisplayFlowPart.tsx",
    "content": "import \"@xyflow/react/dist/style.css\"\n\nimport { useIsDark } from \"@follow/hooks\"\nimport { thenable } from \"@follow/utils\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ToolWithState } from \"@folo-services/ai-tools\"\nimport { Background, Controls, ReactFlow } from \"@xyflow/react\"\nimport { useCallback } from \"react\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { toolMemo } from \"./share\"\n\nconst FlowPreviewModal = ({\n  nodes,\n  edges,\n  colorMode,\n}: {\n  nodes: any[]\n  edges: any[]\n  colorMode: \"light\" | \"dark\"\n}) => {\n  return (\n    <div className=\"flex size-full flex-col\">\n      <ReactFlow\n        colorMode={colorMode}\n        nodes={nodes}\n        edges={edges}\n        fitView\n        nodesDraggable={true}\n        nodesConnectable={false}\n        nodesFocusable={true}\n        edgesFocusable={true}\n        elementsSelectable={true}\n        preventScrolling={false}\n        className=\"size-full\"\n      >\n        <Background />\n        <Controls />\n      </ReactFlow>\n    </div>\n  )\n}\n\nexport const AIDisplayFlowPart = toolMemo(({ part }: { part: ToolWithState<any> }) => {\n  if (!part.input) throw thenable\n  if (part.state === \"input-streaming\") {\n    throw thenable\n  }\n\n  // `part.input.flowChart` to make compatible with old data\n  const { nodes, edges } = part.input.flowChart || (part.input.schema.flowChart as any)\n  const colorMode = useIsDark() ? \"dark\" : \"light\"\n  const { present } = useModalStack()\n\n  const handleOpenModal = useCallback(() => {\n    present({\n      title: \"Flow Chart Preview\",\n      content: () => <FlowPreviewModal nodes={nodes} edges={edges} colorMode={colorMode} />,\n      max: true,\n      canClose: true,\n      clickOutsideToDismiss: false,\n      modalContentClassName: \"p-0 h-full\",\n      modalClassName: \"h-[90vh] w-[90vw]\",\n    })\n  }, [nodes, edges, colorMode, present])\n\n  return (\n    <div className=\"group relative my-2 aspect-[4/3] w-[calc(var(--ai-chat-message-container-width,65ch))] max-w-full overflow-hidden rounded-md\">\n      <ReactFlow\n        colorMode={colorMode}\n        nodes={nodes}\n        edges={edges}\n        fitView\n        nodesDraggable={false}\n        nodesConnectable={false}\n        nodesFocusable={false}\n        edgesFocusable={false}\n        elementsSelectable={false}\n        preventScrolling={false}\n      >\n        <Background />\n        <Controls />\n      </ReactFlow>\n\n      {/* Expand/Preview button */}\n      <button\n        type=\"button\"\n        onClick={handleOpenModal}\n        className={cn(\n          \"absolute right-2 top-2 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5\",\n          \"bg-material-thick text-sm font-medium text-text-secondary\",\n          \"opacity-0 transition-all duration-200 group-hover:opacity-100\",\n          \"hover:bg-material-medium hover:text-text focus:opacity-100\",\n          \"focus:outline-none focus:ring-2 focus:ring-blue focus:ring-offset-1\",\n        )}\n        title=\"Open in full screen\"\n      >\n        <i className=\"i-mgc-external-link-cute-re size-4\" />\n        <span>Preview</span>\n      </button>\n    </div>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIReasoningPart.tsx",
    "content": "import { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { cn } from \"@follow/utils\"\nimport * as React from \"react\"\nimport { useRef } from \"react\"\n\nimport { useAutoScroll } from \"../../hooks/useAutoScroll\"\n\ninterface AIReasoningPartProps {\n  text: string\n  isStreaming?: boolean\n  className?: string\n}\n\nexport const AIReasoningPart: React.FC<AIReasoningPartProps> = React.memo(\n  ({ text, className, isStreaming }) => {\n    const scrollAreaRef = useRef<HTMLDivElement | null>(null)\n    useAutoScroll(scrollAreaRef.current, !!isStreaming)\n\n    if (!text) return null\n\n    return (\n      <div className={cn(\"min-w-0 max-w-full text-left\", className)}>\n        <div className=\"w-[calc(var(--ai-chat-message-container-width,65ch))] max-w-full\" />\n        <div className=\"text-xs\">\n          <ScrollArea mask viewportClassName=\"max-h-[30vh]\" ref={scrollAreaRef}>\n            <pre className=\"overflow-x-auto whitespace-pre-wrap rounded bg-material-medium p-3 text-[11px] leading-relaxed text-text-secondary\">\n              {text}\n            </pre>\n          </ScrollArea>\n        </div>\n      </div>\n    )\n  },\n)\n\nAIReasoningPart.displayName = \"AIReasoningPart\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/index.ts",
    "content": "export { AIChainOfThought } from \"./AIChainOfThought\"\nexport { AIReasoningPart } from \"./AIReasoningPart\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/share.tsx",
    "content": "import type { ComponentType } from \"react\"\nimport { memo } from \"react\"\nimport isEqual from \"react-fast-compare\"\n\nimport { ErrorState, LoadingState } from \"../shared/common-states\"\n\ninterface PartWithState {\n  part: {\n    state: string\n  }\n}\n\nexport const toolMemo = <P extends PartWithState>(FC: ComponentType<P>): ComponentType<P> =>\n  memo(FC, (prev, next) => {\n    if (prev.part.state === \"output-available\") return true\n    return isEqual(prev, next)\n  }) as ComponentType<P>\n\n// Higher-Order Component for display state handling\nexport function withDisplayStateHandler<T>(config: {\n  title: string\n  loadingDescription: string\n  errorTitle: string\n  maxWidth?: string\n}) {\n  return function <P extends { part: { state: string; output?: T; input?: any } }>(\n    WrappedComponent: ComponentType<P & { output: NonNullable<T>; input: any }>,\n  ): ComponentType<P> {\n    const WithDisplayStateHandler = toolMemo((props: P) => {\n      const { part } = props\n\n      // Handle error state\n      if (part.state === \"output-error\") {\n        return (\n          <ErrorState error={`An error occurred while loading ${config.title.toLowerCase()}`} />\n        )\n      }\n\n      // Handle loading/invalid state\n      if (part.state !== \"output-available\" || !part.output) {\n        return <LoadingState description={config.loadingDescription} />\n      }\n\n      // Render the wrapped component with the validated output\n      return (\n        <WrappedComponent {...props} output={part.output as NonNullable<T>} input={part.input} />\n      )\n    })\n\n    WithDisplayStateHandler.displayName = `withDisplayStateHandler(${WrappedComponent.displayName || WrappedComponent.name})`\n\n    return WithDisplayStateHandler\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/AnalyticsMetrics.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nexport interface AnalyticsMetric {\n  label: string\n  value: string | number\n}\n\nexport interface AnalyticsMetricsProps {\n  metrics: AnalyticsMetric[]\n  className?: string\n}\n\nexport const AnalyticsMetrics = ({ metrics, className }: AnalyticsMetricsProps) => (\n  <ul className={cn(\"space-y-1 text-xs\", className)}>\n    {metrics.map((metric, index) => (\n      <li key={index} className=\"flex items-center justify-between\">\n        <span className=\"text-text-tertiary\">{metric.label}</span>\n        <span className=\"font-medium text-text\">{metric.value}</span>\n      </li>\n    ))}\n  </ul>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/CategoryTag.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nexport interface CategoryTagProps {\n  category: string\n  variant?: \"default\" | \"small\"\n  className?: string\n}\n\nexport const CategoryTag = ({ category, variant = \"default\", className }: CategoryTagProps) => (\n  <span\n    className={cn(\n      \"rounded bg-fill-tertiary px-2 py-1 text-xs text-text-secondary\",\n      variant === \"small\" && \"px-1 py-0.5 text-xs\",\n      className,\n    )}\n  >\n    {category}\n  </span>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/DisplayHeader.tsx",
    "content": "import { CardDescription, CardHeader, CardTitle } from \"@follow/components/ui/card/index.js\"\nimport type { ReactNode } from \"react\"\n\nexport interface DisplayHeaderProps {\n  title: string\n  emoji: string\n  description?: string\n  children?: ReactNode\n}\n\nexport const DisplayHeader = ({ title, emoji, description, children }: DisplayHeaderProps) => (\n  <CardHeader>\n    <CardTitle className=\"flex items-center gap-2 text-xl font-semibold text-text\">\n      <span className=\"text-lg\">{emoji}</span>\n      <span>{title}</span>\n    </CardTitle>\n    {description && <CardDescription>{description}</CardDescription>}\n    {children}\n  </CardHeader>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/EmptyState.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nexport interface EmptyStateProps {\n  message: string\n  icon?: string\n  className?: string\n}\n\nexport const EmptyState = ({ message, icon, className }: EmptyStateProps) => (\n  <div className={cn(\"text-center text-sm text-text-secondary\", className)}>\n    {icon && <span className=\"text-lg\">{icon}</span>}\n    {message}\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/FeedItemCard.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@follow/components/ui/card/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ReactNode } from \"react\"\n\nexport interface FeedItemCardProps {\n  title: string\n  description?: string\n  icon?: ReactNode\n  headerHeight?: string\n  className?: string\n  children?: ReactNode\n}\n\nexport const FeedItemCard = ({\n  title,\n  description,\n  icon,\n  headerHeight = \"h-24\",\n  className,\n  children,\n}: FeedItemCardProps) => (\n  <Card className={cn(\"p-4\", className)}>\n    <CardHeader className={cn(\"px-2 py-3\", headerHeight)}>\n      <div className=\"flex items-start gap-3\">\n        {icon && <div className=\"shrink-0\">{icon}</div>}\n        <div className=\"-mt-1 min-w-0 flex-1\">\n          <CardTitle className=\"line-clamp-2 text-base\">{title}</CardTitle>\n          {description && (\n            <CardDescription className=\"mt-1 line-clamp-2 text-xs\">{description}</CardDescription>\n          )}\n        </div>\n      </div>\n    </CardHeader>\n    {children && <CardContent className=\"space-y-3 p-0 px-2 pb-3\">{children}</CardContent>}\n  </Card>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/GroupedContent.tsx",
    "content": "import type { ReactNode } from \"react\"\n\nexport interface GroupedContentProps<T> {\n  data: T[]\n  groupBy: string\n  groupKeyExtractor: (item: T) => string\n  renderGroup: (groupData: T[], groupName: string) => ReactNode\n  sortGroups?: (a: string, b: string) => number\n  className?: string\n}\n\nexport const GroupedContent = <T,>({\n  data,\n  groupBy,\n  groupKeyExtractor,\n  renderGroup,\n  sortGroups,\n  className,\n}: GroupedContentProps<T>) => {\n  if (!data?.length || groupBy === \"none\") {\n    return null\n  }\n\n  const groups = data.reduce(\n    (acc, item) => {\n      const key = groupKeyExtractor(item)\n      if (!acc[key]) acc[key] = []\n      acc[key]?.push(item)\n      return acc\n    },\n    {} as Record<string, T[]>,\n  )\n\n  const sortedEntries = Object.entries(groups).sort(([a], [b]) => {\n    if (sortGroups) {\n      return sortGroups(a, b)\n    }\n    return a.localeCompare(b)\n  })\n\n  return (\n    <div className={className}>\n      <div className=\"space-y-6\">\n        {sortedEntries.map(([groupName, groupData]) => (\n          <div key={groupName}>\n            <h3 className=\"mb-4 text-lg font-semibold text-text\">{groupName}</h3>\n            {renderGroup(groupData, groupName)}\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/StatCard.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@follow/components/ui/card/index.js\"\n\nexport interface StatCardProps {\n  title: string\n  value: string | number\n  description?: string\n  emoji?: string\n}\n\nexport const StatCard = ({ title, value, description, emoji }: StatCardProps) => (\n  <Card className=\"p-4\">\n    <CardHeader className=\"p-2 pt-0\">\n      <CardTitle className=\"flex items-center gap-2 text-sm font-medium text-text-secondary\">\n        {emoji && <span className=\"text-base\">{emoji}</span>}\n        {title}\n      </CardTitle>\n    </CardHeader>\n    <CardContent className=\"px-2 py-0\">\n      <div className=\"text-2xl font-bold text-text\">{value}</div>\n      {description && <CardDescription className=\"mt-1 text-xs\">{description}</CardDescription>}\n    </CardContent>\n  </Card>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/shared/index.ts",
    "content": "export * from \"./AnalyticsMetrics\"\nexport * from \"./CategoryTag\"\nexport * from \"./DisplayHeader\"\nexport * from \"./EmptyState\"\nexport * from \"./FeedItemCard\"\nexport * from \"./GroupedContent\"\nexport * from \"./StatCard\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/file/GlobalFileDropZone.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn } from \"@follow/utils\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { memo, useCallback, useRef, useState } from \"react\"\n\nimport { useFileUploadWithDefaults } from \"../../hooks/useFileUpload\"\n\ninterface GlobalFileDropZoneProps extends PropsWithChildren {\n  className?: string\n}\n\nexport const GlobalFileDropZone: FC<GlobalFileDropZoneProps> = memo(({ children, className }) => {\n  const { handleFileDrop } = useFileUploadWithDefaults()\n  const [isDragOver, setIsDragOver] = useState(false)\n  const [isProcessing, setIsProcessing] = useState(false)\n  const dragCounterRef = useRef(0)\n\n  const handleDragEnter = useCallback((e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n\n    dragCounterRef.current += 1\n\n    if (e.dataTransfer.types.includes(\"Files\")) {\n      setIsDragOver(true)\n    }\n  }, [])\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n\n    dragCounterRef.current -= 1\n\n    if (dragCounterRef.current === 0) {\n      setIsDragOver(false)\n    }\n  }, [])\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n  }, [])\n\n  const handleDrop = useCallback(\n    async (e: React.DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n\n      dragCounterRef.current = 0\n      setIsDragOver(false)\n\n      const { files } = e.dataTransfer\n      if (!files || files.length === 0) return\n\n      setIsProcessing(true)\n\n      try {\n        await handleFileDrop(files)\n      } catch (error) {\n        console.error(\"Error processing files:\", error)\n      } finally {\n        setIsProcessing(false)\n      }\n    },\n    [handleFileDrop],\n  )\n\n  return (\n    <div\n      className={cn(\"relative size-full\", className)}\n      onDragEnter={handleDragEnter}\n      onDragLeave={handleDragLeave}\n      onDragOver={handleDragOver}\n      onDrop={handleDrop}\n    >\n      {children}\n\n      {/* Global Drag Overlay */}\n      <AnimatePresence>\n        {isDragOver && (\n          <m.div className=\"pointer-events-none absolute inset-0 z-50 flex items-center justify-center\">\n            {/* Glass morphism backdrop */}\n            <m.div\n              className=\"absolute inset-0 bg-material-thin/80 backdrop-blur-xl\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={Spring.presets.smooth}\n            />\n\n            {/* Content */}\n            <m.div\n              initial={{ scale: 0.9, y: 20, opacity: 0 }}\n              animate={{ scale: 1, y: 0, opacity: 1 }}\n              exit={{ scale: 0.9, y: 20, opacity: 0 }}\n              transition={Spring.presets.snappy}\n              className=\"relative flex max-w-md flex-col items-center gap-4 rounded-2xl border border-accent/20 bg-background/95 p-8 shadow-2xl shadow-accent/10\"\n            >\n              {isProcessing ? (\n                <>\n                  <div className=\"size-12 animate-spin rounded-full border-4 border-accent border-t-transparent\" />\n                  <div className=\"text-center\">\n                    <p className=\"text-lg font-medium text-text\">Processing files...</p>\n                    <p className=\"text-sm text-text-secondary\">\n                      Please wait while we process your files\n                    </p>\n                  </div>\n                </>\n              ) : (\n                <>\n                  <div className=\"relative text-accent\">\n                    <i className=\"i-mgc-file-upload-cute-re size-16\" />\n                    <m.div\n                      className=\"absolute inset-0 text-accent blur-lg\"\n                      animate={{\n                        scale: [1, 1.1, 1],\n                        opacity: [0.5, 1, 0.5],\n                      }}\n                      transition={{\n                        duration: 2,\n                        repeat: Number.POSITIVE_INFINITY,\n                        ease: \"easeInOut\",\n                      }}\n                    >\n                      <i className=\"i-mgc-file-upload-cute-re size-16\" />\n                    </m.div>\n                  </div>\n                  <div className=\"text-center\">\n                    <p className=\"text-lg font-medium text-text\">Drop files to attach</p>\n                    <p className=\"text-sm text-text-secondary\">\n                      Images, PDFs, text files, and audio files are supported\n                    </p>\n                  </div>\n                </>\n              )}\n            </m.div>\n          </m.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n})\n\nGlobalFileDropZone.displayName = \"GlobalFileDropZone\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatContextBar.tsx",
    "content": "import { Popover, PopoverContent, PopoverTrigger } from \"@follow/components/ui/popover/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport { memo, useCallback, useEffect, useMemo, useRef } from \"react\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useDisplayBlocks } from \"~/modules/ai-chat/hooks/useDisplayBlocks\"\nimport { useFileUploadWithDefaults } from \"~/modules/ai-chat/hooks/useFileUpload\"\nimport { useAIChatStore } from \"~/modules/ai-chat/store/AIChatContext\"\nimport { SUPPORTED_MIME_ACCEPT } from \"~/modules/ai-chat/utils/file-validation\"\n\nimport { useBlockActions } from \"../../store/hooks\"\nimport { BlockSliceAction } from \"../../store/slices/block.slice\"\nimport { CombinedContextBlock, ContextBlock } from \"../context-bar/blocks\"\nimport { MentionButton } from \"../context-bar/MentionButton\"\n\n// Maximum number of context blocks to show before collapsing into \"more\" popover\nconst MAX_VISIBLE_BLOCKS = 4\n\nexport const AIChatContextBar: Component = memo(({ className }) => {\n  const blocks = useAIChatStore()((s) => s.blocks)\n  const fileInputRef = useRef<HTMLInputElement>(null)\n  const { handleFileInputChange } = useFileUploadWithDefaults()\n\n  const handleAttachFile = useCallback(() => {\n    fileInputRef.current?.click()\n  }, [])\n\n  const { addOrUpdateBlock, removeBlock } = useBlockActions()\n\n  const view = useRouteParamsSelector((i) => {\n    if (!i.isPendingEntry) return\n    return i.view\n  })\n  const feedId = useRouteParamsSelector((i) => {\n    if (i.isAllFeeds || !i.isPendingEntry) return\n    return i.feedId\n  })\n  useEffect(() => {\n    if (typeof view === \"number\") {\n      addOrUpdateBlock({\n        id: BlockSliceAction.SPECIAL_TYPES.mainView,\n        type: \"mainView\",\n        value: `${view}`,\n      })\n    } else {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.mainView)\n    }\n\n    return () => {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.mainView)\n    }\n  }, [addOrUpdateBlock, view, removeBlock])\n\n  useEffect(() => {\n    if (feedId) {\n      addOrUpdateBlock({\n        id: BlockSliceAction.SPECIAL_TYPES.mainFeed,\n        type: \"mainFeed\",\n        value: feedId,\n      })\n    } else {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.mainFeed)\n    }\n    return () => {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.mainFeed)\n    }\n  }, [addOrUpdateBlock, feedId, removeBlock])\n\n  // Add unreadOnly context block only when unreadOnly is enabled\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  useEffect(() => {\n    if (unreadOnly) {\n      addOrUpdateBlock({\n        id: BlockSliceAction.SPECIAL_TYPES.unreadOnly,\n        type: \"unreadOnly\",\n        value: \"true\",\n      })\n    } else {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.unreadOnly)\n    }\n\n    return () => {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.unreadOnly)\n    }\n  }, [addOrUpdateBlock, unreadOnly, removeBlock])\n\n  const displayBlocks = useDisplayBlocks(blocks)\n\n  // Split blocks into visible and hidden based on MAX_VISIBLE_BLOCKS\n  const { visibleBlocks, hiddenBlocks } = useMemo(() => {\n    if (displayBlocks.length <= MAX_VISIBLE_BLOCKS) {\n      return { visibleBlocks: displayBlocks, hiddenBlocks: [] }\n    }\n    return {\n      visibleBlocks: displayBlocks.slice(0, MAX_VISIBLE_BLOCKS),\n      hiddenBlocks: displayBlocks.slice(MAX_VISIBLE_BLOCKS),\n    }\n  }, [displayBlocks])\n\n  const renderBlock = useCallback((item: (typeof displayBlocks)[number]) => {\n    if (item.kind === \"combined\") {\n      return (\n        <CombinedContextBlock\n          key={`combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`}\n          viewBlock={item.viewBlock}\n          feedBlock={item.feedBlock}\n          unreadOnlyBlock={item.unreadOnlyBlock}\n        />\n      )\n    }\n\n    return <ContextBlock key={item.block.id} block={item.block} />\n  }, [])\n\n  return (\n    <div className={cn(\"flex items-center gap-2 px-4 py-3\", className)}>\n      <MentionButton />\n\n      {/* File Upload Button */}\n      <button\n        type=\"button\"\n        onClick={handleAttachFile}\n        className=\"flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-material-medium text-text-secondary transition-colors hover:bg-material-thin hover:text-text-secondary\"\n        title=\"Upload Files\"\n      >\n        <i className=\"i-mgc-attachment-cute-re size-3.5\" />\n      </button>\n\n      {/* Hidden File Input */}\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        multiple\n        accept={SUPPORTED_MIME_ACCEPT}\n        onChange={handleFileInputChange}\n        className=\"hidden\"\n      />\n\n      {/* Visible Context Blocks */}\n      <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n        {visibleBlocks.map((item) => (\n          <div\n            key={\n              item.kind === \"combined\"\n                ? `combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`\n                : item.block.id\n            }\n            className=\"max-w-[min(240px,100%)] shrink-0\"\n          >\n            {renderBlock(item)}\n          </div>\n        ))}\n\n        {/* More Button with Popover */}\n        {hiddenBlocks.length > 0 && (\n          <Popover>\n            <PopoverTrigger asChild>\n              <button\n                type=\"button\"\n                className=\"flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-border bg-material-medium px-2.5 text-xs text-text-secondary transition-colors hover:bg-fill-secondary hover:text-text\"\n              >\n                <i className=\"i-mgc-more-1-cute-re size-3.5\" />\n                <span>+{hiddenBlocks.length}</span>\n              </button>\n            </PopoverTrigger>\n            <PopoverContent className=\"w-80 p-3\" align=\"start\">\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"mb-1 text-xs font-medium text-text-secondary\">\n                  Additional Context\n                </div>\n                {hiddenBlocks.map((item) => renderBlock(item))}\n              </div>\n            </PopoverContent>\n          </Popover>\n        )}\n      </div>\n    </div>\n  )\n})\nAIChatContextBar.displayName = \"AIChatContextBar\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatRoot.tsx",
    "content": "import type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/types.js\"\nimport type { IdGenerator } from \"ai\"\nimport { atom } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useEffect, useMemo, useRef } from \"react\"\n\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { HotkeyScope } from \"~/constants\"\n\nimport { useAIShortcut } from \"../../hooks/useAIShortcut\"\nimport type { AIPanelRefs } from \"../../store/AIChatContext\"\nimport {\n  AIChatStoreContext,\n  AIPanelRefsContext,\n  AIRootStateContext,\n} from \"../../store/AIChatContext\"\nimport { ChatSliceActions } from \"../../store/chat-core/chat-actions\"\nimport { useChatActions, useCurrentChatId } from \"../../store/hooks\"\nimport { createAIChatStore } from \"../../store/store\"\n\ninterface AIChatRootProps extends PropsWithChildren {\n  wrapFocusable?: boolean\n  chatId?: string\n  generateId?: IdGenerator\n}\n\nconst AIChatRootInner: FC<AIChatRootProps> = ({ children, chatId: externalChatId }) => {\n  const currentChatId = useCurrentChatId()\n\n  const chatActions = useChatActions()\n\n  useMemo(() => {\n    if (!currentChatId && !externalChatId) {\n      chatActions.newChat()\n    }\n  }, [currentChatId, externalChatId, chatActions])\n\n  const inputRef = useRef<LexicalRichEditorRef>(null!)\n  const refsContext = useMemo<AIPanelRefs>(() => ({ inputRef }), [inputRef])\n  useAIShortcut()\n\n  if (!currentChatId) {\n    return (\n      <div className=\"flex size-full items-center justify-center bg-background\">\n        <div className=\"flex items-center gap-2\">\n          <i className=\"i-mgc-loading-3-cute-re size-6 animate-spin text-text\" />\n          <span className=\"text-text-secondary\">Initializing chat...</span>\n        </div>\n      </div>\n    )\n  }\n\n  return <AIPanelRefsContext value={refsContext}>{children}</AIPanelRefsContext>\n}\n\nexport const AIChatRoot: FC<AIChatRootProps> = ({\n  children,\n  wrapFocusable = true,\n  chatId: externalChatId,\n  generateId,\n}) => {\n  const stableGenerateIdFn = useRef(generateId)\n  stableGenerateIdFn.current = generateId\n\n  const useAiContextStore = useMemo(\n    () => createAIChatStore({ chatId: externalChatId, generateId: stableGenerateIdFn.current }),\n    [externalChatId],\n  )\n  const chatActions = useAiContextStore((state) => state.chatActions)\n\n  useEffect(() => {\n    ChatSliceActions.setActiveInstance(chatActions)\n  }, [chatActions])\n\n  const Element = (\n    <AIChatStoreContext value={useAiContextStore}>\n      <AIRootStateContext\n        value={useMemo(\n          () => ({\n            isScrolledBeyondThreshold: atom(false),\n          }),\n          [],\n        )}\n      >\n        <AIChatRootInner chatId={externalChatId}>{children}</AIChatRootInner>\n      </AIRootStateContext>\n    </AIChatStoreContext>\n  )\n\n  if (wrapFocusable) {\n    return (\n      <Focusable scope={HotkeyScope.AIChat} className=\"size-full\">\n        {Element}\n      </Focusable>\n    )\n  }\n  return Element\n}\nAIChatRoot.displayName = \"AIChatRoot\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatSendButton.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { cn } from \"@follow/utils\"\nimport type { FC } from \"react\"\n\ninterface AIChatSendButtonProps {\n  onClick: () => void\n  disabled?: boolean\n  isProcessing?: boolean\n  className?: string\n  size?: \"sm\" | \"md\"\n}\n\nexport const AIChatSendButton: FC<AIChatSendButtonProps> = ({\n  onClick,\n  disabled = false,\n  isProcessing = false,\n  className,\n}) => {\n  return (\n    <Button\n      onClick={onClick}\n      disabled={disabled}\n      buttonClassName={cn(\n        \"size-8 rounded-xl p-0 transition-all duration-300 active:scale-95\",\n        isProcessing\n          ? \"bg-red-500/90 hover:bg-red-500 shadow-lg shadow-red-500/25 backdrop-blur-sm\"\n          : disabled\n            ? \"bg-gray-200/80 cursor-not-allowed backdrop-blur-sm\"\n            : \"bg-gradient-to-r from-accent to-accent/90 hover:from-accent hover:to-accent/90 shadow-lg shadow-accent/25 backdrop-blur-sm hover:shadow-accent/35\",\n        className,\n      )}\n    >\n      {isProcessing ? (\n        <i className=\"i-mgc-stop-circle-cute-fi size-4 text-white\" />\n      ) : (\n        <i className=\"i-mgc-send-plane-cute-fi size-4 text-white\" />\n      )}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIErrorFallback.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\n\nimport type { FallbackRender } from \"~/components/common/ErrorBoundary\"\nimport { attachOpenInEditor } from \"~/lib/dev\"\n\nimport { FeedbackIssue } from \"../../../../components/common/ErrorElement\"\nimport { parseError } from \"../../../../components/errors/helper\"\n\nexport const AIErrorFallback: FallbackRender = (props) => {\n  const { message, stack } = parseError(props.error)\n\n  return (\n    <div className=\"absolute inset-0 mx-auto flex max-w-2xl flex-col items-center justify-center rounded-lg bg-theme-background p-8 shadow-sm\">\n      <div className=\"text-center\">\n        {/* AI-specific icon */}\n        <div className=\"mb-6\">\n          <i className=\"i-mgc-ai-cute-re text-5xl text-orange\" />\n        </div>\n\n        {/* Error title */}\n        <h2 className=\"mb-3 text-xl font-semibold text-text\">AI Chat Encountered an Error</h2>\n\n        {/* Error message */}\n        <div className=\"mb-6 text-sm leading-relaxed text-text-secondary\">\n          {message || \"An unexpected error occurred while processing your request.\"}\n        </div>\n\n        {/* Development stack trace */}\n        {import.meta.env.DEV && stack ? (\n          <details className=\"mb-6 text-left\">\n            <summary className=\"mb-2 cursor-pointer text-xs text-text-tertiary hover:text-text-secondary\">\n              Show technical details\n            </summary>\n            <pre className=\"max-h-32 cursor-text select-text overflow-auto whitespace-pre-wrap rounded-md border border-border/40 bg-fill p-3 font-mono text-xs text-text-secondary\">\n              {attachOpenInEditor(stack)}\n            </pre>\n          </details>\n        ) : null}\n\n        {/* Error description */}\n        <p className=\"mb-8 text-sm leading-relaxed text-text-tertiary\">\n          Don't worry! You can try again or reload the chat to continue your conversation.\n        </p>\n\n        {/* Action buttons */}\n        <div className=\"flex items-center justify-center gap-3\">\n          <Button onClick={() => props.resetError()} variant=\"primary\">\n            Try Again\n          </Button>\n\n          <Button onClick={() => window.location.reload()} variant=\"outline\">\n            Reload Page\n          </Button>\n        </div>\n\n        {/* Feedback component */}\n        <div className=\"mt-8 border-t border-border/40 pt-6\">\n          <FeedbackIssue message={message || \"AI Chat Error\"} stack={stack} error={props.error} />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIModelIndicator.tsx",
    "content": "import type { UserRole } from \"@follow/constants\"\nimport { UserRolePriority } from \"@follow/constants\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { Fragment, memo, useMemo } from \"react\"\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\n\nimport { useAIModel } from \"../../hooks/useAIModel\"\n\ninterface AIModelIndicatorProps {\n  className?: string\n  onModelChange?: (model: string) => void\n}\n\ntype ProviderType = \"openai\" | \"google\" | \"auto\" | \"deepseek\" | \"anthropic\" | \"moonshotai\"\n\nconst providerIcons: Record<ProviderType, string> = {\n  auto: \"i-mgc-folo-bot-original size-4 -ml-0.5\",\n  openai: \"i-mgc-openai-original\",\n  google: \"i-simple-icons-googlegemini\",\n  anthropic: \"i-simple-icons-claude\",\n  deepseek: \"i-mgc-deepseek-original\",\n  moonshotai: \"i-mgc-moonshotai-original\",\n}\n\nconst MODEL_PAID_LEVELS = [\"basic\", \"plus\", \"pro\"] as const\ntype ModelPaidLevel = (typeof MODEL_PAID_LEVELS)[number]\n\nconst paidLevelPriority: Record<ModelPaidLevel, number> = {\n  basic: 1,\n  plus: 2,\n  pro: 3,\n}\n\nconst paidLevelBadgeStyles: Record<ModelPaidLevel, string> = {\n  basic: \"border-green/30 bg-green/10 text-green\",\n  plus: \"border-blue/30 bg-blue/10 text-blue\",\n  pro: \"border-purple/40 bg-purple/10 text-purple\",\n}\n\nconst paidLevelLabels: Record<ModelPaidLevel, string> = {\n  basic: \"Basic\",\n  plus: \"Plus\",\n  pro: \"Pro\",\n}\n\nconst isModelPaidLevel = (value: unknown): value is ModelPaidLevel => {\n  return typeof value === \"string\" && MODEL_PAID_LEVELS.includes(value as ModelPaidLevel)\n}\n\nconst hasAccessToPaidLevel = (role: UserRole | null | undefined, level?: ModelPaidLevel) => {\n  if (!level) return true\n  const roleScore = role ? (UserRolePriority[role] ?? 0) : 0\n  return roleScore >= paidLevelPriority[level]\n}\n\nconst parseModelString = (modelString: string) => {\n  if (!modelString || !modelString.includes(\"/\") || modelString === \"auto\") {\n    return { provider: \"auto\" as ProviderType, modelName: modelString || \"Unknown\" }\n  }\n\n  const [provider, ...modelParts] = modelString.split(\"/\")\n  const modelName = modelParts.join(\"/\")\n\n  return {\n    provider: (provider as ProviderType) || \"auto\",\n    modelName: modelName || \"Unknown\",\n  }\n}\n\nexport const AIModelIndicator = memo(({ className, onModelChange }: AIModelIndicatorProps) => {\n  const { data, changeModel } = useAIModel()\n  const { defaultModel, availableModels = [], currentModel, availableModelsMenu = [] } = data || {}\n  const role = useUserRole()\n  const settingModalPresent = useSettingModal()\n\n  const { provider, modelName } = useMemo(() => {\n    return parseModelString(currentModel || defaultModel || \"\")\n  }, [currentModel, defaultModel])\n\n  const selectedMenuItem = useMemo(() => {\n    return availableModelsMenu.find((item) => item.value === currentModel)\n  }, [availableModelsMenu, currentModel])\n\n  const iconClass = providerIcons[provider] || providerIcons.auto\n  const hasMultipleModels = availableModels && availableModels.length > 1\n\n  const modelContent = (\n    <div\n      className={cn(\n        \"inline-flex shrink-0 items-center rounded-xl border font-medium backdrop-blur-sm transition-colors\",\n        hasMultipleModels\n          ? \"cursor-button hover:bg-material-medium\"\n          : \"hover:bg-material-medium/50\",\n        \"duration-200\",\n        \"gap-1.5 p-1 text-xs\",\n        hasMultipleModels && \"px-2\",\n        \"border-border/50 bg-material-ultra-thin\",\n        \"text-text-secondary\",\n\n        className,\n      )}\n    >\n      <i className={cn(\"size-3\", iconClass)} />\n      <span className=\"hidden max-w-20 truncate @md:inline\">\n        {selectedMenuItem?.label || modelName}\n      </span>\n      {hasMultipleModels && <i className=\"i-mingcute-down-line size-3 opacity-60\" />}\n    </div>\n  )\n\n  if (!hasMultipleModels) {\n    return modelContent\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>{modelContent}</DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"min-w-48\">\n        {availableModelsMenu.map(({ label, value, paidLevel }, index) => {\n          if (value) {\n            const { provider: itemProvider, modelName: itemModelName } = parseModelString(value)\n            const itemIconClass = providerIcons[itemProvider] || providerIcons.auto\n            const isSelected = value === (currentModel || defaultModel)\n            const normalizedPaidLevel = isModelPaidLevel(paidLevel) ? paidLevel : undefined\n            const requiresUpgrade = !hasAccessToPaidLevel(role, normalizedPaidLevel)\n\n            const handleModelSelect = () => {\n              if (requiresUpgrade) {\n                settingModalPresent(\"plan\")\n                return\n              }\n              changeModel(value)\n              onModelChange?.(value)\n            }\n\n            return (\n              <DropdownMenuItem\n                key={value}\n                className={cn(\"gap-2\", requiresUpgrade && \"text-text-secondary\")}\n                onClick={handleModelSelect}\n                checked={isSelected}\n              >\n                <i className={cn(\"size-3\", itemIconClass)} />\n                <span className=\"truncate\">{label || itemModelName}</span>\n                {normalizedPaidLevel && (\n                  <span\n                    className={cn(\n                      \"ml-auto inline-flex rounded-full border px-1.5 text-[9px] font-semibold uppercase tracking-wide\",\n                      paidLevelBadgeStyles[normalizedPaidLevel],\n                    )}\n                  >\n                    {paidLevelLabels[normalizedPaidLevel]}\n                  </span>\n                )}\n              </DropdownMenuItem>\n            )\n          } else {\n            return (\n              <Fragment key={label}>\n                {index > 0 && <DropdownMenuSeparator />}\n                <DropdownMenuLabel>{label}</DropdownMenuLabel>\n              </Fragment>\n            )\n          }\n        })}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n})\n\nAIModelIndicator.displayName = \"AIModelIndicator\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AISmartSidebar.css",
    "content": "/* Glassmorphic Depth Design - Apple-inspired elegant AI sidebar */\n\n/* Multi-layer glass edge bars with depth perception */\n.ai-glass-layer-1,\n.ai-glass-layer-2,\n.ai-glass-layer-3 {\n  transform-origin: right bottom;\n  transition:\n    width 0.5s cubic-bezier(0.23, 1, 0.32, 1),\n    opacity 0.5s cubic-bezier(0.23, 1, 0.32, 1),\n    transform 0.5s cubic-bezier(0.23, 1, 0.32, 1),\n    box-shadow 0.5s cubic-bezier(0.23, 1, 0.32, 1);\n  will-change: width, opacity, transform, box-shadow;\n}\n\n/* Layered glass card with depth */\n.ai-glass-card {\n  will-change: transform;\n}\n\n/* Premium glass button */\n.ai-glass-button {\n  will-change: transform, box-shadow;\n  position: relative;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n/* Subtle shimmer on hover */\n.ai-glass-button:hover {\n  box-shadow:\n    0 4px 16px rgba(255, 92, 0, 0.15),\n    0 8px 32px rgba(255, 92, 0, 0.08),\n    inset 0 1px 1px rgba(255, 255, 255, 0.1);\n}\n\n/* Depth shadow for glass layers */\n.ai-glass-card > div {\n  transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);\n}\n\n/* Performance optimization with hardware acceleration */\n.ai-glass-layer-1,\n.ai-glass-layer-2,\n.ai-glass-layer-3,\n.ai-glass-card,\n.ai-glass-button {\n  backface-visibility: hidden;\n  -webkit-backface-visibility: hidden;\n  perspective: 1000px;\n  -webkit-perspective: 1000px;\n  transform: translateZ(0);\n  -webkit-transform: translateZ(0);\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AISmartSidebar.tsx",
    "content": "// Glassmorphic Depth Design - Apple-inspired elegant AI sidebar\nimport \"./AISmartSidebar.css\"\n\nimport { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { Spring } from \"@follow/components/constants/spring.js\"\nimport { KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { AnimatePresence, m, useSpring, useTransform } from \"motion/react\"\nimport * as React from \"react\"\nimport { useEffect, useState } from \"react\"\n\nimport { setAIPanelVisibility, useAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandShortcut } from \"~/modules/command/hooks/use-command-binding\"\n\nconst AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {\n  const [showPrompt, setShowPrompt] = useState(false)\n  const isShowPromptRef = React.useRef(false)\n  const intensity = useSpring(0, Spring.presets.smooth)\n\n  const layer3Width = useTransform(intensity, (value) => (value > 0.1 ? 1 + value * 3 : 0))\n  const layer3Opacity = useTransform(intensity, (value) => value * 0.15)\n  const layer3X = useTransform(intensity, (value) => value * -8)\n\n  const layer2Width = useTransform(intensity, (value) => (value > 0.2 ? 1.5 + value * 4 : 0))\n  const layer2Opacity = useTransform(intensity, (value) => value * 0.25)\n  const layer2BoxShadow = useTransform(intensity, (value) =>\n    value > 0.3 ? `0 0 ${12 + value * 20}px rgba(255, 92, 0, ${value * 0.15})` : \"none\",\n  )\n  const layer2X = useTransform(intensity, (value) => value * -4)\n\n  const layer1Width = useTransform(intensity, (value) => (value > 0 ? 2 + value * 6 : 1))\n  const layer1Opacity = useTransform(intensity, (value) => value * 0.6)\n  const layer1BoxShadow = useTransform(intensity, (value) =>\n    value > 0.5 ? `0 0 ${16 + value * 24}px rgba(255, 92, 0, ${value * 0.25})` : \"none\",\n  )\n  const layer1Background = useTransform(intensity, (value) => {\n    const primaryAlpha = Math.min(1, Math.max(0, value * 0.4))\n    const secondaryAlpha = Math.min(1, Math.max(0, value * 0.2))\n    return `linear-gradient(to left, rgba(255, 92, 0, ${primaryAlpha}), rgba(255, 140, 0, ${secondaryAlpha}), transparent)`\n  })\n\n  const glowOpacity = useTransform(intensity, (value) => (value <= 0.4 ? 0 : (value - 0.4) * 0.3))\n  const glowBackground = useTransform(intensity, (value) => {\n    const alpha = value <= 0.4 ? 0 : (value - 0.4) * 0.12\n    return `radial-gradient(ellipse at center, rgba(255, 92, 0, ${alpha}) 0%, transparent 70%)`\n  })\n  const glowX = useTransform(intensity, (value) => value * -12)\n  const glowY = useTransform(intensity, (value) => value * -24)\n\n  const canShowPrompt = useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope)\n  useEffect(() => {\n    if (!canShowPrompt) {\n      intensity.set(0)\n      if (isShowPromptRef.current) {\n        isShowPromptRef.current = false\n        setShowPrompt(false)\n      }\n      return\n    }\n\n    const effectWidth = 220\n    const effectHeight = 220\n    const activationWidth = 50\n    const activationHeight = 50\n    const releaseWidth = 90\n    const releaseHeight = 90\n    const frameRef = { current: null as number | null }\n\n    const resetState = () => {\n      intensity.set(0)\n      if (isShowPromptRef.current) {\n        isShowPromptRef.current = false\n        setShowPrompt(false)\n      }\n    }\n\n    const handlePointerMove = (event: PointerEvent) => {\n      if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current)\n      }\n\n      const { clientX, clientY } = event\n      frameRef.current = window.requestAnimationFrame(() => {\n        frameRef.current = null\n\n        const rightEdgeDistance = window.innerWidth - clientX\n        const bottomEdgeDistance = window.innerHeight - clientY\n        const withinEffectZone =\n          rightEdgeDistance <= effectWidth && bottomEdgeDistance <= effectHeight\n\n        if (!withinEffectZone) {\n          resetState()\n          return\n        }\n\n        const normalizedX = 1 - Math.min(1, rightEdgeDistance / effectWidth)\n        const normalizedY = 1 - Math.min(1, bottomEdgeDistance / effectHeight)\n        const newIntensity = Math.max(normalizedX, normalizedY)\n        intensity.set(newIntensity)\n\n        const withinActivation =\n          rightEdgeDistance <= activationWidth && bottomEdgeDistance <= activationHeight\n        const withinRelease =\n          rightEdgeDistance <= releaseWidth && bottomEdgeDistance <= releaseHeight\n\n        if (withinActivation && !isShowPromptRef.current) {\n          isShowPromptRef.current = true\n          setShowPrompt(true)\n        } else if (isShowPromptRef.current && !withinRelease) {\n          isShowPromptRef.current = false\n          setShowPrompt(false)\n        }\n      })\n    }\n\n    const handlePointerLeave = () => {\n      if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current)\n        frameRef.current = null\n      }\n      resetState()\n    }\n\n    window.addEventListener(\"pointermove\", handlePointerMove, { passive: true })\n    window.addEventListener(\"pointerleave\", handlePointerLeave)\n\n    return () => {\n      if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current)\n      }\n      window.removeEventListener(\"pointermove\", handlePointerMove)\n      window.removeEventListener(\"pointerleave\", handlePointerLeave)\n    }\n  }, [canShowPrompt, intensity])\n\n  const toggleAIChatShortcut = useCommandShortcut(COMMAND_ID.global.toggleAIChat)\n  if (!canShowPrompt) return null\n\n  return (\n    <>\n      {/* Multi-layer glass edge with depth */}\n      <div className=\"pointer-events-none fixed inset-y-0 right-0 z-40\">\n        {/* Background layer - deepest */}\n        <m.div\n          className=\"ai-glass-layer-3 absolute inset-y-0 right-0 h-full\"\n          style={{\n            width: layer3Width,\n            opacity: layer3Opacity,\n            background: \"linear-gradient(to left, rgba(255, 92, 0, 0.15), transparent)\",\n            x: layer3X,\n          }}\n        />\n\n        {/* Middle layer */}\n        <m.div\n          className=\"ai-glass-layer-2 absolute inset-y-0 right-0 h-full\"\n          style={{\n            width: layer2Width,\n            opacity: layer2Opacity,\n            background: \"linear-gradient(to left, rgba(255, 92, 0, 0.2), transparent)\",\n            x: layer2X,\n            boxShadow: layer2BoxShadow,\n          }}\n        />\n\n        {/* Front layer - most prominent */}\n        <m.div\n          className=\"ai-glass-layer-1 absolute inset-y-0 right-0 h-full\"\n          style={{\n            width: layer1Width,\n            opacity: layer1Opacity,\n            background: layer1Background,\n            boxShadow: layer1BoxShadow,\n          }}\n        />\n\n        {/* Subtle ambient glow */}\n        <m.div\n          className=\"absolute bottom-6 right-6 size-32\"\n          style={{\n            opacity: glowOpacity,\n            background: glowBackground,\n            filter: \"blur(30px)\",\n            x: glowX,\n            y: glowY,\n          }}\n        />\n      </div>\n\n      <AnimatePresence>\n        {showPrompt && (\n          <m.div\n            initial={{ opacity: 0, x: 30, scale: 0.95 }}\n            animate={{ opacity: 1, x: 0, scale: 1 }}\n            exit={{ opacity: 0, x: 30, scale: 0.95 }}\n            transition={Spring.presets.smooth}\n            className=\"fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3\"\n          >\n            {/* Unified glass card with integrated button */}\n            <m.div\n              className=\"ai-glass-card relative\"\n              initial={{ y: 8, opacity: 0 }}\n              animate={{ y: 0, opacity: 1 }}\n              transition={Spring.presets.snappy}\n            >\n              {/* Main unified card */}\n              <div\n                className=\"relative overflow-hidden rounded-2xl bg-gradient-to-br to-background/95 backdrop-blur-2xl\"\n                style={{\n                  backgroundImage:\n                    \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n                  borderWidth: \"1px\",\n                  borderStyle: \"solid\",\n                  borderColor: \"rgba(255, 92, 0, 0.2)\",\n                  boxShadow:\n                    \"0 8px 32px rgba(255, 92, 0, 0.08), 0 4px 16px rgba(255, 92, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n                }}\n              >\n                {/* Inner glow */}\n                <div\n                  className=\"absolute inset-0 rounded-2xl\"\n                  style={{\n                    background:\n                      \"linear-gradient(to bottom right, rgba(255, 92, 0, 0.05), transparent, rgba(255, 92, 0, 0.05))\",\n                  }}\n                />\n\n                {/* Info section */}\n                <div className=\"relative px-5 py-3.5 text-right\">\n                  <p className=\"text-sm font-medium text-text\">Ask AI anything</p>\n                  <p className=\"mt-0.5 text-xs text-text-secondary\">\n                    Get insights about this article\n                  </p>\n                </div>\n\n                {/* Divider */}\n                <div\n                  className=\"mx-4 h-px\"\n                  style={{\n                    background:\n                      \"linear-gradient(to right, transparent, rgba(255, 92, 0, 0.2), transparent)\",\n                  }}\n                />\n\n                {/* Button section */}\n                <button\n                  type=\"button\"\n                  className=\"group relative w-full px-5 py-3 text-left transition-all duration-300\"\n                  onClick={onExpand}\n                  onMouseEnter={(e) => {\n                    e.currentTarget.style.background =\n                      \"linear-gradient(to right, rgba(255, 92, 0, 0.08), rgba(255, 140, 0, 0.05))\"\n                  }}\n                  onMouseLeave={(e) => {\n                    e.currentTarget.style.background = \"transparent\"\n                  }}\n                >\n                  {/* Subtle shine effect on hover */}\n                  <div className=\"absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray/5 to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5\" />\n\n                  <div className=\"relative flex items-center justify-between\">\n                    <div className=\"flex items-center gap-2.5\">\n                      {/* Minimal indicator dot */}\n                      <m.div\n                        className=\"size-2 rounded-full\"\n                        style={{ backgroundColor: \"#FF5C00\" }}\n                        animate={{\n                          opacity: [0.6, 1, 0.6],\n                        }}\n                        transition={{\n                          duration: 2,\n                          repeat: Number.POSITIVE_INFINITY,\n                          ease: \"easeInOut\",\n                        }}\n                      />\n                      <span className=\"text-sm font-medium text-text\">Open AI Chat</span>\n                    </div>\n\n                    <KbdCombined\n                      abbr=\"Open AI Chat\"\n                      joint\n                      className=\"rounded-md bg-fill/40 px-2 backdrop-blur-sm\"\n                    >\n                      {toggleAIChatShortcut}\n                    </KbdCombined>\n                  </div>\n                </button>\n              </div>\n            </m.div>\n          </m.div>\n        )}\n      </AnimatePresence>\n    </>\n  )\n}\n\nexport const AISmartSidebar: React.FC = () =>\n  !useAIPanelVisibility() && <AIAmbientSidebar onExpand={() => setAIPanelVisibility(true)} />\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { EditorState } from \"lexical\"\nimport { m } from \"motion/react\"\nimport { useCallback, useLayoutEffect, useRef } from \"react\"\n\nimport { useI18n } from \"~/hooks/common/useI18n\"\nimport { ChatInput } from \"~/modules/ai-chat/components/layouts/ChatInput\"\nimport { ChatShortcutsRow } from \"~/modules/ai-chat/components/layouts/ChatShortcutsRow\"\nimport { RateLimitNotice } from \"~/modules/ai-chat/components/layouts/RateLimitNotice\"\n\nimport type { ShortcutData } from \"../../editor\"\nimport { useSendAIShortcut } from \"../../hooks/useSendAIShortcut\"\n\ninterface ChatBottomPanelProps {\n  hasMessages: boolean\n  centerInputOnEmpty?: boolean\n  shouldShowInterruptionNotice: boolean\n  rateLimitMessage: string | null\n  isRateLimited: boolean\n  onRetryLastMessage: () => void\n  onSendMessage: (message: string | EditorState) => void\n  initialDraftState?: EditorState\n  onDraftChange: (state: EditorState) => void\n  onHeightChange: (height: number) => void\n}\n\nexport const ChatBottomPanel = ({\n  hasMessages,\n  centerInputOnEmpty,\n  shouldShowInterruptionNotice,\n  rateLimitMessage,\n  isRateLimited,\n  onRetryLastMessage,\n  onSendMessage,\n  initialDraftState,\n  onDraftChange,\n  onHeightChange,\n}: ChatBottomPanelProps) => {\n  const panelRef = useRef<HTMLDivElement | null>(null)\n  const t = useI18n()\n  const { sendAIShortcut } = useSendAIShortcut()\n\n  useLayoutEffect(() => {\n    const element = panelRef.current\n    if (!element) return\n\n    const updateHeight = () => {\n      onHeightChange(element.offsetHeight)\n    }\n\n    updateHeight()\n\n    const resizeObserver = new ResizeObserver(() => {\n      updateHeight()\n    })\n\n    resizeObserver.observe(element)\n\n    return () => {\n      resizeObserver.disconnect()\n      onHeightChange(0)\n    }\n  }, [onHeightChange])\n\n  const handleShortcutSelect = useCallback(\n    (shortcutData: ShortcutData) => {\n      void sendAIShortcut({\n        shortcut: shortcutData,\n        behavior: \"send\",\n        openPanel: false,\n        onSend: (editorState) => {\n          onSendMessage(editorState)\n        },\n      })\n    },\n    [onSendMessage, sendAIShortcut],\n  )\n\n  return (\n    <div\n      ref={panelRef}\n      data-testid=\"chat-input-container\"\n      className={cn(\n        \"absolute z-10 mx-auto duration-500 ease-in-out\",\n        \"inset-x-0 bottom-0 max-w-4xl px-4 pb-4\",\n        centerInputOnEmpty &&\n          !hasMessages &&\n          \"bottom-1/2 translate-y-[calc(100%+1rem)] duration-200\",\n      )}\n    >\n      {shouldShowInterruptionNotice && (\n        <m.div\n          initial={{ opacity: 0, y: -10 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: -10 }}\n          transition={{ duration: 0.2 }}\n          className=\"mb-3 flex w-full items-start gap-2 rounded-lg border border-border bg-material-ultra-thick px-3 py-2 text-xs text-text-secondary backdrop-blur-background\"\n        >\n          <i className=\"i-mingcute-information-fill size-4 flex-shrink-0 text-text\" />\n          <div className=\"flex flex-1 items-center justify-between gap-1\">\n            <span>{t.ai(\"session.interrupted.message\")}</span>\n            {!rateLimitMessage && (\n              <button\n                type=\"button\"\n                onClick={onRetryLastMessage}\n                className=\"cursor-button self-start text-xs text-accent duration-200 hover:opacity-80\"\n              >\n                {t.ai(\"session.interrupted.retry\")}\n              </button>\n            )}\n          </div>\n        </m.div>\n      )}\n      <RateLimitNotice message={rateLimitMessage} />\n      {!isRateLimited && <ChatShortcutsRow onSelect={handleShortcutSelect} />}\n      <ChatInput\n        onSend={onSendMessage}\n        variant={!hasMessages ? \"minimal\" : \"default\"}\n        initialDraftState={initialDraftState}\n        onEditorStateChange={onDraftChange}\n        submitDisabled={isRateLimited}\n      />\n\n      {(!centerInputOnEmpty || hasMessages) && (\n        <div className=\"absolute inset-x-0 bottom-0 isolate\">\n          <div\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-44 backdrop-blur-xl backdrop-brightness-110 dark:backdrop-brightness-75\"\n            style={{\n              maskImage: \"linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.6) 25%, transparent)\",\n              WebkitMaskImage:\n                \"linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.6) 25%, transparent)\",\n            }}\n          />\n\n          <div\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-60 bg-gradient-to-b from-background/20 to-background/0\"\n            style={{\n              maskImage: \"linear-gradient(to top, black 20%, transparent 70%)\",\n              WebkitMaskImage: \"linear-gradient(to top, black 20%, transparent 70%)\",\n              backdropFilter: \"blur(50px) saturate(130%)\",\n              WebkitBackdropFilter: \"blur(50px) saturate(130%)\",\n            }}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHeader.tsx",
    "content": "import { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { cn } from \"@follow/utils\"\nimport { FeedViewType } from \"@follow-app/client-sdk\"\nimport { useAtomValue } from \"jotai\"\nimport type { FC, ReactNode } from \"react\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  AIChatPanelStyle,\n  setAIChatPanelStyle,\n  setAIPanelVisibility,\n  useAIChatPanelStyle,\n} from \"~/atoms/settings/ai\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useTimelineSummaryAutoContext } from \"~/modules/ai-chat/hooks/useTimelineSummaryAutoContext\"\nimport {\n  useBlockActions,\n  useChatActions,\n  useCurrentTitle,\n  useHasMessages,\n} from \"~/modules/ai-chat/store/hooks\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\n\nimport { useAIRootState } from \"../../store/AIChatContext\"\nimport { AISpline } from \"../3d-models/AISpline\"\nimport { ChatHistoryDropdown } from \"./ChatHistoryDropdown\"\nimport { ChatMoreDropdown } from \"./ChatMoreDropdown\"\nimport { AIHeaderTitle } from \"./ChatTitle\"\nimport { TaskReportDropdown } from \"./TaskReportDropdown\"\n\n// Base header layout with shared logic inside\nconst ChatHeaderLayout = ({\n  renderActions,\n  isFloating,\n}: {\n  renderActions: (ctx: {\n    onNewChatClick: () => void\n    currentTitle: string | undefined\n    displayTitle: string | undefined\n    panelStyle: AIChatPanelStyle\n    onTogglePanelStyle: () => void\n  }) => ReactNode\n  isFloating: boolean\n}) => {\n  const hasMessages = useHasMessages()\n  const currentTitle = useCurrentTitle()\n  const chatActions = useChatActions()\n  const blockActions = useBlockActions()\n  const { t } = useTranslation(\"ai\")\n  const shouldDisableTimelineSummary = useTimelineSummaryAutoContext()\n  const settingModalPresent = useSettingModal()\n  const panelStyle = useAIChatPanelStyle()\n\n  const displayTitle = currentTitle\n\n  const handleNewChatClick = useCallback(() => {\n    const messages = chatActions.getMessages()\n\n    if (messages.length === 0) {\n      return\n    }\n\n    if (shouldDisableTimelineSummary) {\n      chatActions.setTimelineSummaryManualOverride(true)\n    }\n\n    chatActions.newChat()\n    blockActions.clearBlocks({ keepSpecialTypes: true })\n  }, [chatActions, blockActions, shouldDisableTimelineSummary])\n\n  const handleTogglePanelStyle = useCallback(() => {\n    const newStyle =\n      panelStyle === AIChatPanelStyle.Fixed ? AIChatPanelStyle.Floating : AIChatPanelStyle.Fixed\n    setAIChatPanelStyle(newStyle)\n  }, [panelStyle])\n\n  const { isScrolledBeyondThreshold } = useAIRootState()\n  const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)\n  return (\n    <div\n      className={cn(\n        \"absolute inset-x-0 top-0 z-[1] border-b border-transparent duration-200\",\n        !isFloating && \"bg-background data-[scrolled-beyond-threshold=true]:border-b-border\",\n      )}\n      data-scrolled-beyond-threshold={isScrolledBeyondThresholdValue}\n    >\n      <div className=\"h-top-header\">\n        {isFloating && (\n          <div\n            className=\"absolute inset-0 bg-background/70 backdrop-blur-background\"\n            style={{\n              maskImage: `linear-gradient(to bottom, black 0%, black 90%, transparent 100%)`,\n            }}\n          />\n        )}\n\n        <div className=\"relative z-10 flex h-full items-center justify-between px-4\">\n          <div className=\"mr-2 flex min-w-0 items-center\">\n            {(hasMessages || currentTitle) && (\n              <div onClick={() => settingModalPresent(\"ai\")}>\n                <AISpline className=\"no-drag-region -mx-1 -mb-1 mr-1 size-9\" />\n              </div>\n            )}\n            <ChatHistoryDropdown\n              triggerElement={\n                <AIHeaderTitle title={displayTitle} placeholder={t(\"common.new_chat\")} />\n              }\n            />\n          </div>\n\n          {/* Right side - Actions */}\n          <div className=\"flex items-center gap-2\">\n            {renderActions({\n              onNewChatClick: handleNewChatClick,\n              currentTitle,\n              displayTitle,\n              panelStyle,\n              onTogglePanelStyle: handleTogglePanelStyle,\n            })}\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport const ChatHeader: FC<{ isFloating: boolean }> = ({ isFloating }) => {\n  const { t } = useTranslation(\"ai\")\n\n  const view = useRouteParamsSelector((state) => state.view)\n  const isAllView = view === FeedViewType.All\n  return (\n    <ChatHeaderLayout\n      isFloating={isFloating}\n      renderActions={({ onNewChatClick, panelStyle, onTogglePanelStyle }) => (\n        <>\n          <ActionButton tooltip={t(\"common.new_chat\")} onClick={onNewChatClick}>\n            <i className=\"i-mgc-edit-cute-re size-5 text-text-secondary\" />\n          </ActionButton>\n          <ActionButton\n            tooltip={\n              panelStyle === AIChatPanelStyle.Fixed\n                ? \"Switch to Floating Panel\"\n                : \"Switch to Fixed Panel\"\n            }\n            onClick={onTogglePanelStyle}\n          >\n            <i\n              className={`size-5 text-text-secondary ${\n                panelStyle === AIChatPanelStyle.Fixed\n                  ? \"i-mingcute-rectangle-vertical-line\"\n                  : \"i-mingcute-layout-right-line\"\n              }`}\n            />\n          </ActionButton>\n\n          <ChatMoreDropdown\n            canClosePanel={!isAllView}\n            triggerElement={\n              <ActionButton tooltip=\"More\">\n                <i className=\"i-mingcute-more-1-fill size-5 text-text-secondary\" />\n              </ActionButton>\n            }\n          />\n\n          {isFloating && (\n            <>\n              <div className=\"h-5 w-px bg-border\" />\n              <ActionButton tooltip=\"Close\" onClick={() => setAIPanelVisibility(false)}>\n                <i className=\"i-mgc-close-cute-re size-5 text-text-secondary\" />\n              </ActionButton>\n            </>\n          )}\n        </>\n      )}\n    />\n  )\n}\n\nexport const ChatPageHeader = () => {\n  const { t } = useTranslation(\"ai\")\n\n  return (\n    <ChatHeaderLayout\n      isFloating={false}\n      renderActions={({ onNewChatClick, panelStyle, onTogglePanelStyle }) => (\n        <>\n          <ActionButton tooltip={t(\"common.new_chat\")} onClick={onNewChatClick}>\n            <i className=\"i-mgc-edit-cute-re size-5 text-text-secondary\" />\n          </ActionButton>\n          <ActionButton\n            tooltip={\n              panelStyle === AIChatPanelStyle.Fixed\n                ? \"Switch to Floating Panel\"\n                : \"Switch to Fixed Panel\"\n            }\n            onClick={onTogglePanelStyle}\n          >\n            <i\n              className={`size-5 text-text-secondary ${\n                panelStyle === AIChatPanelStyle.Fixed\n                  ? \"i-mingcute-rectangle-vertical-line\"\n                  : \"i-mingcute-layout-right-line\"\n              }`}\n            />\n          </ActionButton>\n\n          <TaskReportDropdown />\n\n          <div className=\"mx-2 h-5 w-px bg-border\" />\n          <ChatMoreDropdown\n            triggerElement={\n              <ActionButton tooltip=\"More\">\n                <i className=\"i-mingcute-more-1-fill size-5 text-text-secondary\" />\n              </ActionButton>\n            }\n          />\n        </>\n      )}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx",
    "content": "import { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport { nextFrame } from \"@follow/utils\"\nimport type { ReactNode } from \"react\"\nimport { startTransition, useCallback, useMemo, useState } from \"react\"\nimport { toast } from \"sonner\"\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useChatHistory } from \"~/modules/ai-chat/hooks/useChatHistory\"\nimport { useAIChatSessionListQuery } from \"~/modules/ai-chat-session/query\"\nimport { AITaskModal, useAITaskListQuery, useCanCreateNewAITask } from \"~/modules/ai-task\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\nimport { AI_SETTING_SECTION_IDS } from \"~/modules/settings/tabs/ai\"\n\nimport {\n  EmptyState,\n  isTaskSession,\n  isUnreadSession,\n  SessionItem,\n  useChatSessionHandlers,\n} from \"./shared\"\n\ninterface ChatHistoryDropdownProps {\n  triggerElement?: ReactNode\n  asChild?: boolean\n}\n\nexport const ChatHistoryDropdown = ({\n  triggerElement,\n  asChild = true,\n}: ChatHistoryDropdownProps) => {\n  const [loadingChatId, setLoadingChatId] = useState<string | null>(null)\n  const [activeTab, setActiveTab] = useState(\"chats\")\n  const { sessions, loading, loadHistory } = useChatHistory()\n\n  // Task session related hooks\n  const tasks = useAITaskListQuery()\n  const taskSessions = useAIChatSessionListQuery({\n    refetchInterval: tasks?.length ? 1 * 60 * 1000 : false,\n  })\n  const { present } = useModalStack()\n  const canCreateNewTask = useCanCreateNewAITask()\n  const showSettings = useSettingModal()\n\n  // Merge both session types\n  const allSessions = useMemo(() => {\n    const regularSessions = sessions || []\n    const aiTaskSessions = taskSessions || []\n    return [...regularSessions, ...aiTaskSessions]\n  }, [sessions, taskSessions])\n\n  // Filter sessions by type\n  const regularSessions = useMemo(() => {\n    return (sessions || []).filter((s) => !isTaskSession(s))\n  }, [sessions])\n\n  const taskSessionsFiltered = useMemo(() => {\n    return (taskSessions || []).filter((s) => isTaskSession(s))\n  }, [taskSessions])\n\n  // Count unread sessions\n  const hasUnreadRegularSessions = useMemo(() => {\n    return regularSessions.some((s) => isUnreadSession(s))\n  }, [regularSessions])\n\n  const hasUnreadTaskSessions = useMemo(() => {\n    return taskSessionsFiltered.some((s) => isUnreadSession(s))\n  }, [taskSessionsFiltered])\n\n  const handleScheduleActionClick = () => {\n    if (!canCreateNewTask) {\n      toast.error(\"Please remove an existing task before creating a new one.\")\n      return\n    }\n    showSettings({ tab: \"ai\", section: AI_SETTING_SECTION_IDS.tasks })\n    nextFrame(() => {\n      present({\n        title: \"New AI Task\",\n        canClose: true,\n        content: () => <AITaskModal showSettingsTip />,\n      })\n    })\n  }\n\n  const { handleSessionSelect, handleDeleteSession } = useChatSessionHandlers({\n    sessions: allSessions,\n  })\n\n  const handleDropdownOpen = useCallback(\n    (isOpen: boolean) => {\n      if (isOpen) {\n        startTransition(() => {\n          loadHistory()\n        })\n      }\n    },\n    [loadHistory],\n  )\n\n  const defaultTrigger = (\n    <ActionButton tooltip=\"Chat History\" className=\"relative\">\n      <i className=\"i-mgc-history-cute-re size-5 text-text-secondary\" />\n      {(hasUnreadRegularSessions || hasUnreadTaskSessions) && (\n        <span\n          className=\"absolute right-1 top-1 block size-2 rounded-full bg-accent shadow-[0_0_0_2px_var(--color-bg-default)] dark:shadow-[0_0_0_2px_var(--color-bg-default)]\"\n          aria-label=\"Unread messages\"\n        />\n      )}\n    </ActionButton>\n  )\n\n  return (\n    <DropdownMenu onOpenChange={handleDropdownOpen}>\n      <DropdownMenuTrigger asChild={asChild}>\n        {triggerElement || defaultTrigger}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\" className=\"w-80\">\n        <SegmentGroup value={activeTab} onValueChanged={setActiveTab} className=\"mb-4 w-full\">\n          <SegmentItem\n            value=\"chats\"\n            label={\n              <span className=\"flex items-center gap-1\">\n                Chats\n                {hasUnreadRegularSessions && <span className=\"size-1.5 rounded-full bg-accent\" />}\n              </span>\n            }\n          />\n          <SegmentItem\n            value=\"tasks\"\n            label={\n              <span className=\"flex items-center gap-1\">\n                Tasks\n                {hasUnreadTaskSessions && <span className=\"size-1.5 rounded-full bg-accent\" />}\n              </span>\n            }\n          />\n        </SegmentGroup>\n\n        <div className=\"max-h-80 overflow-y-auto\">\n          {activeTab === \"chats\" ? (\n            loading && sessions.length === 0 ? (\n              <div className=\"flex items-center justify-center py-8\">\n                <i className=\"i-mgc-loading-3-cute-re size-5 animate-spin text-text-secondary\" />\n              </div>\n            ) : regularSessions.length > 0 ? (\n              <>\n                <div className=\"mb-1.5 px-2 py-1\">\n                  <p className=\"text-xs font-medium text-text-secondary\">Recent Chats</p>\n                </div>\n                {regularSessions.map((session) => (\n                  <SessionItem\n                    key={session.chatId}\n                    session={session}\n                    onClick={() => handleSessionSelect(session)}\n                    onDelete={(e) => {\n                      handleDeleteSession(session.chatId, { event: e }).finally(() => {\n                        setLoadingChatId(null)\n                      })\n                    }}\n                    isLoading={loadingChatId === session.chatId}\n                  />\n                ))}\n              </>\n            ) : (\n              <EmptyState message=\"No chat history yet\" />\n            )\n          ) : taskSessionsFiltered.length > 0 ? (\n            <>\n              <div className=\"mb-1.5 px-2 py-1\">\n                <p className=\"text-xs font-medium text-text-secondary\">Task Sessions</p>\n              </div>\n              {taskSessionsFiltered.map((session) => (\n                <SessionItem\n                  key={session.chatId}\n                  session={session}\n                  onClick={() => handleSessionSelect(session)}\n                  onDelete={(e) => {\n                    handleDeleteSession(session.chatId, {\n                      event: e,\n                      onBeforeDelete: () => setLoadingChatId(session.chatId),\n                    }).finally(() => {\n                      setLoadingChatId(null)\n                    })\n                  }}\n                  isLoading={loadingChatId === session.chatId}\n                  hasUnread={hasUnreadTaskSessions}\n                />\n              ))}\n              {canCreateNewTask && (\n                <>\n                  <DropdownMenuSeparator />\n                  <DropdownMenuItem onClick={handleScheduleActionClick}>\n                    <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n                    New Task\n                  </DropdownMenuItem>\n                </>\n              )}\n            </>\n          ) : (\n            <EmptyState message=\"No task sessions yet\" />\n          )}\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx",
    "content": "import type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport { LexicalRichEditor } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { cn, nextFrame, stopPropagation } from \"@follow/utils\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\nimport type { EditorState, LexicalEditor } from \"lexical\"\nimport { $getRoot } from \"lexical\"\nimport type { Ref } from \"react\"\nimport { memo, use, useCallback, useImperativeHandle, useRef, useState } from \"react\"\nimport { matchKeyBindingPress, parseKeybinding } from \"tinykeys\"\n\nimport { AIChatContextBar } from \"~/modules/ai-chat/components/layouts/AIChatContextBar\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { getCommand } from \"~/modules/command/hooks/use-command\"\nimport { useCommandShortcut } from \"~/modules/command/hooks/use-command-binding\"\n\nimport { FileUploadPlugin, MentionPlugin, SelectedTextPlugin, ShortcutPlugin } from \"../../editor\"\nimport { useMainEntryId } from \"../../hooks/useMainEntryId\"\nimport { AIPanelRefsContext } from \"../../store/AIChatContext\"\nimport { useChatActions, useChatScene, useChatStatus } from \"../../store/hooks\"\nimport { AIChatSendButton } from \"./AIChatSendButton\"\nimport { AIModelIndicator } from \"./AIModelIndicator\"\n\nconst chatInputVariants = cva(\n  [\n    \"bg-mix-background/transparent-8/2 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80\",\n    \"relative overflow-hidden rounded-2xl border backdrop-blur-background duration-200 focus-within:ring-2\",\n    \"z-[1]\",\n  ],\n  {\n    variants: {\n      variant: {\n        default: \"shadow-2xl shadow-black/5 dark:shadow-zinc-800\",\n        minimal: \"shadow shadow-zinc-100 dark:shadow-black/5\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n)\n\ninterface ChatInputProps extends VariantProps<typeof chatInputVariants> {\n  onSend: (message: EditorState | string, editor: LexicalEditor | null) => void\n  ref?: Ref<LexicalRichEditorRef | null>\n  initialDraftState?: EditorState\n  onEditorStateChange?: (editorState: EditorState) => void\n  submitDisabled?: boolean\n}\n\nexport const ChatInput = memo(\n  ({\n    onSend,\n    variant,\n    ref: forwardedRef,\n    initialDraftState,\n    onEditorStateChange,\n    submitDisabled,\n  }: ChatInputProps) => {\n    const status = useChatStatus()\n    const chatActions = useChatActions()\n    const mainEntryId = useMainEntryId()\n\n    const stop = useCallback(() => {\n      chatActions.stop()\n    }, [chatActions])\n\n    const editorRef = useRef<LexicalRichEditorRef | null>(null)\n\n    useImperativeHandle<LexicalRichEditorRef | null, LexicalRichEditorRef | null>(\n      forwardedRef,\n      () => editorRef.current,\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      [editorRef.current],\n    )\n\n    const aiPanelRefs = use(AIPanelRefsContext)\n    if (editorRef.current) {\n      aiPanelRefs.inputRef.current = editorRef.current\n    }\n\n    const [isEmpty, setIsEmpty] = useState(true)\n    const [currentEditor, setCurrentEditor] = useState<LexicalEditor | null>(null)\n\n    const isProcessing = status === \"submitted\" || status === \"streaming\"\n    const isSubmitDisabled = submitDisabled || (!isProcessing && isEmpty)\n\n    const handleEditorChange = useCallback(\n      (editorState: EditorState, editor: LexicalEditor) => {\n        setCurrentEditor(editor)\n\n        editorState.read(() => {\n          const textContent = $getRoot().getTextContent().trim()\n          setIsEmpty(textContent === \"\")\n        })\n\n        onEditorStateChange?.(editorState)\n      },\n      [onEditorStateChange],\n    )\n\n    const scene = useChatScene()\n\n    const handleSend = useCallback(async () => {\n      if (submitDisabled) return\n      if (currentEditor && editorRef.current && !editorRef.current.isEmpty()) {\n        const editorState = currentEditor?.getEditorState()\n        nextFrame(() => {\n          onSend(editorState, currentEditor)\n        })\n        editorRef.current.clear()\n      }\n    }, [currentEditor, onSend, submitDisabled])\n\n    const handleSendClick = useCallback(() => {\n      void handleSend()\n    }, [handleSend])\n\n    const toggleAIChatShortcut = useCommandShortcut(COMMAND_ID.global.toggleAIChat)\n\n    const handleKeyDown = useCallback(\n      (event: KeyboardEvent) => {\n        // Check if the event matches the toggleAIChat shortcut using tinykeys utilities\n        // Handle comma-separated shortcuts (e.g., \"meta+i, ctrl+i\")\n        const shortcuts = toggleAIChatShortcut.split(\",\").map((s) => s.trim())\n\n        const matchesToggleShortcut = shortcuts.some((shortcut) => {\n          const presses = parseKeybinding(shortcut)\n\n          // For single key shortcuts (not sequences), check if the first press matches\n          return presses.length === 1 && presses[0] && matchKeyBindingPress(event, presses[0])\n        })\n\n        if (matchesToggleShortcut) {\n          event.preventDefault()\n          event.stopPropagation()\n\n          getCommand(COMMAND_ID.global.toggleAIChat)?.run()\n\n          return true\n        }\n\n        if (event.key === \"Enter\" && !event.shiftKey) {\n          event.preventDefault()\n          if (isProcessing || isSubmitDisabled) {\n            return false\n          }\n          void handleSend()\n          return true\n        }\n\n        return false\n      },\n      [handleSend, isProcessing, toggleAIChatShortcut, isSubmitDisabled],\n    )\n\n    return (\n      <div data-testid=\"chat-input\" className={cn(chatInputVariants({ variant }))}>\n        {/* Input Area */}\n        <div className=\"relative z-10 flex items-end\" onContextMenu={stopPropagation}>\n          <ScrollArea rootClassName=\"mr-14 flex-1 overflow-auto\" viewportClassName=\"px-5 py-3.5\">\n            <LexicalRichEditor\n              initalEditorState={initialDraftState}\n              ref={editorRef}\n              placeholder={\n                scene === \"onboarding\"\n                  ? \"Enter your message\"\n                  : `Ask anything about this ${mainEntryId ? \"entry\" : \"timeline\"}...`\n              }\n              className=\"h-14\"\n              onChange={handleEditorChange}\n              onKeyDown={handleKeyDown}\n              autoFocus\n              plugins={\n                scene === \"onboarding\"\n                  ? []\n                  : [MentionPlugin, ShortcutPlugin, FileUploadPlugin, SelectedTextPlugin]\n              }\n              namespace=\"AIChatRichEditor\"\n            />\n          </ScrollArea>\n          <div className=\"absolute right-3 top-3\">\n            <AIChatSendButton\n              onClick={isProcessing ? stop : handleSendClick}\n              disabled={isSubmitDisabled}\n              isProcessing={isProcessing}\n              size=\"sm\"\n            />\n          </div>\n        </div>\n\n        {/* Context Bar - only shown in non-onboarding scene, positioned below the input area */}\n        {scene !== \"onboarding\" && (\n          <div className=\"relative z-10 border-t border-border/20 bg-transparent\">\n            <div className=\"flex items-center justify-between px-4 py-2.5\">\n              <div className=\"min-w-0 flex-1 shrink\">\n                <AIChatContextBar className=\"border-0 bg-transparent p-0\" />\n              </div>\n              <div className=\"flex items-center gap-3 self-start\">\n                <AIModelIndicator className=\"-mr-1.5 ml-1 translate-y-[2px]\" />\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    )\n  },\n)\n\nChatInput.displayName = \"ChatInput\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx",
    "content": "import { useFocusable } from \"@follow/components/common/Focusable/hooks.js\"\nimport type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/types.js\"\nimport {\n  convertLexicalToMarkdown,\n  getEditorStateJSONString,\n} from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { isFreeRole } from \"@follow/constants\"\nimport { getCategoryFeedIds } from \"@follow/store/subscription/getter\"\nimport { usePrefetchSummary } from \"@follow/store/summary/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { detectIsEditableElement, nextFrame } from \"@follow/utils\"\nimport type { ConfigResponse } from \"@follow-app/client-sdk\"\nimport type { EditorState } from \"lexical\"\nimport { createEditor } from \"lexical\"\nimport { nanoid } from \"nanoid\"\nimport type { RefObject } from \"react\"\nimport { use, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useEventCallback, useEventListener } from \"usehooks-ts\"\n\nimport { useAISettingKey } from \"~/atoms/settings/ai\"\nimport { useActionLanguage } from \"~/atoms/settings/general\"\nimport { ErrorBoundary } from \"~/components/common/ErrorBoundary\"\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { useAutoScroll } from \"~/modules/ai-chat/hooks/useAutoScroll\"\nimport { useLoadMessages } from \"~/modules/ai-chat/hooks/useLoadMessages\"\nimport { useMainEntryId } from \"~/modules/ai-chat/hooks/useMainEntryId\"\nimport {\n  useBlockActions,\n  useChatActions,\n  useChatError,\n  useChatStatus,\n  useCurrentChatId,\n  useHasMessages,\n  useMessages,\n} from \"~/modules/ai-chat/store/hooks\"\n\nimport { LexicalAIEditorNodes } from \"../../editor\"\nimport { useAIConfiguration } from \"../../hooks/useAIConfiguration\"\nimport { useAttachScrollBeyond } from \"../../hooks/useAttachScrollBeyond\"\nimport { AIPanelRefsContext } from \"../../store/AIChatContext\"\nimport type { AIChatContextBlock, BizUIMessage, SendingUIMessage } from \"../../store/types\"\nimport { computeIsRateLimited, computeRateLimitMessage } from \"../../utils/rate-limit\"\nimport {\n  extractShortcutIdFromMessageParts,\n  extractShortcutIdFromSerializedState,\n  prefixMessageIdWithShortcut,\n} from \"../../utils/shortcut\"\nimport { GlobalFileDropZone } from \"../file/GlobalFileDropZone\"\nimport { AIErrorFallback } from \"./AIErrorFallback\"\nimport { ChatBottomPanel } from \"./ChatBottomPanel\"\nimport { ChatMessageContainer } from \"./ChatMessageContainer\"\n\nconst draftMessages = new Map<string, EditorState>()\nconst ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {\n  const hasMessages = useHasMessages()\n  const status = useChatStatus()\n  const chatActions = useChatActions()\n  const error = useChatError()\n  const messages = useMessages()\n  const { ensureLogin } = useRequireLogin()\n  const userRole = useUserRole()\n\n  const isFocusWithin = useFocusable()\n\n  const aiPanelRefs = use(AIPanelRefsContext)\n\n  useChatInputFocusHandler(isFocusWithin, aiPanelRefs.inputRef)\n  useLogChatError(error)\n\n  const currentChatId = useCurrentChatId()\n  const mainEntryId = useMainEntryId()\n  const actionLanguage = useActionLanguage()\n\n  usePrefetchSummary({\n    entryId: mainEntryId || \"\",\n    target: \"content\",\n    actionLanguage,\n    enabled: !!mainEntryId && !hasMessages,\n  })\n\n  const {\n    scrollAreaRef,\n    setScrollAreaRef,\n    messageContainerMinHeight,\n    messagesContentRef,\n    scrollContainerParentRef,\n    captureContentHeightBeforeSend,\n    handleScrollPositioning,\n    updateContentHeightSnapshot,\n  } = useChatScroller(currentChatId)\n\n  const { isLoading: isLoadingHistory, isSyncingRemote } = useLoadMessages(currentChatId || \"\", {\n    onLoad: () => {\n      nextFrame(() => {\n        const $scrollArea = scrollAreaRef\n        const scrollHeight = $scrollArea?.scrollHeight\n\n        if (scrollHeight) {\n          $scrollArea?.scrollTo({\n            top: scrollHeight,\n          })\n        }\n      })\n    },\n  })\n\n  const autoScrollWhenStreaming = useAISettingKey(\"autoScrollWhenStreaming\")\n\n  const { shouldShowInterruptionNotice, lastUserMessage } = useInterruptionNotice(messages, status)\n\n  const { resetScrollState } = useAutoScroll(\n    scrollAreaRef,\n    autoScrollWhenStreaming && status === \"streaming\",\n  )\n\n  const blockActions = useBlockActions()\n\n  const staticEditor = useMemo(() => {\n    return createEditor({\n      nodes: LexicalAIEditorNodes,\n    })\n  }, [])\n\n  const handleSendMessage = useEventCallback((message: string | EditorState) => {\n    if (!ensureLogin()) {\n      return\n    }\n    resetScrollState()\n\n    const blocks = [] as AIChatContextBlock[]\n\n    for (const block of blockActions.getBlocks()) {\n      if (block.type === \"fileAttachment\" && block.attachment.serverUrl) {\n        blocks.push({\n          ...block,\n          attachment: {\n            id: block.attachment.id,\n            name: block.attachment.name,\n            type: block.attachment.type,\n            size: block.attachment.size,\n            serverUrl: block.attachment.serverUrl,\n          },\n        })\n      } else if (block.type === \"mainFeed\" && block.value.startsWith(ROUTE_FEED_IN_FOLDER)) {\n        const categoryName = block.value.slice(ROUTE_FEED_IN_FOLDER.length)\n        const { view } = getRouteParams()\n        const feedIds = getCategoryFeedIds(categoryName, view)\n        blocks.push({\n          ...block,\n          value: feedIds.join(\",\"),\n        })\n      } else {\n        blocks.push(block)\n      }\n    }\n\n    const parts: BizUIMessage[\"parts\"] = [\n      {\n        type: \"data-block\",\n        data: blocks.filter((block) => !block.disabled),\n      },\n    ]\n\n    let shortcutIdFromMessage: string | undefined\n\n    if (typeof message === \"string\") {\n      parts.push({\n        type: \"data-rich-text\",\n        data: {\n          state: getEditorStateJSONString(message),\n          text: message,\n        },\n      })\n    } else {\n      staticEditor.setEditorState(message)\n      const serializedState = message.toJSON()\n      shortcutIdFromMessage = extractShortcutIdFromSerializedState(serializedState)\n      parts.push({\n        type: \"data-rich-text\",\n        data: {\n          state: JSON.stringify(serializedState),\n          text: convertLexicalToMarkdown(staticEditor),\n        },\n      })\n    }\n\n    captureContentHeightBeforeSend()\n    const messageId = prefixMessageIdWithShortcut(nanoid(), shortcutIdFromMessage)\n    const sendMessage: SendingUIMessage = {\n      parts,\n      role: \"user\",\n      id: messageId,\n    }\n    chatActions.sendMessage(sendMessage)\n    tracker.aiChatMessageSent()\n\n    // Clear draft message after sending\n    clearDraft()\n\n    nextFrame(() => {\n      // Calculate and adjust scroll positioning immediately\n      handleScrollPositioning()\n    })\n  })\n\n  const handleRetryLastMessage = useEventCallback(() => {\n    if (!ensureLogin()) {\n      return\n    }\n    if (!lastUserMessage) {\n      return\n    }\n\n    resetScrollState()\n\n    const clonedMessage = structuredClone(lastUserMessage)\n    const { createdAt: _createdAt, id: _originalId, ...rest } = clonedMessage\n    const retryMessage: SendingUIMessage = {\n      ...(rest as Omit<SendingUIMessage, \"id\">),\n      id: nanoid(),\n    }\n    const retryShortcutId = extractShortcutIdFromMessageParts(retryMessage.parts)\n    retryMessage.id = prefixMessageIdWithShortcut(retryMessage.id, retryShortcutId)\n\n    captureContentHeightBeforeSend()\n\n    chatActions.popMessage()\n    void chatActions.sendMessage(retryMessage)\n    tracker.aiChatMessageSent()\n\n    nextFrame(() => {\n      handleScrollPositioning()\n    })\n  })\n\n  const { handleDraftChange, initialDraft, clearDraft } = useChatDraft(currentChatId)\n\n  const [bottomPanelHeight, setBottomPanelHeight] = useState<number>(0)\n\n  useEffect(() => {\n    if (status === \"submitted\") {\n      resetScrollState()\n    }\n\n    // When AI response is complete, update the reference height but keep the container height unchanged\n    // This avoids CLS while ensuring next calculation is based on actual content\n    if (status === \"ready\" && scrollAreaRef && messagesContentRef.current) {\n      // Update the reference to actual content height for next calculation (use messages container)\n      updateContentHeightSnapshot()\n      // Keep the current minHeight unchanged to avoid CLS\n    }\n  }, [status, resetScrollState, scrollAreaRef, updateContentHeightSnapshot, messagesContentRef])\n\n  const { handleScroll } = useAttachScrollBeyond()\n\n  const { data: configuration } = useAIConfiguration()\n  const shouldHideResetDetails = userRole ? isFreeRole(userRole) : false\n\n  const { isRateLimited, rateLimitMessage } = useRateLimitInfo(\n    error,\n    configuration,\n    shouldHideResetDetails,\n  )\n\n  return (\n    <div className=\"flex size-full flex-col @container\">\n      <GlobalFileDropZone className=\"flex size-full flex-col @container\">\n        <div className=\"flex min-h-0 flex-1 flex-col\" ref={scrollContainerParentRef}>\n          <ChatMessageContainer\n            currentChatId={currentChatId}\n            hasMessages={hasMessages}\n            isLoadingHistory={isLoadingHistory}\n            isSyncingRemote={isSyncingRemote}\n            bottomPanelHeight={bottomPanelHeight}\n            messageContainerMinHeight={messageContainerMinHeight}\n            messagesContentRef={messagesContentRef}\n            onScroll={handleScroll}\n            setScrollAreaRef={setScrollAreaRef}\n            status={status}\n            centerInputOnEmpty={centerInputOnEmpty}\n            onScrollToBottom={resetScrollState}\n          />\n        </div>\n\n        <ChatBottomPanel\n          hasMessages={hasMessages}\n          centerInputOnEmpty={centerInputOnEmpty}\n          shouldShowInterruptionNotice={shouldShowInterruptionNotice}\n          rateLimitMessage={rateLimitMessage}\n          isRateLimited={isRateLimited}\n          onRetryLastMessage={handleRetryLastMessage}\n          onSendMessage={handleSendMessage}\n          initialDraftState={initialDraft}\n          onDraftChange={handleDraftChange}\n          onHeightChange={setBottomPanelHeight}\n        />\n      </GlobalFileDropZone>\n    </div>\n  )\n}\n\ninterface ChatInterfaceProps {\n  centerInputOnEmpty?: boolean\n  visualOffsetY?: string | number\n}\nexport const ChatInterface = (props: ChatInterfaceProps) => (\n  <ErrorBoundary fallback={AIErrorFallback}>\n    <ChatInterfaceContent {...props} />\n  </ErrorBoundary>\n)\n\nconst useChatInputFocusHandler = (\n  isFocusWithin: boolean,\n  inputRef?: RefObject<LexicalRichEditorRef>,\n) => {\n  useEventListener(\"keydown\", (event) => {\n    if (!isFocusWithin) {\n      return\n    }\n    const currentActiveElement = document.activeElement\n\n    if (detectIsEditableElement(currentActiveElement as HTMLElement)) {\n      return\n    }\n\n    if (event.shiftKey || event.metaKey || event.ctrlKey) {\n      return\n    }\n\n    inputRef?.current?.focus()\n  })\n}\n\nconst useLogChatError = (error: unknown) => {\n  useEffect(() => {\n    if (error) {\n      console.error(\"AIChat Error:\", error)\n    }\n  }, [error])\n}\n\nconst useInterruptionNotice = (\n  messages: BizUIMessage[],\n  status: ReturnType<typeof useChatStatus>,\n) => {\n  return useMemo(() => {\n    if (messages.length === 0) {\n      return {\n        shouldShowInterruptionNotice: false,\n        lastUserMessage: null as BizUIMessage | null,\n      }\n    }\n\n    const lastMessage = messages.at(-1)!\n    const shouldShow =\n      lastMessage.role === \"user\" &&\n      status !== \"streaming\" &&\n      status !== \"error\" &&\n      status !== \"submitted\"\n\n    return {\n      shouldShowInterruptionNotice: shouldShow,\n      lastUserMessage: shouldShow ? lastMessage : null,\n    }\n  }, [messages, status])\n}\n\nconst useChatDraft = (currentChatId?: string | null) => {\n  const handleDraftChange = useEventCallback((editorState: EditorState) => {\n    if (currentChatId) {\n      draftMessages.set(currentChatId, editorState)\n    }\n  })\n\n  const clearDraft = useEventCallback(() => {\n    if (currentChatId) {\n      draftMessages.delete(currentChatId)\n    }\n  })\n\n  const initialDraft = currentChatId ? draftMessages.get(currentChatId) : undefined\n\n  return {\n    handleDraftChange,\n    initialDraft,\n    clearDraft,\n  }\n}\n\nconst useRateLimitInfo = (\n  error: Error | string | undefined,\n  configuration: ConfigResponse | undefined,\n  shouldHideResetDetails: boolean,\n) => {\n  const isRateLimited = useMemo(\n    () => computeIsRateLimited(error, configuration),\n    [error, configuration],\n  )\n\n  const rateLimitMessage = useMemo(\n    () =>\n      computeRateLimitMessage(error, configuration, {\n        hideResetDetails: shouldHideResetDetails,\n      }),\n    [error, configuration, shouldHideResetDetails],\n  )\n\n  return {\n    isRateLimited,\n    rateLimitMessage,\n  }\n}\n\nconst useChatScroller = (currentChatId?: string | null) => {\n  const [scrollAreaRef, setScrollAreaRef] = useState<HTMLDivElement | null>(null)\n  const [messageContainerMinHeight, setMessageContainerMinHeight] = useState<number | undefined>()\n  const messagesContentRef = useRef<HTMLDivElement | null>(null)\n  const scrollContainerParentRef = useRef<HTMLDivElement | null>(null)\n  const scrollHeightBeforeSendingRef = useRef<number>(0)\n  const previousMinHeightRef = useRef<number>(0)\n\n  useEffect(() => {\n    setMessageContainerMinHeight(undefined)\n    previousMinHeightRef.current = 0\n  }, [currentChatId])\n\n  const captureContentHeightBeforeSend = useEventCallback(() => {\n    scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0\n  })\n\n  const handleScrollPositioning = useEventCallback(() => {\n    const $scrollContainerParent = scrollContainerParentRef.current\n    if (!scrollAreaRef || !$scrollContainerParent) return\n\n    const parentClientHeight = $scrollContainerParent.clientHeight\n    const currentScrollHeight = scrollHeightBeforeSendingRef.current\n    const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight)\n    const newMinHeight = baseHeight + parentClientHeight - 250\n\n    setMessageContainerMinHeight(newMinHeight)\n\n    nextFrame(() => {\n      scrollAreaRef.scrollTo({\n        top: scrollAreaRef.scrollHeight,\n        behavior: \"instant\",\n      })\n    })\n  })\n\n  const updateContentHeightSnapshot = useEventCallback(() => {\n    if (scrollAreaRef && messagesContentRef.current) {\n      previousMinHeightRef.current = messagesContentRef.current.scrollHeight\n    }\n  })\n\n  return {\n    scrollAreaRef,\n    setScrollAreaRef,\n    messageContainerMinHeight,\n    messagesContentRef,\n    scrollContainerParentRef,\n    captureContentHeightBeforeSend,\n    handleScrollPositioning,\n    updateContentHeightSnapshot,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatMessageContainer.tsx",
    "content": "import { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { AnimatePresence } from \"motion/react\"\nimport type { RefObject, UIEventHandler } from \"react\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\n\nimport { WelcomeScreen } from \"~/modules/ai-chat/components/layouts/WelcomeScreen\"\nimport { AIChatWaitingIndicator } from \"~/modules/ai-chat/components/message/AIChatMessage\"\nimport type { ChatStatus } from \"~/modules/ai-chat/store/slices\"\n\nimport { Messages } from \"./Messages\"\nimport { ScrollToBottomButton } from \"./ScrollToBottomButton\"\n\nconst SCROLL_BOTTOM_THRESHOLD = 100\n\ninterface ChatMessageContainerProps {\n  currentChatId: string | null\n  hasMessages: boolean\n  isLoadingHistory: boolean\n  isSyncingRemote: boolean\n  bottomPanelHeight: number\n  messageContainerMinHeight?: number\n  messagesContentRef: RefObject<HTMLDivElement | null>\n  onScroll: UIEventHandler<HTMLDivElement>\n  setScrollAreaRef: (instance: HTMLDivElement | null) => void\n  status: ChatStatus\n  centerInputOnEmpty?: boolean\n  onScrollToBottom: () => void\n}\n\nexport const ChatMessageContainer = ({\n  currentChatId,\n  hasMessages,\n  isLoadingHistory,\n  isSyncingRemote,\n  bottomPanelHeight,\n  messageContainerMinHeight,\n  messagesContentRef,\n  onScroll,\n  setScrollAreaRef,\n  status,\n  centerInputOnEmpty,\n  onScrollToBottom,\n}: ChatMessageContainerProps) => {\n  const [isAtBottom, setIsAtBottom] = useState(true)\n\n  useEffect(() => {\n    setIsAtBottom(true)\n  }, [currentChatId])\n\n  const shouldShowLoadingOverlay = useMemo(() => {\n    return Boolean(currentChatId) && !hasMessages && (isLoadingHistory || isSyncingRemote)\n  }, [currentChatId, hasMessages, isLoadingHistory, isSyncingRemote])\n\n  const shouldShowScrollToBottom = useMemo(() => {\n    return hasMessages && !isAtBottom && !isLoadingHistory\n  }, [hasMessages, isAtBottom, isLoadingHistory])\n\n  const handleScrollEvent = useCallback<UIEventHandler<HTMLDivElement>>(\n    (event) => {\n      const { scrollTop, scrollHeight, clientHeight } = event.currentTarget\n      const distanceFromBottom = scrollHeight - scrollTop - clientHeight\n      const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD\n      if (atBottom !== isAtBottom) {\n        setIsAtBottom(atBottom)\n      }\n      onScroll(event)\n    },\n    [isAtBottom, onScroll],\n  )\n\n  return (\n    <>\n      <AnimatePresence>\n        {!hasMessages && !shouldShowLoadingOverlay ? (\n          <WelcomeScreen centerInputOnEmpty={centerInputOnEmpty} />\n        ) : (\n          <>\n            {shouldShowLoadingOverlay ? (\n              <div className=\"absolute inset-0 flex items-center justify-center\">\n                <div className=\"flex -translate-y-24 flex-col items-center space-y-2\">\n                  <i className=\"i-mgc-loading-3-cute-re size-8 animate-spin text-text\" />\n                  {isSyncingRemote && (\n                    <p className=\"text-sm text-text-secondary\">Syncing messages from server...</p>\n                  )}\n                </div>\n              </div>\n            ) : null}\n            <ScrollArea\n              onScroll={handleScrollEvent}\n              flex\n              scrollbarClassName=\"mt-12\"\n              scrollbarProps={{\n                style: {\n                  marginBottom: Math.max(160, bottomPanelHeight),\n                },\n              }}\n              ref={setScrollAreaRef}\n              rootClassName=\"flex-1\"\n              viewportProps={{\n                style: {\n                  paddingBottom: Math.max(128, bottomPanelHeight),\n                },\n              }}\n              viewportClassName=\"pt-12\"\n            >\n              <div\n                className=\"mx-auto w-full max-w-4xl px-6 py-8\"\n                style={{\n                  minHeight: messageContainerMinHeight\n                    ? `${messageContainerMinHeight}px`\n                    : undefined,\n                }}\n              >\n                <Messages contentRef={messagesContentRef} />\n                {(status === \"submitted\" || status === \"streaming\") && <AIChatWaitingIndicator />}\n              </div>\n            </ScrollArea>\n          </>\n        )}\n      </AnimatePresence>\n      {shouldShowScrollToBottom && <ScrollToBottomButton onClick={onScrollToBottom} />}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatMoreDropdown.tsx",
    "content": "import type { ReactNode } from \"react\"\nimport { useCallback, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\n\nimport { useChatActions, useCurrentChatId, useMessages } from \"../../store/hooks\"\nimport { generateAndUpdateChatTitle } from \"../../utils/titleGeneration\"\n\nexport const ChatMoreDropdown = ({\n  triggerElement,\n  asChild = true,\n  canClosePanel = true,\n}: {\n  triggerElement: ReactNode\n  asChild?: boolean\n  canClosePanel?: boolean\n}) => {\n  const settingModalPresent = useSettingModal()\n  const chatActions = useChatActions()\n  const currentChatId = useCurrentChatId()\n\n  const messages = useMessages()\n  const [isGenerating, setIsGenerating] = useState(false)\n  const { t } = useTranslation(\"ai\")\n\n  const handleCloseSidebar = useRef(() => {\n    setAIPanelVisibility(false)\n  }).current\n\n  const handleGenerateTitle = useCallback(\n    async (e: React.MouseEvent) => {\n      e.stopPropagation()\n      if (!currentChatId || messages.length === 0 || isGenerating) {\n        return\n      }\n\n      setIsGenerating(true)\n      try {\n        await generateAndUpdateChatTitle(currentChatId, messages.slice(-2), (newTitle) => {\n          chatActions.setCurrentTitle(newTitle)\n        })\n      } catch (error) {\n        console.error(\"Failed to generate title:\", error)\n      } finally {\n        setIsGenerating(false)\n      }\n    },\n    [currentChatId, messages, chatActions, isGenerating],\n  )\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild={asChild}>{triggerElement}</DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-56\">\n        <DropdownMenuItem\n          onClick={handleGenerateTitle}\n          disabled={!currentChatId || messages.length === 0 || isGenerating}\n        >\n          <i className=\"i-mgc-magic-2-cute-re mr-2 size-4\" />\n          <span>{isGenerating ? t(\"common.generating_title\") : t(\"common.generate_title\")}</span>\n        </DropdownMenuItem>\n\n        <DropdownMenuSeparator />\n        <DropdownMenuItem onClick={() => settingModalPresent(\"ai\")}>\n          <i className=\"i-mgc-settings-1-cute-re mr-2 size-4\" />\n          <span>AI Settings</span>\n        </DropdownMenuItem>\n\n        {canClosePanel && (\n          <>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem onClick={handleCloseSidebar}>\n              <i className=\"i-mgc-close-cute-re mr-2 size-4\" />\n              <span>Close Sidebar</span>\n            </DropdownMenuItem>\n          </>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatShortcutsRow.tsx",
    "content": "import { getReadonlyRoute } from \"@follow/components/atoms/route.js\"\nimport { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from \"@follow/shared/settings/defaults\"\nimport type { AIShortcut } from \"@follow/shared/settings/interface\"\nimport { DEFAULT_SHORTCUT_TARGETS } from \"@follow/shared/settings/interface\"\nimport { cn } from \"@follow/utils\"\nimport type { TFunction } from \"i18next\"\nimport { useCallback, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { MenuItemText, useShowContextMenu } from \"~/atoms/context-menu\"\nimport { getAISettings, setAISetting, useAISettingValue } from \"~/atoms/settings/ai\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\nimport {\n  useCreateAIShortcutModal,\n  useEditAIShortcutModal,\n} from \"~/modules/settings/tabs/ai/shortcuts/hooks\"\n\nimport type { ShortcutData } from \"../../editor/plugins/shortcut/types\"\nimport { useMainEntryId } from \"../../hooks/useMainEntryId\"\nimport { AIShortcutButton } from \"../ui/AIShortcutButton\"\nimport { ShortcutTooltip } from \"../ui/ShortcutTooltip\"\n\ninterface ChatShortcutsRowProps {\n  onSelect: (shortcutData: ShortcutData) => void\n}\n\nexport const ChatShortcutsRow: React.FC<ChatShortcutsRowProps> = ({ onSelect }) => {\n  const { t } = useTranslation(\"ai\")\n  const aiSettings = useAISettingValue()\n  const mainEntryId = useMainEntryId()\n  const isAiPage = useMemo(() => getReadonlyRoute().location.pathname === \"/ai\", [])\n\n  const shortcutsToDisplay = useMemo(() => {\n    const shortcuts = aiSettings.shortcuts ?? []\n    const list: typeof shortcuts = []\n    const entry: typeof shortcuts = []\n    const aiPage: typeof shortcuts = []\n    for (const shortcut of shortcuts) {\n      if (!shortcut.enabled) continue\n      const targets =\n        shortcut.displayTargets && shortcut.displayTargets.length > 0\n          ? shortcut.displayTargets\n          : DEFAULT_SHORTCUT_TARGETS\n      if (targets.includes(\"list\")) {\n        list.push(shortcut)\n      }\n      if (targets.includes(\"entry\")) {\n        entry.push(shortcut)\n      }\n      aiPage.push(shortcut)\n    }\n\n    if (mainEntryId) {\n      return entry\n    }\n    if (isAiPage) {\n      return aiPage\n    }\n    return list\n  }, [aiSettings.shortcuts, mainEntryId, isAiPage])\n\n  const handleAddShortcut = useCreateAIShortcutModal()\n  const handleEditShortcut = useEditAIShortcutModal()\n\n  const handleDisableShortcut = useCallback((shortcutId: string) => {\n    const { shortcuts = [] } = getAISettings()\n    setAISetting(\n      \"shortcuts\",\n      shortcuts.map((shortcut) =>\n        shortcut.id === shortcutId ? { ...shortcut, enabled: false } : shortcut,\n      ),\n    )\n  }, [])\n\n  const handleDeleteShortcut = useCallback(\n    (shortcutId: string) => {\n      const { shortcuts = [] } = getAISettings()\n      setAISetting(\n        \"shortcuts\",\n        shortcuts.filter((shortcut) => shortcut.id !== shortcutId),\n      )\n      toast.success(t(\"shortcuts.deleted\"))\n    },\n    [t],\n  )\n\n  const handleCustomize = useCallback(() => {\n    handleAddShortcut()\n  }, [handleAddShortcut])\n\n  return (\n    <div className=\"mb-3 px-1\">\n      <div className=\"flex flex-nowrap items-center gap-2 overflow-x-auto py-1\">\n        <AIShortcutButton\n          className={cn(shortcutsToDisplay.length > 0 ? \"aspect-square rounded-full p-2\" : \"\")}\n          onClick={handleCustomize}\n          animationDelay={0}\n          size=\"sm\"\n          title={t(\"new_shortcuts\")}\n        >\n          <i className=\"i-mgc-add-cute-re\" />\n          <span className={shortcutsToDisplay.length > 0 ? \"sr-only\" : \"text-text\"}>\n            {t(\"new_shortcuts\")}\n          </span>\n        </AIShortcutButton>\n        {shortcutsToDisplay.map((shortcut) => (\n          <ShortcutMenuButton\n            key={shortcut.id}\n            shortcut={shortcut}\n            onSelect={onSelect}\n            onEdit={handleEditShortcut}\n            onDisable={handleDisableShortcut}\n            onDelete={handleDeleteShortcut}\n            t={t}\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n\ninterface ShortcutMenuButtonProps {\n  shortcut: AIShortcut\n  onSelect: (shortcutData: ShortcutData) => void\n  onEdit: (shortcut: AIShortcut) => void\n  onDisable: (shortcutId: string) => void\n  onDelete: (shortcutId: string) => void\n  t: TFunction<\"ai\">\n}\n\nconst ShortcutMenuButton: React.FC<ShortcutMenuButtonProps> = ({\n  shortcut,\n  onSelect,\n  onEdit,\n  onDisable,\n  onDelete,\n  t,\n}) => {\n  const showContextMenu = useShowContextMenu()\n  const contextMenuProps = useContextMenu({\n    onContextMenu: async (event) => {\n      event.preventDefault()\n      event.stopPropagation()\n\n      const isProtected =\n        !!shortcut.defaultPrompt || shortcut.id === DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID\n\n      await showContextMenu(\n        [\n          new MenuItemText({\n            label: t(\"shortcuts.actions.edit\"),\n            click: () => onEdit(shortcut),\n            requiresLogin: true,\n          }),\n          new MenuItemText({\n            label: t(\"shortcuts.actions.disable\"),\n            click: () => onDisable(shortcut.id),\n            requiresLogin: true,\n          }),\n          !isProtected\n            ? new MenuItemText({\n                label: t(\"shortcuts.actions.delete\"),\n                click: () => onDelete(shortcut.id),\n                requiresLogin: true,\n              })\n            : null,\n        ],\n        event,\n      )\n    },\n  })\n\n  return (\n    <div {...contextMenuProps}>\n      <ShortcutTooltip\n        name={shortcut.name}\n        prompt={shortcut.prompt || shortcut.defaultPrompt}\n        hotkey={shortcut.hotkey}\n      >\n        <AIShortcutButton onClick={() => onSelect(shortcut)} animationDelay={0} size=\"sm\">\n          <span className=\"flex items-center gap-1\">\n            {shortcut.icon ? (\n              <i className={shortcut.icon} />\n            ) : (\n              <i className=\"i-mgc-hotkey-cute-re\" />\n            )}\n            <span>{shortcut.name}</span>\n          </span>\n        </AIShortcutButton>\n      </ShortcutTooltip>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatTitle.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { ButtonHTMLAttributes } from \"react\"\n\ninterface AIHeaderTitleProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"type\"> {\n  title?: string\n  placeholder?: string\n  onTitleSave?: (newTitle: string) => Promise<void>\n}\n\nexport const AIHeaderTitle = ({\n  ref,\n  title = \"\",\n  placeholder = \"Untitled Chat\",\n  className,\n  onTitleSave,\n  ...buttonProps\n}: AIHeaderTitleProps & { ref?: React.RefObject<HTMLButtonElement | null> }) => {\n  const displayTitle = title || placeholder\n  const { [\"aria-label\"]: ariaLabelProp, ...restButtonProps } = buttonProps\n  const ariaLabel = ariaLabelProp ?? displayTitle\n\n  return (\n    <div className=\"group relative flex min-w-0 flex-1 items-center gap-2\">\n      <button\n        {...restButtonProps}\n        ref={ref}\n        type=\"button\"\n        aria-haspopup=\"menu\"\n        aria-label={ariaLabel}\n        className={cn(\n          \"group/button no-drag-region flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md bg-transparent p-0 text-left\",\n          \"outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          className,\n        )}\n      >\n        <h1 className=\"truncate font-bold text-text\">\n          <span className=\"animate-mask-left-to-right [--animation-duration:1s]\">\n            {displayTitle}\n          </span>\n        </h1>\n        <i className=\"i-mingcute-down-line size-4 shrink-0 text-text-secondary transition-all duration-200 group-hover/button:text-text group-data-[state=open]:rotate-180 group-data-[state=open]:text-text\" />\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/Messages.tsx",
    "content": "import { useElementWidth } from \"@follow/hooks\"\nimport type { CSSProperties, FC, RefObject } from \"react\"\nimport { Suspense, useRef } from \"react\"\n\nimport { AIChatMessage } from \"~/modules/ai-chat/components/message/AIChatMessage\"\nimport { ErrorMessage } from \"~/modules/ai-chat/components/message/ErrorMessage\"\nimport { UserChatMessage } from \"~/modules/ai-chat/components/message/UserChatMessage\"\nimport { useChatError, useMessages } from \"~/modules/ai-chat/store/hooks\"\n\ninterface MessagesProps {\n  contentRef?: RefObject<HTMLDivElement | null>\n}\n\nexport const Messages: FC<MessagesProps> = ({ contentRef }) => {\n  const messages = useMessages()\n  const error = useChatError()\n  const fallbackRef = useRef<HTMLDivElement>(null)\n  const effectiveRef = contentRef ?? fallbackRef\n\n  const messageContainerWidth = useElementWidth(effectiveRef)\n\n  const style = messageContainerWidth\n    ? ({ \"--ai-chat-message-container-width\": `${messageContainerWidth}px` } as CSSProperties)\n    : undefined\n\n  return (\n    <div ref={effectiveRef} className=\"relative flex min-w-0 flex-1 flex-col\" style={style}>\n      {!!messageContainerWidth &&\n        messages.map((message, index) => {\n          const isLastMessage = index === messages.length - 1\n          return (\n            <Suspense key={message.id}>\n              {message.role === \"user\" ? (\n                <UserChatMessage message={message} />\n              ) : (\n                <AIChatMessage message={message} isLastMessage={isLastMessage} />\n              )}\n            </Suspense>\n          )\n        })}\n      {!!messageContainerWidth && error && <ErrorMessage error={error} />}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/RateLimitNotice.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\n\nimport { useIsInMASReview } from \"~/atoms/server-configs\"\nimport { useI18n } from \"~/hooks/common/useI18n\"\nimport { useSettingModal } from \"~/modules/settings/modal/useSettingModal\"\n\ninterface RateLimitNoticeProps {\n  className?: string\n  message?: string | null\n}\n\n/**\n * RateLimitNotice component\n * Displays rate limit information above the input in a subtle, non-alarming way\n */\nexport const RateLimitNotice = ({ className, message }: RateLimitNoticeProps) => {\n  const t = useI18n()\n  const settingModalPresent = useSettingModal()\n  const isInMASReview = useIsInMASReview()\n\n  if (!message || isInMASReview) {\n    return\n  }\n\n  return (\n    <m.button\n      initial={{ opacity: 0, y: -10 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, y: -10 }}\n      transition={{ duration: 0.2 }}\n      className={cn(\"mb-3 block w-full text-left\", className)}\n      onClick={() => settingModalPresent(\"plan\")}\n    >\n      <div className=\"flex items-center gap-2 rounded-lg border border-border bg-material-ultra-thick px-3 py-2 backdrop-blur-background\">\n        <i className=\"i-mgc-power size-4 flex-shrink-0 text-text\" />\n        <span className=\"min-w-0 flex-1 truncate text-xs text-text-secondary\">{message}</span>\n\n        <button\n          type=\"button\"\n          className=\"cursor-button text-xs text-accent duration-200 hover:opacity-80\"\n        >\n          {t.ai(\"rate_limit.upgrade_plan_button\" as any)}\n        </button>\n      </div>\n    </m.button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx",
    "content": "import { clsx, cn } from \"@follow/utils\"\n\ninterface ScrollToBottomButtonProps {\n  onClick: () => void\n}\n\nexport const ScrollToBottomButton = ({ onClick }: ScrollToBottomButtonProps) => {\n  return (\n    <div className={clsx(\"absolute right-1/2 z-40 translate-x-1/2\", \"bottom-48 -translate-y-2\")}>\n      <button\n        type=\"button\"\n        onClick={onClick}\n        className={cn(\n          \"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2\",\n          \"border-border\",\n          \"hover:border-border/60 active:scale-[0.98]\",\n        )}\n      >\n        <i className=\"i-mingcute-arrow-down-line text-text/90\" />\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx",
    "content": "import { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { nextFrame } from \"@follow/utils\"\nimport type { ReactElement } from \"react\"\nimport { useMemo, useState } from \"react\"\nimport { toast } from \"sonner\"\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAIChatSessionListQuery } from \"~/modules/ai-chat-session/query\"\nimport { AITaskModal, useAITaskListQuery, useCanCreateNewAITask } from \"~/modules/ai-task\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\nimport { AI_SETTING_SECTION_IDS } from \"~/modules/settings/tabs/ai\"\n\nimport {\n  EmptyState,\n  isTaskSession,\n  isUnreadSession,\n  SessionItem,\n  useChatSessionHandlers,\n} from \"./shared\"\n\ninterface TaskReportDropdownProps {\n  triggerElement?: ReactElement\n  asChild?: boolean\n}\n\nexport const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskReportDropdownProps) => {\n  const tasks = useAITaskListQuery()\n  const sessions = useAIChatSessionListQuery({\n    refetchInterval: tasks?.length ? 1 * 60 * 1000 : false, // 1 minute\n  })\n  const [loadingChatId, setLoadingChatId] = useState<string | null>(null)\n  const showSettings = useSettingModal()\n\n  // Only keep task sessions for display\n  const taskSessions = useMemo(() => (sessions || []).filter((s) => isTaskSession(s)), [sessions])\n  const hasTaskSessions = taskSessions.length > 0\n  const hasUnreadSessions = useMemo(\n    () => taskSessions.some((s) => isUnreadSession(s)),\n    [taskSessions],\n  )\n\n  // Call all hooks at the top level, never conditionally\n  const { present } = useModalStack()\n  const canCreateNewTask = useCanCreateNewAITask()\n  const { handleSessionSelect, handleDeleteSession } = useChatSessionHandlers({\n    sessions: taskSessions,\n  })\n\n  // If no unread sessions, don't render the button (only when no custom trigger)\n  if (!hasUnreadSessions && !triggerElement) {\n    return null\n  }\n\n  const handleScheduleActionClick = () => {\n    if (!canCreateNewTask) {\n      toast.error(\"Please remove an existing task before creating a new one.\")\n      return\n    }\n    showSettings({ tab: \"ai\", section: AI_SETTING_SECTION_IDS.tasks })\n    nextFrame(() => {\n      present({\n        title: \"New AI Task\",\n        canClose: true,\n        content: () => <AITaskModal showSettingsTip />,\n      })\n    })\n  }\n\n  const defaultTrigger = (\n    <ActionButton tooltip=\"Task Reports\" className=\"relative\">\n      <i className=\"i-mgc-calendar-time-add-cute-re size-5 text-text-secondary\" />\n      {hasUnreadSessions && (\n        <span\n          className=\"absolute right-1 top-1 block size-2 rounded-full bg-accent shadow-[0_0_0_2px_var(--color-bg-default)] dark:shadow-[0_0_0_2px_var(--color-bg-default)]\"\n          aria-label=\"Unread task reports\"\n        />\n      )}\n    </ActionButton>\n  )\n\n  return (\n    <DropdownMenu>\n      {asChild ? (\n        <DropdownMenuTrigger asChild={asChild}>\n          {triggerElement || defaultTrigger}\n        </DropdownMenuTrigger>\n      ) : (\n        <DropdownMenuTrigger className=\"relative\">\n          {triggerElement || defaultTrigger}\n          {hasTaskSessions && triggerElement && (\n            <span\n              className=\"absolute right-1 top-1 block size-2 rounded-full bg-accent shadow-[0_0_0_2px_var(--color-bg-default)] dark:shadow-[0_0_0_2px_var(--color-bg-default)]\"\n              aria-label=\"Unread task reports\"\n            />\n          )}\n        </DropdownMenuTrigger>\n      )}\n\n      <DropdownMenuContent align=\"end\" className=\"w-80\">\n        {taskSessions.length > 0 ? (\n          taskSessions.map((session) => (\n            <SessionItem\n              key={session.chatId}\n              session={session}\n              onClick={() => handleSessionSelect(session)}\n              onDelete={(e) => {\n                handleDeleteSession(session.chatId, {\n                  event: e,\n                  onBeforeDelete: () => setLoadingChatId(session.chatId),\n                }).finally(() => {\n                  setLoadingChatId(null)\n                })\n              }}\n              isLoading={loadingChatId === session.chatId}\n              hasUnread={hasUnreadSessions}\n            />\n          ))\n        ) : (\n          <EmptyState\n            message=\"No unread task reports\"\n            icon={\n              <i className=\"i-mgc-calendar-time-add-cute-re mb-2 block size-8 text-text-secondary\" />\n            }\n          />\n        )}\n\n        {canCreateNewTask && (\n          <>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem onClick={handleScheduleActionClick}>\n              <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n              New Task\n            </DropdownMenuItem>\n          </>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/WelcomeScreen.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { clsx } from \"@follow/utils\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { AISpline } from \"~/modules/ai-chat/components/3d-models/AISpline\"\n\nimport { useAttachScrollBeyond } from \"../../hooks/useAttachScrollBeyond\"\nimport { useMainEntryId } from \"../../hooks/useMainEntryId\"\nimport { DefaultWelcomeContent, EntryWelcomeContent } from \"../welcome\"\n\ninterface WelcomeScreenProps {\n  centerInputOnEmpty?: boolean\n}\n\nexport const WelcomeScreen = ({ centerInputOnEmpty }: WelcomeScreenProps) => {\n  const { t } = useTranslation(\"ai\")\n  const mainEntryId = useMainEntryId()\n  const hasEntryContext = Boolean(mainEntryId)\n\n  const { handleScroll } = useAttachScrollBeyond()\n\n  return (\n    <ScrollArea\n      rootClassName=\"flex min-h-0 flex-1\"\n      viewportClassName=\"px-6 pt-24 flex min-h-0 grow\"\n      scrollbarClassName=\"mb-40 mt-12\"\n      flex\n      onScroll={handleScroll}\n    >\n      <div className=\"mx-auto flex w-full flex-1 flex-col justify-center space-y-8 pb-52\">\n        <DefaultWelcomeHeader\n          description={\n            hasEntryContext ? t(\"welcome_description_contextual\") : t(\"welcome_description\")\n          }\n        />\n\n        {/* Dynamic Content Area */}\n        <div\n          className={clsx(\n            \"relative flex items-start justify-center\",\n            centerInputOnEmpty && \"absolute bottom-0 translate-y-40\",\n          )}\n        >\n          <AnimatePresence mode=\"wait\">\n            {hasEntryContext && mainEntryId ? (\n              <EntryWelcomeContent key=\"entry-welcome\" entryId={mainEntryId} />\n            ) : (\n              <DefaultWelcomeContent key=\"default-welcome\" />\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n    </ScrollArea>\n  )\n}\n\nconst DefaultWelcomeHeader = ({ description }: { description: string }) => (\n  <m.div\n    initial={{ opacity: 0, y: -20 }}\n    animate={{ opacity: 1, y: 0 }}\n    className=\"space-y-6 text-center\"\n  >\n    <div data-testid=\"welcome-screen-header\">\n      <div className=\"center\">\n        <AISpline />\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <h1 className=\"flex items-center justify-center gap-2 text-2xl font-semibold text-text\">\n          <Folo className=\"size-11\" /> AI\n        </h1>\n\n        <p className=\"text-balance text-sm text-text-secondary\">{description}</p>\n      </div>\n    </div>\n  </m.div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/ChatSessionComponents.tsx",
    "content": "import type { AIChatSession } from \"@follow-app/client-sdk\"\nimport type { ReactNode } from \"react\"\n\nimport { RelativeDay } from \"~/components/ui/datetime\"\nimport { DropdownMenuItem } from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport type { ChatSession } from \"~/modules/ai-chat/types/ChatSession\"\n\nimport { isUnreadSession } from \"./utils\"\n\n// Types\nexport interface SessionItemProps {\n  session: ChatSession | AIChatSession\n  onClick?: () => void\n  onDelete?: (e: React.MouseEvent) => void\n  isLoading?: boolean\n  hasUnread?: boolean\n}\n\nexport interface EmptyStateProps {\n  message: string\n  icon?: ReactNode\n}\n\nexport const SessionItem = ({\n  session,\n  onClick,\n  onDelete,\n\n  isLoading = false,\n  hasUnread = false,\n}: SessionItemProps) => {\n  const hasUnreadMessages = isUnreadSession(session)\n  return (\n    <DropdownMenuItem\n      onClick={onClick}\n      className={`group relative ${onClick ? \"cursor-pointer\" : \"cursor-default\"}`}\n    >\n      <div className=\"ml-1 flex min-w-0 flex-1 justify-between\">\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          {hasUnreadMessages && (\n            <span\n              className=\"absolute left-2 block size-2 shrink-0 rounded-full bg-accent group-hover:bg-white\"\n              aria-label=\"Unread\"\n              role=\"status\"\n            />\n          )}\n          <p className={`mb-0.5 truncate font-medium ${hasUnread ? \"ml-2\" : \"\"}`}>\n            {session.title || \"Untitled Chat\"}\n          </p>\n        </div>\n        <div className=\"relative flex min-w-0 items-center\">\n          <p className=\"ml-2 shrink-0 truncate text-xs text-text-secondary\">\n            <RelativeDay date={new Date(session.updatedAt)} />\n          </p>\n          {onDelete && (\n            <button\n              type=\"button\"\n              onClick={onDelete}\n              disabled={isLoading}\n              className=\"absolute inset-y-0 right-0 flex items-center rounded-md bg-accent px-2 py-1 text-white opacity-0 group-data-[highlighted]:text-white group-data-[highlighted]:opacity-100\"\n            >\n              {isLoading ? (\n                <i className=\"i-mgc-loading-3-cute-re size-4 animate-spin\" />\n              ) : (\n                <i className=\"i-mgc-delete-2-cute-re size-4\" />\n              )}\n            </button>\n          )}\n        </div>\n      </div>\n    </DropdownMenuItem>\n  )\n}\n\nexport const EmptyState = ({ message, icon }: EmptyStateProps) => {\n  return (\n    <div className=\"flex flex-col items-center py-8 text-center\">\n      {icon || <i className=\"i-mgc-time-cute-re mb-2 block size-8 text-text-secondary\" />}\n      <p className=\"text-sm text-text-secondary\">{message}</p>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/index.ts",
    "content": "export {\n  EmptyState,\n  type EmptyStateProps,\n  SessionItem,\n  type SessionItemProps,\n} from \"./ChatSessionComponents\"\nexport { useChatSessionHandlers, type UseChatSessionHandlersProps } from \"./useChatSessionHandlers\"\nexport { isTaskSession, isUnreadSession } from \"./utils\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/useChatSessionHandlers.tsx",
    "content": "import type { AIChatSession } from \"@follow-app/client-sdk\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useDialog } from \"~/components/ui/modal/stacked/hooks\"\nimport { useTimelineSummaryAutoContext } from \"~/modules/ai-chat/hooks/useTimelineSummaryAutoContext\"\nimport { AIPersistService } from \"~/modules/ai-chat/services\"\nimport { useChatActions, useCurrentChatId } from \"~/modules/ai-chat/store/hooks\"\nimport type { ChatSession } from \"~/modules/ai-chat/types/ChatSession\"\nimport { AIChatSessionService } from \"~/modules/ai-chat-session\"\nimport {\n  useDeleteAIChatSessionMutation,\n  useMarkChatSessionSeenMutation,\n} from \"~/modules/ai-chat-session/query\"\n\nimport { isUnreadSession } from \"./utils\"\n\nexport interface UseChatSessionHandlersProps {\n  sessions?: (ChatSession | AIChatSession)[]\n}\n\nexport const useChatSessionHandlers = ({ sessions = [] }: UseChatSessionHandlersProps) => {\n  const { t } = useTranslation(\"ai\")\n  const chatActions = useChatActions()\n  const currentChatId = useCurrentChatId()\n  const shouldDisableTimelineSummary = useTimelineSummaryAutoContext()\n  const { ask } = useDialog()\n  const deleteSessionMutation = useDeleteAIChatSessionMutation()\n  const markChatSessionSeenMutation = useMarkChatSessionSeenMutation()\n\n  const handleSessionSelect = useCallback(\n    async (session: ChatSession | AIChatSession) => {\n      if (session.chatId === currentChatId) {\n        console.warn(\"Session already active, no action taken\")\n        return\n      }\n\n      // Only sync AI chat sessions (not local ChatSession)\n      if (\"userId\" in session) {\n        try {\n          await AIChatSessionService.fetchAndPersistMessages(session as AIChatSession)\n        } catch (e) {\n          console.error(\"Failed to sync chat session messages:\", e)\n          toast.error(\"Failed to load chat messages\")\n        }\n      }\n\n      if (shouldDisableTimelineSummary) {\n        chatActions.setTimelineSummaryManualOverride(true)\n      }\n      chatActions.switchToChat(session.chatId)\n\n      // Mark as seen only for AI chat sessions\n      if (\"userId\" in session && isUnreadSession(session)) {\n        markChatSessionSeenMutation.mutate({\n          chatId: session.chatId,\n          lastSeenAt: new Date().toISOString(),\n        })\n      }\n    },\n    [chatActions, currentChatId, markChatSessionSeenMutation, shouldDisableTimelineSummary],\n  )\n\n  const handleDeleteSession = useCallback(\n    async (\n      chatId: string,\n      options: {\n        event?: React.MouseEvent\n        onBeforeDelete?: () => void\n      } = {},\n    ) => {\n      options.event?.stopPropagation()\n      options.event?.preventDefault()\n\n      const session = sessions?.find((s) => s.chatId === chatId)\n      if (!session) return\n\n      const confirm = await ask({\n        title: t(\"delete_chat\"),\n        message: t(\"delete_chat_message\", { title: session.title || \"Untitled Chat\" }),\n        variant: \"danger\",\n      })\n\n      if (!confirm) return\n      options.onBeforeDelete?.()\n\n      try {\n        // Only delete AI chat sessions through the mutation\n        if (\"userId\" in session) {\n          await deleteSessionMutation.mutateAsync({ chatId })\n        }\n\n        // Always delete from local persistence\n        await AIPersistService.deleteSession(chatId)\n\n        toast.success(t(\"delete_chat_success\"))\n\n        if (chatId === currentChatId) {\n          if (shouldDisableTimelineSummary) {\n            chatActions.setTimelineSummaryManualOverride(true)\n          }\n          chatActions.newChat()\n        }\n      } catch (error) {\n        console.error(\"Failed to delete session:\", error)\n        toast.error(t(\"delete_chat_error\"))\n      }\n    },\n    [\n      sessions,\n      ask,\n      t,\n      currentChatId,\n      chatActions,\n      deleteSessionMutation,\n      shouldDisableTimelineSummary,\n    ],\n  )\n\n  return {\n    handleSessionSelect,\n    handleDeleteSession,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/shared/utils.ts",
    "content": "import type { AIChatSession } from \"@follow-app/client-sdk\"\n\nimport type { ChatSession } from \"~/modules/ai-chat/types/ChatSession\"\n\nexport const isTaskSession = (session: ChatSession | AIChatSession): boolean => {\n  if (!(\"lastSeenAt\" in session) || !(\"updatedAt\" in session)) return false\n  if (!(\"chatId\" in session) || !session.chatId.startsWith(\"ai-task\")) return false\n  return true\n}\n\nexport const isUnreadSession = (session: ChatSession | AIChatSession): boolean => {\n  if (!(\"lastSeenAt\" in session) || !(\"updatedAt\" in session)) return false\n  return new Date(session.updatedAt) > new Date(session.lastSeenAt)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx",
    "content": "import { createDefaultLexicalEditor } from \"@follow/components/ui/lexical-rich-editor/editor.js\"\nimport { convertLexicalToMarkdown } from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { stopPropagation, thenable } from \"@follow/utils\"\nimport type { LexicalEditor } from \"lexical\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\nimport { toast } from \"sonner\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\nimport type { BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\nimport { MentionPlugin, ShortcutPlugin } from \"../../editor\"\nimport { AIMessageParts } from \"./AIMessageParts\"\nimport { TokenUsagePill } from \"./TokenUsagePill\"\n\nexport interface ChatMessage {\n  id: string\n  role: \"user\" | \"assistant\"\n  content: string\n  timestamp: Date\n}\n\ninterface AIChatMessageProps {\n  message: BizUIMessage\n  isLastMessage: boolean\n}\n\n// Utility function for converting message to markdown\nconst useMessageMarkdownFormat = (message: BizUIMessage) => {\n  return React.useCallback(() => {\n    let content = \"\"\n    for (const part of message.parts) {\n      let lexicalEditor: LexicalEditor | null = null\n      switch (part.type) {\n        case \"text\": {\n          content += part.text\n          break\n        }\n        case \"data-rich-text\": {\n          lexicalEditor ||= createDefaultLexicalEditor([MentionPlugin, ShortcutPlugin])\n          lexicalEditor.setEditorState(lexicalEditor.parseEditorState(part.data.state))\n          content += convertLexicalToMarkdown(lexicalEditor)\n          break\n        }\n\n        default: {\n          if (part.type.startsWith(\"tool-\")) {\n            content += `\\n\\n[TOOL CALL: ${part.type.replace(\"tool-\", \"\")}]\\n\\n`\n          }\n          break\n        }\n      }\n    }\n    return content\n  }, [message.parts])\n}\n\nconst filterEmptyMessagePart = (messageParts: BizUIMessage[\"parts\"]) => {\n  const parts = [] as BizUIMessage[\"parts\"]\n  for (const part of messageParts) {\n    switch (part.type) {\n      case \"step-start\": {\n        break\n      }\n      case \"reasoning\":\n      case \"text\": {\n        if (part.text) {\n          parts.push(part)\n        }\n        break\n      }\n      default: {\n        parts.push(part)\n        break\n      }\n    }\n  }\n  return parts\n}\n\nexport const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(\n  ({ message: originalMessage, isLastMessage }) => {\n    const message = React.useMemo(() => {\n      return {\n        ...originalMessage,\n        parts: filterEmptyMessagePart(originalMessage.parts),\n      }\n    }, [originalMessage])\n    if (message.parts.length === 0) {\n      throw thenable\n    }\n\n    const getMessageMarkdownFormat = useMessageMarkdownFormat(message)\n\n    const handleCopy = React.useCallback(async () => {\n      const messageContent = getMessageMarkdownFormat()\n      try {\n        await copyToClipboard(messageContent)\n        toast.success(\"Message copied to clipboard\")\n      } catch {\n        toast.error(\"Failed to copy message\")\n      }\n    }, [getMessageMarkdownFormat])\n\n    return (\n      <div onContextMenu={stopPropagation} className=\"group flex justify-start\">\n        <div className=\"relative flex w-full max-w-full flex-col gap-2 text-text\">\n          {/* Normal message display */}\n          <div className=\"w-full text-text\">\n            <div className=\"flex cursor-text select-text flex-col gap-2 text-sm\">\n              <AIMessageParts message={message} isLastMessage={isLastMessage} />\n            </div>\n          </div>\n\n          {/* Action buttons */}\n          {!!originalMessage.metadata?.finishTime && (\n            <div className=\"absolute -left-2 bottom-1 right-0 flex items-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100\">\n              <span className=\"whitespace-nowrap px-2 py-1 text-[11px] leading-none text-text-tertiary\">\n                <RelativeTime date={originalMessage.createdAt} />\n              </span>\n              <button\n                type=\"button\"\n                onClick={handleCopy}\n                className=\"flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-fill-tertiary\"\n                title=\"Copy message\"\n              >\n                <i className=\"i-mgc-copy-2-cute-re size-3\" />\n                <span>Copy</span>\n              </button>\n\n              <TokenUsagePill metadata={originalMessage.metadata}>\n                <button\n                  type=\"button\"\n                  className=\"absolute right-0 flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-fill-tertiary\"\n                >\n                  <i className=\"i-mgc-information-cute-re size-3\" />\n                </button>\n              </TokenUsagePill>\n            </div>\n          )}\n          <div className=\"h-6\" />\n        </div>\n      </div>\n    )\n  },\n)\n\nexport const AIChatWaitingIndicator: React.FC = () => {\n  return (\n    <m.div\n      initial={{ opacity: 0, y: 8 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, y: 8 }}\n      transition={{ duration: 0.2, ease: [0.25, 0.46, 0.45, 0.94] }}\n      className=\"mb-4\"\n    >\n      <div className=\"flex items-center gap-2 rounded-full text-xs text-text-secondary\">\n        <i className=\"i-mgc-loading-3-cute-re size-3 animate-spin\" />\n        <span className=\"font-medium\">Thinking…</span>\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockPart.tsx",
    "content": "import * as React from \"react\"\n\nimport { CombinedContextBlock, ContextBlock } from \"~/modules/ai-chat/components/context-bar/blocks\"\nimport { useDisplayBlocks } from \"~/modules/ai-chat/hooks/useDisplayBlocks\"\nimport type { AIChatContextBlock } from \"~/modules/ai-chat/store/types\"\n\ninterface AIDataBlockPartProps {\n  blocks: AIChatContextBlock[]\n}\n\n/**\n * Main component for rendering AI chat context blocks\n * Displays various types of context (entries, feeds, text, files) with compact styling\n */\nexport const AIDataBlockPart: React.FC<AIDataBlockPartProps> = React.memo(({ blocks }) => {\n  const displayBlocks = useDisplayBlocks(blocks)\n\n  // Early return for empty blocks\n  if (displayBlocks.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      {displayBlocks.map((item) => {\n        if (item.kind === \"combined\") {\n          return (\n            <CombinedContextBlock\n              key={`combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`}\n              viewBlock={item.viewBlock}\n              feedBlock={item.feedBlock}\n              unreadOnlyBlock={item.unreadOnlyBlock}\n              readOnly\n            />\n          )\n        }\n\n        return <ContextBlock key={item.block.id} block={item.block} readOnly />\n      })}\n    </div>\n  )\n})\n\nAIDataBlockPart.displayName = \"AIDataBlockPart\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx",
    "content": "import { cn, stopPropagation } from \"@follow/utils\"\nimport { memo, useRef } from \"react\"\n\nimport { MarkdownAnimateText } from \"./animated/AnimatedMarkdown\"\nimport { parseIncompleteMarkdown } from \"./parse-incomplete-markdown\"\n\nexport const AIMarkdownStreamingMessage = memo(\n  ({\n    text,\n    className: classNameProp,\n    isStreaming,\n  }: {\n    text: string\n    className?: string\n    isStreaming?: boolean\n  }) => {\n    const className = tw`prose max-w-full dark:prose-invert prose-sm\n  prose-h1:text-2xl prose-h2:text-xl prose-h2:mt-2 prose-h3:text-lg prose-h4:text-base prose-h5:text-base prose-h6:text-sm\n  prose-li:list-disc prose-li:marker:text-accent prose-hr:border-border prose-hr:mx-8\n  w-[calc(var(--ai-chat-message-container-width,65ch))]\n  prose-pre:!text-base\n  prose-strong:font-bold prose-headings:font-bold\n  [&_ol>li]:list-decimal\n  `\n\n    const stableStreamingState = useRef(isStreaming)\n    return (\n      <div onContextMenu={stopPropagation} className={cn(className, classNameProp)}>\n        <MarkdownAnimateText\n          content={parseIncompleteMarkdown(text)}\n          isStreaming={stableStreamingState.current}\n        />\n      </div>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageIdContext.tsx",
    "content": "import { createContext, use } from \"react\"\n\nexport const AIMessageIdContext = createContext<string | null>(null)\n\nexport const useAIMessageId = () => {\n  const ctx = use(AIMessageIdContext)\n  if (!ctx && import.meta.env.DEV) {\n    throw new Error(\"useAIMessageId must be used within a AIMessageIdContext\")\n  }\n  return ctx\n}\n\nexport const useAIMessageOptionalId = () => {\n  const ctx = use(AIMessageIdContext)\n  return ctx\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx",
    "content": "import \"@xyflow/react/dist/style.css\"\n\nimport { alwaysFalse } from \"@follow/utils\"\nimport type { ReasoningUIPart, TextUIPart, ToolUIPart } from \"ai\"\nimport * as React from \"react\"\n\nimport { ErrorBoundary } from \"~/components/common/ErrorBoundary\"\nimport type { AIDisplayFlowTool, BizUIMessage, BizUITools } from \"~/modules/ai-chat/store/types\"\n\nimport { useChatStatus } from \"../../store/hooks\"\nimport { AIChainOfThought } from \"../displays\"\nimport type { ChainReasoningPart } from \"../displays/AIChainOfThought\"\nimport { AIMarkdownStreamingMessage } from \"./AIMarkdownMessage\"\nimport { ToolInvocationComponent } from \"./ToolInvocationComponent\"\n\nconst LazyAIDisplayFlowPart = React.lazy(() =>\n  import(\"../displays/AIDisplayFlowPart\").then((mod) => ({ default: mod.AIDisplayFlowPart })),\n)\n\ninterface AIMessagePartsProps {\n  message: BizUIMessage\n  isLastMessage: boolean\n}\n\nconst shouldBypassMergeToolName = (name: string) => name.startsWith(\"tool-display\")\n\nexport const AIMessageParts: React.FC<AIMessagePartsProps> = React.memo(\n  ({ message, isLastMessage }) => {\n    const chatStatus = useChatStatus()\n\n    const shouldMessageAnimation = React.useMemo(() => {\n      return chatStatus === \"streaming\" && isLastMessage\n    }, [chatStatus, isLastMessage])\n\n    const chainThoughtParts = React.useMemo(() => {\n      const parts = [] as (ChainReasoningPart[] | TextUIPart | ToolUIPart<BizUITools>)[]\n\n      let chainReasoningParts: ChainReasoningPart[] | null = null\n      for (const part of message.parts) {\n        const isReasoning = part.type === \"reasoning\" && !!(part as ReasoningUIPart).text\n        const isTool = part.type.startsWith(\"tool-\")\n        const bypassedTool = isTool && shouldBypassMergeToolName(part.type)\n\n        if (isReasoning) {\n          if (!chainReasoningParts) {\n            chainReasoningParts = []\n            // insert by reference once; keep appending to the same array thereafter\n            parts.push(chainReasoningParts)\n          }\n          chainReasoningParts.push(part as ReasoningUIPart)\n          continue\n        }\n\n        if (isTool) {\n          if (chainReasoningParts && chainReasoningParts.length > 0 && !bypassedTool) {\n            chainReasoningParts.push(part as ToolUIPart<BizUITools>)\n          } else {\n            parts.push(part as ToolUIPart<BizUITools>)\n          }\n          continue\n        }\n\n        // Only add text to top-level; do not break an active chain\n        if (part.type === \"text\") {\n          parts.push(part)\n          continue\n        }\n\n        // Unknown/meta parts (e.g., step-start, source) are skipped here without breaking an active chain\n      }\n\n      // No final flush needed; chain array already referenced in parts\n      return parts\n    }, [message.parts])\n\n    // console.info(\"displayParts\", displayParts)\n\n    const lowPriorityChainParts = React.useDeferredValue(chainThoughtParts)\n\n    return (\n      <>\n        {lowPriorityChainParts.map((partOrParts, index) => {\n          const partKey = `${message.id}-${index}`\n\n          if (Array.isArray(partOrParts)) {\n            const reasoningParts = partOrParts as ChainReasoningPart[]\n            return (\n              <AIChainOfThought\n                key={partKey}\n                groups={reasoningParts}\n                isStreaming={shouldMessageAnimation}\n              />\n            )\n          }\n\n          const part = partOrParts as TextUIPart | ToolUIPart<BizUITools>\n\n          switch (part.type) {\n            case \"text\": {\n              return (\n                <AIMarkdownStreamingMessage\n                  key={partKey}\n                  text={part.text}\n                  className={\"text-text\"}\n                  isStreaming={shouldMessageAnimation}\n                />\n              )\n            }\n\n            case \"tool-display_flow_chart\": {\n              const loadingElement = (\n                <div className=\"my-2 flex aspect-[4/3] w-[calc(var(--ai-chat-message-container-width,65ch))] max-w-full items-center justify-center rounded bg-material-medium\">\n                  <div className=\"flex flex-col items-center gap-4\">\n                    <div className=\"flex items-center gap-2\">\n                      <i className=\"i-mgc-loading-3-cute-re size-4 animate-spin text-text-secondary\" />\n                      <span className=\"text-sm font-medium text-text-secondary\">\n                        Generating Flow Chart...\n                      </span>\n                    </div>\n                  </div>\n                </div>\n              )\n              return (\n                <ErrorBoundary key={partKey} beforeCapture={alwaysFalse}>\n                  <React.Suspense fallback={loadingElement}>\n                    <LazyAIDisplayFlowPart part={part as AIDisplayFlowTool} />\n                  </React.Suspense>\n                </ErrorBoundary>\n              )\n            }\n\n            default: {\n              if (part.type.startsWith(\"tool-\")) {\n                return (\n                  <ToolInvocationComponent\n                    key={partKey}\n                    part={part as ToolUIPart<BizUITools>}\n                    variant=\"tight\"\n                  />\n                )\n              }\n\n              return null\n            }\n          }\n        })}\n      </>\n    )\n  },\n)\n\nAIMessageParts.displayName = \"AIMessageParts\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/BlockTitleComponents.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedsByIds } from \"@follow/store/feed/hooks\"\nimport * as React from \"react\"\n\ninterface TitleProps {\n  fallback: string\n}\n\ninterface EntryTitleProps extends TitleProps {\n  entryId?: string\n}\n\ninterface FeedTitleProps extends TitleProps {\n  feedId?: string\n}\n\n/**\n * Displays entry title with fallback handling\n */\nexport const EntryTitle: React.FC<EntryTitleProps> = React.memo(({ entryId, fallback }) => {\n  const entryTitle = useEntry(entryId!, (e) => e?.title)\n\n  if (!entryId || !entryTitle) {\n    return <span className=\"text-text-tertiary\">{fallback}</span>\n  }\n\n  return <span title={entryTitle}>{entryTitle}</span>\n})\n\nEntryTitle.displayName = \"EntryTitle\"\n\n/**\n * Displays feed title with fallback handling\n */\nexport const FeedTitle: React.FC<FeedTitleProps> = React.memo(({ feedId, fallback }) => {\n  const finalFeedIds = feedId?.split(\",\").map((id) => id.trim())\n  const feeds = useFeedsByIds(finalFeedIds, (feed) => ({ title: feed?.title }))\n  const feedTitles = feeds.map((feed) => feed.title).join(\", \")\n\n  if (!feedId || !feedTitles) {\n    return <span className=\"text-text-tertiary\">{fallback}</span>\n  }\n\n  return <span title={feedTitles}>{feedTitles}</span>\n})\n\nFeedTitle.displayName = \"FeedTitle\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/EditableMessage.tsx",
    "content": "import { Kbd } from \"@follow/components/ui/kbd/Kbd.js\"\nimport type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport { LexicalRichEditor } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { cn, nextFrame } from \"@follow/utils\"\nimport { isEqual } from \"es-toolkit\"\nimport type { EditorState, LexicalEditor, SerializedEditorState } from \"lexical\"\nimport { $getRoot } from \"lexical\"\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { useEditingMessageId, useSetEditingMessageId } from \"~/modules/ai-chat/atoms/session\"\nimport { useChatStatus } from \"~/modules/ai-chat/store/hooks\"\nimport type { BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\nimport { MentionPlugin, ShortcutPlugin } from \"../../editor\"\n\ninterface EditableMessageProps {\n  messageId: string\n  parts: BizUIMessage[\"parts\"]\n  onSave: (content: SerializedEditorState, editor: LexicalEditor) => void\n  onCancel: () => void\n  className?: string\n  initialHeight?: number\n}\n\nexport const EditableMessage = ({\n  messageId,\n  parts,\n  onSave,\n  onCancel,\n  className,\n  initialHeight,\n}: EditableMessageProps) => {\n  const status = useChatStatus()\n  const editingMessageId = useEditingMessageId()\n  const setEditingMessageId = useSetEditingMessageId()\n  const [isEmpty, setIsEmpty] = useState(false)\n  const editorRef = useRef<LexicalRichEditorRef>(null)\n  const [currentEditor, setCurrentEditor] = useState<LexicalEditor | null>(null)\n\n  const initialEditorState = useMemo(() => {\n    return (parts.find((part) => part.type === \"data-rich-text\") as any)?.data\n      .state as SerializedEditorState\n  }, [parts])\n\n  const isEditing = editingMessageId === messageId\n  const isProcessing = status === \"submitted\" || status === \"streaming\"\n\n  // Compute initial editor height based on original message height\n  const editorInitialHeight = Math.max(56, initialHeight ?? 56)\n\n  // Initialize editor with initial content\n  useEffect(() => {\n    if (isEditing && editorRef.current && currentEditor) {\n      // Focus the editor\n      editorRef.current.focus()\n    }\n  }, [isEditing, currentEditor])\n\n  const setInitialEditorStateOnceRef = useRef(false)\n\n  useEffect(() => {\n    return nextFrame(() => {\n      if (setInitialEditorStateOnceRef.current) return\n      const editor = editorRef.current?.getEditor()\n\n      if (!editor) return\n      editor.setEditorState(editor.parseEditorState(initialEditorState))\n      setInitialEditorStateOnceRef.current = true\n    })\n  }, [initialEditorState])\n\n  const handleSave = useCallback(() => {\n    if (currentEditor && editorRef.current && !editorRef.current.isEmpty()) {\n      const serializedEditorState = currentEditor.getEditorState().toJSON()\n\n      if (!isEqual(serializedEditorState, initialEditorState)) {\n        onSave(serializedEditorState, currentEditor)\n      }\n    }\n  }, [currentEditor, initialEditorState, onSave])\n\n  const handleCancel = useCallback(() => {\n    setEditingMessageId(null)\n    onCancel()\n  }, [onCancel, setEditingMessageId])\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent) => {\n      if (event.key === \"Enter\" && !event.shiftKey) {\n        event.preventDefault()\n        if (!isProcessing) {\n          handleSave()\n        }\n        return true\n      } else if (event.key === \"Escape\") {\n        event.preventDefault()\n        handleCancel()\n        return true\n      }\n      return false\n    },\n    [handleSave, handleCancel, isProcessing],\n  )\n\n  const handleEditorChange = useCallback((editorState: EditorState, editor: LexicalEditor) => {\n    setCurrentEditor(editor)\n    // Update isEmpty state based on editor content\n    editorState.read(() => {\n      const root = $getRoot()\n      const textContent = root.getTextContent().trim()\n      setIsEmpty(textContent === \"\")\n    })\n  }, [])\n\n  if (!isEditing) {\n    return null\n  }\n\n  return (\n    <div className={cn(\"relative\", className)}>\n      {/* Edit input */}\n      <div className=\"relative overflow-hidden rounded-xl border border-border/80 bg-background/60 backdrop-blur-xl duration-200 focus-within:border-accent/80 focus-within:ring-2 focus-within:ring-accent/20\">\n        <ScrollArea\n          rootClassName=\"mr-20 flex-1 overflow-auto\"\n          viewportClassName=\"px-5 py-3.5\"\n          viewportProps={{ style: { height: editorInitialHeight } }}\n        >\n          <LexicalRichEditor\n            ref={editorRef}\n            placeholder=\"Edit your message...\"\n            className=\"h-full min-w-64\"\n            onChange={handleEditorChange}\n            onKeyDown={handleKeyDown}\n            namespace=\"EditableMessageRichEditor\"\n            plugins={[MentionPlugin, ShortcutPlugin]}\n          />\n        </ScrollArea>\n\n        {/* Action buttons */}\n        <div className=\"absolute right-2 top-1/2 flex -translate-y-1/2 gap-1\">\n          <button\n            type=\"button\"\n            onClick={handleCancel}\n            disabled={isProcessing}\n            className=\"flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-fill/50 hover:text-text disabled:opacity-50\"\n            title=\"Cancel (Esc)\"\n          >\n            <i className=\"i-mgc-close-cute-re size-4\" />\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleSave}\n            disabled={isProcessing || isEmpty}\n            className=\"flex size-8 items-center justify-center rounded-lg text-accent transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50\"\n            title=\"Save (Enter)\"\n          >\n            <i className=\"i-mgc-send-plane-cute-fi size-4\" />\n          </button>\n        </div>\n      </div>\n\n      {/* Helper text */}\n      <div className=\"relative mt-2\">\n        <div className=\"absolute -inset-x-2 -bottom-2 -top-8 z-[-1] bg-background\" />\n        <div className=\"relative z-[1] text-xs text-text-secondary\">\n          Press <Kbd abbr=\"Enter\">Enter</Kbd> to save, <Kbd abbr=\"Esc\">Esc</Kbd> to cancel\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ErrorMessage.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn } from \"@follow/utils\"\nimport { ExceptionCodeMap } from \"@follow-app/client-sdk\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { getErrorMessage, parseAIError } from \"~/modules/ai-chat/utils/error\"\n\ninterface ErrorMessageProps {\n  error: Error | string\n  className?: string\n}\n\n/**\n * ErrorMessage component for displaying errors in the message list\n * Uses a subtle, message-like appearance with low-key colors\n * Note: Rate limit errors are handled separately by RateLimitNotice component\n */\nexport const ErrorMessage: React.FC<ErrorMessageProps> = ({ error, className }) => {\n  const parsedError = React.useMemo(() => parseAIError(error), [error])\n\n  if (parsedError.isRateLimitError) {\n    return null\n  }\n\n  const displayMessage = getErrorMessage(parsedError)\n  const { errorCode, errorData } = parsedError\n\n  const getContextualInfo = () => {\n    if (!parsedError.isBusinessError || !errorData) return null\n\n    switch (errorCode) {\n      default: {\n        return null\n      }\n    }\n  }\n\n  const getErrorTitle = () => {\n    if (parsedError.isBusinessError) {\n      switch (errorCode) {\n        case ExceptionCodeMap.AIRateLimitExceeded: {\n          return \"AI Rate Limit Exceeded\"\n        }\n        default: {\n          return \"Error occurred\"\n        }\n      }\n    }\n    return \"Error occurred\"\n  }\n\n  const contextualInfo = getContextualInfo()\n\n  return (\n    <m.div\n      initial={{ opacity: 0, y: 10 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={Spring.presets.smooth}\n      className={cn(\"group mb-4 flex justify-start\", className)}\n    >\n      <div className=\"relative flex max-w-full flex-col text-text\">\n        {/* Main error message bubble - similar to AI message style */}\n        <div className=\"rounded-2xl border border-border bg-fill/50 px-4 py-3\">\n          <div className=\"flex flex-col gap-2\">\n            {/* Header with subtle icon */}\n            <div className=\"flex items-center gap-2\">\n              <i className=\"i-mgc-information-cute-re size-4 text-text-tertiary\" />\n              <span className=\"text-xs font-medium text-text-secondary\">{getErrorTitle()}</span>\n            </div>\n\n            {/* Error message - always visible but subtle */}\n            <div className=\"cursor-text select-text text-sm leading-relaxed text-text-secondary\">\n              {displayMessage}\n            </div>\n\n            {/* Contextual info if exists */}\n            {contextualInfo && (\n              <div className=\"mt-1 space-y-1.5 rounded-lg border border-border/50 bg-fill-secondary/30 p-2.5\">\n                {contextualInfo}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ImageThumbnail.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as HoverCard from \"@radix-ui/react-hover-card\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport type { FileAttachment } from \"~/modules/ai-chat/store/types\"\n\nimport { getImageUrl } from \"./ai-block-constants\"\n\ntype AttachmentImageProps = {\n  attachment: FileAttachment\n  className?: string\n  fallbackIcon?: string\n}\n\ntype ImageThumbnailProps = AttachmentImageProps\n\n/**\n * Unified ImageThumbnail with hover preview, click-to-modal, loading and error fallbacks\n */\nexport const ImageThumbnail: React.FC<ImageThumbnailProps> = React.memo((props) => {\n  const { present } = useModalStack()\n\n  const [contentImageError, setContentImageError] = React.useState(false)\n\n  const computed = React.useMemo(() => {\n    const { attachment, className, fallbackIcon } = props\n    const imageUrl = getImageUrl(attachment)\n    const { name, serverUrl } = attachment\n    const original = serverUrl || imageUrl || null\n    return {\n      previewUrl: imageUrl,\n      originalUrl: original,\n      alt: name,\n      filename: name,\n      className: className ?? \"size-3 rounded object-cover\",\n      fallbackIcon: fallbackIcon ?? \"i-mgc-pic-cute-re\",\n    }\n  }, [props])\n\n  React.useEffect(() => {\n    setContentImageError(false)\n  }, [computed.previewUrl])\n\n  // If no preview URL available, show fallback icon\n  if (!computed.previewUrl) {\n    return <i className={cn(\"size-3\", computed.fallbackIcon)} />\n  }\n\n  return (\n    <HoverCard.Root openDelay={300} closeDelay={100}>\n      <HoverCard.Trigger\n        className=\"cursor-pointer\"\n        onClick={() =>\n          computed.originalUrl &&\n          present({\n            max: true,\n            title: \"Preview Image\",\n            clickOutsideToDismiss: true,\n            content: () => (\n              <div className=\"flex max-h-full max-w-full items-center justify-center\">\n                <img src={computed.originalUrl!} className=\"max-h-full max-w-full\" />\n              </div>\n            ),\n          })\n        }\n      >\n        <ImageThumbnailInner\n          src={computed.previewUrl}\n          alt={computed.alt}\n          className={computed.className}\n        />\n      </HoverCard.Trigger>\n      <HoverCard.Portal>\n        <HoverCard.Content\n          className=\"w-fit rounded-md border border-border bg-material-thick shadow-lg\"\n          sideOffset={8}\n        >\n          <div className=\"relative overflow-hidden rounded-md\">\n            {contentImageError ? (\n              <div className=\"flex h-32 w-40 items-center justify-center rounded-md border border-border bg-fill-secondary\">\n                <div className=\"flex flex-col items-center gap-2 text-text-tertiary\">\n                  <i className=\"i-mgc-photo-album-cute-fi size-6\" />\n                  <span className=\"text-xs\">{computed.filename}</span>\n                </div>\n              </div>\n            ) : (\n              <m.img\n                src={computed.previewUrl}\n                alt={computed.alt}\n                onError={() => setContentImageError(true)}\n                initial={{ opacity: 0, scale: 0.95 }}\n                animate={{ opacity: 1, scale: 1 }}\n                transition={{ duration: 0.2 }}\n                className=\"max-h-[300px] max-w-[400px] rounded-md\"\n              />\n            )}\n          </div>\n        </HoverCard.Content>\n      </HoverCard.Portal>\n    </HoverCard.Root>\n  )\n})\n\nImageThumbnail.displayName = \"ImageThumbnail\"\n\nconst ImageThumbnailInner: React.FC<{ src: string; alt: string; className?: string }> = ({\n  src,\n  alt,\n  className,\n}) => {\n  const [imageError, setImageError] = React.useState(false)\n  const [imageLoaded, setImageLoaded] = React.useState(false)\n\n  const handleError = React.useCallback(() => {\n    setImageError(true)\n  }, [])\n\n  const handleLoad = React.useCallback(() => {\n    setImageLoaded(true)\n  }, [])\n\n  if (imageError) {\n    return (\n      <div\n        className={cn(\n          \"flex items-center justify-center border border-border bg-fill-secondary\",\n          className,\n        )}\n      >\n        <i className=\"i-mgc-photo-album-cute-re size-3 text-text-tertiary\" />\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn(\"relative overflow-hidden\", className)}>\n      {!imageLoaded && (\n        <div\n          className={\n            \"absolute inset-0 flex items-center justify-center border border-border bg-fill-secondary\"\n          }\n        >\n          <i className=\"i-mgc-loading-3-cute-re size-3 animate-spin text-text-tertiary\" />\n        </div>\n      )}\n      <m.img\n        src={src}\n        alt={alt}\n        onError={handleError}\n        onLoad={handleLoad}\n        initial={{ opacity: 0 }}\n        animate={{ opacity: imageLoaded ? 1 : 0 }}\n        transition={{ duration: 0.2 }}\n        className={\"size-full object-cover\"}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/TokenUsagePill.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { isFreeRole } from \"@follow/constants\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { BizUIMetadata } from \"@folo-services/ai-tools\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { formatTokenCountString } from \"~/modules/settings/tabs/ai/usage/utils\"\n\ninterface TokenUsagePillProps {\n  metadata: BizUIMetadata | undefined\n  className?: string\n  children: React.ReactNode\n}\n\nconst formatDuration = (ms: number): string => {\n  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`\n  return `${ms}ms`\n}\n\ninterface ModelInfoSectionProps {\n  metadata: BizUIMetadata\n}\n\nconst ModelInfoSection: React.FC<ModelInfoSectionProps> = ({ metadata }) => {\n  const { t } = useTranslation(\"ai\")\n  const hasProviderInfo = metadata.provider != null || metadata.providerType != null\n\n  return (\n    <>\n      <div className=\"mb-2 flex flex-col gap-2\">\n        <div className=\"text-xs text-text\">{t(\"token_usage_pill.model_info\")}</div>\n        <div className=\"font-mono text-xs text-text-secondary\">\n          {metadata.modelUsed ?? t(\"token_usage_pill.unknown\")}\n        </div>\n      </div>\n      {hasProviderInfo && (\n        <div className=\"mb-2 flex flex-col gap-2\">\n          <div className=\"text-xs text-text\">{t(\"token_usage_pill.provider_info\")}</div>\n          <div className=\"font-mono text-xs text-text-secondary\">\n            <span>{metadata.provider ?? t(\"token_usage_pill.unknown\")}</span>\n            {metadata.providerType && (\n              <span className=\"ml-2 text-text-tertiary\">\n                <span>(</span>\n                <span>\n                  {metadata.providerType === \"byok\"\n                    ? t(\"token_usage_pill.byok\")\n                    : t(\"token_usage_pill.system\")}\n                </span>\n                <span>)</span>\n              </span>\n            )}\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n\ninterface FreeUserTokenUsageProps {\n  metadata: BizUIMetadata\n}\n\nconst FreeUserTokenUsage: React.FC<FreeUserTokenUsageProps> = ({ metadata }) => {\n  const { t } = useTranslation(\"ai\")\n  const summarizedTokens =\n    metadata.billedTokens ??\n    metadata.totalTokens ??\n    metadata.outputTokens ??\n    metadata.contextTokens ??\n    null\n\n  return (\n    <>\n      <ModelInfoSection metadata={metadata} />\n      <div className=\"space-y-2 text-xs\">\n        <div className=\"font-medium text-text\">{t(\"token_usage_pill.credits_usage\")}</div>\n        <div className=\"flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2\">\n          <span className=\"text-text-secondary\">{t(\"token_usage_pill.credits\")}:</span>\n          <span className=\"font-mono text-text\">\n            {summarizedTokens != null ? formatTokenCountString(summarizedTokens) : \"—\"}\n          </span>\n        </div>\n      </div>\n    </>\n  )\n}\n\ninterface NormalUserTokenUsageProps {\n  metadata: BizUIMetadata\n}\n\nconst NormalUserTokenUsage: React.FC<NormalUserTokenUsageProps> = ({ metadata }) => {\n  const { t } = useTranslation(\"ai\")\n  const hasBillingMultiplier =\n    metadata.billingMultiplier != null && metadata.billingMultiplier !== 1\n  const hasDuration = metadata.duration != null\n\n  return (\n    <>\n      <ModelInfoSection metadata={metadata} />\n      <div className=\"space-y-2 text-xs\">\n        <div className=\"font-medium text-text\">{t(\"token_usage_pill.credits_usage\")}</div>\n        <div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n          {metadata.totalTokens != null && (\n            <div className=\"flex justify-between gap-2\">\n              <span className=\"text-text-secondary\">{t(\"token_usage_pill.total\")}:</span>\n              <span className=\"font-mono text-text\">\n                {formatTokenCountString(metadata.totalTokens)}\n              </span>\n            </div>\n          )}\n          {metadata.billedTokens != null && (\n            <div className=\"flex justify-between gap-2\">\n              <span className=\"text-text-secondary\">{t(\"token_usage_pill.billed\")}:</span>\n              <span className=\"font-mono\">{formatTokenCountString(metadata.billedTokens)}</span>\n            </div>\n          )}\n        </div>\n        {(hasDuration || hasBillingMultiplier) && (\n          <>\n            <hr className=\"border-fill-secondary\" />\n            <div className=\"grid grid-cols-2 gap-x-4 gap-y-1\">\n              {hasDuration && (\n                <div className=\"flex justify-between gap-2\">\n                  <span className=\"text-text-secondary\">{t(\"token_usage_pill.duration\")}:</span>\n                  <span className=\"font-mono text-text\">{formatDuration(metadata.duration!)}</span>\n                </div>\n              )}\n              {hasBillingMultiplier && (\n                <div className=\"flex justify-between gap-2\">\n                  <span className=\"text-text-secondary\">{t(\"token_usage_pill.multiplier\")}:</span>\n                  <span className=\"font-mono text-text\">{metadata.billingMultiplier!}×</span>\n                </div>\n              )}\n            </div>\n          </>\n        )}\n      </div>\n    </>\n  )\n}\n\nexport const TokenUsagePill: React.FC<TokenUsagePillProps> = ({ metadata, children }) => {\n  const userRole = useUserRole()\n  if (!metadata) return null\n\n  const isFreeUser = userRole ? isFreeRole(userRole) : false\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{children}</TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent side=\"top\" className=\"p-2\" align=\"center\" sideOffset={8}>\n          {isFreeUser ? (\n            <FreeUserTokenUsage metadata={metadata} />\n          ) : (\n            <NormalUserTokenUsage metadata={metadata} />\n          )}\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ToolInvocationComponent.tsx",
    "content": "import { CollapseCss, CollapseCssGroup } from \"@follow/components/ui/collapse/index.js\"\nimport { JsonHighlighter } from \"@follow/components/ui/json-highlighter/index.js\"\nimport type { ToolUIPart } from \"ai\"\nimport { getStaticToolName } from \"ai\"\nimport clsx from \"clsx\"\nimport * as React from \"react\"\nimport { titleCase } from \"title-case\"\n\ninterface ToolInvocationComponentProps {\n  part: ToolUIPart\n\n  variant: \"loose\" | \"tight\"\n}\n\nexport const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = React.memo(\n  ({ part, variant }) => {\n    const toolName = titleCase(getStaticToolName(part).replaceAll(\"_\", \" \"))\n\n    const hasError = \"errorText\" in part && part.errorText\n    const hasResult = \"output\" in part && part.output\n    const hasArgs = \"input\" in part && part.input\n\n    const isCalling = part.state === \"input-streaming\"\n\n    // Generate a unique value for this accordion item\n    const accordionValue = `tool-${\"toolCallId\" in part ? part.toolCallId : Math.random()}`\n\n    const result = React.useMemo(() => {\n      if (hasResult) {\n        const string = JSON.stringify(part.output, null, 2)\n        if (string.length > 1000) {\n          return `${string.slice(0, 1000)}\\n...`\n        } else {\n          return string\n        }\n      }\n    }, [hasResult, part])\n\n    return (\n      <div className={clsx(\"relative pl-8 last:pb-0\", variant === \"tight\" ? \"pb-0\" : \"pb-3\")}>\n        <div\n          aria-hidden\n          className={`absolute left-2 top-2 size-2 -translate-x-1/2 ${hasError ? \"text-red\" : \"\"}`}\n        >\n          <i className={`i-mgc-tool-cute-re absolute top-1/2 -translate-x-1/4 -translate-y-1/2`} />\n        </div>\n\n        <CollapseCssGroup>\n          <CollapseCss\n            collapseId={accordionValue}\n            hideArrow\n            className=\"group/collapse border-none\"\n            title={\n              <div className=\"group/tool flex h-6 min-w-0 flex-1 items-center py-0\">\n                <div className=\"flex items-center gap-2 text-xs text-text-secondary\">\n                  <span>\n                    {hasError ? \"Tool Failed:\" : isCalling ? \"Tool Calling:\" : \"Tool Called:\"}\n                  </span>\n                  <span className={`truncate font-medium ${hasError ? \"text-red\" : \"text-text\"}`}>\n                    {toolName}\n                  </span>\n                </div>\n                <div className=\"ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover/tool:opacity-100\">\n                  <i className=\"i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]/collapse:rotate-90\" />\n                </div>\n              </div>\n            }\n            contentClassName=\"pb-0 pt-2\"\n          >\n            <div className=\"space-y-2 text-xs\">\n              {/* Show tool arguments if available */}\n              {hasArgs ? (\n                <div>\n                  <div className=\"mb-1 font-medium text-text-secondary\">Arguments:</div>\n                  <JsonHighlighter\n                    className=\"overflow-x-auto rounded bg-fill-secondary p-2 text-[11px] text-text-tertiary\"\n                    json={JSON.stringify(part.input, null, 2)}\n                  />\n                </div>\n              ) : null}\n\n              {/* Show tool result if available */}\n              {hasResult ? (\n                <div>\n                  <div className=\"mb-1 font-medium text-text-secondary\">Result:</div>\n                  <JsonHighlighter\n                    className=\"overflow-x-auto rounded bg-fill-secondary p-2 text-[11px] text-text-tertiary\"\n                    json={result!}\n                  />\n                </div>\n              ) : null}\n\n              {/* Show error if available */}\n              {hasError && \"errorText\" in part ? (\n                <div>\n                  <div className=\"mb-1 font-medium text-red\">Error:</div>\n                  <pre className=\"overflow-x-auto rounded bg-red/10 p-2 text-[11px] text-red\">\n                    {String(part.errorText)}\n                  </pre>\n                </div>\n              ) : null}\n            </div>\n          </CollapseCss>\n        </CollapseCssGroup>\n      </div>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { convertLexicalToMarkdown } from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { nextFrame, stopPropagation, thenable } from \"@follow/utils\"\nimport type { LexicalEditor, SerializedEditorState } from \"lexical\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { useEditingMessageId, useSetEditingMessageId } from \"~/modules/ai-chat/atoms/session\"\nimport { useChatActions, useChatScene, useChatStatus } from \"~/modules/ai-chat/store/hooks\"\nimport type { AIChatContextBlock, BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\nimport { AIDataBlockPart } from \"./AIDataBlockPart\"\nimport { AIMessageIdContext } from \"./AIMessageIdContext\"\nimport { EditableMessage } from \"./EditableMessage\"\nimport { UserMessageParts } from \"./UserMessageParts\"\n\ninterface UserChatMessageProps {\n  message: BizUIMessage\n}\n\nexport const UserChatMessage: React.FC<UserChatMessageProps> = React.memo(({ message }) => {\n  if (message.parts.length === 0) {\n    throw thenable\n  }\n\n  const chatActions = useChatActions()\n  const messageId = message.id\n  const [isHovered, setIsHovered] = React.useState(false)\n  const editingMessageId = useEditingMessageId()\n  const setEditingMessageId = useSetEditingMessageId()\n\n  const chatStatus = useChatStatus()\n\n  const isStreaming = chatStatus === \"submitted\" || chatStatus === \"streaming\"\n  const isEditing = editingMessageId === messageId\n\n  // Extract data-block parts for separate rendering\n  const dataBlockParts = React.useMemo(\n    () => message.parts.filter((part) => part.type === \"data-block\"),\n    [message.parts],\n  )\n\n  // Ref to measure data-block height for edit overlay positioning\n  const dataBlockRef = React.useRef<HTMLDivElement>(null)\n  const [dataBlockHeight, setDataBlockHeight] = React.useState(0)\n\n  // Update data-block height when it changes\n  React.useEffect(() => {\n    if (dataBlockRef.current && dataBlockParts.length > 0) {\n      const { height } = dataBlockRef.current.getBoundingClientRect()\n      setDataBlockHeight(height + 12) // Add gap between data-block and message (0.75rem = 12px)\n    } else {\n      setDataBlockHeight(0)\n    }\n  }, [dataBlockParts.length, isEditing])\n\n  // Measure original message bubble height to initialize edit box height\n  const messageBubbleRef = React.useRef<HTMLDivElement>(null)\n  const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(56)\n  // Only compute once before edit overlay appears\n\n  const handleEdit = React.useCallback(() => {\n    const el = messageBubbleRef.current\n    if (el) {\n      const { height } = el.getBoundingClientRect()\n      setMessageBubbleHeight(Math.max(56, Math.round(height)))\n    }\n    nextFrame(() => {\n      setEditingMessageId(messageId)\n    })\n  }, [messageId, setEditingMessageId, setMessageBubbleHeight])\n\n  const handleSaveEdit = React.useCallback(\n    (newState: SerializedEditorState, editor: LexicalEditor) => {\n      const messageContent = convertLexicalToMarkdown(editor)\n      const messages = chatActions.getMessages()\n      const messageIndex = messages.findIndex((msg) => msg.id === messageId)\n      if (messageIndex !== -1) {\n        const messagesToKeep = messages.slice(0, messageIndex)\n        const nextMessage = messages[messageIndex]!\n        chatActions.setMessages(messagesToKeep)\n\n        const richTextPart = nextMessage.parts.find((part) => part.type === \"data-rich-text\")\n        if (richTextPart) {\n          richTextPart.data = {\n            state: JSON.stringify(newState),\n            text: messageContent,\n          }\n        }\n\n        // Send the edited message\n        chatActions.sendMessage(nextMessage)\n      }\n      setEditingMessageId(null)\n    },\n    [chatActions, messageId, setEditingMessageId],\n  )\n\n  const handleCancelEdit = React.useCallback(() => {\n    setEditingMessageId(null)\n  }, [setEditingMessageId])\n\n  const handleRetry = React.useCallback(() => {\n    chatActions.regenerate({ messageId })\n  }, [chatActions, messageId])\n\n  const scene = useChatScene()\n\n  return (\n    <AIMessageIdContext value={messageId}>\n      <div className=\"relative flex flex-col gap-3\">\n        {/* Render data-block parts separately, outside the chat bubble */}\n        {dataBlockParts.length > 0 && scene !== \"onboarding\" && dataBlockParts.length > 0 && (\n          <div ref={dataBlockRef} className=\"flex justify-end\">\n            <div className=\"max-w-[calc(100%-1rem)]\">\n              {dataBlockParts.map((part) => {\n                if (part.type === \"data-block\" && \"data\" in part) {\n                  const blocks = part.data as AIChatContextBlock[]\n                  return (\n                    <AIDataBlockPart\n                      key={`${messageId}-datablock-${blocks.map((b) => b.id).join(\"-\")}`}\n                      blocks={blocks}\n                    />\n                  )\n                }\n                return null\n              })}\n            </div>\n          </div>\n        )}\n\n        {/* Main chat message */}\n        <m.div\n          initial={\n            isStreaming\n              ? {\n                  opacity: 0,\n                  y: 20,\n                  scale: 0.95,\n                }\n              : true\n          }\n          animate={{\n            opacity: 1,\n            y: 0,\n            scale: 1,\n          }}\n          transition={Spring.presets.smooth}\n          onContextMenu={stopPropagation}\n          className=\"group flex justify-end\"\n          onMouseEnter={() => setIsHovered(true)}\n          onMouseLeave={() => setIsHovered(false)}\n        >\n          <div className=\"relative flex max-w-[calc(100%-1rem)] flex-col gap-2 text-text\">\n            <div\n              ref={messageBubbleRef}\n              className=\"rounded-2xl bg-fill-tertiary px-4 py-2.5 text-text\"\n            >\n              <div className=\"flex select-text flex-col gap-2 text-sm\">\n                <UserMessageParts message={message} />\n              </div>\n            </div>\n\n            {/* Action buttons - only show when not editing */}\n            {!isEditing && (\n              <m.div\n                className=\"absolute bottom-1 right-0 flex items-center gap-1\"\n                initial={{ opacity: 0 }}\n                animate={{\n                  opacity: isHovered ? 1 : 0,\n                }}\n                transition={{ duration: 0.2, ease: \"easeOut\" }}\n              >\n                <span className=\"whitespace-nowrap px-2 py-1 text-[11px] leading-none text-text-tertiary\">\n                  <RelativeTime date={message.createdAt} />\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={handleEdit}\n                  className=\"flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-fill-secondary\"\n                  title=\"Edit message\"\n                >\n                  <i className=\"i-mgc-edit-cute-re size-3\" />\n                  <span>Edit</span>\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={handleRetry}\n                  className=\"flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-fill-secondary\"\n                  title=\"Retry\"\n                >\n                  <i className=\"i-mgc-refresh-2-cute-re size-3\" />\n                  <span>Retry</span>\n                </button>\n              </m.div>\n            )}\n\n            <div className=\"h-6\" />\n          </div>\n        </m.div>\n\n        {/* Full-width edit overlay - positioned at the top level to span entire container */}\n        <AnimatePresence>\n          {isEditing && (\n            <m.div\n              className=\"absolute inset-x-0 bottom-0 z-[1] flex\"\n              style={{\n                top: dataBlockHeight > 0 ? `${dataBlockHeight}px` : 0,\n              }}\n              initial={{ opacity: 0, scale: 0.98 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.98 }}\n              transition={{ duration: 0.15, ease: \"easeOut\" }}\n            >\n              <div className=\"w-full max-w-[var(--ai-chat-message-container-width,65ch)]\">\n                <EditableMessage\n                  messageId={messageId}\n                  parts={message.parts}\n                  onSave={handleSaveEdit}\n                  onCancel={handleCancelEdit}\n                  className=\"w-full\"\n                  initialHeight={messageBubbleHeight}\n                />\n              </div>\n            </m.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </AIMessageIdContext>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx",
    "content": "import * as React from \"react\"\n\nimport type { BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\nimport { AIMarkdownStreamingMessage } from \"./AIMarkdownMessage\"\nimport { UserRichTextMessage } from \"./UserRichTextMessage\"\n\ninterface UserMessagePartsProps {\n  message: BizUIMessage\n}\n\nexport const UserMessageParts: React.FC<UserMessagePartsProps> = React.memo(({ message }) => {\n  return message.parts.map((part, index) => {\n    const partKey = `${message.id}-${index}`\n\n    switch (part.type) {\n      case \"text\": {\n        return <AIMarkdownStreamingMessage isStreaming={false} key={partKey} text={part.text} />\n      }\n\n      case \"data-block\": {\n        // Skip data-block rendering here since it's handled separately in UserChatMessage\n        return null\n      }\n\n      case \"data-rich-text\": {\n        return (\n          <UserRichTextMessage\n            key={partKey}\n            data={part.data as { state: string; text: string }}\n            className=\"text-text\"\n          />\n        )\n      }\n\n      default: {\n        return null\n      }\n    }\n  })\n})\n\nUserMessageParts.displayName = \"UserMessageParts\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserRichTextMessage.tsx",
    "content": "import { defaultLexicalTheme } from \"@follow/components/ui/lexical-rich-editor/theme.js\"\nimport { cn } from \"@follow/utils\"\nimport type { InitialConfigType } from \"@lexical/react/LexicalComposer\"\nimport { LexicalComposer } from \"@lexical/react/LexicalComposer\"\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport { ContentEditable } from \"@lexical/react/LexicalContentEditable\"\nimport { LexicalErrorBoundary } from \"@lexical/react/LexicalErrorBoundary\"\nimport { RichTextPlugin } from \"@lexical/react/LexicalRichTextPlugin\"\nimport type { SerializedEditorState } from \"lexical\"\nimport * as React from \"react\"\nimport isEqual from \"react-fast-compare\"\n\nimport { LexicalAIEditorNodes } from \"../../editor\"\nimport { getShortcutMarkdownValue } from \"../../editor/plugins/shortcut/utils/shortcutTextValue\"\n\nfunction onError(error: Error) {\n  console.error(\"Lexical Read-Only Editor Error:\", error)\n}\n\nfunction replaceShortcutTagsWithMarkdown(state: string): string {\n  try {\n    const parsed = JSON.parse(state) as Record<string, any>\n    const textNodes: Array<{ text: string }> = []\n\n    const traverse = (node: any) => {\n      if (!node) return\n      if (Array.isArray(node.children)) {\n        node.children.forEach(traverse)\n      }\n      if (node.type === \"text\" && typeof node.text === \"string\") {\n        textNodes.push(node)\n      }\n    }\n\n    traverse(parsed.root)\n\n    textNodes.forEach((node) => {\n      node.text = node.text.replaceAll(/<shortcut id=\"([^\"]+)\"><\\/shortcut>/g, (_, id: string) => {\n        return getShortcutMarkdownValue(id)\n      })\n    })\n\n    return JSON.stringify(parsed)\n  } catch (error) {\n    console.error(\"Failed to transform shortcut tags to markdown:\", error)\n    return state\n  }\n}\n\ninterface UserRichTextMessageProps {\n  data: {\n    state: SerializedEditorState | string // Serialized editor state as a JSON string\n    text: string\n  }\n  className?: string\n}\n\nexport const UserRichTextMessage: React.FC<UserRichTextMessageProps> = React.memo(\n  ({ data, className }) => {\n    const sanitizedState = React.useMemo(() => {\n      const rawState = typeof data.state === \"string\" ? data.state : JSON.stringify(data.state)\n      return replaceShortcutTagsWithMarkdown(rawState)\n    }, [data.state])\n\n    const editorState = sanitizedState\n\n    let initialConfig: InitialConfigType = null!\n    if (!initialConfig) {\n      initialConfig = {\n        namespace: \"AIRichTextDisplay\",\n        theme: defaultLexicalTheme,\n        onError,\n        editable: false,\n        editorState,\n        nodes: LexicalAIEditorNodes,\n      }\n    }\n    return (\n      <div className={cn(\"relative cursor-text text-sm text-text\", className)}>\n        <LexicalComposer initialConfig={initialConfig}>\n          <RichTextPlugin\n            contentEditable={\n              <ContentEditable className=\"focus:outline-none\" style={{ outline: \"none\" }} />\n            }\n            ErrorBoundary={LexicalErrorBoundary}\n            placeholder={null}\n          />\n          <ListenableContentChangedPlugin state={editorState} />\n        </LexicalComposer>\n      </div>\n    )\n  },\n)\n\nconst ListenableContentChangedPlugin = ({ state }: { state: string }) => {\n  const [editor] = useLexicalComposerContext()\n  React.useEffect(() => {\n    const editorState = editor.getEditorState()\n    let timeoutId: number | null = null\n\n    editorState.read(() => {\n      const text = editorState.toJSON()\n\n      if (isEqual(text, state)) {\n        return\n      }\n      // Move setEditorState to a timeout to avoid flushSync during render\n      // Related to https://github.com/facebook/lexical/discussions/3536\n      timeoutId && clearTimeout(timeoutId)\n      timeoutId = setTimeout(() => {\n        editor.setEditorState(editor.parseEditorState(state))\n      }, 0)\n    })\n\n    return () => {\n      if (timeoutId) {\n        clearTimeout(timeoutId)\n      }\n    }\n  }, [editor, state])\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts",
    "content": "import { getView } from \"@follow/constants\"\n\nimport type { AIChatContextBlock, FileAttachment } from \"~/modules/ai-chat/store/types\"\nimport {\n  getFileCategoryFromMimeType,\n  getFileIconName,\n} from \"~/modules/ai-chat/utils/file-validation\"\n\n/**\n * Block style configurations for different context block types\n */\nexport const BLOCK_STYLES = {\n  mainEntry: {\n    container: \"from-orange/5 to-orange/10 border-orange/20 hover:border-orange/30\",\n    icon: \"bg-orange/10 text-orange\",\n    label: \"text-orange\",\n  },\n  mainFeed: {\n    container: \"from-orange/5 to-orange/10 border-orange/20 hover:border-orange/30\",\n    icon: \"bg-orange/10 text-orange\",\n    label: \"text-orange\",\n  },\n  fileAttachment: {\n    container: \"from-pink/5 to-pink/10 border-pink/20 hover:border-pink/30\",\n    icon: \"bg-pink/10 text-pink\",\n    label: \"text-pink\",\n  },\n  unreadOnly: {\n    container: \"from-green/5 to-green/10 border-green/20 hover:border-green/30\",\n    icon: \"bg-green/10 text-green\",\n    label: \"text-green\",\n  },\n} as const\n\n/**\n * Default fallback styles for unknown block types\n */\nexport const DEFAULT_BLOCK_STYLES = {\n  container: \"from-gray/5 to-gray/10 border-gray/20 hover:border-gray/30\",\n  icon: \"bg-gray/10 text-gray\",\n  label: \"text-gray\",\n} as const\n\n/**\n * Block icons for different context block types\n */\nexport const BLOCK_ICONS = {\n  mainEntry: \"i-mgc-star-cute-fi\",\n  mainFeed: \"i-mgc-rss-cute-fi\",\n  fileAttachment: \"i-mgc-file-upload-cute-re\",\n  unreadOnly: \"i-mgc-round-cute-fi\",\n} as const\n\n/**\n * Block labels for different context block types\n */\nexport const BLOCK_LABELS = {\n  mainEntry: \"Current\",\n  mainFeed: \"Current\",\n  fileAttachment: \"File\",\n  mainView: \"View\",\n  unreadOnly: \"Filter\",\n} as const\n\n/**\n * File upload status labels\n */\nexport const FILE_STATUS_LABELS = {\n  uploading: \"Uploading...\",\n  error: \"Failed\",\n  processing: \"Processing...\",\n  completed: \"\",\n} as const\n\n/**\n * Gets the appropriate styles for a block type\n */\nexport function getBlockStyles(type: AIChatContextBlock[\"type\"]) {\n  return BLOCK_STYLES[type] || DEFAULT_BLOCK_STYLES\n}\n\n/**\n * Gets the appropriate icon for a block\n */\nexport function getBlockIcon(block: AIChatContextBlock): string {\n  if (block.type === \"fileAttachment\" && block.attachment) {\n    const fileCategory = getFileCategoryFromMimeType(block.attachment.type)\n    return getFileIconName(fileCategory)\n  }\n\n  if (block.type === \"mainView\") {\n    const viewIcon = getView(Number(block.value))?.icon.props.className\n    return viewIcon\n  }\n\n  return BLOCK_ICONS[block.type] || BLOCK_ICONS.fileAttachment\n}\n\n/**\n * Gets the appropriate label for a block type\n */\nexport function getBlockLabel(type: AIChatContextBlock[\"type\"]): string {\n  return BLOCK_LABELS[type] || \"\"\n}\n\n/**\n * Gets the best available image URL for a file attachment\n */\nexport function getImageUrl(attachment: FileAttachment): string | null {\n  return attachment.previewUrl || attachment.dataUrl || attachment.serverUrl || null\n}\n\n/**\n * Checks if a block represents an image attachment\n */\nexport function isImageAttachment(block: AIChatContextBlock): boolean {\n  return (\n    block.type === \"fileAttachment\" &&\n    !!block.attachment &&\n    getFileCategoryFromMimeType(block.attachment.type) === \"image\"\n  )\n}\n\n/**\n * Gets display content for file attachments based on upload status\n */\nexport function getFileDisplayContent(attachment: FileAttachment): string {\n  const statusLabel = FILE_STATUS_LABELS[attachment.uploadStatus || \"completed\"]\n  return statusLabel || attachment.name\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animated/AnimatedMarkdown.tsx",
    "content": "/**\n * @see https://github.com/Ephibbs/flowtoken/blob/main/src/components/AnimatedMarkdown.tsx\n */\n\nimport type { LinkProps } from \"@follow/components/ui/link/LinkWithTooltip.js\"\nimport { getSubscriptionById } from \"@follow/store/subscription/getter\"\nimport { cn, isBizId } from \"@follow/utils\"\nimport * as React from \"react\"\nimport type { Components } from \"react-markdown\"\nimport ReactMarkdown from \"react-markdown\"\nimport rehypeRaw from \"rehype-raw\"\nimport remarkGfm from \"remark-gfm\"\n\nimport { MemoizedShikiCode } from \"~/components/ui/code-highlighter\"\nimport { EntryPreviewCard, FeedPreviewCard } from \"~/components/ui/hover-preview\"\nimport { MarkdownLink } from \"~/components/ui/markdown/renderers\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { usePeekModal } from \"~/hooks/biz/usePeekModal\"\n\nimport { ANIMATION_STYLE as ANIMATION_STYLE_DEFAULT } from \"./constants\"\nimport { TokenizedText } from \"./TokenizedText\"\n\ninterface MarkdownAnimateTextProps {\n  content: string\n  isStreaming?: boolean\n}\n\nconst emptyObject = {}\nconst animateText: (text: string | Array<any>) => React.ReactNode = (text: string | Array<any>) => {\n  text = Array.isArray(text) ? text : [text]\n  let keyCounter = 0\n  const processText: (input: any, keyPrefix?: string) => React.ReactNode = (\n    input: any,\n    keyPrefix = \"item\",\n  ) => {\n    if (Array.isArray(input)) {\n      // Process each element in the array\n      return input.map((element, index) => (\n        <React.Fragment key={`${keyPrefix}-${index}`}>\n          {processText(element, `${keyPrefix}-${index}`)}\n        </React.Fragment>\n      ))\n    } else if (typeof input === \"string\") {\n      return <TokenizedText key={`pcc-${keyCounter++}`} input={input} />\n    } else if (typeof input === \"number\") {\n      return <TokenizedText key={`pcc-${keyCounter++}`} input={String(input)} />\n    } else if (React.isValidElement(input)) {\n      // Preserve element structure and do not wrap block elements (avoid <span><ul>...)\n      return React.cloneElement(input as React.ReactElement, { key: `pcc-${keyCounter++}` })\n    } else {\n      // Return other inputs unchanged (null, undefined, booleans, etc.)\n      return input\n    }\n  }\n\n  return processText(text)\n}\n\nconst createAiMessageMarkdownElementsRender = (canAnimate: boolean) => {\n  const ANIMATION_STYLE = canAnimate ? ANIMATION_STYLE_DEFAULT : emptyObject\n\n  const textAnimator = canAnimate ? animateText : (text: string | Array<any>) => text\n\n  return {\n    pre: ({ children }) => {\n      const props = React.isValidElement(children) && \"props\" in children && children.props\n\n      if (props) {\n        const { className, children } = props as any\n\n        if (className && className.includes(\"language-\") && typeof children === \"string\") {\n          const language = className.replace(\"language-\", \"\")\n          const code = children\n\n          return <MemoizedShikiCode code={code} language={language} showCopy />\n        }\n      }\n\n      return <pre className=\"bg-material-medium text-text-secondary\">{children}</pre>\n    },\n    a: ({ node, ...props }) => {\n      return React.createElement(RelatedEntryLink, { ...props } as any)\n    },\n    \"mention-entry\": ({ node, children, ...props }: any) => (\n      <InlineFoloReference type=\"entry\" style={ANIMATION_STYLE} {...props}>\n        {children}\n      </InlineFoloReference>\n    ),\n    \"mention-feed\": ({ node, children, ...props }: any) => (\n      <InlineFoloReference type=\"feed\" style={ANIMATION_STYLE} {...props}>\n        {children}\n      </InlineFoloReference>\n    ),\n\n    text: ({ node, ...props }: any) => <span {...props}>{textAnimator(props.children)}</span>,\n    h1: ({ node, ...props }: any) => <h1 {...props}>{textAnimator(props.children)}</h1>,\n    h2: ({ node, ...props }: any) => <h2 {...props}>{textAnimator(props.children)}</h2>,\n    h3: ({ node, ...props }: any) => <h3 {...props}>{textAnimator(props.children)}</h3>,\n    h4: ({ node, ...props }: any) => <h4 {...props}>{textAnimator(props.children)}</h4>,\n    h5: ({ node, ...props }: any) => <h5 {...props}>{textAnimator(props.children)}</h5>,\n    h6: ({ node, ...props }: any) => <h6 {...props}>{textAnimator(props.children)}</h6>,\n    p: ({ node, ...props }: any) => <p {...props}>{textAnimator(props.children)}</p>,\n    li: ({ node, ...props }: any) => (\n      <li {...props} style={ANIMATION_STYLE}>\n        {textAnimator(props.children)}\n      </li>\n    ),\n\n    strong: ({ node, ...props }: any) => <strong {...props}>{textAnimator(props.children)}</strong>,\n    em: ({ node, ...props }: any) => <em {...props}>{textAnimator(props.children)}</em>,\n\n    hr: ({ node, ...props }: any) => (\n      <hr {...props} className=\"whitespace-pre-wrap\" style={ANIMATION_STYLE} />\n    ),\n\n    table: ({ children, ref, node, ...props }) => {\n      return (\n        <div className=\"overflow-x-auto rounded-lg border border-border bg-material-thin\">\n          <table\n            {...props}\n            style={ANIMATION_STYLE}\n            className=\"my-0 min-w-full divide-y divide-border text-sm\"\n          >\n            {children}\n          </table>\n        </div>\n      )\n    },\n    thead: ({ children, ref, node, ...props }) => {\n      return (\n        <thead {...props} className=\"bg-fill-tertiary\">\n          {children}\n        </thead>\n      )\n    },\n    th: ({ children, ref, node, ...props }) => {\n      return (\n        <th\n          {...props}\n          className=\"whitespace-nowrap px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-secondary\"\n        >\n          {children}\n        </th>\n      )\n    },\n    tbody: ({ children, ref, node, ...props }) => {\n      return (\n        <tbody {...props} className=\"divide-y divide-border bg-material-ultra-thin\">\n          {children}\n        </tbody>\n      )\n    },\n    tr: ({ children, ref, node, ...props }) => {\n      return (\n        <tr {...props} className=\"transition-colors duration-150 hover:bg-material-thin\">\n          {textAnimator(children as any)}\n        </tr>\n      )\n    },\n    td: ({ children, ref, node, ...props }) => {\n      return (\n        <td {...props} className=\"whitespace-nowrap px-4 py-3 text-sm text-text\">\n          {textAnimator(children as any)}\n        </td>\n      )\n    },\n  } as Components\n}\n\nconst animatedComponents = createAiMessageMarkdownElementsRender(true)\nconst staticComponents = createAiMessageMarkdownElementsRender(false)\nexport const MarkdownAnimateText: React.FC<MarkdownAnimateTextProps> = ({\n  content,\n  isStreaming,\n}) => {\n  const components = isStreaming ? animatedComponents : staticComponents\n\n  return (\n    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>\n      {content}\n    </ReactMarkdown>\n  )\n}\n\ntype BaseInlineFoloReferenceProps = {\n  type: \"entry\" | \"feed\"\n  id?: string\n}\nconst InlineFoloReference: React.FC<\n  BaseInlineFoloReferenceProps & {\n    children?: React.ReactNode\n    className?: string\n    style?: React.CSSProperties\n  }\n> = ({ type, children, className, style, id }) => {\n  const peekModal = usePeekModal()\n  const navigateEntry = useNavigateEntry()\n\n  const targetId = React.useMemo(() => {\n    return (\n      id ||\n      React.Children.toArray(children)\n        .map((child) => {\n          if (typeof child === \"string\" || typeof child === \"number\") {\n            return String(child)\n          }\n          return \"\"\n        })\n        .join(\"\")\n        .trim()\n    )\n  }, [id, children])\n\n  const handleClick = React.useCallback(() => {\n    if (!targetId) return\n\n    if (type === \"entry\") {\n      peekModal(targetId, \"modal\")\n    } else {\n      const subscription = getSubscriptionById(targetId)\n      const view = subscription?.view\n      navigateEntry({ feedId: targetId, entryId: null, view })\n    }\n  }, [navigateEntry, peekModal, targetId, type])\n\n  if (!targetId) return null\n\n  const isEntry = type === \"entry\"\n  const button = (\n    <button\n      type=\"button\"\n      aria-label={type === \"entry\" ? `Open entry ${targetId}` : `Open feed ${targetId}`}\n      title={type === \"entry\" ? `Open entry ${targetId}` : `Open feed ${targetId}`}\n      className={cn(\n        \"mx-[0.15em] inline-flex cursor-pointer items-center align-middle text-text-secondary opacity-80 transition-opacity hover:text-text hover:opacity-100\",\n        \"-translate-y-0.5\",\n        className,\n      )}\n      style={style}\n      onClick={handleClick}\n    >\n      {isEntry ? (\n        <i className=\"i-mgc-docment-cute-re size-[1em]\" />\n      ) : (\n        <i className=\"i-mgc-rss-2-cute-fi size-[1em]\" />\n      )}\n    </button>\n  )\n\n  if (isEntry) {\n    return (\n      <EntryPreviewCard entryId={targetId} onNavigate={handleClick}>\n        {button}\n      </EntryPreviewCard>\n    )\n  } else {\n    return (\n      <FeedPreviewCard feedId={targetId} onNavigate={handleClick}>\n        {button}\n      </FeedPreviewCard>\n    )\n  }\n}\n\nconst RelatedEntryLink = (props: LinkProps) => {\n  const { href, children } = props\n  const entryId = isBizId(href) ? href : null\n\n  const peekModal = usePeekModal()\n  if (!entryId) {\n    return <MarkdownLink {...props} />\n  }\n  return (\n    <button\n      type=\"button\"\n      className=\"follow-link--underline cursor-pointer font-semibold text-text no-underline\"\n      onClick={() => {\n        peekModal(entryId, \"modal\")\n      }}\n    >\n      {children}\n      <i className=\"i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70\" />\n    </button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animated/TokenizedText.tsx",
    "content": "/**\n * @see https://github.com/Ephibbs/flowtoken/blob/main/src/components/SplitText.tsx\n */\nimport type { ReactElement } from \"react\"\nimport * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\n\nimport { ANIMATION_STYLE } from \"./constants\"\n\ninterface TokenWithSource {\n  text: string\n  source: number\n}\n// Helper function to check if token is a TokenWithSource type\nconst isTokenWithSource = (token: TokenType): token is TokenWithSource => {\n  return token !== null && typeof token === \"object\" && \"text\" in token && \"source\" in token\n}\ntype TokenType = string | TokenWithSource | ReactElement\n\nexport const TokenizedText = ({ input }: { input: React.ReactNode }) => {\n  // Track previous input to detect changes\n  const prevInputRef = useRef<string>(\"\")\n  // Track tokens with their source for proper keying in diff mode\n  const tokensWithSources = useRef<TokenWithSource[]>([])\n\n  // For detecting and handling duplicated content\n  const fullTextRef = useRef<string>(\"\")\n\n  const tokens = React.useMemo(() => {\n    if (React.isValidElement(input)) return [input]\n\n    if (typeof input !== \"string\") return null\n\n    // If this is the first render or we've gone backward, reset everything\n    if (!prevInputRef.current || input.length < prevInputRef.current.length) {\n      tokensWithSources.current = []\n      fullTextRef.current = \"\"\n    }\n\n    // Only process input if it's different from previous\n    if (input !== prevInputRef.current) {\n      // Find the true unique content by comparing with our tracked full text\n      // This handles cases where the input contains duplicates\n\n      // First check if we're just seeing the same content repeated\n      if (input.includes(fullTextRef.current)) {\n        const uniqueNewContent = input.slice(fullTextRef.current.length)\n\n        // Only add if there's actual new content\n        if (uniqueNewContent.length > 0) {\n          tokensWithSources.current.push({\n            text: uniqueNewContent,\n            source: tokensWithSources.current.length,\n          })\n\n          // Update our full text tracking\n          fullTextRef.current = input\n        }\n      } else {\n        // Handle case when input completely changes\n        // Just take the whole thing as a new token\n        tokensWithSources.current = [\n          {\n            text: input,\n            source: 0,\n          },\n        ]\n        fullTextRef.current = input\n      }\n    }\n\n    // Return the tokensWithSources directly\n    return tokensWithSources.current\n  }, [input])\n\n  // Update previous input after processing\n  useEffect(() => {\n    if (typeof input === \"string\") {\n      prevInputRef.current = input\n    }\n  }, [input])\n\n  return (\n    <>\n      {tokens?.map((token, index) => {\n        // Determine the key and text based on token type\n        let key = index\n        let text = \"\"\n\n        if (isTokenWithSource(token)) {\n          key = token.source\n          text = token.text\n        } else if (typeof token === \"string\") {\n          key = index\n          text = token\n        } else if (React.isValidElement(token)) {\n          key = index\n          text = \"\"\n          return React.cloneElement(token, { key })\n        }\n\n        // Skip rendering completely empty tokens\n        if (text.length === 0) {\n          return null\n        }\n\n        // For whitespace-only tokens, preserve spacing without adding a DOM element\n        if (/^\\s+$/.test(text)) {\n          return <React.Fragment key={key}>{text}</React.Fragment>\n        }\n\n        return (\n          <span key={key} className=\"inline whitespace-pre-wrap\" style={ANIMATION_STYLE}>\n            {text}\n          </span>\n        )\n      })}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animated/constants.ts",
    "content": "export const DEFAULT_ANIMATION = \"mask-left-to-right 0.5s ease-in-out\"\nexport const ANIMATION_STYLE = {\n  animation: DEFAULT_ANIMATION,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/index.ts",
    "content": "export { AIMarkdownStreamingMessage } from \"./AIMarkdownMessage\"\nexport { AIMessageParts } from \"./AIMessageParts\"\nexport { ToolInvocationComponent } from \"./ToolInvocationComponent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/parse-incomplete-markdown.ts",
    "content": "// @copy https://github.com/vercel/streamdown/blob/main/packages/streamdown/lib/parse-incomplete-markdown.ts\n/* eslint-disable unicorn/prefer-string-slice */\nconst boldPattern = /(\\*\\*)([^*]*)$/\nconst italicPattern = /(__)([^_]*)$/\nconst boldItalicPattern = /(\\*\\*\\*)([^*]*)$/\nconst singleAsteriskPattern = /(\\*)([^*]*)$/\nconst singleUnderscorePattern = /(_)([^_]*)$/\nconst inlineCodePattern = /(`)([^`]*)$/\nconst strikethroughPattern = /(~~)([^~]*)$/\nconst whitespaceOrMarkersPattern = /^[\\s_~*`]*$/\nconst listItemPattern = /^\\s*[-*+]\\s+$/\nconst letterNumberUnderscorePattern = /[\\p{L}\\p{N}_]/u\nconst inlineTripleBacktickPattern = /^```[^`\\n]*```?$/\nconst fourOrMoreAsterisksPattern = /^\\*{4,}$/\n\n// OPTIMIZATION: Precompute which characters are word characters\n// Using ASCII fast path before falling back to Unicode regex\nconst isWordChar = (char: string): boolean => {\n  if (!char) {\n    return false\n  }\n  // eslint-disable-next-line unicorn/prefer-code-point\n  const code = char.charCodeAt(0)\n  // ASCII optimization: a-z, A-Z, 0-9, _\n  if (\n    (code >= 48 && code <= 57) || // 0-9\n    (code >= 65 && code <= 90) || // A-Z\n    (code >= 97 && code <= 122) || // a-z\n    code === 95 // _\n  ) {\n    return true\n  }\n  // Fallback to regex for Unicode characters (less common)\n  return letterNumberUnderscorePattern.test(char)\n}\n\n// Detect custom inline reference tags\nconst mentionTagStartPattern = /<\\s*mention-(?:entry|feed)\\b/gi\nconst mentionTagCompletePattern = /^<\\s*(mention-(?:entry|feed))/i\n\n// Finds the end index of a mention tag (self-closing or paired) starting at `startIndex`.\n// Returns the index of the closing `>` when found outside of quotes; otherwise -1.\nconst findMentionTagEnd = (text: string, startIndex: number): number => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) return -1\n\n  let inQuote: '\"' | \"'\" | null = null\n  let openingTagEnd = -1\n  for (let i = startIndex; i < text.length; i++) {\n    const char = text[i]\n    if (inQuote) {\n      if (char === inQuote && text[i - 1] !== \"\\\\\") {\n        inQuote = null\n      }\n      continue\n    }\n    if (char === '\"' || char === \"'\") {\n      inQuote = char\n      continue\n    }\n    if (char === \"/\" && text[i + 1] === \">\") {\n      return i + 1 // index of '>' in `/>`\n    }\n    if (char === \">\") {\n      openingTagEnd = i\n      break\n    }\n  }\n\n  if (openingTagEnd === -1) {\n    return -1\n  }\n\n  const openingTag = text.substring(startIndex, openingTagEnd + 1)\n  const tagNameMatch = openingTag.match(mentionTagCompletePattern)\n  if (!tagNameMatch) {\n    return -1\n  }\n\n  // If the tag is already self-closing (allow whitespace before `/`)\n  if (/\\/\\s*>$/.test(openingTag)) {\n    return openingTagEnd\n  }\n\n  const tagName = (tagNameMatch[1] ?? \"\").toLowerCase()\n  if (!tagName) {\n    return -1\n  }\n  const afterOpening = text.substring(openingTagEnd + 1)\n  const closingTagPattern = new RegExp(`<\\\\s*/\\\\s*${tagName}\\\\s*>`, \"i\")\n  const closingMatch = closingTagPattern.exec(afterOpening)\n\n  if (!closingMatch) {\n    return -1\n  }\n\n  return openingTagEnd + 1 + closingMatch.index + closingMatch[0].length - 1\n}\n\n// Trims trailing, incomplete `<mention-entry ...>` or `<mention-feed ...>` tags to avoid\n// injecting broken raw HTML into markdown while streaming.\nconst handleIncompleteMentionTags = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  let cutIndex: number | null = null\n  let match: RegExpExecArray | null\n  mentionTagStartPattern.lastIndex = 0\n  while ((match = mentionTagStartPattern.exec(text))) {\n    const start = match.index\n    const end = findMentionTagEnd(text, start)\n    if (end === -1) {\n      cutIndex = start\n      break\n    } else {\n      // continue scanning after this complete tag\n      mentionTagStartPattern.lastIndex = end + 1\n    }\n  }\n\n  if (cutIndex !== null) {\n    const nextNewlineIndex = text.indexOf(\"\\n\", cutIndex)\n    if (nextNewlineIndex !== -1) {\n      // Remove only the incomplete tag segment and preserve following lines\n      return text.substring(0, cutIndex) + text.substring(nextNewlineIndex)\n    }\n    // No newline after the incomplete tag; drop the trailing incomplete segment\n    return text.substring(0, cutIndex)\n  }\n  return text\n}\n\n// Handles `<Use: ...>` wrappers that contain mention tags (self-closing or paired) by:\n// - Replacing the whole wrapper with only the inner `<mention-...>` when complete\n// - Trimming from `<Use:` if the inner mention tag is incomplete while streaming\nconst handleUseWrapper = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) return text\n\n  const usePattern = /<\\s*Use:\\s*/gi\n  let result = text\n  let match: RegExpExecArray | null\n  usePattern.lastIndex = 0\n\n  // We rebuild iteratively in case of multiple occurrences\n  while ((match = usePattern.exec(result))) {\n    const useStart = match.index\n    const mentionStart = result.indexOf(\"<mention-\", useStart)\n    if (mentionStart === -1) {\n      // Incomplete `<Use:` without a mention yet → remove only the incomplete segment\n      const nextNewlineIndex = result.indexOf(\"\\n\", useStart)\n      return nextNewlineIndex !== -1\n        ? result.substring(0, useStart) + result.substring(nextNewlineIndex)\n        : result.substring(0, useStart)\n    }\n\n    // Ensure mention is the immediate content of the Use wrapper (allow whitespace)\n    const between = result.substring(useStart + match[0].length, mentionStart)\n    if (!/^\\s*$/.test(between)) {\n      // Unexpected content between Use and mention → treat as plain text, continue\n      continue\n    }\n\n    const mentionEnd = findMentionTagEnd(result, mentionStart)\n    if (mentionEnd === -1) {\n      // Mention not finished yet → remove only the incomplete wrapper segment\n      const nextNewlineIndex = result.indexOf(\"\\n\", useStart)\n      return nextNewlineIndex !== -1\n        ? result.substring(0, useStart) + result.substring(nextNewlineIndex)\n        : result.substring(0, useStart)\n    }\n\n    // Replace `<Use: <mention-...>` with `<mention-...>`\n    const before = result.substring(0, useStart)\n    const mentionTag = result.substring(mentionStart, mentionEnd + 1)\n    const after = result.substring(mentionEnd + 1)\n    result = before + mentionTag + after\n\n    // Reset the regex lastIndex to continue scanning after the replaced tag\n    usePattern.lastIndex = before.length + mentionTag.length\n  }\n\n  return result\n}\n\n// Helper function to check if we have a complete code block\nconst hasCompleteCodeBlock = (text: string): boolean => {\n  const tripleBackticks = (text.match(/```/g) || []).length\n  return tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes(\"\\n\")\n}\n\n// Helper function to find the matching opening bracket for a closing bracket\n// Handles nested brackets correctly by searching backwards\nconst findMatchingOpeningBracket = (text: string, closeIndex: number): number => {\n  let depth = 1\n  for (let i = closeIndex - 1; i >= 0; i -= 1) {\n    if (text[i] === \"]\") {\n      depth += 1\n    } else if (text[i] === \"[\") {\n      depth -= 1\n      if (depth === 0) {\n        return i\n      }\n    }\n  }\n  return -1 // No matching bracket found\n}\n\n// Helper function to find the matching closing bracket for an opening bracket\n// Handles nested brackets correctly\nconst findMatchingClosingBracket = (text: string, openIndex: number): number => {\n  let depth = 1\n  for (let i = openIndex + 1; i < text.length; i += 1) {\n    if (text[i] === \"[\") {\n      depth += 1\n    } else if (text[i] === \"]\") {\n      depth -= 1\n      if (depth === 0) {\n        return i\n      }\n    }\n  }\n  return -1 // No matching bracket found\n}\n\n// Check if a position is inside a code block (between ``` or `)\nconst isInsideCodeBlock = (text: string, position: number): boolean => {\n  // Check for inline code (backticks)\n  let inInlineCode = false\n  let inMultilineCode = false\n\n  for (let i = 0; i < position; i += 1) {\n    // Check for triple backticks (multiline code blocks)\n    if (text.substring(i, i + 3) === \"```\") {\n      inMultilineCode = !inMultilineCode\n      i += 2 // Skip the next 2 backticks\n      continue\n    }\n\n    // Only check for inline code if not in multiline code\n    if (!inMultilineCode && text[i] === \"`\") {\n      inInlineCode = !inInlineCode\n    }\n  }\n\n  return inInlineCode || inMultilineCode\n}\n\n// Handles incomplete links and images by preserving them with a special marker\nconst handleIncompleteLinksAndImages = (text: string): string => {\n  // Look for patterns like [text]( or ![text]( at the end of text\n  // We need to handle nested brackets in the link text\n\n  // Start from the end and look for ]( pattern\n  const lastParenIndex = text.lastIndexOf(\"](\")\n  if (lastParenIndex !== -1 && !isInsideCodeBlock(text, lastParenIndex)) {\n    // Check if this ]( is not followed by a closing )\n    const afterParen = text.substring(lastParenIndex + 2)\n    if (!afterParen.includes(\")\")) {\n      // We have an incomplete URL like [text](partial-url\n      // Now find the matching opening bracket for the ] before (\n      const openBracketIndex = findMatchingOpeningBracket(text, lastParenIndex)\n\n      if (openBracketIndex !== -1 && !isInsideCodeBlock(text, openBracketIndex)) {\n        // Check if there's a ! before the [\n        const isImage = openBracketIndex > 0 && text[openBracketIndex - 1] === \"!\"\n        const startIndex = isImage ? openBracketIndex - 1 : openBracketIndex\n\n        // Extract everything before this link/image\n        const beforeLink = text.substring(0, startIndex)\n        const linkText = text.substring(openBracketIndex + 1, lastParenIndex)\n\n        if (isImage) {\n          // For images with incomplete URLs, remove them entirely\n          return beforeLink\n        }\n\n        // For links with incomplete URLs, replace the URL with placeholder and close it\n        return `${beforeLink}[${linkText}](streamdown:incomplete-link)`\n      }\n    }\n  }\n\n  // Then check for incomplete link text: [partial-text without closing ]\n  // Search backwards for an opening bracket that doesn't have a matching closing bracket\n  for (let i = text.length - 1; i >= 0; i -= 1) {\n    if (text[i] === \"[\" && !isInsideCodeBlock(text, i)) {\n      // Check if there's a ! before it\n      const isImage = i > 0 && text[i - 1] === \"!\"\n      const openIndex = isImage ? i - 1 : i\n\n      // Check if we have a closing bracket after this\n      const afterOpen = text.substring(i + 1)\n      if (!afterOpen.includes(\"]\")) {\n        // This is an incomplete link/image\n        const beforeLink = text.substring(0, openIndex)\n\n        if (isImage) {\n          // For images, we remove them as they can't show skeleton\n          return beforeLink\n        }\n\n        // For links, preserve the text and close the link with a\n        // special placeholder URL that indicates it's incomplete\n        return `${text}](streamdown:incomplete-link)`\n      }\n\n      // If we found a closing bracket, we need to check if it's the matching one\n      // (accounting for nested brackets)\n      const closingIndex = findMatchingClosingBracket(text, i)\n      if (closingIndex === -1) {\n        // No matching closing bracket\n        const beforeLink = text.substring(0, openIndex)\n\n        if (isImage) {\n          return beforeLink\n        }\n\n        return `${text}](streamdown:incomplete-link)`\n      }\n    }\n  }\n\n  return text\n}\n\n// Completes incomplete bold formatting (**)\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex markdown parsing logic with multiple edge cases\"\nconst handleIncompleteBold = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  const boldMatch = text.match(boldPattern)\n\n  if (boldMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // boldMatch[2] contains the content after **\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = boldMatch[2]\n    if (!contentAfterMarker || whitespaceOrMarkersPattern.test(contentAfterMarker)) {\n      return text\n    }\n\n    // Check if the bold marker is in a list item context\n    // Find the position of the matched bold marker\n    const markerIndex = text.lastIndexOf(boldMatch[1]!)\n    const beforeMarker = text.substring(0, markerIndex)\n    const lastNewlineBeforeMarker = beforeMarker.lastIndexOf(\"\\n\")\n    const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1\n    const lineBeforeMarker = text.substring(lineStart, markerIndex)\n\n    // Check if this line is a list item with just the bold marker\n    if (listItemPattern.test(lineBeforeMarker)) {\n      // This is a list item with just emphasis markers\n      // Check if content after marker spans multiple lines\n      const hasNewlineInContent = contentAfterMarker.includes(\"\\n\")\n      if (hasNewlineInContent) {\n        // Don't complete if the content spans to another line\n        return text\n      }\n    }\n\n    const asteriskPairs = (text.match(/\\*\\*/g) || []).length\n    if (asteriskPairs % 2 === 1) {\n      return `${text}**`\n    }\n  }\n\n  return text\n}\n\n// Completes incomplete italic formatting with double underscores (__)\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex markdown parsing logic with multiple edge cases\"\nconst handleIncompleteDoubleUnderscoreItalic = (text: string): string => {\n  const italicMatch = text.match(italicPattern)\n\n  if (italicMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // italicMatch[2] contains the content after __\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = italicMatch[2]\n    if (!contentAfterMarker || whitespaceOrMarkersPattern.test(contentAfterMarker)) {\n      return text\n    }\n\n    // Check if the underscore marker is in a list item context\n    // Find the position of the matched underscore marker\n    const markerIndex = text.lastIndexOf(italicMatch[1]!)\n    const beforeMarker = text.substring(0, markerIndex)\n    const lastNewlineBeforeMarker = beforeMarker.lastIndexOf(\"\\n\")\n    const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1\n    const lineBeforeMarker = text.substring(lineStart, markerIndex)\n\n    // Check if this line is a list item with just the underscore marker\n    if (listItemPattern.test(lineBeforeMarker)) {\n      // This is a list item with just emphasis markers\n      // Check if content after marker spans multiple lines\n      const hasNewlineInContent = contentAfterMarker.includes(\"\\n\")\n      if (hasNewlineInContent) {\n        // Don't complete if the content spans to another line\n        return text\n      }\n    }\n\n    const underscorePairs = (text.match(/__/g) || []).length\n    if (underscorePairs % 2 === 1) {\n      return `${text}__`\n    }\n  }\n\n  return text\n}\n\n// OPTIMIZATION: Counts single asterisks without split(\"\").reduce()\n// Counts single asterisks that are not part of double asterisks, not escaped, not list markers, and not word-internal\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex character counting logic with multiple edge cases\"\nconst countSingleAsterisks = (text: string): number => {\n  let count = 0\n  const len = text.length\n\n  for (let index = 0; index < len; index += 1) {\n    if (text[index] !== \"*\") {\n      continue\n    }\n\n    const prevChar = index > 0 ? text[index - 1] : \"\"\n    const nextChar = index < len - 1 ? text[index + 1] : \"\"\n\n    // Skip if escaped with backslash\n    if (prevChar === \"\\\\\") {\n      continue\n    }\n\n    // Skip if part of ** or ***\n    if (prevChar === \"*\" || nextChar === \"*\") {\n      continue\n    }\n\n    // Skip if asterisk is word-internal (between word characters)\n    if (prevChar && nextChar && isWordChar(prevChar) && isWordChar(nextChar)) {\n      continue\n    }\n\n    // Check if this is a list marker (asterisk at start of line followed by space)\n    // Look backwards to find the start of the current line\n    let lineStartIndex = 0\n    for (let i = index - 1; i >= 0; i -= 1) {\n      if (text[i] === \"\\n\") {\n        lineStartIndex = i + 1\n        break\n      }\n    }\n\n    // Check if this asterisk is at the beginning of a line (with optional whitespace)\n    let isListMarker = true\n    for (let i = lineStartIndex; i < index; i += 1) {\n      if (text[i] !== \" \" && text[i] !== \"\\t\") {\n        isListMarker = false\n        break\n      }\n    }\n\n    if (isListMarker && (nextChar === \" \" || nextChar === \"\\t\")) {\n      continue\n    }\n\n    count += 1\n  }\n\n  return count\n}\n\n// Completes incomplete italic formatting with single asterisks (*)\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex italic handling logic with multiple edge cases for markdown parsing\"\nconst handleIncompleteSingleAsteriskItalic = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  const singleAsteriskMatch = text.match(singleAsteriskPattern)\n\n  if (singleAsteriskMatch) {\n    // Find the first single asterisk position (not part of ** and not word-internal)\n    let firstSingleAsteriskIndex = -1\n    for (let i = 0; i < text.length; i += 1) {\n      if (text[i] === \"*\" && text[i - 1] !== \"*\" && text[i + 1] !== \"*\" && text[i - 1] !== \"\\\\\") {\n        // Check if asterisk is word-internal (between word characters)\n        const prevChar = i > 0 ? text[i - 1] : \"\"\n        const nextChar = i < text.length - 1 ? text[i + 1] : \"\"\n        if (prevChar && nextChar && isWordChar(prevChar) && isWordChar(nextChar)) {\n          continue\n        }\n\n        firstSingleAsteriskIndex = i\n        break\n      }\n    }\n\n    if (firstSingleAsteriskIndex === -1) {\n      return text\n    }\n\n    // Get content after the first single asterisk\n    const contentAfterFirstAsterisk = text.substring(firstSingleAsteriskIndex + 1)\n\n    // Check if there's meaningful content after the asterisk\n    // Don't close if content is only whitespace or emphasis markers\n    if (!contentAfterFirstAsterisk || whitespaceOrMarkersPattern.test(contentAfterFirstAsterisk)) {\n      return text\n    }\n\n    const singleAsterisks = countSingleAsterisks(text)\n    if (singleAsterisks % 2 === 1) {\n      return `${text}*`\n    }\n  }\n\n  return text\n}\n\n// Check if a position is within a math block (between $ or $$)\nconst isWithinMathBlock = (text: string, position: number): boolean => {\n  // Count dollar signs before this position\n  let inInlineMath = false\n  let inBlockMath = false\n\n  for (let i = 0; i < text.length && i < position; i += 1) {\n    // Skip escaped dollar signs\n    if (text[i] === \"\\\\\" && text[i + 1] === \"$\") {\n      i += 1 // Skip the next character\n      continue\n    }\n\n    if (text[i] === \"$\") {\n      // Check for block math ($$)\n      if (text[i + 1] === \"$\") {\n        inBlockMath = !inBlockMath\n        i += 1 // Skip the second $\n        inInlineMath = false // Block math takes precedence\n      } else if (!inBlockMath) {\n        // Only toggle inline math if not in block math\n        inInlineMath = !inInlineMath\n      }\n    }\n  }\n\n  return inInlineMath || inBlockMath\n}\n\n// OPTIMIZATION: Counts single underscores without split(\"\").reduce()\n// Counts single underscores that are not part of double underscores, not escaped, and not in math blocks\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex character counting logic with multiple edge cases\"\nconst countSingleUnderscores = (text: string): number => {\n  // OPTIMIZATION: For large texts, if there are no dollar signs, skip math block checking entirely\n  const hasMathBlocks = text.includes(\"$\")\n\n  let count = 0\n  const len = text.length\n\n  for (let index = 0; index < len; index += 1) {\n    if (text[index] !== \"_\") {\n      continue\n    }\n\n    const prevChar = index > 0 ? text[index - 1] : \"\"\n    const nextChar = index < len - 1 ? text[index + 1] : \"\"\n\n    // Skip if escaped with backslash\n    if (prevChar === \"\\\\\") {\n      continue\n    }\n\n    // Skip if within math block (only check if text has dollar signs)\n    if (hasMathBlocks && isWithinMathBlock(text, index)) {\n      continue\n    }\n\n    // Skip if part of __\n    if (prevChar === \"_\" || nextChar === \"_\") {\n      continue\n    }\n\n    // Skip if underscore is word-internal (between word characters)\n    if (prevChar && nextChar && isWordChar(prevChar) && isWordChar(nextChar)) {\n      continue\n    }\n\n    count += 1\n  }\n\n  return count\n}\n\n// Completes incomplete italic formatting with single underscores (_)\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex italic handling logic with multiple edge cases for markdown parsing\"\nconst handleIncompleteSingleUnderscoreItalic = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  const singleUnderscoreMatch = text.match(singleUnderscorePattern)\n\n  if (singleUnderscoreMatch) {\n    // Find the first single underscore position (not part of __ and not word-internal)\n    let firstSingleUnderscoreIndex = -1\n    for (let i = 0; i < text.length; i += 1) {\n      if (\n        text[i] === \"_\" &&\n        text[i - 1] !== \"_\" &&\n        text[i + 1] !== \"_\" &&\n        text[i - 1] !== \"\\\\\" &&\n        !isWithinMathBlock(text, i)\n      ) {\n        // Check if underscore is word-internal (between word characters)\n        const prevChar = i > 0 ? text[i - 1] : \"\"\n        const nextChar = i < text.length - 1 ? text[i + 1] : \"\"\n        if (prevChar && nextChar && isWordChar(prevChar) && isWordChar(nextChar)) {\n          continue\n        }\n\n        firstSingleUnderscoreIndex = i\n        break\n      }\n    }\n\n    if (firstSingleUnderscoreIndex === -1) {\n      return text\n    }\n\n    // Get content after the first single underscore\n    const contentAfterFirstUnderscore = text.substring(firstSingleUnderscoreIndex + 1)\n\n    // Check if there's meaningful content after the underscore\n    // Don't close if content is only whitespace or emphasis markers\n    if (\n      !contentAfterFirstUnderscore ||\n      whitespaceOrMarkersPattern.test(contentAfterFirstUnderscore)\n    ) {\n      return text\n    }\n\n    const singleUnderscores = countSingleUnderscores(text)\n    if (singleUnderscores % 2 === 1) {\n      // If text ends with newline(s), insert underscore before them\n      // Use string methods instead of regex to avoid ReDoS vulnerability\n      let endIndex = text.length\n      while (endIndex > 0 && text[endIndex - 1] === \"\\n\") {\n        endIndex -= 1\n      }\n      if (endIndex < text.length) {\n        const textBeforeNewlines = text.slice(0, endIndex)\n        const trailingNewlines = text.slice(endIndex)\n        return `${textBeforeNewlines}_${trailingNewlines}`\n      }\n      return `${text}_`\n    }\n  }\n\n  return text\n}\n\n// Checks if a backtick at position i is part of a triple backtick sequence\nconst isPartOfTripleBacktick = (text: string, i: number): boolean => {\n  const isTripleStart = text.substring(i, i + 3) === \"```\"\n  const isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === \"```\"\n  const isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === \"```\"\n\n  return isTripleStart || isTripleMiddle || isTripleEnd\n}\n\n// Counts single backticks that are not part of triple backticks\nconst countSingleBackticks = (text: string): number => {\n  let count = 0\n  for (let i = 0; i < text.length; i += 1) {\n    if (text[i] === \"`\" && !isPartOfTripleBacktick(text, i)) {\n      count += 1\n    }\n  }\n  return count\n}\n\n// Completes incomplete inline code formatting (`)\n// Avoids completing if inside an incomplete code block\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex inline code handling logic with multiple edge cases for markdown parsing\"\nconst handleIncompleteInlineCode = (text: string): string => {\n  // Check if we have inline triple backticks (starts with ``` and should end with ```)\n  // This pattern should ONLY match truly inline code (no newlines)\n  // Examples: ```code``` or ```python code```\n  const inlineTripleBacktickMatch = text.match(inlineTripleBacktickPattern)\n  if (inlineTripleBacktickMatch && !text.includes(\"\\n\")) {\n    // Check if it ends with exactly 2 backticks (incomplete)\n    if (text.endsWith(\"``\") && !text.endsWith(\"```\")) {\n      return `${text}\\``\n    }\n    // Already complete inline triple backticks\n    return text\n  }\n\n  // Check if we're inside a multi-line code block (complete or incomplete)\n  const allTripleBackticks = (text.match(/```/g) || []).length\n  const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1\n\n  // Don't modify text if we have complete multi-line code blocks (even pairs of ```)\n  if (allTripleBackticks > 0 && allTripleBackticks % 2 === 0 && text.includes(\"\\n\")) {\n    // We have complete multi-line code blocks, don't add any backticks\n    return text\n  }\n\n  // Special case: if text ends with ```\\n (triple backticks followed by newline)\n  // This is actually a complete code block, not incomplete\n  if ((text.endsWith(\"```\\n\") || text.endsWith(\"```\")) && allTripleBackticks % 2 === 0) {\n    // Count all triple backticks - if even, it's complete\n    return text\n  }\n\n  const inlineCodeMatch = text.match(inlineCodePattern)\n\n  if (inlineCodeMatch && !insideIncompleteCodeBlock) {\n    // Don't close if there's no meaningful content after the opening marker\n    // inlineCodeMatch[2] contains the content after `\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = inlineCodeMatch[2]\n    if (!contentAfterMarker || whitespaceOrMarkersPattern.test(contentAfterMarker)) {\n      return text\n    }\n\n    const singleBacktickCount = countSingleBackticks(text)\n    if (singleBacktickCount % 2 === 1) {\n      return `${text}\\``\n    }\n  }\n\n  return text\n}\n\n// Completes incomplete strikethrough formatting (~~)\n// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: \"Complex markdown parsing logic with multiple edge cases\"\nconst handleIncompleteStrikethrough = (text: string): string => {\n  const strikethroughMatch = text.match(strikethroughPattern)\n\n  if (strikethroughMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // strikethroughMatch[2] contains the content after ~~\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = strikethroughMatch[2]\n    if (!contentAfterMarker || whitespaceOrMarkersPattern.test(contentAfterMarker)) {\n      return text\n    }\n\n    const tildePairs = (text.match(/~~/g) || []).length\n    if (tildePairs % 2 === 1) {\n      return `${text}~~`\n    }\n  }\n\n  return text\n}\n\n// Counts single dollar signs that are not part of double dollar signs and not escaped\nconst _countSingleDollarSigns = (text: string): number => {\n  return text.split(\"\").reduce((acc, char, index) => {\n    if (char === \"$\") {\n      const prevChar = text[index - 1]\n      const nextChar = text[index + 1]\n      // Skip if escaped with backslash\n      if (prevChar === \"\\\\\") {\n        return acc\n      }\n      if (prevChar !== \"$\" && nextChar !== \"$\") {\n        return acc + 1\n      }\n    }\n    return acc\n  }, 0)\n}\n\n// Completes incomplete block KaTeX formatting ($$)\nconst handleIncompleteBlockKatex = (text: string): string => {\n  // Count all $$ pairs in the text\n  const dollarPairs = (text.match(/\\$\\$/g) || []).length\n\n  // If we have an even number of $$, the block is complete\n  if (dollarPairs % 2 === 0) {\n    return text\n  }\n\n  // If we have an odd number, add closing $$\n  // Check if this looks like a multi-line math block (contains newlines after opening $$)\n  const firstDollarIndex = text.indexOf(\"$$\")\n  const hasNewlineAfterStart = firstDollarIndex !== -1 && text.includes(\"\\n\", firstDollarIndex)\n\n  // For multi-line blocks, add newline before closing $$ if not present\n  if (hasNewlineAfterStart && !text.endsWith(\"\\n\")) {\n    return `${text}\\n$$`\n  }\n\n  // For inline blocks or when already ending with newline, just add $$\n  return `${text}$$`\n}\n\n// Counts triple asterisks that are not part of quadruple or more asterisks\n// OPTIMIZATION: Count *** without regex to avoid allocation\nconst countTripleAsterisks = (text: string): number => {\n  let count = 0\n  let consecutiveAsterisks = 0\n\n  // biome-ignore lint/style/useForOf: \"Need index access to check character codes for performance\"\n  for (const element of text) {\n    if (element === \"*\") {\n      consecutiveAsterisks += 1\n    } else {\n      // End of asterisk sequence\n      if (consecutiveAsterisks >= 3) {\n        count += Math.floor(consecutiveAsterisks / 3)\n      }\n      consecutiveAsterisks = 0\n    }\n  }\n\n  // Handle trailing asterisks\n  if (consecutiveAsterisks >= 3) {\n    count += Math.floor(consecutiveAsterisks / 3)\n  }\n\n  return count\n}\n\n// Completes incomplete bold-italic formatting (***)\nconst handleIncompleteBoldItalic = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  // Don't process if text is only asterisks and has 4 or more consecutive asterisks\n  // This prevents cases like **** from being treated as incomplete ***\n  if (fourOrMoreAsterisksPattern.test(text)) {\n    return text\n  }\n\n  const boldItalicMatch = text.match(boldItalicPattern)\n\n  if (boldItalicMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // boldItalicMatch[2] contains the content after ***\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = boldItalicMatch[2]\n    if (!contentAfterMarker || whitespaceOrMarkersPattern.test(contentAfterMarker)) {\n      return text\n    }\n\n    const tripleAsteriskCount = countTripleAsterisks(text)\n    if (tripleAsteriskCount % 2 === 1) {\n      return `${text}***`\n    }\n  }\n\n  return text\n}\n\n// Parses markdown text and removes incomplete tokens to prevent partial rendering\nexport const parseIncompleteMarkdown = (text: string): string => {\n  if (!text || typeof text !== \"string\") {\n    return text\n  }\n\n  let result = text\n\n  // Handle incomplete links and images first\n  const processedResult = handleIncompleteLinksAndImages(result)\n\n  // If we added an incomplete link marker, don't process other formatting\n  // as the content inside the link should be preserved as-is\n  if (processedResult.endsWith(\"](streamdown:incomplete-link)\")) {\n    return processedResult\n  }\n\n  result = processedResult\n\n  // Handle various formatting completions\n  // Handle triple asterisks first (most specific)\n  result = handleIncompleteBoldItalic(result)\n  // Normalize and guard the `<Use:` wrapper first so inner tags are handled correctly\n  result = handleUseWrapper(result)\n  // Handle custom mention tags trimming before other single-character completions\n  result = handleIncompleteMentionTags(result)\n  result = handleIncompleteBold(result)\n  result = handleIncompleteDoubleUnderscoreItalic(result)\n  result = handleIncompleteSingleAsteriskItalic(result)\n  result = handleIncompleteSingleUnderscoreItalic(result)\n  result = handleIncompleteInlineCode(result)\n  result = handleIncompleteStrikethrough(result)\n\n  // Handle KaTeX formatting (only block math with $$)\n  result = handleIncompleteBlockKatex(result)\n  // Note: We don't handle inline KaTeX with single $ as they're likely currency symbols\n\n  return result\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/message/useContextBlockPresentation.tsx",
    "content": "import { getView } from \"@follow/constants\"\nimport type { ReactNode } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport type { AIChatContextBlock, FileAttachment } from \"~/modules/ai-chat/store/types\"\n\nimport { CircularProgress } from \"../ui/UploadProgress\"\nimport {\n  getBlockIcon,\n  getBlockLabel,\n  getFileDisplayContent,\n  isImageAttachment,\n} from \"./ai-block-constants\"\nimport { EntryTitle, FeedTitle } from \"./BlockTitleComponents\"\nimport { ImageThumbnail } from \"./ImageThumbnail\"\n\nexport interface ContextBlockPresentation {\n  icon?: string | null\n  label?: string\n  displayContent: ReactNode\n  title?: string\n  attachment?: FileAttachment\n  isImageAttachment: boolean\n}\n\nexport function useContextBlockPresentation(block: AIChatContextBlock): ContextBlockPresentation {\n  const { t } = useTranslation(\"common\")\n\n  return useMemo(() => {\n    const label = block.type === \"mainView\" ? \"\" : getBlockLabel(block.type)\n    const isImage = isImageAttachment(block)\n    const icon = block.type === \"fileAttachment\" && isImage ? null : getBlockIcon(block)\n    const attachment = block.type === \"fileAttachment\" ? block.attachment : undefined\n\n    const buildFileContent = (): ReactNode => {\n      if (!attachment) {\n        return <span className=\"text-text-tertiary\">[File: Unknown]</span>\n      }\n\n      const { dataUrl, previewUrl, uploadStatus, uploadProgress, errorMessage, name } = attachment\n\n      if (isImage && (dataUrl || previewUrl)) {\n        return (\n          <div className=\"flex items-center gap-1.5\">\n            <div className=\"relative\">\n              <ImageThumbnail className=\"m-0.5 size-5 rounded-md\" attachment={attachment} />\n\n              {uploadStatus === \"uploading\" && uploadProgress !== undefined && (\n                <div className=\"absolute inset-0 flex items-center justify-center rounded-md bg-black/50\">\n                  <CircularProgress\n                    progress={uploadProgress}\n                    size={16}\n                    strokeWidth={2}\n                    variant=\"default\"\n                    className=\"text-white\"\n                  />\n                </div>\n              )}\n\n              {uploadStatus === \"error\" && (\n                <div\n                  className=\"absolute inset-0 flex items-center justify-center rounded-md bg-red/80\"\n                  title={errorMessage}\n                >\n                  <i className=\"i-mgc-close-cute-re size-3 text-white\" />\n                </div>\n              )}\n            </div>\n\n            <div className=\"flex min-w-0 flex-1 items-center gap-1\">\n              <span className=\"truncate\" title={name}>\n                {name}\n              </span>\n\n              {uploadStatus === \"uploading\" && uploadProgress !== undefined && (\n                <span className=\"text-xs text-text-tertiary\">{Math.round(uploadProgress)}%</span>\n              )}\n\n              {uploadStatus === \"error\" && (\n                <span className=\"text-xs text-red\" title={errorMessage}>\n                  Upload failed\n                </span>\n              )}\n            </div>\n          </div>\n        )\n      }\n\n      return (\n        <div className=\"flex items-center gap-1.5\">\n          <span className=\"min-w-0 flex-1 truncate\" title={name}>\n            {name}\n          </span>\n\n          {uploadStatus === \"uploading\" && uploadProgress !== undefined && (\n            <div className=\"flex items-center gap-1\">\n              <CircularProgress\n                progress={uploadProgress}\n                size={14}\n                strokeWidth={2}\n                variant=\"default\"\n              />\n              <span className=\"text-xs text-text-tertiary\">{Math.round(uploadProgress)}%</span>\n            </div>\n          )}\n\n          {uploadStatus === \"error\" && (\n            <i className=\"i-mgc-close-cute-re size-3 text-red\" title={errorMessage} />\n          )}\n        </div>\n      )\n    }\n\n    let displayContent: ReactNode\n    let title: string | undefined\n\n    switch (block.type) {\n      case \"mainView\": {\n        const viewName = getView(Number(block.value))?.name\n        const translated = viewName ? t(viewName) : block.value\n        displayContent = translated\n        title = typeof translated === \"string\" ? translated : undefined\n        break\n      }\n      case \"mainEntry\": {\n        displayContent = <EntryTitle entryId={block.value} fallback={block.value} />\n        break\n      }\n      case \"mainFeed\": {\n        const category = block.value?.startsWith(ROUTE_FEED_IN_FOLDER)\n          ? block.value.slice(ROUTE_FEED_IN_FOLDER.length)\n          : undefined\n\n        displayContent = category ? (\n          <span>{category}</span>\n        ) : (\n          <FeedTitle feedId={block.value} fallback={block.value} />\n        )\n        break\n      }\n      case \"unreadOnly\": {\n        displayContent = \"Unread Only\"\n        title = \"Unread Only\"\n        break\n      }\n      case \"fileAttachment\": {\n        displayContent = buildFileContent()\n        title = attachment\n          ? attachment.name || getFileDisplayContent(attachment)\n          : \"[File: Unknown]\"\n        break\n      }\n      default: {\n        displayContent = \"\"\n        break\n      }\n    }\n\n    if (!title && typeof displayContent === \"string\") {\n      title = displayContent\n    }\n\n    return {\n      icon,\n      label,\n      displayContent,\n      title,\n      attachment,\n      isImageAttachment: isImage,\n    }\n  }, [block, t])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/shared/common-states.tsx",
    "content": "interface LoadingStateProps {\n  description?: string\n}\n\ninterface ErrorStateProps {\n  error?: string\n}\n\nexport const LoadingState = ({ description = \"Fetching data...\" }: LoadingStateProps) => (\n  <div className=\"flex h-32 animate-pulse items-center justify-center rounded-lg bg-material-medium text-sm text-text-tertiary\">\n    {description}\n  </div>\n)\n\nexport const ErrorState = ({ error = \"An error occurred. Please try again.\" }: ErrorStateProps) => {\n  return (\n    <div className=\"flex h-32 items-center justify-center rounded-lg text-sm text-text-tertiary bg-mix-red/background-1/4\">\n      {error}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/ui/AIShortcutButton.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport { m } from \"motion/react\"\n\nconst aiShortcutButtonVariants = cva(\n  [\n    // Base styles\n    \"inline-flex items-center gap-2 rounded-full font-medium\",\n    \"hover:shadow-sm whitespace-nowrap\",\n    \"backdrop-blur-background\",\n  ],\n  {\n    variants: {\n      size: {\n        sm: \"px-2.5 py-1.5 text-xs\",\n        md: \"px-4 py-2 text-sm\",\n      },\n      variant: {\n        default: [\n          \"hover:bg-material-thick bg-material-ultra-thick\",\n          \"border-border/50 hover:border-border border\",\n          \"text-text hover:text-text\",\n        ],\n      },\n      disabled: {\n        true: \"cursor-not-allowed opacity-50\",\n      },\n    },\n    defaultVariants: {\n      size: \"md\",\n      variant: \"default\",\n      disabled: false,\n    },\n  },\n)\n\nexport interface AIShortcutButtonProps extends VariantProps<typeof aiShortcutButtonVariants> {\n  animationDelay?: number\n}\n\nexport const AIShortcutButton = ({\n  children,\n  onClick,\n  className,\n  animationDelay = 0,\n  size,\n  variant,\n  disabled,\n  style,\n  ref,\n  ...rest\n}: AIShortcutButtonProps & HTMLMotionProps<\"button\">) => {\n  return (\n    <m.button\n      initial={{ opacity: 0, scale: 0.9 }}\n      animate={{ opacity: 1, scale: 1 }}\n      whileTap={{ scale: 0.95 }}\n      transition={{ delay: animationDelay, ...Spring.presets.snappy }}\n      onClick={onClick}\n      disabled={disabled ?? false}\n      className={cn(aiShortcutButtonVariants({ size, variant, disabled }), className)}\n      ref={ref}\n      {...rest}\n    >\n      {children}\n    </m.button>\n  )\n}\n\nAIShortcutButton.displayName = \"AIShortcutButton\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/ui/ShortcutTooltip.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipRoot,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport type { ReactNode } from \"react\"\n\nexport interface ShortcutTooltipProps {\n  name: string\n  prompt?: string\n  hotkey?: string\n  children: ReactNode\n  asChild?: boolean\n}\n\nexport const ShortcutTooltip: React.FC<ShortcutTooltipProps> = ({\n  name,\n  prompt,\n  hotkey,\n  children,\n  asChild = true,\n}) => {\n  return (\n    <Tooltip>\n      <TooltipRoot>\n        <TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>\n        <TooltipPortal>\n          <TooltipContent side=\"top\" className=\"max-w-[320px]\">\n            <div className=\"flex flex-col gap-1 p-1\">\n              <div className=\"flex items-center gap-1.5\">\n                <span className=\"font-mono text-xs text-text-tertiary\">/</span>\n                <span className=\"text-sm font-medium text-text\">{name}</span>\n                {hotkey && (\n                  <span className=\"ml-auto font-mono text-xs text-text-tertiary\">{hotkey}</span>\n                )}\n              </div>\n\n              {prompt && <span className=\"text-xs leading-snug text-text-secondary\">{prompt}</span>}\n            </div>\n          </TooltipContent>\n        </TooltipPortal>\n      </TooltipRoot>\n    </Tooltip>\n  )\n}\n\nShortcutTooltip.displayName = \"ShortcutTooltip\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/ui/UploadProgress.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\nimport type { FC } from \"react\"\nimport { memo } from \"react\"\n\nexport interface UploadProgressProps {\n  /** Progress percentage (0-100) */\n  progress: number\n  /** Progress bar size variant */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Show percentage text */\n  showPercentage?: boolean\n  /** Custom className */\n  className?: string\n  /** Progress bar color */\n  variant?: \"default\" | \"success\" | \"error\"\n}\n\nexport const UploadProgress: FC<UploadProgressProps> = memo(\n  ({ progress, size = \"md\", showPercentage = false, className, variant = \"default\" }) => {\n    const progressValue = Math.max(0, Math.min(100, progress))\n\n    const sizeClasses = {\n      sm: \"h-1\",\n      md: \"h-2\",\n      lg: \"h-3\",\n    }\n\n    const colorClasses = {\n      default: \"bg-blue\",\n      success: \"bg-green\",\n      error: \"bg-red\",\n    }\n\n    return (\n      <div className={cn(\"w-full\", className)}>\n        {/* Progress Bar */}\n        <div\n          className={cn(\n            \"relative overflow-hidden rounded-full bg-fill-secondary\",\n            sizeClasses[size],\n          )}\n        >\n          <m.div\n            className={cn(\"h-full rounded-full transition-colors\", colorClasses[variant])}\n            initial={{ width: 0 }}\n            animate={{ width: `${progressValue}%` }}\n            transition={{\n              type: \"spring\",\n              damping: 20,\n              stiffness: 100,\n            }}\n          />\n        </div>\n\n        {/* Percentage Text */}\n        {showPercentage && (\n          <div className=\"mt-1 text-center text-xs text-text-tertiary\">\n            {Math.round(progressValue)}%\n          </div>\n        )}\n      </div>\n    )\n  },\n)\n\nUploadProgress.displayName = \"UploadProgress\"\n\nexport interface CircularProgressProps {\n  /** Progress percentage (0-100) */\n  progress: number\n  /** Circle size */\n  size?: number\n  /** Stroke width */\n  strokeWidth?: number\n  /** Show percentage text in center */\n  showPercentage?: boolean\n  /** Custom className */\n  className?: string\n  /** Progress color */\n  variant?: \"default\" | \"success\" | \"error\"\n}\n\nexport const CircularProgress: FC<CircularProgressProps> = memo(\n  ({\n    progress,\n    size = 20,\n    strokeWidth = 2,\n    showPercentage = false,\n    className,\n    variant = \"default\",\n  }) => {\n    const progressValue = Math.max(0, Math.min(100, progress))\n    const radius = (size - strokeWidth) / 2\n    const circumference = 2 * Math.PI * radius\n    const strokeDashoffset = circumference - (progressValue / 100) * circumference\n\n    const colorClasses = {\n      default: \"text-blue\",\n      success: \"text-green\",\n      error: \"text-red\",\n    }\n\n    return (\n      <div className={cn(\"relative inline-flex items-center justify-center\", className)}>\n        <svg\n          width={size}\n          height={size}\n          className=\"-rotate-90 transform\"\n          viewBox={`0 0 ${size} ${size}`}\n        >\n          {/* Background circle */}\n          <circle\n            cx={size / 2}\n            cy={size / 2}\n            r={radius}\n            stroke=\"currentColor\"\n            strokeWidth={strokeWidth}\n            fill=\"none\"\n            className=\"text-fill-tertiary\"\n          />\n\n          {/* Progress circle */}\n          <m.circle\n            cx={size / 2}\n            cy={size / 2}\n            r={radius}\n            stroke=\"currentColor\"\n            strokeWidth={strokeWidth}\n            fill=\"none\"\n            className={colorClasses[variant]}\n            strokeLinecap=\"round\"\n            initial={{ strokeDasharray: circumference, strokeDashoffset: circumference }}\n            animate={{ strokeDashoffset }}\n            transition={{\n              type: \"spring\",\n              damping: 20,\n              stiffness: 100,\n            }}\n          />\n        </svg>\n\n        {/* Percentage text */}\n        {showPercentage && (\n          <div className=\"absolute inset-0 flex items-center justify-center text-xs font-medium text-text-tertiary\">\n            {Math.round(progressValue)}%\n          </div>\n        )}\n      </div>\n    )\n  },\n)\n\nCircularProgress.displayName = \"CircularProgress\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/welcome/DefaultWelcomeContent.tsx",
    "content": "export const DefaultWelcomeContent: React.FC = () => null\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/welcome/EntrySummaryCard.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { usePrefetchSummary } from \"@follow/store/summary/hooks\"\nimport { m } from \"motion/react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useEntryIsInReadabilitySuccess } from \"~/atoms/readability\"\nimport { useActionLanguage } from \"~/atoms/settings/general\"\nimport { AISummaryCardBase } from \"~/components/ui/ai-summary-card\"\n\ninterface EntrySummaryCardProps {\n  entryId: string\n  className?: string\n}\n\nexport const EntrySummaryCard: React.FC<EntrySummaryCardProps> = ({ entryId, className }) => {\n  const { t } = useTranslation(\"ai\")\n  const actionLanguage = useActionLanguage()\n  const isInReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)\n  const summary = usePrefetchSummary({\n    entryId,\n    target: isInReadabilitySuccess ? \"readabilityContent\" : \"content\",\n    actionLanguage,\n    enabled: true,\n  })\n\n  return (\n    <m.div\n      initial={{ opacity: 0, y: 20, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      transition={Spring.presets.smooth}\n      className=\"w-full max-w-2xl\"\n    >\n      <AISummaryCardBase\n        content={summary.data}\n        isLoading={summary.isLoading}\n        className={className}\n        title={t(\"ai_summary\")}\n        error={summary.error}\n      />\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/welcome/EntryWelcomeContent.tsx",
    "content": "import { m } from \"motion/react\"\n\nimport { EntrySummaryCard } from \"./EntrySummaryCard\"\n\ninterface EntryWelcomeContentProps {\n  entryId: string\n}\n\nexport const EntryWelcomeContent: React.FC<EntryWelcomeContentProps> = ({ entryId }) => (\n  <div className=\"flex w-full flex-col items-center gap-6\">\n    <m.div\n      initial={{ opacity: 0, y: 20, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={{ opacity: 0, y: -20, scale: 0.98 }}\n      className=\"w-full max-w-2xl\"\n    >\n      <EntrySummaryCard entryId={entryId} />\n    </m.div>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/components/welcome/index.ts",
    "content": "export { DefaultWelcomeContent } from \"./DefaultWelcomeContent\"\nexport { EntrySummaryCard } from \"./EntrySummaryCard\"\nexport { EntryWelcomeContent } from \"./EntryWelcomeContent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/constants/index.ts",
    "content": "export const SCROLLED_BEYOND_THRESHOLD = 100\n\nexport const AI_CHAT_SPECIAL_ID_PREFIX = {\n  TIMELINE_SUMMARY: \"timeline-summary:\",\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/index.ts",
    "content": "import { LexicalRichEditorNodes } from \"@follow/components/ui/lexical-rich-editor/nodes.js\"\n\nimport { FileAttachmentNode, MentionNode, SelectedTextNode, ShortcutNode } from \"./plugins\"\n\nexport * from \"./plugins\"\n\nexport const LexicalAIEditorNodes = [\n  ...LexicalRichEditorNodes,\n  MentionNode,\n  ShortcutNode,\n  FileAttachmentNode,\n  SelectedTextNode,\n]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/FileAttachmentNode.tsx",
    "content": "/* eslint-disable react-refresh/only-export-components */\nimport type {\n  DOMConversionMap,\n  DOMConversionOutput,\n  DOMExportOutput,\n  LexicalNode,\n  NodeKey,\n  SerializedLexicalNode,\n  Spread,\n} from \"lexical\"\nimport { DecoratorNode } from \"lexical\"\nimport * as React from \"react\"\n\nimport { useAIMessageOptionalId } from \"~/modules/ai-chat/components/message/AIMessageIdContext\"\nimport { useChatBlockSelector, useMessageByIdSelector } from \"~/modules/ai-chat/store/hooks\"\nimport type { FileAttachment } from \"~/modules/ai-chat/store/types\"\nimport { findFileAttachmentBlock, isDataBlockPart } from \"~/modules/ai-chat/utils/extractor\"\n\nexport type SerializedFileAttachmentNode = Spread<\n  {\n    attachmentId: string\n  },\n  SerializedLexicalNode\n>\n\nfunction convertFileAttachmentElement(domNode: Node): null | DOMConversionOutput {\n  const element = domNode as HTMLElement\n  const { attachmentId } = element.dataset\n  if (attachmentId) {\n    const node = $createFileAttachmentNode(attachmentId)\n    return { node }\n  }\n  return null\n}\n\nexport class FileAttachmentNode extends DecoratorNode<React.ReactElement> {\n  __attachmentId: string\n\n  static override getType(): string {\n    return \"file-attachment\"\n  }\n\n  static override clone(node: FileAttachmentNode): FileAttachmentNode {\n    return new FileAttachmentNode(node.__attachmentId, node.__key)\n  }\n\n  static override importJSON(serializedNode: SerializedFileAttachmentNode): FileAttachmentNode {\n    const { attachmentId } = serializedNode\n    const node = $createFileAttachmentNode(attachmentId)\n    return node\n  }\n\n  static override importDOM(): DOMConversionMap | null {\n    return {\n      span: () => ({\n        conversion: convertFileAttachmentElement,\n        priority: 1,\n      }),\n    }\n  }\n\n  constructor(attachmentId: string, key?: NodeKey) {\n    super(key)\n    this.__attachmentId = attachmentId\n  }\n\n  override exportJSON(): SerializedFileAttachmentNode {\n    return {\n      attachmentId: this.__attachmentId,\n      type: \"file-attachment\",\n      version: 1,\n    }\n  }\n\n  override exportDOM(): DOMExportOutput {\n    const element = document.createElement(\"span\")\n    element.dataset.attachmentId = this.__attachmentId\n    element.textContent = `[File: ${this.__attachmentId}]`\n    return { element }\n  }\n\n  override createDOM(): HTMLElement {\n    const span = document.createElement(\"span\")\n    span.style.display = \"inline-block\"\n    span.dataset.attachmentId = this.__attachmentId\n    return span\n  }\n\n  override updateDOM(): false {\n    return false\n  }\n\n  getAttachmentId(): string {\n    return this.__attachmentId\n  }\n\n  setAttachmentId(attachmentId: string): void {\n    const writable = this.getWritable()\n    writable.__attachmentId = attachmentId\n  }\n\n  override decorate(): React.ReactElement {\n    return <FileAttachmentComponent node={this} />\n  }\n\n  override isInline(): boolean {\n    return true\n  }\n}\n\ninterface FileAttachmentComponentProps {\n  node: FileAttachmentNode\n}\n\nfunction FileAttachmentPill({ attachment }: { attachment: FileAttachment }) {\n  return (\n    <span\n      className=\"inline-flex items-center gap-1 rounded border border-border bg-fill px-2 py-1 text-xs\"\n      style={{\n        backgroundColor: \"var(--fill)\",\n        color: \"var(--text)\",\n        border: \"1px solid var(--border)\",\n      }}\n    >\n      <i className=\"i-mgc-attachment-cute-re\" />\n      <span className=\"max-w-32 truncate\" title={attachment.name}>\n        {attachment.name}\n      </span>\n      {attachment.uploadStatus === \"uploading\" && (\n        <i className=\"i-mgc-loading-3-cute-re animate-spin text-accent\" />\n      )}\n      {attachment.uploadStatus === \"processing\" && (\n        <i className=\"i-mgc-loading-3-cute-re animate-spin text-accent\" />\n      )}\n      {attachment.uploadStatus === \"error\" && <i className=\"i-mgc-close-cute-re text-red\" />}\n      {attachment.uploadStatus === \"completed\" && <i className=\"i-mgc-check-cute-re text-green\" />}\n    </span>\n  )\n}\n\nfunction MissingFilePill() {\n  return (\n    <span className=\"inline-flex items-center gap-1 rounded border border-border bg-fill px-2 py-1 text-xs text-gray\">\n      <i className=\"i-mgc-attachment-cute-re\" />\n      <span className=\"max-w-32 truncate\">File not found</span>\n    </span>\n  )\n}\n\nfunction BlockBasedAttachment({ attachmentId }: { attachmentId: string }) {\n  const block = useChatBlockSelector((state) =>\n    state.blocks.find(\n      (block) => block.type === \"fileAttachment\" && block.attachment.id === attachmentId,\n    ),\n  )\n\n  if (!block || block.type !== \"fileAttachment\") {\n    return <MissingFilePill />\n  }\n\n  return <FileAttachmentPill attachment={block.attachment} />\n}\n\nfunction MessageBasedAttachment({\n  attachmentId,\n  messageId,\n}: {\n  attachmentId: string\n  messageId: string\n}) {\n  const attachment = useMessageByIdSelector(messageId, (message) => {\n    for (const part of message.parts) {\n      if (!isDataBlockPart(part)) continue\n      const block = findFileAttachmentBlock(part, attachmentId)\n      if (block) return block.attachment\n    }\n  })\n\n  if (attachment) return <FileAttachmentPill attachment={attachment} />\n  // Fallback to block-based when message-based lookup fails\n  return <BlockBasedAttachment attachmentId={attachmentId} />\n}\n\nfunction FileAttachmentComponent({ node }: FileAttachmentComponentProps) {\n  const attachmentId = node.getAttachmentId()\n\n  const messageId = useAIMessageOptionalId()\n\n  if (messageId) {\n    return <MessageBasedAttachment attachmentId={attachmentId} messageId={messageId} />\n  }\n  return <BlockBasedAttachment attachmentId={attachmentId} />\n}\n\nexport function $createFileAttachmentNode(attachmentId: string): FileAttachmentNode {\n  return new FileAttachmentNode(attachmentId)\n}\n\nexport function $isFileAttachmentNode(\n  node: LexicalNode | null | undefined,\n): node is FileAttachmentNode {\n  return node instanceof FileAttachmentNode\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/FileUploadPlugin.tsx",
    "content": "import type { LexicalPluginFC } from \"@follow/components/ui/lexical-rich-editor/types.js\"\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport type { RefObject } from \"react\"\nimport { useCallback, useEffect, useRef } from \"react\"\n\nimport { FileAttachmentNode } from \"./FileAttachmentNode\"\nimport { useFileAttachmentBlockSync } from \"./hooks/useFileAttachmentBlockSync\"\nimport { useFileUploadIntegration } from \"./hooks/useFileUploadIntegration\"\nimport type { FileUploadPluginConfig } from \"./types\"\nimport {\n  clipboardEventHasFiles,\n  createDragCounter,\n  dragEventHasFiles,\n  getFilesFromDrop,\n  getFilesFromPaste,\n  preventDefaultDrag,\n} from \"./utils/file-handling\"\n\nconst defaultConfig: FileUploadPluginConfig = {\n  enableDragDrop: true,\n  enablePaste: true,\n}\n\nexport const FileUploadPlugin: LexicalPluginFC = () => {\n  const [editor] = useLexicalComposerContext()\n\n  // Initialize file attachment block synchronization\n  const { handleFileAttachmentInsert } = useFileAttachmentBlockSync()\n\n  // Initialize file upload integration with sync callback\n  const { handleFileDrop, handlePaste } = useFileUploadIntegration(handleFileAttachmentInsert)\n\n  const dragCounterRef: RefObject<ReturnType<typeof createDragCounter>> = useRef(undefined) as any\n  if (!dragCounterRef.current) {\n    dragCounterRef.current = createDragCounter()\n  }\n\n  const finalConfig = defaultConfig\n\n  // Handle drag enter\n  const handleDragEnter = useCallback(\n    (event: DragEvent) => {\n      if (!finalConfig.enableDragDrop || !dragEventHasFiles(event)) return\n\n      preventDefaultDrag(event)\n    },\n    [finalConfig.enableDragDrop],\n  )\n\n  // Handle drag over\n  const handleDragOver = useCallback(\n    (event: DragEvent) => {\n      if (!finalConfig.enableDragDrop || !dragEventHasFiles(event)) return\n\n      preventDefaultDrag(event)\n    },\n    [finalConfig.enableDragDrop],\n  )\n\n  // Handle drag leave\n  const handleDragLeave = useCallback(\n    (event: DragEvent) => {\n      if (!finalConfig.enableDragDrop) return\n\n      preventDefaultDrag(event)\n\n      const newCounter = dragCounterRef.current.decrement()\n\n      if (newCounter <= 0) {\n        dragCounterRef.current.reset()\n      }\n    },\n    [finalConfig.enableDragDrop],\n  )\n\n  // Handle drop\n  const handleDrop = useCallback(\n    async (event: DragEvent) => {\n      if (!finalConfig.enableDragDrop) return\n\n      preventDefaultDrag(event)\n\n      dragCounterRef.current.reset()\n\n      const files = getFilesFromDrop(event)\n\n      if (files && files.length > 0) {\n        await handleFileDrop(files)\n      }\n    },\n    [finalConfig.enableDragDrop, handleFileDrop],\n  )\n\n  // Handle paste\n  const handlePasteEvent = useCallback(\n    async (event: ClipboardEvent) => {\n      if (!finalConfig.enablePaste || !clipboardEventHasFiles(event)) return\n\n      event.preventDefault()\n\n      const files = getFilesFromPaste(event)\n\n      if (files && files.length > 0) {\n        await handlePaste(event.clipboardData!)\n      }\n    },\n    [finalConfig.enablePaste, handlePaste],\n  )\n\n  // Set up event listeners\n  useEffect(() => {\n    const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {\n      // Remove previous listeners\n      if (prevRootElement) {\n        if (finalConfig.enableDragDrop) {\n          prevRootElement.removeEventListener(\"dragenter\", handleDragEnter)\n          prevRootElement.removeEventListener(\"dragover\", handleDragOver)\n          prevRootElement.removeEventListener(\"dragleave\", handleDragLeave)\n          prevRootElement.removeEventListener(\"drop\", handleDrop)\n        }\n\n        if (finalConfig.enablePaste) {\n          prevRootElement.removeEventListener(\"paste\", handlePasteEvent)\n        }\n      }\n\n      // Add new listeners\n      if (rootElement) {\n        if (finalConfig.enableDragDrop) {\n          rootElement.addEventListener(\"dragenter\", handleDragEnter, { passive: false })\n          rootElement.addEventListener(\"dragover\", handleDragOver, { passive: false })\n          rootElement.addEventListener(\"dragleave\", handleDragLeave, { passive: false })\n          rootElement.addEventListener(\"drop\", handleDrop, { passive: false })\n        }\n\n        if (finalConfig.enablePaste) {\n          rootElement.addEventListener(\"paste\", handlePasteEvent, { passive: false })\n        }\n      }\n    })\n\n    return removeRootListener\n  }, [\n    editor,\n    finalConfig,\n    handleDragEnter,\n    handleDragOver,\n    handleDragLeave,\n    handleDrop,\n    handlePasteEvent,\n  ])\n\n  return null\n}\n\nFileUploadPlugin.id = \"file-upload\"\nFileUploadPlugin.nodes = [FileAttachmentNode]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/components/FileDropZone.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn } from \"@follow/utils\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { memo } from \"react\"\n\ninterface FileDropZoneProps {\n  isVisible: boolean\n  isDragOver: boolean\n  className?: string\n}\n\nexport const FileDropZone = memo(({ isVisible, isDragOver, className }: FileDropZoneProps) => {\n  return (\n    <AnimatePresence>\n      {isVisible && (\n        <m.div\n          className={cn(\n            \"pointer-events-none absolute inset-0 z-50 flex items-center justify-center\",\n            className,\n          )}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={Spring.presets.smooth}\n        >\n          {/* Backdrop */}\n          <m.div\n            className={cn(\n              \"absolute inset-0 backdrop-blur-sm transition-colors duration-200\",\n              isDragOver ? \"bg-material-thin/90\" : \"bg-material-thin/60\",\n            )}\n          />\n\n          {/* Drop zone content */}\n          <m.div\n            initial={{ scale: 0.9, y: 10 }}\n            animate={{ scale: 1, y: 0 }}\n            exit={{ scale: 0.9, y: 10 }}\n            transition={Spring.presets.snappy}\n            className={cn(\n              \"rounded-xl border-2 border-dashed bg-background/95 p-6 text-center transition-all duration-200\",\n              isDragOver\n                ? \"border-accent bg-accent/5 shadow-lg shadow-accent/20\"\n                : \"border-border/50 shadow-sm\",\n            )}\n          >\n            <m.div\n              className=\"mb-3 flex justify-center text-accent\"\n              animate={isDragOver ? { scale: [1, 1.1, 1] } : {}}\n              transition={{\n                duration: 0.6,\n                repeat: isDragOver ? Number.POSITIVE_INFINITY : 0,\n                ease: \"easeInOut\",\n              }}\n            >\n              <i className=\"i-mgc-file-upload-cute-re size-8\" />\n            </m.div>\n\n            <p className={cn(\"font-medium text-text\", isDragOver && \"text-accent\")}>\n              {isDragOver ? \"Drop files to upload\" : \"Drag files here to upload\"}\n            </p>\n\n            <p className=\"mt-1 text-sm text-text-secondary\">\n              Images, PDFs, and text files supported\n            </p>\n          </m.div>\n        </m.div>\n      )}\n    </AnimatePresence>\n  )\n})\n\nFileDropZone.displayName = \"FileDropZone\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/hooks/useFileAttachmentBlockSync.ts",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport { $getNodeByKey } from \"lexical\"\nimport { useCallback, useEffect, useRef } from \"react\"\n\nimport { useAIChatStore } from \"~/modules/ai-chat/store/AIChatContext\"\nimport { useChatBlockActions } from \"~/modules/ai-chat/store/hooks\"\nimport type { FileAttachmentContextBlock } from \"~/modules/ai-chat/store/types\"\n\nimport { $isFileAttachmentNode, FileAttachmentNode } from \"../FileAttachmentNode\"\n\ninterface FileAttachmentBlockReference {\n  fileNodeKey: string\n  blockId: string\n  fileId: string // unique identifier for the file\n}\n\n/**\n * Hook that manages bidirectional synchronization between file attachment nodes and context blocks\n * - When a file attachment is added, corresponding block is created\n * - When a block is removed, corresponding file nodes are removed\n * - When a file node is removed, corresponding block is removed\n */\nexport const useFileAttachmentBlockSync = () => {\n  const [editor] = useLexicalComposerContext()\n  const blockActions = useChatBlockActions()\n  const blocks = useAIChatStore()((state) => state.blocks)\n\n  // Reference tracking maps\n  const nodeToBlockRef = useRef<Map<string, FileAttachmentBlockReference>>(undefined!)\n  if (!nodeToBlockRef.current) {\n    nodeToBlockRef.current = new Map()\n  }\n\n  const fileToNodeRef = useRef<Map<string, Set<string>>>(undefined!)\n  if (!fileToNodeRef.current) {\n    fileToNodeRef.current = new Map()\n  }\n\n  const blockToFileRef = useRef<Map<string, string>>(undefined!)\n  if (!blockToFileRef.current) {\n    blockToFileRef.current = new Map()\n  }\n\n  // Add file attachment reference\n  const addFileReference = useCallback(\n    (attachmentId: string, fileNodeKey: string, blockId: string) => {\n      const reference: FileAttachmentBlockReference = {\n        fileNodeKey,\n        blockId,\n        fileId: attachmentId,\n      }\n\n      // Update tracking maps\n      nodeToBlockRef.current.set(fileNodeKey, reference)\n\n      if (!fileToNodeRef.current.has(attachmentId)) {\n        fileToNodeRef.current.set(attachmentId, new Set())\n      }\n      fileToNodeRef.current.get(attachmentId)!.add(fileNodeKey)\n\n      blockToFileRef.current.set(blockId, attachmentId)\n    },\n    [],\n  )\n\n  // Remove file attachment reference\n  const removeFileReference = useCallback((fileNodeKey: string) => {\n    const reference = nodeToBlockRef.current.get(fileNodeKey)\n    if (!reference) return null\n\n    const { fileId, blockId } = reference\n\n    // Clean up tracking maps\n    nodeToBlockRef.current.delete(fileNodeKey)\n\n    const nodeSet = fileToNodeRef.current.get(fileId)\n    if (nodeSet) {\n      nodeSet.delete(fileNodeKey)\n      if (nodeSet.size === 0) {\n        fileToNodeRef.current.delete(fileId)\n      }\n    }\n\n    blockToFileRef.current.delete(blockId)\n\n    return reference\n  }, [])\n\n  // Handle file attachment insertion - create block and track reference\n  const handleFileAttachmentInsert = useCallback(\n    (attachmentId: string, fileNodeKey: string) => {\n      // Check if block already exists for this file\n      const existingBlock = blocks.find(\n        (block): block is FileAttachmentContextBlock =>\n          block.type === \"fileAttachment\" &&\n          blockToFileRef.current.get(block.attachment.id) === attachmentId,\n      )\n\n      let blockId: string\n      if (existingBlock) {\n        // Use existing block\n        blockId = existingBlock.id\n      } else {\n        // Find the file attachment in the current blocks state\n        const currentBlocks = blockActions.getBlocks()\n        const addedBlock = currentBlocks.find(\n          (block): block is FileAttachmentContextBlock =>\n            block.type === \"fileAttachment\" && block.attachment.id === attachmentId,\n        )\n\n        if (addedBlock) {\n          blockId = addedBlock.id\n        } else {\n          // Fallback to a predictable ID pattern\n          blockId = `fileAttachment-${attachmentId}-${fileNodeKey}`\n        }\n      }\n\n      // Track the reference\n      addFileReference(attachmentId, fileNodeKey, blockId)\n    },\n    [blocks, blockActions, addFileReference],\n  )\n\n  // Handle file attachment removal - remove block if no other nodes reference it\n  const handleFileAttachmentRemove = useCallback(\n    (fileNodeKey: string) => {\n      const reference = removeFileReference(fileNodeKey)\n      if (!reference) return\n\n      const { fileId } = reference\n\n      // Check if any other nodes still reference this file\n      const remainingNodes = fileToNodeRef.current.get(fileId)\n      if (!remainingNodes || remainingNodes.size === 0) {\n        // No more nodes reference this file, remove the block\n        blockActions.removeFileAttachment(fileId)\n      }\n    },\n    [blockActions, removeFileReference],\n  )\n\n  // Handle block removal - remove corresponding file nodes\n  const handleBlockRemove = useCallback(\n    (blockId: string) => {\n      const fileId = blockToFileRef.current.get(blockId)\n      if (!fileId) return\n\n      const nodeKeys = fileToNodeRef.current.get(fileId)\n      if (!nodeKeys) return\n\n      // Remove all file attachment nodes for this file\n      editor.update(() => {\n        Array.from(nodeKeys).forEach((nodeKey) => {\n          const node = $getNodeByKey(nodeKey)\n          if (node && $isFileAttachmentNode(node)) {\n            node.remove()\n          }\n        })\n      })\n\n      // Clean up references\n      Array.from(nodeKeys).forEach((nodeKey) => {\n        removeFileReference(nodeKey)\n      })\n    },\n    [editor, removeFileReference],\n  )\n\n  // Monitor block changes\n  useEffect(() => {\n    const currentBlockIds = new Set(blocks.map((block) => block.id))\n    const trackedBlockIds = new Set(blockToFileRef.current.keys())\n\n    // Find removed blocks\n    for (const trackedBlockId of trackedBlockIds) {\n      if (!currentBlockIds.has(trackedBlockId)) {\n        handleBlockRemove(trackedBlockId)\n      }\n    }\n  }, [blocks, handleBlockRemove])\n\n  // Monitor file attachment node changes using mutation observer\n  useEffect(() => {\n    const removedNodeKeys = new Set<string>()\n\n    const unregisterMutationListener = editor.registerMutationListener(\n      FileAttachmentNode,\n      (mutatedNodes) => {\n        for (const [nodeKey, mutation] of mutatedNodes) {\n          // Only track destroyed mutations for nodes we're actually tracking\n          if (mutation === \"destroyed\" && nodeToBlockRef.current.has(nodeKey)) {\n            removedNodeKeys.add(nodeKey)\n          }\n        }\n\n        // Process removed nodes in next tick to avoid state conflicts\n        if (removedNodeKeys.size > 0) {\n          const timeoutId = setTimeout(() => {\n            Array.from(removedNodeKeys).forEach((nodeKey) => {\n              handleFileAttachmentRemove(nodeKey)\n            })\n            removedNodeKeys.clear()\n          }, 100)\n\n          return () => clearTimeout(timeoutId)\n        }\n      },\n    )\n\n    return unregisterMutationListener\n  }, [editor, handleFileAttachmentRemove])\n\n  // Cleanup on unmount\n  useEffect(() => {\n    const nodeToBlock = nodeToBlockRef.current\n    const fileToNode = fileToNodeRef.current\n    const blockToFile = blockToFileRef.current\n\n    return () => {\n      nodeToBlock.clear()\n      fileToNode.clear()\n      blockToFile.clear()\n    }\n  }, [])\n\n  return {\n    handleFileAttachmentInsert,\n    handleFileAttachmentRemove,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/hooks/useFileUploadIntegration.ts",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport {\n  $createParagraphNode,\n  $getRoot,\n  $getSelection,\n  $insertNodes,\n  $isElementNode,\n} from \"lexical\"\nimport { useCallback } from \"react\"\n\nimport { useFileUploadWithDefaults } from \"../../../../hooks/useFileUpload\"\nimport { $createFileAttachmentNode } from \"../FileAttachmentNode\"\n\nexport function useFileUploadIntegration(\n  onFileNodeInsert?: (attachmentId: string, nodeKey: string) => void,\n) {\n  const [editor] = useLexicalComposerContext()\n  const { uploadFiles } = useFileUploadWithDefaults()\n\n  const insertFileAttachmentNode = useCallback(\n    (attachmentId: string) => {\n      editor.update(() => {\n        const selection = $getSelection()\n        const root = $getRoot()\n\n        const fileNode = $createFileAttachmentNode(attachmentId)\n\n        if (selection) {\n          // Insert at current selection\n          $insertNodes([fileNode])\n        } else {\n          // No selection - append to the end\n          const lastChild = root.getLastChild()\n          if (lastChild && $isElementNode(lastChild)) {\n            lastChild.append(fileNode)\n          } else {\n            // Create a paragraph and add the file node to it\n            const paragraph = $createParagraphNode()\n            paragraph.append(fileNode)\n            root.append(paragraph)\n          }\n        }\n\n        // Notify the sync handler\n        const nodeKey = fileNode.getKey()\n        if (onFileNodeInsert) {\n          onFileNodeInsert(attachmentId, nodeKey)\n        }\n      })\n    },\n    [editor, onFileNodeInsert],\n  )\n\n  const handleMultipleFileUpload = useCallback(\n    async (files: File[] | FileList) => {\n      try {\n        const results = await uploadFiles(files)\n\n        // Insert successful uploads into the editor\n        results.forEach((result) => {\n          if (result.success && result.fileAttachment) {\n            insertFileAttachmentNode(result.fileAttachment.id)\n          }\n        })\n      } catch (error) {\n        console.error(\"Multiple file upload failed:\", error)\n      }\n    },\n    [uploadFiles, insertFileAttachmentNode],\n  )\n\n  const handleFileDrop = useCallback(\n    async (files: FileList) => {\n      if (files && files.length > 0) {\n        await handleMultipleFileUpload(files)\n      }\n    },\n    [handleMultipleFileUpload],\n  )\n\n  const handlePaste = useCallback(\n    async (clipboardData: DataTransfer) => {\n      const files = Array.from(clipboardData.files)\n      if (files.length > 0) {\n        await handleMultipleFileUpload(files)\n      }\n    },\n    [handleMultipleFileUpload],\n  )\n\n  return {\n    handleMultipleFileUpload,\n    handleFileDrop,\n    handlePaste,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/index.ts",
    "content": "export { FileAttachmentNode } from \"./FileAttachmentNode\"\nexport { FileUploadPlugin } from \"./FileUploadPlugin\"\nexport type * from \"./types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/types.ts",
    "content": "export interface FileUploadPluginConfig {\n  /**\n   * Enable drag and drop file upload\n   */\n  enableDragDrop?: boolean\n  /**\n   * Enable paste file upload\n   */\n  enablePaste?: boolean\n}\n\nexport interface FileDropZoneState {\n  isDragOver: boolean\n  dragCounter: number\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/utils/file-handling.ts",
    "content": "/**\n * Check if drag event contains files\n */\nexport function dragEventHasFiles(event: DragEvent): boolean {\n  return event.dataTransfer?.types.includes(\"Files\") ?? false\n}\n\n/**\n * Check if clipboard event contains files\n */\nexport function clipboardEventHasFiles(event: ClipboardEvent): boolean {\n  return !!(event.clipboardData?.files && event.clipboardData.files.length > 0)\n}\n\n/**\n * Prevent default drag behaviors\n */\nexport function preventDefaultDrag(event: DragEvent): void {\n  event.preventDefault()\n  event.stopPropagation()\n}\n\n/**\n * Get files from drop event\n */\nexport function getFilesFromDrop(event: DragEvent): FileList | null {\n  return event.dataTransfer?.files ?? null\n}\n\n/**\n * Get files from paste event\n */\nexport function getFilesFromPaste(event: ClipboardEvent): FileList | null {\n  return event.clipboardData?.files ?? null\n}\n\n/**\n * Debounce drag counter for proper drag leave handling\n */\nexport function createDragCounter() {\n  let counter = 0\n\n  return {\n    increment: () => ++counter,\n    decrement: () => --counter,\n    get: () => counter,\n    reset: () => {\n      counter = 0\n      return counter\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/index.tsx",
    "content": "export * from \"./file-upload\"\nexport * from \"./mention\"\nexport * from \"./selection\"\nexport * from \"./shortcut\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/MentionNode.tsx",
    "content": "import i18next from \"i18next\"\nimport type {\n  DOMConversionMap,\n  DOMExportOutput,\n  EditorConfig,\n  LexicalEditor,\n  LexicalNode,\n  NodeKey,\n  SerializedLexicalNode,\n  Spread,\n} from \"lexical\"\nimport { $applyNodeReplacement, DecoratorNode } from \"lexical\"\nimport * as React from \"react\"\n\nimport { MentionComponent } from \"./components/MentionComponent\"\nimport { getDateMentionDisplayName } from \"./hooks/dateMentionUtils\"\nimport type { MentionData } from \"./types\"\n\nexport type SerializedMentionNode = Spread<\n  {\n    mentionData: MentionData\n  },\n  SerializedLexicalNode\n>\n\nexport class MentionNode extends DecoratorNode<React.JSX.Element> {\n  __mentionData: MentionData\n\n  static override getType(): string {\n    return \"mention\"\n  }\n\n  static override clone(node: MentionNode): MentionNode {\n    return new MentionNode(node.__mentionData, node.__key)\n  }\n\n  constructor(mentionData: MentionData, key?: NodeKey) {\n    super(key)\n    this.__mentionData = mentionData\n  }\n\n  getMentionData(): MentionData {\n    return this.__mentionData\n  }\n\n  setMentionData(mentionData: MentionData): void {\n    const writable = this.getWritable()\n    writable.__mentionData = mentionData\n  }\n\n  override createDOM(config: EditorConfig): HTMLElement {\n    const dom = document.createElement(\"span\")\n    dom.className = config.theme.mention || \"mention-node\"\n    dom.dataset.lexicalMention = \"true\"\n    dom.dataset.mentionType = this.__mentionData.type\n    dom.dataset.mentionId = this.__mentionData.id\n    return dom\n  }\n\n  override updateDOM(): false {\n    return false\n  }\n\n  static override importDOM(): DOMConversionMap | null {\n    return {\n      span: () => {\n        throw new Error(\"Not implemented\")\n      },\n    }\n  }\n\n  static override importJSON(serializedNode: SerializedMentionNode): MentionNode {\n    const { mentionData } = serializedNode\n    const node = $createMentionNode(mentionData)\n    return node\n  }\n\n  override exportDOM(): DOMExportOutput {\n    const element = document.createElement(\"span\")\n    element.dataset.lexicalMention = \"true\"\n    element.dataset.mentionType = this.__mentionData.type\n    element.dataset.mentionId = this.__mentionData.id\n    element.textContent = `@${resolveMentionDisplayName(this.__mentionData)}`\n    element.className = \"mention-node\"\n    return { element }\n  }\n\n  override exportJSON(): SerializedMentionNode {\n    return {\n      mentionData: this.__mentionData,\n      type: \"mention\",\n      version: 1,\n    }\n  }\n\n  /**\n   * For export markdown conversion\n   */\n  override getTextContent(): string {\n    return this.__mentionData.text\n  }\n\n  override decorate(editor: LexicalEditor): React.JSX.Element {\n    // Use a combination of key and value to ensure re-render when mention data changes\n    const dataKey =\n      typeof this.__mentionData.value === \"string\"\n        ? this.__mentionData.value\n        : String(this.__mentionData.value)\n\n    return (\n      <React.Suspense fallback={null}>\n        <MentionComponent\n          mentionData={this.__mentionData}\n          nodeKey={this.__key}\n          editor={editor}\n          key={`${this.__key}-${dataKey}`}\n        />\n      </React.Suspense>\n    )\n  }\n\n  override isInline(): boolean {\n    return true\n  }\n\n  override isKeyboardSelectable(): boolean {\n    return false\n  }\n\n  canInsertTextBefore(): boolean {\n    return false\n  }\n\n  canInsertTextAfter(): boolean {\n    return true\n  }\n\n  canBeEmpty(): boolean {\n    return false\n  }\n\n  isSegmented(): boolean {\n    return true\n  }\n\n  extractWithChild(): boolean {\n    return false\n  }\n}\n\nexport function $createMentionNode(mentionData: MentionData): MentionNode {\n  const mentionNode = new MentionNode(mentionData)\n  return $applyNodeReplacement(mentionNode)\n}\n\nexport function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {\n  return node instanceof MentionNode\n}\n\nconst resolveMentionDisplayName = (mentionData: MentionData): string => {\n  if (mentionData.type !== \"date\") {\n    return mentionData.name\n  }\n\n  const language = i18next.language || i18next.resolvedLanguage || i18next.options?.lng || \"en\"\n  const translate = i18next.getFixedT(language, \"ai\")\n\n  return getDateMentionDisplayName(mentionData, translate, language)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/MentionPlugin.tsx",
    "content": "import * as React from \"react\"\nimport { Suspense, useMemo } from \"react\"\n\nimport { MentionDropdown } from \"./components/MentionDropdown\"\nimport { useMentionKeyboard } from \"./hooks/useMentionKeyboard\"\nimport { useMentionSearch } from \"./hooks/useMentionSearch\"\nimport { useMentionSearchService } from \"./hooks/useMentionSearchService\"\nimport { useMentionSelection } from \"./hooks/useMentionSelection\"\nimport { useMentionTrigger } from \"./hooks/useMentionTrigger\"\nimport { MentionNode } from \"./MentionNode\"\nimport { defaultTriggerFn } from \"./utils/triggerDetection\"\n\nexport function MentionPlugin() {\n  // Get integrated search and context block handling\n  const { searchMentions } = useMentionSearchService()\n\n  // Hook for detecting mention triggers\n  const { mentionMatch, isActive, clearMentionMatch } = useMentionTrigger({\n    triggerFn: defaultTriggerFn,\n  })\n\n  // Hook for searching mentions\n  const {\n    suggestions,\n    selectedIndex,\n    isLoading,\n    searchMentions: performSearch,\n    clearSuggestions,\n    setSelectedIndex,\n    hasResults,\n  } = useMentionSearch({\n    onSearch: searchMentions,\n  })\n\n  // Hook for handling mention selection\n  const { selectMention } = useMentionSelection({\n    mentionMatch,\n    onSelectionComplete: () => {\n      clearMentionMatch()\n      clearSuggestions()\n    },\n  })\n\n  // Hook for keyboard navigation\n  const handleArrowKey = React.useCallback(\n    (isUp: boolean) => {\n      if (!hasResults) return\n\n      const newIndex = isUp\n        ? selectedIndex <= 0\n          ? suggestions.length - 1\n          : selectedIndex - 1\n        : selectedIndex >= suggestions.length - 1\n          ? 0\n          : selectedIndex + 1\n\n      setSelectedIndex(newIndex)\n    },\n    [hasResults, suggestions.length, selectedIndex, setSelectedIndex],\n  )\n\n  const handleEnterKey = React.useCallback(() => {\n    if (hasResults && selectedIndex >= 0 && selectedIndex < suggestions.length) {\n      const mention = suggestions[selectedIndex]\n      if (mention) {\n        selectMention(mention)\n      }\n    }\n  }, [hasResults, selectedIndex, suggestions, selectMention])\n\n  const handleEscapeKey = React.useCallback(() => {\n    clearMentionMatch()\n    clearSuggestions()\n  }, [clearMentionMatch, clearSuggestions])\n\n  useMentionKeyboard({\n    isActive,\n    suggestions,\n    selectedIndex,\n    onArrowKey: handleArrowKey,\n    onEnterKey: handleEnterKey,\n    onEscapeKey: handleEscapeKey,\n  })\n\n  // Search when mention match changes\n  React.useEffect(() => {\n    if (mentionMatch) {\n      performSearch(mentionMatch.matchingString)\n    } else {\n      clearSuggestions()\n    }\n  }, [mentionMatch, performSearch, clearSuggestions])\n\n  // Calculate dropdown props\n  const dropdownProps = useMemo(() => {\n    if (!isActive || !hasResults) return null\n\n    return {\n      isVisible: true,\n      suggestions,\n      selectedIndex,\n      isLoading,\n      onSetSelectIndex: setSelectedIndex,\n      onSelect: selectMention,\n      onClose: handleEscapeKey,\n      query: mentionMatch?.matchingString || \"\",\n    }\n  }, [\n    isActive,\n    hasResults,\n    suggestions,\n    selectedIndex,\n    isLoading,\n    setSelectedIndex,\n    selectMention,\n    handleEscapeKey,\n    mentionMatch?.matchingString,\n  ])\n\n  return dropdownProps ? (\n    <Suspense fallback={null}>\n      <MentionDropdown {...dropdownProps} />\n    </Suspense>\n  ) : null\n}\n\nMentionPlugin.id = \"mention\"\nMentionPlugin.nodes = [MentionNode]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/MentionComponent.tsx",
    "content": "import { DateTimePicker } from \"@follow/components/ui/input/index.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipRoot,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { getView } from \"@follow/constants\"\nimport { cn } from \"@follow/utils\"\nimport dayjs from \"dayjs\"\nimport type { LexicalEditor } from \"lexical\"\nimport { $getNodeByKey } from \"lexical\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { MentionLikePill } from \"../../shared/components/MentionLikePill\"\nimport {\n  createDateMentionData,\n  getDateMentionDisplayName,\n  parseRangeValue,\n} from \"../hooks/dateMentionUtils\"\nimport { $isMentionNode } from \"../MentionNode\"\nimport type { MentionData } from \"../types\"\nimport { getMentionDisplayTextValue } from \"../utils/mentionTextValue\"\nimport { MentionTypeIcon } from \"./shared/MentionTypeIcon\"\n\ninterface MentionComponentProps {\n  mentionData: MentionData\n  className?: string\n  nodeKey?: string\n  editor?: LexicalEditor\n}\n\nconst MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => {\n  const { t, i18n } = useTranslation(\"ai\")\n  const language = i18n.language || i18n.resolvedLanguage || \"en\"\n  const displayValue = getMentionDisplayTextValue(mentionData, t, language)\n\n  const getIconBgColor = () => {\n    if (mentionData.type === \"view\" && typeof mentionData.value === \"number\") {\n      const viewDef = getView(mentionData.value)\n      if (viewDef?.backgroundClassName) {\n        return viewDef.backgroundClassName\n      }\n    }\n\n    switch (mentionData.type) {\n      case \"entry\": {\n        return \"bg-blue\"\n      }\n      case \"feed\": {\n        return \"bg-orange\"\n      }\n      case \"date\": {\n        return \"bg-purple\"\n      }\n    }\n  }\n\n  return (\n    <div className=\"flex items-start gap-2 p-1\">\n      <div\n        className={cn(\n          \"flex size-5 shrink-0 items-center justify-center rounded text-white\",\n          getIconBgColor(),\n        )}\n      >\n        <MentionTypeIcon type={mentionData.type} value={mentionData.value} className=\"size-3\" />\n      </div>\n      <span className=\"text-sm text-text\">{displayValue}</span>\n    </div>\n  )\n}\n\nconst getMentionStyles = (mentionData: MentionData) => {\n  const { type, value } = mentionData\n  const baseStyles = tw`\n    inline items-center gap-1 px-2 py-0.5 rounded-md\n    font-medium text-sm cursor-pointer select-none\n  `\n\n  switch (type) {\n    case \"entry\": {\n      return cn(\n        baseStyles,\n        \"bg-blue/10 text-blue border-blue/20\",\n        \"hover:bg-blue/20 hover:border-blue/30\",\n      )\n    }\n    case \"feed\": {\n      return cn(\n        baseStyles,\n        \"bg-orange/10 text-orange border-orange/20\",\n        \"hover:bg-orange/20 hover:border-orange/30\",\n      )\n    }\n    case \"category\": {\n      return cn(\n        baseStyles,\n        \"bg-green/10 text-green border-green/20\",\n        \"hover:bg-green/20 hover:border-green/30\",\n      )\n    }\n    case \"date\": {\n      return cn(\n        baseStyles,\n        \"bg-purple/10 text-purple border-purple/20\",\n        \"hover:bg-purple/20 hover:border-purple/30\",\n      )\n    }\n    case \"view\": {\n      const viewDef = getView(value as number)\n      return cn(baseStyles, viewDef!.mentionClassName)\n    }\n  }\n}\n\nexport const MentionComponent: React.FC<MentionComponentProps> = ({\n  mentionData,\n  className,\n  nodeKey,\n  editor,\n}) => {\n  const { t, i18n } = useTranslation(\"ai\")\n  const language = i18n.language || i18n.resolvedLanguage || \"en\"\n\n  const displayName = React.useMemo(() => {\n    if (mentionData.type === \"date\") {\n      return getDateMentionDisplayName(mentionData, t, language)\n    }\n    return `@${mentionData.name}`\n  }, [mentionData, t, language])\n\n  const handleDateRangeChange = React.useCallback(\n    (value: { start?: string; end?: string }) => {\n      if (!nodeKey || !value.start || !value.end || !editor) return\n\n      const startDate = dayjs(value.start).startOf(\"day\")\n      const endDate = dayjs(value.end).startOf(\"day\")\n      const range = { start: startDate, end: endDate }\n\n      const newMentionData = createDateMentionData({\n        range,\n        translate: t,\n      })\n\n      editor.update(() => {\n        const node = $getNodeByKey(nodeKey)\n        if ($isMentionNode(node)) {\n          node.setMentionData(newMentionData)\n        }\n      })\n    },\n    [nodeKey, editor, t],\n  )\n\n  const currentDateRange = React.useMemo(() => {\n    if (mentionData.type !== \"date\" || typeof mentionData.value !== \"string\") {\n      return\n    }\n    const range = parseRangeValue(mentionData.value)\n    if (!range) return\n\n    return {\n      start: range.start.toISOString(),\n      end: range.end.toISOString(),\n    }\n  }, [mentionData])\n\n  const mentionSpan = (\n    <TooltipTrigger asChild>\n      <MentionLikePill\n        className={cn(getMentionStyles(mentionData), className)}\n        icon={\n          <MentionTypeIcon type={mentionData.type} value={mentionData.value} className=\"size-3\" />\n        }\n      >\n        {displayName}\n      </MentionLikePill>\n    </TooltipTrigger>\n  )\n\n  const isEditableDateMention = mentionData.type === \"date\" && nodeKey && editor\n\n  return (\n    <Tooltip>\n      <TooltipRoot>\n        {isEditableDateMention ? (\n          <DateTimePicker\n            mode=\"range\"\n            rangeValue={currentDateRange}\n            onRangeChange={handleDateRangeChange}\n            minDate={dayjs().subtract(1, \"month\").toISOString()}\n          >\n            {mentionSpan}\n          </DateTimePicker>\n        ) : (\n          mentionSpan\n        )}\n        <TooltipPortal>\n          <TooltipContent side=\"top\" className=\"max-w-[300px]\">\n            <MentionTooltipContent mentionData={mentionData} />\n          </TooltipContent>\n        </TooltipPortal>\n      </TooltipRoot>\n    </Tooltip>\n  )\n}\n\nMentionComponent.displayName = \"MentionComponent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/MentionDropdown.tsx",
    "content": "import { cn, thenable } from \"@follow/utils\"\nimport * as React from \"react\"\nimport { useCallback, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { TypeaheadGroup } from \"../../shared/components/TypeaheadDropdown\"\nimport { TypeaheadDropdown } from \"../../shared/components/TypeaheadDropdown\"\nimport { MENTION_TRIGGER_PATTERN } from \"../constants\"\nimport { getDateMentionDisplayName } from \"../hooks/dateMentionUtils\"\nimport type { MentionData } from \"../types\"\nimport { MentionTypeIcon } from \"./shared/MentionTypeIcon\"\n\ninterface MentionDropdownProps {\n  isVisible: boolean\n  suggestions: MentionData[]\n  selectedIndex: number\n  isLoading: boolean\n  onSelect: (mention: MentionData) => void\n  onSetSelectIndex: (index: number) => void\n  onClose: () => void\n  query: string\n  anchor?: HTMLElement | null\n  showSearchInput?: boolean\n  onQueryChange?: (query: string) => void\n}\n\nconst MentionSuggestionItem = React.memo(\n  ({\n    mention,\n    isSelected,\n    onClick,\n    query,\n    ...props\n  }: {\n    mention: MentionData\n    isSelected: boolean\n    onClick: (mention: MentionData) => void\n    query: string\n  } & Omit<React.HTMLAttributes<HTMLDivElement>, \"onClick\">) => {\n    const { t, i18n } = useTranslation(\"ai\")\n    const language = i18n.language || i18n.resolvedLanguage || \"en\"\n\n    const displayName = React.useMemo(() => {\n      if (mention.type === \"date\") {\n        return getDateMentionDisplayName(mention, t, language)\n      }\n      return mention.name\n    }, [mention, t, language])\n\n    const handleClick = useCallback(() => {\n      onClick(mention)\n    }, [mention, onClick])\n\n    // Highlight matching text\n    const highlightText = (text: string, rawQuery: string) => {\n      const cleanQuery = rawQuery.replace(MENTION_TRIGGER_PATTERN, \"\").toLowerCase()\n      if (!cleanQuery) return text\n\n      const parts = text.split(new RegExp(`(${cleanQuery})`, \"gi\"))\n      return parts.map((part, index) => {\n        const isMatch = part.toLowerCase() === cleanQuery\n\n        if (!part) {\n          return null\n        }\n\n        return (\n          <span\n            key={`${mention.id}-${index}`}\n            className={isMatch ? \"font-semibold text-text-vibrant\" : \"\"}\n          >\n            {part}\n          </span>\n        )\n      })\n    }\n\n    return (\n      <div\n        className={cn(\n          \"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none\",\n          \"focus-within:outline-transparent\",\n          \"focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[highlighted]:bg-theme-selection-hover data-[highlighted]:text-theme-selection-foreground\",\n          \"h-[28px]\",\n          isSelected && \"bg-theme-selection-active text-theme-selection-foreground\",\n        )}\n        onClick={handleClick}\n        role=\"option\"\n        aria-selected={isSelected}\n        {...props}\n      >\n        {/* Icon */}\n        <span className=\"mr-1.5 inline-flex size-4 items-center justify-center\">\n          <MentionTypeIcon type={mention.type} value={mention.value} />\n        </span>\n\n        {/* Content */}\n        <span className=\"flex-1 truncate\">{highlightText(displayName, query)}</span>\n      </div>\n    )\n  },\n)\n\nMentionSuggestionItem.displayName = \"MentionSuggestionItem\"\n\nconst MentionGroupHeader = React.memo(({ type }: { type: MentionData[\"type\"] }) => {\n  const { t } = useTranslation(\"ai\")\n\n  const label = useMemo(() => {\n    switch (type) {\n      case \"date\": {\n        return t(\"mentions.section.date\")\n      }\n      case \"entry\": {\n        return t(\"mentions.section.entry\")\n      }\n      case \"feed\": {\n        return t(\"mentions.section.feed\")\n      }\n      case \"category\": {\n        return t(\"mentions.section.category\")\n      }\n      case \"view\": {\n        return t(\"mentions.section.view\")\n      }\n      default: {\n        return \"\"\n      }\n    }\n  }, [type, t])\n\n  return (\n    <div className=\"mb-1 mt-2 px-2.5 text-xs font-medium text-text-tertiary first:mt-0\">\n      {label}\n    </div>\n  )\n})\n\nMentionGroupHeader.displayName = \"MentionGroupHeader\"\n\nexport const MentionDropdown: React.FC<MentionDropdownProps> = ({\n  isVisible,\n  suggestions,\n  selectedIndex,\n  isLoading,\n  onSelect,\n  onSetSelectIndex,\n  onClose,\n  query,\n  anchor,\n  showSearchInput = false,\n  onQueryChange,\n}) => {\n  if (!isVisible) throw thenable\n\n  // Group suggestions by type with stable ordering\n  const groupedSuggestions = useMemo<TypeaheadGroup<MentionData, MentionData[\"type\"]>[]>(() => {\n    const groupMap = new Map<MentionData[\"type\"], MentionData[]>()\n\n    // Group mentions by type\n    for (const mention of suggestions) {\n      const items = groupMap.get(mention.type)\n      if (items) {\n        items.push(mention)\n      } else {\n        groupMap.set(mention.type, [mention])\n      }\n    }\n\n    // Define stable type order\n    const typeOrder: MentionData[\"type\"][] = [\"view\", \"date\", \"category\", \"feed\", \"entry\"]\n\n    // Convert to array with stable ordering\n    return typeOrder\n      .map((type) => {\n        const items = groupMap.get(type)\n        return items?.length ? { key: type, items } : null\n      })\n      .filter((group): group is TypeaheadGroup<MentionData, MentionData[\"type\"]> => group !== null)\n  }, [suggestions])\n\n  return (\n    <TypeaheadDropdown<MentionData, MentionData[\"type\"]>\n      isVisible={isVisible}\n      items={groupedSuggestions}\n      selectedIndex={selectedIndex}\n      isLoading={isLoading}\n      onSelect={onSelect}\n      onSetSelectIndex={onSetSelectIndex}\n      onClose={onClose}\n      query={query}\n      ariaLabel=\"Mention suggestions\"\n      getKey={(mention) => `${mention.type}-${mention.id}`}\n      renderItem={(mention, _index, isSelected, handlers) => (\n        <MentionSuggestionItem\n          mention={mention}\n          isSelected={isSelected}\n          onMouseMove={handlers.onMouseMove}\n          onClick={() => handlers.onClick()}\n          query={query}\n        />\n      )}\n      renderGroupHeader={(groupKey) => <MentionGroupHeader type={groupKey} />}\n      anchor={anchor}\n      showSearchInput={showSearchInput}\n      onQueryChange={onQueryChange}\n    />\n  )\n}\n\nMentionDropdown.displayName = \"MentionDropdown\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx",
    "content": "import { getView } from \"@follow/constants\"\nimport * as React from \"react\"\n\nimport type { MentionType } from \"../../types\"\n\ninterface MentionTypeIconProps {\n  type: MentionType\n  value?: unknown\n  className?: string\n}\n\nexport const MentionTypeIcon: React.FC<MentionTypeIconProps> = ({\n  type,\n  value,\n  className = \"size-3\",\n}) => {\n  switch (type) {\n    case \"entry\": {\n      return <i className={`i-mgc-paper-cute-fi ${className}`} />\n    }\n    case \"feed\": {\n      return <i className={`i-mgc-rss-cute-fi ${className}`} />\n    }\n    case \"category\": {\n      return <i className={`i-mgc-folder-open-cute-re ${className}`} />\n    }\n    case \"date\": {\n      return <i className={`i-mgc-calendar-time-add-cute-re ${className}`} />\n    }\n    case \"view\": {\n      if (typeof value === \"number\") {\n        const viewDef = getView(value)\n        if (viewDef?.icon?.props?.className) {\n          return <i className={`${viewDef.icon.props.className} ${className}`} />\n        }\n      }\n      return <i className={`i-mgc-grid-cute-re ${className}`} />\n    }\n    default: {\n      return <i className={`i-mgc-ai-cute-re ${className}`} />\n    }\n  }\n}\n\nMentionTypeIcon.displayName = \"MentionTypeIcon\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/constants.ts",
    "content": "import { createCommand } from \"lexical\"\n\nimport type { MentionData } from \"./types\"\n\n// Commands\nexport const MENTION_COMMAND = createCommand<MentionData>(\"MENTION_COMMAND\")\nexport const MENTION_TYPEAHEAD_COMMAND = createCommand<string>(\"MENTION_TYPEAHEAD_COMMAND\")\n\n// Default configuration\n\nexport const DEFAULT_MAX_SUGGESTIONS = 10\n\n// Trigger patterns\n// Support CJK characters (Chinese, Japanese, Korean) in addition to ASCII\nexport const MENTION_TRIGGER_PATTERN =\n  /(?:^|\\s)(@[#+!]?[\\w\\s\\u4e00-\\u9fff\\u3400-\\u4dbf\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af-]*)$/\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionConfig.ts",
    "content": "import type { Dayjs } from \"dayjs\"\nimport dayjs from \"dayjs\"\n\nimport type { DateRange } from \"./dateMentionUtils\"\n\nexport const MAX_INLINE_DATE_SUGGESTIONS = 2\n\nexport type DateRangeFactory = (today: Dayjs) => DateRange\n\nexport interface RelativeDateDefinition {\n  id: string\n  labelKey: I18nKeysForAi\n  searchKeys: I18nKeysForAi[]\n  range: DateRangeFactory\n}\n\nexport const RELATIVE_DATE_DEFINITIONS: readonly RelativeDateDefinition[] = [\n  {\n    id: \"date:relative:today\",\n    labelKey: \"mentions.date.relative.today.label\",\n    searchKeys: [\"mentions.date.relative.today.search\"],\n    range: (today) => ({ start: today, end: dayjs() }),\n  },\n  {\n    id: \"date:relative:yesterday\",\n    labelKey: \"mentions.date.relative.yesterday.label\",\n    searchKeys: [\"mentions.date.relative.yesterday.search\"],\n    range: (today) => {\n      const target = today.subtract(1, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:last-3-days\",\n    labelKey: \"mentions.date.relative.last_3_days.label\",\n    searchKeys: [\"mentions.date.relative.last_3_days.search\"],\n    range: (today) => ({ start: today.subtract(2, \"day\"), end: today }),\n  },\n  {\n    id: \"date:relative:last-7-days\",\n    labelKey: \"mentions.date.relative.last_7_days.label\",\n    searchKeys: [\"mentions.date.relative.last_7_days.search\"],\n    range: (today) => ({ start: today.subtract(6, \"day\"), end: today }),\n  },\n  {\n    id: \"date:relative:last-15-days\",\n    labelKey: \"mentions.date.relative.last_15_days.label\",\n    searchKeys: [\"mentions.date.relative.last_15_days.search\"],\n    range: (today) => ({ start: today.subtract(14, \"day\"), end: today }),\n  },\n  {\n    id: \"date:relative:last-30-days\",\n    labelKey: \"mentions.date.relative.last_30_days.label\",\n    searchKeys: [\"mentions.date.relative.last_30_days.search\"],\n    range: (today) => ({ start: today.subtract(29, \"day\"), end: today }),\n  },\n  {\n    id: \"date:relative:this-week\",\n    labelKey: \"mentions.date.relative.this_week.label\",\n    searchKeys: [\"mentions.date.relative.this_week.search\"],\n    range: (today) => ({ start: today.startOf(\"week\"), end: today }),\n  },\n  {\n    id: \"date:relative:last-week\",\n    labelKey: \"mentions.date.relative.last_week.label\",\n    searchKeys: [\"mentions.date.relative.last_week.search\"],\n    range: (today) => {\n      const start = today.subtract(1, \"week\").startOf(\"week\")\n      const end = start.add(6, \"day\")\n      return { start, end }\n    },\n  },\n  // Weekday in this week (future days are filtered by )\n  {\n    id: \"date:relative:this-week-monday\",\n    labelKey: \"mentions.date.weekday.day.monday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.monday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(1, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:this-week-tuesday\",\n    labelKey: \"mentions.date.weekday.day.tuesday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.tuesday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(2, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:this-week-wednesday\",\n    labelKey: \"mentions.date.weekday.day.wednesday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.wednesday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(3, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:this-week-thursday\",\n    labelKey: \"mentions.date.weekday.day.thursday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.thursday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(4, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:this-week-friday\",\n    labelKey: \"mentions.date.weekday.day.friday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.friday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(5, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:this-week-saturday\",\n    labelKey: \"mentions.date.weekday.day.saturday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.saturday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(6, \"day\")\n      return { start: target, end: target }\n    },\n  },\n  {\n    id: \"date:relative:this-week-sunday\",\n    labelKey: \"mentions.date.weekday.day.sunday.label\",\n    searchKeys: [\n      \"mentions.date.weekday.prefix.this.search\",\n      \"mentions.date.weekday.day.sunday.search\",\n    ],\n    range: (today) => {\n      const startOfWeek = today.startOf(\"week\")\n      const target = startOfWeek.add(0, \"day\")\n      return { start: target, end: target }\n    },\n  },\n\n  {\n    id: \"date:relative:this-month\",\n    labelKey: \"mentions.date.relative.this_month.label\",\n    searchKeys: [\"mentions.date.relative.this_month.search\"],\n    range: (today) => ({ start: today.startOf(\"month\"), end: today }),\n  },\n  {\n    id: \"date:relative:last-month\",\n    labelKey: \"mentions.date.relative.last_month.label\",\n    searchKeys: [\"mentions.date.relative.last_month.search\"],\n    range: (today) => {\n      const start = today.subtract(1, \"month\").startOf(\"month\")\n      const end = start.endOf(\"month\")\n      return { start, end }\n    },\n  },\n]\n\nexport type WeekdayPrefix = \"auto\" | \"this\" | \"last\"\n\nexport interface WeekdayTranslationDescriptor {\n  id: string\n  index: number\n  labelKey: string\n  searchKey: string\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionParsers.ts",
    "content": "import dayjs from \"dayjs\"\nimport type { IFuseOptions } from \"fuse.js\"\nimport Fuse from \"fuse.js\"\nimport type { TFunction } from \"i18next\"\n\nimport type { MentionData, MentionLabelDescriptor } from \"../types\"\nimport { parseNaturalLanguageDate } from \"../utils/parseNaturalLanguageDate\"\nimport type { RelativeDateDefinition } from \"./dateMentionConfig\"\nimport { RELATIVE_DATE_DEFINITIONS } from \"./dateMentionConfig\"\nimport type { DateRange } from \"./dateMentionUtils\"\nimport {\n  createDateMentionData,\n  formatLocalizedRange,\n  resolveMentionLabel,\n} from \"./dateMentionUtils\"\n\ntype AiTFunction = TFunction<\"ai\">\n\ninterface DateMentionBuilderContext {\n  t: AiTFunction\n  language: string\n}\n\ninterface RelativeDateCandidate {\n  definition: RelativeDateDefinition\n  label: MentionLabelDescriptor\n  searchTerms: string[]\n}\n\nconst FUSE_OPTIONS: IFuseOptions<RelativeDateCandidate> = {\n  includeScore: true,\n  threshold: 0.3,\n  ignoreLocation: true,\n  minMatchCharLength: 1,\n  keys: [\"searchTerms\"],\n}\n\nconst sanitizeTerm = (term: string): string => term.trim()\n\nconst addSearchTerm = (set: Set<string>, term: string) => {\n  const cleaned = sanitizeTerm(term)\n  if (!cleaned) return\n\n  set.add(cleaned)\n  const lowered = cleaned.toLowerCase()\n  if (lowered !== cleaned) {\n    set.add(lowered)\n  }\n}\n\nconst extractSearchTerms = (t: AiTFunction, key: string, lng?: string): string[] => {\n  // Use a relaxed call signature to avoid strict key typing issues\n  const tUnsafe: (key: string, options?: any) => unknown = (key, options) =>\n    (t as unknown as (k: string, o?: any) => unknown)(key, options)\n  const raw = tUnsafe(key, { returnObjects: true, lng }) as unknown\n\n  // Backward-compatible: if translations provided an array, keep supporting it\n  if (Array.isArray(raw)) {\n    return raw\n      .map((item) => (typeof item === \"string\" ? item : String(item)))\n      .map(sanitizeTerm)\n      .filter(Boolean)\n  }\n\n  // Preferred: translations provide a single string; support multiple synonyms\n  // delimited by common separators: |, comma (en/, zh ，), Japanese/Chinese lists (、), or newline\n  const value = tUnsafe(key, { lng }) as unknown\n  if (typeof value !== \"string\") return []\n\n  const pieces = value\n    .split(/[|,，、\\n]/g)\n    .map(sanitizeTerm)\n    .filter(Boolean)\n\n  // If no delimiter found and non-empty, treat as single term\n  return pieces.length > 0 ? pieces : [sanitizeTerm(value)].filter(Boolean)\n}\n\nconst buildRelativeCandidates = ({ t }: DateMentionBuilderContext): RelativeDateCandidate[] => {\n  return RELATIVE_DATE_DEFINITIONS.map<RelativeDateCandidate>((definition) => {\n    const terms = new Set<string>()\n    const label: MentionLabelDescriptor = { key: definition.labelKey }\n\n    addSearchTerm(terms, t(definition.labelKey))\n    // Always include English label as a searchable term\n    const tUnsafeLabel: (key: string, options?: any) => string = (key, options) =>\n      (t as unknown as (k: string, o?: any) => string)(key, options)\n    addSearchTerm(terms, tUnsafeLabel(definition.labelKey, { lng: \"en\" }))\n    definition.searchKeys.forEach((key) => {\n      // Localized terms\n      extractSearchTerms(t, key).forEach((term) => addSearchTerm(terms, term))\n      // Always include English terms\n      extractSearchTerms(t, key, \"en\").forEach((term) => addSearchTerm(terms, term))\n    })\n\n    return {\n      definition,\n      label,\n      searchTerms: Array.from(terms),\n    }\n  })\n}\n\nconst buildRangeMention = (\n  candidate: RelativeDateCandidate,\n  range: DateRange,\n  context: DateMentionBuilderContext,\n): MentionData => {\n  const labelText = resolveMentionLabel(candidate.label, context.t)\n  const rangeText = formatLocalizedRange(range, context.language)\n  const appendRange = labelText\n    ? labelText.localeCompare(rangeText, undefined, { sensitivity: \"accent\" }) !== 0\n    : true\n\n  return createDateMentionData({\n    id: candidate.definition.id,\n    range,\n    label: candidate.label,\n    labelOptions: appendRange ? { appendRange: true } : undefined,\n    translate: context.t,\n  })\n}\n\nconst normalizeQuery = (query: string): string => {\n  const trimmed = query.trim()\n  if (!trimmed) return \"\"\n\n  return trimmed.startsWith(\"@\") ? trimmed.slice(1) : trimmed\n}\n\nexport const createDateMentionBuilder = (context: DateMentionBuilderContext) => {\n  const candidates = buildRelativeCandidates(context)\n  const fuse = new Fuse(candidates, FUSE_OPTIONS)\n\n  return (query: string): MentionData[] => {\n    const normalized = normalizeQuery(query)\n    const today = dayjs().startOf(\"day\")\n    const mentions: MentionData[] = []\n\n    if (normalized) {\n      const naturalDateRange = parseNaturalLanguageDate(normalized, context.language)\n      if (naturalDateRange) {\n        const chronoMention = createDateMentionData({\n          range: naturalDateRange,\n          translate: context.t,\n          displayName: query,\n        })\n        mentions.push(chronoMention)\n      }\n    }\n\n    // Add predefined relative date suggestions\n    const bucket = normalized ? fuse.search(normalized).map((result) => result.item) : candidates\n\n    bucket.forEach((candidate) => {\n      const range = candidate.definition.range(today)\n      if (!range) return\n\n      mentions.push(buildRangeMention(candidate, range, context))\n    })\n\n    return mentions\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionSearch.ts",
    "content": "export { MAX_INLINE_DATE_SUGGESTIONS } from \"./dateMentionConfig\"\nexport { createDateMentionBuilder } from \"./dateMentionParsers\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionUtils.ts",
    "content": "import type { Dayjs } from \"dayjs\"\nimport dayjs from \"dayjs\"\nimport type { TFunction } from \"i18next\"\n\nimport { MENTION_DATE_VALUE_FORMAT } from \"~/modules/ai-chat/utils/mentionDate\"\n\nimport type { DateMentionData, MentionLabelDescriptor, MentionLabelValue } from \"../types\"\nimport type { RelativeDateDefinition } from \"./dateMentionConfig\"\nimport { RELATIVE_DATE_DEFINITIONS } from \"./dateMentionConfig\"\n\nexport interface DateRange {\n  start: Dayjs\n  end: Dayjs\n}\n\nconst formatRangeValue = (range: DateRange, text?: string): string => {\n  const startIso = range.start.format(MENTION_DATE_VALUE_FORMAT)\n  const endIso = range.end.format(MENTION_DATE_VALUE_FORMAT)\n\n  return `<mention-date start=\"${startIso}\" end=\"${endIso}\"${text ? ` text=\"${text}\"` : \"\"}></mention-date>`\n}\n\nconst formatLocalizedDate = (date: Dayjs, locale: string, template = \"LLL\"): string => {\n  return date.locale(locale).format(template)\n}\n\nexport const formatLocalizedRange = (\n  range: DateRange,\n  locale: string,\n  template?: string,\n): string => {\n  const startFormatted = formatLocalizedDate(range.start, locale, template)\n  const endFormatted = formatLocalizedDate(range.end, locale, template)\n\n  if (startFormatted === endFormatted) {\n    return startFormatted\n  }\n\n  return `${startFormatted} – ${endFormatted}`\n}\n\nexport type LabelTranslator = TFunction<\"ai\", undefined>\n\nconst isLabelDescriptor = (value: MentionLabelValue): value is MentionLabelDescriptor => {\n  return typeof value === \"object\" && value !== null && \"key\" in value\n}\n\nconst resolveLabelValue = (\n  value: MentionLabelValue,\n  translate: LabelTranslator,\n): string | number | boolean => {\n  if (isLabelDescriptor(value)) {\n    return resolveMentionLabel(value, translate) ?? \"\"\n  }\n  return value\n}\n\nexport const resolveMentionLabel = (\n  label: MentionLabelDescriptor | undefined,\n  translate: LabelTranslator,\n): string | undefined => {\n  if (!label) {\n    return undefined\n  }\n\n  const resolvedValues = label.values\n    ? Object.fromEntries(\n        Object.entries(label.values).map(([key, value]) => [\n          key,\n          resolveLabelValue(value, translate),\n        ]),\n      )\n    : undefined\n\n  return translate(label.key, resolvedValues)\n}\n\nexport const createDateMentionData = ({\n  id,\n  range,\n  label,\n  labelOptions,\n  translate,\n  displayName,\n}: {\n  id?: string\n  range: DateRange\n  label?: MentionLabelDescriptor\n  labelOptions?: DateMentionData[\"labelOptions\"]\n  translate: LabelTranslator\n  displayName?: string\n}): DateMentionData => {\n  const value = formatRangeValue(range, id || displayName)\n  const text = value // Use the same value for text\n\n  const resolvedName = displayName ?? (resolveMentionLabel(label, translate) || \"\")\n\n  return {\n    id: id ?? `date:${value}`,\n    name: resolvedName,\n    type: \"date\",\n    value,\n    text,\n    label,\n    labelOptions,\n  }\n}\n\nexport const parseRangeValue = (value: string): DateRange | null => {\n  // Parse XML format: <mention-date start=\"YYYY-MM-DD\" end=\"YYYY-MM-DD\"></mention-date>\n  const match = value.match(/start=\"([^\"]+)\"\\s+end=\"([^\"]+)\"/)\n  if (!match) return null\n\n  const [, startIso, endIso] = match\n  if (!startIso || !endIso) return null\n\n  const start = dayjs(startIso, MENTION_DATE_VALUE_FORMAT, true)\n  const end = dayjs(endIso, MENTION_DATE_VALUE_FORMAT, true)\n  if (!start.isValid() || !end.isValid()) return null\n\n  return { start, end }\n}\n\nexport const getDateMentionDisplayName = (\n  mention: Pick<DateMentionData, \"label\" | \"labelOptions\" | \"value\" | \"name\" | \"id\">,\n  translate: LabelTranslator,\n  locale: string,\n  asRange = false,\n): string => {\n  // Only rely on value range to determine the display name\n  if (typeof mention.value !== \"string\") {\n    return mention.name\n  }\n\n  const range = parseRangeValue(mention.value)\n  if (!range) {\n    return mention.name\n  }\n\n  const matchRelative = (): RelativeDateDefinition | null => {\n    for (const def of RELATIVE_DATE_DEFINITIONS) {\n      if (def.id === mention.id) {\n        return def\n      }\n    }\n    return null\n  }\n\n  const matched = matchRelative()\n  if (matched && !asRange) {\n    return translate(matched.labelKey)\n  }\n\n  return asRange\n    ? formatLocalizedRange(range, locale)\n    : mention.name || formatLocalizedRange(range, locale, \"LL\")\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionKeyboard.ts",
    "content": "import { useCallback } from \"react\"\n\nimport { useListKeyboardNavigation } from \"../../shared/hooks/useListKeyboardNavigation\"\nimport type { MentionData } from \"../types\"\n\ninterface UseMentionKeyboardOptions {\n  isActive: boolean\n  suggestions: MentionData[]\n  selectedIndex: number\n  onArrowKey: (isUp: boolean) => void\n  onEnterKey: () => void\n  onEscapeKey: () => void\n}\n\nexport const useMentionKeyboard = ({\n  isActive,\n  suggestions,\n  selectedIndex,\n  onArrowKey,\n  onEnterKey,\n  onEscapeKey,\n}: UseMentionKeyboardOptions) => {\n  const handleMove = useCallback((isUp: boolean) => onArrowKey(isUp), [onArrowKey])\n  const {\n    handleCancel,\n    handleConfirm,\n    handleMove: _,\n  } = useListKeyboardNavigation({\n    isActive,\n    itemCount: suggestions.length,\n    selectedIndex,\n    onMove: handleMove,\n    onConfirm: onEnterKey,\n    onCancel: onEscapeKey,\n  })\n\n  return { handleArrowKey: _, handleEnterKey: handleConfirm, handleEscapeKey: handleCancel }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearch.ts",
    "content": "import { useCallback, useRef, useState, useTransition } from \"react\"\n\nimport type { MentionData, MentionSearchState, MentionType } from \"../types\"\nimport { getMentionType, shouldTriggerMention } from \"../utils/triggerDetection\"\n\ninterface UseMentionSearchOptions {\n  onSearch?: (\n    query: string,\n    type: MentionType | undefined,\n    maxSuggestions?: number,\n  ) => Promise<MentionData[]> | MentionData[]\n}\n\n// Default search function\nconst defaultSearchFn = async (): Promise<MentionData[]> => []\n\nexport const useMentionSearch = ({ onSearch = defaultSearchFn }: UseMentionSearchOptions = {}) => {\n  const [searchState, setSearchState] = useState<MentionSearchState>({\n    suggestions: [],\n    selectedIndex: -1,\n    isLoading: false,\n  })\n\n  const [isPending, startTransition] = useTransition()\n  const onSearchRef = useRef(onSearch)\n  const abortControllerRef = useRef<AbortController | null>(null)\n\n  // Update refs when props change to avoid stale closures\n  onSearchRef.current = onSearch\n\n  const searchMentions = useCallback(\n    async (query: string, maxSuggestions?: number) => {\n      // Cancel any pending search\n      if (abortControllerRef.current) {\n        abortControllerRef.current.abort()\n      }\n\n      if (!shouldTriggerMention(query)) {\n        setSearchState((prev) => ({\n          ...prev,\n          suggestions: [],\n          selectedIndex: -1,\n          isLoading: false,\n        }))\n        return\n      }\n\n      // Create new abort controller for this search\n      const abortController = new AbortController()\n      abortControllerRef.current = abortController\n\n      // Use transition to defer search as low-priority update\n      startTransition(() => {\n        setSearchState((prev) => ({ ...prev, isLoading: true }))\n      })\n\n      try {\n        const [mentionType, cleanQuery] = getMentionType(query)\n\n        const results = await onSearchRef.current(cleanQuery, mentionType, maxSuggestions)\n\n        // Check if this search was aborted\n        if (abortController.signal.aborted) {\n          return\n        }\n\n        startTransition(() => {\n          setSearchState({\n            suggestions: results,\n            selectedIndex: results.length > 0 ? 0 : -1,\n            isLoading: false,\n          })\n        })\n      } catch (error) {\n        // Check if this search was aborted\n        if (abortController.signal.aborted) {\n          return\n        }\n\n        console.error(\"Error searching mentions:\", error)\n        startTransition(() => {\n          setSearchState({\n            suggestions: [],\n            selectedIndex: -1,\n            isLoading: false,\n          })\n        })\n      }\n    },\n    [], // Empty deps array - we use refs to avoid dependency issues\n  )\n\n  const clearSuggestions = useCallback(() => {\n    setSearchState({\n      suggestions: [],\n      selectedIndex: -1,\n      isLoading: false,\n    })\n  }, [])\n\n  const setSelectedIndex = useCallback((index: number) => {\n    setSearchState((prev) => ({ ...prev, selectedIndex: index }))\n  }, [])\n\n  return {\n    ...searchState,\n    searchMentions,\n    clearSuggestions,\n    setSelectedIndex,\n    hasResults: searchState.suggestions.length > 0,\n    isLoading: searchState.isLoading || isPending,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearchService.ts",
    "content": "import { getViewList } from \"@follow/constants\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useFeedEntrySearchService } from \"~/modules/ai-chat/hooks/useFeedEntrySearchService\"\n\nimport type { MentionData, MentionType } from \"../types\"\nimport { getMentionTextValue } from \"../utils/mentionTextValue\"\nimport { createDateMentionBuilder, MAX_INLINE_DATE_SUGGESTIONS } from \"./dateMentionSearch\"\n\n/**\n * Hook that provides search functionality for mentions\n * Uses the shared feed/entry search service\n */\nexport const useMentionSearchService = () => {\n  const { t, i18n } = useTranslation(\"ai\")\n  const language = i18n.language || i18n.resolvedLanguage || \"en\"\n  const { search } = useFeedEntrySearchService()\n\n  const buildDateMentions = useMemo(() => createDateMentionBuilder({ t, language }), [t, language])\n\n  const searchMentions = useMemo(() => {\n    return async (\n      query: string,\n      type?: MentionType,\n      maxSuggestions = 10,\n    ): Promise<MentionData[]> => {\n      const trimmedQuery = query.trim()\n      const results: MentionData[] = []\n      const seen = new Set<string>()\n\n      const pushResult = (mention: MentionData) => {\n        const key = `${mention.type}:${String(mention.value)}`\n        if (!seen.has(key)) {\n          seen.add(key)\n          results.push(mention)\n        }\n      }\n\n      if (type === \"date\") {\n        buildDateMentions(trimmedQuery).forEach(pushResult)\n        return results\n      }\n\n      if (type === \"feed\" || type === \"entry\" || type === \"category\") {\n        const searchResults = search(trimmedQuery, type, maxSuggestions)\n        searchResults.forEach((item) =>\n          pushResult({\n            id: item.id,\n            name: item.title,\n            type: item.type,\n            value: item.id,\n            text: getMentionTextValue({\n              type: item.type,\n              value: item.id,\n            }),\n          }),\n        )\n        return results\n      }\n\n      const views = getViewList()\n      const lowerQuery = trimmedQuery.toLowerCase()\n\n      const firstView = views.find((view) => {\n        const viewName = t(view.name, { ns: \"common\" }).toLowerCase()\n        return viewName.includes(lowerQuery) || lowerQuery === \"\"\n      })\n\n      if (firstView) {\n        pushResult({\n          id: `view-${firstView.view}`,\n          name: t(firstView.name, { ns: \"common\" }),\n          type: \"view\",\n          value: firstView.view,\n          text: getMentionTextValue({\n            type: \"view\",\n            value: firstView.view,\n          }),\n        })\n      }\n\n      const dateSuggestions = buildDateMentions(trimmedQuery)\n      dateSuggestions.slice(0, MAX_INLINE_DATE_SUGGESTIONS).forEach(pushResult)\n\n      // Calculate remaining slots for search results\n      const remainingSlots = Math.max(0, maxSuggestions - results.length)\n\n      const searchResults = search(trimmedQuery, undefined, remainingSlots)\n      searchResults.forEach((item) =>\n        pushResult({\n          id: item.id,\n          name: item.title,\n          type: item.type,\n          value: item.id,\n          text: getMentionTextValue({\n            type: item.type,\n            value: item.id,\n          }),\n        }),\n      )\n\n      return results\n    }\n  }, [buildDateMentions, search, t])\n\n  return { searchMentions }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSelection.ts",
    "content": "import { useTypeaheadSelection } from \"../../shared/hooks/useTypeaheadSelection\"\nimport { MENTION_COMMAND } from \"../constants\"\nimport type { MentionData, MentionMatch } from \"../types\"\nimport { insertMentionNode } from \"../utils/textReplacement\"\n\ninterface UseMentionSelectionOptions {\n  mentionMatch: MentionMatch | null\n  onMentionInsert?: (mention: MentionData, nodeKey?: string) => void\n  onSelectionComplete?: () => void\n}\n\nexport const useMentionSelection = ({\n  mentionMatch,\n  onMentionInsert,\n  onSelectionComplete,\n}: UseMentionSelectionOptions) => {\n  const { selectItem } = useTypeaheadSelection<MentionMatch, MentionData>({\n    match: mentionMatch,\n    command: MENTION_COMMAND,\n    replaceWith: (item, match) => insertMentionNode(item, match),\n    onInsert: onMentionInsert,\n    onComplete: onSelectionComplete,\n  })\n\n  return { selectMention: selectItem }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionTrigger.ts",
    "content": "import type { LexicalEditor } from \"lexical\"\n\nimport { useTextTrigger } from \"../../shared/hooks/useTextTrigger\"\nimport type { MentionMatch } from \"../types\"\nimport { defaultTriggerFn } from \"../utils/triggerDetection\"\n\ninterface UseMentionTriggerOptions {\n  triggerFn?: (text: string, editor: LexicalEditor) => MentionMatch | null\n  onTrigger?: (match: MentionMatch | null) => void\n}\n\nexport const useMentionTrigger = ({\n  triggerFn = defaultTriggerFn,\n  onTrigger,\n}: UseMentionTriggerOptions = {}) => {\n  const { match, isActive, clear } = useTextTrigger({ triggerFn, onTrigger })\n  return { mentionMatch: match as MentionMatch | null, isActive, clearMentionMatch: clear }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/index.ts",
    "content": "// Public API exports\nexport { MentionComponent } from \"./components/MentionComponent\"\nexport { MentionDropdown } from \"./components/MentionDropdown\"\nexport { $createMentionNode, $isMentionNode, MentionNode } from \"./MentionNode\"\nexport { MentionPlugin } from \"./MentionPlugin\"\n\n// Commands\nexport { MENTION_COMMAND, MENTION_TYPEAHEAD_COMMAND } from \"./constants\"\n\n// Types\nexport type {\n  MentionData,\n  MentionDropdownPosition,\n  MentionMatch,\n  MentionSearchState,\n  MentionTriggerState,\n  MentionType,\n} from \"./types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/types.ts",
    "content": "import type { FeedViewType } from \"@follow-app/client-sdk\"\n\nexport type MentionLabelValue = string | number | boolean | MentionLabelDescriptor\n\nexport interface MentionLabelDescriptor {\n  key: I18nKeysForAi\n  values?: Record<string, MentionLabelValue>\n}\n\nexport interface MentionBaseData {\n  id: string\n  name: string\n  text: string\n  label?: MentionLabelDescriptor\n}\n\nexport interface EntryMentionData extends MentionBaseData {\n  type: \"entry\"\n  value: string\n}\n\nexport interface FeedMentionData extends MentionBaseData {\n  type: \"feed\"\n  value: string\n}\n\nexport interface DateMentionData extends MentionBaseData {\n  type: \"date\"\n  value: string\n  labelOptions?: {\n    appendRange?: boolean\n  }\n}\n\nexport interface CategoryMentionData extends MentionBaseData {\n  type: \"category\"\n  value: string\n}\n\nexport interface ViewMentionData extends MentionBaseData {\n  type: \"view\"\n  value: FeedViewType\n}\n\nexport type MentionData =\n  | EntryMentionData\n  | FeedMentionData\n  | DateMentionData\n  | CategoryMentionData\n  | ViewMentionData\n\nexport type MentionType = MentionData[\"type\"]\n\nexport interface MentionMatch {\n  leadOffset: number\n  matchingString: string\n  replaceableString: string\n}\n\nexport interface MentionDropdownPosition {\n  top: number\n  left: number\n}\n\nexport interface MentionSearchState {\n  suggestions: MentionData[]\n  selectedIndex: number\n  isLoading: boolean\n}\n\nexport interface MentionTriggerState {\n  mentionMatch: MentionMatch | null\n  isActive: boolean\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/utils/mentionTextValue.ts",
    "content": "import { getView } from \"@follow/constants\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { getCategoryFeedIds } from \"@follow/store/subscription/getter\"\n\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { getI18n } from \"~/i18n\"\n\nimport type { LabelTranslator } from \"../hooks/dateMentionUtils\"\nimport { getDateMentionDisplayName } from \"../hooks/dateMentionUtils\"\nimport type { MentionData } from \"../types\"\n\nexport function getMentionTextValue(mentionData: {\n  type: MentionData[\"type\"]\n  value: MentionData[\"value\"]\n}): string {\n  const { type, value } = mentionData\n\n  if (type === \"date\" && value) {\n    return value as string\n  }\n\n  if (type === \"category\" && typeof value === \"string\" && value.startsWith(ROUTE_FEED_IN_FOLDER)) {\n    const { view } = getRouteParams()\n    const ids = getCategoryFeedIds(value.slice(ROUTE_FEED_IN_FOLDER.length), view)\n    return `<mention-feed ids=${JSON.stringify(ids)}></mention-feed>`\n  }\n\n  return `<mention-${type} id=\"${value}\"></mention-${type}>`\n}\n\nexport function getMentionDisplayTextValue(\n  mentionData: MentionData,\n  translate: LabelTranslator,\n  locale: string,\n): string {\n  const { type, value } = mentionData\n\n  switch (type) {\n    case \"category\": {\n      if (typeof value === \"string\" && value.startsWith(ROUTE_FEED_IN_FOLDER)) {\n        const { view } = getRouteParams()\n        const ids = getCategoryFeedIds(value.slice(ROUTE_FEED_IN_FOLDER.length), view)\n        const feedNames = ids.map((id) => getFeedById(id)?.title).join(\", \")\n        return feedNames\n      }\n      return \"Unknown Category\"\n    }\n\n    case \"view\": {\n      const viewDef = getView(value)\n      const viewKey = viewDef?.name\n\n      if (viewKey) {\n        return getI18n().t(viewKey, { ns: \"common\" })\n      }\n      return \"Unknown View\"\n    }\n\n    case \"date\": {\n      return getDateMentionDisplayName(mentionData, translate, locale, true)\n    }\n    default: {\n      return value\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/utils/parseNaturalLanguageDate.ts",
    "content": "import * as chrono from \"chrono-node\"\nimport dayjs from \"dayjs\"\n\nimport { RELATIVE_DATE_DEFINITIONS } from \"../hooks/dateMentionConfig\"\nimport type { DateRange } from \"../hooks/dateMentionUtils\"\n\nconst getChronoParser = (language: string) => {\n  if (language === \"zh-CN\") {\n    return chrono.zh.hans\n  }\n  if (language === \"zh-TW\") {\n    return chrono.zh.hant\n  }\n  if (language === \"ja\") {\n    return chrono.ja\n  }\n  return chrono.en\n}\n\nexport const parseNaturalLanguageDate = (query: string, language: string): DateRange | null => {\n  if (!query.trim()) return null\n\n  try {\n    const parser = getChronoParser(language)\n    let parsed = parser.parse(query)\n    if ((!parsed || parsed.length === 0) && parser !== chrono.en) {\n      parsed = chrono.en.parse(query)\n    }\n\n    if (!parsed || parsed.length === 0) return null\n    const result = parsed[0]\n    if (!result) return null\n\n    const start = dayjs(result.start.date())\n    const end = result.end ? dayjs(result.end.date()) : dayjs()\n\n    if (!start.isValid() || !end.isValid()) return null\n    if (start.isAfter(end)) {\n      return { start: end, end: start }\n    }\n    return { start, end }\n  } catch {\n    return null\n  }\n}\n\nexport const parseDateRangeById = (id: string | undefined, language: string): DateRange | null => {\n  if (!id) return null\n\n  const relativeDef = RELATIVE_DATE_DEFINITIONS.find((def) => def.id === id)\n  if (relativeDef) {\n    const today = dayjs().startOf(\"day\")\n    return relativeDef.range(today)\n  }\n\n  return parseNaturalLanguageDate(id, language)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/utils/textReplacement.ts",
    "content": "import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode } from \"lexical\"\n\nimport { $createMentionNode } from \"../MentionNode\"\nimport type { MentionData, MentionMatch } from \"../types\"\n\nexport const insertMentionNode = (\n  mentionData: MentionData,\n  mentionMatch: MentionMatch,\n): { success: boolean; nodeKey?: string } => {\n  const selection = $getSelection()\n  if (!$isRangeSelection(selection) || !selection.isCollapsed()) return { success: false }\n\n  const { anchor } = selection\n  const anchorNode = anchor.getNode()\n\n  if (!$isTextNode(anchorNode)) return { success: false }\n\n  // Replace the mention text with the mention node\n  const textContent = anchorNode.getTextContent()\n  const { leadOffset, replaceableString } = mentionMatch\n\n  // Split the text node\n  const beforeText = textContent.slice(0, leadOffset)\n  const afterText = textContent.slice(leadOffset + replaceableString.length)\n\n  // Create new nodes\n  const beforeNode = beforeText ? $createTextNode(beforeText) : null\n  const mentionNode = $createMentionNode(mentionData)\n  const afterNode = afterText ? $createTextNode(afterText) : null\n\n  // Replace the current node\n  if (beforeNode) {\n    anchorNode.insertBefore(beforeNode)\n  }\n  anchorNode.insertBefore(mentionNode)\n  if (afterNode) {\n    anchorNode.insertBefore(afterNode)\n  }\n\n  // Remove the original node\n  anchorNode.remove()\n\n  // Position cursor after the mention\n  if (afterNode) {\n    afterNode.select(0, 0)\n  } else {\n    // Create a space after the mention if there's no following text\n    const spaceNode = $createTextNode(\" \")\n    mentionNode.insertAfter(spaceNode)\n    spaceNode.select(1, 1)\n  }\n\n  return { success: true, nodeKey: mentionNode.getKey() }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/utils/triggerDetection.ts",
    "content": "import { $getSelection, $isRangeSelection, $isTextNode } from \"lexical\"\n\nimport { MENTION_TRIGGER_PATTERN } from \"../constants\"\nimport type { MentionMatch, MentionType } from \"../types\"\n\nexport const defaultTriggerFn = (): MentionMatch | null => {\n  const selection = $getSelection()\n  if (!$isRangeSelection(selection) || !selection.isCollapsed()) {\n    return null\n  }\n\n  const { anchor } = selection\n  const { focus } = selection\n  const anchorNode = anchor.getNode()\n\n  // Only trigger on text nodes\n  if (!$isTextNode(anchorNode) || anchor.key !== focus.key || anchor.offset !== focus.offset) {\n    return null\n  }\n\n  const textContent = anchorNode.getTextContent()\n  const cursorOffset = anchor.offset\n\n  // Look for @ symbol followed by text\n  const mentionMatch = textContent.slice(0, cursorOffset).match(MENTION_TRIGGER_PATTERN)\n\n  if (!mentionMatch) {\n    return null\n  }\n\n  const matchingString = mentionMatch[1] || \"\"\n  const replaceableString = matchingString\n  const leadOffset = (mentionMatch.index ?? 0) + (mentionMatch[0]?.startsWith(\" \") ? 1 : 0)\n\n  return {\n    leadOffset,\n    matchingString,\n    replaceableString,\n  }\n}\n\nexport const getMentionType = (query: string): [MentionType | undefined, string] => {\n  // Simple heuristic - could be enhanced with more sophisticated detection\n  if (query.startsWith(\"@#\")) return [\"feed\", query.slice(2)]\n  if (query.startsWith(\"@+\")) return [\"entry\", query.slice(2)]\n  if (query.startsWith(\"@!\")) return [\"date\", query.slice(2)]\n  // Return undefined for general @ trigger to search both types\n  return [undefined, query.slice(1)]\n}\n\nexport const cleanQuery = (query: string): string => {\n  return query.replace(/^@[#+!]?/, \"\").trim()\n}\n\nexport const shouldTriggerMention = (query: string): boolean => {\n  return query.startsWith(\"@\") && query.length > 0\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNode.tsx",
    "content": "import type {\n  DOMConversionMap,\n  DOMExportOutput,\n  LexicalEditor,\n  LexicalNode,\n  NodeKey,\n  SerializedLexicalNode,\n  Spread,\n} from \"lexical\"\nimport { DecoratorNode } from \"lexical\"\nimport * as React from \"react\"\n\nimport { SelectedTextNodeComponent } from \"./SelectedTextNodeComponent\"\n\nexport type SelectedTextNodePayload = {\n  text: string\n  sourceEntryId?: string\n  timestamp?: number\n}\n\nexport type SerializedSelectedTextNode = Spread<SelectedTextNodePayload, SerializedLexicalNode>\n\nexport class SelectedTextNode extends DecoratorNode<React.JSX.Element> {\n  __text: string\n  __sourceEntryId?: string\n  __timestamp?: number\n\n  static override getType(): string {\n    return \"selected-text\"\n  }\n\n  static override clone(node: SelectedTextNode): SelectedTextNode {\n    return new SelectedTextNode(node.__text, node.__sourceEntryId, node.__timestamp, node.__key)\n  }\n\n  constructor(text: string, sourceEntryId?: string, timestamp?: number, key?: NodeKey) {\n    super(key)\n    this.__text = text\n    this.__sourceEntryId = sourceEntryId\n    this.__timestamp = timestamp\n  }\n\n  getText(): string {\n    return this.__text\n  }\n\n  setText(text: string): void {\n    const writable = this.getWritable()\n    writable.__text = text\n  }\n\n  getSourceEntryId(): string | undefined {\n    return this.__sourceEntryId\n  }\n\n  getTimestamp(): number | undefined {\n    return this.__timestamp\n  }\n\n  override createDOM(): HTMLElement {\n    const div = document.createElement(\"div\")\n    div.dataset.selectedTextNode = \"true\"\n    return div\n  }\n\n  override updateDOM(): false {\n    return false\n  }\n\n  static override importDOM(): DOMConversionMap | null {\n    return null\n  }\n\n  static override importJSON(serializedNode: SerializedSelectedTextNode): SelectedTextNode {\n    const { text, sourceEntryId, timestamp } = serializedNode\n    return $createSelectedTextNode({ text, sourceEntryId, timestamp })\n  }\n\n  override exportJSON(): SerializedSelectedTextNode {\n    return {\n      text: this.__text,\n      sourceEntryId: this.__sourceEntryId,\n      timestamp: this.__timestamp,\n      type: \"selected-text\",\n      version: 1,\n    }\n  }\n\n  override exportDOM(): DOMExportOutput {\n    const element = document.createElement(\"div\")\n    element.dataset.selectedTextNode = \"true\"\n    element.textContent = this.__text\n    return { element }\n  }\n\n  override decorate(_editor: LexicalEditor): React.JSX.Element {\n    return (\n      <SelectedTextNodeComponent\n        text={this.__text}\n        sourceEntryId={this.__sourceEntryId}\n        timestamp={this.__timestamp}\n      />\n    )\n  }\n\n  override isInline(): boolean {\n    return false\n  }\n\n  override isKeyboardSelectable(): boolean {\n    return false\n  }\n\n  override getTextContent(): string {\n    return `<user-selection>${escapeXML(this.__text)}</user-selection>`\n  }\n}\n\nexport function $createSelectedTextNode(payload: SelectedTextNodePayload): SelectedTextNode {\n  return new SelectedTextNode(payload.text, payload.sourceEntryId, payload.timestamp)\n}\n\nexport function $isSelectedTextNode(\n  node: LexicalNode | null | undefined,\n): node is SelectedTextNode {\n  return node instanceof SelectedTextNode\n}\nfunction escapeXML(text: string): string {\n  return text\n    .replaceAll(\"&\", \"&amp;\")\n    .replaceAll(\"<\", \"&lt;\")\n    .replaceAll(\">\", \"&gt;\")\n    .replaceAll('\"', \"&quot;\")\n    .replaceAll(\"'\", \"&#39;\")\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNodeComponent.tsx",
    "content": "import { cn } from \"@follow/utils\"\n\ninterface SelectedTextNodeComponentProps {\n  text: string\n  sourceEntryId?: string\n  timestamp?: number\n}\n\nexport function SelectedTextNodeComponent({ text }: SelectedTextNodeComponentProps) {\n  return (\n    <span\n      className={cn(\n        \"relative select-none rounded-md border px-2 py-1 text-sm font-medium transition-colors\",\n        \"border-blue/20 bg-blue/10 text-blue\",\n        \"hover:border-blue/30 hover:bg-blue/20\",\n        \"mb-2 flex items-start\",\n      )}\n    >\n      <i className=\"i-mingcute-text-2-line size-4 shrink-0 translate-y-0.5\" />\n      <span className=\"ml-2 line-clamp-3 max-w-full whitespace-pre-wrap\" title={text}>\n        \"{text}\"\n      </span>\n    </span>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextPlugin.tsx",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport { useEffect } from \"react\"\n\nimport { insertSelectedTextNode } from \"./insertSelectedTextNode\"\nimport { subscribeSelectedTextInsertion } from \"./selectedTextBridge\"\nimport { SelectedTextNode } from \"./SelectedTextNode\"\n\nexport function SelectedTextPlugin() {\n  const [editor] = useLexicalComposerContext()\n\n  useEffect(() => {\n    return subscribeSelectedTextInsertion((payload) => {\n      editor.focus()\n      insertSelectedTextNode(editor, payload)\n    })\n  }, [editor])\n\n  return null\n}\n\nSelectedTextPlugin.id = \"selected-text\"\nSelectedTextPlugin.nodes = [SelectedTextNode]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/index.ts",
    "content": "export * from \"./insertSelectedTextNode\"\nexport * from \"./selectedTextBridge\"\nexport * from \"./SelectedTextNode\"\nexport * from \"./SelectedTextNodeComponent\"\nexport * from \"./SelectedTextPlugin\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/insertSelectedTextNode.ts",
    "content": "import type { LexicalEditor } from \"lexical\"\nimport {\n  $createParagraphNode,\n  $createTextNode,\n  $getRoot,\n  $getSelection,\n  $isRangeSelection,\n} from \"lexical\"\n\nimport type { SelectedTextNodePayload } from \"./SelectedTextNode\"\nimport { $createSelectedTextNode } from \"./SelectedTextNode\"\n\nexport function insertSelectedTextNode(editor: LexicalEditor, payload: SelectedTextNodePayload) {\n  editor.update(() => {\n    let selection = $getSelection()\n\n    if (!$isRangeSelection(selection)) {\n      const root = $getRoot()\n      const paragraph = $createParagraphNode()\n      root.append(paragraph)\n      paragraph.selectEnd()\n      selection = $getSelection()\n    }\n\n    if (!selection) return\n    const selectedNode = $createSelectedTextNode(payload)\n    selection.insertNodes([selectedNode, $createTextNode(\" \")])\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/selectedTextBridge.ts",
    "content": "import type { SelectedTextNodePayload } from \"./SelectedTextNode\"\n\ntype Listener = (payload: SelectedTextNodePayload) => void\n\nconst listeners = new Set<Listener>()\nlet pendingPayload: SelectedTextNodePayload | null = null\n\nexport function queueSelectedTextInsertion(payload: SelectedTextNodePayload) {\n  pendingPayload = payload\n  if (listeners.size === 0) return\n\n  for (const listener of listeners) {\n    listener(payload)\n  }\n  pendingPayload = null\n}\n\nexport function subscribeSelectedTextInsertion(listener: Listener) {\n  listeners.add(listener)\n\n  if (pendingPayload) {\n    listener(pendingPayload)\n    pendingPayload = null\n  }\n\n  return () => {\n    listeners.delete(listener)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/MentionLikePill.tsx",
    "content": "import { clsx, cn } from \"@follow/utils\"\nimport * as React from \"react\"\n\nexport interface MentionLikePillProps extends React.HTMLAttributes<HTMLSpanElement> {\n  icon?: React.ReactNode\n  variant?: \"mention\" | \"command\"\n  prefix?: string\n}\n\nexport const MentionLikePill = ({\n  className,\n  icon,\n  children,\n  variant = \"mention\",\n  prefix,\n  ref,\n  ...rest\n}: MentionLikePillProps & {\n  ref?: React.RefObject<HTMLSpanElement>\n}) => {\n  const baseStyles =\n    \"relative inline-flex -translate-y-px select-none items-center rounded-md px-2 py-0.5 text-xs font-medium transition-colors\"\n\n  const variantStyles = {\n    mention: \"border-[0.5px] bg-fill-secondary hover:bg-fill\",\n    command: cn(\n      \"border border-fill font-mono\",\n      \"bg-fill-secondary/50 hover:bg-fill-secondary\",\n      \"text-text-secondary hover:text-text\",\n    ),\n  }\n\n  return (\n    <span ref={ref} className={cn(baseStyles, variantStyles[variant], className)} {...rest}>\n      {prefix && variant === \"command\" ? (\n        <span className=\"mr-0.5 text-[10px] text-text-tertiary opacity-60\">{prefix}</span>\n      ) : null}\n      {icon ? (\n        <span\n          className={cn(\n            \"flex items-center justify-center\",\n            variant === \"command\" ? \"mr-1 size-3\" : \"absolute left-0.5 top-0 size-5\",\n          )}\n        >\n          {icon}\n        </span>\n      ) : null}\n      <span className={clsx(\"truncate text-xs\", variant === \"mention\" && icon && \"ml-3.5\")}>\n        {children}\n      </span>\n    </span>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/TypeaheadDropdown.tsx",
    "content": "import {\n  autoUpdate,\n  flip,\n  offset,\n  shift,\n  useDismiss,\n  useFloating,\n  useInteractions,\n  useRole,\n} from \"@floating-ui/react\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { cn, thenable } from \"@follow/utils\"\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport * as React from \"react\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { calculateDropdownPosition } from \"../utils/positioning\"\n\nexport interface TypeaheadGroup<TItem, TGroupKey = string> {\n  key: TGroupKey\n  items: TItem[]\n}\n\nexport interface TypeaheadDropdownProps<TItem, TGroupKey = string> {\n  isVisible: boolean\n  items: TItem[] | TypeaheadGroup<TItem, TGroupKey>[]\n  selectedIndex: number\n  isLoading: boolean\n  onSelect: (item: TItem) => void\n  onSetSelectIndex: (index: number) => void\n  onClose: () => void\n  query: string\n  ariaLabel: string\n  renderItem: (\n    item: TItem,\n    index: number,\n    isSelected: boolean,\n    handlers: { onMouseMove: () => void; onClick: () => void },\n  ) => React.ReactNode\n  getKey: (item: TItem) => string\n  loadingMessage?: string\n  emptyMessage?: string\n  emptyHint?: string\n  anchor?: HTMLElement | null\n  showSearchInput?: boolean\n  onQueryChange?: (query: string) => void\n  // Group support\n  renderGroupHeader?: (groupKey: TGroupKey) => React.ReactNode\n}\n\nfunction useOptionalLexicalEditor() {\n  try {\n    const [editor] = useLexicalComposerContext()\n    return editor\n  } catch {\n    return null\n  }\n}\n\nexport function TypeaheadDropdown<TItem, TGroupKey = string>({\n  isVisible,\n  items,\n  selectedIndex,\n  isLoading,\n  onSelect,\n  onSetSelectIndex,\n  onClose,\n  query,\n  ariaLabel,\n  renderItem,\n  getKey,\n  loadingMessage = \"Searching...\",\n  emptyMessage = \"No matches found\",\n  emptyHint = \"Try a different search term\",\n  anchor,\n  showSearchInput = false,\n  onQueryChange,\n  renderGroupHeader,\n}: TypeaheadDropdownProps<TItem, TGroupKey>) {\n  if (!isVisible) throw thenable\n\n  const editor = useOptionalLexicalEditor()\n  const dropdownRef = useRef<HTMLDivElement>(null)\n  const [referenceWidth, setReferenceWidth] = useState<number>(320)\n\n  // Check if items are grouped\n  const isGrouped =\n    items.length > 0 && typeof items[0] === \"object\" && items[0] !== null && \"key\" in items[0]\n\n  // Flatten grouped items for selection logic\n  const flatItems = useMemo(() => {\n    if (!isGrouped) return items as TItem[]\n    return (items as TypeaheadGroup<TItem, TGroupKey>[]).flatMap((group) => group.items)\n  }, [items, isGrouped])\n\n  const virtualReference = useRef({\n    getBoundingClientRect: () => {\n      // If anchor is provided, use it\n      if (anchor) {\n        return anchor.getBoundingClientRect()\n      }\n\n      if (!editor) {\n        return {\n          top: 0,\n          left: 0,\n          bottom: 0,\n          right: 0,\n          width: 0,\n          height: 0,\n          x: 0,\n          y: 0,\n        }\n      }\n\n      const position = calculateDropdownPosition(editor)\n      const editorElement = editor.getRootElement()\n\n      if (!position || !editorElement) {\n        return (\n          editorElement?.getBoundingClientRect() || {\n            top: 0,\n            left: 0,\n            bottom: 0,\n            right: 0,\n            width: 0,\n            height: 0,\n            x: 0,\n            y: 0,\n          }\n        )\n      }\n\n      const editorRect = editorElement.getBoundingClientRect()\n\n      return {\n        top: editorRect.top + position.top,\n        left: editorRect.left + position.left,\n        bottom: editorRect.top + position.top,\n        right: editorRect.left + position.left,\n        width: 0,\n        height: 0,\n        x: editorRect.left + position.left,\n        y: editorRect.top + position.top,\n      }\n    },\n  })\n\n  const { refs, floatingStyles, context } = useFloating({\n    open: isVisible,\n    onOpenChange: (open) => {\n      if (!open) onClose()\n    },\n    elements: {\n      reference: anchor,\n    },\n    middleware: [\n      offset(8),\n      flip({ fallbackPlacements: [\"bottom-start\", \"top-start\", \"bottom-end\", \"top-end\"] }),\n      shift({ padding: 8 }),\n    ],\n    whileElementsMounted: autoUpdate,\n    placement: \"bottom-start\",\n  })\n\n  const dismiss = useDismiss(context, {\n    enabled: isVisible,\n  })\n\n  const role = useRole(context, {\n    role: \"listbox\",\n  })\n\n  const { getFloatingProps } = useInteractions([dismiss, role])\n\n  useEffect(() => {\n    if (isVisible && dropdownRef.current && selectedIndex >= 0) {\n      const listContainer = dropdownRef.current.querySelector('[role=\"listbox\"]')\n      if (listContainer) {\n        const selectedElement = listContainer.children[selectedIndex] as HTMLElement\n        if (selectedElement) {\n          selectedElement.scrollIntoView({\n            block: \"nearest\",\n            behavior: \"smooth\",\n          })\n        }\n      }\n    }\n  }, [selectedIndex, isVisible])\n\n  useEffect(() => {\n    if (isVisible) {\n      refs.setReference(virtualReference.current)\n\n      const editorElement = editor?.getRootElement()\n      if (editorElement) {\n        const rect = editorElement.getBoundingClientRect()\n        setReferenceWidth(rect.width || 320)\n      }\n    }\n  }, [editor, refs, isVisible, query])\n\n  const content = useMemo(() => {\n    if (isLoading) {\n      return (\n        <div className=\"flex items-center gap-2 px-2.5 py-1.5 text-text-secondary\">\n          <i className=\"i-mgc-loading-3-cute-re size-4 animate-spin\" />\n          <span className=\"text-sm\">{loadingMessage}</span>\n        </div>\n      )\n    }\n\n    const totalItems = isGrouped ? flatItems.length : items.length\n    if (totalItems === 0) {\n      return (\n        <div className=\"px-2.5 py-1.5 text-center text-text-tertiary\">\n          <span className=\"text-sm\">{emptyMessage}</span>\n          {query && <div className=\"mt-1 text-xs text-text-quaternary\">{emptyHint}</div>}\n        </div>\n      )\n    }\n\n    if (!isGrouped) {\n      // Render flat list\n      return (\n        <div role=\"listbox\" aria-label={ariaLabel}>\n          {(items as TItem[]).map((item, index) => {\n            const isSelected = index === selectedIndex\n            const handlers = {\n              onMouseMove: () => onSetSelectIndex(index),\n              onClick: () => onSelect(item),\n            }\n            return (\n              <React.Fragment key={getKey(item)}>\n                {renderItem(item, index, isSelected, handlers)}\n              </React.Fragment>\n            )\n          })}\n        </div>\n      )\n    }\n\n    // Render grouped list\n    let itemIndex = 0\n    return (\n      <div role=\"listbox\" aria-label={ariaLabel}>\n        {(items as TypeaheadGroup<TItem, TGroupKey>[]).map((group) => (\n          <React.Fragment key={String(group.key)}>\n            {renderGroupHeader && renderGroupHeader(group.key)}\n            {group.items.map((item) => {\n              const currentIndex = itemIndex\n              itemIndex++\n              const isSelected = currentIndex === selectedIndex\n              const handlers = {\n                onMouseMove: () => onSetSelectIndex(currentIndex),\n                onClick: () => onSelect(item),\n              }\n              return (\n                <React.Fragment key={getKey(item)}>\n                  {renderItem(item, currentIndex, isSelected, handlers)}\n                </React.Fragment>\n              )\n            })}\n          </React.Fragment>\n        ))}\n      </div>\n    )\n  }, [\n    isLoading,\n    items,\n    selectedIndex,\n    renderItem,\n    getKey,\n    ariaLabel,\n    query,\n    loadingMessage,\n    emptyMessage,\n    emptyHint,\n    onSetSelectIndex,\n    onSelect,\n    isGrouped,\n    flatItems,\n    renderGroupHeader,\n  ])\n\n  return (\n    <RootPortal>\n      {isVisible && (\n        <div\n          ref={refs.setFloating}\n          style={floatingStyles}\n          className=\"z-[1000]\"\n          {...getFloatingProps()}\n        >\n          <div\n            ref={dropdownRef}\n            className={cn(\n              \"shadow-context-menu bg-material-medium text-text backdrop-blur-background\",\n              \"min-w-32 overflow-hidden rounded-[6px] border p-1\",\n              \"text-body\",\n            )}\n            style={{\n              width: anchor ? 320 : Math.max(referenceWidth, 200),\n              maxWidth: 320,\n            }}\n          >\n            {showSearchInput && onQueryChange && (\n              <div className=\"-mx-1 mb-1 border-b border-border px-3.5 pb-1.5 pt-1\">\n                <input\n                  type=\"text\"\n                  value={query}\n                  onChange={(e) => onQueryChange(e.target.value)}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\") {\n                      const suggestion = flatItems[selectedIndex] || flatItems[0]\n                      if (suggestion) {\n                        e.preventDefault()\n                        onSelect(suggestion)\n                      }\n                    }\n                  }}\n                  placeholder=\"Search for context...\"\n                  autoFocus\n                  className=\"w-full bg-transparent text-sm text-text outline-none placeholder:text-text-quaternary\"\n                />\n              </div>\n            )}\n            {content}\n          </div>\n        </div>\n      )}\n    </RootPortal>\n  )\n}\n\nTypeaheadDropdown.displayName = \"TypeaheadDropdown\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/index.ts",
    "content": "export * from \"./MentionLikePill\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/hooks/useListKeyboardNavigation.ts",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport {\n  COMMAND_PRIORITY_HIGH,\n  COMMAND_PRIORITY_LOW,\n  KEY_ARROW_DOWN_COMMAND,\n  KEY_ARROW_UP_COMMAND,\n  KEY_ENTER_COMMAND,\n  KEY_ESCAPE_COMMAND,\n  KEY_TAB_COMMAND,\n} from \"lexical\"\nimport { useCallback, useEffect } from \"react\"\n\ninterface UseListKeyboardNavigationOptions {\n  isActive: boolean\n  itemCount: number\n  selectedIndex: number\n  onMove: (isUp: boolean) => void\n  onConfirm: () => void\n  onCancel: () => void\n}\n\nexport const useListKeyboardNavigation = ({\n  isActive,\n  itemCount,\n  selectedIndex,\n  onMove,\n  onConfirm,\n  onCancel,\n}: UseListKeyboardNavigationOptions) => {\n  const [editor] = useLexicalComposerContext()\n\n  const handleMove = useCallback(\n    (isUp: boolean) => {\n      if (!isActive || itemCount === 0) return false\n      onMove(isUp)\n      return true\n    },\n    [isActive, itemCount, onMove],\n  )\n\n  const handleConfirm = useCallback(() => {\n    if (!isActive || itemCount === 0 || selectedIndex < 0 || selectedIndex >= itemCount) {\n      return false\n    }\n    onConfirm()\n    return true\n  }, [isActive, itemCount, selectedIndex, onConfirm])\n\n  const handleCancel = useCallback(() => {\n    if (!isActive) return false\n    onCancel()\n    return true\n  }, [isActive, onCancel])\n\n  useEffect(() => {\n    const remove = [\n      editor.registerCommand(\n        KEY_ARROW_UP_COMMAND,\n        (event) => {\n          if (isActive && itemCount > 0) {\n            event.preventDefault()\n            return handleMove(true)\n          }\n          return false\n        },\n        COMMAND_PRIORITY_LOW,\n      ),\n      editor.registerCommand(\n        KEY_ARROW_DOWN_COMMAND,\n        (event) => {\n          if (isActive && itemCount > 0) {\n            event.preventDefault()\n            return handleMove(false)\n          }\n          return false\n        },\n        COMMAND_PRIORITY_LOW,\n      ),\n      editor.registerCommand(\n        KEY_ENTER_COMMAND,\n        (event) => {\n          if (isActive && itemCount > 0 && selectedIndex >= 0 && selectedIndex < itemCount) {\n            event?.preventDefault()\n            return handleConfirm()\n          }\n          return false\n        },\n        COMMAND_PRIORITY_HIGH,\n      ),\n      editor.registerCommand(\n        KEY_TAB_COMMAND,\n        (event) => {\n          if (isActive && itemCount > 0 && selectedIndex >= 0 && selectedIndex < itemCount) {\n            event.preventDefault()\n            return handleConfirm()\n          }\n          return false\n        },\n        COMMAND_PRIORITY_HIGH,\n      ),\n      editor.registerCommand(\n        KEY_ESCAPE_COMMAND,\n        (event) => {\n          if (isActive) {\n            event.preventDefault()\n            return handleCancel()\n          }\n          return false\n        },\n        COMMAND_PRIORITY_LOW,\n      ),\n    ]\n\n    return () => remove.forEach((fn) => fn())\n  }, [editor, isActive, itemCount, selectedIndex, handleMove, handleConfirm, handleCancel])\n\n  return { handleMove, handleConfirm, handleCancel }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/hooks/useTextTrigger.ts",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport type { LexicalEditor } from \"lexical\"\nimport { $getSelection, $isRangeSelection, $isTextNode } from \"lexical\"\nimport { useCallback, useEffect, useState } from \"react\"\n\nexport interface TriggerMatch {\n  leadOffset: number\n  matchingString: string\n  replaceableString: string\n}\n\nexport interface UseTextTriggerOptions {\n  triggerFn: (text: string, editor: LexicalEditor) => TriggerMatch | null\n  onTrigger?: (match: TriggerMatch | null) => void\n}\n\nexport const useTextTrigger = ({ triggerFn, onTrigger }: UseTextTriggerOptions) => {\n  const [editor] = useLexicalComposerContext()\n  const [match, setMatch] = useState<TriggerMatch | null>(null)\n\n  const update = useCallback(\n    (m: TriggerMatch | null) => {\n      setMatch(m)\n      onTrigger?.(m)\n    },\n    [onTrigger],\n  )\n\n  const clear = useCallback(() => update(null), [update])\n\n  useEffect(() => {\n    const remove = editor.registerUpdateListener(({ editorState }) => {\n      editorState.read(() => {\n        const selection = $getSelection()\n        if (!$isRangeSelection(selection) || !selection.isCollapsed()) {\n          update(null)\n          return\n        }\n        const { anchor } = selection\n        const anchorNode = anchor.getNode()\n        if (!$isTextNode(anchorNode)) {\n          update(null)\n          return\n        }\n        const textContent = anchorNode.getTextContent()\n        const m = triggerFn(textContent, editor)\n        update(m)\n      })\n    })\n    return remove\n  }, [editor, triggerFn, update])\n\n  return { match, isActive: match !== null, clear }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/hooks/useTypeaheadSelection.ts",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport type { LexicalCommand } from \"lexical\"\nimport { COMMAND_PRIORITY_LOW } from \"lexical\"\nimport { useCallback, useEffect } from \"react\"\n\nexport interface ReplaceHandlerResult {\n  success: boolean\n  nodeKey?: string\n}\n\nexport interface UseTypeaheadSelectionOptions<TMatch, TItem> {\n  match: TMatch | null\n  command: string | LexicalCommand<TItem>\n  replaceWith: (item: TItem, match: TMatch) => ReplaceHandlerResult\n  onInsert?: (item: TItem, nodeKey?: string) => void\n  onComplete?: () => void\n}\n\nexport const useTypeaheadSelection = <TMatch, TItem>({\n  match,\n  command,\n  replaceWith,\n  onInsert,\n  onComplete,\n}: UseTypeaheadSelectionOptions<TMatch, TItem>) => {\n  const [editor] = useLexicalComposerContext()\n\n  const selectItem = useCallback(\n    (item: TItem) => {\n      if (!match) return false\n\n      let result: ReplaceHandlerResult = { success: false, nodeKey: undefined }\n      editor.update(() => {\n        result = replaceWith(item, match)\n        if (result.success && result.nodeKey) onInsert?.(item, result.nodeKey)\n      })\n      if (result.success) {\n        setTimeout(() => onComplete?.(), 0)\n      }\n      return result.success\n    },\n    [editor, match, onInsert, onComplete, replaceWith],\n  )\n\n  useEffect(() => {\n    const remove = editor.registerCommand(\n      // Support both LexicalCommand and string keys (cast for string case)\n      command as unknown as any,\n      (item: TItem) => selectItem(item),\n      COMMAND_PRIORITY_LOW,\n    )\n    return remove\n  }, [editor, command, selectItem])\n\n  return { selectItem }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/utils/positioning.ts",
    "content": "import type { LexicalEditor } from \"lexical\"\n\nimport type { MentionDropdownPosition } from \"../../mention/types\"\n\nexport const calculateDropdownPosition = (\n  editor: LexicalEditor,\n): MentionDropdownPosition | null => {\n  const selection = window.getSelection()\n  if (!selection || selection.rangeCount === 0) return null\n\n  const range = selection.getRangeAt(0)\n  const rect = range.getBoundingClientRect()\n  const editorElement = editor.getRootElement()\n\n  if (!editorElement) return null\n\n  const editorRect = editorElement.getBoundingClientRect()\n\n  return {\n    top: rect.bottom - editorRect.top + 8, // 8px offset below cursor\n    left: rect.left - editorRect.left,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/ShortcutNode.tsx",
    "content": "import type {\n  DOMConversionMap,\n  DOMExportOutput,\n  EditorConfig,\n  LexicalEditor,\n  LexicalNode,\n  NodeKey,\n  SerializedLexicalNode,\n  Spread,\n} from \"lexical\"\nimport { $applyNodeReplacement, DecoratorNode } from \"lexical\"\nimport * as React from \"react\"\n\nimport { ShortcutComponent } from \"./components/ShortcutComponent\"\nimport type { ShortcutData } from \"./types\"\nimport { getShortcutTextValue } from \"./utils/shortcutTextValue\"\n\nexport type SerializedShortcutNode = Spread<\n  {\n    shortcutData: ShortcutData\n  },\n  SerializedLexicalNode\n>\n\nexport class ShortcutNode extends DecoratorNode<React.JSX.Element> {\n  __shortcutData: ShortcutData\n\n  static override getType(): string {\n    return \"shortcut\"\n  }\n\n  static override clone(node: ShortcutNode): ShortcutNode {\n    return new ShortcutNode(node.__shortcutData, node.__key)\n  }\n\n  constructor(shortcutData: ShortcutData, key?: NodeKey) {\n    super(key)\n    this.__shortcutData = shortcutData\n  }\n\n  getShortcutData(): ShortcutData {\n    return this.__shortcutData\n  }\n\n  setShortcutData(shortcutData: ShortcutData): void {\n    const writable = this.getWritable()\n    writable.__shortcutData = shortcutData\n  }\n\n  override createDOM(config: EditorConfig): HTMLElement {\n    const dom = document.createElement(\"span\")\n    dom.className = config.theme.mention || \"shortcut-node\"\n    dom.dataset.lexicalShortcut = \"true\"\n    dom.dataset.shortcutId = this.__shortcutData.id\n    return dom\n  }\n\n  override updateDOM(): false {\n    return false\n  }\n\n  static override importDOM(): DOMConversionMap | null {\n    return {\n      span: () => {\n        throw new Error(\"Not implemented\")\n      },\n    }\n  }\n\n  static override importJSON(serializedNode: SerializedShortcutNode): ShortcutNode {\n    const { shortcutData } = serializedNode\n    return $createShortcutNode(shortcutData)\n  }\n\n  override exportDOM(): DOMExportOutput {\n    const element = document.createElement(\"span\")\n    element.dataset.lexicalShortcut = \"true\"\n    element.dataset.shortcutId = this.__shortcutData.id\n    element.textContent = `/${this.__shortcutData.name}`\n    element.className = \"shortcut-node\"\n    return { element }\n  }\n\n  override exportJSON(): SerializedShortcutNode {\n    return {\n      shortcutData: this.__shortcutData,\n      type: \"shortcut\",\n      version: 1,\n    }\n  }\n\n  override getTextContent(): string {\n    return getShortcutTextValue(this.__shortcutData)\n  }\n\n  override decorate(_editor: LexicalEditor): React.JSX.Element {\n    const dataKey = this.__shortcutData.id\n\n    return (\n      <React.Suspense fallback={null}>\n        <ShortcutComponent\n          className=\"cursor-default\"\n          shortcutData={this.__shortcutData}\n          key={`${this.__key}-${dataKey}`}\n        />\n      </React.Suspense>\n    )\n  }\n\n  override isInline(): boolean {\n    return true\n  }\n\n  override isKeyboardSelectable(): boolean {\n    return false\n  }\n\n  canInsertTextBefore(): boolean {\n    return false\n  }\n\n  canInsertTextAfter(): boolean {\n    return true\n  }\n\n  canBeEmpty(): boolean {\n    return false\n  }\n\n  isSegmented(): boolean {\n    return true\n  }\n\n  extractWithChild(): boolean {\n    return false\n  }\n}\n\nexport function $createShortcutNode(shortcutData: ShortcutData): ShortcutNode {\n  const shortcutNode = new ShortcutNode(shortcutData)\n  return $applyNodeReplacement(shortcutNode)\n}\n\nexport function $isShortcutNode(node: LexicalNode | null | undefined): node is ShortcutNode {\n  return node instanceof ShortcutNode\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/ShortcutPlugin.tsx",
    "content": "import * as React from \"react\"\nimport { Suspense, useMemo } from \"react\"\n\nimport { ShortcutDropdown } from \"./components/ShortcutDropdown\"\nimport { DEFAULT_MAX_SHORTCUT_SUGGESTIONS } from \"./constants\"\nimport { useShortcutKeyboard } from \"./hooks/useShortcutKeyboard\"\nimport { useShortcutSearch } from \"./hooks/useShortcutSearch\"\nimport { useShortcutSearchService } from \"./hooks/useShortcutSearchService\"\nimport { useShortcutSelection } from \"./hooks/useShortcutSelection\"\nimport { useShortcutTrigger } from \"./hooks/useShortcutTrigger\"\nimport { ShortcutNode } from \"./ShortcutNode\"\n\nexport function ShortcutPlugin() {\n  const { searchShortcuts } = useShortcutSearchService()\n\n  const { shortcutMatch, isActive, clearShortcutMatch } = useShortcutTrigger()\n\n  const {\n    suggestions,\n    selectedIndex,\n    isLoading,\n    searchShortcuts: performSearch,\n    clearSuggestions,\n    setSelectedIndex,\n    hasResults,\n  } = useShortcutSearch({\n    onSearch: searchShortcuts,\n    maxSuggestions: DEFAULT_MAX_SHORTCUT_SUGGESTIONS,\n  })\n\n  const { selectShortcut } = useShortcutSelection({\n    shortcutMatch,\n    onSelectionComplete: () => {\n      clearShortcutMatch()\n      clearSuggestions()\n    },\n  })\n\n  const handleArrowKey = React.useCallback(\n    (isUp: boolean) => {\n      if (!hasResults) return\n\n      const newIndex = isUp\n        ? selectedIndex <= 0\n          ? suggestions.length - 1\n          : selectedIndex - 1\n        : selectedIndex >= suggestions.length - 1\n          ? 0\n          : selectedIndex + 1\n\n      setSelectedIndex(newIndex)\n    },\n    [hasResults, suggestions.length, selectedIndex, setSelectedIndex],\n  )\n\n  const handleEnterKey = React.useCallback(() => {\n    if (hasResults && selectedIndex >= 0 && selectedIndex < suggestions.length) {\n      const shortcut = suggestions[selectedIndex]\n      if (shortcut) {\n        selectShortcut(shortcut)\n      }\n    }\n  }, [hasResults, selectedIndex, suggestions, selectShortcut])\n\n  const handleEscapeKey = React.useCallback(() => {\n    clearShortcutMatch()\n    clearSuggestions()\n  }, [clearShortcutMatch, clearSuggestions])\n\n  useShortcutKeyboard({\n    isActive,\n    suggestions,\n    selectedIndex,\n    onArrowKey: handleArrowKey,\n    onEnterKey: handleEnterKey,\n    onEscapeKey: handleEscapeKey,\n  })\n\n  React.useEffect(() => {\n    if (shortcutMatch) {\n      performSearch(shortcutMatch.matchingString)\n    } else {\n      clearSuggestions()\n    }\n  }, [shortcutMatch, performSearch, clearSuggestions])\n\n  const dropdownProps = useMemo(() => {\n    if (!isActive || !hasResults) return null\n\n    return {\n      isVisible: true,\n      suggestions,\n      selectedIndex,\n      isLoading,\n      onSetSelectIndex: setSelectedIndex,\n      onSelect: selectShortcut,\n      onClose: handleEscapeKey,\n      query: shortcutMatch?.matchingString || \"\",\n    }\n  }, [\n    isActive,\n    hasResults,\n    suggestions,\n    selectedIndex,\n    isLoading,\n    setSelectedIndex,\n    selectShortcut,\n    handleEscapeKey,\n    shortcutMatch?.matchingString,\n  ])\n\n  return dropdownProps ? (\n    <Suspense fallback={null}>\n      <ShortcutDropdown {...dropdownProps} />\n    </Suspense>\n  ) : null\n}\n\nShortcutPlugin.id = \"shortcut\"\nShortcutPlugin.nodes = [ShortcutNode]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/components/ShortcutComponent.tsx",
    "content": "import * as React from \"react\"\n\nimport { useAISettingValue } from \"~/atoms/settings/ai\"\n\nimport { ShortcutTooltip } from \"../../../../components/ui/ShortcutTooltip\"\nimport { MentionLikePill } from \"../../shared/components/MentionLikePill\"\nimport type { ShortcutData } from \"../types\"\n\ninterface ShortcutComponentProps {\n  shortcutData: ShortcutData\n  className?: string\n  onSelect?: (shortcut: ShortcutData) => void\n}\n\nexport const ShortcutComponent: React.FC<ShortcutComponentProps> = ({\n  shortcutData,\n  className,\n  onSelect,\n}) => {\n  const { shortcuts } = useAISettingValue()\n  const matched = React.useMemo(() => {\n    return shortcuts.find((s) => s.name === shortcutData.name)\n  }, [shortcuts, shortcutData.name])\n  const handleClick = React.useCallback(() => {\n    onSelect?.(shortcutData)\n  }, [onSelect, shortcutData])\n\n  return (\n    <ShortcutTooltip\n      name={shortcutData.name}\n      prompt={shortcutData.prompt || matched?.defaultPrompt}\n    >\n      <MentionLikePill\n        className={className}\n        variant=\"command\"\n        icon={\n          matched?.icon ? <i className={matched.icon} /> : <i className=\"i-mgc-hotkey-cute-re\" />\n        }\n        prefix=\"/\"\n        data-shortcut-id={shortcutData.id}\n        onClick={handleClick}\n      >\n        {shortcutData.name}\n      </MentionLikePill>\n    </ShortcutTooltip>\n  )\n}\n\nShortcutComponent.displayName = \"ShortcutComponent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/components/ShortcutDropdown.tsx",
    "content": "import { cn, thenable } from \"@follow/utils\"\nimport * as React from \"react\"\nimport { useCallback } from \"react\"\n\nimport { useAISettingValue } from \"~/atoms/settings/ai\"\n\nimport { TypeaheadDropdown } from \"../../shared/components/TypeaheadDropdown\"\nimport type { ShortcutData } from \"../types\"\n\ninterface ShortcutDropdownProps {\n  isVisible: boolean\n  suggestions: ShortcutData[]\n  selectedIndex: number\n  isLoading: boolean\n  onSelect: (shortcut: ShortcutData) => void\n  onSetSelectIndex: (index: number) => void\n  onClose: () => void\n  query: string\n}\n\nconst ShortcutSuggestionItem = React.memo(\n  ({\n    shortcut,\n    isSelected,\n    onClick,\n    query,\n    ...props\n  }: {\n    shortcut: ShortcutData\n    isSelected: boolean\n    onClick: (shortcut: ShortcutData) => void\n    query: string\n  } & Omit<React.HTMLAttributes<HTMLDivElement>, \"onClick\">) => {\n    const handleClick = useCallback(() => {\n      onClick(shortcut)\n    }, [shortcut, onClick])\n\n    const highlightText = useCallback(\n      (text: string, rawQuery: string) => {\n        const cleanQuery = rawQuery.replace(/^\\//, \"\").toLowerCase()\n        if (!cleanQuery) return text\n\n        const parts = text.split(new RegExp(`(${cleanQuery})`, \"gi\"))\n        return parts.map((part, index) => {\n          const isMatch = part.toLowerCase() === cleanQuery\n\n          if (!part) {\n            return null\n          }\n\n          return (\n            <span\n              key={`${shortcut.id}-${index}`}\n              className={isMatch ? \"font-semibold text-text-vibrant\" : \"\"}\n            >\n              {part}\n            </span>\n          )\n        })\n      },\n      [shortcut.id],\n    )\n\n    const { shortcuts } = useAISettingValue()\n    const matched = React.useMemo(() => {\n      return shortcuts.find((s) => s.name === shortcut.name)\n    }, [shortcuts, shortcut.name])\n\n    return (\n      <div\n        className={cn(\n          \"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none\",\n          \"focus-within:outline-transparent\",\n          \"focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[highlighted]:bg-theme-selection-hover data-[highlighted]:text-theme-selection-foreground\",\n          \"h-[28px]\",\n          isSelected && \"bg-theme-selection-active text-theme-selection-foreground\",\n        )}\n        onClick={handleClick}\n        role=\"option\"\n        aria-selected={isSelected}\n        {...props}\n      >\n        <span className=\"mr-1.5 inline-flex size-4 items-center justify-center text-blue\">\n          {matched?.icon ? (\n            <i className={cn(\"text-[16px]\", matched.icon)} />\n          ) : (\n            <span className=\"inline-flex size-4 items-center justify-center rounded-full bg-blue/10 text-xs font-semibold leading-none\">\n              <i className=\"i-mgc-hotkey-cute-re\" />\n            </span>\n          )}\n        </span>\n        <span className=\"flex-1 truncate leading-tight\">{highlightText(shortcut.name, query)}</span>\n      </div>\n    )\n  },\n)\n\nShortcutSuggestionItem.displayName = \"ShortcutSuggestionItem\"\n\nexport const ShortcutDropdown: React.FC<ShortcutDropdownProps> = ({\n  isVisible,\n  suggestions,\n  selectedIndex,\n  isLoading,\n  onSelect,\n  onSetSelectIndex,\n  onClose,\n  query,\n}) => {\n  if (!isVisible) throw thenable\n\n  return (\n    <TypeaheadDropdown\n      isVisible={isVisible}\n      items={suggestions}\n      selectedIndex={selectedIndex}\n      isLoading={isLoading}\n      onSelect={onSelect}\n      onSetSelectIndex={onSetSelectIndex}\n      onClose={onClose}\n      query={query}\n      ariaLabel=\"Shortcut suggestions\"\n      getKey={(s) => s.id}\n      loadingMessage=\"Searching...\"\n      emptyMessage=\"No shortcuts found\"\n      emptyHint=\"Try a different search term\"\n      renderItem={(shortcut, _index, isSelected, handlers) => (\n        <ShortcutSuggestionItem\n          key={shortcut.id}\n          shortcut={shortcut}\n          isSelected={isSelected}\n          onMouseMove={handlers.onMouseMove}\n          onClick={() => handlers.onClick()}\n          query={query}\n        />\n      )}\n    />\n  )\n}\n\nShortcutDropdown.displayName = \"ShortcutDropdown\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/constants.ts",
    "content": "import { createCommand } from \"lexical\"\n\nimport type { ShortcutData } from \"./types\"\n\nexport const SHORTCUT_COMMAND = createCommand<ShortcutData>(\"SHORTCUT_COMMAND\")\n\nexport const DEFAULT_MAX_SHORTCUT_SUGGESTIONS = 10\n\nexport const SHORTCUT_TRIGGER_PATTERN =\n  /(?:^|\\s)(\\/[\\w\\-\\s\\u4e00-\\u9fff\\u3400-\\u4dbf\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]*)$/\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/hooks/useShortcutKeyboard.ts",
    "content": "import { useCallback } from \"react\"\n\nimport { useListKeyboardNavigation } from \"../../shared/hooks/useListKeyboardNavigation\"\nimport type { ShortcutData } from \"../types\"\n\ninterface UseShortcutKeyboardOptions {\n  isActive: boolean\n  suggestions: ShortcutData[]\n  selectedIndex: number\n  onArrowKey: (isUp: boolean) => void\n  onEnterKey: () => void\n  onEscapeKey: () => void\n}\n\nexport const useShortcutKeyboard = ({\n  isActive,\n  suggestions,\n  selectedIndex,\n  onArrowKey,\n  onEnterKey,\n  onEscapeKey,\n}: UseShortcutKeyboardOptions) => {\n  const handleMove = useCallback((isUp: boolean) => onArrowKey(isUp), [onArrowKey])\n  const {\n    handleCancel,\n    handleConfirm,\n    handleMove: _,\n  } = useListKeyboardNavigation({\n    isActive,\n    itemCount: suggestions.length,\n    selectedIndex,\n    onMove: handleMove,\n    onConfirm: onEnterKey,\n    onCancel: onEscapeKey,\n  })\n\n  return { handleArrowKey: _, handleEnterKey: handleConfirm, handleEscapeKey: handleCancel }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/hooks/useShortcutSearch.ts",
    "content": "import { useCallback, useRef, useState, useTransition } from \"react\"\n\nimport { DEFAULT_MAX_SHORTCUT_SUGGESTIONS } from \"../constants\"\nimport type { ShortcutData, ShortcutSearchState } from \"../types\"\nimport { cleanShortcutQuery, shouldTriggerShortcut } from \"../utils/triggerDetection\"\n\ninterface UseShortcutSearchOptions {\n  onSearch?: (query: string) => Promise<ShortcutData[]> | ShortcutData[]\n  maxSuggestions?: number\n}\n\nconst defaultSearchFn = async (): Promise<ShortcutData[]> => []\n\nexport const useShortcutSearch = ({\n  onSearch = defaultSearchFn,\n  maxSuggestions = DEFAULT_MAX_SHORTCUT_SUGGESTIONS,\n}: UseShortcutSearchOptions = {}) => {\n  const [searchState, setSearchState] = useState<ShortcutSearchState>({\n    suggestions: [],\n    selectedIndex: -1,\n    isLoading: false,\n  })\n\n  const [isPending, startTransition] = useTransition()\n  const onSearchRef = useRef(onSearch)\n  const maxSuggestionsRef = useRef(maxSuggestions)\n\n  onSearchRef.current = onSearch\n  maxSuggestionsRef.current = maxSuggestions\n\n  const searchShortcuts = useCallback(async (query: string) => {\n    if (!shouldTriggerShortcut(query)) {\n      setSearchState((prev) => ({\n        ...prev,\n        suggestions: [],\n        selectedIndex: -1,\n        isLoading: false,\n      }))\n      return\n    }\n\n    startTransition(() => {\n      setSearchState((prev) => ({ ...prev, isLoading: true }))\n    })\n\n    try {\n      const cleanQuery = cleanShortcutQuery(query)\n      const results = await onSearchRef.current(cleanQuery)\n\n      startTransition(() => {\n        setSearchState({\n          suggestions: results.slice(0, maxSuggestionsRef.current),\n          selectedIndex: results.length > 0 ? 0 : -1,\n          isLoading: false,\n        })\n      })\n    } catch (error) {\n      console.error(\"Error searching shortcuts:\", error)\n      startTransition(() => {\n        setSearchState({\n          suggestions: [],\n          selectedIndex: -1,\n          isLoading: false,\n        })\n      })\n    }\n  }, [])\n\n  const clearSuggestions = useCallback(() => {\n    setSearchState({\n      suggestions: [],\n      selectedIndex: -1,\n      isLoading: false,\n    })\n  }, [])\n\n  const setSelectedIndex = useCallback((index: number) => {\n    setSearchState((prev) => ({ ...prev, selectedIndex: index }))\n  }, [])\n\n  return {\n    ...searchState,\n    searchShortcuts,\n    clearSuggestions,\n    setSelectedIndex,\n    hasResults: searchState.suggestions.length > 0,\n    isLoading: searchState.isLoading || isPending,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/hooks/useShortcutSearchService.ts",
    "content": "import { useMemo } from \"react\"\n\nimport { getShortcutEffectivePrompt, useAISettingValue } from \"~/atoms/settings/ai\"\n\nimport type { ShortcutData } from \"../types\"\n\nexport const useShortcutSearchService = () => {\n  const aiSettings = useAISettingValue()\n\n  const searchShortcuts = useMemo(() => {\n    const shortcuts = (aiSettings.shortcuts ?? []).filter((shortcut) => shortcut.enabled)\n\n    const normalizedShortcuts: ShortcutData[] = shortcuts.map((shortcut) => ({\n      id: shortcut.id,\n      name: shortcut.name,\n      prompt: getShortcutEffectivePrompt(shortcut),\n      hotkey: shortcut.hotkey,\n      displayTargets: shortcut.displayTargets,\n    }))\n\n    return async (query: string): Promise<ShortcutData[]> => {\n      const trimmedQuery = query.trim().toLowerCase()\n\n      if (!trimmedQuery) {\n        return normalizedShortcuts\n      }\n\n      return normalizedShortcuts.filter((shortcut) => {\n        const normalizedName = shortcut.name.toLowerCase()\n        return normalizedName.includes(trimmedQuery)\n      })\n    }\n  }, [aiSettings.shortcuts])\n\n  return { searchShortcuts }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/hooks/useShortcutSelection.ts",
    "content": "import { useTypeaheadSelection } from \"../../shared/hooks/useTypeaheadSelection\"\nimport { SHORTCUT_COMMAND } from \"../constants\"\nimport type { ShortcutData, ShortcutMatch } from \"../types\"\nimport { insertShortcutNode } from \"../utils/textReplacement\"\n\ninterface UseShortcutSelectionOptions {\n  shortcutMatch: ShortcutMatch | null\n  onShortcutInsert?: (shortcut: ShortcutData, nodeKey?: string) => void\n  onSelectionComplete?: () => void\n}\n\nexport const useShortcutSelection = ({\n  shortcutMatch,\n  onShortcutInsert,\n  onSelectionComplete,\n}: UseShortcutSelectionOptions) => {\n  const { selectItem } = useTypeaheadSelection<ShortcutMatch, ShortcutData>({\n    match: shortcutMatch,\n    command: SHORTCUT_COMMAND,\n    replaceWith: (item, match) => insertShortcutNode(item, match),\n    onInsert: onShortcutInsert,\n    onComplete: onSelectionComplete,\n  })\n\n  return { selectShortcut: selectItem }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/hooks/useShortcutTrigger.ts",
    "content": "import type { LexicalEditor } from \"lexical\"\n\nimport { useTextTrigger } from \"../../shared/hooks/useTextTrigger\"\nimport type { ShortcutMatch } from \"../types\"\nimport { defaultShortcutTriggerFn } from \"../utils/triggerDetection\"\n\ninterface UseShortcutTriggerOptions {\n  triggerFn?: (text: string, editor: LexicalEditor) => ShortcutMatch | null\n  onTrigger?: (match: ShortcutMatch | null) => void\n}\n\nexport const useShortcutTrigger = ({\n  triggerFn = defaultShortcutTriggerFn,\n  onTrigger,\n}: UseShortcutTriggerOptions = {}) => {\n  const { match, isActive, clear } = useTextTrigger({ triggerFn, onTrigger })\n  return { shortcutMatch: match as ShortcutMatch | null, isActive, clearShortcutMatch: clear }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/index.ts",
    "content": "export { ShortcutComponent } from \"./components/ShortcutComponent\"\nexport { ShortcutDropdown } from \"./components/ShortcutDropdown\"\nexport { useShortcutSearchService } from \"./hooks/useShortcutSearchService\"\nexport { $createShortcutNode, $isShortcutNode, ShortcutNode } from \"./ShortcutNode\"\nexport { ShortcutPlugin } from \"./ShortcutPlugin\"\nexport type {\n  ShortcutData,\n  ShortcutMatch,\n  ShortcutSearchState,\n  ShortcutTriggerState,\n} from \"./types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/types.ts",
    "content": "import type { AIShortcutTarget } from \"@follow/shared/settings/interface\"\n\nexport interface ShortcutData {\n  id: string\n  name: string\n  prompt: string\n\n  hotkey?: string\n  displayTargets?: readonly AIShortcutTarget[]\n}\n\nexport interface ShortcutMetadata {\n  id: string\n  name: string\n}\n\nexport interface ShortcutMatch {\n  leadOffset: number\n  matchingString: string\n  replaceableString: string\n}\n\nexport interface ShortcutSearchState {\n  suggestions: ShortcutData[]\n  selectedIndex: number\n  isLoading: boolean\n}\n\nexport interface ShortcutTriggerState {\n  shortcutMatch: ShortcutMatch | null\n  isActive: boolean\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/utils/index.ts",
    "content": "export * from \"./positioning\"\nexport * from \"./shortcutTextValue\"\nexport * from \"./textReplacement\"\nexport * from \"./triggerDetection\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/utils/positioning.ts",
    "content": "import type { LexicalEditor } from \"lexical\"\n\nimport { calculateDropdownPosition } from \"../../shared/utils/positioning\"\n\nexport const calculateShortcutDropdownPosition = (editor: LexicalEditor) =>\n  calculateDropdownPosition(editor)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/utils/shortcutTextValue.ts",
    "content": "import { getAISettings, getShortcutEffectivePrompt } from \"~/atoms/settings/ai\"\n\nimport type { ShortcutData } from \"../types\"\n\nexport function getShortcutTextValue(shortcutData: ShortcutData): string {\n  const allShortcuts = getAISettings().shortcuts ?? []\n  const matchedShortcut = allShortcuts.find((shortcut) => shortcut.id === shortcutData.id)\n  if (matchedShortcut) {\n    return getShortcutEffectivePrompt(matchedShortcut)\n  }\n  return shortcutData.prompt\n}\n\nexport function getShortcutDisplayTextValue(shortcutData: ShortcutData): string {\n  const allShortcuts = getAISettings().shortcuts ?? []\n  const matchedShortcut = allShortcuts.find((shortcut) => shortcut.id === shortcutData.id)\n  return matchedShortcut?.name ?? shortcutData.name\n}\n\nexport function getShortcutMarkdownValue(shortcutId: string): string {\n  const allShortcuts = getAISettings().shortcuts ?? []\n  const matchedShortcut = allShortcuts.find((shortcut) => shortcut.id === shortcutId)\n  return matchedShortcut ? `/${matchedShortcut.name}` : `/${shortcutId}`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/utils/textReplacement.ts",
    "content": "import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode } from \"lexical\"\n\nimport { $createShortcutNode } from \"../ShortcutNode\"\nimport type { ShortcutData, ShortcutMatch } from \"../types\"\n\nexport const insertShortcutNode = (\n  shortcutData: ShortcutData,\n  shortcutMatch: ShortcutMatch,\n): { success: boolean; nodeKey?: string } => {\n  const selection = $getSelection()\n  if (!$isRangeSelection(selection) || !selection.isCollapsed()) return { success: false }\n\n  const { anchor } = selection\n  const anchorNode = anchor.getNode()\n\n  if (!$isTextNode(anchorNode)) return { success: false }\n\n  const textContent = anchorNode.getTextContent()\n  const { leadOffset, replaceableString } = shortcutMatch\n\n  const beforeText = textContent.slice(0, leadOffset)\n  const afterText = textContent.slice(leadOffset + replaceableString.length)\n\n  const beforeNode = beforeText ? $createTextNode(beforeText) : null\n  const shortcutNode = $createShortcutNode(shortcutData)\n  const afterNode = afterText ? $createTextNode(afterText) : null\n\n  if (beforeNode) {\n    anchorNode.insertBefore(beforeNode)\n  }\n  anchorNode.insertBefore(shortcutNode)\n  if (afterNode) {\n    anchorNode.insertBefore(afterNode)\n  }\n\n  anchorNode.remove()\n\n  if (afterNode) {\n    afterNode.select(0, 0)\n  } else {\n    const spaceNode = $createTextNode(\" \")\n    shortcutNode.insertAfter(spaceNode)\n    spaceNode.select(1, 1)\n  }\n\n  return { success: true, nodeKey: shortcutNode.getKey() }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shortcut/utils/triggerDetection.ts",
    "content": "import { $getSelection, $isRangeSelection, $isTextNode } from \"lexical\"\n\nimport { SHORTCUT_TRIGGER_PATTERN } from \"../constants\"\nimport type { ShortcutMatch } from \"../types\"\n\nexport const defaultShortcutTriggerFn = (): ShortcutMatch | null => {\n  const selection = $getSelection()\n  if (!$isRangeSelection(selection) || !selection.isCollapsed()) {\n    return null\n  }\n\n  const { anchor, focus } = selection\n  const anchorNode = anchor.getNode()\n\n  if (!$isTextNode(anchorNode) || anchor.key !== focus.key || anchor.offset !== focus.offset) {\n    return null\n  }\n\n  const textContent = anchorNode.getTextContent()\n  const cursorOffset = anchor.offset\n  const match = textContent.slice(0, cursorOffset).match(SHORTCUT_TRIGGER_PATTERN)\n\n  if (!match) {\n    return null\n  }\n\n  const matchingString = match[1] || \"\"\n  const replaceableString = matchingString\n  const leadOffset = (match.index ?? 0) + (match[0]?.startsWith(\" \") ? 1 : 0)\n\n  return {\n    leadOffset,\n    matchingString,\n    replaceableString,\n  }\n}\n\nexport const cleanShortcutQuery = (query: string): string => {\n  return query.replace(/^\\//, \"\").trim()\n}\n\nexport const shouldTriggerShortcut = (query: string): boolean => {\n  return query.startsWith(\"/\") && query.length > 0\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAIConfiguration.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\"\n\nimport { followApi } from \"~/lib/api-client\"\n\nexport const useAIConfiguration = () => {\n  return useQuery({\n    queryKey: [\"aiConfiguration\"],\n    queryFn: async () => {\n      return followApi.ai.config()\n    },\n    staleTime: 5 * 60 * 1000,\n    retry: false,\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAIModel.ts",
    "content": "import { useEffect, useMemo } from \"react\"\n\nimport { setAIModelState, useAIModelState } from \"../atoms/session\"\nimport { useAIConfiguration } from \"./useAIConfiguration\"\n\nexport const useAIModel = () => {\n  const { data: configuration, isLoading } = useAIConfiguration()\n  const modelState = useAIModelState()\n\n  // Validate and sync persistent model with available models\n  useEffect(() => {\n    if (!configuration || isLoading) return\n\n    const { selectedModel } = modelState\n    const { defaultModel, availableModels = [] } = configuration\n\n    // If no model is selected or selected model is not available, use default\n    if (!selectedModel || !availableModels.includes(selectedModel)) {\n      setAIModelState({\n        selectedModel: defaultModel || null,\n      })\n    }\n  }, [configuration, isLoading, modelState])\n\n  // Get current effective model\n  const currentModel = useMemo(() => {\n    if (!configuration) return null\n\n    const { selectedModel } = modelState\n    const { defaultModel, availableModels = [] } = configuration\n\n    // Return selected model if valid, otherwise fallback to default\n    if (selectedModel && availableModels.includes(selectedModel)) {\n      return selectedModel\n    }\n\n    return defaultModel || null\n  }, [configuration, modelState])\n\n  const changeModel = (model: string) => {\n    if (!configuration?.availableModels?.includes(model)) {\n      console.warn(`Model ${model} is not available in current configuration`)\n      return\n    }\n\n    setAIModelState({\n      selectedModel: model,\n    })\n  }\n\n  return {\n    data: {\n      defaultModel: configuration?.defaultModel,\n      availableModels: configuration?.availableModels,\n      availableModelsMenu: configuration?.availableModelsMenu,\n      currentModel,\n    },\n    isLoading,\n    changeModel,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAIShortcut.ts",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { useEffect } from \"react\"\nimport { tinykeys } from \"tinykeys\"\n\nimport {\n  AIChatPanelStyle,\n  getAIPanelVisibility,\n  getAISettings,\n  setAIPanelVisibility,\n} from \"~/atoms/settings/ai\"\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { useChatActions } from \"../store/hooks\"\nimport { isTimelineSummaryAutoContext } from \"./useTimelineSummaryAutoContext\"\n\nexport const useAIShortcut = () => {\n  const isFocus = useGlobalFocusableScopeSelector(FocusablePresets.isAIChat)\n\n  const chatActions = useChatActions()\n  useEffect(() => {\n    if (!isFocus) return\n    return tinykeys(document.documentElement, {\n      \"$mod+n\": (e) => {\n        e.preventDefault()\n        // New chat\n        const { entryId } = getRouteParams()\n        if (isTimelineSummaryAutoContext({ entryId })) {\n          chatActions.setTimelineSummaryManualOverride(true)\n        }\n        chatActions.newChat()\n      },\n      \"$mod+w\": (e) => {\n        if (getAISettings().panelStyle === AIChatPanelStyle.Floating && getAIPanelVisibility()) {\n          e.preventDefault()\n          // close AI chat\n          setAIPanelVisibility(false)\n        }\n      },\n    })\n  }, [chatActions, isFocus])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAttachScrollBeyond.tsx",
    "content": "import { useSetAtom } from \"jotai\"\nimport { useCallback, useEffect } from \"react\"\n\nimport { SCROLLED_BEYOND_THRESHOLD } from \"../constants\"\nimport { useAIRootState } from \"../store/AIChatContext\"\nimport { useCurrentChatId } from \"../store/hooks\"\n\nexport const useAttachScrollBeyond = () => {\n  const { isScrolledBeyondThreshold } = useAIRootState()\n  const setIsScrolledBeyondThreshold = useSetAtom(isScrolledBeyondThreshold)\n  const handleScroll = useCallback(\n    (event: React.UIEvent<HTMLDivElement>) => {\n      const { scrollTop } = event.currentTarget\n      setIsScrolledBeyondThreshold(scrollTop > SCROLLED_BEYOND_THRESHOLD)\n    },\n    [setIsScrolledBeyondThreshold],\n  )\n  const currentChatId = useCurrentChatId()\n  useEffect(() => {\n    if (currentChatId) {\n      setIsScrolledBeyondThreshold(false)\n    }\n  }, [currentChatId, setIsScrolledBeyondThreshold])\n  return { handleScroll }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAutoScroll.tsx",
    "content": "import { springScrollTo } from \"@follow/utils/scroller\"\nimport { debounce, throttle } from \"es-toolkit/compat\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\nconst BOTTOM_THRESHOLD = 50\n\nexport const useAutoScroll = (viewport: HTMLElement | null, enabled: boolean) => {\n  const [isAtBottom, setIsAtBottom] = useState(true)\n  const scrollAnimationRef = useRef<{ stop: () => void } | null>(null)\n  const isAutoScrollingRef = useRef(false)\n  const isAutoScrollCancelledRef = useRef(false)\n\n  const isAtBottomRef = useRef(isAtBottom)\n  useEffect(() => {\n    isAtBottomRef.current = isAtBottom\n  }, [isAtBottom])\n\n  const scrollToBottom = useCallback(\n    (force = false) => {\n      if (!viewport) return\n\n      if (scrollAnimationRef.current) {\n        scrollAnimationRef.current.stop()\n      }\n\n      if (force || (isAtBottomRef.current && !isAutoScrollCancelledRef.current)) {\n        const { scrollTop, scrollHeight, clientHeight } = viewport\n        const targetScrollTop = scrollHeight - clientHeight\n        const distance = Math.abs(targetScrollTop - scrollTop)\n\n        // If the jump is very large, set immediately to avoid the animation falling behind\n        const MAX_ANIMATED_DISTANCE = clientHeight * 1.5\n        if (distance > MAX_ANIMATED_DISTANCE) {\n          isAutoScrollingRef.current = true\n          viewport.scrollTop = targetScrollTop\n          // After immediate jump, update state\n          const atBottom = scrollHeight - viewport.scrollTop - clientHeight <= BOTTOM_THRESHOLD\n          if (isAtBottomRef.current !== atBottom) {\n            setIsAtBottom(atBottom)\n          }\n          isAutoScrollingRef.current = false\n          scrollAnimationRef.current = null\n          return\n        }\n\n        isAutoScrollingRef.current = true\n        const animation = springScrollTo(targetScrollTop, viewport)\n\n        scrollAnimationRef.current = animation\n        animation.then(() => {\n          scrollAnimationRef.current = null\n          isAutoScrollingRef.current = false\n          // After animation, re-evaluate position\n          const { scrollTop: st, scrollHeight: sh, clientHeight: ch } = viewport\n          const atBottom = sh - st - ch <= BOTTOM_THRESHOLD\n          if (isAtBottomRef.current !== atBottom) {\n            setIsAtBottom(atBottom)\n          }\n        })\n      }\n    },\n    [viewport],\n  )\n\n  useEffect(() => {\n    if (!viewport) return\n    if (!enabled) return\n\n    const handleScroll = throttle(() => {\n      if (isAutoScrollingRef.current) {\n        return\n      }\n      const { scrollTop, scrollHeight, clientHeight } = viewport\n      const atBottom = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD\n      if (atBottom !== isAtBottomRef.current) {\n        setIsAtBottom(atBottom)\n      }\n      // If user scrolled back to bottom, resume auto-scroll\n      if (atBottom) {\n        isAutoScrollCancelledRef.current = false\n      }\n    }, 100)\n\n    const cancelAutoScroll = () => {\n      isAutoScrollingRef.current = false\n\n      if (scrollAnimationRef.current) {\n        scrollAnimationRef.current.stop()\n      }\n      isAutoScrollCancelledRef.current = true\n      handleScroll()\n    }\n\n    const handleWheel = throttle(cancelAutoScroll, 100)\n    const handleTouchStart = handleWheel\n    const handleTouchMove = handleWheel\n\n    viewport.addEventListener(\"scroll\", handleScroll, { passive: true })\n    viewport.addEventListener(\"wheel\", handleWheel, { passive: true })\n    viewport.addEventListener(\"touchstart\", handleTouchStart, { passive: true })\n    viewport.addEventListener(\"touchmove\", handleTouchMove, { passive: true })\n\n    return () => {\n      viewport.removeEventListener(\"scroll\", handleScroll)\n      viewport.removeEventListener(\"wheel\", handleWheel)\n      viewport.removeEventListener(\"touchstart\", handleTouchStart)\n      viewport.removeEventListener(\"touchmove\", handleTouchMove)\n      if (scrollAnimationRef.current) {\n        scrollAnimationRef.current.stop()\n      }\n    }\n  }, [viewport, enabled])\n\n  useEffect(() => {\n    if (!viewport || !enabled) return\n\n    const content = viewport.firstElementChild as HTMLElement\n    if (!content) return\n\n    const resizeObserver = new ResizeObserver(\n      debounce(() => {\n        if (isAtBottomRef.current && !isAutoScrollCancelledRef.current) {\n          requestAnimationFrame(() => {\n            scrollToBottom()\n          })\n        }\n      }, 16),\n    )\n\n    resizeObserver.observe(content)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [viewport, enabled, scrollToBottom])\n\n  // Ensure we start at bottom on mount/enable\n  useEffect(() => {\n    if (!viewport || !enabled) return\n    requestAnimationFrame(() => {\n      scrollToBottom(true)\n    })\n  }, [viewport, enabled, scrollToBottom])\n\n  const resetScrollState = useCallback(() => {\n    isAutoScrollCancelledRef.current = false\n    setIsAtBottom(true)\n    scrollToBottom(true)\n  }, [scrollToBottom])\n\n  return { resetScrollState, scrollToBottom }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut.ts",
    "content": "import { convertLexicalToMarkdown } from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from \"@follow/shared/settings/defaults\"\nimport { getCategoryFeedIds } from \"@follow/store/subscription/getter\"\nimport type { LexicalEditor } from \"lexical\"\nimport { $createParagraphNode, $getRoot, createEditor } from \"lexical\"\nimport { nanoid } from \"nanoid\"\nimport { useEffect, useMemo, useRef } from \"react\"\n\nimport { getShortcutEffectivePrompt, useAISettingValue } from \"~/atoms/settings/ai\"\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from \"~/constants\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\nimport { AI_CHAT_SPECIAL_ID_PREFIX } from \"../constants\"\nimport { LexicalAIEditorNodes, ShortcutNode } from \"../editor\"\nimport { AIPersistService } from \"../services\"\nimport { useAIChatStore } from \"../store/AIChatContext\"\nimport { useBlockActions, useChatActions, useCurrentChatId } from \"../store/hooks\"\nimport { BlockSliceAction } from \"../store/slices/block.slice\"\nimport type { AIChatContextBlock, SendingUIMessage } from \"../store/types\"\nimport { isTimelineSummaryAutoContext } from \"./useTimelineSummaryAutoContext\"\n\nconst ONE_HOUR = 60 * 60 * 1000\n\nconst buildSummaryMessage = (\n  editor: LexicalEditor,\n  contextBlocks: AIChatContextBlock[],\n  messageId: string,\n): SendingUIMessage => {\n  const parts: SendingUIMessage[\"parts\"] = []\n\n  if (contextBlocks.length > 0) {\n    parts.push({\n      type: \"data-block\",\n      data: contextBlocks,\n    })\n  }\n\n  parts.push({\n    type: \"data-rich-text\",\n    data: {\n      state: JSON.stringify(editor.getEditorState().toJSON()),\n      text: convertLexicalToMarkdown(editor),\n    },\n  })\n\n  return {\n    id: messageId,\n    role: \"user\",\n    parts,\n  }\n}\n\nconst buildTimelineSummaryChatId = ({\n  view,\n  feedId,\n  timelineId,\n  unreadOnly,\n  seed,\n}: {\n  view: number\n  feedId: string\n  timelineId?: string | null\n  unreadOnly: boolean\n  seed: string\n}) => {\n  const normalizedTimelineId = timelineId ?? \"all\"\n  const unreadSegment = unreadOnly ? \"unread\" : \"all\"\n  const prefix = AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY\n  return `${prefix}${view}:${feedId}:${normalizedTimelineId}:${unreadSegment}:${seed}`\n}\n\nexport const useAutoTimelineSummaryShortcut = () => {\n  const aiSettings = useAISettingValue()\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n\n  const { view, feedId, entryId, timelineId } = useRouteParamsSelector((params) => ({\n    view: params.view,\n    feedId: params.feedId,\n    entryId: params.entryId,\n    timelineId: params.timelineId,\n  }))\n\n  const chatActions = useChatActions()\n  const blockActions = useBlockActions()\n  const currentChatId = useCurrentChatId()\n  const timelineSummaryManualOverride = useAIChatStore()(\n    (state) => state.timelineSummaryManualOverride,\n  )\n\n  const automationStateRef = useRef<{\n    contextKey: string | null\n    promise: Promise<void> | null\n    failed: boolean\n  }>({\n    contextKey: null,\n    promise: null,\n    failed: false,\n  })\n  const previousContextKeyRef = useRef<string | null>(null)\n\n  const isAllTimeline = isTimelineSummaryAutoContext({ entryId })\n\n  const defaultShortcut = useMemo(() => {\n    const shortcuts = aiSettings.shortcuts ?? []\n    return shortcuts.find(\n      (shortcut) => shortcut.id === DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID && shortcut.enabled,\n    )\n  }, [aiSettings.shortcuts])\n\n  const normalizedFeedId = feedId ?? ROUTE_FEED_PENDING\n\n  const contextKey = useMemo(() => {\n    if (!isAllTimeline) return null\n    const keyParts = [\n      `timeline:${timelineId ?? \"all\"}`,\n      `feed:${normalizedFeedId}`,\n      `unread:${unreadOnly ? \"1\" : \"0\"}`,\n    ]\n    return keyParts.join(\"|\")\n  }, [isAllTimeline, timelineId, normalizedFeedId, unreadOnly])\n\n  useEffect(() => {\n    if (previousContextKeyRef.current !== contextKey) {\n      chatActions.setTimelineSummaryManualOverride(false)\n      previousContextKeyRef.current = contextKey\n    }\n  }, [chatActions, contextKey])\n\n  const previousIsAllTimelineRef = useRef(isAllTimeline)\n\n  useEffect(() => {\n    const wasAllTimeline = previousIsAllTimelineRef.current\n    if (\n      wasAllTimeline &&\n      !isAllTimeline &&\n      currentChatId &&\n      currentChatId.startsWith(AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY)\n    ) {\n      blockActions.clearBlocks({ keepSpecialTypes: true })\n      chatActions.newChat()\n    }\n    previousIsAllTimelineRef.current = isAllTimeline\n  }, [blockActions, chatActions, currentChatId, isAllTimeline])\n\n  const contextBlocks = useMemo<AIChatContextBlock[]>(() => {\n    if (!isAllTimeline) return []\n\n    const blocks: AIChatContextBlock[] = []\n\n    if (typeof view === \"number\") {\n      blocks.push({\n        id: BlockSliceAction.SPECIAL_TYPES.mainView,\n        type: \"mainView\",\n        value: `${view}`,\n      })\n    }\n\n    if (normalizedFeedId && normalizedFeedId !== ROUTE_FEED_PENDING) {\n      let value = normalizedFeedId\n      if (normalizedFeedId.startsWith(ROUTE_FEED_IN_FOLDER)) {\n        const categoryName = normalizedFeedId.slice(ROUTE_FEED_IN_FOLDER.length)\n        const ids = getCategoryFeedIds(categoryName, FeedViewType.All)\n        if (ids.length > 0) {\n          value = ids.join(\",\")\n        }\n      }\n\n      blocks.push({\n        id: BlockSliceAction.SPECIAL_TYPES.mainFeed,\n        type: \"mainFeed\",\n        value,\n      })\n    }\n\n    if (unreadOnly) {\n      blocks.push({\n        id: BlockSliceAction.SPECIAL_TYPES.unreadOnly,\n        type: \"unreadOnly\",\n        value: \"true\",\n      })\n    }\n\n    return blocks\n  }, [isAllTimeline, normalizedFeedId, unreadOnly, view])\n\n  useEffect(() => {\n    if (!contextKey || !defaultShortcut) {\n      if (!contextKey) {\n        automationStateRef.current = { contextKey: null, promise: null, failed: false }\n      }\n      return\n    }\n\n    if (automationStateRef.current.contextKey !== contextKey) {\n      automationStateRef.current = {\n        contextKey,\n        promise: null,\n        failed: false,\n      }\n    } else {\n      if (automationStateRef.current.promise) {\n        return\n      }\n      if (automationStateRef.current.failed) {\n        return\n      }\n    }\n\n    if (timelineSummaryManualOverride) {\n      return\n    }\n\n    const run = async () => {\n      try {\n        const prompt = getShortcutEffectivePrompt(defaultShortcut)\n        const { id, name } = defaultShortcut\n\n        const existingSession = await AIPersistService.findTimelineSummarySession({\n          view,\n          feedId: normalizedFeedId,\n          timelineId: timelineId ?? null,\n          unreadOnly,\n        })\n        const now = Date.now()\n\n        if (existingSession) {\n          const lastUpdatedAt = existingSession.updatedAt?.getTime?.() ?? existingSession.updatedAt\n          if (typeof lastUpdatedAt === \"number\" && now - lastUpdatedAt < ONE_HOUR) {\n            if (currentChatId !== existingSession.chatId) {\n              await chatActions.switchToChat(existingSession.chatId)\n            }\n            automationStateRef.current.failed = false\n            return\n          }\n        }\n\n        const timelineSummaryChatId = buildTimelineSummaryChatId({\n          view,\n          feedId: normalizedFeedId,\n          timelineId: timelineId ?? null,\n          unreadOnly,\n          seed: nanoid(6),\n        })\n\n        await AIPersistService.ensureSession(timelineSummaryChatId, {\n          title: \"Timeline Summary\",\n        })\n\n        await chatActions.switchToChat(timelineSummaryChatId)\n        blockActions.clearBlocks({ keepSpecialTypes: true })\n\n        const tempEditor = createEditor({\n          nodes: LexicalAIEditorNodes,\n        })\n\n        tempEditor.update(\n          () => {\n            const root = $getRoot()\n            root.clear()\n            const paragraph = $createParagraphNode()\n            const shortcutNode = new ShortcutNode({ id, name, prompt })\n            paragraph.append(shortcutNode)\n            root.append(paragraph)\n          },\n          {\n            discrete: true,\n          },\n        )\n\n        const message = buildSummaryMessage(tempEditor, contextBlocks, nanoid())\n\n        await chatActions.sendMessage(message, {\n          body: { scene: \"general\" },\n        })\n\n        automationStateRef.current.failed = false\n      } catch (error) {\n        automationStateRef.current.failed = true\n        console.error(\"[AI Chat] Failed to auto-run timeline summary shortcut:\", error)\n      } finally {\n        if (automationStateRef.current.contextKey === contextKey) {\n          automationStateRef.current.promise = null\n        }\n      }\n    }\n\n    const promise = run()\n    automationStateRef.current.promise = promise\n  }, [\n    blockActions,\n    chatActions,\n    contextBlocks,\n    contextKey,\n    currentChatId,\n    defaultShortcut,\n    normalizedFeedId,\n    timelineId,\n    unreadOnly,\n    view,\n    timelineSummaryManualOverride,\n  ])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useChatHistory.ts",
    "content": "import { useMemo } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { AIChatSessionService } from \"~/modules/ai-chat-session/service\"\nimport { aiChatSessionStoreActions, useAIChatSessionStore } from \"~/modules/ai-chat-session/store\"\n\nexport const useChatHistory = () => {\n  const state = useAIChatSessionStore()\n\n  const { sessions } = state\n  const loading = state.isLoading || state.isSyncing\n\n  const loadHistory = useEventCallback(async () => {\n    if (state.isLoading) return\n\n    aiChatSessionStoreActions.setLoading(true)\n    aiChatSessionStoreActions.clearError()\n\n    try {\n      // 1) Always hydrate latest local first so UI updates immediately\n      await AIChatSessionService.loadSessionsFromDb()\n      // 2) Then sync remote → upsert into local DB → reload from DB inside service\n      await aiChatSessionStoreActions.fetchRemoteSessions()\n    } catch (error) {\n      console.error(\"Failed to load chat history:\", error)\n      aiChatSessionStoreActions.setError(error instanceof Error ? error.message : \"Unknown error\")\n    } finally {\n      aiChatSessionStoreActions.setLoading(false)\n    }\n  })\n\n  return useMemo(\n    () => ({\n      sessions,\n      loading,\n      loadHistory,\n      stats: state.stats,\n      lastSyncedAt: state.lastSyncedAt,\n      error: state.error,\n    }),\n    [sessions, loading, loadHistory, state.stats, state.lastSyncedAt, state.error],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useDisplayBlocks.ts",
    "content": "import { useMemo } from \"react\"\n\nimport type { AIChatContextBlock, ValueContextBlock } from \"~/modules/ai-chat/store/types\"\n\ntype ValueBlockOf<Type extends ValueContextBlock[\"type\"]> = Omit<ValueContextBlock, \"type\"> & {\n  type: Type\n}\n\nexport type DisplayBlockItem =\n  | {\n      kind: \"combined\"\n      viewBlock?: ValueBlockOf<\"mainView\">\n      feedBlock?: ValueBlockOf<\"mainFeed\">\n      unreadOnlyBlock?: ValueBlockOf<\"unreadOnly\">\n    }\n  | { kind: \"single\"; block: AIChatContextBlock }\n\n/**\n * Custom hook to process blocks and merge mainView, mainFeed, and unreadOnly when any of them exist\n * Returns an array of display items that can be either combined or single blocks\n */\nexport const useDisplayBlocks = (blocks: AIChatContextBlock[]): DisplayBlockItem[] => {\n  return useMemo(() => {\n    // Early return for empty blocks\n    if (!blocks?.length) {\n      return []\n    }\n\n    const mainViewBlock = blocks.find(\n      (block): block is ValueBlockOf<\"mainView\"> => block.type === \"mainView\",\n    )\n    const mainFeedBlock = blocks.find(\n      (block): block is ValueBlockOf<\"mainFeed\"> => block.type === \"mainFeed\",\n    )\n    const unreadOnlyBlock = blocks.find(\n      (block): block is ValueBlockOf<\"unreadOnly\"> => block.type === \"unreadOnly\",\n    )\n\n    // If any of the three special block types exist, create a combined block\n    if (mainViewBlock || mainFeedBlock || unreadOnlyBlock) {\n      const items: DisplayBlockItem[] = []\n\n      // Create combined block with optional blocks\n      items.push({\n        kind: \"combined\",\n        ...(mainViewBlock && { viewBlock: mainViewBlock }),\n        ...(mainFeedBlock && { feedBlock: mainFeedBlock }),\n        ...(unreadOnlyBlock && { unreadOnlyBlock }),\n      })\n\n      // Add other blocks (excluding mainView, mainFeed, and unreadOnly)\n      const otherBlocks = blocks.filter(\n        (block) =>\n          block.type !== \"mainView\" && block.type !== \"mainFeed\" && block.type !== \"unreadOnly\",\n      )\n      otherBlocks.forEach((block) => {\n        items.push({ kind: \"single\", block })\n      })\n\n      return items\n    }\n\n    // If none of the special blocks exist, show all blocks as single blocks\n    return blocks.map((block) => ({ kind: \"single\" as const, block }))\n  }, [blocks])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFeedEntrySearchService.ts",
    "content": "import { getEntry } from \"@follow/store/entry/getter\"\nimport { useEntryIdsByView } from \"@follow/store/entry/hooks\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { useAllFeedSubscription, useCategories } from \"@follow/store/subscription/hooks\"\nimport type { IFuseOptions } from \"fuse.js\"\nimport Fuse from \"fuse.js\"\nimport { useMemo } from \"react\"\n\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\n/**\n * Generic search item interface\n */\nexport interface SearchItem {\n  id: string\n  title: string\n  type: \"feed\" | \"entry\" | \"category\"\n}\n\n/**\n * Search service options\n */\nexport interface SearchServiceOptions {\n  /** Fuse.js search options */\n  fuseOptions?: IFuseOptions<SearchItem>\n}\n\nconst defaultFuseOptions: IFuseOptions<SearchItem> = {\n  keys: [\"title\", \"id\"],\n  threshold: 0.3,\n  includeScore: true,\n}\n\n/**\n * Hook that provides unified search functionality for feeds and entries\n * Used by both context bar and mention plugin\n */\nexport const useFeedEntrySearchService = (options: SearchServiceOptions = {}) => {\n  const { fuseOptions = defaultFuseOptions } = options\n\n  // Get data sources\n  const view = useRouteParamsSelector((route) => route.view)\n  const allSubscriptions = useAllFeedSubscription()\n  const categories = useCategories()\n  const recentEntryIds = useEntryIdsByView(view, false)\n\n  const categoryItems = useMemo(() => {\n    if (!categories?.length) return []\n\n    return categories\n      .filter((category) => !!category && category.trim().length > 0)\n      .map((category) => ({\n        id: `${ROUTE_FEED_IN_FOLDER}${category}`,\n        title: category,\n        type: \"category\" as const,\n      }))\n      .sort((a, b) => a.title.localeCompare(b.title))\n  }, [categories])\n\n  // Prepare feed items\n  const feedItems = useMemo(() => {\n    return allSubscriptions\n      .filter((subscription) => subscription.feedId)\n      .map((subscription) => {\n        const customTitle = subscription.title\n        if (!subscription.feedId) return null\n\n        const feed = getFeedById(subscription.feedId!)\n        return {\n          id: subscription.feedId!,\n          title: customTitle || feed?.title || `Feed ${subscription.feedId}`,\n          type: \"feed\" as const,\n        }\n      })\n      .filter(Boolean) as SearchItem[]\n  }, [allSubscriptions])\n\n  // Prepare entry items (recent entries, limited for performance)\n  const entryItems = useMemo(() => {\n    if (!recentEntryIds) return []\n\n    return recentEntryIds\n      .map((entryId) => {\n        const entry = getEntry(entryId)\n        return entry\n          ? {\n              id: entryId,\n              title: entry.title || \"Untitled\",\n              type: \"entry\" as const,\n            }\n          : null\n      })\n      .filter(Boolean) as SearchItem[]\n  }, [recentEntryIds])\n\n  // Combine all search items\n  const allItems = useMemo(() => {\n    return [...categoryItems, ...feedItems, ...entryItems]\n  }, [categoryItems, feedItems, entryItems])\n\n  // Create Fuse instance for fuzzy search\n  const fuse = useMemo(() => {\n    return new Fuse(allItems, fuseOptions)\n  }, [allItems, JSON.stringify(fuseOptions)])\n\n  // Calculate type ratios for proportional result distribution\n  const typeRatios = useMemo(() => {\n    const totalItems = allItems.length\n    if (totalItems === 0) {\n      return { category: 0, feed: 0, entry: 0 }\n    }\n\n    const categoryCounts = allItems.reduce(\n      (acc, item) => {\n        acc[item.type] += 1\n        return acc\n      },\n      { category: 0, feed: 0, entry: 0 },\n    )\n\n    return {\n      category: categoryCounts.category / totalItems,\n      feed: categoryCounts.feed / totalItems,\n      entry: categoryCounts.entry / totalItems,\n    }\n  }, [allItems])\n\n  // Search function\n  const search = useMemo(() => {\n    const applyProportionalLimit = (items: SearchItem[], maxResults: number) => {\n      // Calculate max items per type based on ratios\n      const maxPerType = {\n        category: Math.max(1, Math.floor(maxResults * typeRatios.category)),\n        feed: Math.max(1, Math.floor(maxResults * typeRatios.feed)),\n        entry: Math.max(1, Math.floor(maxResults * typeRatios.entry)),\n      }\n\n      const counts = { category: 0, feed: 0, entry: 0 }\n      const result: SearchItem[] = []\n\n      for (const item of items) {\n        if (result.length >= maxResults) break\n\n        if (counts[item.type] < maxPerType[item.type]) {\n          result.push(item)\n          counts[item.type] += 1\n        }\n      }\n\n      return result\n    }\n\n    return (query: string, type?: \"feed\" | \"entry\" | \"category\", maxResults = 10): SearchItem[] => {\n      const matchesType = (item: SearchItem) => {\n        if (!type) return true\n        if (type === \"feed\") return item.type === \"feed\" || item.type === \"category\"\n        return item.type === type\n      }\n\n      if (!query.trim()) {\n        // If no query, return recent items of the specified type\n        const filteredItems = allItems.filter(matchesType)\n        return applyProportionalLimit(filteredItems, maxResults)\n      }\n\n      // Perform fuzzy search\n      const fuseResults = fuse.search(query)\n      const filteredResults = fuseResults.map((result) => result.item).filter(matchesType)\n\n      return applyProportionalLimit(filteredResults, maxResults)\n    }\n  }, [allItems, fuse, typeRatios])\n\n  return {\n    search,\n    feedItems,\n    entryItems,\n    categoryItems,\n    allItems,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFileUpload.ts",
    "content": "import { nanoid } from \"nanoid\"\nimport { useCallback } from \"react\"\nimport { toast } from \"sonner\"\n\nimport { useChatBlockActions } from \"../store/hooks\"\nimport type { FileAttachment } from \"../store/types\"\nimport type { ProcessFileOptions, ProcessFileResult } from \"../utils/file-processing\"\nimport { processAndUploadFile } from \"../utils/file-processing\"\n\nexport interface UseFileUploadOptions extends ProcessFileOptions {\n  /**\n   * Show success toast on successful upload\n   */\n  showSuccessToast?: boolean\n  /**\n   * Show error toast on upload failure\n   */\n  showErrorToast?: boolean\n  /**\n   * Custom success message for toast\n   */\n  successMessage?: string\n  /**\n   * Custom error message prefix for toast\n   */\n  errorMessagePrefix?: string\n}\n\nexport interface FileUploadHandlers {\n  /**\n   * Upload a single file with progress tracking\n   */\n  uploadFile: (file: File, id?: string) => Promise<ProcessFileResult>\n  /**\n   * Upload multiple files with progress tracking\n   */\n  uploadFiles: (files: File[] | FileList) => Promise<ProcessFileResult[]>\n  /**\n   * Handle file input change event\n   */\n  handleFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>\n  /**\n   * Handle drag and drop files\n   */\n  handleFileDrop: (files: FileList) => Promise<void>\n}\n\n/**\n * Hook for handling file uploads with progress tracking and block management\n */\nexport function useFileUpload(\n  options: Omit<UseFileUploadOptions, \"nonce\"> = {},\n): FileUploadHandlers {\n  const {\n    showSuccessToast = false,\n    showErrorToast = true,\n    successMessage = \"File uploaded successfully\",\n    errorMessagePrefix = \"File upload error\",\n    ...processOptions\n  } = options\n\n  const blockActions = useChatBlockActions()\n\n  const uploadFile = useCallback(\n    async (file: File, id?: string): Promise<ProcessFileResult> => {\n      // Create initial file attachment for immediate UI feedback\n\n      const initialFileAttachment: FileAttachment = {\n        id: id || nanoid(),\n        name: file.name,\n        type: file.type,\n        size: file.size,\n        dataUrl: \"\", // Will be filled during processing\n        uploadStatus: \"processing\",\n        uploadProgress: 0,\n      }\n\n      // Add the initial block immediately for real-time UI feedback\n      blockActions.addFileAttachment(initialFileAttachment)\n\n      try {\n        const result = await processAndUploadFile(\n          file,\n          { ...processOptions, nonce: initialFileAttachment.id },\n          (updatedAttachment) => {\n            // Update the attachment with the same ID to maintain consistency\n            const syncedAttachment = {\n              ...updatedAttachment,\n              id: initialFileAttachment.id, // Keep the same ID\n            }\n            blockActions.updateFileAttachment(initialFileAttachment.id, syncedAttachment)\n          },\n        )\n\n        if (result.success && result.fileAttachment) {\n          // Update the final completed state with the same ID\n          const finalAttachment = {\n            ...result.fileAttachment,\n            id: initialFileAttachment.id, // Keep the same ID\n          }\n\n          blockActions.updateFileAttachment(initialFileAttachment.id, finalAttachment)\n\n          if (showSuccessToast) {\n            toast.success(`${successMessage}: ${file.name}`)\n          }\n\n          // Return result with consistent ID\n          return {\n            ...result,\n            fileAttachment: finalAttachment,\n          }\n        } else {\n          // Update to error state\n          const errorAttachment: FileAttachment = {\n            ...initialFileAttachment,\n            uploadStatus: \"error\",\n            errorMessage: result.error || \"Upload failed\",\n            uploadProgress: undefined,\n          }\n\n          blockActions.updateFileAttachment(initialFileAttachment.id, errorAttachment)\n\n          if (showErrorToast && result.error) {\n            toast.error(`${errorMessagePrefix}: ${result.error}`)\n          }\n\n          return {\n            ...result,\n            fileAttachment: errorAttachment,\n          }\n        }\n      } catch (error) {\n        const errorMessage = error instanceof Error ? error.message : \"Unknown error\"\n\n        // Update to error state\n        const errorAttachment: FileAttachment = {\n          ...initialFileAttachment,\n          uploadStatus: \"error\",\n          errorMessage,\n          uploadProgress: undefined,\n        }\n\n        blockActions.updateFileAttachment(initialFileAttachment.id, errorAttachment)\n\n        if (showErrorToast) {\n          toast.error(`${errorMessagePrefix}: ${errorMessage}`)\n        }\n\n        console.error(\"File upload failed:\", error)\n\n        return {\n          success: false,\n          error: errorMessage,\n          fileAttachment: errorAttachment,\n        }\n      }\n    },\n    [\n      blockActions,\n      processOptions,\n      showSuccessToast,\n      showErrorToast,\n      successMessage,\n      errorMessagePrefix,\n    ],\n  )\n\n  const uploadFiles = useCallback(\n    async (files: File[] | FileList): Promise<ProcessFileResult[]> => {\n      const results: ProcessFileResult[] = []\n      const fileArray = Array.from(files)\n\n      // Process files sequentially to avoid overwhelming the server\n      for (const file of fileArray) {\n        const result = await uploadFile(file)\n        results.push(result)\n      }\n\n      return results\n    },\n    [uploadFile],\n  )\n\n  const handleFileInputChange = useCallback(\n    async (event: React.ChangeEvent<HTMLInputElement>) => {\n      const { files } = event.target\n      if (files && files.length > 0) {\n        await uploadFiles(files)\n      }\n      // Reset file input\n      event.target.value = \"\"\n    },\n    [uploadFiles],\n  )\n\n  const handleFileDrop = useCallback(\n    async (files: FileList) => {\n      if (files && files.length > 0) {\n        await uploadFiles(files)\n      }\n    },\n    [uploadFiles],\n  )\n\n  return {\n    uploadFile,\n    uploadFiles,\n    handleFileInputChange,\n    handleFileDrop,\n  }\n}\n\n/**\n * Convenience hook for file upload with default settings\n */\nexport function useFileUploadWithDefaults(): FileUploadHandlers {\n  return useFileUpload({\n    showErrorToast: true,\n    showSuccessToast: false,\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useLoadMessages.ts",
    "content": "import { useEffect, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { AIChatSessionService } from \"~/modules/ai-chat-session/service\"\n\nimport { AIPersistService } from \"../services\"\nimport { useChatActions } from \"../store/hooks\"\nimport type { BizUIMessage } from \"../store/types\"\n\nexport const useLoadMessages = (\n  chatId: string,\n  options?: { onLoad?: (messages: BizUIMessage[]) => void },\n) => {\n  const chatActions = useChatActions()\n\n  const [isLoading, setIsLoading] = useState(true)\n  const [isSyncingRemote, setIsSyncingRemote] = useState(false)\n\n  const onLoadEventCallback = useEventCallback((messages: BizUIMessage[]) => {\n    options?.onLoad?.(messages)\n  })\n\n  useEffect(() => {\n    if (chatActions.get().isLocal) {\n      AIPersistService.loadUIMessages(chatId)\n      setIsLoading(false)\n\n      return\n    }\n    let mounted = true\n    setIsLoading(true)\n    setIsSyncingRemote(false)\n    AIChatSessionService.syncSessionMessages(chatId)\n      .then(async (messages) => {\n        if (!mounted) {\n          return []\n        }\n        chatActions.setMessages(messages)\n        onLoadEventCallback(messages)\n        return messages\n      })\n      .catch((error) => {\n        console.error(error)\n      })\n      .finally(() => {\n        if (mounted) {\n          setIsLoading(false)\n          setIsSyncingRemote(false)\n        }\n      })\n    return () => {\n      mounted = false\n    }\n  }, [chatId, onLoadEventCallback, chatActions])\n  return { isLoading, isSyncingRemote }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useMainEntryId.ts",
    "content": "import { useAIChatStore } from \"../store/AIChatContext\"\n\n/**\n * Hook to get the current main entry ID from the AI chat store.\n * Returns undefined if no entry context is available.\n *\n * This hook accesses the mainEntry block that gets set when viewing an entry,\n * enabling context-aware AI chat features.\n */\nexport const useMainEntryId = (): string | undefined => {\n  return useAIChatStore()((state) => {\n    const block = state.blocks.find((b) => b.type === \"mainEntry\")\n    return block && block.type === \"mainEntry\" ? block.value : undefined\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useSendAIShortcut.ts",
    "content": "import { convertLexicalToMarkdown } from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from \"@follow/shared/settings/defaults\"\nimport type { AIShortcut } from \"@follow/shared/settings/interface\"\nimport { getCategoryFeedIds } from \"@follow/store/subscription/getter\"\nimport type { EditorState } from \"lexical\"\nimport { $createParagraphNode, $getRoot, createEditor } from \"lexical\"\nimport { nanoid } from \"nanoid\"\nimport { use, useCallback, useMemo } from \"react\"\n\nimport {\n  getShortcutEffectivePrompt,\n  setAIPanelVisibility,\n  useAISettingKey,\n} from \"~/atoms/settings/ai\"\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport type { ShortcutData } from \"~/modules/ai-chat/editor\"\nimport { LexicalAIEditorNodes, ShortcutNode } from \"~/modules/ai-chat/editor\"\nimport { AIPanelRefsContext } from \"~/modules/ai-chat/store/AIChatContext\"\nimport { useBlockActions, useChatActions } from \"~/modules/ai-chat/store/hooks\"\nimport type { AIChatContextBlock, SendingUIMessage } from \"~/modules/ai-chat/store/types\"\nimport { prefixMessageIdWithShortcut } from \"~/modules/ai-chat/utils/shortcut\"\n\ntype ShortcutLike = ShortcutData | AIShortcut\n\ntype ShortcutResolver =\n  | {\n      shortcutId: string\n      shortcut?: never\n    }\n  | {\n      shortcutId?: never\n      shortcut: ShortcutLike\n    }\n\ntype SendAIShortcutOptions = ShortcutResolver & {\n  behavior?: \"send\" | \"prefill\"\n  ensureNewChat?: boolean\n  openPanel?: boolean\n  onSend?: (editorState: EditorState) => void | Promise<void>\n}\n\nexport const useSendAIShortcut = () => {\n  const shortcuts = useAISettingKey(\"shortcuts\")\n  const chatActions = useChatActions()\n  const blockActions = useBlockActions()\n  const aiPanelRefs = use(AIPanelRefsContext)\n  const { ensureLogin } = useRequireLogin()\n\n  const staticEditor = useMemo(() => {\n    return createEditor({\n      nodes: LexicalAIEditorNodes,\n    })\n  }, [])\n\n  const createShortcutEditorState = useCallback((shortcutData: ShortcutData): EditorState => {\n    const tempEditor = createEditor({\n      nodes: LexicalAIEditorNodes,\n    })\n\n    tempEditor.update(\n      () => {\n        const root = $getRoot()\n        root.clear()\n        const paragraph = $createParagraphNode()\n        const shortcutNode = new ShortcutNode(shortcutData)\n        paragraph.append(shortcutNode)\n        root.append(paragraph)\n      },\n      {\n        discrete: true,\n      },\n    )\n\n    return tempEditor.getEditorState()\n  }, [])\n\n  const resolveShortcut = useCallback(\n    ({ shortcutId, shortcut }: { shortcutId?: string; shortcut?: ShortcutLike }) => {\n      const allShortcuts = shortcuts ?? []\n      const source =\n        shortcut ??\n        (shortcutId\n          ? allShortcuts.find((item) => item.id === shortcutId && item.enabled)\n          : undefined)\n\n      if (!source) {\n        return null\n      }\n\n      if (\"enabled\" in source && source.enabled === false) {\n        return null\n      }\n\n      const promptSource =\n        \"defaultPrompt\" in source ? getShortcutEffectivePrompt(source as AIShortcut) : source.prompt\n\n      const prompt = (promptSource || \"\").trim()\n      if (!prompt) {\n        return null\n      }\n\n      return {\n        id: source.id,\n        name: source.name || source.id,\n        prompt,\n        hotkey: source.hotkey,\n        displayTargets: source.displayTargets,\n      } satisfies ShortcutData\n    },\n    [shortcuts],\n  )\n\n  const buildContextBlocks = useCallback((): AIChatContextBlock[] => {\n    const blocks: AIChatContextBlock[] = []\n\n    for (const block of blockActions.getBlocks()) {\n      if (block.type === \"fileAttachment\" && block.attachment.serverUrl) {\n        blocks.push({\n          ...block,\n          attachment: {\n            id: block.attachment.id,\n            name: block.attachment.name,\n            type: block.attachment.type,\n            size: block.attachment.size,\n            serverUrl: block.attachment.serverUrl,\n          },\n        })\n      } else if (block.type === \"mainFeed\" && block.value.startsWith(ROUTE_FEED_IN_FOLDER)) {\n        const categoryName = block.value.slice(ROUTE_FEED_IN_FOLDER.length)\n        const { view } = getRouteParams()\n        const feedIds = getCategoryFeedIds(categoryName, view)\n        blocks.push({\n          ...block,\n          value: feedIds.join(\",\"),\n        })\n      } else {\n        blocks.push(block)\n      }\n    }\n\n    return blocks.filter((block) => !block.disabled)\n  }, [blockActions])\n\n  const sendShortcutMessage = useCallback(\n    (editorState: EditorState, shortcutId?: string) => {\n      const isTimelineSummaryShortcut = shortcutId === DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID\n      if (!isTimelineSummaryShortcut && !ensureLogin()) {\n        return\n      }\n      const contextBlocks = buildContextBlocks()\n\n      staticEditor.setEditorState(editorState)\n\n      const parts: SendingUIMessage[\"parts\"] = [\n        {\n          type: \"data-block\",\n          data: contextBlocks,\n        },\n        {\n          type: \"data-rich-text\",\n          data: {\n            state: JSON.stringify(editorState.toJSON()),\n            text: convertLexicalToMarkdown(staticEditor),\n          },\n        },\n      ]\n\n      const message: SendingUIMessage = {\n        parts,\n        role: \"user\",\n        id: prefixMessageIdWithShortcut(nanoid(), shortcutId),\n      }\n\n      void chatActions.sendMessage(\n        message,\n        isTimelineSummaryShortcut\n          ? {\n              body: {\n                scene: \"timeline-summary\",\n              },\n            }\n          : undefined,\n      )\n    },\n    [buildContextBlocks, chatActions, ensureLogin, staticEditor],\n  )\n\n  const prefillInput = useCallback(\n    (editorState: EditorState) => {\n      const editorRef = aiPanelRefs?.inputRef?.current\n      if (!editorRef) {\n        return false\n      }\n\n      const lexicalEditor = editorRef.getEditor()\n      const serialized = editorState.toJSON()\n      const parsedState = lexicalEditor.parseEditorState(serialized)\n      lexicalEditor.setEditorState(parsedState)\n      editorRef.focus()\n\n      return true\n    },\n    [aiPanelRefs],\n  )\n\n  const sendAIShortcut = useCallback(\n    async (options: SendAIShortcutOptions) => {\n      const {\n        behavior = \"send\",\n        ensureNewChat = false,\n        openPanel = true,\n        onSend,\n        shortcut,\n        shortcutId,\n      } = options\n\n      if (!shortcut && !shortcutId) {\n        return false\n      }\n\n      if (openPanel) {\n        setAIPanelVisibility(true)\n      }\n\n      const shortcutData = resolveShortcut({ shortcut, shortcutId })\n\n      if (!shortcutData) {\n        return false\n      }\n\n      const editorState = createShortcutEditorState(shortcutData)\n\n      if (behavior === \"prefill\") {\n        return prefillInput(editorState)\n      }\n\n      if (typeof onSend === \"function\") {\n        await onSend(editorState)\n        return true\n      }\n\n      if (ensureNewChat) {\n        await chatActions.newChat()\n      }\n\n      sendShortcutMessage(editorState, shortcutData.id)\n      return true\n    },\n    [chatActions, createShortcutEditorState, prefillInput, resolveShortcut, sendShortcutMessage],\n  )\n\n  const hasShortcut = useCallback(\n    (shortcutId: string) => {\n      return !!resolveShortcut({ shortcutId })\n    },\n    [resolveShortcut],\n  )\n\n  return {\n    sendAIShortcut,\n    hasShortcut,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useTimelineSummaryAutoContext.ts",
    "content": "import { ROUTE_ENTRY_PENDING } from \"~/constants\"\nimport type { BizRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\nexport type TimelineSummaryContextParams = Pick<BizRouteParams, \"entryId\">\n\nexport const isTimelineSummaryAutoContext = ({ entryId }: TimelineSummaryContextParams) => {\n  return !entryId || entryId === ROUTE_ENTRY_PENDING\n}\n\nexport const useTimelineSummaryAutoContext = () =>\n  useRouteParamsSelector(({ entryId }) => isTimelineSummaryAutoContext({ entryId }))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/services/index.ts",
    "content": "import { db } from \"@follow/database/db\"\nimport type { AiChatMessagesModel } from \"@follow/database/schemas/index\"\nimport { aiChatMessagesTable, aiChatTable } from \"@follow/database/schemas/index\"\nimport { and, asc, eq, inArray, isNull, sql } from \"drizzle-orm\"\n\nimport { getI18n } from \"~/i18n\"\nimport { followClient } from \"~/lib/api-client\"\n\nimport { AI_CHAT_SPECIAL_ID_PREFIX } from \"../constants\"\nimport type { BizUIMessage, BizUIMessagePart, BizUIMetadata } from \"../store/types\"\nimport { isDataBlockPart, isFileAttachmentBlock } from \"../utils/extractor\"\n\nclass AIPersistServiceStatic {\n  // Cache for session existence to avoid repeated queries\n  private sessionExistsCache = new Map<string, boolean>()\n\n  // Clear cache when session is created or deleted\n  private markSessionExists(chatId: string, exists: boolean) {\n    this.sessionExistsCache.set(chatId, exists)\n  }\n\n  private getSessionExistsFromCache(chatId: string): boolean | undefined {\n    return this.sessionExistsCache.get(chatId)\n  }\n\n  private clearSessionCache(chatId?: string) {\n    if (chatId) {\n      this.sessionExistsCache.delete(chatId)\n    } else {\n      this.sessionExistsCache.clear()\n    }\n  }\n\n  async loadMessages(chatId: string): Promise<AiChatMessagesModel[]> {\n    return db.query.aiChatMessagesTable.findMany({\n      where: eq(aiChatMessagesTable.chatId, chatId),\n      orderBy: [asc(aiChatMessagesTable.createdAt)],\n    })\n  }\n\n  async hasPersistedMessages(chatId: string): Promise<boolean> {\n    const existingMessage = await db.query.aiChatMessagesTable.findFirst({\n      where: eq(aiChatMessagesTable.chatId, chatId),\n      columns: {\n        id: true,\n      },\n    })\n\n    return Boolean(existingMessage?.id === chatId)\n  }\n\n  async hasAssistantMessagesMissingMetadata(chatId: string): Promise<boolean> {\n    const missingMetadataMessage = await db.query.aiChatMessagesTable.findFirst({\n      where: and(\n        eq(aiChatMessagesTable.chatId, chatId),\n        eq(aiChatMessagesTable.role, \"assistant\"),\n        isNull(aiChatMessagesTable.metadata),\n      ),\n      columns: {\n        id: true,\n      },\n    })\n\n    return Boolean(missingMetadataMessage?.id)\n  }\n\n  /**\n   * Convert enhanced database message to BizUIMessage format for compatibility\n   */\n  private convertToUIMessage(dbMessage: AiChatMessagesModel): BizUIMessage {\n    const uiMessage: BizUIMessage = {\n      id: dbMessage.id,\n      role: dbMessage.role,\n      createdAt: dbMessage.createdAt,\n      parts: [],\n      metadata: (dbMessage.metadata ?? undefined) as BizUIMetadata | undefined,\n    }\n\n    if (dbMessage.messageParts && dbMessage.messageParts.length > 0) {\n      uiMessage.parts = dbMessage.messageParts as any[] as BizUIMessagePart[]\n    }\n\n    return uiMessage\n  }\n\n  /**\n   * Enhanced message loading that converts to UIMessage format\n   */\n  async loadUIMessages(chatId: string): Promise<BizUIMessage[]> {\n    const dbMessages = await this.loadMessages(chatId)\n    return dbMessages.map((msg) => this.convertToUIMessage(msg))\n  }\n\n  /**\n   * Load session and messages in a single optimized call\n   * Returns both session details and messages to avoid redundant queries\n   */\n  async loadSessionWithMessages(chatId: string): Promise<{\n    session: {\n      chatId: string\n      title?: string\n      createdAt: Date\n      updatedAt: Date\n      isLocal: boolean\n      syncStatus: \"local\" | \"synced\"\n    } | null\n    messages: BizUIMessage[]\n  }> {\n    // Load both session and messages in parallel\n    const [sessionRaw, messages] = await Promise.all([\n      this.getChatSession(chatId),\n      this.loadUIMessages(chatId),\n    ])\n\n    // Convert null title to undefined for type compatibility\n    if (!sessionRaw) {\n      return { session: null, messages }\n    }\n\n    const resolvedTitle = this.resolveSessionTitle(sessionRaw.chatId, sessionRaw.title, {\n      createdAt: sessionRaw.createdAt,\n      updatedAt: sessionRaw.updatedAt,\n    })\n\n    if (resolvedTitle && sessionRaw.title !== resolvedTitle) {\n      await this.updateSessionTitle(sessionRaw.chatId, resolvedTitle)\n    }\n\n    const isLocal = Boolean(sessionRaw.isLocal)\n    const syncStatus: \"local\" | \"synced\" = isLocal ? \"local\" : \"synced\"\n\n    const session = {\n      ...sessionRaw,\n      title: resolvedTitle ?? undefined,\n      isLocal,\n      syncStatus,\n    }\n\n    return { session, messages }\n  }\n\n  async replaceAllMessages(chatId: string, messages: BizUIMessage[]) {\n    await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.chatId, chatId))\n    await this.upsertMessages(chatId, messages)\n  }\n\n  /**\n   * Upsert specific messages (insert new, update existing)\n   * Ensures the chat session exists before inserting messages\n   */\n  async upsertMessages(chatId: string, messages: BizUIMessage[]) {\n    if (messages.length === 0) {\n      return\n    }\n\n    // Ensure the chat session exists first to avoid foreign key constraint failure\n    await this.ensureSession(chatId)\n\n    const results = messages.reduce<(typeof aiChatMessagesTable.$inferInsert)[]>((acc, message) => {\n      if (message.parts.length === 0) return acc\n\n      const { createdAt } = message\n      const cleanParts = [] as typeof message.parts\n\n      for (const part of message.parts) {\n        // Skip streaming messages\n        if (\"state\" in part && part.state === \"streaming\") {\n          return acc\n        }\n        if (isDataBlockPart(part)) {\n          const nextPart = structuredClone(part)\n          for (const block of nextPart.data) {\n            if (isFileAttachmentBlock(block)) {\n              Reflect.deleteProperty(block.attachment, \"dataUrl\")\n            }\n          }\n\n          cleanParts.push(nextPart)\n        } else {\n          cleanParts.push(part)\n        }\n      }\n\n      acc.push({\n        id: message.id,\n        chatId,\n        role: message.role,\n        createdAt,\n        status: \"completed\" as const,\n        finishedAt: message.metadata?.finishTime\n          ? new Date(message.metadata.finishTime)\n          : undefined,\n        messageParts: cleanParts,\n        metadata: message.metadata,\n      })\n\n      return acc\n    }, [])\n    if (results.length === 0) {\n      return\n    }\n    await db\n      .insert(aiChatMessagesTable)\n      .values(results)\n      .onConflictDoUpdate({\n        target: [aiChatMessagesTable.id],\n        set: {\n          messageParts: sql`excluded.message_parts`,\n          metadata: sql`excluded.metadata`,\n          finishedAt: sql`excluded.finished_at`,\n          status: sql`excluded.status`,\n          createdAt: sql`excluded.created_at`,\n        },\n      })\n\n    const date = results.reduce<Date | null>((latest, msg) => {\n      const date = msg.createdAt ? new Date(msg.createdAt) : null\n      if (date === null) {\n        return latest\n      }\n      if (!latest || date > latest) {\n        return date\n      }\n      return latest\n    }, null)\n    if (date) {\n      // Update session time after successfully saving messages\n      await AIPersistService.updateSessionTime(chatId, date)\n    }\n  }\n\n  /**\n   * Delete specific messages by ID\n   */\n  async deleteMessages(chatId: string, messageIds: string[]) {\n    if (messageIds.length === 0) {\n      return\n    }\n\n    await db\n      .delete(aiChatMessagesTable)\n      .where(eq(aiChatMessagesTable.chatId, chatId) && inArray(aiChatMessagesTable.id, messageIds))\n  }\n\n  private resolveSessionTitle(\n    chatId: string,\n    title?: string | null,\n    timestamps?: { createdAt?: Date; updatedAt?: Date },\n  ): string | undefined {\n    const trimmed = title?.trim()\n    if (trimmed) {\n      return trimmed\n    }\n\n    return this.getDefaultSessionTitle(chatId, timestamps)\n  }\n\n  private getDefaultSessionTitle(\n    chatId: string,\n    timestamps?: { createdAt?: Date; updatedAt?: Date },\n  ): string | undefined {\n    const i18n = getI18n()\n    const prefix = AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY\n\n    if (!chatId.startsWith(prefix)) {\n      const referenceDate = timestamps?.updatedAt ?? timestamps?.createdAt ?? new Date()\n      const formattedDateTime = this.formatDateTime(referenceDate, i18n.language)\n\n      return `${formattedDateTime} chat`\n    }\n\n    const datePart = chatId.slice(prefix.length)\n    const [yearStr, monthStr, dayStr] = datePart.split(\"-\")\n\n    const now = new Date()\n    const year = Number.parseInt(yearStr ?? \"\", 10)\n    const month = Number.parseInt(monthStr ?? \"\", 10)\n    const day = Number.parseInt(dayStr ?? \"\", 10)\n\n    let targetDate = new Date(now)\n    if (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {\n      const parsedDate = new Date(year, month - 1, day, now.getHours(), now.getMinutes())\n      if (!Number.isNaN(parsedDate.getTime())) {\n        targetDate = parsedDate\n      }\n    }\n\n    const formattedDateTime = this.formatDateTime(targetDate, i18n.language)\n\n    return `${formattedDateTime} timeline summary`\n  }\n\n  private formatDateTime(date: Date, locale?: string): string {\n    try {\n      const resolvedLocale = locale && locale.length > 0 ? locale : undefined\n      const timeFormatter = new Intl.DateTimeFormat(resolvedLocale, {\n        hour: \"numeric\",\n      })\n      const dateFormatter = new Intl.DateTimeFormat(resolvedLocale, {\n        dateStyle: \"medium\",\n      })\n\n      const formattedTime = timeFormatter.format(date)\n      const formattedDate = dateFormatter.format(date)\n\n      return `${formattedTime} ${formattedDate}`\n    } catch {\n      const pad = (value: number) => value.toString().padStart(2, \"0\")\n      const year = date.getFullYear()\n      const month = pad(date.getMonth() + 1)\n      const day = pad(date.getDate())\n      const hours = pad(date.getHours())\n      return `${hours} ${year}-${month}-${day}`\n    }\n  }\n\n  /**\n   * Ensure session exists (idempotent operation)\n   */\n  async ensureSession(\n    chatId: string,\n    options: { title?: string; createdAt?: Date; updatedAt?: Date; isLocal?: boolean } = {},\n  ): Promise<void> {\n    const cachedExists = this.getSessionExistsFromCache(chatId)\n    const shouldCheckDb =\n      cachedExists !== true || options.title !== undefined || typeof options.isLocal === \"boolean\"\n\n    if (!shouldCheckDb) {\n      return\n    }\n\n    const existing = await this.getChatSession(chatId)\n\n    if (existing) {\n      this.markSessionExists(chatId, true)\n\n      const updates: Partial<typeof aiChatTable.$inferInsert> = {}\n      let shouldUpdate = false\n\n      const hasExistingTitle = existing.title?.trim().length\n      if (!hasExistingTitle) {\n        const resolvedTitle = this.resolveSessionTitle(chatId, options.title ?? existing.title, {\n          createdAt: existing.createdAt,\n          updatedAt: existing.updatedAt,\n        })\n        if (resolvedTitle && resolvedTitle !== existing.title) {\n          updates.title = resolvedTitle\n          shouldUpdate = true\n        }\n      }\n\n      if (typeof options.isLocal === \"boolean\" && existing.isLocal !== options.isLocal) {\n        updates.isLocal = options.isLocal\n        shouldUpdate = true\n      }\n\n      if (shouldUpdate) {\n        updates.updatedAt = new Date()\n        await db.update(aiChatTable).set(updates).where(eq(aiChatTable.chatId, chatId))\n      }\n      return\n    }\n\n    // Create new session\n    await this.createSession(chatId, options)\n    this.markSessionExists(chatId, true)\n  }\n\n  async createSession(\n    chatId: string,\n    options: { title?: string; createdAt?: Date; updatedAt?: Date; isLocal?: boolean } = {},\n  ) {\n    const now = new Date()\n    await db.insert(aiChatTable).values({\n      chatId,\n      title: this.resolveSessionTitle(chatId, options.title, { createdAt: now, updatedAt: now }),\n      createdAt: options.createdAt ?? now,\n      updatedAt: options.updatedAt ?? now,\n      isLocal: options.isLocal ?? true,\n    })\n    // Mark session as existing in cache\n    this.markSessionExists(chatId, true)\n  }\n\n  async findTimelineSummarySession(criteria: {\n    view: number\n    feedId: string\n    timelineId?: string | null\n    unreadOnly: boolean\n  }) {\n    const timelineSegment = criteria.timelineId ?? \"all\"\n    const unreadSegment = criteria.unreadOnly ? \"unread\" : \"all\"\n    const prefix = `${AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY}${criteria.view}:${criteria.feedId}:${timelineSegment}:${unreadSegment}:`\n    return db.query.aiChatTable\n      .findFirst({\n        where: (table) => sql`${table.chatId} LIKE ${`${prefix}%`}`,\n        orderBy: (table, { desc }) => desc(table.updatedAt),\n        columns: {\n          chatId: true,\n          title: true,\n          createdAt: true,\n          updatedAt: true,\n        },\n      })\n      .then((session) => session ?? null)\n  }\n\n  async getChatSession(chatId: string) {\n    const result = await db.query.aiChatTable.findFirst({\n      where: eq(aiChatTable.chatId, chatId),\n      columns: {\n        chatId: true,\n        title: true,\n        createdAt: true,\n        updatedAt: true,\n        isLocal: true,\n      },\n    })\n    return result?.chatId ? result : null\n  }\n\n  async getChatSessions(limit = 20) {\n    const chats = await db.query.aiChatTable.findMany({\n      columns: {\n        chatId: true,\n        title: true,\n        createdAt: true,\n        updatedAt: true,\n        isLocal: true,\n      },\n      orderBy: (t, { desc }) => desc(t.updatedAt),\n      limit,\n    })\n\n    if (chats.length === 0) {\n      return []\n    }\n\n    const normalizedChats = await Promise.all(\n      chats.map(async (chat) => {\n        const resolvedTitle = this.resolveSessionTitle(chat.chatId, chat.title, {\n          createdAt: chat.createdAt,\n          updatedAt: chat.updatedAt,\n        })\n\n        if (resolvedTitle && chat.title !== resolvedTitle) {\n          await this.updateSessionTitle(chat.chatId, resolvedTitle)\n        }\n\n        return {\n          ...chat,\n          title: resolvedTitle ?? chat.title ?? undefined,\n        }\n      }),\n    )\n\n    return normalizedChats.map((chat) => {\n      const isLocal = Boolean(chat.isLocal)\n      const syncStatus: \"local\" | \"synced\" = isLocal ? \"local\" : \"synced\"\n\n      return {\n        chatId: chat.chatId,\n        title: chat.title,\n        createdAt: chat.createdAt,\n        updatedAt: chat.updatedAt,\n        isLocal,\n        syncStatus,\n      }\n    })\n  }\n\n  async deleteSession(chatId: string) {\n    await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.chatId, chatId))\n    await db.delete(aiChatTable).where(eq(aiChatTable.chatId, chatId))\n    // Clear session from cache\n    this.clearSessionCache(chatId)\n    await followClient.api.aiChatSessions.delete({ chatId }).catch((error) => {\n      console.error(\"Failed to delete remote chat session:\", error)\n    })\n  }\n\n  async updateSessionTitle(chatId: string, title: string) {\n    await db\n      .update(aiChatTable)\n      .set({\n        title,\n        updatedAt: new Date(Date.now()),\n      })\n      .where(eq(aiChatTable.chatId, chatId))\n  }\n\n  async updateSessionTime(chatId: string, date: Date = new Date()) {\n    await db\n      .update(aiChatTable)\n      .set({\n        updatedAt: date,\n      })\n      .where(eq(aiChatTable.chatId, chatId))\n  }\n\n  async markSessionSynced(chatId: string) {\n    await this.ensureSession(chatId, { isLocal: false })\n  }\n\n  async cleanupEmptySessions() {\n    const emptySessions = await db.values<[string]>(\n      sql`\n        SELECT ${aiChatTable.chatId}\n        FROM ${aiChatTable}\n        LEFT JOIN ${aiChatMessagesTable} ON ${aiChatTable.chatId} = ${aiChatMessagesTable.chatId}\n        GROUP BY ${aiChatTable.chatId}\n        HAVING COUNT(${aiChatMessagesTable.id}) = 0\n      `,\n    )\n\n    // Delete empty sessions\n    if (emptySessions.length > 0) {\n      const chatIdsToDelete = emptySessions.map((row) => row[0])\n      await db.delete(aiChatTable).where(inArray(aiChatTable.chatId, chatIdsToDelete))\n\n      // Clear deleted sessions from cache\n      chatIdsToDelete.forEach((chatId) => this.clearSessionCache(chatId))\n      for (const chatId of chatIdsToDelete) {\n        await followClient.api.aiChatSessions.delete({ chatId }).catch((error) => {\n          console.error(\"Failed to delete remote chat sessions:\", error)\n        })\n      }\n    }\n  }\n}\nexport const AIPersistService = new AIPersistServiceStatic()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/AIChatContext.ts",
    "content": "import type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/types.js\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { createContext, use } from \"react\"\nimport type { StoreApi } from \"zustand\"\nimport type { UseBoundStoreWithEqualityFn } from \"zustand/traditional\"\n\nimport type { AiChatStore } from \"./store\"\n\nexport type AIPanelRefs = {\n  inputRef: React.RefObject<LexicalRichEditorRef>\n}\n\nexport const AIPanelRefsContext = createContext<AIPanelRefs>(null!)\n\nexport const AIChatStoreContext = createContext<UseBoundStoreWithEqualityFn<StoreApi<AiChatStore>>>(\n  null!,\n)\n\nexport const useAIChatStore = () => {\n  const store = use(AIChatStoreContext)\n  if (!store && import.meta.env.DEV) {\n    throw new Error(\"useAIChatStore must be used within a AIChatStoreContext\")\n  }\n  return store\n}\n\nexport type AIRootStateContext = {\n  isScrolledBeyondThreshold: PrimitiveAtom<boolean>\n}\n\nexport const AIRootStateContext = createContext<AIRootStateContext>(null!)\n\nexport const useAIRootState = () => {\n  const context = use(AIRootStateContext)\n  if (!context) {\n    throw new Error(\"useAIRootState must be used within a AIRootStateContext\")\n  }\n  return context\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts",
    "content": "import { autoBindThis } from \"@follow/utils/bind-this\"\nimport { createDesktopAPIHeaders } from \"@follow/utils/headers\"\nimport PKG from \"@pkg\"\nimport type { ChatRequestOptions, ChatStatus } from \"ai\"\nimport { merge } from \"es-toolkit/compat\"\nimport { nanoid } from \"nanoid\"\nimport type { StateCreator } from \"zustand\"\n\nimport { AIPersistService } from \"../../services\"\nimport { createChatTitleHandler, createChatTransport } from \"../transport\"\nimport type { BizUIMessage, SendingUIMessage } from \"../types\"\nimport { ZustandChat } from \"./chat-instance\"\nimport type { ChatSlice } from \"./types\"\n\nexport class ChatSliceActions {\n  // Hold reference to the most recently constructed (active) ChatSliceActions instance\n  private static _current: ChatSliceActions | null = null\n\n  /**\n   * Get the currently active ChatSliceActions instance.\n   *\n   * WARNING: Anti-pattern — temporary global accessor used. Do NOT use in new code.\n   * This may be removed/refactored.\n   */\n  static getActiveInstance(): ChatSliceActions | null {\n    if (!this._current) return null\n    return this._current\n  }\n\n  /**\n   * See warning above — this setter exists solely for the same limited purpose.\n   */\n  static setActiveInstance(instance: ChatSliceActions | null) {\n    this._current = instance\n  }\n\n  private chatInstance: ZustandChat\n  constructor(\n    private params: Parameters<StateCreator<ChatSlice, [], [], ChatSlice>>,\n\n    options: {\n      chatInstance: ZustandChat\n      hasChatId: boolean\n    },\n  ) {\n    if (options.hasChatId) {\n      options.chatInstance.resumeStream()\n    }\n    this.chatInstance = options.chatInstance\n    return autoBindThis(this)\n  }\n\n  get set() {\n    return this.params[0]\n  }\n\n  get get() {\n    return this.params[1]\n  }\n\n  private computeSyncStatus(isLocal: boolean): \"local\" | \"synced\" {\n    return isLocal ? \"local\" : \"synced\"\n  }\n\n  private setSyncState(isLocal: boolean) {\n    this.set((state) => {\n      const nextStatus = this.computeSyncStatus(isLocal)\n      if (state.isLocal === isLocal && state.syncStatus === nextStatus) {\n        return state\n      }\n      return {\n        isLocal,\n        syncStatus: nextStatus,\n      }\n    })\n  }\n\n  async markSessionSynced() {\n    const currentChatId = this.get().chatId\n    if (!currentChatId) {\n      return\n    }\n\n    if (!this.get().isLocal) {\n      return\n    }\n\n    this.setSyncState(false)\n\n    try {\n      await AIPersistService.markSessionSynced(currentChatId)\n    } catch (error) {\n      console.error(\"Failed to mark chat session as synced:\", error)\n    }\n  }\n\n  // Direct message management methods (delegating to chat instance state)\n  setMessages = (\n    messagesParam: BizUIMessage[] | ((messages: BizUIMessage[]) => BizUIMessage[]),\n  ) => {\n    if (typeof messagesParam === \"function\") {\n      this.chatInstance.chatState.messages = messagesParam(this.chatInstance.chatState.messages)\n    } else {\n      this.chatInstance.chatState.messages = messagesParam\n    }\n  }\n\n  pushMessage = (message: BizUIMessage) => {\n    this.chatInstance.chatState.pushMessage(message)\n  }\n\n  popMessage = () => {\n    this.chatInstance.chatState.popMessage()\n  }\n\n  replaceMessage = (index: number, message: BizUIMessage) => {\n    this.chatInstance.chatState.replaceMessage(index, message)\n  }\n\n  updateMessage = (id: string, updates: Partial<BizUIMessage>) => {\n    const messageIndex = this.chatInstance.chatState.messages.findIndex(\n      (msg: BizUIMessage) => msg.id === id,\n    )\n    if (messageIndex !== -1) {\n      const message = this.chatInstance.chatState.messages[messageIndex]\n      if (message) {\n        const updatedMessage = { ...message, ...updates }\n        this.replaceMessage(messageIndex, updatedMessage)\n      }\n    }\n  }\n\n  // Getter\n  getChatInstance = (): ZustandChat => {\n    return this.chatInstance\n  }\n\n  getMessages = (): BizUIMessage[] => {\n    return this.chatInstance.chatState.messages\n  }\n\n  // Status management (delegating to chat instance state)\n  setStatus = (status: ChatStatus) => {\n    this.chatInstance.chatState.status = status\n  }\n\n  setError = (error: Error | undefined) => {\n    this.chatInstance.chatState.error = error\n  }\n\n  setStreaming = (streaming: boolean) => {\n    this.chatInstance.chatState.status = streaming ? \"streaming\" : \"ready\"\n  }\n\n  // Title management\n  setCurrentTitle = (title: string | undefined) => {\n    this.set((state) => ({ ...state, currentTitle: title }))\n  }\n\n  getCurrentTitle = (): string | undefined => {\n    return this.get().currentTitle\n  }\n\n  getCurrentChatId = (): string | null => {\n    return this.get().chatId\n  }\n\n  private createTransportTitleHandler = (chatId: string) => {\n    return createChatTitleHandler({\n      chatId,\n      getActiveChatId: () => this.get().chatId,\n      onTitleChange: (title) => {\n        this.setCurrentTitle(title)\n      },\n    })\n  }\n\n  // Edit chat title\n  editChatTitle = async (newTitle: string) => {\n    const currentChatId = this.getCurrentChatId()\n    if (!currentChatId) {\n      throw new Error(\"No active chat to edit title for\")\n    }\n\n    const trimmedTitle = newTitle.trim()\n    const currentTitle = this.getCurrentTitle()\n\n    // If no changes, return early\n    if (trimmedTitle === currentTitle) {\n      return\n    }\n\n    try {\n      // Optimistic update\n      this.setCurrentTitle(trimmedTitle)\n\n      // Persist to database\n      await AIPersistService.updateSessionTitle(currentChatId, trimmedTitle)\n    } catch (error) {\n      // Rollback on error\n      this.setCurrentTitle(currentTitle)\n      console.error(\"Failed to update chat title:\", error)\n      throw error\n    }\n  }\n\n  // Core chat actions using AI SDK AbstractChat methods\n  sendMessage = async (message: string | SendingUIMessage, options?: ChatRequestOptions) => {\n    try {\n      // Convert string to message object if needed\n      const messageObj =\n        typeof message === \"string\"\n          ? ({ parts: [{ type: \"text\", text: message }] } as Parameters<\n              typeof this.chatInstance.sendMessage\n            >[0])\n          : (message as Parameters<typeof this.chatInstance.sendMessage>[0])\n\n      // Use the AI SDK's sendMessage method\n      const finalOptions = merge(\n        {\n          body: { scene: this.get().scene },\n          headers: createDesktopAPIHeaders({ version: PKG.version }),\n        },\n        options,\n      )\n\n      return await this.chatInstance.sendMessage(messageObj, finalOptions)\n    } catch (error) {\n      this.setError(error as Error)\n      throw error\n    }\n  }\n\n  regenerate = async ({ messageId, ...options }: { messageId: string } & ChatRequestOptions) => {\n    try {\n      // Use the AI SDK's regenerate method\n      const finalOptions = merge(\n        {\n          body: { scene: this.get().scene },\n        },\n        options,\n      )\n      return await this.chatInstance.regenerate({ messageId, ...finalOptions })\n    } catch (error) {\n      this.setError(error as Error)\n      throw error\n    }\n  }\n\n  stop = () => {\n    // Use AI SDK's stop method\n    this.chatInstance.stop()\n  }\n\n  resumeStream = async () => {\n    try {\n      // Use AI SDK's resumeStream method\n      await this.chatInstance.resumeStream()\n    } catch (error) {\n      this.setError(error as Error)\n      throw error\n    }\n  }\n\n  resetChat = () => {\n    // Reset through the chat instance state\n    this.chatInstance.chatState.messages = []\n    this.chatInstance.chatState.error = undefined\n    this.chatInstance.chatState.status = \"ready\"\n    // Reset title\n    this.setCurrentTitle(undefined)\n  }\n\n  newChat = async () => {\n    const newChatId = nanoid()\n    // Cleanup old chat instance\n    await this.chatInstance.destroy()\n\n    // Create new chat instance\n    const newChatInstance = new ZustandChat(\n      {\n        id: newChatId,\n        messages: [],\n        transport: createChatTransport({\n          titleHandler: this.createTransportTitleHandler(newChatId),\n        }),\n      },\n      this.set,\n    )\n\n    // Update store state\n    this.set((state) => ({\n      ...state,\n      chatId: newChatId,\n      messages: [],\n      status: \"ready\" as ChatStatus,\n      error: undefined,\n      isStreaming: false,\n      currentTitle: undefined,\n      chatInstance: newChatInstance,\n      isLocal: true,\n      syncStatus: \"local\",\n    }))\n\n    // Update the reference\n    this.chatInstance = newChatInstance\n  }\n\n  switchToChat = async (chatId: string) => {\n    try {\n      // Cleanup old chat instance\n      await this.chatInstance.destroy()\n      // Set loading state (using ready as there's no loading status in ChatStatus)\n      this.setStatus(\"ready\")\n      this.setError(undefined)\n\n      // Load session and messages in parallel to reduce database queries\n      const { session: chatSession, messages } =\n        await AIPersistService.loadSessionWithMessages(chatId)\n\n      // Create new chat instance with loaded messages\n      const newChatInstance = new ZustandChat(\n        {\n          id: chatId,\n          messages,\n          transport: createChatTransport({\n            titleHandler: this.createTransportTitleHandler(chatId),\n          }),\n        },\n        this.set,\n      )\n\n      // Update store state\n      this.set((state) => ({\n        ...state,\n        chatId,\n        messages: [...messages],\n        status: \"ready\" as ChatStatus,\n        error: undefined,\n        isStreaming: false,\n        currentTitle: chatSession?.title || undefined,\n        chatInstance: newChatInstance,\n        isLocal: chatSession ? chatSession.isLocal : true,\n        syncStatus: chatSession ? chatSession.syncStatus : \"local\",\n      }))\n\n      await newChatInstance.resumeStream()\n      // Update the reference\n      this.chatInstance = newChatInstance\n    } catch (error) {\n      console.error(\"Failed to switch to chat:\", error)\n      this.setError(error as Error)\n      this.setStatus(\"ready\")\n      throw error\n    }\n  }\n\n  setScene = (scene: ChatSlice[\"scene\"]) => {\n    this.set((state) => ({ ...state, scene }))\n  }\n\n  setTimelineSummaryManualOverride = (override: boolean) => {\n    this.set((state) => {\n      if (state.timelineSummaryManualOverride === override) {\n        return state\n      }\n      return { ...state, timelineSummaryManualOverride: override }\n    })\n  }\n\n  getTimelineSummaryManualOverride = () => {\n    return this.get().timelineSummaryManualOverride\n  }\n\n  setTimelineSummaryWasInAutoContext = (isInAutoContext: boolean) => {\n    this.set((state) => {\n      if (state.timelineSummaryWasInAutoContext === isInAutoContext) {\n        return state\n      }\n      return { ...state, timelineSummaryWasInAutoContext: isInAutoContext }\n    })\n  }\n\n  getTimelineSummaryWasInAutoContext = () => {\n    return this.get().timelineSummaryWasInAutoContext\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-instance.ts",
    "content": "import type { ChatInit, ChatStatus } from \"ai\"\nimport { AbstractChat } from \"ai\"\n\nimport type { BizUIMessage } from \"../types\"\nimport { ZustandChatState } from \"./chat-state\"\nimport type { ChatSlice } from \"./types\"\n\n// Custom Chat class that uses Zustand-integrated state\nexport class ZustandChat extends AbstractChat<BizUIMessage> {\n  override state: ZustandChatState\n  #unsubscribeFns: (() => void)[] = []\n\n  constructor(\n    { messages, ...init }: ChatInit<BizUIMessage>,\n    updateZustandState: (updater: (state: ChatSlice) => ChatSlice) => void,\n  ) {\n    const state = new ZustandChatState(messages, updateZustandState, init.id || \"\")\n    super({ ...init, state })\n    this.state = state\n\n    const baseResumeStream = this.resumeStream.bind(this)\n    // Track resume calls so the state can ignore the temporary \"submitted\" status when no stream exists.\n    this.resumeStream = async (...args) => {\n      this.state.setResumingStream(true)\n      try {\n        return await baseResumeStream(...args)\n      } finally {\n        this.state.setResumingStream(false)\n      }\n    }\n  }\n\n  // Public getter for state access\n  get chatState() {\n    return this.state\n  }\n\n  // Cleanup method\n  async destroy(): Promise<void> {\n    await this.stop()\n    // Unsubscribe from AI SDK callbacks\n    this.#unsubscribeFns.forEach((unsubscribe) => unsubscribe())\n    this.#unsubscribeFns = []\n\n    this.state.destroy()\n  }\n\n  protected override setStatus({ status, error }: { status: ChatStatus; error?: Error }): void {\n    super.setStatus({ status, error })\n    this.state.status = status\n    this.state.error = error\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-state.ts",
    "content": "/* eslint-disable unicorn/no-for-loop */\nimport type { ChatState, ChatStatus } from \"ai\"\nimport { throttle } from \"es-toolkit/compat\"\nimport { produce } from \"immer\"\nimport { startTransition } from \"react\"\n\nimport { AIPersistService } from \"../../services\"\nimport { ChatStateEventEmitter } from \"../event-system/event-emitter\"\nimport type { BizUIMessage, SendingUIMessage } from \"../types\"\nimport type { ChatSlice } from \"./types\"\n\n// Zustand Chat State that implements AI SDK ChatState interface\nexport class ZustandChatState implements ChatState<BizUIMessage> {\n  #messages: BizUIMessage[]\n  #status: ChatStatus\n  #error: Error | undefined\n  #eventEmitter: ChatStateEventEmitter\n  #isResumingStream = false\n\n  constructor(\n    initialMessages: BizUIMessage[] = [],\n    private updateZustandState: (updater: (state: ChatSlice) => ChatSlice) => void,\n    private chatId: string,\n  ) {\n    this.#eventEmitter = new ChatStateEventEmitter()\n    this.#messages = initialMessages\n    this.#status = \"ready\"\n    this.#error = undefined\n    this.#setupEventHandlers()\n  }\n\n  #setupEventHandlers(): void {\n    // Setup event handlers for automatic Zustand synchronization\n    this.#eventEmitter.on(\"messages\", ({ messages }) => {\n      this.updateZustandState(\n        produce((state) => {\n          const stateMessages = state.messages\n          for (let i = 0; i < messages.length; i++) {\n            const message = messages[i]!\n            if (!stateMessages[i]) {\n              stateMessages[i] = structuredClone(message) as any\n            } else {\n              const stateMessage = stateMessages[i]!\n              stateMessage.id = message.id\n\n              for (let j = 0; j < message.parts.length; j++) {\n                const statePart = stateMessage.parts[j] || {}\n                const messagePart = message.parts[j]!\n\n                Object.assign(statePart, messagePart)\n                stateMessage.parts[j] = statePart as any\n              }\n\n              stateMessage.parts.length = message.parts.length\n\n              stateMessage.role = message.role\n\n              stateMessage.metadata = stateMessage.metadata ?? {}\n              Object.assign(stateMessage.metadata, message.metadata)\n            }\n          }\n          stateMessages.length = messages.length\n        }),\n      )\n    })\n\n    this.#eventEmitter.on(\"status\", ({ status }) => {\n      // Suppress the transient \"submitted\" status emitted when resumeStream probes for an active stream.\n      if (this.#isResumingStream && status === \"submitted\") {\n        return\n      }\n\n      this.updateZustandState((state) => {\n        const isStreaming = status === \"streaming\"\n        if (isStreaming) {\n          void state.chatActions.markSessionSynced()\n        }\n\n        if (\n          this.#isResumingStream &&\n          (status === \"ready\" || status === \"streaming\" || status === \"error\")\n        ) {\n          this.#isResumingStream = false\n        }\n\n        return {\n          ...state,\n          status,\n          isStreaming,\n        }\n      })\n    })\n\n    this.#eventEmitter.on(\"error\", ({ error }) => {\n      this.updateZustandState((state) => ({\n        ...state,\n        error,\n      }))\n    })\n  }\n\n  //// AI SDK ChatState abstract override methods or properties\n  get status(): ChatStatus {\n    return this.#status\n  }\n\n  set status(newStatus: ChatStatus) {\n    if (this.#status === newStatus) return\n\n    this.#status = newStatus\n    this.#eventEmitter.emit(\"status\", { status: newStatus })\n  }\n\n  get error(): Error | undefined {\n    return this.#error\n  }\n\n  set error(newError: Error | undefined) {\n    if (this.#error === newError) return\n\n    this.#error = newError\n    this.#eventEmitter.emit(\"error\", { error: newError })\n  }\n\n  get messages(): BizUIMessage[] {\n    return this.#messages\n  }\n\n  set messages(newMessages: BizUIMessage[]) {\n    startTransition(() => {\n      this.#messages = [...newMessages]\n\n      this.#eventEmitter.emit(\"messages\", { messages: this.#messages })\n\n      // Auto-persist messages when they change\n      this.#persistMessages()\n    })\n  }\n\n  pushMessage = (message: SendingUIMessage) => {\n    this.messages = this.#messages.concat(this.#fillMessageCreatedAt(message))\n  }\n\n  popMessage = () => {\n    if (this.#messages.length === 0) return\n\n    this.messages = this.#messages.slice(0, -1)\n  }\n\n  replaceMessage = (index: number, message: BizUIMessage) => {\n    if (index < 0 || index >= this.#messages.length) return\n\n    this.messages = [\n      ...this.#messages.slice(0, index),\n      this.snapshot(this.#fillMessageCreatedAt(message)),\n      ...this.#messages.slice(index + 1),\n    ]\n  }\n\n  snapshot = <T>(value: T): T => structuredClone(value)\n  //// AI SDK ChatState abstract override methods or properties\n  //// END\n\n  #persistMessages = throttle(\n    async () => {\n      // Skip if no messages\n      if (this.#messages.length === 0) return\n\n      try {\n        await AIPersistService.ensureSession(this.chatId)\n        // Save messages using incremental updates\n        await AIPersistService.replaceAllMessages(this.chatId, this.#messages)\n      } catch (error) {\n        console.error(\"Failed to persist messages:\", error)\n      }\n    },\n    100,\n    { leading: false, trailing: true },\n  )\n\n  #fillMessageCreatedAt(message: SendingUIMessage | BizUIMessage): BizUIMessage {\n    // we should directly edit the message object instead of creating a new one\n    const nextMessage = message as BizUIMessage\n\n    if (nextMessage.createdAt) return nextMessage\n    if (\n      nextMessage.role === \"assistant\" &&\n      nextMessage.metadata?.finishTime &&\n      nextMessage.metadata?.duration\n    ) {\n      nextMessage.createdAt = new Date(\n        new Date(nextMessage.metadata.finishTime).getTime() - nextMessage.metadata.duration,\n      )\n    } else {\n      nextMessage.createdAt = new Date()\n    }\n\n    return nextMessage\n  }\n\n  destroy(): void {\n    this.#eventEmitter.clear()\n  }\n\n  setResumingStream(isResuming: boolean) {\n    this.#isResumingStream = isResuming\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/types.ts",
    "content": "import type { ChatStatus } from \"ai\"\n\nimport type { BizUIMessage } from \"../types\"\nimport type { ChatSliceActions } from \"./chat-actions\"\nimport type { ZustandChat } from \"./chat-instance\"\n\n// Zustand slice interface\nexport interface ChatSlice {\n  chatId: string\n  messages: BizUIMessage[]\n  status: ChatStatus\n  error: Error | undefined\n  isStreaming: boolean\n  isLocal: boolean\n  syncStatus: \"local\" | \"synced\"\n\n  // UI state\n  currentTitle: string | undefined\n\n  // AI SDK Chat instance (forward declaration to avoid circular import)\n  chatInstance: ZustandChat\n\n  // Actions\n  chatActions: ChatSliceActions\n\n  // Scene\n  scene: \"general\" | \"onboarding\" | \"timeline-summary\"\n\n  timelineSummaryManualOverride: boolean\n  timelineSummaryWasInAutoContext: boolean\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/event-system/event-emitter.ts",
    "content": "import type { BizUIMessage } from \"../types\"\nimport type { ChatStateEvents, ChatStateEventType } from \"./types\"\n\n// Event emitter for AI chat state changes with typed payloads\nexport class ChatStateEventEmitter {\n  #listeners = new Map<ChatStateEventType, Set<(payload: any) => void>>()\n\n  on<T extends ChatStateEventType>(\n    event: T,\n    listener: (payload: ChatStateEvents<BizUIMessage>[T]) => void,\n  ): () => void {\n    if (!this.#listeners.has(event)) {\n      this.#listeners.set(event, new Set())\n    }\n    this.#listeners.get(event)!.add(listener)\n\n    // Return unsubscribe function\n    return () => {\n      this.#listeners.get(event)?.delete(listener)\n    }\n  }\n\n  emit<T extends ChatStateEventType>(event: T, payload: ChatStateEvents<BizUIMessage>[T]): void {\n    this.#listeners.get(event)?.forEach((listener) => {\n      try {\n        listener(payload)\n      } catch (error) {\n        console.error(`Error in chat state listener for event ${event}:`, error)\n      }\n    })\n  }\n\n  clear(): void {\n    this.#listeners.clear()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/event-system/types.ts",
    "content": "import type { ChatStatus } from \"ai\"\n\nimport type { BizUIMessage } from \"../types\"\n\n// Event types and payloads\nexport interface ChatStateEvents<UI_MESSAGE extends BizUIMessage> {\n  messages: { messages: UI_MESSAGE[] }\n  status: { status: ChatStatus }\n  error: { error: Error | undefined }\n}\n\nexport type ChatStateEventType = keyof ChatStateEvents<any>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts",
    "content": "import { useShallow } from \"zustand/shallow\"\n\nimport { useAIChatStore } from \"./AIChatContext\"\nimport type { BlockSlice } from \"./slices/block.slice\"\nimport type { BizUIMessage } from \"./types\"\n\n/**\n * Hook to get the current room ID (chat ID) from the AI chat store\n */\nexport const useCurrentChatId = () => {\n  const store = useAIChatStore()\n  return store((state) => state.chatId)\n}\n\n/**\n * Hook to get the current chat title from the AI chat store\n */\nexport const useCurrentTitle = () => {\n  const store = useAIChatStore()\n  return store((state) => state.currentTitle)\n}\n\n/**\n * Hook to get the chat actions\n */\nexport const useChatActions = () => {\n  const store = useAIChatStore()\n  return store((state) => state.chatActions)\n}\n\n/**\n * Hook to get the block actions\n */\nexport const useBlockActions = () => {\n  const store = useAIChatStore()\n  return store((state) => state.blockActions)\n}\n\n/**\n * Hook to get the current messages\n */\nexport const useMessages = () => {\n  const store = useAIChatStore()\n  return store((state) => state.messages)\n}\n\nexport const useMessageByIdSelector = <T>(\n  messageId: string,\n  selector: (message: BizUIMessage) => T,\n): T | undefined => {\n  const store = useAIChatStore()\n  return store(\n    useShallow((state) => {\n      const message = state.messages.find((message) => message.id === messageId)\n      return message ? selector(message) : undefined\n    }),\n  )\n}\n\n/**\n * Hook to check if the chat has messages\n */\nexport const useHasMessages = () => {\n  const store = useAIChatStore()\n  return store((state) => state.messages.length > 0)\n}\n\nexport const useIsLocalChat = () => {\n  const store = useAIChatStore()\n  return store((state) => state.isLocal)\n}\n\nexport const useSyncStatus = () => {\n  const store = useAIChatStore()\n  return store((state) => state.syncStatus)\n}\n\nexport const useSyncStateActions = () => {\n  const store = useAIChatStore()\n  return store((state) => state.chatActions)\n}\n\nexport const useChatBlockActions = () => useAIChatStore()((state) => state.blockActions)\n/**\n * Hook to get the chat status\n */\nexport const useChatStatus = () => {\n  const store = useAIChatStore()\n  return store((state) => state.status)\n}\n\n/**\n * Hook to get the chat error\n */\nexport const useChatError = () => {\n  const store = useAIChatStore()\n  return store((state) => state.error)\n}\n\n/**\n * Hook to get the chat scene\n */\nexport const useChatScene = () => {\n  const store = useAIChatStore()\n  return store((state) => state.scene)\n}\n\nexport const useChatBlockSelector = <T>(selector: (state: Pick<BlockSlice, \"blocks\">) => T) => {\n  const store = useAIChatStore()\n  return store(useShallow((state) => selector(state)))\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts",
    "content": "import { autoBindThis } from \"@follow/utils/bind-this\"\nimport { produce } from \"immer\"\nimport { nanoid } from \"nanoid\"\nimport type { StateCreator } from \"zustand\"\n\nimport { cleanupFileAttachment } from \"../../utils/file-processing\"\nimport type { AIChatContextBlock, AIChatContextBlockInput, FileAttachment } from \"../types\"\n\nexport interface BlockSlice {\n  blocks: AIChatContextBlock[]\n  blockActions: BlockSliceAction\n}\n\nexport const createBlockSlice: (\n  initialBlocks?: AIChatContextBlock[],\n) => StateCreator<BlockSlice, [], [], BlockSlice> =\n  (initialBlocks?: AIChatContextBlock[]) =>\n  (...params) => {\n    const defaultBlocks: AIChatContextBlock[] = initialBlocks || []\n\n    return {\n      blocks: defaultBlocks,\n      blockActions: new BlockSliceAction(params),\n    }\n  }\n\nexport class BlockSliceAction {\n  constructor(private params: Parameters<StateCreator<BlockSlice, [], [], BlockSlice>>) {\n    return autoBindThis(this)\n  }\n\n  static SPECIAL_TYPES = {\n    mainView: \"mainView\",\n    mainEntry: \"mainEntry\",\n    mainFeed: \"mainFeed\",\n    unreadOnly: \"unreadOnly\",\n  }\n  get set() {\n    return this.params[0]\n  }\n\n  get get() {\n    return this.params[1]\n  }\n  addBlock(block: AIChatContextBlockInput) {\n    const currentBlocks = this.get().blocks\n\n    // Only allow one SPECIAL_TYPES\n    if (\n      Object.values(BlockSliceAction.SPECIAL_TYPES).includes(block.type) &&\n      currentBlocks.some((b) => b.type === block.type)\n    ) {\n      return\n    }\n\n    this.set(\n      produce((state: BlockSlice) => {\n        state.blocks.push({ ...block, id: BlockSliceAction.SPECIAL_TYPES[block.type] || nanoid(8) })\n      }),\n    )\n  }\n\n  removeBlock(id: string) {\n    this.set(\n      produce((state: BlockSlice) => {\n        const blockToRemove = state.blocks.find((block) => block.id === id)\n        if (blockToRemove && blockToRemove.type === \"fileAttachment\") {\n          cleanupFileAttachment(blockToRemove.attachment)\n        }\n        state.blocks = state.blocks.filter((block) => block.id !== id)\n      }),\n    )\n  }\n\n  toggleBlockDisabled(id: string, disabled?: boolean) {\n    this.set(\n      produce((state: BlockSlice) => {\n        const block = state.blocks.find((block) => block.id === id)\n        if (block) {\n          const nextDisabled = disabled ?? !block.disabled\n\n          if (!nextDisabled) {\n            delete block.disabled\n          } else {\n            block.disabled = true\n          }\n        }\n      }),\n    )\n  }\n  updateBlock(id: string, updates: Partial<AIChatContextBlock>) {\n    this.set(\n      produce((state: BlockSlice) => {\n        state.blocks = state.blocks.map((block) => {\n          if (block.id !== id) return block\n\n          // Handle discriminated union updates carefully\n          if (updates.type && updates.type !== block.type) {\n            // Type change - need to replace the entire block\n            return { ...updates, id } as AIChatContextBlock\n          } else {\n            // Same type - safe to spread\n            return { ...block, ...updates } as AIChatContextBlock\n          }\n        })\n      }),\n    )\n  }\n\n  addOrUpdateBlock(block: AIChatContextBlock) {\n    const isExist = this.get().blocks.some((b) => b.id === block.id)\n    if (isExist) {\n      this.updateBlock(block.id, block)\n    } else {\n      this.addBlock(block)\n    }\n  }\n\n  clearBlocks({ keepSpecialTypes = false }: { keepSpecialTypes?: boolean } = {}) {\n    this.set(\n      produce((state: BlockSlice) => {\n        // Clean up file attachments before clearing\n        state.blocks.forEach((block) => {\n          if (block.type === \"fileAttachment\") {\n            cleanupFileAttachment(block.attachment)\n          }\n        })\n        state.blocks = keepSpecialTypes\n          ? state.blocks.filter((b) =>\n              Object.values(BlockSliceAction.SPECIAL_TYPES).includes(b.type),\n            )\n          : []\n      }),\n    )\n  }\n\n  resetContext() {\n    this.set(\n      produce((state: BlockSlice) => {\n        // Clean up file attachments before resetting\n        state.blocks.forEach((block) => {\n          if (block.type === \"fileAttachment\") {\n            cleanupFileAttachment(block.attachment)\n          }\n        })\n        state.blocks = []\n      }),\n    )\n  }\n\n  getBlocks() {\n    return this.get().blocks\n  }\n\n  // File attachment specific methods\n  addFileAttachment(fileAttachment: FileAttachment) {\n    const fileBlock: AIChatContextBlock = {\n      id: fileAttachment.id,\n      type: \"fileAttachment\",\n      attachment: fileAttachment,\n    }\n    this.addBlock(fileBlock)\n  }\n\n  updateFileAttachment(attachmentId: string, updatedAttachment: FileAttachment) {\n    this.set(\n      produce((state: BlockSlice) => {\n        const block = state.blocks.find(\n          (b) => b.type === \"fileAttachment\" && b.attachment.id === attachmentId,\n        )\n        if (block && block.type === \"fileAttachment\") {\n          block.attachment = updatedAttachment\n        }\n      }),\n    )\n  }\n\n  updateFileAttachmentStatus(\n    fileId: string,\n    status: FileAttachment[\"uploadStatus\"],\n    errorMessage?: string,\n  ) {\n    this.set(\n      produce((state: BlockSlice) => {\n        const block = state.blocks.find((b) => b.id === fileId)\n        if (block && block.type === \"fileAttachment\") {\n          block.attachment.uploadStatus = status\n          if (errorMessage) {\n            block.attachment.errorMessage = errorMessage\n          }\n        }\n      }),\n    )\n  }\n\n  removeFileAttachment(fileId: string) {\n    this.removeBlock(fileId)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts",
    "content": "import type { IdGenerator } from \"ai\"\nimport { nanoid } from \"nanoid\"\nimport type { StateCreator } from \"zustand\"\n\nimport { ChatSliceActions } from \"../chat-core/chat-actions\"\nimport { ZustandChat } from \"../chat-core/chat-instance\"\nimport type { ChatSlice } from \"../chat-core/types\"\nimport { createChatTitleHandler, createChatTransport } from \"../transport\"\n\nexport const createChatSlice: (options: {\n  chatId?: string\n  generateId?: IdGenerator\n  isLocal?: boolean\n  syncStatus?: \"local\" | \"synced\"\n}) => StateCreator<ChatSlice, [], [], ChatSlice> =\n  (options) =>\n  (...params) => {\n    const [set, get] = params\n    const { chatId, generateId, isLocal, syncStatus } = options\n\n    const nextChatId = chatId || nanoid()\n    const chatInstance = new ZustandChat(\n      {\n        id: nextChatId,\n        messages: [],\n        transport: createChatTransport({\n          titleHandler: createChatTitleHandler({\n            chatId: nextChatId,\n            getActiveChatId: () => get().chatId,\n            onTitleChange: (title) => {\n              set({\n                currentTitle: title,\n              })\n            },\n          }),\n        }),\n        generateId,\n      },\n      set,\n    )\n\n    const chatActions = new ChatSliceActions(params, {\n      chatInstance,\n      hasChatId: !!chatId,\n    })\n\n    return {\n      chatId: nextChatId,\n      messages: [],\n      status: \"ready\",\n      error: undefined,\n      isStreaming: false,\n      isLocal: isLocal ?? true,\n      syncStatus: syncStatus ?? (isLocal === false ? \"synced\" : \"local\"),\n      currentTitle: undefined,\n      chatInstance,\n      chatActions,\n      scene: \"general\",\n      timelineSummaryManualOverride: false,\n      timelineSummaryWasInAutoContext: false,\n    }\n  }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/index.ts",
    "content": "export type { ChatSlice } from \"../chat-core/types\"\nexport {\n  type BlockSlice as ContextSlice,\n  createBlockSlice as createContextSlice,\n} from \"./block.slice\"\nexport { createChatSlice } from \"./chat.slice\"\nexport { type ChatStatus } from \"ai\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/store.ts",
    "content": "import { createWithEqualityFn } from \"zustand/traditional\"\n\nimport type { ChatSlice } from \"./chat-core/types\"\nimport type { BlockSlice } from \"./slices/block.slice\"\nimport { createBlockSlice } from \"./slices/block.slice\"\nimport { createChatSlice } from \"./slices/chat.slice\"\nimport type { AIChatStoreInitial } from \"./types\"\n\nexport type AiChatStore = BlockSlice &\n  ChatSlice & {\n    reset: () => void\n  }\n\nexport const createAIChatStore = (initialState?: Partial<AIChatStoreInitial>) => {\n  const { blocks, chatId, generateId } = initialState || {}\n  return createWithEqualityFn<AiChatStore>((...a) => {\n    const blockSlice = createBlockSlice(blocks)(...a)\n    const chatSlice = createChatSlice({ chatId, generateId })(...a)\n\n    return {\n      ...blockSlice,\n      ...chatSlice,\n\n      reset: () => {\n        blockSlice.blockActions.resetContext()\n        chatSlice.chatActions.resetChat()\n      },\n    }\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/transport.ts",
    "content": "import { env } from \"@follow/shared/env.desktop\"\nimport type { HttpChatTransportInitOptions, UIMessageChunk } from \"ai\"\nimport { HttpChatTransport, parseJsonEventStream, uiMessageChunkSchema } from \"ai\"\n\nimport { getAIModelState } from \"../atoms/session\"\nimport { AIPersistService } from \"../services\"\nimport type { BizUIMessage } from \"./types\"\n\ntype TitleHandlerPersistOption = boolean | ((title: string) => void | Promise<void>)\n\nexport interface TitleHandlerOptions {\n  chatId?: string\n  shouldHandle?: () => boolean\n  onTitleChange?: (title: string) => void\n  persist?: TitleHandlerPersistOption\n}\n\nexport interface CreateChatTransportOptions {\n  onValue?: (value: UIMessageChunk) => void\n  titleHandler?: TitleHandlerOptions\n}\n\nexport interface CreateChatTitleHandlerOptions {\n  chatId: string\n  getActiveChatId: () => string | null | undefined\n  onTitleChange?: (title: string) => void\n  persist?: TitleHandlerPersistOption\n}\n\nexport function createChatTitleHandler(\n  options: CreateChatTitleHandlerOptions,\n): TitleHandlerOptions {\n  const { chatId, getActiveChatId, onTitleChange, persist } = options\n\n  return {\n    chatId,\n    persist,\n    onTitleChange,\n    shouldHandle: () => getActiveChatId() === chatId,\n  }\n}\n\n/**\n * Create a chat transport for AI SDK\n * This is used by the AbstractChat instance to communicate with AI providers\n */\nexport function createChatTransport({ onValue, titleHandler }: CreateChatTransportOptions = {}) {\n  return new ExtendChatTransport({\n    onValue,\n    titleHandler,\n    // Custom fetch configuration\n    api: `${env.VITE_API_URL}/ai/chat`,\n    credentials: \"include\",\n    // Add selected model to request body\n    body: () => {\n      const modelState = getAIModelState()\n      const { selectedModel } = modelState\n\n      return selectedModel ? { model: selectedModel } : {}\n    },\n  })\n}\n\ntype UIMessageChunkParseResult =\n  ReturnType<typeof parseJsonEventStream<UIMessageChunk>> extends ReadableStream<infer T>\n    ? T\n    : never\n\nconst coerceFinishChunk = (chunk: UIMessageChunkParseResult): UIMessageChunk | null => {\n  const { rawValue } = chunk\n  if (!rawValue || typeof rawValue !== \"object\" || Array.isArray(rawValue)) {\n    return null\n  }\n\n  if ((rawValue as { type?: unknown }).type !== \"finish\") {\n    return null\n  }\n\n  const { finishReason, messageMetadata } = rawValue as {\n    finishReason?: unknown\n    messageMetadata?: unknown\n  }\n\n  return {\n    type: \"finish\",\n    finishReason: typeof finishReason === \"string\" ? finishReason : undefined,\n    messageMetadata,\n  } as UIMessageChunk\n}\n\nclass ExtendChatTransport extends HttpChatTransport<BizUIMessage> {\n  constructor(\n    private options: HttpChatTransportInitOptions<BizUIMessage> & {\n      onValue?: (value: UIMessageChunk) => void\n      titleHandler?: TitleHandlerOptions\n    },\n  ) {\n    super(options)\n  }\n\n  protected processResponseStream(\n    stream: ReadableStream<Uint8Array<ArrayBufferLike>>,\n  ): ReadableStream<UIMessageChunk> {\n    const { onValue } = this.options || {}\n    const handleGeneratedTitle = this.handleGeneratedTitle.bind(this)\n    return parseJsonEventStream({\n      stream,\n      schema: uiMessageChunkSchema,\n    }).pipeThrough(\n      new TransformStream<UIMessageChunkParseResult, UIMessageChunk>({\n        async transform(chunk, controller) {\n          const parsedChunk = chunk.success ? chunk.value : coerceFinishChunk(chunk)\n          if (!parsedChunk) {\n            throw chunk.error\n          }\n\n          await handleGeneratedTitle(parsedChunk)\n          onValue?.(parsedChunk)\n          controller.enqueue(parsedChunk)\n        },\n      }),\n    )\n  }\n\n  private async handleGeneratedTitle(chunk: UIMessageChunk) {\n    const { titleHandler } = this.options\n    if (!titleHandler) {\n      return\n    }\n\n    if (chunk.type !== \"data-generated-title\" || typeof chunk.data !== \"string\") {\n      return\n    }\n\n    const shouldHandle = titleHandler.shouldHandle?.() ?? true\n    if (!shouldHandle) {\n      return\n    }\n\n    titleHandler.onTitleChange?.(chunk.data)\n\n    const persistOption = titleHandler.persist\n    const shouldPersist = persistOption === undefined ? true : persistOption\n\n    if (!shouldPersist) {\n      return\n    }\n\n    try {\n      if (typeof persistOption === \"function\") {\n        await persistOption(chunk.data)\n        return\n      }\n\n      if (titleHandler.chatId) {\n        await AIPersistService.updateSessionTitle(titleHandler.chatId, chunk.data)\n      }\n    } catch (error) {\n      console.error(\"Failed to persist generated title:\", error)\n    }\n  }\n\n  override reconnectToStream(\n    options: Parameters<HttpChatTransport<BizUIMessage>[\"reconnectToStream\"]>[0],\n  ) {\n    options.chatId = encodeURIComponent(options.chatId)\n    return super.reconnectToStream(options)\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts",
    "content": "import type { BizUIMetadata, BizUITools, ToolWithState } from \"@folo-services/ai-tools\"\nimport type { IdGenerator, UIMessage, UIMessagePart } from \"ai\"\n\nexport interface FileAttachment {\n  id: string\n  name: string\n  type: string\n  size: number\n  dataUrl?: string\n  previewUrl?: string\n  uploadStatus?: \"processing\" | \"uploading\" | \"completed\" | \"error\"\n  serverUrl?: string\n  errorMessage?: string\n  /** Upload progress percentage (0-100) */\n  uploadProgress?: number\n}\n\ninterface BaseContextBlock {\n  id: string\n  disabled?: boolean\n}\n\nexport type ValueContextBlockType = \"mainView\" | \"mainEntry\" | \"mainFeed\" | \"unreadOnly\"\nexport interface AbstractValueContextBlock<T extends string> extends BaseContextBlock {\n  type: T\n  value: string\n}\n\nexport type ValueContextBlock = AbstractValueContextBlock<ValueContextBlockType>\n\nexport interface FileAttachmentContextBlock extends BaseContextBlock {\n  type: \"fileAttachment\"\n  attachment: FileAttachment\n}\n\nexport type AIChatContextBlock = ValueContextBlock | FileAttachmentContextBlock\n\n// Helper type for creating new blocks without id\nexport type AIChatContextBlockInput =\n  | Omit<ValueContextBlock, \"id\">\n  | Omit<FileAttachmentContextBlock, \"id\">\n\nexport type AIChatContextBlockType = AIChatContextBlock[\"type\"]\n\nexport interface AIChatStoreInitial {\n  blocks: AIChatContextBlock[]\n  chatId?: string\n  generateId?: IdGenerator\n  isLocal?: boolean\n  syncStatus?: \"local\" | \"synced\"\n}\n\nexport interface AIChatContextBlocks {\n  blocks: AIChatContextBlock[]\n}\n\nexport type AIDisplayFlowTool = ToolWithState<BizUITools[\"display_flow_chart\"]>\n\nexport { type BizUIMetadata, type BizUITools } from \"@folo-services/ai-tools\"\nexport type BizUIDataTypes = {\n  \"rich-text\": {\n    state: string\n    text: string\n  }\n  block: AIChatContextBlock[]\n}\nexport type BizUIMessage = UIMessage<BizUIMetadata, BizUIDataTypes, BizUITools> & {\n  createdAt: Date\n}\n\nexport type BizUIMessagePart = UIMessagePart<BizUIDataTypes, BizUITools>\n\nexport type SendingUIMessage = Omit<BizUIMessage, \"createdAt\">\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/types/ChatSession.ts",
    "content": "import type { SerializedEditorState } from \"lexical\"\n\nexport interface ChatSession {\n  chatId: string\n  title?: string\n  createdAt: Date\n  updatedAt: Date\n  isLocal: boolean\n  syncStatus: \"local\" | \"synced\"\n}\n\nexport type RichTextPart = {\n  type: \"data-rich-text\"\n  data: {\n    state: SerializedEditorState\n    text: string\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/error.ts",
    "content": "import { ExceptionCodeMap } from \"@follow-app/client-sdk\"\n\nimport { getI18n } from \"~/i18n\"\n\nexport interface ParsedErrorData {\n  code?: number\n  remainedTokens?: number\n  windowResetTime?: string\n  [key: string]: any\n}\n\nexport interface ParsedError {\n  rawMessage: string\n  errorCode: number | null\n  errorData: ParsedErrorData | null\n  isBusinessError: boolean\n  isRateLimitError: boolean\n}\n\n/**\n * Parse error object or string to extract structured error information\n * @param error - Error object or error message string\n * @returns Parsed error information\n */\nexport function parseAIError(error: Error | string | undefined): ParsedError {\n  if (!error) {\n    return {\n      rawMessage: \"\",\n      errorCode: null,\n      errorData: null,\n      isBusinessError: false,\n      isRateLimitError: false,\n    }\n  }\n\n  const rawMessage = typeof error === \"string\" ? error : error.message\n\n  try {\n    const parsed = JSON.parse(rawMessage)\n    const errorData: ParsedErrorData = parsed || {}\n    const { code } = errorData\n\n    const isRateLimitError = code === ExceptionCodeMap.AIRateLimitExceeded\n    const isBusinessError = !!(code && ExceptionCodeMap[code])\n\n    return {\n      rawMessage,\n      errorCode: code || null,\n      errorData: isBusinessError ? errorData : null,\n      isBusinessError,\n      isRateLimitError,\n    }\n  } catch {\n    // Not a JSON error, return as plain text error\n    return {\n      rawMessage,\n      errorCode: null,\n      errorData: null,\n      isBusinessError: false,\n      isRateLimitError: false,\n    }\n  }\n}\n\n/**\n * Check if an error is a rate limit error\n * @param error - Error object or error message string\n * @returns True if the error is a rate limit error\n */\nexport function isRateLimitError(error: Error | string | undefined): boolean {\n  return parseAIError(error).isRateLimitError\n}\n\n/**\n * Get translated error message with fallback\n * @param error - Parsed error information\n * @returns User-friendly error message\n */\nexport function getErrorMessage(error: ParsedError): string {\n  if (!error.isBusinessError || !error.errorCode) {\n    return error.rawMessage\n  }\n\n  const errorKey = `errors:${error.errorCode}` as any\n  const translatedMessage = getI18n().t(errorKey)\n\n  // If translation exists and is different from the key, use it; otherwise fallback to raw message\n  return translatedMessage !== errorKey ? translatedMessage : error.rawMessage\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/extractor.ts",
    "content": "import type { AIChatContextBlock, FileAttachmentContextBlock } from \"../store/types\"\n\ntype AIMessageDataBlockPart = {\n  type: \"data-block\"\n  data: AIChatContextBlock[]\n}\nexport const isDataBlockPart = (part: unknown): part is AIMessageDataBlockPart => {\n  return !!part && typeof part === \"object\" && \"type\" in part && part.type === \"data-block\"\n}\n\n// Narrow a context block to the file attachment block\nexport const isFileAttachmentBlock = (\n  block: AIChatContextBlock,\n): block is FileAttachmentContextBlock => {\n  return block.type === \"fileAttachment\"\n}\n\nexport const findFileAttachmentBlock = (\n  part: AIMessageDataBlockPart,\n  attachmentId: string,\n): FileAttachmentContextBlock | undefined => {\n  if (!isDataBlockPart(part)) return\n\n  for (const block of part.data) {\n    if (isFileAttachmentBlock(block) && block.attachment.id === attachmentId) {\n      return block\n    }\n  }\n  return\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-processing.ts",
    "content": "import { followApi } from \"~/lib/api-client\"\n\nimport type { FileAttachment } from \"../store/types\"\nimport type { FileValidationResult } from \"./file-validation\"\nimport { validateFile } from \"./file-validation\"\n\nexport interface ProcessFileOptions {\n  maxImageWidth?: number\n  maxImageHeight?: number\n  imageQuality?: number\n\n  nonce: string\n}\n\nexport interface ProcessFileResult {\n  success: boolean\n  fileAttachment?: FileAttachment\n  error?: string\n}\n\nexport async function processFile(\n  file: File,\n  options: ProcessFileOptions,\n): Promise<ProcessFileResult> {\n  const { maxImageWidth = 1920, maxImageHeight = 1080, imageQuality = 0.85 } = options\n\n  // Validate file\n  const validation: FileValidationResult = validateFile(file)\n  if (!validation.isValid) {\n    return {\n      success: false,\n      error: validation.error?.message || \"File validation failed\",\n    }\n  }\n\n  try {\n    const { nonce: fileId } = options\n    let dataUrl: string\n    let previewUrl: string | undefined\n\n    if (validation.fileInfo?.category === \"image\") {\n      // Process image: compress and generate preview\n      const processedImage = await processImage(file, {\n        maxWidth: maxImageWidth,\n        maxHeight: maxImageHeight,\n        quality: imageQuality,\n      })\n\n      dataUrl = processedImage.dataUrl\n      previewUrl = processedImage.previewUrl\n    } else {\n      // For non-images, just convert to data URL\n      dataUrl = await fileToDataUrl(file)\n    }\n\n    const fileAttachment: FileAttachment = {\n      id: fileId,\n      name: file.name,\n      type: file.type,\n      size: file.size,\n      dataUrl,\n      previewUrl,\n      uploadStatus: \"completed\",\n    }\n\n    return {\n      success: true,\n      fileAttachment,\n    }\n  } catch (error) {\n    return {\n      success: false,\n      error: `Failed to process file: ${error instanceof Error ? error.message : \"Unknown error\"}`,\n    }\n  }\n}\n\ninterface ProcessImageResult {\n  dataUrl: string\n  previewUrl: string\n}\n\nasync function processImage(\n  file: File,\n  options: { maxWidth: number; maxHeight: number; quality: number },\n): Promise<ProcessImageResult> {\n  return new Promise((resolve, reject) => {\n    const img = new Image()\n    const canvas = document.createElement(\"canvas\")\n    const ctx = canvas.getContext(\"2d\")\n\n    if (!ctx) {\n      reject(new Error(\"Could not get canvas context\"))\n      return\n    }\n\n    img.onload = () => {\n      // Calculate new dimensions\n      let { width, height } = img\n      const { maxWidth, maxHeight, quality } = options\n\n      if (width > maxWidth || height > maxHeight) {\n        const ratio = Math.min(maxWidth / width, maxHeight / height)\n        width *= ratio\n        height *= ratio\n      }\n\n      // Set canvas dimensions\n      canvas.width = width\n      canvas.height = height\n\n      // Draw and compress image\n      ctx.drawImage(img, 0, 0, width, height)\n\n      const dataUrl = canvas.toDataURL(file.type, quality)\n\n      // Create smaller preview (thumbnail)\n      const previewCanvas = document.createElement(\"canvas\")\n      const previewCtx = previewCanvas.getContext(\"2d\")\n\n      if (previewCtx) {\n        const previewSize = 150\n        const previewRatio = Math.min(previewSize / width, previewSize / height)\n        previewCanvas.width = width * previewRatio\n        previewCanvas.height = height * previewRatio\n\n        previewCtx.drawImage(img, 0, 0, previewCanvas.width, previewCanvas.height)\n        const previewUrl = previewCanvas.toDataURL(file.type, 0.7)\n\n        resolve({ dataUrl, previewUrl })\n      } else {\n        resolve({ dataUrl, previewUrl: dataUrl })\n      }\n    }\n\n    img.onerror = () => {\n      reject(new Error(\"Failed to load image\"))\n    }\n\n    img.src = URL.createObjectURL(file)\n  })\n}\n\nfunction fileToDataUrl(file: File): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const reader = new FileReader()\n\n    reader.onload = () => {\n      resolve(reader.result as string)\n    }\n\n    reader.onerror = () => {\n      reject(new Error(\"Failed to read file\"))\n    }\n\n    reader.readAsDataURL(file)\n  })\n}\n\n// Utility to clean up object URLs to prevent memory leaks\nexport function cleanupFileAttachment(fileAttachment: FileAttachment) {\n  if (fileAttachment.previewUrl?.startsWith(\"blob:\")) {\n    URL.revokeObjectURL(fileAttachment.previewUrl)\n  }\n}\n\nexport async function uploadFileAttachment(\n  fileAttachment: FileAttachment,\n  onProgressUpdate?: (attachment: FileAttachment) => void,\n): Promise<FileAttachment> {\n  try {\n    // Update status to uploading with 0% progress\n    let currentAttachment: FileAttachment = {\n      ...fileAttachment,\n      uploadStatus: \"uploading\" as const,\n      uploadProgress: 0,\n    }\n    onProgressUpdate?.(currentAttachment)\n\n    const { dataUrl } = fileAttachment\n    if (!dataUrl) {\n      throw new Error(\"No data URL found for file attachment\")\n    }\n    const blob = await fetch(dataUrl).then((r) => r.blob())\n\n    // TODO: Replace with real progress tracking when followApi supports it\n    // Currently followApi.upload.uploadChatAttachment doesn't provide progress callbacks\n    // Future implementation could use XMLHttpRequest or a custom fetch wrapper\n\n    // Simulate realistic progress updates during upload\n    // This mimics a realistic upload progression pattern\n    const progressInterval = setInterval(() => {\n      if (currentAttachment.uploadProgress! < 85) {\n        // Start faster, then slow down (realistic network behavior)\n        const currentProgress = currentAttachment.uploadProgress || 0\n        const increment =\n          currentProgress < 50\n            ? Math.random() * 20 + 5 // Fast initial progress\n            : Math.random() * 8 + 2 // Slower progress as it approaches completion\n\n        currentAttachment = {\n          ...currentAttachment,\n          uploadProgress: Math.min(85, currentProgress + increment),\n        }\n        onProgressUpdate?.(currentAttachment)\n      }\n    }, 150)\n\n    try {\n      // Actual upload\n      const response = await followApi.upload.uploadChatAttachment({ file: blob })\n      const serverUrl = response.data.url\n\n      // Update to 100% and completed status\n      const completedAttachment: FileAttachment = {\n        ...fileAttachment,\n        serverUrl,\n        uploadStatus: \"completed\",\n        uploadProgress: 100,\n        errorMessage: undefined,\n      }\n\n      // Show 100% briefly before final callback\n      onProgressUpdate?.(completedAttachment)\n\n      return completedAttachment\n    } finally {\n      // Clear progress interval\n      clearInterval(progressInterval)\n    }\n  } catch (error) {\n    // Return attachment with error status\n    const errorAttachment: FileAttachment = {\n      ...fileAttachment,\n      uploadStatus: \"error\",\n      uploadProgress: undefined,\n      errorMessage: error instanceof Error ? error.message : \"Upload failed\",\n    }\n\n    return errorAttachment\n  }\n}\n\nexport async function processAndUploadFile(\n  file: File,\n  options: ProcessFileOptions,\n  onProgressUpdate?: (attachment: FileAttachment) => void,\n): Promise<ProcessFileResult> {\n  // First process the file locally\n  const localResult = await processFile(file, options)\n\n  if (!localResult.success || !localResult.fileAttachment) {\n    return localResult\n  }\n\n  // Then upload to server\n  const uploadedAttachment = await uploadFileAttachment(\n    localResult.fileAttachment,\n    onProgressUpdate,\n  )\n\n  return {\n    success: uploadedAttachment.uploadStatus === \"completed\",\n    fileAttachment: uploadedAttachment,\n    error:\n      uploadedAttachment.uploadStatus === \"error\" ? uploadedAttachment.errorMessage : undefined,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-validation.ts",
    "content": "const MAX_IMAGE_ALLOWED_SIZE = 3 * 1024 * 1024\nconst MAX_DOCUMENT_ALLOWED_SIZE = 1 * 1024 * 1024\nexport const SUPPORTED_MIME_ACCEPT = \"image/*,.pdf,.txt,.md\"\nexport const SUPPORTED_FILE_TYPES = {\n  // Images\n  \"image/png\": { extension: \"png\", category: \"image\", maxSize: MAX_IMAGE_ALLOWED_SIZE },\n  \"image/jpeg\": { extension: \"jpg\", category: \"image\", maxSize: MAX_IMAGE_ALLOWED_SIZE },\n  \"image/jpg\": { extension: \"jpg\", category: \"image\", maxSize: MAX_IMAGE_ALLOWED_SIZE },\n  \"image/webp\": { extension: \"webp\", category: \"image\", maxSize: MAX_IMAGE_ALLOWED_SIZE },\n  \"image/gif\": { extension: \"gif\", category: \"image\", maxSize: MAX_IMAGE_ALLOWED_SIZE },\n\n  // Documents\n  \"application/pdf\": { extension: \"pdf\", category: \"document\", maxSize: MAX_DOCUMENT_ALLOWED_SIZE },\n  \"text/plain\": { extension: \"txt\", category: \"text\", maxSize: MAX_DOCUMENT_ALLOWED_SIZE },\n  \"text/markdown\": { extension: \"md\", category: \"text\", maxSize: MAX_DOCUMENT_ALLOWED_SIZE },\n} as const\n\nexport type SupportedFileType = keyof typeof SUPPORTED_FILE_TYPES\nexport type FileCategory = (typeof SUPPORTED_FILE_TYPES)[SupportedFileType][\"category\"]\n\nexport interface FileValidationError {\n  type: \"unsupported\" | \"too_large\" | \"invalid\"\n  message: string\n}\n\nexport interface FileValidationResult {\n  isValid: boolean\n  error?: FileValidationError\n  fileInfo?: {\n    type: SupportedFileType\n    category: FileCategory\n    extension: string\n    maxSize: number\n  }\n}\n\nexport function validateFile(file: File): FileValidationResult {\n  const fileType = file.type as SupportedFileType\n  const fileInfo = SUPPORTED_FILE_TYPES[fileType]\n\n  if (!fileInfo) {\n    return {\n      isValid: false,\n      error: {\n        type: \"unsupported\",\n        message: `File type \"${file.type}\" is not supported. Supported types: images, PDFs, text files.`,\n      },\n    }\n  }\n\n  if (file.size > fileInfo.maxSize) {\n    const maxSizeMB = Math.round(fileInfo.maxSize / (1024 * 1024))\n    const fileSizeMB = Math.round((file.size / (1024 * 1024)) * 100) / 100\n    return {\n      isValid: false,\n      error: {\n        type: \"too_large\",\n        message: `File size (${fileSizeMB}MB) exceeds the maximum allowed size of ${maxSizeMB}MB for ${fileInfo.category} files.`,\n      },\n    }\n  }\n\n  if (file.size === 0) {\n    return {\n      isValid: false,\n      error: {\n        type: \"invalid\",\n        message: \"File appears to be empty or corrupted.\",\n      },\n    }\n  }\n\n  return {\n    isValid: true,\n    fileInfo: {\n      type: fileType,\n      category: fileInfo.category,\n      extension: fileInfo.extension,\n      maxSize: fileInfo.maxSize,\n    },\n  }\n}\n\nexport function formatFileSize(bytes: number): string {\n  if (bytes === 0) return \"0 Bytes\"\n\n  const k = 1024\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"]\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n  return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`\n}\n\nexport function getFileCategoryFromMimeType(mimeType: string): FileCategory {\n  // Images\n  if (mimeType.startsWith(\"image/\")) {\n    return \"image\"\n  }\n\n  // Documents\n  if (mimeType === \"application/pdf\") {\n    return \"document\"\n  }\n\n  // Text files\n  if (mimeType.startsWith(\"text/\")) {\n    return \"text\"\n  }\n\n  // Default fallback\n  return \"image\"\n}\n\nexport function getFileIconName(category: FileCategory): string {\n  switch (category) {\n    case \"image\": {\n      return tw`i-mgc-pic-cute-re`\n    }\n    case \"document\": {\n      return tw`i-mingcute-file-line`\n    }\n    case \"text\": {\n      return tw`i-mingcute-text-line`\n    }\n    default: {\n      return tw`i-mgc-attachment-cute-re`\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/mentionDate.ts",
    "content": "import dayjs from \"dayjs\"\n\nexport const MENTION_DATE_VALUE_FORMAT = \"YYYY-MM-DDTHH:mm:ssZ\"\n\nconst LEGACY_MENTION_DATE_VALUE_FORMAT = \"YYYY-MM-DD\"\n\nexport interface MentionDateDisplay {\n  label: string\n  startISO: string | null\n  endISO: string | null\n  startLabel: string | null\n  endLabel: string | null\n}\n\nconst parseMentionBoundary = (raw: string) => {\n  if (!raw) return null\n\n  const direct = dayjs(raw)\n  if (direct.isValid()) {\n    return direct\n  }\n\n  const legacy = dayjs(raw, LEGACY_MENTION_DATE_VALUE_FORMAT, true)\n  return legacy.isValid() ? legacy : null\n}\n\nconst buildRangeLabel = (startISO: string, endISO: string): MentionDateDisplay => {\n  const start = parseMentionBoundary(startISO)\n  const end = parseMentionBoundary(endISO)\n\n  if (!start || !end) {\n    return {\n      label: `${startISO}..${endISO}`,\n      startISO: start ? startISO : null,\n      endISO: end ? endISO : null,\n      startLabel: start ? start.startOf(\"day\").format(\"MMM D, YYYY\") : null,\n      endLabel: end ? end.startOf(\"day\").format(\"MMM D, YYYY\") : null,\n    }\n  }\n\n  const normalizedStart = start.startOf(\"day\")\n  const normalizedEnd = end.startOf(\"day\")\n\n  const hasExclusiveEnd =\n    normalizedEnd.isAfter(normalizedStart) &&\n    end.hour() === 0 &&\n    end.minute() === 0 &&\n    end.second() === 0 &&\n    end.millisecond() === 0\n\n  const displayEnd = hasExclusiveEnd ? normalizedEnd.subtract(1, \"day\") : normalizedEnd\n\n  const label = normalizedStart.isSame(displayEnd, \"day\")\n    ? normalizedStart.format(\"MMM D, YYYY\")\n    : normalizedStart.year() === displayEnd.year()\n      ? `${normalizedStart.format(\"MMM D\")} – ${displayEnd.format(\"MMM D, YYYY\")}`\n      : `${normalizedStart.format(\"MMM D, YYYY\")} – ${displayEnd.format(\"MMM D, YYYY\")}`\n\n  return {\n    label,\n    startISO: normalizedStart.format(MENTION_DATE_VALUE_FORMAT),\n    endISO: normalizedEnd.format(MENTION_DATE_VALUE_FORMAT),\n    startLabel: normalizedStart.format(\"MMM D, YYYY\"),\n    endLabel: displayEnd.format(\"MMM D, YYYY\"),\n  }\n}\n\nexport const formatMentionDateValue = (value: string): MentionDateDisplay => {\n  if (!value) {\n    return {\n      label: \"\",\n      startISO: null,\n      endISO: null,\n      startLabel: null,\n      endLabel: null,\n    }\n  }\n\n  const parts = value.includes(\"..\") ? value.split(\"..\", 2) : [value, value]\n  const rawStart = (parts[0] ?? value).trim()\n  const rawEnd = (parts[1] ?? parts[0] ?? value).trim()\n\n  return buildRangeLabel(rawStart, rawEnd)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/rate-limit.ts",
    "content": "import type { FreeQuota, TokenUsage } from \"@follow-app/client-sdk\"\n\nimport { getI18n } from \"~/i18n\"\n\nimport { parseAIError } from \"./error\"\n\nexport interface AIConfigLike {\n  usage?: TokenUsage\n  freeQuota?: FreeQuota\n}\n\nexport interface RateLimitMessageOptions {\n  hideResetDetails?: boolean\n}\n\nconst formatResetTime = (windowResetTime: Date) => {\n  const i18n = getI18n()\n  const { t } = i18n\n  const resetDate = new Date(windowResetTime)\n  const now = new Date()\n  const diffMs = resetDate.getTime() - now.getTime()\n  const diffMinutes = Math.ceil(diffMs / (1000 * 60))\n\n  if (diffMinutes < 60 && diffMinutes > 0) {\n    const unit =\n      diffMinutes === 1\n        ? t(\"rate_limit.minute\", { ns: \"ai\" })\n        : t(\"rate_limit.minutes\", { ns: \"ai\" })\n    const value = `${diffMinutes} ${unit}`\n    return t(\"rate_limit.resets_in\", { ns: \"ai\", value })\n  }\n\n  const timeFormatter = new Intl.DateTimeFormat(\"en-GB\", {\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n    hour12: false,\n  })\n  return t(\"rate_limit.resets_at\", { ns: \"ai\", time: timeFormatter.format(resetDate) })\n}\n\nexport function computeIsRateLimited(\n  error: Error | string | undefined,\n  conf?: AIConfigLike | null,\n): boolean {\n  if (error) {\n    const parsed = parseAIError(error)\n    if (parsed.isRateLimitError) return true\n  }\n\n  if (conf) {\n    return (\n      !!conf?.freeQuota?.shouldCheckDailyLimit &&\n      (!conf.freeQuota.remainingRequests || !conf.freeQuota.remainingMonthlyRequests)\n    )\n  }\n\n  return false\n}\n\nexport function computeRateLimitMessage(\n  error: Error | string | undefined,\n  configuration?: AIConfigLike | null,\n  options?: RateLimitMessageOptions,\n): string | null {\n  const i18n = getI18n()\n  const { t } = i18n\n  const hideResetDetails = options?.hideResetDetails ?? false\n\n  const aiNs = { ns: \"ai\" } as const\n  if (error) {\n    const parsed = parseAIError(error)\n    const { isRateLimitError, errorData } = parsed\n    if (isRateLimitError && errorData) {\n      const remainingTokens = (errorData as any).remainedTokens as number | undefined\n      const resetText = (errorData as any).windowResetTime\n        ? formatResetTime(new Date((errorData as any).windowResetTime))\n        : null\n\n      const parts: string[] = []\n      if (remainingTokens !== undefined) {\n        if (remainingTokens === 0) {\n          parts.push(t(\"rate_limit.depleted\", aiNs))\n        } else {\n          parts.push(t(\"ai:rate_limit.credits_left\", { count: remainingTokens }))\n        }\n      } else {\n        if ((errorData as any).reason) {\n          parts.push((errorData as any).reason)\n        }\n        parts.push(t(\"rate_limit.upgrade_to_get_more\", aiNs))\n      }\n\n      if (resetText && !hideResetDetails) {\n        parts.push(resetText)\n      }\n\n      return parts.join(\" · \")\n    }\n  }\n\n  if (!configuration) {\n    return null\n  }\n\n  if (configuration?.freeQuota?.shouldCheckDailyLimit) {\n    const daily = configuration.freeQuota.remainingRequests\n    const monthly = configuration.freeQuota.remainingMonthlyRequests\n    if (!daily || !monthly) {\n      const parts: string[] = []\n      if (!hideResetDetails) {\n        if (!daily && monthly) {\n          parts.push(t(\"rate_limit.resets_tomorrow\", aiNs))\n        } else {\n          parts.push(t(\"rate_limit.resets_next_month\", aiNs))\n        }\n      }\n      parts.push(t(\"rate_limit.upgrade_to_get_more\", aiNs))\n      return parts.join(\" · \")\n    }\n  }\n\n  const remaining = configuration?.usage?.remaining\n\n  if (typeof remaining === \"number\") {\n    if (remaining > 0) return null\n    const resetAt = configuration?.usage?.resetAt ? new Date(configuration.usage.resetAt) : null\n    const formattedResetText = resetAt ? formatResetTime(resetAt) : null\n    const parts: string[] = []\n    if (remaining === 0) {\n      parts.push(t(\"rate_limit.depleted\", aiNs))\n    } else {\n      parts.push(t(\"rate_limit.credits_left\", { ns: \"ai\", count: remaining }))\n    }\n    if (formattedResetText && !hideResetDetails) {\n      parts.push(formattedResetText)\n    }\n    if (parts.length < 2) {\n      parts.push(t(\"rate_limit.upgrade_to_get_more\", aiNs))\n    }\n    return parts.join(\" · \")\n  }\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/shortcut.ts",
    "content": "import type { SerializedEditorState, SerializedLexicalNode } from \"lexical\"\n\nimport type { SerializedShortcutNode } from \"../editor/plugins/shortcut/ShortcutNode\"\nimport type { SendingUIMessage } from \"../store/types\"\n\ntype SerializedNodeWithChildren = SerializedLexicalNode & {\n  children?: SerializedLexicalNode[]\n}\n\nconst isSerializedShortcutNode = (node: SerializedLexicalNode): node is SerializedShortcutNode =>\n  node.type === \"shortcut\"\n\nconst hasChildren = (node: SerializedLexicalNode): node is SerializedNodeWithChildren =>\n  Array.isArray((node as SerializedNodeWithChildren).children)\n\nconst findShortcutIdInNodes = (nodes: SerializedLexicalNode[]): string | undefined => {\n  for (const node of nodes) {\n    if (isSerializedShortcutNode(node) && node.shortcutData?.id?.trim()) {\n      return node.shortcutData.id.trim()\n    }\n\n    if (hasChildren(node)) {\n      const match = findShortcutIdInNodes(node.children ?? [])\n      if (match) {\n        return match\n      }\n    }\n  }\n\n  return undefined\n}\n\nexport const extractShortcutIdFromSerializedState = (\n  state?: SerializedEditorState,\n): string | undefined => {\n  if (!state?.root || !Array.isArray(state.root.children)) {\n    return undefined\n  }\n\n  return findShortcutIdInNodes(state.root.children as SerializedLexicalNode[])\n}\n\nconst parseSerializedState = (\n  rawState: string | SerializedEditorState,\n): SerializedEditorState | null => {\n  if (typeof rawState !== \"string\") {\n    return rawState\n  }\n\n  try {\n    return JSON.parse(rawState) as SerializedEditorState\n  } catch (error) {\n    console.error(\"Failed to parse serialized editor state\", error)\n    return null\n  }\n}\n\nexport const extractShortcutIdFromMessageParts = (\n  parts: SendingUIMessage[\"parts\"],\n): string | undefined => {\n  for (const part of parts) {\n    if (part.type !== \"data-rich-text\") {\n      continue\n    }\n\n    const serializedState = parseSerializedState(part.data.state)\n    if (!serializedState) {\n      continue\n    }\n\n    const match = extractShortcutIdFromSerializedState(serializedState)\n    if (match) {\n      return match\n    }\n  }\n\n  return undefined\n}\n\nexport const prefixMessageIdWithShortcut = (baseId: string, shortcutId?: string): string => {\n  const normalized = shortcutId?.trim()\n  if (!normalized) {\n    return baseId\n  }\n\n  return `${normalized}-${baseId}`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat/utils/titleGeneration.ts",
    "content": "import { followClient } from \"~/lib/api-client\"\n\nimport { AIPersistService } from \"../services\"\nimport type { SendingUIMessage } from \"../store/types\"\n\nexport const generateChatTitle = async (chatId: string, messages: SendingUIMessage[]) => {\n  const relevantMessages = messages.map((msg) => {\n    let content = \"\"\n    if (msg.parts && Array.isArray(msg.parts)) {\n      for (const part of msg.parts) {\n        switch (part.type) {\n          case \"text\": {\n            content += `${part.text}`\n            break\n          }\n          case \"data-rich-text\": {\n            content += part.data.text\n            break\n          }\n        }\n      }\n    }\n\n    return {\n      role: msg.role,\n      content,\n    }\n  })\n\n  const response = await followClient.api.ai\n    .summaryTitle({\n      chatId,\n      messages: relevantMessages,\n    })\n    .catch((error) => {\n      console.error(\"Failed to generate chat title:\", error)\n      return null\n    })\n\n  if (response && \"title\" in response) {\n    return response.title\n  }\n\n  return null\n}\n\n/**\n * Generate and update chat title based on messages\n * @param chatId - Current chat session ID\n * @param messages - Messages to generate title from\n * @param onTitleUpdate - Callback when title is updated\n * @returns Generated title or null\n */\nexport const generateAndUpdateChatTitle = async (\n  chatId: string,\n  messages: SendingUIMessage[],\n  onTitleUpdate?: (title: string) => void,\n): Promise<string | null> => {\n  if (messages.length === 0) {\n    return null\n  }\n\n  const title = await generateChatTitle(chatId, messages)\n\n  if (title && chatId) {\n    try {\n      await AIPersistService.updateSessionTitle(chatId, title)\n      onTitleUpdate?.(title)\n      return title\n    } catch (error) {\n      console.error(\"Failed to update session title:\", error)\n      throw error\n    }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat-session/index.ts",
    "content": "export * from \"./query\"\nexport * from \"./service\"\nexport * from \"./store\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat-session/query.ts",
    "content": "import type {\n  DeleteSessionRequest,\n  GetMessagesQuery,\n  GetUnreadQuery,\n  ListSessionsQuery,\n  MarkSeenRequest,\n  SessionResponse,\n  UpdateSessionRequest,\n} from \"@follow-app/client-sdk\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\n\nimport { followApi } from \"~/lib/api-client\"\n\nconst aiChatSessionKey = \"ai-chat-session\"\nexport const aiChatSessionKeys = {\n  lists: [aiChatSessionKey, \"list\"] as const,\n  list: (filters?: ListSessionsQuery) => [aiChatSessionKey, \"list\", filters] as const,\n  details: [aiChatSessionKey, \"detail\"] as const,\n  detail: (chatId: string) => [aiChatSessionKey, \"detail\", chatId] as const,\n  messages: [aiChatSessionKey, \"messages\"] as const,\n  message: (chatId: string, filters?: GetMessagesQuery) =>\n    [aiChatSessionKey, \"messages\", chatId, filters] as const,\n  unread: [aiChatSessionKey, \"unread\"] as const,\n} as const\n\n// Queries\n\nexport const useAIChatSessionListQuery = ({\n  refetchInterval,\n  filters,\n}: { refetchInterval?: number | false; filters?: ListSessionsQuery } = {}) => {\n  const { data } = useQuery({\n    queryKey: aiChatSessionKeys.list(filters),\n    queryFn: () => followApi.aiChatSessions.list(filters).then((res) => res.data),\n    refetchInterval,\n  })\n  return data\n}\n\nexport const useAIChatSessionQuery = (chatId: string | undefined, opts?: { enabled?: boolean }) => {\n  const enabled = !!chatId && (opts?.enabled ?? true)\n  const { data } = useQuery({\n    queryKey: chatId ? aiChatSessionKeys.detail(chatId) : aiChatSessionKeys.details,\n    queryFn: () =>\n      followApi.aiChatSessions.get({ chatId: chatId as string }).then((res) => res.data),\n    enabled,\n  })\n  return data\n}\n\nexport const useAIChatMessagesQuery = (\n  chatId: string | undefined,\n  filters?: GetMessagesQuery,\n  opts?: { enabled?: boolean },\n) => {\n  const enabled = !!chatId && (opts?.enabled ?? true)\n  const { data } = useQuery({\n    queryKey: chatId ? aiChatSessionKeys.message(chatId, filters) : aiChatSessionKeys.messages,\n    queryFn: () =>\n      followApi.aiChatSessions.messages\n        .get({ chatId: chatId as string, ...filters })\n        .then((res) => res.data),\n    enabled,\n  })\n  return data\n}\n\nexport const useUnreadChatSessionsQuery = (filters?: GetUnreadQuery) => {\n  const { data } = useQuery({\n    queryKey: aiChatSessionKeys.unread,\n    queryFn: () => followApi.aiChatSessions.unread(filters).then((res) => res.data),\n  })\n  return data\n}\n\n// Mutations\n\nexport const useUpdateAIChatSessionMutation = () => {\n  const qc = useQueryClient()\n  return useMutation<SessionResponse, unknown, UpdateSessionRequest>({\n    mutationFn: (input) => followApi.aiChatSessions.update(input),\n    onSuccess: async (res) => {\n      const chatId = res?.data?.chatId ?? undefined\n      await Promise.all([\n        qc.invalidateQueries({ queryKey: aiChatSessionKeys.lists }),\n        chatId\n          ? qc.invalidateQueries({ queryKey: aiChatSessionKeys.detail(chatId) })\n          : Promise.resolve(),\n      ])\n    },\n  })\n}\n\nexport const useDeleteAIChatSessionMutation = () => {\n  const qc = useQueryClient()\n  return useMutation<{ success: boolean }, unknown, DeleteSessionRequest>({\n    mutationFn: (input) => followApi.aiChatSessions.delete(input),\n    onSuccess: async (_res, vars) => {\n      await Promise.all([\n        qc.invalidateQueries({ queryKey: aiChatSessionKeys.lists }),\n        qc.invalidateQueries({ queryKey: aiChatSessionKeys.detail(vars.chatId) }),\n        qc.invalidateQueries({ queryKey: aiChatSessionKeys.messages }),\n      ])\n    },\n  })\n}\n\nexport const useMarkChatSessionSeenMutation = () => {\n  const qc = useQueryClient()\n  return useMutation<SessionResponse, unknown, MarkSeenRequest>({\n    mutationFn: (input) => followApi.aiChatSessions.markSeen(input),\n    onSuccess: async (res) => {\n      const chatId = res?.data?.chatId ?? undefined\n      await Promise.all([\n        qc.invalidateQueries({ queryKey: aiChatSessionKeys.lists }),\n        qc.invalidateQueries({ queryKey: aiChatSessionKeys.unread }),\n        chatId\n          ? qc.invalidateQueries({ queryKey: aiChatSessionKeys.detail(chatId) })\n          : Promise.resolve(),\n      ])\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat-session/service.ts",
    "content": "import type { AIChatMessage, AIChatSession, ListSessionsQuery } from \"@follow-app/client-sdk\"\nimport type { UIMessagePart } from \"ai\"\n\nimport { followApi } from \"../../lib/api-client\"\nimport { queryClient } from \"../../lib/query-client\"\nimport { AIPersistService } from \"../ai-chat/services\"\nimport type {\n  BizUIDataTypes,\n  BizUIMessage,\n  BizUIMetadata,\n  BizUITools,\n} from \"../ai-chat/store/types\"\nimport { aiChatSessionKeys } from \"./query\"\nimport { aiChatSessionStoreActions } from \"./store\"\n\n// Hard cap on pagination to prevent excessive API calls while keeping initial sync fast.\nconst MAX_PAGES = 10\n\n/**\n * Service for syncing AI chat session messages from remote API into local DB.\n */\nclass AIChatSessionServiceStatic {\n  private sync = false\n\n  /**\n   * List sessions from backend and ensure local DB has sessions and unseen messages.\n   * This does NOT mark sessions as seen on the server (non-destructive sync).\n   *\n   * Returns the list of sessions.\n   */\n  async syncSessionsAndMessagesFromServer(filters?: ListSessionsQuery): Promise<AIChatSession[]> {\n    if (this.sync) {\n      return []\n    }\n\n    this.sync = true\n\n    aiChatSessionStoreActions.setSyncing(true)\n    aiChatSessionStoreActions.clearError()\n\n    try {\n      const { data: sessions } = await followApi.aiChatSessions.list(filters)\n\n      const summary = { sessions: sessions.length, messages: 0, failures: 0 }\n\n      sessions.forEach(async (session) => {\n        await AIPersistService.ensureSession(session.chatId, {\n          title: session.title,\n          createdAt: new Date(session.createdAt),\n          // Use createdAt for updatedAt as we are syncing session instead of messages\n          updatedAt: new Date(session.createdAt),\n          isLocal: false,\n        })\n      })\n      await this.loadSessionsFromDb()\n      aiChatSessionStoreActions.setStats(summary)\n      aiChatSessionStoreActions.setLastSyncedAt(new Date())\n      return sessions\n    } catch (error) {\n      aiChatSessionStoreActions.setError(\n        error instanceof Error ? error.message : \"Failed to sync chat sessions\",\n      )\n      throw error\n    } finally {\n      aiChatSessionStoreActions.setSyncing(false)\n      this.sync = false\n    }\n  }\n\n  async loadSessionsFromDb() {\n    const rows = await AIPersistService.getChatSessions()\n\n    aiChatSessionStoreActions.setSessions(rows)\n    return rows\n  }\n  /**\n   * Fetch messages for a chat session from the remote API and persist (upsert) them locally.\n   * Returns the normalized BizUIMessage list that was persisted.\n   *\n   * Defensive in extracting the message array because the SDK response shape may evolve.\n   */\n  async fetchAndPersistMessages(\n    session: AIChatSession,\n    options?: {\n      force?: boolean\n    },\n  ): Promise<void> {\n    const [dbSession, hasPersistedMessages, needsMetadataBackfill] = await Promise.all([\n      AIPersistService.getChatSession(session.chatId),\n      AIPersistService.hasPersistedMessages(session.chatId),\n      AIPersistService.hasAssistantMessagesMissingMetadata(session.chatId),\n    ])\n\n    const lastUpdatedAt = dbSession ? dbSession.updatedAt : new Date(0)\n    const hasUpToDateSession = lastUpdatedAt >= new Date(session.updatedAt)\n\n    if (!options?.force && hasUpToDateSession && hasPersistedMessages && !needsMetadataBackfill) {\n      // If local session is already up-to-date, skip fetching messages\n      return\n    }\n    const referenceLastSeenAt = session.lastSeenAt ? new Date(session.lastSeenAt) : new Date(0)\n    const unseenMessages = await this.fetchUnseenRemoteMessages(\n      session.chatId,\n      needsMetadataBackfill ? new Date(0) : referenceLastSeenAt,\n    )\n    const normalized = unseenMessages.map(this.normalizeRemoteMessage)\n\n    await AIPersistService.ensureSession(session.chatId, {\n      title: session.title,\n      createdAt: new Date(session.createdAt),\n      // Use createdAt for updatedAt\n      // Because we are fetching session data instead of messages\n      updatedAt: new Date(session.createdAt),\n      isLocal: false,\n    })\n    await AIPersistService.upsertMessages(session.chatId, normalized)\n\n    await this.loadSessionsFromDb()\n    aiChatSessionStoreActions.setLastSyncedAt(new Date())\n\n    // Invalidate related queries so UI updates outside of hook-based mutation flows\n    Promise.all([\n      queryClient.invalidateQueries({ queryKey: aiChatSessionKeys.detail(session.chatId) }),\n      queryClient.invalidateQueries({ queryKey: aiChatSessionKeys.lists }),\n      queryClient.invalidateQueries({ queryKey: aiChatSessionKeys.unread }),\n    ])\n  }\n\n  async syncSessionMessages(chatId: string) {\n    try {\n      const sessionRecord = await AIPersistService.getChatSession(chatId)\n      if (sessionRecord?.isLocal) {\n        return AIPersistService.loadUIMessages(chatId)\n      }\n\n      const sessionResponse = await followApi.aiChatSessions.get({ chatId })\n      const session = sessionResponse.data\n\n      if (!session) {\n        return AIPersistService.loadUIMessages(chatId)\n      }\n\n      await this.fetchAndPersistMessages(session)\n      return AIPersistService.loadUIMessages(chatId)\n    } catch (error) {\n      console.error(\"syncSessionMessages: failed\", error)\n      throw error\n    }\n  }\n\n  /**\n   * Fetch remote messages for a chat session that are newer than the provided lastSeenAt timestamp.\n   * Implements keyset pagination using `before` / `nextBefore` and stops when:\n   *  - We have paged past (<=) lastSeenAt, or\n   *  - There is no further `nextBefore`, or\n   *  - A safety MAX_PAGES cap is reached.\n   * Returns only unseen (newer) remote messages.\n   */\n  private async fetchUnseenRemoteMessages(\n    chatId: string,\n    lastSeenAt: Date,\n  ): Promise<AIChatMessage[]> {\n    const allMessages: AIChatMessage[] = []\n    let before: string | undefined\n    for (let page = 0; page < MAX_PAGES; page++) {\n      const resp = await followApi.aiChatSessions.messages.get(\n        before ? { chatId, before } : { chatId },\n      )\n      const { data } = resp\n      const batch = data.messages\n      allMessages.push(...batch)\n\n      const { nextBefore } = data\n      if (!nextBefore || new Date(nextBefore) <= lastSeenAt) {\n        break\n      }\n      before = nextBefore\n    }\n    return allMessages.sort(\n      (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n    )\n  }\n\n  /**\n   * Normalize a remote message object into BizUIMessage shape expected by the local chat system.\n   */\n  private normalizeRemoteMessage = (msg: AIChatMessage): BizUIMessage => {\n    const metadata =\n      msg.metadata && typeof msg.metadata === \"object\" ? (msg.metadata as BizUIMetadata) : undefined\n    return {\n      id: msg.id,\n      role: msg.role satisfies BizUIMessage[\"role\"],\n      parts: msg.messageParts as UIMessagePart<BizUIDataTypes, BizUITools>[],\n      metadata,\n      createdAt: new Date(msg.createdAt),\n    }\n  }\n}\n\nexport const AIChatSessionService = new AIChatSessionServiceStatic()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-chat-session/store.ts",
    "content": "import { createWithEqualityFn } from \"zustand/traditional\"\n\nimport type { ChatSession } from \"../ai-chat/types/ChatSession\"\nimport { AIChatSessionService } from \"./service\"\n\nexport interface AIChatSessionSyncStats {\n  sessions: number\n  messages: number\n  failures: number\n}\n\nexport interface AIChatSessionViewModelState {\n  sessions: ChatSession[]\n  isLoading: boolean\n  isSyncing: boolean\n  stats: AIChatSessionSyncStats\n  lastSyncedAt?: Date\n  error?: string\n}\n\nconst createInitialStats = (): AIChatSessionSyncStats => ({\n  sessions: 0,\n  messages: 0,\n  failures: 0,\n})\n\nexport const useAIChatSessionStore = createWithEqualityFn<AIChatSessionViewModelState>(() => ({\n  sessions: [],\n  isLoading: false,\n  isSyncing: false,\n  stats: createInitialStats(),\n  lastSyncedAt: undefined,\n  error: undefined,\n}))\n\nconst { setState, getState, subscribe } = useAIChatSessionStore\n\nexport const aiChatSessionStoreActions = {\n  setSessions: (sessions: ChatSession[]) =>\n    setState({\n      sessions,\n    }),\n  setLoading: (isLoading: boolean) =>\n    setState({\n      isLoading,\n    }),\n  setSyncing: (isSyncing: boolean) =>\n    setState({\n      isSyncing,\n    }),\n  setStats: (stats: AIChatSessionSyncStats) =>\n    setState({\n      stats: { ...stats },\n    }),\n  resetStats: () =>\n    setState({\n      stats: createInitialStats(),\n    }),\n  setLastSyncedAt: (lastSyncedAt?: Date) =>\n    setState({\n      lastSyncedAt,\n    }),\n  setError: (error?: string) =>\n    setState({\n      error,\n    }),\n  clearError: () =>\n    setState({\n      error: undefined,\n    }),\n\n  // syncing\n  fetchRemoteSessions: async () => {\n    try {\n      await AIChatSessionService.syncSessionsAndMessagesFromServer()\n    } catch (error) {\n      console.error(\"fetchRemoteSessionsAndMessages: failed\", error)\n      aiChatSessionStoreActions.setError(error instanceof Error ? error.message : \"fetch_failed\")\n    }\n  },\n}\n\nexport const useAIChatSessionViewModel = useAIChatSessionStore\n\nexport const createEmptyAIChatSessionSyncStats = createInitialStats\n\nexport const getAIChatSessionState = getState\nexport const subscribeAIChatSessionStore = subscribe\n\nexport const hydrateSessionsFromLocalDb = async () => {\n  try {\n    await AIChatSessionService.loadSessionsFromDb()\n  } catch (error) {\n    console.error(\"hydrateSessionsFromLocalDb: failed\", error)\n    aiChatSessionStoreActions.setError(error instanceof Error ? error.message : \"hydrate_failed\")\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-chat-pane.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.js\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport {\n  convertLexicalToMarkdown,\n  getEditorStateJSONString,\n} from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { nextFrame } from \"@follow/utils\"\nimport { cn } from \"@follow/utils/utils\"\nimport { AnimatePresence, m } from \"framer-motion\"\nimport { useSetAtom } from \"jotai\"\nimport type { EditorState } from \"lexical\"\nimport { $getRoot, $getSelection, $isRangeSelection, createEditor } from \"lexical\"\nimport { nanoid } from \"nanoid\"\nimport type { RefObject } from \"react\"\nimport { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\nimport { useI18n } from \"~/hooks/common\"\nimport { ChatInput } from \"~/modules/ai-chat/components/layouts/ChatInput\"\nimport { useAttachScrollBeyond } from \"~/modules/ai-chat/hooks/useAttachScrollBeyond\"\nimport { useAutoScroll } from \"~/modules/ai-chat/hooks/useAutoScroll\"\nimport {\n  useBlockActions,\n  useChatActions,\n  useChatError,\n  useChatStatus,\n  useCurrentChatId,\n  useHasMessages,\n  useMessages,\n} from \"~/modules/ai-chat/store/hooks\"\nimport type { AIChatContextBlock, BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\nimport { Messages } from \"../ai-chat/components/layouts/Messages\"\nimport { RateLimitNotice } from \"../ai-chat/components/layouts/RateLimitNotice\"\nimport { AIChatWaitingIndicator } from \"../ai-chat/components/message/AIChatMessage\"\nimport { AIShortcutButton } from \"../ai-chat/components/ui/AIShortcutButton\"\nimport { LexicalAIEditorNodes } from \"../ai-chat/editor\"\nimport { computeRateLimitMessage } from \"../ai-chat/utils/rate-limit\"\nimport {\n  extractShortcutIdFromSerializedState,\n  prefixMessageIdWithShortcut,\n} from \"../ai-chat/utils/shortcut\"\nimport { stepAtom } from \"./store\"\n\nconst SUGGESTION_KEYS = [\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\",\n] as I18nKeys[]\n\nconst SUGGESTION_SAMPLE_SIZE = 5\n\ntype SuggestionKey = (typeof SUGGESTION_KEYS)[number]\n\nfunction pickSuggestionKeys(previous?: readonly SuggestionKey[]): SuggestionKey[] {\n  const shuffle = (input: readonly SuggestionKey[]) => {\n    const pool = [...input] as SuggestionKey[]\n    for (let i = pool.length - 1; i > 0; i--) {\n      const j = Math.floor(Math.random() * (i + 1))\n      ;[pool[i], pool[j]] = [pool[j]!, pool[i]!]\n    }\n    return pool\n  }\n\n  if (!previous || previous.length === 0) {\n    return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)\n  }\n\n  const previousSet = new Set(previous)\n  const available = SUGGESTION_KEYS.filter((key) => !previousSet.has(key))\n\n  if (available.length >= SUGGESTION_SAMPLE_SIZE) {\n    return shuffle(available).slice(0, SUGGESTION_SAMPLE_SIZE)\n  }\n\n  // When there aren't enough unique suggestions left, attempt to find a fully new batch.\n  const maxAttempts = 10\n  for (let attempt = 0; attempt < maxAttempts; attempt++) {\n    const candidate = shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)\n    if (!candidate.some((key) => previousSet.has(key))) {\n      return candidate\n    }\n  }\n\n  return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)\n}\n\nexport function AIChatPane() {\n  return (\n    <div className=\"flex h-full flex-col justify-between gap-8 overflow-hidden p-2\">\n      <AIChatPaneImpl />\n    </div>\n  )\n}\n\nfunction AIChatPaneImpl() {\n  const setStep = useSetAtom(stepAtom)\n\n  const hasMessages = useHasMessages()\n  const chatInputRef = useRef<LexicalRichEditorRef | null>(null)\n\n  const appendSuggestionToInput = (suggestion: string) => {\n    const ref = chatInputRef.current\n    const editor = ref?.getEditor()\n\n    if (!editor) {\n      return\n    }\n\n    editor.focus()\n    editor.update(() => {\n      const root = $getRoot()\n      const currentText = root.getTextContent()\n      const needsLeadingSpace = currentText.length > 0 && !currentText.endsWith(\" \")\n      const textToInsert = needsLeadingSpace ? ` ${suggestion}` : suggestion\n\n      root.selectEnd()\n      let selection = $getSelection()\n\n      if (!$isRangeSelection(selection)) {\n        root.selectEnd()\n        selection = $getSelection()\n      }\n\n      if ($isRangeSelection(selection)) {\n        selection.insertText(textToInsert)\n      }\n    })\n  }\n\n  const handleSkip = useEventCallback(async () => {\n    setStep(\"finish\")\n  })\n\n  return (\n    <div className=\"relative flex h-full flex-col\">\n      <header className=\"flex w-full items-start justify-between px-4 pb-4 pt-2\">\n        <div className=\"flex items-center gap-2\">\n          <Folo className=\"size-9\" /> <span className=\"text-xl font-semibold\">AI</span>\n        </div>\n        <GlassButton onClick={handleSkip} variant=\"flat\">\n          <i className=\"i-mgc-close-cute-re\" />\n        </GlassButton>\n      </header>\n\n      <AnimatePresence mode=\"popLayout\">\n        {!hasMessages && <Welcome onSuggestionClick={appendSuggestionToInput} />}\n      </AnimatePresence>\n\n      <div className=\"flex-1 overflow-hidden\">\n        <AIChatInterface inputRef={chatInputRef} />\n      </div>\n    </div>\n  )\n}\n\ninterface WelcomeProps {\n  onSuggestionClick: (suggestion: string) => void\n}\n\nfunction Welcome({ onSuggestionClick }: WelcomeProps) {\n  const t = useI18n()\n  const isDark = useIsDark()\n  const [suggestionKeys, setSuggestionKeys] = useState<SuggestionKey[]>(() => pickSuggestionKeys())\n\n  const onClickSuggestion = useEventCallback((suggestion: string) => {\n    onSuggestionClick(suggestion)\n  })\n\n  const rerollSuggestions = useEventCallback(() => {\n    setSuggestionKeys((prev) => pickSuggestionKeys(prev))\n  })\n\n  return (\n    <m.div\n      initial={{ opacity: 0, x: 20 }}\n      animate={{ opacity: 1, x: 0 }}\n      exit={{ opacity: 0, x: 20 }}\n      transition={{ duration: 0.5, ease: \"easeOut\" }}\n      className=\"flex flex-col items-start gap-4 px-4 py-3\"\n    >\n      <div className=\"flex flex-col items-start gap-4\">\n        <div className=\"space-y-3\">\n          <p className=\"text-xl leading-snug\">\n            {(t.app(\"new_user_guide.ai_chat.intro\") as string).split(\"\\n\").map((line) => (\n              <Fragment key={line}>\n                {line}\n                <br />\n              </Fragment>\n            ))}\n          </p>\n        </div>\n      </div>\n\n      <div className=\"w-full space-y-3\">\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          <p className=\"text-xs font-medium uppercase text-text-secondary\">\n            {t.app(\"new_user_guide.ai_chat.you_can_say\")}\n          </p>\n          <Button variant=\"ghost\" size=\"sm\" onClick={rerollSuggestions}>\n            <i className=\"i-mgc-refresh-2-cute-re mr-2 text-sm\" aria-hidden />\n            {t.app(\"new_user_guide.ai_chat.reroll\")}\n          </Button>\n        </div>\n        <div className=\"flex flex-wrap gap-2\">\n          {suggestionKeys.map((suggestionKey, index) => {\n            const suggestionText = t.app(suggestionKey) as string\n            const gradient = gradientByIndex(index, isDark)\n            return (\n              <AIShortcutButton\n                key={suggestionKey}\n                onClick={() => onClickSuggestion(suggestionText)}\n                animationDelay={index * 0.05 + 0.2}\n                className=\"font-normal text-text\"\n                style={{ background: gradient }}\n              >\n                {suggestionText}\n              </AIShortcutButton>\n            )\n          })}\n        </div>\n      </div>\n\n      <FinishListener />\n    </m.div>\n  )\n}\n\n// if the chat response has `tool-onboardingGetTrendingFeedsTool`, mark the flow as finished\nfunction FinishListener() {\n  const chatMessages = useMessages()\n  const setStep = useSetAtom(stepAtom)\n  useEffect(() => {\n    const hasCalledConfirmTool = chatMessages.some((msg) =>\n      msg.parts.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\"),\n    )\n    if (hasCalledConfirmTool) {\n      setStep(\"finish\")\n    }\n  }, [chatMessages, setStep])\n\n  return null\n}\n\nconst SCROLL_BOTTOM_THRESHOLD = 100\n\ninterface AIChatInterfaceProps {\n  inputRef?: RefObject<LexicalRichEditorRef | null>\n}\n\nfunction AIChatInterface({ inputRef }: AIChatInterfaceProps) {\n  const hasMessages = useHasMessages()\n  const status = useChatStatus()\n  const chatActions = useChatActions()\n  const error = useChatError()\n  const t = useI18n()\n\n  useEffect(() => {\n    if (error) {\n      console.error(\"AIChat Error:\", error)\n    }\n  }, [error])\n\n  // on init, set the scene to onboarding\n  useEffect(() => {\n    chatActions.setScene(\"onboarding\")\n\n    return () => {\n      // reset the scene to general\n      chatActions.setScene(\"general\")\n    }\n  }, [chatActions])\n\n  const currentChatId = useCurrentChatId()\n\n  const [scrollAreaRef, setScrollAreaRef] = useState<HTMLDivElement | null>(null)\n  const [isAtBottom, setIsAtBottom] = useState(true)\n  const [messageContainerMinHeight, setMessageContainerMinHeight] = useState<number | undefined>()\n  const previousMinHeightRef = useRef<number>(0)\n  const messagesContentRef = useRef<HTMLDivElement | null>(null)\n\n  useEffect(() => {\n    setIsAtBottom(true)\n    setMessageContainerMinHeight(undefined)\n    previousMinHeightRef.current = 0\n  }, [currentChatId])\n\n  const { resetScrollState } = useAutoScroll(scrollAreaRef, status === \"streaming\")\n\n  const { handleScroll } = useAttachScrollBeyond()\n\n  useEffect(() => {\n    const scrollElement = scrollAreaRef\n\n    if (!scrollElement) return\n\n    const handleScroll = () => {\n      const { scrollTop, scrollHeight, clientHeight } = scrollElement\n      const distanceFromBottom = scrollHeight - scrollTop - clientHeight\n      const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD\n      setIsAtBottom(atBottom)\n    }\n\n    scrollElement.addEventListener(\"scroll\", handleScroll, { passive: true })\n\n    handleScroll()\n\n    return () => {\n      scrollElement.removeEventListener(\"scroll\", handleScroll)\n    }\n  }, [scrollAreaRef])\n\n  const blockActions = useBlockActions()\n\n  const scrollHeightBeforeSendingRef = useRef<number>(0)\n  const scrollContainerParentRef = useRef<HTMLDivElement | null>(null)\n  const handleScrollPositioning = useEventCallback(() => {\n    const $scrollContainerParent = scrollContainerParentRef.current\n    if (!scrollAreaRef || !$scrollContainerParent) return\n\n    const parentClientHeight = $scrollContainerParent.clientHeight\n    // Use actual content height captured before send (messages container height), not inflated by minHeight\n    const currentScrollHeight = scrollHeightBeforeSendingRef.current\n\n    // Calculate new minimum height based on actual content height\n    // Use previousMinHeightRef which tracks the real content height, not reserved space\n    const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight)\n    const newMinHeight = baseHeight + parentClientHeight - 250\n\n    setMessageContainerMinHeight(newMinHeight)\n\n    // Scroll to the end immediately to position user message at top\n    nextFrame(() => {\n      scrollAreaRef.scrollTo({\n        top: scrollAreaRef.scrollHeight,\n        behavior: \"instant\",\n      })\n    })\n  })\n\n  const staticEditor = useMemo(() => {\n    return createEditor({\n      nodes: LexicalAIEditorNodes,\n    })\n  }, [])\n\n  const handleSendMessage = useEventCallback((message: string | EditorState) => {\n    resetScrollState()\n\n    const blocks = [] as AIChatContextBlock[]\n\n    for (const block of blockActions.getBlocks()) {\n      if (block.type === \"fileAttachment\" && block.attachment.serverUrl) {\n        blocks.push({\n          ...block,\n          attachment: {\n            id: block.attachment.id,\n            name: block.attachment.name,\n            type: block.attachment.type,\n            size: block.attachment.size,\n            serverUrl: block.attachment.serverUrl,\n          },\n        })\n      } else {\n        blocks.push(block)\n      }\n    }\n\n    const parts: BizUIMessage[\"parts\"] = [\n      {\n        type: \"data-block\",\n        data: blocks,\n      },\n    ]\n\n    let shortcutIdFromMessage: string | undefined\n\n    if (typeof message === \"string\") {\n      parts.push({\n        type: \"data-rich-text\",\n        data: {\n          state: getEditorStateJSONString(message),\n          text: message,\n        },\n      })\n    } else {\n      staticEditor.setEditorState(message)\n      const serializedState = message.toJSON()\n      shortcutIdFromMessage = extractShortcutIdFromSerializedState(serializedState)\n      parts.push({\n        type: \"data-rich-text\",\n        data: {\n          state: JSON.stringify(serializedState),\n          text: convertLexicalToMarkdown(staticEditor),\n        },\n      })\n    }\n\n    // Capture actual content height (messages container), not including reserved minHeight\n    scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0\n    chatActions.sendMessage({\n      parts,\n      role: \"user\",\n      id: prefixMessageIdWithShortcut(nanoid(), shortcutIdFromMessage),\n    })\n    tracker.aiChatMessageSent()\n\n    nextFrame(() => {\n      // Calculate and adjust scroll positioning immediately\n      handleScrollPositioning()\n    })\n  })\n\n  const [bottomPanelHeight, setBottomPanelHeight] = useState<number>(0)\n  const bottomPanelRef = useRef<HTMLDivElement | null>(null)\n\n  useLayoutEffect(() => {\n    if (!bottomPanelRef.current) {\n      return\n    }\n    setBottomPanelHeight(bottomPanelRef.current.offsetHeight)\n\n    const resizeObserver = new ResizeObserver(() => {\n      if (!bottomPanelRef.current) {\n        return\n      }\n      setBottomPanelHeight(bottomPanelRef.current.offsetHeight)\n    })\n    resizeObserver.observe(bottomPanelRef.current)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  useEffect(() => {\n    if (status === \"submitted\") {\n      resetScrollState()\n    }\n\n    // When AI response is complete, update the reference height but keep the container height unchanged\n    // This avoids CLS while ensuring next calculation is based on actual content\n    if (status === \"ready\" && scrollAreaRef && messagesContentRef.current) {\n      // Update the reference to actual content height for next calculation (use messages container)\n      previousMinHeightRef.current = messagesContentRef.current.scrollHeight\n      // Keep the current minHeight unchanged to avoid CLS\n    }\n  }, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef])\n\n  const shouldShowScrollToBottom = hasMessages && !isAtBottom\n  const rateLimitMessage = useMemo(() => computeRateLimitMessage(error, null), [error])\n\n  // Additional height for rate limit notice (~40px)\n  const rateLimitExtraHeight = rateLimitMessage ? 40 : 0\n\n  const messages = useMessages()\n  const setStep = useSetAtom(stepAtom)\n\n  const hasFeedsSelection = messages.some((msg) =>\n    msg.parts.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\" && p.output),\n  )\n\n  return (\n    <div className=\"flex h-full flex-1 flex-col\" ref={scrollContainerParentRef}>\n      <ScrollArea\n        onScroll={handleScroll}\n        flex\n        scrollbarClassName=\"mt-12\"\n        scrollbarProps={{\n          style: {\n            marginBottom: Math.max(160, bottomPanelHeight) + rateLimitExtraHeight,\n          },\n        }}\n        ref={setScrollAreaRef}\n        rootClassName=\"flex-1\"\n        viewportProps={{\n          style: {\n            paddingBottom: Math.max(128, bottomPanelHeight) + rateLimitExtraHeight,\n          },\n        }}\n        viewportClassName={\"pt-12\"}\n      >\n        <div\n          className=\"mx-auto w-full px-6 py-8\"\n          style={{\n            minHeight: messageContainerMinHeight ? `${messageContainerMinHeight}px` : undefined,\n          }}\n        >\n          <Messages contentRef={messagesContentRef as RefObject<HTMLDivElement>} />\n\n          {/* if the last message is from ai, show \"Next Step\" button */}\n          {messages.length > 0 &&\n            messages.at(-1)?.role === \"assistant\" &&\n            status === \"ready\" &&\n            hasFeedsSelection && (\n              <div>\n                <Button onClick={() => setStep(\"finish\")}>\n                  {t.app(\"new_user_guide.actions.finish\")}\n                </Button>\n              </div>\n            )}\n\n          {(status === \"submitted\" || status === \"streaming\") && <AIChatWaitingIndicator />}\n        </div>\n      </ScrollArea>\n\n      {shouldShowScrollToBottom && (\n        <div className={cn(\"absolute right-1/2 z-40 translate-x-1/2\", \"bottom-32\")}>\n          <button\n            type=\"button\"\n            onClick={() => resetScrollState()}\n            className={cn(\n              \"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2\",\n              \"border-border\",\n              \"hover:border-border/60 active:scale-[0.98]\",\n            )}\n          >\n            <i className=\"i-mingcute-arrow-down-line text-text/90\" />\n          </button>\n        </div>\n      )}\n\n      <div ref={bottomPanelRef} className={\"px-6\"}>\n        {rateLimitMessage && <RateLimitNotice message={rateLimitMessage} />}\n        <ChatInput\n          ref={inputRef}\n          onSend={handleSendMessage}\n          variant={!hasMessages ? \"minimal\" : \"default\"}\n        />\n      </div>\n    </div>\n  )\n}\n\n// Softer gradient colors based on ACCENT_COLOR_MAP\nconst GRADIENT_COLORS = [\n  {\n    light: { from: \"#FF6B35\", to: \"#FFB088\" },\n    dark: { from: \"#FF5C00\", to: \"#FF8B4D\" },\n  },\n  {\n    light: { from: \"#4CD7A5\", to: \"#8FE8C7\" },\n    dark: { from: \"#1FA97A\", to: \"#4DCFA0\" },\n  },\n  {\n    light: { from: \"#F7B500\", to: \"#FFD966\" },\n    dark: { from: \"#D99800\", to: \"#F7C84D\" },\n  },\n  {\n    light: { from: \"#B07BEF\", to: \"#D4B4F7\" },\n    dark: { from: \"#8A3DCC\", to: \"#B07BEF\" },\n  },\n  {\n    light: { from: \"#F266A8\", to: \"#F9A1CA\" },\n    dark: { from: \"#C63C82\", to: \"#E86BAA\" },\n  },\n]\n\nfunction gradientByIndex(index: number, isDark: boolean) {\n  const colors = GRADIENT_COLORS[index % GRADIENT_COLORS.length]!\n  const mode = isDark ? \"dark\" : \"light\"\n  return `linear-gradient(to right, ${colors[mode].from}, ${colors[mode].to})`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-onboarding-modal-content.tsx",
    "content": "import { tracker } from \"@follow/tracker\"\nimport { useAtomValue } from \"jotai\"\nimport { useEffect, useMemo } from \"react\"\n\nimport { AIChatRoot } from \"~/modules/ai-chat/components/layouts/AIChatRoot\"\n\nimport { settingSyncQueue } from \"../settings/helper/sync-queue\"\nimport { AIChatPane } from \"./ai-chat-pane\"\nimport { FeedsSelectionList } from \"./feeds-selection-list\"\nimport { stepAtom } from \"./store\"\n\nexport function AiOnboardingModalContent({ onClose }: { onClose: () => void }) {\n  const step = useAtomValue(stepAtom)\n\n  useEffect(() => {\n    tracker.onBoarding({\n      stepV2: step,\n      done: step === \"finish\",\n    })\n  }, [step])\n\n  useEffect(() => {\n    if (step !== \"finish\") return\n\n    const syncSettings = async () => {\n      try {\n        await settingSyncQueue.replaceRemote(\"general\")\n      } catch (error) {\n        console.error(\"Failed to sync settings after onboarding\", error)\n      }\n    }\n\n    syncSettings()\n  }, [step])\n\n  useEffect(() => {\n    if (step === \"finish\") {\n      onClose()\n    }\n  }, [onClose, step])\n\n  const content = useMemo(() => {\n    switch (step) {\n      case \"intro\":\n      case \"selecting-feeds\": {\n        return (\n          <div className=\"relative flex size-full flex-col overflow-hidden lg:flex-row\">\n            {/* Left side - Feed Selection (45% width on large screens) */}\n            <div className=\"overflow-hidden lg:w-2/5\">\n              <FeedsSelectionList />\n            </div>\n\n            {/* Gradient divider */}\n            <div\n              className=\"hidden w-px flex-shrink-0 lg:block\"\n              style={{\n                background:\n                  \"linear-gradient(to bottom, transparent, rgba(255, 92, 0, 0.2), transparent)\",\n              }}\n            />\n\n            {/* Right side - AI Chat (55% width on large screens) */}\n            <div className=\"flex-1 overflow-hidden lg:w-[55%]\">\n              <AIChatPane />\n            </div>\n          </div>\n        )\n      }\n\n      default: {\n        return null\n      }\n    }\n  }, [step])\n\n  if (!content) return null\n\n  return (\n    <AIChatRoot>\n      <div className=\"absolute inset-8 flex flex-col overflow-hidden rounded-xl bg-background\">\n        {content}\n      </div>\n    </AIChatRoot>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-onboarding/feeds-selection-list.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n} from \"@follow/components/ui/card/index.jsx\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { decode } from \"@toon-format/toon\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { useAtom, useAtomValue, useSetAtom, useStore } from \"jotai\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useEffect, useMemo, useRef } from \"react\"\n\nimport { useI18n } from \"~/hooks/common\"\n\nimport { AISplineLoader } from \"../ai-chat/components/3d-models/AISplineLoader\"\nimport { useMessages } from \"../ai-chat/store/hooks\"\nimport { SearchResultContent } from \"../discover/DiscoverFeedCard\"\nimport { FeedIcon } from \"../feed/feed-icon\"\nimport type { FeedSelection } from \"./store\"\nimport { feedSelectionAtomsAtom, selectedFeedSelectionAtomsAtom, stepAtom } from \"./store\"\n\ntype FeedToSelect = Omit<FeedSelection, \"selected\">\n\nexport function FeedsSelectionList() {\n  const chatMessages = useMessages()\n  const setStep = useSetAtom(stepAtom)\n\n  const hasFeedsSelection = chatMessages.some((msg) =>\n    msg.parts.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\" && p.output),\n  )\n\n  useEffect(() => {\n    if (hasFeedsSelection) {\n      setStep(\"selecting-feeds\")\n    }\n  }, [hasFeedsSelection, setStep])\n\n  return (\n    <div className=\"h-full overflow-hidden\">\n      <AnimatePresence mode=\"popLayout\">\n        {hasFeedsSelection ? <FeedSelectionOperationScreen /> : <FeedSelectionFirstScreen />}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nfunction FeedSelectionOperationScreen() {\n  const chatMessages = useMessages()\n  const t = useI18n()\n\n  const feedsToSelect: FeedToSelect[] = useMemo(() => {\n    // find the last message that has the tool\n    const output = chatMessages\n      .findLast((m) => m.parts?.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\"))\n      ?.parts?.findLast((p) => p.type === \"tool-onboardingGetTrendingFeeds\")?.output\n\n    return typeof output === \"string\" ? (decode(output) as any[]) : (output as any[])\n  }, [chatMessages])\n\n  const store = useStore()\n  const atomList = useAtomValue(feedSelectionAtomsAtom)\n  const dispatch = useSetAtom(feedSelectionAtomsAtom)\n\n  const lastKeyRef = useRef<string | null>(null)\n\n  const outputKey = useMemo(() => {\n    const ids = Array.from(new Set(feedsToSelect.map((f) => String(f.id))))\n    ids.sort()\n    return ids.join(\"|\")\n  }, [feedsToSelect])\n\n  const existingIds = useMemo(\n    () => new Set(atomList.map((a) => String(store.get(a).id))),\n    [atomList, store],\n  )\n\n  useEffect(() => {\n    if (lastKeyRef.current === outputKey) return\n    lastKeyRef.current = outputKey\n\n    const seen = new Set(existingIds)\n\n    for (const feed of feedsToSelect) {\n      const id = String(feed.id)\n      if (seen.has(id)) continue\n      seen.add(id)\n\n      dispatch({\n        type: \"insert\",\n        value: { ...feed, selected: true },\n      })\n    }\n  }, [dispatch, feedsToSelect, existingIds, outputKey])\n\n  const selectedAtoms = useAtomValue(selectedFeedSelectionAtomsAtom)\n  const items = useMemo(\n    () => selectedAtoms.map((atom) => ({ atom, id: store.get(atom).id })),\n    [selectedAtoms, store],\n  )\n\n  if (items.length === 0) {\n    return (\n      <div className=\"flex h-full flex-col items-center justify-center px-8 text-center\">\n        <i className=\"i-mgc-inbox-cute-re mb-4 text-6xl text-text-secondary\" aria-hidden />\n\n        <p className=\"text-base font-semibold text-text\">\n          {t.app(\"new_user_guide.selection.empty_title\")}\n        </p>\n        <p className=\"mt-2 max-w-sm text-sm text-text-secondary\">\n          {t.app(\"new_user_guide.selection.empty_description\")}\n        </p>\n      </div>\n    )\n  }\n\n  return (\n    <ScrollArea flex rootClassName=\"h-full\" viewportClassName=\"px-3 flex min-h-0 grow\">\n      <div className=\"flex flex-col gap-5 py-5\">\n        <AnimatePresence mode=\"popLayout\">\n          {items.map(({ atom, id }) => (\n            <m.div\n              key={id}\n              layout\n              initial={{ opacity: 0, scale: 0.9 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.9 }}\n            >\n              <FeedSelectionItem feedAtom={atom} />\n            </m.div>\n          ))}\n        </AnimatePresence>\n      </div>\n    </ScrollArea>\n  )\n}\n\nfunction FeedSelectionItem({ feedAtom }: { feedAtom: PrimitiveAtom<FeedSelection> }) {\n  const [feed, setFeed] = useAtom(feedAtom)\n\n  const onRemove = () => {\n    setFeed((prev) => ({\n      ...prev,\n      selected: false,\n    }))\n  }\n\n  return (\n    <div className=\"relative mr-4\">\n      {/* remove button */}\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <i\n            onClick={onRemove}\n            className=\"i-mingcute-minus-circle-fill absolute right-0 top-0 z-10 size-5 -translate-y-1/2 translate-x-1/2 cursor-pointer text-text-secondary transition-colors hover:text-text\"\n          />\n        </TooltipTrigger>\n        <TooltipContent>Remove</TooltipContent>\n      </Tooltip>\n\n      <Card\n        data-feed-id={feed.id}\n        className={cn(\n          \"flex-shrink-0 select-text overflow-hidden border border-zinc-200/50 bg-white/80 backdrop-blur-xl transition-all duration-300 dark:border-zinc-800/50 dark:bg-neutral-800/50\",\n        )}\n      >\n        <CardHeader className=\"pb-2\">\n          <div className=\"flex items-center gap-1\">\n            <FeedIcon\n              size={32}\n              target={{ type: \"feed\", ...feed }}\n              siteUrl={feed.url}\n              fallbackUrl={feed.image ?? undefined}\n              fallback\n            />\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-sm font-semibold text-text\">{feed.title}</p>\n              <p className=\"text-xs text-text-secondary\">{feed.url}</p>\n            </div>\n          </div>\n        </CardHeader>\n\n        <CardContent>\n          <CardDescription className=\"text-sm text-text-secondary\">\n            {feed.description}\n          </CardDescription>\n\n          <div className=\"pointer-events-none mt-5 grid grid-cols-4 gap-2\">\n            {feed.entries?.map((entry) => (\n              <SearchResultContent key={entry.id} entry={entry as any} />\n            ))}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n\nfunction FeedSelectionFirstScreen() {\n  const t = useI18n()\n\n  return (\n    <m.div\n      className=\"relative h-full overflow-hidden p-8\"\n      aria-hidden=\"true\"\n      initial={{ opacity: 0, x: -20 }}\n      animate={{ opacity: 1, x: 0 }}\n      exit={{ opacity: 0, x: -20 }}\n      transition={{ duration: 0.5, ease: \"easeOut\" }}\n    >\n      {/* Grid background - consistent with app patterns */}\n      <div className=\"absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.03)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,black,transparent)] dark:bg-[linear-gradient(rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.05)_1px,transparent_1px)]\" />\n\n      {/* Content */}\n      <div className=\"relative z-10 flex h-full flex-col items-center justify-center text-center\">\n        {/* Icon - using app's existing icon library */}\n        <m.div\n          initial={{ scale: 0.8, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          transition={{ delay: 0.2, duration: 0.6, type: \"spring\" }}\n          className=\"mb-6\"\n        >\n          <div className=\"mx-auto mb-4 flex items-center justify-center\">\n            <AISplineLoader />\n          </div>\n        </m.div>\n\n        {/* Title - using app's gradient text pattern */}\n        <m.div\n          initial={{ y: 20, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          transition={{ delay: 0.4, duration: 0.6, ease: \"easeOut\" }}\n          className=\"mb-4\"\n        >\n          <h1 className=\"bg-gradient-to-r from-zinc-800 to-zinc-600 bg-clip-text text-4xl font-bold text-transparent dark:from-zinc-100 dark:to-zinc-300\">\n            {t.app(\"new_user_guide.intro.title\")}\n          </h1>\n        </m.div>\n\n        {/* Description text */}\n        <m.div\n          initial={{ y: 20, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          transition={{ delay: 0.6, duration: 0.6, ease: \"easeOut\" }}\n          className=\"mb-8 max-w-md\"\n        >\n          <p className=\"text-lg leading-relaxed text-text-secondary\">\n            {t.app(\"new_user_guide.intro.description\")}\n          </p>\n        </m.div>\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-onboarding/modal.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.jsx\"\nimport { m } from \"motion/react\"\nimport type { PropsWithChildren } from \"react\"\nimport { useState } from \"react\"\n\nimport { DeclarativeModal } from \"~/components/ui/modal/stacked/declarative-modal\"\n\nimport { AiOnboardingModalContent } from \"./ai-onboarding-modal-content\"\n\nconst Modal = ({ children }: PropsWithChildren) => {\n  return (\n    <div className=\"center h-full\">\n      <m.div\n        initial={{ scale: 0.95, opacity: 0, y: 20 }}\n        animate={{ scale: 1, opacity: 1, y: 0 }}\n        exit={{ scale: 0.95, opacity: 0, y: 20 }}\n        transition={Spring.presets.smooth}\n        className=\"relative flex h-[85vh] w-[90vw] max-w-[1400px] flex-col overflow-hidden\"\n      >\n        <div className=\"relative z-10 flex size-full flex-col overflow-hidden\">{children}</div>\n      </m.div>\n    </div>\n  )\n}\n\nexport const AiOnboardingModal = () => {\n  const [open, setOpen] = useState(true)\n  return (\n    <RootPortal>\n      <DeclarativeModal\n        id=\"ai-onboarding\"\n        title=\"AI Onboarding\"\n        CustomModalComponent={Modal}\n        modalContainerClassName=\"flex items-center justify-center\"\n        open={open}\n        canClose={false}\n        clickOutsideToDismiss={false}\n        overlay\n      >\n        <AiOnboardingModalContent onClose={() => setOpen(false)} />\n      </DeclarativeModal>\n    </RootPortal>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-onboarding/store.ts",
    "content": "import type { MediaModel } from \"@follow/database/schemas/types\"\nimport type { FeedViewType } from \"@follow-app/client-sdk\"\nimport { atom } from \"jotai\"\nimport { splitAtom } from \"jotai/utils\"\n\nexport const stepAtom = atom<\"intro\" | \"selecting-feeds\" | \"finish\">(\"intro\")\n\nexport type FeedSelection = {\n  description: string | null\n  id: string\n  image: string | null\n  title: string | null\n  url: string\n  selected?: boolean\n\n  entries: {\n    description: string | null\n    id: string\n    media: MediaModel[] | null\n    publishedAt: Date\n    title: string | null\n    url: string | null\n  }[]\n\n  analytics: {\n    view: FeedViewType | null\n  }\n}\n\nexport const feedSelectionsAtom = atom<FeedSelection[]>([])\n\nexport const feedSelectionAtomsAtom = splitAtom(feedSelectionsAtom)\n\nexport const selectedFeedSelectionAtomsAtom = atom((get) =>\n  get(feedSelectionAtomsAtom).filter((a) => get(a).selected),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/ai-item-actions.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\n\nexport interface ActionButton {\n  icon: string\n  onClick: () => void\n  title?: string\n  disabled?: boolean\n  loading?: boolean\n}\n\nexport interface ItemActionsProps {\n  /**\n   * Array of action buttons to display\n   */\n  actions: ActionButton[]\n\n  /**\n   * Whether the item is enabled\n   */\n  enabled: boolean\n\n  /**\n   * Callback when the switch is toggled\n   */\n  onToggle: (enabled: boolean) => void\n}\n\n/**\n * Reusable component for item actions (buttons + switch)\n * Used across AI Task Item, AI Shortcut Item, and MCP Service Item\n */\nexport const ItemActions = ({ actions, enabled, onToggle }: ItemActionsProps) => {\n  return (\n    <div className=\"ml-4 flex items-center gap-3\">\n      {/* Action buttons group */}\n      <div className=\"flex items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100\">\n        {actions.map((action) =>\n          action.title ? (\n            <Tooltip key={action.title} delayDuration={300}>\n              <TooltipTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={action.onClick}\n                  disabled={action.disabled}\n                >\n                  {action.loading ? (\n                    <i className=\"i-mgc-loading-3-cute-re size-4 animate-spin\" />\n                  ) : (\n                    <i className={`${action.icon} size-4`} />\n                  )}\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent>{action.title}</TooltipContent>\n            </Tooltip>\n          ) : (\n            <Button\n              key={action.icon}\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={action.onClick}\n              disabled={action.disabled}\n            >\n              {action.loading ? (\n                <i className=\"i-mgc-loading-3-cute-re size-4 animate-spin\" />\n              ) : (\n                <i className={`${action.icon} size-4`} />\n              )}\n            </Button>\n          ),\n        )}\n      </div>\n\n      {/* Switch area */}\n      <div className=\"flex items-center gap-2 border-l border-fill-tertiary pl-3\">\n        <Switch checked={enabled} onCheckedChange={onToggle} />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/ai-task-modal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport { LexicalRichEditorTextArea } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport type { AITask } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport dayjs from \"dayjs\"\nimport { useRef, useState } from \"react\"\nimport type { GlobalError } from \"react-hook-form\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { MentionPlugin, ShortcutPlugin } from \"~/modules/ai-chat/editor\"\nimport { AIPersistService } from \"~/modules/ai-chat/services\"\nimport { useCreateAITaskMutation, useUpdateAITaskMutation } from \"~/modules/ai-task/query\"\nimport type { ScheduleType, TaskFormData } from \"~/modules/ai-task/types\"\nimport { MAX_PROMPT_LENGTH, taskSchema } from \"~/modules/ai-task/types\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\n\nimport { NotifyChannelsConfig } from \"./notify-channels-config\"\nimport { ScheduleConfig } from \"./schedule-config\"\n\ninterface AITaskModalProps {\n  task?: AITask // Existing task for editing (optional)\n  prompt?: string\n  /**\n   * Explicitly control whether to show the \"open settings\" tip/link.\n   */\n  showSettingsTip?: boolean\n}\n\n// Convert existing task data to form format or use defaults\nconst getDefaultFormData = (task?: AITask, prompt?: string): TaskFormData => {\n  // Get current date/time for default values\n  const now = dayjs()\n\n  if (!task) {\n    // Default values for creating new task\n    return {\n      name: \"AI Task\",\n      prompt: prompt || \"\",\n      schedule: {\n        type: \"once\",\n        date: now.add(1, \"hour\").toISOString(),\n      },\n      options: { notifyChannels: [\"email\"] },\n    }\n  }\n  if (prompt) {\n    console.warn(\"Using provided prompt for existing task, ignoring task prompt\", task, prompt)\n  }\n\n  // Convert existing task data for editing\n  const { schedule } = task\n  let formSchedule: TaskFormData[\"schedule\"]\n\n  switch (schedule.type) {\n    case \"once\": {\n      formSchedule = {\n        type: \"once\",\n        date: dayjs(schedule.date).toISOString(),\n      }\n      break\n    }\n    case \"daily\": {\n      formSchedule = {\n        type: \"daily\",\n        timeOfDay: dayjs(schedule.timeOfDay).toISOString(),\n      }\n      break\n    }\n    case \"weekly\": {\n      formSchedule = {\n        type: \"weekly\",\n        dayOfWeek: schedule.dayOfWeek,\n        timeOfDay: dayjs(schedule.timeOfDay).toISOString(),\n      }\n      break\n    }\n    case \"monthly\": {\n      formSchedule = {\n        type: \"monthly\",\n        dayOfMonth: schedule.dayOfMonth,\n        timeOfDay: dayjs(schedule.timeOfDay).toISOString(),\n      }\n      break\n    }\n    default: {\n      formSchedule = {\n        type: \"once\",\n        date: now.add(1, \"hour\").toISOString(),\n      }\n    }\n  }\n\n  return {\n    name: task.name,\n    prompt: task.prompt,\n    schedule: formSchedule,\n    options: { notifyChannels: [\"email\"], ...task.options },\n  }\n}\n\nexport const AITaskModal = ({ task, prompt, showSettingsTip = false }: AITaskModalProps) => {\n  const { dismiss } = useCurrentModal()\n  const createAITaskMutation = useCreateAITaskMutation()\n  const updateAITaskMutation = useUpdateAITaskMutation()\n  const { t } = useTranslation(\"ai\")\n  const settingModalPresent = useSettingModal()\n\n  const isEditing = !!task\n\n  const form = useForm<TaskFormData>({\n    resolver: zodResolver(taskSchema),\n    defaultValues: getDefaultFormData(task, prompt),\n  })\n\n  const scheduleValue = form.watch(\"schedule\")\n  const notifyChannelsValue = form.watch(\"options.notifyChannels\")\n\n  // Uncontrolled prompt state handled outside react-hook-form\n  const initialPromptRef = useRef(form.getValues(\"prompt\"))\n  const promptEditorRef = useRef<LexicalRichEditorRef | null>(null)\n  const [promptTextLength, setPromptTextLength] = useState(0)\n\n  const handleScheduleChange = (newSchedule: ScheduleType) => {\n    form.setValue(\"schedule\", newSchedule)\n  }\n\n  const handleSubmit = async (data: TaskFormData) => {\n    // The optimistic mutations handle success/error toasts and error cases automatically\n    if (isEditing) {\n      // Update existing task\n      updateAITaskMutation.mutate(\n        {\n          id: task.id,\n          ...data,\n        },\n        {\n          onSuccess: async () => {\n            // If task name changed, sync the AI chat session title (chatId === task.id)\n            const trimmedTitle = data.name?.trim()\n            if (trimmedTitle) {\n              try {\n                await AIPersistService.updateSessionTitle(task.id, trimmedTitle)\n              } catch (err) {\n                console.error(\"Failed to update AI session title:\", err, task, data)\n              }\n            }\n            toast.success(t(\"tasks.toast.updated\"))\n            dismiss()\n          },\n        },\n      )\n    } else {\n      // Create new task\n      createAITaskMutation.mutate(data, {\n        onSuccess: () => {\n          toast.success(t(\"tasks.toast.created\"))\n          dismiss()\n        },\n      })\n    }\n  }\n\n  const currentMutation = isEditing ? updateAITaskMutation : createAITaskMutation\n\n  const onSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {\n    if (!promptEditorRef.current) return\n    const promptValue = JSON.stringify(\n      promptEditorRef.current?.getEditor().getEditorState().toJSON(),\n    )\n\n    form.setValue(\"prompt\", promptValue, { shouldDirty: true, shouldValidate: true })\n    return form.handleSubmit(handleSubmit)(e)\n  }\n\n  return (\n    <div className=\"w-[500px] max-w-full space-y-6\">\n      <Form {...form}>\n        <form onSubmit={onSubmit} className=\"space-y-6\">\n          {/* Task Basic Information Section */}\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center gap-2\">\n              <i className=\"i-mgc-file-upload-cute-re size-4 text-text-secondary\" />\n              <h3 className=\"text-sm font-medium text-text\">{t(\"tasks.section.info\")}</h3>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label className=\"pl-2 text-sm font-medium text-text\">{t(\"tasks.name\")}</Label>\n              <FormField\n                control={form.control}\n                name=\"name\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormControl>\n                      <Input placeholder={t(\"tasks.name_placeholder\")} {...field} />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n            </div>\n          </div>\n\n          {/* Schedule Configuration Section */}\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center gap-2\">\n              <i className=\"i-mgc-calendar-time-add-cute-re size-4 text-text-secondary\" />\n              <h3 className=\"text-sm font-medium text-text\">{t(\"tasks.section.schedule\")}</h3>\n            </div>\n\n            <ScheduleConfig\n              value={scheduleValue}\n              onChange={handleScheduleChange}\n              errors={form.formState.errors.schedule as Record<string, GlobalError>}\n            />\n          </div>\n\n          {/* AI Prompt Section */}\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center gap-2\">\n              <i className=\"i-mgc-magic-2-cute-re size-4 text-text-secondary\" />\n              <h3 className=\"text-sm font-medium text-text\">{t(\"tasks.section.instructions\")}</h3>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label className=\"pl-2 text-sm font-medium text-text\">{t(\"tasks.prompt\")}</Label>\n              <LexicalRichEditorTextArea\n                initialValue={initialPromptRef.current}\n                ref={promptEditorRef}\n                onLengthChange={(textLength) => {\n                  setPromptTextLength(textLength)\n                }}\n                plugins={[MentionPlugin, ShortcutPlugin]}\n                namespace=\"AITaskPromptEditor\"\n                placeholder={t(\"tasks.prompt_placeholder\")}\n                className=\"min-h-[120px] resize-none text-sm leading-relaxed\"\n              />\n              <div className=\"flex items-center justify-between\">\n                <div className=\"text-xs text-text-tertiary\">{t(\"tasks.prompt_helper\")}</div>\n                {promptTextLength > MAX_PROMPT_LENGTH * 0.8 && (\n                  <div className=\"text-xs font-medium text-text-secondary\">\n                    {promptTextLength}/{MAX_PROMPT_LENGTH}\n                  </div>\n                )}\n              </div>\n              {(form.formState.errors.prompt as GlobalError | undefined)?.message && (\n                <div className=\"text-xs text-red\">\n                  {(form.formState.errors.prompt as GlobalError).message}\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Notification Channels Section */}\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center gap-2\">\n              <i className=\"i-mgc-notification-cute-re size-4 text-text-secondary\" />\n              <h3 className=\"text-sm font-medium text-text\">{t(\"tasks.section.notifications\")}</h3>\n            </div>\n            <NotifyChannelsConfig\n              value={notifyChannelsValue}\n              onChange={(channels) => form.setValue(\"options.notifyChannels\", channels)}\n            />\n          </div>\n\n          {/* Form Actions */}\n\n          <div className=\"flex items-center justify-end\">\n            {showSettingsTip && (\n              <button\n                type=\"button\"\n                onClick={() => settingModalPresent(\"ai\")}\n                className=\"mr-auto flex items-center gap-1 text-xs text-text-tertiary underline-offset-2 hover:text-text-secondary hover:underline disabled:opacity-50\"\n                disabled={currentMutation.isPending}\n              >\n                <i className=\"i-mgc-settings-7-cute-re size-3\" />\n                {t(\"tasks.view_in_settings\")}\n              </button>\n            )}\n            <div className=\"flex gap-3\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={dismiss}\n                disabled={currentMutation.isPending}\n              >\n                {t(\"words.cancel\", { ns: \"common\" })}\n              </Button>\n              <Button type=\"submit\" size=\"sm\" disabled={currentMutation.isPending}>\n                {currentMutation.isPending\n                  ? isEditing\n                    ? t(\"tasks.actions.updating\")\n                    : t(\"tasks.actions.scheduling\")\n                  : isEditing\n                    ? t(\"tasks.actions.update\")\n                    : t(\"tasks.actions.schedule\")}\n              </Button>\n            </div>\n          </div>\n        </form>\n      </Form>\n    </div>\n  )\n}\n\nAITaskModal.displayName = \"AITaskModal\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/index.ts",
    "content": "export { AITaskModal } from \"./ai-task-modal\"\nexport { AITaskList } from \"./task-list\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/notify-channels-config.tsx",
    "content": "import { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport { cn } from \"@follow/utils\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { AITaskOptions } from \"../types\"\n\ninterface NotifyChannelsConfigProps {\n  value: AITaskOptions[\"notifyChannels\"]\n  onChange: (channels: AITaskOptions[\"notifyChannels\"]) => void\n}\n\n// Currently backend only supports 'email'. Using a single switch; easy to extend later.\nconst EMAIL_CHANNEL = {\n  key: \"email\",\n  icon: \"i-mgc-mail-cute-re\",\n  labelKey: \"tasks.notify.email\",\n  helperKey: \"tasks.notify.email_helper\",\n} as const\n\nexport const NotifyChannelsConfig = ({ value, onChange }: NotifyChannelsConfigProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  const enabled = value.includes(EMAIL_CHANNEL.key)\n  const toggle = useCallback(\n    (checked: boolean) => {\n      if (checked) onChange([EMAIL_CHANNEL.key])\n      else onChange([])\n    },\n    [onChange],\n  )\n\n  return (\n    <div className=\"space-y-3\">\n      <div className=\"flex items-center gap-3 rounded-md border p-3 text-sm\">\n        <div className=\"flex flex-1 flex-col gap-1\">\n          <div className=\"flex items-center gap-1 font-medium text-text\">\n            <i className={cn(\"size-4 text-text-secondary\", EMAIL_CHANNEL.icon)} />\n            {t(EMAIL_CHANNEL.labelKey)}\n          </div>\n          <div className=\"text-xs leading-relaxed text-text-tertiary\">\n            {t(EMAIL_CHANNEL.helperKey)}\n          </div>\n        </div>\n        <Switch checked={enabled} onCheckedChange={toggle} />\n      </div>\n    </div>\n  )\n}\n\nNotifyChannelsConfig.displayName = \"NotifyChannelsConfig\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/schedule-config.tsx",
    "content": "import { DateTimePicker, TimeSelect } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@follow/components/ui/select/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport dayjs from \"dayjs\"\nimport { memo, useMemo } from \"react\"\nimport type { GlobalError } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { ScheduleType } from \"~/modules/ai-task/types\"\n\ninterface ScheduleConfigProps {\n  value: ScheduleType\n  onChange: (value: ScheduleType) => void\n  errors?: Record<string, GlobalError>\n}\n\nconst dayOfWeekOptions = [\n  { value: \"0\", label: \"schedule.days.sunday\" },\n  { value: \"1\", label: \"schedule.days.monday\" },\n  { value: \"2\", label: \"schedule.days.tuesday\" },\n  { value: \"3\", label: \"schedule.days.wednesday\" },\n  { value: \"4\", label: \"schedule.days.thursday\" },\n  { value: \"5\", label: \"schedule.days.friday\" },\n  { value: \"6\", label: \"schedule.days.saturday\" },\n] as const\n\nconst dayOfMonthOptions = Array.from({ length: 31 }, (_, i) => ({\n  value: (i + 1).toString(),\n  label: (i + 1).toString(),\n}))\n\nconst frequencyOptions = [\n  {\n    value: \"once\",\n    label: \"schedule.frequency.once\",\n    icon: \"i-mgc-time-cute-re\",\n  },\n  {\n    value: \"daily\",\n    label: \"schedule.frequency.daily\",\n    icon: \"i-mgc-round-cute-re\",\n  },\n  {\n    value: \"weekly\",\n    label: \"schedule.frequency.weekly\",\n    icon: \"i-mgc-layout-4-cute-re\",\n  },\n  {\n    value: \"monthly\",\n    label: \"schedule.frequency.monthly\",\n    icon: \"i-mgc-grid-cute-re\",\n  },\n] as const\n\n// Quick preset options for common schedules\nconst getQuickPresets = () => {\n  const now = dayjs()\n  return [\n    {\n      label: \"schedule.presets.tomorrow_9am\" as const,\n      value: {\n        type: \"once\" as const,\n        date: now.add(1, \"day\").hour(9).minute(0).second(0).millisecond(0).toISOString(),\n      },\n    },\n    {\n      label: \"schedule.presets.daily_6pm\" as const,\n      value: {\n        type: \"daily\" as const,\n        timeOfDay: now.hour(18).minute(0).second(0).millisecond(0).toISOString(),\n      },\n    },\n    {\n      label: \"schedule.presets.monday_9am\" as const,\n      value: {\n        type: \"weekly\" as const,\n        dayOfWeek: 1,\n        timeOfDay: now.hour(9).minute(0).second(0).millisecond(0).toISOString(),\n      },\n    },\n    {\n      label: \"schedule.presets.first_9am\" as const,\n      value: {\n        type: \"monthly\" as const,\n        dayOfMonth: 1,\n        timeOfDay: now.hour(9).minute(0).second(0).millisecond(0).toISOString(),\n      },\n    },\n  ]\n}\n\nconst defaultErrors: NonNullable<ScheduleConfigProps[\"errors\"]> = {}\n\n// Calculate next execution times for timeline preview\nconst getNextExecutions = (schedule: ScheduleType, count = 5): dayjs.Dayjs[] => {\n  const now = dayjs()\n  const executions: dayjs.Dayjs[] = []\n\n  switch (schedule.type) {\n    case \"once\": {\n      const executeTime = dayjs(schedule.date)\n      if (executeTime.isAfter(now)) {\n        executions.push(executeTime)\n      }\n      break\n    }\n    case \"daily\": {\n      const time = dayjs(schedule.timeOfDay)\n      let nextExecution = now.hour(time.hour()).minute(time.minute()).second(0).millisecond(0)\n\n      if (nextExecution.isBefore(now)) {\n        nextExecution = nextExecution.add(1, \"day\")\n      }\n\n      for (let i = 0; i < count; i++) {\n        executions.push(nextExecution.add(i, \"day\"))\n      }\n      break\n    }\n    case \"weekly\": {\n      const time = dayjs(schedule.timeOfDay)\n      let nextExecution = now\n        .day(schedule.dayOfWeek)\n        .hour(time.hour())\n        .minute(time.minute())\n        .second(0)\n        .millisecond(0)\n\n      if (nextExecution.isBefore(now) || nextExecution.day() !== schedule.dayOfWeek) {\n        nextExecution = nextExecution.add(1, \"week\").day(schedule.dayOfWeek)\n      }\n\n      for (let i = 0; i < count; i++) {\n        executions.push(nextExecution.add(i, \"week\"))\n      }\n      break\n    }\n    case \"monthly\": {\n      const time = dayjs(schedule.timeOfDay)\n      let nextExecution = now\n        .date(schedule.dayOfMonth)\n        .hour(time.hour())\n        .minute(time.minute())\n        .second(0)\n        .millisecond(0)\n\n      if (nextExecution.isBefore(now)) {\n        nextExecution = nextExecution.add(1, \"month\")\n      }\n\n      for (let i = 0; i < count; i++) {\n        executions.push(nextExecution.add(i, \"month\"))\n      }\n      break\n    }\n  }\n\n  return executions\n}\n\n// Compact Timeline Preview Component\nconst TimelinePreview = memo<{ schedule: ScheduleType }>(({ schedule }) => {\n  const { t } = useTranslation(\"ai\")\n  const executions = useMemo(() => getNextExecutions(schedule, 1), [schedule])\n  const nextExecution = executions[0]\n\n  if (!nextExecution) {\n    return (\n      <div className=\"flex items-center gap-2 text-xs text-text-tertiary\">\n        <div className=\"i-mgc-information-cute-re size-3\" />\n        <span>{t(\"schedule.no_upcoming\")}</span>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex items-center gap-2 text-xs text-text-secondary\">\n      <div className=\"i-mgc-time-cute-re size-3 text-accent\" />\n      <span>\n        {t(\"schedule.next_execution\", {\n          time: nextExecution.format(\"MMM D, h:mm A\"),\n          relative: nextExecution.fromNow(),\n        })}\n      </span>\n    </div>\n  )\n})\n\nTimelinePreview.displayName = \"TimelinePreview\"\n\nexport const ScheduleConfig = memo<ScheduleConfigProps>(\n  ({ value, onChange, errors = defaultErrors }) => {\n    const { t } = useTranslation(\"ai\")\n    const now = useMemo(() => dayjs(), [])\n    const quickPresets = useMemo(() => getQuickPresets(), [])\n\n    const scheduleType = value.type\n\n    const updateSchedule = (newValue: ScheduleType) => {\n      onChange(newValue)\n    }\n\n    return (\n      <div className=\"space-y-4\">\n        {/* Quick Presets */}\n        <div className=\"space-y-2\">\n          <Label className=\"pl-2 text-sm font-medium text-text\">\n            {t(\"schedule.presets_title\")}\n          </Label>\n          <div className=\"flex gap-2\">\n            {quickPresets.map((preset) => (\n              <button\n                key={preset.label}\n                type=\"button\"\n                onClick={() => onChange(preset.value)}\n                className=\"flex-1 rounded-md border border-border/50 bg-material-opaque px-2 py-1.5 text-xs text-text-secondary transition-all duration-200 hover:border-border hover:bg-fill-tertiary hover:text-text\"\n              >\n                {t(preset.label)}\n              </button>\n            ))}\n          </div>\n        </div>\n\n        {/* Frequency Selection */}\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center justify-between\">\n            <Label className=\"pl-2 text-sm font-medium text-text\">{t(\"schedule.title\")}</Label>\n            <TimelinePreview schedule={value} />\n          </div>\n          <div className=\"grid grid-cols-4 gap-2\">\n            {frequencyOptions.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                onClick={() => {\n                  const defaultDate = value.type === \"once\" ? value.date : value.timeOfDay\n                  const defaultSchedules: Record<string, ScheduleType> = {\n                    once: { type: \"once\", date: defaultDate },\n                    daily: {\n                      type: \"daily\",\n                      timeOfDay: defaultDate,\n                    },\n                    weekly: {\n                      type: \"weekly\",\n                      dayOfWeek: 1,\n                      timeOfDay: defaultDate,\n                    },\n                    monthly: {\n                      type: \"monthly\",\n                      dayOfMonth: 1,\n                      timeOfDay: defaultDate,\n                    },\n                  }\n                  const defaultSchedule = defaultSchedules[option.value]\n                  if (defaultSchedule) {\n                    onChange(defaultSchedule)\n                  }\n                }}\n                className={cn(\n                  \"flex flex-col items-center gap-1.5 rounded-lg border p-3 text-xs font-medium transition-all duration-200\",\n                  scheduleType === option.value\n                    ? \"border-accent/30 bg-accent/20 text-accent\"\n                    : \"border-border bg-background text-text-secondary hover:border-border hover:bg-material-opaque hover:text-text\",\n                )}\n              >\n                <div\n                  className={cn(\n                    \"size-4\",\n                    option.icon,\n                    scheduleType === option.value ? \"text-accent\" : \"text-text-tertiary\",\n                  )}\n                />\n                <span className=\"font-medium\">{t(option.label)}</span>\n              </button>\n            ))}\n          </div>\n          {errors.type && <p className=\"mt-2 text-sm text-red\">{errors.type.message}</p>}\n        </div>\n\n        {/* Time Configuration */}\n        {scheduleType === \"once\" && (\n          <div className=\"space-y-2\">\n            <Label className=\"pl-2 text-sm font-medium text-text\">\n              {t(\"schedule.date_time_label\")}\n            </Label>\n            <DateTimePicker\n              value={value.date}\n              minDate={now.toISOString()}\n              onChange={(date) => {\n                updateSchedule({\n                  type: \"once\",\n                  date,\n                })\n              }}\n              placeholder={t(\"schedule.date_time_placeholder\")}\n            />\n            {errors.date && <p className=\"mt-2 text-sm text-red\">{errors.date.message}</p>}\n          </div>\n        )}\n\n        {scheduleType === \"daily\" && (\n          <div className=\"space-y-2 pl-2\">\n            <Label className=\"text-sm font-medium text-text\">{t(\"schedule.time_label\")}</Label>\n            <TimeSelect\n              value={dayjs(value.timeOfDay).format(\"HH:mm\")}\n              onChange={(time) => {\n                const [hours, minutes] = time.split(\":\")\n                const currentDate = dayjs()\n                const timeOfDay = currentDate\n                  .hour(Number(hours))\n                  .minute(Number(minutes))\n                  .second(0)\n                  .millisecond(0)\n                  .toISOString()\n                updateSchedule({\n                  type: \"daily\",\n                  timeOfDay,\n                })\n              }}\n            />\n            {errors.timeOfDay && (\n              <p className=\"mt-2 text-sm text-red\">{errors.timeOfDay.message}</p>\n            )}\n          </div>\n        )}\n\n        {scheduleType === \"weekly\" && (\n          <div className=\"space-y-2 pl-2\">\n            <Label className=\"text-sm font-medium text-text\">\n              {t(\"schedule.configuration_label\")}\n            </Label>\n            <div className=\"grid grid-cols-2 gap-3\">\n              <div className=\"space-y-1\">\n                <Label className=\"text-xs text-text-secondary\">{t(\"schedule.day_label\")}</Label>\n                <Select\n                  onValueChange={(dayOfWeek) =>\n                    updateSchedule({\n                      type: \"weekly\",\n                      timeOfDay: value.timeOfDay,\n                      dayOfWeek: Number(dayOfWeek),\n                    })\n                  }\n                  value={value.dayOfWeek.toString()}\n                >\n                  <SelectTrigger className=\"h-6 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent/background-1/4\">\n                    <SelectValue placeholder={t(\"schedule.day_placeholder\")} />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {dayOfWeekOptions.map((option) => (\n                      <SelectItem key={option.value} value={option.value}>\n                        {t(option.label)}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                {errors.dayOfWeek && <p className=\"text-sm text-red\">{errors.dayOfWeek.message}</p>}\n              </div>\n              <div className=\"space-y-1\">\n                <Label className=\"text-xs text-text-secondary\">{t(\"schedule.time_label\")}</Label>\n                <TimeSelect\n                  value={dayjs(value.timeOfDay).format(\"HH:mm\")}\n                  onChange={(time) => {\n                    const [hours, minutes] = time.split(\":\")\n                    const currentDate = dayjs()\n                    const timeOfDay = currentDate\n                      .hour(Number(hours))\n                      .minute(Number(minutes))\n                      .second(0)\n                      .millisecond(0)\n                      .toISOString()\n                    updateSchedule({\n                      type: \"weekly\",\n                      dayOfWeek: value.dayOfWeek,\n                      timeOfDay,\n                    })\n                  }}\n                />\n                {errors.timeOfDay && <p className=\"text-sm text-red\">{errors.timeOfDay.message}</p>}\n              </div>\n            </div>\n          </div>\n        )}\n\n        {scheduleType === \"monthly\" && (\n          <div className=\"space-y-2 pl-2\">\n            <Label className=\"text-sm font-medium text-text\">\n              {t(\"schedule.configuration_label\")}\n            </Label>\n            <div className=\"grid grid-cols-2 gap-3\">\n              <div className=\"space-y-1\">\n                <Label className=\"text-xs text-text-secondary\">{t(\"schedule.day_label\")}</Label>\n                <Select\n                  onValueChange={(dayOfMonth) =>\n                    updateSchedule({\n                      type: \"monthly\",\n                      timeOfDay: value.timeOfDay,\n                      dayOfMonth: Number(dayOfMonth),\n                    })\n                  }\n                  value={value.dayOfMonth.toString()}\n                >\n                  <SelectTrigger className=\"h-6 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent/background-1/4\">\n                    <SelectValue placeholder={t(\"schedule.day_placeholder\")} />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {dayOfMonthOptions.map((option) => (\n                      <SelectItem key={option.value} value={option.value}>\n                        {option.label}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                {errors.dayOfMonth && (\n                  <p className=\"text-sm text-red\">{errors.dayOfMonth.message}</p>\n                )}\n              </div>\n              <div className=\"space-y-1\">\n                <Label className=\"text-xs text-text-secondary\">{t(\"schedule.time_label\")}</Label>\n                <TimeSelect\n                  value={dayjs(value.timeOfDay).format(\"HH:mm\")}\n                  onChange={(time) => {\n                    const [hours, minutes] = time.split(\":\")\n                    const currentDate = dayjs()\n                    const timeOfDay = currentDate\n                      .hour(Number(hours))\n                      .minute(Number(minutes))\n                      .second(0)\n                      .millisecond(0)\n                      .toISOString()\n                    updateSchedule({\n                      type: \"monthly\",\n                      dayOfMonth: value.dayOfMonth,\n                      timeOfDay,\n                    })\n                  }}\n                />\n                {errors.timeOfDay && <p className=\"text-sm text-red\">{errors.timeOfDay.message}</p>}\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    )\n  },\n)\n\nScheduleConfig.displayName = \"ScheduleConfig\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/task-item.tsx",
    "content": "import { cn, sleep } from \"@follow/utils/utils\"\nimport type { AITask, TaskSchedule } from \"@follow-app/client-sdk\"\nimport dayjs from \"dayjs\"\nimport type { i18n, TFunction } from \"i18next\"\nimport { memo, useCallback, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { setAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport { useDialog, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { toastFetchError } from \"~/lib/error-parser\"\nimport { AIPersistService } from \"~/modules/ai-chat/services\"\nimport { ChatSliceActions } from \"~/modules/ai-chat/store/chat-core/chat-actions\"\nimport { AIChatSessionService } from \"~/modules/ai-chat-session\"\nimport { useAIChatSessionListQuery } from \"~/modules/ai-chat-session/query\"\nimport type { ActionButton } from \"~/modules/ai-task/components/ai-item-actions\"\nimport { ItemActions } from \"~/modules/ai-task/components/ai-item-actions\"\n\nimport {\n  useDeleteAITaskMutation,\n  useTestRunAITaskMutation,\n  useUpdateAITaskMutation,\n} from \"../query\"\nimport { AITaskModal } from \"./ai-task-modal\"\n\n/**\n * Returns a localized weekday name for the given dayOfWeek index (0=Sunday ... 6=Saturday).\n *\n * @see https://stackoverflow.com/questions/30437134/how-to-get-the-weekday-names-using-intl\n *\n * @example\n * ```ts\n * getLocalizedWeekday(0, 'zh') // '星期日'\n * getLocalizedWeekday(1, 'ja-jp') // '月曜日'\n * ```\n */\nconst getLocalizedWeekday = (\n  dayOfWeek: number,\n  locale: string | undefined,\n  options: { format?: \"long\" | \"short\" | \"narrow\" } = {},\n): string => {\n  const fmt = options.format || \"long\"\n  const d = new Date()\n  d.setHours(15, 0, 0, 0) /* normalise */\n  d.setDate(d.getDate() - d.getDay()) /* Sunday */\n  const loc = locale || \"en-US\"\n  const date = d.setDate(d.getDate() + dayOfWeek)\n  return new Intl.DateTimeFormat(loc, { weekday: fmt }).format(date)\n}\n\nconst formatScheduleText = (schedule: TaskSchedule, t: TFunction<\"ai\", undefined>, i18n: i18n) => {\n  if (!schedule) return t(\"tasks.schedule.unknown\")\n  switch (schedule.type) {\n    case \"once\": {\n      const date = dayjs(schedule.date)\n      return t(\"tasks.schedule.once\", {\n        date: date.format(\"MMM D, YYYY\"),\n        time: date.format(\"h:mm A\"),\n      })\n    }\n    case \"daily\": {\n      const time = dayjs(schedule.timeOfDay)\n      return t(\"tasks.schedule.daily\", { time: time.format(\"h:mm A\") })\n    }\n    case \"weekly\": {\n      const time = dayjs(schedule.timeOfDay)\n      const dayName = getLocalizedWeekday(schedule.dayOfWeek, i18n.language)\n      return t(\"tasks.schedule.weekly\", { day: dayName, time: time.format(\"h:mm A\") })\n    }\n    case \"monthly\": {\n      const time = dayjs(schedule.timeOfDay)\n      return t(\"tasks.schedule.monthly\", { day: schedule.dayOfMonth, time: time.format(\"h:mm A\") })\n    }\n    default: {\n      return t(\"tasks.schedule.unknown\")\n    }\n  }\n}\n\nconst getTaskStatus = (task: AITask) => {\n  if (!task.schedule) return \"unknown\"\n\n  // If task is disabled, it's paused\n  if (!task.isEnabled) return \"paused\"\n\n  const now = dayjs()\n\n  if (task.schedule.type === \"once\") {\n    const scheduledDate = dayjs(task.schedule.date)\n    return scheduledDate.isBefore(now) ? \"completed\" : \"scheduled\"\n  }\n\n  return \"scheduled\"\n}\n\nconst getStatusColor = (status: string) => {\n  switch (status) {\n    case \"completed\": {\n      return \"text-green bg-green-50 dark:bg-green-950\"\n    }\n    case \"scheduled\": {\n      return \"text-blue bg-blue-50 dark:bg-blue-950\"\n    }\n    case \"paused\": {\n      return \"text-gray bg-gray-50 dark:bg-gray-950\"\n    }\n    default: {\n      return \"text-gray bg-gray-50 dark:bg-gray-950\"\n    }\n  }\n}\n\nexport const TaskItem = memo(({ task }: { task: AITask }) => {\n  const { present } = useModalStack()\n  const deleteTaskMutation = useDeleteAITaskMutation()\n  const updateTaskMutation = useUpdateAITaskMutation()\n  const testRunMutation = useTestRunAITaskMutation()\n  const { ask } = useDialog()\n  const { t, i18n } = useTranslation(\"ai\")\n  const sessions = useAIChatSessionListQuery()\n  // const chatActions = useChatActions()\n  const [openingReport, setOpeningReport] = useState(false)\n  const status = getTaskStatus(task)\n  const statusColorClass = getStatusColor(status)\n\n  const taskSession = useMemo(\n    () => sessions?.find((s) => s.chatId.startsWith(`ai-task-${task.id}`)),\n    [sessions, task.id],\n  )\n\n  const handleEditTask = (task: AITask) => {\n    present({\n      title: t(\"tasks.modal.edit_title\"),\n      content: () => <AITaskModal task={task} />,\n    })\n  }\n\n  const isDeleting = deleteTaskMutation.isPending\n\n  const handleOpenReport = useCallback(async () => {\n    if (!taskSession) {\n      toast.error(t(\"tasks.toast.no_report\"))\n      return\n    }\n    setOpeningReport(true)\n    try {\n      await AIChatSessionService.fetchAndPersistMessages(taskSession)\n    } catch (e) {\n      console.error(\"Failed to sync chat session messages:\", e)\n      toast.error(t(\"tasks.toast.load_failed\"))\n    }\n    setAIPanelVisibility(true)\n    const chatActions = ChatSliceActions.getActiveInstance()\n    if (!chatActions) {\n      console.error(\"No active chat session found.\")\n    }\n    chatActions?.switchToChat(taskSession.chatId)\n    setOpeningReport(false)\n    toast(t(\"tasks.toast.switch_to_chat\"))\n  }, [taskSession, t])\n\n  const actions: ActionButton[] = [\n    // Only show if the task has at least one run\n    ...(taskSession\n      ? [\n          {\n            icon: \"i-mgc-history-cute-re\",\n            onClick: handleOpenReport,\n            title: t(\"tasks.actions.view_reports\"),\n            loading: openingReport,\n            disabled: openingReport,\n          } satisfies ActionButton,\n        ]\n      : []),\n    {\n      icon: \"i-mgc-test-tube-cute-re\",\n      onClick: async () => {\n        const loadingId = toast.loading(t(\"tasks.toast.test_start\"))\n        try {\n          const testRunResult = await testRunMutation.mutateAsync({ id: task.id })\n          if (testRunResult.data.error) {\n            throw new Error(testRunResult.data.error)\n          }\n          const { sessionId } = testRunResult.data\n          if (!sessionId) {\n            throw new Error(\"No session ID returned from test run\")\n          }\n\n          // Ensure the session exists in local DB\n          await AIPersistService.ensureSession(sessionId, {\n            title: task.name,\n            createdAt: new Date(),\n            updatedAt: new Date(),\n          })\n\n          // Switch to the chat\n          setAIPanelVisibility(true)\n          const chatActions = ChatSliceActions.getActiveInstance()\n          if (chatActions) {\n            await sleep(1500) // wait for backend stream to be ready\n            chatActions.switchToChat(sessionId)\n          }\n\n          toast.success(t(\"tasks.toast.test_success\"), {\n            id: loadingId,\n          })\n        } catch (error) {\n          console.error(\"Failed to run test:\", error)\n          toast.dismiss(loadingId)\n          if (error instanceof Error) {\n            toastFetchError(error)\n            return\n          }\n          toast.error(t(\"tasks.toast.test_failed\"))\n        }\n      },\n      title: t(\"tasks.actions.test_run\"),\n      disabled: testRunMutation.isPending,\n      loading: testRunMutation.isPending,\n    },\n    {\n      icon: \"i-mgc-edit-cute-re\",\n      onClick: () => handleEditTask(task),\n      title: t(\"tasks.actions.edit_task\"),\n    },\n    {\n      icon: \"i-mgc-delete-2-cute-re\",\n      onClick: async () => {\n        const confirmed = await ask({\n          title: t(\"tasks.modal.delete_title\"),\n          // translation fallback pattern; primary key then default string\n          message: t(\"tasks.modal.delete_confirm\", { name: task.name }),\n          confirmText: t(\"words.delete\", { ns: \"common\" }),\n          cancelText: t(\"words.cancel\", { ns: \"common\" }),\n          variant: \"danger\",\n        })\n        if (!confirmed) return\n        try {\n          await deleteTaskMutation.mutateAsync({ id: task.id })\n          toast.success(t(\"tasks.toast.delete_success\"))\n        } catch (error) {\n          console.error(\"Failed to delete task:\", error)\n          toast.error(t(\"tasks.toast.delete_failed\"))\n        }\n      },\n      title: t(\"tasks.actions.delete_task\"),\n      disabled: isDeleting,\n      loading: isDeleting,\n    },\n  ]\n\n  return (\n    <div className=\"group -ml-3 rounded-lg border border-border p-3 transition-colors hover:bg-material-medium\">\n      <div className=\"flex items-start justify-between\">\n        <div className=\"flex-1 space-y-2\">\n          <div className=\"flex items-center gap-2\">\n            <h4 className=\"text-sm font-medium text-text\">{task.name}</h4>\n            <span\n              className={cn(\n                \"inline-flex items-center rounded-full px-2 py-1 text-xs\",\n                statusColorClass,\n              )}\n            >\n              {status === \"completed\" && <i className=\"i-mgc-check-cute-re mr-1 size-3\" />}\n              {status === \"scheduled\" && (\n                <i className=\"i-mgc-calendar-time-add-cute-re mr-1 size-3\" />\n              )}\n              {status === \"paused\" && <i className=\"i-mgc-pause-cute-re mr-1 size-3\" />}\n              <span>{t(`tasks.status.${status}`)}</span>\n            </span>\n          </div>\n          <div className=\"space-y-1\">\n            <p className=\"text-xs text-text-secondary\">\n              <span className=\"text-text-tertiary\">{t(\"tasks.fields.schedule\")}</span>{\" \"}\n              {formatScheduleText(task.schedule, t, i18n)}\n            </p>\n            {task.createdAt && (\n              <p className=\"text-xs text-text-secondary\">\n                <span className=\"text-text-tertiary\">{t(\"tasks.fields.created\")}</span>{\" \"}\n                {dayjs(task.createdAt).format(\"MMM D, YYYY h:mm A\")}\n              </p>\n            )}\n          </div>\n        </div>\n\n        <ItemActions\n          actions={actions}\n          enabled={task.isEnabled}\n          onToggle={async () => {\n            try {\n              await updateTaskMutation.mutateAsync({ id: task.id, isEnabled: !task.isEnabled })\n            } catch (error) {\n              console.error(\"Failed to toggle task:\", error)\n              toast.error(t(\"tasks.toast.update_failed\"))\n            }\n          }}\n        />\n      </div>\n    </div>\n  )\n})\n\nTaskItem.displayName = \"TaskItem\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/components/task-list.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useAITaskListQuery } from \"../query\"\nimport { TaskItem } from \"./task-item\"\n\ninterface TaskListProps {\n  className?: string\n}\n\nexport const AITaskList = memo<TaskListProps>(({ className }) => {\n  const tasks = useAITaskListQuery()\n  const { t } = useTranslation(\"ai\")\n\n  if (tasks === undefined) {\n    return null\n  }\n\n  if (tasks.length === 0) {\n    return (\n      <div className=\"py-8 text-center\">\n        <div className=\"mx-auto mb-3 flex size-12 items-center justify-center rounded-full bg-fill-secondary\">\n          <i className=\"i-mgc-calendar-time-add-cute-re size-6 text-text\" />\n        </div>\n        <h4 className=\"mb-1 text-sm font-medium text-text\">{t(\"tasks.empty.title\")}</h4>\n        <p className=\"text-xs text-text-secondary\">{t(\"tasks.empty.desc\")}</p>\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      {tasks.map((task) => (\n        <TaskItem key={task.id} task={task} />\n      ))}\n    </div>\n  )\n})\n\nAITaskList.displayName = \"AITaskList\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/index.ts",
    "content": "export * from \"./components\"\nexport * from \"./query\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/query.ts",
    "content": "import type { WithOptimistic } from \"@follow/hooks\"\nimport { createOptimisticConfig, useOptimisticMutation } from \"@follow/hooks\"\nimport type {\n  AITask,\n  CreateTaskRequest,\n  TaskCreateResponse,\n  UpdateTaskRequest,\n} from \"@follow-app/client-sdk\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\n\nimport { followApi } from \"~/lib/api-client\"\n\nconst MAX_AI_TASKS = 10\n\n// Use the generic optimistic wrapper type\ntype OptimisticAITask = WithOptimistic<AITask>\n\nconst aiTaskKey = \"ai-task\"\nexport const aiTaskKeys = {\n  list: [aiTaskKey, \"list\"] as const,\n  details: [aiTaskKey, \"detail\"] as const,\n  detail: (id: string) => [...aiTaskKeys.details, id] as const,\n  testRun: [aiTaskKey, \"test-run\"] as const,\n}\n\n// Queries\n\nexport const useAITaskListQuery = () => {\n  const { data } = useQuery({\n    queryKey: aiTaskKeys.list,\n    queryFn: () => followApi.aiTask.list().then((res) => res.data),\n  })\n  return data\n}\n\nexport const useCanCreateNewAITask = () => {\n  const tasks = useAITaskListQuery()\n  return !tasks || tasks.length < MAX_AI_TASKS\n}\n\nexport const useAITaskQuery = (id: string | undefined, opts?: { enabled?: boolean }) => {\n  const enabled = !!id && (opts?.enabled ?? true)\n  const { data } = useQuery({\n    queryKey: id ? aiTaskKeys.detail(id) : aiTaskKeys.details,\n    queryFn: () => followApi.aiTask.get({ id: id as string }),\n    enabled,\n  })\n  return data?.data\n}\n\n// Mutations\n\nexport const useCreateAITaskMutation = () => {\n  return useOptimisticMutation(\n    createOptimisticConfig.forCreate<OptimisticAITask, CreateTaskRequest, TaskCreateResponse>({\n      mutationFn: (input) => followApi.aiTask.create(input),\n      queryKey: aiTaskKeys.list,\n      generateOptimistic: (variables) => ({\n        name: variables.name,\n        prompt: variables.prompt,\n        isEnabled: variables.isEnabled ?? true,\n        schedule: variables.schedule,\n        options: variables.options ?? { notifyChannels: [\"email\"] },\n        userId: \"temp-user\",\n        createdAt: new Date().toISOString(),\n        updatedAt: new Date().toISOString(),\n        lastRunAt: null,\n        nextRunAt: null,\n        runCount: 0,\n        lastResult: null,\n        lastError: null,\n      }),\n\n      errorMessage: \"Failed to create AI task\",\n      retryable: false,\n    }),\n  )\n}\n\nexport const useUpdateAITaskMutation = () => {\n  return useOptimisticMutation(\n    createOptimisticConfig.forUpdate<OptimisticAITask, UpdateTaskRequest>({\n      mutationFn: (input) => followApi.aiTask.update(input),\n      queryKey: aiTaskKeys.list,\n      getId: (variables) => variables.id,\n      errorMessage: \"Failed to update AI task\",\n      retryable: false,\n    }),\n  )\n}\n\nexport const useDeleteAITaskMutation = () => {\n  return useOptimisticMutation(\n    createOptimisticConfig.forDelete<OptimisticAITask, { id: string }>({\n      mutationFn: ({ id }) => followApi.aiTask.delete({ id }),\n      queryKey: aiTaskKeys.list,\n      getId: (variables) => variables.id,\n\n      errorMessage: \"Failed to delete AI task\",\n      retryable: false,\n    }),\n  )\n}\n\nexport const useTestRunAITaskMutation = () => {\n  const queryClient = useQueryClient()\n  return useMutation({\n    mutationKey: aiTaskKeys.testRun,\n    mutationFn: ({ id }: { id: string }) => followApi.aiTask.testRun({ id }, { timeout: 80000 }),\n    onSuccess: async (_res, { id }) => {\n      // Refresh task list and detail to reflect any updated run info\n      await Promise.all([\n        queryClient.invalidateQueries({ queryKey: aiTaskKeys.list }),\n        queryClient.invalidateQueries({ queryKey: aiTaskKeys.detail(id) }),\n      ])\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/ai-task/types.ts",
    "content": "import dayjs from \"dayjs\"\nimport { z } from \"zod\"\n\nexport const MAX_PROMPT_LENGTH = 2000\n\n// AI Schedule Schema\nexport const scheduleSchema = z.union([\n  z.object({\n    type: z.literal(\"once\"),\n    date: z.string().datetime(),\n  }),\n  z.object({\n    type: z.literal(\"daily\"),\n    timeOfDay: z.string().datetime(),\n  }),\n  z.object({\n    type: z.literal(\"weekly\"),\n    dayOfWeek: z.number().min(0).max(6),\n    timeOfDay: z.string().datetime(),\n  }),\n  z.object({\n    type: z.literal(\"monthly\"),\n    dayOfMonth: z.number().min(1).max(31),\n    timeOfDay: z.string().datetime(),\n  }),\n])\n\nexport type ScheduleType = z.infer<typeof scheduleSchema>\n\n// AI Task Options (notification channels etc.)\n// Keep in sync with backend schema.\nexport const aiTaskOptionsSchema = z.object({\n  notifyChannels: z\n    .array(z.enum([\"email\"]))\n    .describe(\"Notification channels to use. Currently only 'email' supported.\"),\n})\n\nexport type AITaskOptions = z.infer<typeof aiTaskOptionsSchema>\n\nexport const taskSchema = z\n  .object({\n    name: z.string().min(1, \"Title is required\").max(50, \"Title must be less than 50 characters\"),\n    prompt: z\n      .string()\n      .min(1, \"Prompt is required\")\n      .max(MAX_PROMPT_LENGTH, \"Prompt must be less than 2000 characters\"),\n    schedule: scheduleSchema,\n    options: aiTaskOptionsSchema,\n  })\n  .refine(\n    (data) => {\n      // Validate that for \"once\" type, the date is in the future\n      if (data.schedule.type === \"once\") {\n        const scheduledDate = dayjs(data.schedule.date)\n        const now = dayjs()\n        return scheduledDate.isAfter(now)\n      }\n      return true\n    },\n    {\n      message: \"Scheduled date must be in the future\",\n      path: [\"schedule\", \"date\"],\n    },\n  )\n\nexport type TaskFormData = z.infer<typeof taskSchema>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx",
    "content": "/* eslint-disable @eslint-react/dom/no-missing-iframe-sandbox */\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { getDBFile } from \"@follow/database/db\"\nimport { DEV, MODE } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\n\nimport { useDebugFeatureValue, useSetDebugFeatureValue } from \"~/atoms/debug-feature\"\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { featureConfigMap } from \"~/lib/features\"\n\nimport { DebugRegistry } from \"../debug/registry\"\n\nexport const EnvironmentDebugModalContent = () => {\n  const actionMap = DebugRegistry.getAll()\n  const debugValues = useDebugFeatureValue() as Record<string, boolean>\n  const setDebugValues = useSetDebugFeatureValue()\n\n  const overrideEnabled = !!debugValues.__override\n\n  const handleToggleOverride = (checked: boolean) => {\n    setDebugValues((prev) => ({ ...(prev as Record<string, boolean>), __override: checked }))\n  }\n\n  const handleToggleFeature = (key: string, checked: boolean) => {\n    setDebugValues((prev) => ({ ...(prev as Record<string, boolean>), [key]: checked }))\n  }\n\n  const featureKeys = Object.keys(featureConfigMap)\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"text-sm font-medium text-text\">Debug override features</div>\n          <Switch checked={overrideEnabled} onCheckedChange={handleToggleOverride} />\n        </div>\n        <p className=\"text-xs text-text-secondary\">\n          When enabled, the switches below override server feature flags locally.\n        </p>\n        <div className=\"rounded-md bg-material-medium p-2\">\n          <div className=\"grid grid-cols-1 gap-2\">\n            {featureKeys.map((key) => (\n              <div key={key} className=\"flex items-center justify-between rounded-md p-2\">\n                <span className=\"text-sm text-text\">{key}</span>\n                <Switch\n                  checked={!!debugValues[key]}\n                  onCheckedChange={(v) => handleToggleFeature(key, v)}\n                  disabled={!overrideEnabled}\n                />\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"space-y-2\">\n        <div className=\"text-sm font-medium text-text\">Debug actions</div>\n        <div className=\"flex flex-col gap-2\">\n          {Object.entries(actionMap).map(([key, action]) => (\n            <div key={key} className=\"flex w-full items-center gap-2\">\n              <span className=\"flex flex-1\">{key}</span>\n              <Button variant=\"outline\" type=\"button\" onClick={() => action()}>\n                <i className=\"i-mgc-play-cute-fi size-3\" />\n                <span className=\"ml-1\">Run</span>\n              </Button>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport const EnvironmentIndicator = () => {\n  const role = useUserRole()\n  const { present } = useModalStack()\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          tabIndex={-1}\n          aria-hidden\n          type=\"button\"\n          onClick={() => {\n            if (!DEV) return\n\n            present({\n              title: \"Debug Actions\",\n              content: EnvironmentDebugModalContent,\n            })\n          }}\n        >\n          <div className=\"center fixed bottom-0 right-0 z-[99999] flex rounded-tl bg-folo px-1 py-0.5 text-xs text-white\">\n            {role}:{DEV && <i className=\"i-mgc-bug-cute-re size-3\" />}\n            {MODE}\n          </div>\n        </button>\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent className=\"max-w-max break-all\" side=\"top\">\n          <pre>{JSON.stringify({ ...env }, null, 2)}</pre>\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n\nconst sqliteOnlineWebsite = \"https://sqlite-online.vercel.app\"\n\nDebugRegistry.add(\"SQLite Online\", () => {\n  window.presentModal({\n    title: \"SQLite Online\",\n    content: ({ dismiss }) => (\n      <div className=\"h-full p-16\" onClick={dismiss}>\n        <iframe\n          id=\"sql-viewer\"\n          src={sqliteOnlineWebsite}\n          className=\"size-full\"\n          onLoad={() => {\n            const iframe = document.querySelector(\"#sql-viewer\") as HTMLIFrameElement\n            if (!iframe) return\n            const win = iframe.contentWindow\n            if (!win) return\n\n            const eventHandler = (event: MessageEvent) => {\n              if (event.origin !== sqliteOnlineWebsite) {\n                console.warn(\"Blocked message from unauthorized origin:\", event.origin)\n                return\n              }\n\n              if (event.data.type === \"loadDatabaseBufferReady\") {\n                getDBFile()\n                  .then(async (blob) => {\n                    const arrayBuffer = await blob.arrayBuffer()\n\n                    win.postMessage(\n                      {\n                        type: \"invokeLoadDatabaseBuffer\",\n                        buffer: arrayBuffer,\n                      },\n                      sqliteOnlineWebsite,\n                    )\n\n                    window.removeEventListener(\"message\", eventHandler)\n                  })\n                  .catch((error) => {\n                    console.error(\"Failed to load database file into SQLite Online\", error)\n                  })\n              }\n            }\n\n            window.addEventListener(\"message\", eventHandler)\n          }}\n        />\n      </div>\n    ),\n\n    CustomModalComponent: PlainModal,\n  })\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app/NetworkStatusIndicator.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { NetworkStatus, useApiStatus, useNetworkStatus } from \"~/atoms/network\"\n\nexport const NetworkStatusIndicator = () => {\n  const networkStatus = useNetworkStatus()\n  const apiStatus = useApiStatus()\n\n  if (networkStatus === NetworkStatus.ONLINE && apiStatus === NetworkStatus.ONLINE) {\n    return null\n  }\n\n  const isNetworkOffline = networkStatus === NetworkStatus.OFFLINE\n  const isApiOffline = apiStatus === NetworkStatus.OFFLINE\n\n  // Determine status type for styling\n  const statusType = isNetworkOffline ? \"offline\" : isApiOffline ? \"api-error\" : \"unknown\"\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <div\n          className={cn(\n            \"fixed bottom-3 left-3 flex items-center gap-2 rounded-full border backdrop-blur-md transition-all duration-200 hover:scale-105\",\n            \"px-3 py-2 shadow-lg ring-1 ring-inset\",\n            // Default styling\n            \"border-fill bg-material-thick text-text-secondary\",\n            // Network offline - more severe styling\n            statusType === \"offline\" && [\n              \"border-red/30 bg-red/10 text-red ring-red/20\",\n              \"dark:border-red/40 dark:bg-red/15 dark:text-red dark:ring-red/25\",\n\n              ELECTRON && \"!bg-sidebar\",\n            ],\n            // API error - warning styling\n            statusType === \"api-error\" && [\n              \"border-red/30 bg-red/10 text-red ring-red/20\",\n              \"dark:border-red/40 dark:bg-red/15 dark:text-red dark:ring-red/25\",\n              ELECTRON && \"!bg-sidebar\",\n            ],\n            ELECTRON && \"backdrop-blur-none\",\n          )}\n        >\n          <i\n            className={cn(\n              \"size-4 shrink-0 transition-all duration-200\",\n              statusType === \"offline\" && \"i-mgc-wifi-off-cute-re\",\n              statusType === \"api-error\" && \"i-mgc-wifi-off-cute-re\",\n            )}\n          />\n\n          <span\n            className={cn(\n              \"shrink-0 text-xs font-medium transition-colors duration-200\",\n              statusType === \"offline\" && \"text-orange\",\n              statusType === \"api-error\" && \"text-red\",\n            )}\n          >\n            {isNetworkOffline ? \"Local Mode\" : isApiOffline ? \"API Error\" : \"Connection Issue\"}\n          </span>\n        </div>\n      </TooltipTrigger>\n      <TooltipContent className=\"max-w-[40ch] text-sm\" align=\"start\" side=\"top\" sideOffset={8}>\n        <div className=\"space-y-1\">\n          <div className=\"font-medium\">\n            {isNetworkOffline\n              ? \"🔄 Local Mode Active\"\n              : isApiOffline\n                ? \"⚠️ API Connection Error\"\n                : \"❌ Connection Problem\"}\n          </div>\n          <div className=\"text-xs leading-relaxed text-text-secondary\">\n            {isNetworkOffline\n              ? \"Operating in local data mode due to network connection failure. Some features may be limited.\"\n              : isApiOffline\n                ? \"Your network connection is stable, but our API servers are temporarily unreachable. Please try again later.\"\n                : \"There's an issue with the connection. Please check your network settings.\"}\n          </div>\n        </div>\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app/Titlebar.tsx",
    "content": "import { WindowState } from \"@follow/shared/bridge\"\nimport { preventDefault } from \"@follow/utils/dom\"\n\nimport { useWindowState } from \"~/atoms/app\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from \"~/constants\"\nimport { ipcServices } from \"~/lib/client\"\n\nexport const Titlebar = () => {\n  const isMaximized = useWindowState() === WindowState.MAXIMIZED\n\n  const feedColWidth = useUISettingKey(\"feedColWidth\")\n\n  return (\n    <div\n      onContextMenu={preventDefault}\n      className=\"drag-region absolute right-0 flex items-center justify-end overflow-hidden\"\n      style={{\n        height: `${ElECTRON_CUSTOM_TITLEBAR_HEIGHT}px`,\n        left: `${feedColWidth}px`,\n      }}\n    >\n      <button\n        className=\"no-drag-region pointer-events-auto flex h-full w-[50px] items-center justify-center duration-200 hover:bg-theme-item-active\"\n        type=\"button\"\n        onClick={() => {\n          ipcServices?.app.windowAction({ action: \"minimize\" })\n        }}\n      >\n        <i className=\"i-mingcute-minimize-line\" />\n      </button>\n\n      <button\n        type=\"button\"\n        className=\"no-drag-region pointer-events-auto flex h-full w-[50px] items-center justify-center duration-200 hover:bg-theme-item-active\"\n        onClick={async () => {\n          await ipcServices?.app.windowAction({ action: \"maximum\" })\n        }}\n      >\n        {isMaximized ? (\n          <i className=\"i-mingcute-restore-line\" />\n        ) : (\n          <i className=\"i-mingcute-square-line\" />\n        )}\n      </button>\n\n      <button\n        type=\"button\"\n        className=\"no-drag-region pointer-events-auto flex h-full w-[50px] items-center justify-center duration-200 hover:bg-red-500 hover:!text-white\"\n        onClick={() => {\n          ipcServices?.app.windowAction({ action: \"close\" })\n        }}\n      >\n        <i className=\"i-mingcute-close-line\" />\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/LAYOUT_ARCHITECTURE.md",
    "content": "# Follow App Layout Architecture Analysis\n\n## Overview\n\nThe Follow application uses a sophisticated nested layout system built on React Router v7 with the `Outlet` component pattern. This architecture provides a flexible, hierarchical layout structure that supports multiple application modes while maintaining clean separation of concerns.\n\n## Layout Hierarchy & Route Flow\n\nThe application's layout system follows a hierarchical structure where each level handles specific responsibilities:\n\n```mermaid\ngraph TD\n    A[App.tsx - Root] --> B[AppLayer with Outlet]\n    B --> C[MainLayout - pages/main/layout.tsx]\n    C --> D[MainDestopLayout - Primary Desktop Container]\n    D --> E[Main Content Area with Outlet]\n    E --> F{Route Type}\n    F -->|Timeline Routes| G[TimelineEntryTwoColumnLayout]\n    F -->|AI-Enhanced Timeline| G2[AIEnhancedTimelineLayout]\n    F -->|AI Routes| H[AIChatLayout]\n    F -->|Subview Routes| I[SubviewLayout]\n    G --> J[EntryColumn + Outlet for Content]\n    G2 --> J2[EntryColumn + AI Panel + Animated Content]\n    I --> K[Full-screen Modal with Outlet]\n    H --> L[AI Panel + Content]\n```\n\n## Core Layout Components\n\n### 1. App Component (`App.tsx`)\n\n**Location**: `src/App.tsx`  \n**Purpose**: Root application wrapper  \n**Outlet Usage**: `<Outlet />` renders the main layout tree\n\n```typescript\n// App.tsx structure\n<RootProviders>\n  <Titlebar /> // Electron only\n  <AppLayer>\n    <Outlet /> // Renders main layout based on routes\n  </AppLayer>\n</RootProviders>\n```\n\n### 2. MainDestopLayout (`MainDestopLayout.tsx`)\n\n**Location**: `src/modules/app-layout/MainDestopLayout.tsx`  \n**Purpose**: Primary desktop application layout  \n**Key Features**:\n\n- Subscription sidebar (left)\n- Main content area (right)\n- Authentication modals\n- Global error boundaries\n- App-wide panels (search, commands)\n\n```typescript\n// MainDestopLayout structure\n<RootContainer>\n  <EntriesProvider>\n    <SubscriptionColumnContainer /> // Left sidebar\n    <main>\n      <AppErrorBoundary>\n        <Outlet /> // Renders timeline/AI/subview layouts\n      </AppErrorBoundary>\n    </main>\n  </EntriesProvider>\n  <GlobalPanels />\n</RootContainer>\n```\n\n### 3. TimelineEntryTwoColumnLayout (`TimelineColumnLayout.tsx`)\n\n**Location**: `src/modules/app-layout/timeline-column/TimelineColumnLayout.tsx`  \n**Purpose**: Two-column resizable layout for feed content  \n**Key Features**:\n\n- Resizable entry column (left)\n- Content display area (right)\n- Wide mode support\n- Persistent column width settings\n\n```typescript\n// TimelineEntryTwoColumnLayout structure\n<div className=\"flex min-w-0 grow\">\n  <div style={{width: position}}>\n    <EntryColumn /> // Left: Entry list\n  </div>\n  <PanelSplitter /> // Resizable divider\n  <Outlet /> // Right: Entry content\n</div>\n```\n\n### 4. SubviewLayout (`SubviewLayout.tsx`)\n\n**Location**: `src/modules/app-layout/subview/SubviewLayout.tsx`  \n**Purpose**: Full-screen modal layout for discovery and utility pages  \n**Key Features**:\n\n- Glass morphism header with back navigation\n- Scroll-based title animation\n- Progress indicator FAB\n- Smooth scroll behavior\n\n```typescript\n// SubviewLayout structure\n<Focusable>\n  <div className=\"relative flex size-full\">\n    <FloatingHeader /> // Glass header with back button\n    <ScrollArea>\n      <Outlet /> // Full-screen content (Discover, Power, etc.)\n    </ScrollArea>\n    <ProgressFAB /> // Scroll progress indicator\n  </div>\n</Focusable>\n```\n\n### 5. AIEnhancedTimelineLayout (`AIEnhancedTimelineLayout.tsx`)\n\n**Location**: `src/modules/app-layout/ai-enhanced-timeline/AIEnhancedTimelineLayout.tsx`  \n**Purpose**: Advanced timeline layout with integrated AI chat functionality  \n**Key Features**:\n\n- Entry list with animated content overlay\n- Integrated AI chat panel (Fixed/Floating modes)\n- Dynamic subscription column toggling\n- Smooth entry transition animations\n- Resizable AI panel with persistent settings\n\n```typescript\n// AIEnhancedTimelineLayout structure\n<div className=\"relative flex min-w-0 grow\">\n  <div className=\"h-full flex-1\">\n    <EntryColumn /> // Entry list - always visible\n    <AnimatedOverlays>\n      <AIEntryHeader /> // Animated header overlay\n      <EntryContent />  // Animated content overlay\n    </AnimatedOverlays>\n  </div>\n  <AIChatPanel />   // Optional resizable AI panel\n  <SubscriptionToggler /> // Dynamic subscription control\n</div>\n```\n\n### 6. AIChatLayout (`AIChatLayout.tsx`)\n\n**Location**: `src/modules/app-layout/ai/AIChatLayout.tsx`  \n**Purpose**: Dynamic AI chat interface layout  \n**Key Features**:\n\n- Switchable panel styles (Fixed/Floating)\n- User preference-based rendering\n- Extensible panel system\n\n## Route-to-Layout Mapping\n\nThe routing system connects URLs to specific layouts through the generated routes configuration:\n\n### Route Structure Analysis\n\n```typescript\n// From generated-routes.ts\nexport const routes: RouteObject[] = [\n  {\n    path: \"\", // Root path\n    lazy: () => import(\"./pages/(main)/layout\"), // MainLayout wrapper\n    children: [\n      {\n        path: \"\",\n        Component: MainIndex, // Default timeline view\n      },\n      {\n        path: \"timeline/:timelineId/:feedId\",\n        lazy: () => import(\"./pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout\"),\n        // ^ This loads AIEnhancedTimelineLayout (with AI feature) or TimelineEntryTwoColumnLayout (fallback)\n        children: [\n          {\n            path: \":entryId\",\n            lazy: () =>\n              import(\"./pages/(main)/(layer)/timeline/[timelineId]/[feedId]/[entryId]/index\"),\n            // ^ Entry content rendered in AIEnhancedTimelineLayout's animated overlay or TimelineEntryTwoColumnLayout's Outlet\n          },\n        ],\n      },\n      {\n        path: \"\",\n        lazy: () => import(\"./pages/(main)/(layer)/(subview)/layout\"),\n        // ^ This loads SubviewLayout\n        children: [\n          {\n            path: \"discover\",\n            lazy: () => import(\"./pages/(main)/(layer)/(subview)/discover/index\"),\n            // ^ Discover page rendered in SubviewLayout's Outlet\n          },\n        ],\n      },\n      {\n        path: \"ai\",\n        lazy: () => import(\"./pages/(main)/(layer)/(ai)/ai/index\"),\n        // ^ AI page rendered in MainDestopLayout's Outlet\n      },\n    ],\n  },\n]\n```\n\n## Layout Flow Examples\n\n### Example 1: Timeline Entry View\n\n**URL**: `/timeline/1/feed-123/entry-456`\n\n```\nApp (Outlet)\n  → MainLayout (responsive wrapper)\n    → MainDestopLayout (desktop container)\n      → AIEnhancedTimelineLayout (AI-enhanced timeline)\n        → EntryColumn (base) + EntryContent (animated overlay) + AIPanel (optional)\n```\n\n### Example 2: Discover Page\n\n**URL**: `/discover`\n\n```\nApp (Outlet)\n  → MainLayout (responsive wrapper)\n    → MainDestopLayout (desktop container)\n      → SubviewLayout (full-screen modal)\n        → DiscoverPage (via Outlet)\n```\n\n### Example 3: AI Chat\n\n**URL**: `/ai`\n\n```\nApp (Outlet)\n  → MainLayout (responsive wrapper)\n    → MainDestopLayout (desktop container)\n      → AIChatLayout (dynamic panel)\n```\n\n## Key Architectural Patterns\n\n### 1. Nested Outlets\n\nEach layout level uses `<Outlet />` to render child routes, creating a flexible composition system:\n\n```typescript\n// Parent Layout\nfunction ParentLayout() {\n  return (\n    <div className=\"layout-container\">\n      <Navigation />\n      <main>\n        <Outlet /> // Child layouts/pages render here\n      </main>\n    </div>\n  )\n}\n```\n\n### 2. Conditional Layout Rendering\n\nLayouts adapt based on route parameters and user preferences:\n\n```typescript\n// TimelineEntryTwoColumnLayout\nconst inWideMode = views.find(v => v.view === view)?.wideMode || false\nreturn (\n  <div className=\"flex\">\n    <EntryColumn />\n    {!inWideMode && <PanelSplitter />}\n    <Outlet />\n  </div>\n)\n```\n\n### 3. Context-Aware Layouts\n\nLayouts provide context to their children:\n\n```typescript\n// MainDestopLayout\n<EntriesProvider>\n  <SubscriptionColumnContainer />\n  <main>\n    <AppErrorBoundary>\n      <Outlet /> // Child components can access EntriesContext\n    </AppErrorBoundary>\n  </main>\n</EntriesProvider>\n```\n\n## Layout State Management\n\n### Global Layout State\n\n- **Feed Column Width**: `useUISettingKey(\"feedColWidth\")`\n- **Entry Column Width**: `useUISettingKey(\"entryColWidth\")`\n- **AI Panel Style**: `useAIChatPanelStyle()`\n\n### Layout-Specific State\n\n- **Scroll Position**: Managed in SubviewLayout\n- **Resizer Position**: Managed in TimelineEntryTwoColumnLayout\n- **Authentication State**: Managed in MainDestopLayout\n\n## Responsive Behavior\n\n### Desktop Layout (`MainDestopLayout`)\n\n- Fixed sidebar with subscription list\n- Resizable content areas\n- Multi-column layouts with splitters\n\n### Mobile Layout (via `withResponsiveComponent`)\n\n```typescript\nexport const Component = withResponsiveComponent(\n  () => Promise.resolve({ default: MainDestopLayout }),\n  async () => {\n    const { default: MobileLayout } = await import(\"~/modules/mobile\")\n    return { default: MobileLayout }\n  },\n)\n```\n\n## Error Boundaries & Fallbacks\n\n### Layout-Level Error Handling\n\n```typescript\n// MainDestopLayout\nconst errorTypes = [\n  ErrorComponentType.Page,\n  ErrorComponentType.FeedFoundCanBeFollow,\n  ErrorComponentType.FeedNotFound,\n] as ErrorComponentType[]\n\n<AppErrorBoundary errorType={errorTypes}>\n  <Outlet />\n</AppErrorBoundary>\n```\n\n## Performance Considerations\n\n### Lazy Loading\n\nAll major layouts use React Router's lazy loading:\n\n```typescript\nconst lazy16 = () => import(\"./pages/(main)/layout\")\n```\n\n### Memoization\n\nCritical layout dimensions are memoized:\n\n```typescript\nconst entryColWidth = useMemo(() => getUISettings().entryColWidth, [])\n```\n\n### Event Handling Optimization\n\nScroll and resize handlers use passive listeners and debouncing:\n\n```typescript\n$scroll.addEventListener(\"scroll\", handler, { passive: true })\n```\n\n## Development Guidelines\n\n### Adding New Layouts\n\n1. Create layout component in `src/modules/app-layout/`\n2. Add JSDoc documentation with structure diagrams\n3. Export from appropriate page component in `src/pages/`\n4. Test with nested routes to ensure Outlet rendering works correctly\n\n### Layout Component Structure\n\n````typescript\n/**\n * LayoutName Component\n *\n * Brief description of layout purpose and features.\n *\n * Layout Structure:\n * ```\n * LayoutName\n * ├── Header/Navigation\n * ├── Content Area\n * │   └── Outlet (renders child routes)\n * └── Footer/Controls\n * ```\n *\n * @component\n * @example\n * // Usage context and route examples\n */\nexport function LayoutName() {\n  return (\n    <div className=\"layout-container\">\n      <Navigation />\n      <main>\n        <Outlet />\n      </main>\n    </div>\n  )\n}\n````\n\n### Best Practices\n\n1. **Always use Outlet**: Enable nested routing capabilities\n2. **Document Layout Structure**: Include ASCII diagrams in JSDoc\n3. **Handle Error States**: Wrap Outlets in appropriate error boundaries\n4. **Optimize for Performance**: Use lazy loading and memoization\n5. **Test Responsive Behavior**: Ensure layouts adapt to different screen sizes\n6. **Maintain Context**: Provide necessary context to child components\n\n## Conclusion\n\nThe Follow app's layout architecture demonstrates sophisticated use of React Router v7's nested routing capabilities. The hierarchical Outlet system creates a flexible, maintainable structure that supports multiple application modes while maintaining clean separation of concerns. Each layout level handles specific responsibilities, from global app state (MainDestopLayout) to specialized interfaces (TimelineEntryTwoColumnLayout, SubviewLayout), creating a scalable foundation for the application's diverse content types and user interfaces.\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx",
    "content": "import { RootPortal } from \"@follow/components/ui/portal/index.jsx\"\nimport { IN_ELECTRON, PROD } from \"@follow/shared/constants\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { preventDefault } from \"@follow/utils/dom\"\nimport type { PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { Suspense, useRef, useState } from \"react\"\nimport { Outlet } from \"react-router\"\n\nimport { setMainContainerElement, setRootContainerElement } from \"~/atoms/dom\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { useLoginModalShow } from \"~/atoms/user\"\nimport { AppErrorBoundary } from \"~/components/common/AppErrorBoundary\"\nimport { ErrorComponentType } from \"~/components/errors/enum\"\nimport { PlainModal, PlainWithAnimationModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { DeclarativeModal } from \"~/components/ui/modal/stacked/declarative-modal\"\nimport { ROOT_CONTAINER_ID } from \"~/constants/dom\"\nimport { EnvironmentIndicator } from \"~/modules/app/EnvironmentIndicator\"\nimport { LoginModalContent } from \"~/modules/auth/LoginModalContent\"\nimport { DebugRegistry } from \"~/modules/debug/registry\"\nimport { EntriesProvider } from \"~/modules/entry-column/context/EntriesContext\"\nimport { CmdF } from \"~/modules/panel/cmdf\"\nimport { SearchCmdK } from \"~/modules/panel/cmdk\"\nimport { CmdNTrigger } from \"~/modules/panel/cmdn\"\nimport { AppNotificationContainer } from \"~/modules/upgrade/lazy/index\"\n\nimport { SubscriptionColumnContainer } from \"./subscription-column/SubscriptionColumn\"\n\nconst errorTypes = [\n  ErrorComponentType.Page,\n  ErrorComponentType.FeedFoundCanBeFollow,\n  ErrorComponentType.FeedNotFound,\n] as ErrorComponentType[]\n\n/**\n * MainDestopLayout Component\n *\n * The main desktop layout that serves as the primary container for the Follow application.\n * This layout is responsible for:\n * - Providing the root layout structure with subscription sidebar and main content area\n * - Handling authentication states and displaying login modals\n * - Managing error boundaries for critical app errors\n * - Rendering app-wide panels (search, commands, notifications)\n *\n * ## Layout Scenarios\n *\n * ### Scenario 1: Timeline View (/timeline/1/feed-123/entry-456)\n * ```\n * ┌─────────────────────────────────────────────────────────────────┐\n * │ MainDestopLayout                                                │\n * ├─────────────┬───────────────────────────────────────────────────┤\n * │ Subscription│ TimelineEntryTwoColumnLayout                      │\n * │ Column      ├─────────────────┬─────────────────────────────────┤\n * │             │ EntryColumn     │ EntryContentView                │\n * │ ┌─────────┐ │ ┌─────────────┐ │ ┌─────────────────────────────┐ │\n * │ │ Feeds   │ │ │ Entry List  │ │ │ Article Content             │ │\n * │ │ - Tech  │ │ │ - Article 1 │ │ │                             │ │\n * │ │ - News  │ │ │ - Article 2 │ │ │ # Article Title             │ │\n * │ │ - Blog  │ │ │ - Article 3 │ │ │ Article content here...     │ │\n * │ └─────────┘ │ └─────────────┘ │ └─────────────────────────────┘ │\n * └─────────────┴─────────────────┴─────────────────────────────────┘\n * ```\n *\n * ### Scenario 2: Discover Page (/discover)\n * ```\n * ┌─────────────────────────────────────────────────────────────────┐\n * │ MainDestopLayout                                                │\n * ├─────────────┬───────────────────────────────────────────────────┤\n * │ Subscription│ SubviewLayout (Full-screen Modal)                 │\n * │ Column      │ ┌─────────────────────────────────────────────────┐ │\n * │             │ │ ◄ Back    Discover Feeds    [Import] [Add] │ │\n * │ ┌─────────┐ │ ├─────────────────────────────────────────────────┤ │\n * │ │ Feeds   │ │ │                                             │ │\n * │ │ - Tech  │ │ │        🔍 Search for feeds...               │ │\n * │ │ - News  │ │ │                                             │ │\n * │ │ - Blog  │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐         │ │\n * │ └─────────┘ │ │ │ Tech    │ │ News    │ │ Design  │         │ │\n * │             │ │ │ Feeds   │ │ Sources │ │ Blogs   │         │ │\n * │             │ │ └─────────┘ └─────────┘ └─────────┘         │ │\n * │             │ └─────────────────────────────────────────────────┘ │\n * └─────────────┴───────────────────────────────────────────────────┘\n * ```\n *\n * ### Scenario 3: AI Chat (/ai)\n * ```\n * ┌─────────────────────────────────────────────────────────────────┐\n * │ MainDestopLayout                                                │\n * ├─────────────┬───────────────────────────────────────────────────┤\n * │ Subscription│ AIChatLayout                                      │\n * │ Column      │ ┌─────────────────────────────────────────────────┐ │\n * │             │ │ 🤖 AI Assistant                             ⚙️ │ │\n * │ ┌─────────┐ │ ├─────────────────────────────────────────────────┤ │\n * │ │ Feeds   │ │ │ 💬 How can I help you today?                   │ │\n * │ │ - Tech  │ │ │                                             │ │\n * │ │ - News  │ │ │ 👤 Summarize my latest tech articles        │ │\n * │ │ - Blog  │ │ │                                             │ │\n * │ └─────────┘ │ │ 🤖 Here's a summary of your recent tech...  │ │\n * │             │ │                                             │ │\n * │             │ ├─────────────────────────────────────────────────┤ │\n * │             │ │ Type a message... [📎] [🎙️] [📤]            │ │\n * │             │ └─────────────────────────────────────────────────┘ │\n * └─────────────┴───────────────────────────────────────────────────┘\n * ```\n *\n * ### Scenario 4: Default View (/) - Timeline Home\n * ```\n * ┌─────────────────────────────────────────────────────────────────┐\n * │ MainDestopLayout                                                │\n * ├─────────────┬───────────────────────────────────────────────────┤\n * │ Subscription│ Default Timeline (All Feeds)                      │\n * │ Column      │ ┌─────────────────────────────────────────────────┐ │\n * │             │ │ 📰 All Articles                             ⚙️ │ │\n * │ ┌─────────┐ │ ├─────────────────────────────────────────────────┤ │\n * │ │ 📌 Today │ │ │ [Tech Blog] New React Features              │ │\n * │ │ ⭐ Starred │ │ │ [News Site] Breaking: AI Breakthrough      │ │\n * │ │ 📚 All   │ │ │ [Design Blog] UI Trends 2024               │ │\n * │ │         │ │ │ [Tech News] JavaScript Updates              │ │\n * │ │ Feeds:  │ │ │ [Blog] How to Build Better Apps            │ │\n * │ │ • Tech  │ │ │                                             │ │\n * │ │ • News  │ │ │ Load more articles...                       │ │\n * │ │ • Design│ │ │                                             │ │\n * │ └─────────┘ │ └─────────────────────────────────────────────────┘ │\n * └─────────────┴───────────────────────────────────────────────────┘\n * ```\n *\n * ## Router Outlet Flow\n * The `<Outlet />` in this component renders different child layouts based on the current route:\n * - `/` → Default timeline view\n * - `/timeline/*` → TimelineEntryTwoColumnLayout (two-column feed reader)\n * - `/discover` → SubviewLayout (full-screen discovery)\n * - `/ai` → AIChatLayout (AI chat interface)\n * - `/power`, `/action`, `/rsshub` → SubviewLayout (utility pages)\n *\n * @component\n * @example\n * // This component is automatically rendered by React Router\n * // based on the route configuration in generated-routes.ts\n */\nexport function MainDestopLayout() {\n  const isAuthFail = useLoginModalShow()\n  const user = useWhoami()\n\n  const containerRef = useRef<HTMLDivElement | null>(null)\n\n  // const { shouldShowNewUserGuide } = useNewUserGuideState()\n  // // Auto-trigger new user guide modal\n  // useEffect(() => {\n  //   if (!shouldShowNewUserGuide) return\n\n  //   import(\"~/modules/app-tip/AppTipModalContent\").then((m) => {\n  //     window.presentModal({\n  //       title: getI18n().t(\"new_user_dialog.title\"),\n  //       content: ({ dismiss }) => (\n  //         <m.AppTipModalContent\n  //           onClose={() => {\n  //             dismiss()\n  //           }}\n  //         />\n  //       ),\n  //       CustomModalComponent: PlainWithAnimationModal,\n  //       modalContainerClassName: \"flex items-center justify-center\",\n  //       modalClassName: \"w-full max-w-5xl\",\n  //       canClose: false,\n  //       clickOutsideToDismiss: false,\n  //       overlay: false,\n  //     })\n  //   })\n  // }, [shouldShowNewUserGuide])\n\n  return (\n    <RootContainer ref={containerRef}>\n      {!PROD && <EnvironmentIndicator />}\n\n      <Suspense>\n        <AppNotificationContainer />\n      </Suspense>\n\n      <EntriesProvider>\n        <SubscriptionColumnContainer />\n\n        <main\n          ref={setMainContainerElement}\n          className=\"flex min-w-0 flex-1 bg-theme-background pt-[calc(var(--fo-window-padding-top)_-10px)] !outline-none\"\n          // NOTE: tabIndex for main element can get by `document.activeElement`\n          tabIndex={-1}\n        >\n          <AppErrorBoundary errorType={errorTypes}>\n            <Outlet />\n          </AppErrorBoundary>\n        </main>\n      </EntriesProvider>\n\n      {isAuthFail && !user && (\n        <RootPortal>\n          <DeclarativeModal\n            id=\"login\"\n            defaultOpen\n            CustomModalComponent={PlainModal}\n            overlay\n            title=\"Login\"\n            canClose={true}\n            clickOutsideToDismiss={true}\n          >\n            <LoginModalContent canClose={true} runtime={IN_ELECTRON ? \"app\" : \"browser\"} />\n          </DeclarativeModal>\n        </RootPortal>\n      )}\n\n      <SearchCmdK />\n      <CmdNTrigger />\n      {IN_ELECTRON && <CmdF />}\n    </RootContainer>\n  )\n}\n\n/**\n * RootContainer Component\n *\n * The root container wrapper that:\n * - Sets up CSS custom properties for layout dimensions\n * - Provides the base container styling and dimensions\n * - Manages DOM element references for the layout system\n * - Handles context menu prevention and responsive behavior\n *\n * @param ref - Ref forwarded to the root div element\n * @param children - Child components to render within the container\n * @component\n */\nconst RootContainer = ({\n  ref,\n  children,\n}: PropsWithChildren & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const feedColWidth = useUISettingKey(\"feedColWidth\")\n\n  const [elementRef, _setElementRef] = useState<HTMLDivElement | null>(null)\n  const setElementRef = React.useCallback((el: HTMLDivElement | null) => {\n    _setElementRef(el)\n    setRootContainerElement(el)\n  }, [])\n  React.useImperativeHandle(ref, () => elementRef!)\n  return (\n    <div\n      ref={setElementRef}\n      style={\n        {\n          \"--fo-feed-col-w\": `${feedColWidth}px`,\n        } as any\n      }\n      className=\"relative z-0 flex h-screen overflow-hidden print:h-auto print:overflow-auto\"\n      onContextMenu={preventDefault}\n      id={ROOT_CONTAINER_ID}\n    >\n      {children}\n    </div>\n  )\n}\n\nDebugRegistry.add(\"App Tip Dialog\", () => {\n  import(\"~/modules/app-tip/AppTipModalContent\").then((m) => {\n    window.presentModal({\n      title: \"App Tip\",\n      content: () => <m.AppTipModalContent />,\n      CustomModalComponent: PlainWithAnimationModal,\n      modalContainerClassName: \"flex items-center justify-center\",\n      modalClassName: \"w-full max-w-5xl\",\n      canClose: true,\n      clickOutsideToDismiss: false,\n      overlay: false,\n    })\n  })\n})\n\nDebugRegistry.add(\"AI Onboarding\", () => {\n  import(\"~/modules/ai-onboarding/ai-onboarding-modal-content\").then((m) => {\n    window.presentModal({\n      title: \"AI Onboarding\",\n      content: ({ dismiss }) => (\n        <m.AiOnboardingModalContent\n          onClose={() => {\n            dismiss()\n          }}\n        />\n      ),\n\n      CustomModalComponent: PlainModal,\n      modalContainerClassName: \"flex items-center justify-center\",\n\n      canClose: false,\n      clickOutsideToDismiss: false,\n      overlay: true,\n    })\n  })\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatFixedPanel.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { FC } from \"react\"\n\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { HotkeyScope } from \"~/constants\"\nimport { ChatHeader } from \"~/modules/ai-chat/components/layouts/ChatHeader\"\nimport { ChatInterface } from \"~/modules/ai-chat/components/layouts/ChatInterface\"\n\nexport interface AIChatFixedPanelProps extends React.DetailedHTMLProps<\n  React.HTMLAttributes<HTMLDivElement>,\n  HTMLDivElement\n> {}\n\nexport const AIChatFixedPanel: FC<AIChatFixedPanelProps> = ({ className, ...props }) => {\n  return (\n    <Focusable\n      scope={HotkeyScope.AIChat}\n      data-hide-in-print\n      className={cn(\"relative flex h-full flex-col overflow-hidden bg-background\", className)}\n      {...props}\n    >\n      <ChatHeader isFloating={false} />\n      <ChatInterface />\n    </Focusable>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatFloatingPanel.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn, computeAdjustedTopLeftPosition } from \"@follow/utils\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { ResizeDirection } from \"re-resizable\"\nimport { Resizable } from \"re-resizable\"\nimport type { FC } from \"react\"\nimport { useEffect, useRef } from \"react\"\n\nimport {\n  getFloatingPanelState,\n  setFloatingPanelState,\n  useAIPanelVisibility,\n  useFloatingPanelState,\n} from \"~/atoms/settings/ai\"\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { HotkeyScope } from \"~/constants\"\nimport { ChatHeader } from \"~/modules/ai-chat/components/layouts/ChatHeader\"\nimport { ChatInterface } from \"~/modules/ai-chat/components/layouts/ChatInterface\"\n\nexport interface AIChatFloatingPanelProps extends React.DetailedHTMLProps<\n  React.HTMLAttributes<HTMLDivElement>,\n  HTMLDivElement\n> {}\n\nconst AIChatFloatingPanelInner: FC<AIChatFloatingPanelProps> = ({ className, ...props }) => {\n  const floatingState = useFloatingPanelState()\n\n  // Preserve right/bottom margins to keep panel anchored to bottom-right on window resize\n  const rightBottomMarginRef = useRef({ right: 0, bottom: 0 })\n  useEffect(() => {\n    rightBottomMarginRef.current = {\n      right: Math.max(0, window.innerWidth - (floatingState.x + floatingState.width)),\n      bottom: Math.max(0, window.innerHeight - (floatingState.y + floatingState.height)),\n    }\n  }, [floatingState.x, floatingState.y, floatingState.width, floatingState.height])\n\n  const handleResize = useRef(\n    (_event: MouseEvent | TouchEvent, direction: ResizeDirection, ref: HTMLElement) => {\n      const prev = getFloatingPanelState()\n      const newWidth = ref.offsetWidth\n      const newHeight = ref.offsetHeight\n      const { x, y } = computeAdjustedTopLeftPosition(\n        { x: prev.x, y: prev.y, width: prev.width, height: prev.height },\n        { width: newWidth, height: newHeight },\n        direction,\n      )\n\n      setFloatingPanelState({ width: newWidth, height: newHeight, x, y })\n    },\n  ).current\n\n  // Keep floating panel anchored to bottom-right on window resize\n  useEffect(() => {\n    const handleWindowResize = () => {\n      const { right, bottom } = rightBottomMarginRef.current\n      const newX = Math.max(0, window.innerWidth - floatingState.width - right)\n      const newY = Math.max(0, window.innerHeight - floatingState.height - bottom)\n      setFloatingPanelState({ x: newX, y: newY })\n    }\n\n    window.addEventListener(\"resize\", handleWindowResize)\n    return () => window.removeEventListener(\"resize\", handleWindowResize)\n  }, [floatingState.width, floatingState.height])\n\n  return (\n    <m.div\n      initial={{ scale: 0.92, y: 100, opacity: 0 }}\n      animate={{ scale: 1, y: 0, opacity: 1 }}\n      exit={{ scale: 0.92, y: 100, opacity: 0 }}\n      transition={Spring.presets.smooth}\n      className=\"fixed z-50\"\n      style={{\n        left: floatingState.x,\n        top: floatingState.y,\n        // @ts-expect-error\n        \"--ai-chat-layout-width\": `${floatingState.width}px`,\n      }}\n    >\n      <div className=\"relative\">\n        <Resizable\n          size={{ width: floatingState.width, height: floatingState.height }}\n          onResize={handleResize}\n          onResizeStop={handleResize}\n          minWidth={500}\n          minHeight={600}\n          maxWidth={800}\n          maxHeight={Math.floor(window.innerHeight * 0.9)}\n          enable={{\n            top: true,\n            right: true,\n            bottom: true,\n            left: true,\n            topRight: true,\n            bottomRight: true,\n            bottomLeft: true,\n            topLeft: true,\n          }}\n        >\n          <Focusable\n            data-hide-in-print\n            scope={HotkeyScope.AIChat}\n            className={cn(\n              \"shadow-ai-chat-floating-panel relative flex h-full flex-col overflow-hidden rounded-lg border bg-background\",\n              className,\n            )}\n            {...props}\n          >\n            <ChatHeader isFloating />\n            <ChatInterface />\n          </Focusable>\n        </Resizable>\n      </div>\n    </m.div>\n  )\n}\n\nexport const AIChatFloatingPanel: FC<AIChatFloatingPanelProps> = (props) => {\n  const visibility = useAIPanelVisibility()\n  return (\n    <AnimatePresence>\n      {visibility && <AIChatFloatingPanelInner key=\"ai-chat-floating-panel\" {...props} />}\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { clsx } from \"@follow/utils\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC } from \"react\"\n\nimport { setAIPanelVisibility, useAIPanelVisibility, useAISettingKey } from \"~/atoms/settings/ai\"\nimport { AISpline } from \"~/modules/ai-chat/components/3d-models/AISpline\"\nimport { AISmartSidebar } from \"~/modules/ai-chat/components/layouts/AISmartSidebar\"\n\nimport { AIChatFloatingPanel } from \"./AIChatFloatingPanel\"\n\nexport const AIIndicator: FC = () => {\n  const isVisible = useAIPanelVisibility()\n  const showSplineButton = useAISettingKey(\"showSplineButton\")\n\n  // Only show the spline button when:\n  // 1. Panel style is floating\n  // 2. Panel is currently not visible\n  const shouldShow = !isVisible\n\n  const handleClick = () => {\n    setAIPanelVisibility(true)\n  }\n\n  return (\n    <>\n      <AnimatePresence>\n        {shouldShow && !showSplineButton && <AISmartSidebar />}\n        {shouldShow && showSplineButton && (\n          <m.button\n            key=\"ai-spline-button\"\n            initial={{ scale: 0, opacity: 0 }}\n            animate={{ scale: 1, opacity: 1 }}\n            exit={{ scale: 0, opacity: 0 }}\n            whileHover={{ scale: 1.05 }}\n            whileTap={{ scale: 0.95 }}\n            transition={Spring.presets.smooth}\n            onClick={handleClick}\n            className={clsx(\n              \"fixed bottom-8 right-8 z-40\",\n              \"rounded-2xl\",\n              \"hover:scale-105\",\n              \"active:scale-95\",\n              \"flex items-center justify-center\",\n              \"transition-all duration-300 ease-out\",\n            )}\n            title=\"Open AI Chat\"\n          >\n            <AISpline />\n          </m.button>\n        )}\n      </AnimatePresence>\n      <AIChatFloatingPanel />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/ai-enhanced-timeline/AIEnhancedTimelineLayout.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { PanelSplitter } from \"@follow/components/ui/divider/index.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { defaultUISettings } from \"@follow/shared/settings/defaults\"\nimport { cn } from \"@follow/utils\"\nimport { isSafari } from \"@follow/utils/utils\"\nimport { AnimatePresence } from \"motion/react\"\nimport type { CSSProperties } from \"react\"\nimport { memo, useCallback, useEffect, useMemo, useRef } from \"react\"\nimport { useResizable } from \"react-resizable-layout\"\n\nimport { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport { getUISettings, setUISetting, useUISettingKey } from \"~/atoms/settings/ui\"\nimport { m } from \"~/components/common/Motion\"\nimport { ROUTE_ENTRY_PENDING } from \"~/constants\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useShowEntryDetailsColumn } from \"~/hooks/biz/useShowEntryDetailsColumn\"\nimport { AIChatRoot } from \"~/modules/ai-chat/components/layouts/AIChatRoot\"\nimport { AIChatFixedPanel } from \"~/modules/app-layout/ai/AIChatFixedPanel\"\nimport { AIIndicator } from \"~/modules/app-layout/ai/AISplineButton\"\nimport { EntryContentPlaceholder } from \"~/modules/app-layout/entry-content/EntryContentPlaceholder\"\nimport { EntryColumn } from \"~/modules/entry-column\"\nimport { EntryContent } from \"~/modules/entry-content/components/entry-content\"\nimport { AIEntryHeader } from \"~/modules/entry-content/components/entry-header\"\nimport { AppLayoutGridContainerProvider } from \"~/providers/app-grid-layout-container-provider\"\nimport { MainViewHotkeysProvider } from \"~/providers/main-view-hotkeys-provider\"\n\nimport { MobileTimelineLayout } from \"./MobileTimelineLayout\"\n\nconst MIN_ENTRY_WIDTH = isSafari() ? 356 : 300\n\nconst AIEnhancedTimelineLayoutImpl = () => {\n  const { view, entryId } = useRouteParamsSelector((state) => ({\n    view: state.view,\n    entryId: state.entryId,\n  }))\n\n  const realEntryId = entryId === ROUTE_ENTRY_PENDING ? \"\" : entryId\n  const showEntryDetailsColumn = useShowEntryDetailsColumn()\n  const aiPanelStyle = useAIChatPanelStyle()\n  const isAIPanelVisible = useAIPanelVisibility()\n  const hasSelectedEntry = Boolean(realEntryId)\n  const isMobile = useMobile()\n\n  // Compute derived values first\n  const showEntryContentOnRight = showEntryDetailsColumn && hasSelectedEntry\n  const isFixedPanelStyle = aiPanelStyle === AIChatPanelStyle.Fixed\n  const shouldShowFixedAI = isFixedPanelStyle && isAIPanelVisible\n  const showEntryContentOnLeft = !showEntryDetailsColumn && hasSelectedEntry\n  const shouldRenderRightColumn = showEntryDetailsColumn || shouldShowFixedAI\n  const shouldShowEntryBorder = showEntryDetailsColumn || shouldShowFixedAI\n\n  // Mobile-specific logic: disable resizing and hide splitters\n  const shouldDisableResize = isMobile\n  const shouldShowSplitter = !isMobile && shouldRenderRightColumn\n\n  const layoutContainerRef = useRef<HTMLDivElement>(null)\n  const feedColumnWidth = useUISettingKey(\"feedColWidth\")\n\n  const timelineMaxWidth = useMemo(() => {\n    if (typeof window === \"undefined\") return 600\n    return Math.max((window.innerWidth - feedColumnWidth) / 2, 600)\n  }, [feedColumnWidth])\n\n  const entryColumnInitialWidth = useMemo(() => getUISettings().entryColWidth, [])\n  const timelineStartDragPosition = useRef(0)\n\n  const {\n    position: timelineColumnWidth,\n    separatorProps: timelineSeparatorProps,\n    isDragging: isTimelineDragging,\n    separatorCursor: timelineSeparatorCursor,\n    setPosition: setTimelineColumnWidth,\n  } = useResizable({\n    axis: \"x\",\n    min: MIN_ENTRY_WIDTH,\n    max: timelineMaxWidth,\n    initial: entryColumnInitialWidth,\n    containerRef: layoutContainerRef as React.RefObject<HTMLElement>,\n    disabled: shouldDisableResize,\n    onResizeStart({ position }) {\n      timelineStartDragPosition.current = position\n    },\n    onResizeEnd({ position }) {\n      if (position === timelineStartDragPosition.current) return\n      setUISetting(\"entryColWidth\", position)\n      window.dispatchEvent(new Event(\"resize\"))\n    },\n  })\n\n  const isAllView = view === FeedViewType.All\n  const widthRange: [number, number] = isAllView ? [500, timelineMaxWidth] : [300, timelineMaxWidth]\n  const [minWidth, maxWidth] = widthRange\n\n  const clampWidth = useCallback(\n    (value: number) => Math.max(minWidth, Math.min(maxWidth, Math.round(value))),\n    [minWidth, maxWidth],\n  )\n\n  const resolvePreferredWidth = useCallback(() => {\n    const ui = getUISettings()\n    const preferred = ui.aiColWidth ?? defaultUISettings.aiColWidth\n    return clampWidth(preferred)\n  }, [clampWidth])\n\n  const aiPanelStartDragPosition = useRef(0)\n  const {\n    position: aiPanelWidth,\n    separatorProps: aiSeparatorProps,\n    isDragging: isAiPanelDragging,\n    separatorCursor: aiSeparatorCursor,\n    setPosition: setAiPanelWidth,\n  } = useResizable({\n    axis: \"x\",\n    min: minWidth,\n    max: maxWidth,\n    initial: resolvePreferredWidth(),\n    reverse: true,\n    containerRef: layoutContainerRef as React.RefObject<HTMLElement>,\n    disabled: shouldDisableResize,\n    onResizeStart({ position }) {\n      aiPanelStartDragPosition.current = position\n    },\n    onResizeEnd({ position }) {\n      if (position === aiPanelStartDragPosition.current) return\n      setUISetting(\"aiColWidth\", position)\n      window.dispatchEvent(new Event(\"resize\"))\n    },\n  })\n\n  useEffect(() => {\n    const width = resolvePreferredWidth()\n    setAiPanelWidth(width)\n    window.dispatchEvent(new Event(\"resize\"))\n  }, [resolvePreferredWidth, setAiPanelWidth])\n\n  const entryColumnStyle: CSSProperties = isMobile\n    ? {\n        width: \"100%\",\n        minWidth: \"100%\",\n        flexBasis: \"100%\",\n      }\n    : showEntryDetailsColumn\n      ? {\n          flexBasis: timelineColumnWidth,\n          minWidth: MIN_ENTRY_WIDTH,\n        }\n      : {\n          minWidth: MIN_ENTRY_WIDTH,\n        }\n\n  const rightColumnStyle: CSSProperties = isMobile\n    ? {\n        width: \"100%\",\n        minWidth: \"100%\",\n        flexBasis: \"100%\",\n      }\n    : showEntryDetailsColumn\n      ? {\n          minWidth: 0,\n        }\n      : {\n          width: aiPanelWidth,\n          minWidth: 0,\n          flexBasis: aiPanelWidth,\n        }\n\n  const resetTimelineWidth = useCallback(() => {\n    setUISetting(\"entryColWidth\", defaultUISettings.entryColWidth)\n    setTimelineColumnWidth(defaultUISettings.entryColWidth)\n    window.dispatchEvent(new Event(\"resize\"))\n  }, [setTimelineColumnWidth])\n\n  const resetAiWidth = useCallback(() => {\n    const resetWidth = clampWidth(defaultUISettings.aiColWidth)\n    setUISetting(\"aiColWidth\", resetWidth)\n    setAiPanelWidth(resetWidth)\n    window.dispatchEvent(new Event(\"resize\"))\n  }, [clampWidth, setAiPanelWidth])\n\n  const splitter = shouldShowSplitter ? (\n    shouldRenderRightColumn && showEntryDetailsColumn ? (\n      <PanelSplitter\n        {...timelineSeparatorProps}\n        cursor={timelineSeparatorCursor}\n        isDragging={isTimelineDragging}\n        onDoubleClick={resetTimelineWidth}\n      />\n    ) : shouldShowFixedAI ? (\n      <PanelSplitter\n        {...aiSeparatorProps}\n        cursor={aiSeparatorCursor}\n        isDragging={isAiPanelDragging}\n        onDoubleClick={resetAiWidth}\n      />\n    ) : null\n  ) : null\n\n  // Mobile layout: stacked with view switching\n  if (isMobile) {\n    return <MobileTimelineLayout entryId={realEntryId} hasSelectedEntry={hasSelectedEntry} />\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative h-full min-w-0 grow\",\n        isAllView ? \"flex flex-col overflow-y-auto scroll-smooth\" : \"flex\",\n      )}\n    >\n      <div\n        className={cn(\n          \"relative h-full min-w-0\",\n          isAllView ? \"min-h-full w-full flex-none\" : \"flex-1\",\n        )}\n      >\n        <AppLayoutGridContainerProvider>\n          <div ref={layoutContainerRef} className=\"flex h-full min-w-0\">\n            <div\n              className={cn(\n                \"relative flex h-full flex-col overflow-hidden\",\n                shouldShowEntryBorder && \"border-r\",\n                showEntryDetailsColumn\n                  ? \"flex-none transition-[flex-basis] duration-200 ease-out will-change-[flex-basis]\"\n                  : \"min-w-0 flex-1\",\n                showEntryDetailsColumn && isTimelineDragging && \"transition-none\",\n              )}\n              style={entryColumnStyle}\n            >\n              <EntryColumn />\n\n              {showEntryContentOnLeft && (\n                <>\n                  <AnimatePresence>\n                    {realEntryId && (\n                      <m.div\n                        key=\"entry-header\"\n                        className=\"absolute inset-x-0 top-0 z-10\"\n                        initial={{ translateY: \"-50px\", opacity: 0 }}\n                        animate={{ translateY: 0, opacity: 1 }}\n                        exit={{ translateY: \"-50px\", opacity: 0 }}\n                        transition={Spring.smooth(0.3)}\n                      >\n                        <AIEntryHeader entryId={realEntryId} />\n                      </m.div>\n                    )}\n                  </AnimatePresence>\n\n                  <AnimatePresence>\n                    {realEntryId && (\n                      <div className=\"pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden\">\n                        <m.div\n                          key=\"entry-content\"\n                          lcpOptimization\n                          initial={{ translateY: \"50px\", opacity: 0, scale: 0.98 }}\n                          animate={{ translateY: 0, opacity: 1, scale: 1 }}\n                          exit={{ translateY: \"50px\", opacity: 0, scale: 0.98 }}\n                          transition={Spring.smooth(0.3)}\n                          className=\"pointer-events-auto relative flex h-0 flex-1 flex-col bg-theme-background\"\n                        >\n                          <EntryContent entryId={realEntryId} className=\"h-full\" />\n                        </m.div>\n                      </div>\n                    )}\n                  </AnimatePresence>\n                </>\n              )}\n            </div>\n\n            {shouldRenderRightColumn && (\n              <>\n                {splitter}\n\n                <div\n                  className={cn(\n                    \"relative flex h-full min-w-0 flex-col overflow-hidden bg-theme-background\",\n                    showEntryDetailsColumn ? \"flex-1\" : \"flex-none\",\n                  )}\n                  style={rightColumnStyle}\n                >\n                  {showEntryContentOnRight && realEntryId ? (\n                    <div className=\"flex h-full flex-col overflow-hidden\">\n                      <div className=\"absolute inset-x-0 top-0 z-10\">\n                        <AIEntryHeader entryId={realEntryId} />\n                      </div>\n                      <div className=\"flex h-0 flex-1 flex-col overflow-hidden\">\n                        <EntryContent entryId={realEntryId} className=\"h-full\" />\n                      </div>\n                    </div>\n                  ) : shouldShowFixedAI ? (\n                    <div className=\"flex h-full flex-1 items-center justify-center\">\n                      <AIChatFixedPanel\n                        key=\"ai-chat-layout\"\n                        style={\n                          {\n                            width: showEntryDetailsColumn ? \"100%\" : aiPanelWidth,\n                            \"--ai-chat-layout-width\": showEntryDetailsColumn\n                              ? \"100%\"\n                              : `${aiPanelWidth}px`,\n                          } as CSSProperties\n                        }\n                      />\n                    </div>\n                  ) : (\n                    <div className=\"flex flex-1 items-center justify-center px-8\">\n                      <EntryContentPlaceholder />\n                    </div>\n                  )}\n                </div>\n              </>\n            )}\n          </div>\n        </AppLayoutGridContainerProvider>\n      </div>\n      {!shouldShowFixedAI && <AIIndicator />}\n    </div>\n  )\n}\n\nexport const AIEnhancedTimelineLayout = memo(function AIEnhancedTimelineLayout() {\n  return (\n    <AIChatRoot wrapFocusable={false}>\n      <AIEnhancedTimelineLayoutImpl />\n      <MainViewHotkeysProvider />\n    </AIChatRoot>\n  )\n})\nAIEnhancedTimelineLayout.displayName = \"AIEnhancedTimelineLayout\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/ai-enhanced-timeline/MobileTimelineLayout.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { AnimatePresence } from \"motion/react\"\nimport { memo, useEffect, useState } from \"react\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useShowEntryDetailsColumn } from \"~/hooks/biz/useShowEntryDetailsColumn\"\nimport { EntryColumn } from \"~/modules/entry-column\"\nimport { EntryContent } from \"~/modules/entry-content/components/entry-content\"\nimport { AIEntryHeader } from \"~/modules/entry-content/components/entry-header\"\nimport { AppLayoutGridContainerProvider } from \"~/providers/app-grid-layout-container-provider\"\n\ntype MobileView = \"list\" | \"entry\"\n\ninterface MobileTimelineLayoutProps {\n  entryId: string | undefined\n  hasSelectedEntry: boolean\n}\n\nexport const MobileTimelineLayout = memo(function MobileTimelineLayout({\n  entryId,\n  hasSelectedEntry,\n}: MobileTimelineLayoutProps) {\n  const [mobileView, setMobileView] = useState<MobileView>(\"list\")\n  const navigate = useNavigateEntry()\n  const { view } = useRouteParamsSelector((state) => ({\n    view: state.view,\n  }))\n  const showEntryDetailsColumn = useShowEntryDetailsColumn()\n  // Auto-switch to entry view when entry is selected\n  useEffect(() => {\n    if (hasSelectedEntry && mobileView === \"list\") {\n      setMobileView(\"entry\")\n    } else if (!hasSelectedEntry && mobileView === \"entry\") {\n      setMobileView(\"list\")\n    }\n  }, [hasSelectedEntry, mobileView])\n\n  return (\n    <div className=\"relative flex size-full flex-col overflow-hidden\">\n      <AppLayoutGridContainerProvider>\n        <div className=\"relative flex size-full flex-col\">\n          {/* List View */}\n          <AnimatePresence mode=\"wait\">\n            {mobileView === \"list\" && (\n              <m.div\n                key=\"mobile-list\"\n                initial={{ opacity: 0, x: -20 }}\n                animate={{ opacity: 1, x: 0 }}\n                exit={{ opacity: 0, x: -20 }}\n                transition={Spring.smooth(0.2)}\n                className=\"absolute inset-0 flex size-full flex-col overflow-hidden\"\n              >\n                <EntryColumn />\n              </m.div>\n            )}\n\n            {/* Entry View */}\n            {mobileView === \"entry\" && entryId && (\n              <m.div\n                key=\"mobile-entry\"\n                initial={{ opacity: 0, x: 20 }}\n                animate={{ opacity: 1, x: 0 }}\n                exit={{ opacity: 0, x: 20 }}\n                transition={Spring.smooth(0.2)}\n                className=\"absolute inset-0 flex size-full flex-col overflow-hidden bg-theme-background\"\n              >\n                <div className=\"flex-shrink-0 bg-background\">\n                  <div className=\"flex items-center\">\n                    {/* Mobile back button - only show when EntryHeaderBreadcrumb doesn't show close button */}\n                    {showEntryDetailsColumn && (\n                      <button\n                        type=\"button\"\n                        className=\"no-drag-region mx-2 inline-flex shrink-0 items-center rounded-full bg-transparent p-2 text-text-secondary hover:bg-fill/50 hover:text-text focus-visible:bg-fill/60\"\n                        onClick={() => navigate({ entryId: null, view })}\n                      >\n                        <i className=\"i-mingcute-close-line size-5\" />\n                      </button>\n                    )}\n                    <div className=\"min-w-0 flex-1 overflow-hidden\">\n                      <AIEntryHeader entryId={entryId} />\n                    </div>\n                  </div>\n                </div>\n                <div className=\"flex-1 overflow-hidden\">\n                  <EntryContent entryId={entryId} className=\"h-full\" />\n                </div>\n              </m.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </AppLayoutGridContainerProvider>\n    </div>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/ai-enhanced-timeline/index.tsx",
    "content": "export { AIEnhancedTimelineLayout } from \"./AIEnhancedTimelineLayout\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryContentPlaceholder.tsx",
    "content": "import { AnimatePresence, LayoutGroup, m } from \"motion/react\"\n\nimport { EntryPlaceholderLogo } from \"~/modules/entry-content/components/EntryPlaceholderLogo\"\n\nexport const EntryContentPlaceholder = () => {\n  return (\n    <LayoutGroup>\n      <AnimatePresence>\n        <m.div\n          className=\"center size-full flex-col\"\n          initial={{ opacity: 0.01, y: 300 }}\n          animate={{ opacity: 1, y: 0 }}\n        >\n          <EntryPlaceholderLogo />\n        </m.div>\n      </AnimatePresence>\n    </LayoutGroup>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx",
    "content": "import type { DragEndEvent } from \"@dnd-kit/core\"\nimport { DndContext, PointerSensor, pointerWithin, useSensor, useSensors } from \"@dnd-kit/core\"\nimport { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { PanelSplitter } from \"@follow/components/ui/divider/PanelSplitter.js\"\nimport { Kbd } from \"@follow/components/ui/kbd/Kbd.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { defaultUISettings } from \"@follow/shared/settings/defaults\"\nimport { cn } from \"@follow/utils\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { debounce } from \"es-toolkit/compat\"\nimport type { PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { Trans } from \"react-i18next\"\nimport { useResizable } from \"react-resizable-layout\"\n\nimport { getUISettings, setUISetting } from \"~/atoms/settings/ui\"\nimport {\n  getSubscriptionColumnTempShow,\n  setSubscriptionColumnTempShow,\n  useSubscriptionColumnShow,\n  useSubscriptionColumnTempShow,\n} from \"~/atoms/sidebar\"\nimport { FloatingLayerScope } from \"~/constants\"\nimport { useBatchUpdateSubscription } from \"~/hooks/biz/useSubscriptionActions\"\nimport { useI18n } from \"~/hooks/common\"\nimport { NetworkStatusIndicator } from \"~/modules/app/NetworkStatusIndicator\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandBinding } from \"~/modules/command/hooks/use-command-binding\"\nimport { CornerPlayer } from \"~/modules/player/corner-player\"\nimport { SubscriptionColumn } from \"~/modules/subscription-column\"\nimport { getSelectedFeedIds, resetSelectedFeedIds } from \"~/modules/subscription-column/atom\"\nimport { UpdateNotice } from \"~/modules/update-notice/UpdateNotice\"\nimport { AppLayoutGridContainerProvider } from \"~/providers/app-grid-layout-container-provider\"\n\nexport const SubscriptionColumnContainer = () => {\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    }),\n  )\n\n  const { mutate } = useBatchUpdateSubscription()\n  const handleDragEnd = React.useCallback(\n    (event: DragEndEvent) => {\n      if (!event.over) {\n        return\n      }\n\n      const { category, view } = event.over.data.current as {\n        category?: string | null\n        view: FeedViewType\n      }\n\n      mutate({ category, view, feedIdList: getSelectedFeedIds() })\n\n      resetSelectedFeedIds()\n    },\n    [mutate],\n  )\n\n  return (\n    <AppLayoutGridContainerProvider>\n      <FeedResponsiveResizerContainer>\n        <DndContext\n          autoScroll={{ threshold: { x: 0, y: 0.2 } }}\n          sensors={sensors}\n          collisionDetection={pointerWithin}\n          onDragEnd={handleDragEnd}\n        >\n          <SubscriptionColumn>\n            <CornerPlayer />\n\n            <UpdateNotice />\n\n            <NetworkStatusIndicator />\n          </SubscriptionColumn>\n        </DndContext>\n      </FeedResponsiveResizerContainer>\n    </AppLayoutGridContainerProvider>\n  )\n}\n\nconst FeedResponsiveResizerContainer = ({\n  children,\n}: {\n  children: React.ReactNode\n} & PropsWithChildren) => {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const { isDragging, position, separatorProps, separatorCursor, setPosition } = useResizable({\n    axis: \"x\",\n    min: 256,\n    max: 300,\n    initial: React.useMemo(() => getUISettings().feedColWidth, []),\n    containerRef: containerRef as React.RefObject<HTMLElement>,\n\n    onResizeEnd({ position }) {\n      setUISetting(\"feedColWidth\", position)\n    },\n  })\n\n  const feedColumnShow = useSubscriptionColumnShow()\n  const feedColumnTempShow = useSubscriptionColumnTempShow()\n  const t = useI18n()\n\n  useEffect(() => {\n    if (feedColumnShow) {\n      setSubscriptionColumnTempShow(false)\n      return\n    }\n    const handler = debounce(\n      (e: MouseEvent) => {\n        const mouseX = e.clientX\n        const mouseY = e.clientY\n\n        const uiSettings = getUISettings()\n        const feedColumnTempShow = getSubscriptionColumnTempShow()\n        const isInEntryContentWideMode = false\n        const feedWidth = uiSettings.feedColWidth\n        if (mouseY < 200 && isInEntryContentWideMode && mouseX < feedWidth) return\n        const threshold = feedColumnTempShow ? uiSettings.feedColWidth : 100\n\n        if (mouseX < threshold) {\n          setSubscriptionColumnTempShow(true)\n        } else {\n          setSubscriptionColumnTempShow(false)\n        }\n      },\n      36,\n      {\n        leading: true,\n      },\n    )\n\n    document.addEventListener(\"mousemove\", handler)\n    return () => {\n      document.removeEventListener(\"mousemove\", handler)\n    }\n  }, [feedColumnShow])\n\n  const when = useGlobalFocusableScopeSelector(\n    // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback\n    React.useCallback((activeScope) => !activeScope.or(...FloatingLayerScope), []),\n  )\n\n  useCommandBinding({\n    commandId: COMMAND_ID.layout.toggleSubscriptionColumn,\n    when,\n  })\n\n  const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow)\n\n  useEffect(() => {\n    let timer: any\n    if (feedColumnShow) {\n      timer = setTimeout(() => {\n        setDelayShowSplitter(true)\n      }, 200)\n    } else {\n      setDelayShowSplitter(false)\n    }\n\n    return () => {\n      timer = clearTimeout(timer)\n    }\n  }, [feedColumnShow])\n\n  return (\n    <>\n      <div\n        data-hide-in-print\n        className={cn(\n          \"shrink-0 overflow-hidden\",\n          \"absolute inset-y-0 z-[2]\",\n          feedColumnTempShow && !feedColumnShow && \"shadow-drawer-to-right z-[12]\",\n          !feedColumnShow && !feedColumnTempShow ? \"-translate-x-full\" : \"\",\n          !isDragging ? \"duration-200\" : \"\",\n        )}\n        style={{\n          width: `${position}px`,\n          // @ts-expect-error\n          \"--fo-feed-col-w\": `${position}px`,\n        }}\n      >\n        <Slot className={!feedColumnShow ? \"!bg-sidebar\" : \"\"}>{children}</Slot>\n      </div>\n\n      <div\n        data-hide-in-print\n        className={!isDragging ? \"duration-200\" : \"\"}\n        style={{\n          width: feedColumnShow ? `${position}px` : 0,\n        }}\n      />\n\n      {delayShowSplitter && (\n        <PanelSplitter\n          isDragging={isDragging}\n          cursor={separatorCursor}\n          {...separatorProps}\n          onDoubleClick={() => {\n            setUISetting(\"feedColWidth\", defaultUISettings.feedColWidth)\n            setPosition(defaultUISettings.feedColWidth)\n          }}\n          tooltip={\n            !isDragging && (\n              <>\n                <div>\n                  {/* <b>Drag</b> to resize */}\n                  <Trans t={t} i18nKey=\"resize.tooltip.drag_to_resize\" components={{ b: <b /> }} />\n                </div>\n                <div className=\"center\">\n                  <span>\n                    <Trans\n                      t={t}\n                      i18nKey=\"resize.tooltip.double_click_to_collapse\"\n                      components={{ b: <b /> }}\n                    />\n                  </span>{\" \"}\n                  <Kbd className=\"ml-1\">{\"[\"}</Kbd>\n                </div>\n              </>\n            )\n          }\n        />\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/components/PodcastButton.tsx",
    "content": "import { PresentSheet } from \"@follow/components/ui/sheet/Sheet.js\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useState } from \"react\"\nimport Marquee from \"react-fast-marquee\"\n\nimport { AudioPlayer, useAudioPlayerAtomSelector } from \"~/atoms/player\"\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { PlayerProgress } from \"~/modules/player/corner-player\"\n\nconst handleClickPlay = () => {\n  AudioPlayer.togglePlayAndPause()\n}\n\nexport const PodcastButton = ({ feed }: { feed: FeedModel }) => {\n  const entryId = useAudioPlayerAtomSelector((v) => v.entryId)\n  const status = useAudioPlayerAtomSelector((v) => v.status)\n  const isMute = useAudioPlayerAtomSelector((v) => v.isMute)\n  const playerValue = { entryId, status, isMute }\n\n  const entry = useEntry(playerValue.entryId, (state) => {\n    const { authorAvatar, publishedAt, title } = state\n\n    const media = state.media || []\n    const firstPhotoUrl = media.find((a) => a.type === \"photo\")?.url\n    const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar }\n\n    return { iconEntry, title, publishedAt }\n  })\n\n  if (!entry || !feed) return null\n\n  return (\n    <PresentSheet\n      zIndex={99}\n      content={\n        <>\n          <div className=\"mb-6 flex gap-4\">\n            <FeedIcon target={feed} entry={entry.iconEntry} size={58} fallback={false} noMargin />\n            <div className=\"flex flex-col justify-center\">\n              <Marquee\n                play={playerValue.status === \"playing\"}\n                className=\"mask-horizontal font-medium\"\n                speed={30}\n              >\n                {entry.title}\n              </Marquee>\n              <div className=\"mt-0.5 overflow-hidden truncate text-xs text-text\">\n                <span>{feed.title}</span>\n                <span> · </span>\n                <span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>\n              </div>\n            </div>\n          </div>\n\n          <PlayerProgress />\n\n          <div className=\"mt-2 flex items-center justify-center gap-2\">\n            <div className=\"w-10\">\n              <PlaybackRateButton />\n            </div>\n            <div className=\"flex flex-1 justify-center gap-4\">\n              <ActionIcon className=\"i-mgc-back-2-cute-re\" onClick={() => AudioPlayer.back(10)} />\n\n              <ActionIcon\n                className={cn(\"size-6\", {\n                  \"i-mgc-pause-cute-fi\": playerValue.status === \"playing\",\n                  \"i-mgc-loading-3-cute-re animate-spin\": playerValue.status === \"loading\",\n                  \"i-mgc-play-cute-fi\": playerValue.status === \"paused\",\n                })}\n                onClick={handleClickPlay}\n              />\n\n              <ActionIcon\n                className=\"i-mgc-forward-2-cute-re\"\n                onClick={() => AudioPlayer.forward(10)}\n              />\n            </div>\n            <div className=\"w-10\">\n              <ActionIcon\n                className=\"i-mgc-close-cute-re\"\n                onClick={() => {\n                  AudioPlayer.close()\n                }}\n              />\n            </div>\n          </div>\n        </>\n      }\n    >\n      <div className=\"flex size-5 items-center justify-center\">\n        <FeedIcon target={feed} size={22} noMargin />\n      </div>\n    </PresentSheet>\n  )\n}\n\nconst ActionIcon = ({ className, onClick }: { className?: string; onClick?: () => void }) => (\n  <button type=\"button\" className=\"center size-10 rounded-full text-text\" onClick={onClick}>\n    <i className={className} />\n  </button>\n)\n\nconst PlaybackRateButton = () => {\n  const playbackRate = useAudioPlayerAtomSelector((v) => v.playbackRate)\n  const rates = [0.5, 0.75, 1, 1.25, 1.5, 2]\n  const [currentIndex, setCurrentIndex] = useState(playbackRate ? rates.indexOf(playbackRate) : 2)\n\n  const handleClick = () => {\n    const nextIndex = (currentIndex + 1) % rates.length\n    setCurrentIndex(nextIndex)\n    AudioPlayer.setPlaybackRate(rates[nextIndex]!)\n  }\n\n  return (\n    <button onClick={handleClick} type=\"button\">\n      <span className=\"block font-mono text-xs text-text\">{rates[currentIndex]!.toFixed(2)}x</span>\n    </button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/index.tsx",
    "content": "export { MainDestopLayout } from \"../MainDestopLayout\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/subview/SubviewLayout.tsx",
    "content": "import { getReadonlyRoute } from \"@follow/components/atoms/route.js\"\nimport { useGlobalFocusableHasScope } from \"@follow/components/common/Focusable/hooks.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { LinearBlur } from \"@follow/components/ui/progressive-blur/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { Routes } from \"@follow/constants\"\nimport { ELECTRON_BUILD } from \"@follow/shared/constants\"\nimport { springScrollTo } from \"@follow/utils/scroller\"\nimport { clsx, cn, getOS } from \"@follow/utils/utils\"\nimport { m } from \"framer-motion\"\nimport { isValidElement, useCallback, useEffect, useRef, useState } from \"react\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\nimport { useTranslation } from \"react-i18next\"\nimport { NavigationType, Outlet, useLocation, useNavigate, useNavigationType } from \"react-router\"\nimport { parseQuery } from \"ufo\"\n\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\nimport { HeaderActionButton, HeaderActionGroup } from \"~/components/ui/button/HeaderActionButton\"\nimport { HotkeyScope } from \"~/constants\"\n\nimport { useSubViewRightView, useSubViewTitleValue } from \"./hooks\"\n\n/**\n * SubviewLayout Component\n *\n * A full-screen modal-style layout for subview pages like Discover, AI, etc.\n * This layout provides:\n * - Fullscreen overlay with enhanced header controls\n * - Smooth scroll behavior with progress indicators\n * - Progressive mask blur effects\n * - Back navigation with ESC key support\n * - Dynamic title display based on scroll position\n * - Configurable right-side action buttons\n *\n * Layout Structure:\n * ```\n * SubviewLayout\n * ├── Fixed Header (progressive mask blur)\n * │   ├── Back Button (left)\n * │   ├── Title (center, fade in on scroll)\n * │   └── Action Buttons (right, configurable)\n * ├── Scrollable Content Area\n * │   └── Outlet (renders subview pages)\n * └── Progress FAB (bottom-right, scroll to top)\n * ```\n *\n * @component\n * @example\n * // Used for routes like /discover, /power, /action, /rsshub\n * // Provides full-screen modal-like experience\n */\nexport function SubviewLayout() {\n  return (\n    <Focusable className=\"contents\" scope={HotkeyScope.SubLayer}>\n      <SubviewLayoutInner />\n    </Focusable>\n  )\n}\n\n/**\n * SubviewLayoutInner Component\n *\n * The inner implementation of SubviewLayout that handles:\n * - Scroll state management and progress tracking\n * - Header elevation and transparency effects\n * - Navigation history and ESC key handling\n * - Dynamic title visibility based on scroll position\n * - Smooth scroll animations and auto-scroll behavior\n *\n * @component\n * @internal\n */\nfunction SubviewLayoutInner() {\n  const navigate = useNavigate()\n  const prevLocation = useRef(getReadonlyRoute().location).current\n  const title = useSubViewTitleValue()\n  const [scrollRef, setRef] = useState(null as HTMLDivElement | null)\n  const [scrollY, setScrollY] = useState(0)\n  const navigationType = useNavigationType()\n  const location = useLocation()\n  const [maxScroll, setMaxScroll] = useState(0)\n\n  // Enhanced scroll state management\n  const isTitleVisible = scrollY > 60\n  const isHeaderElevated = scrollY > 20\n\n  const updateMaxScroll = useCallback(() => {\n    if (!scrollRef) return\n\n    const { scrollHeight, clientHeight } = scrollRef\n    setMaxScroll(Math.max(0, scrollHeight - clientHeight))\n  }, [scrollRef])\n\n  useEffect(() => {\n    if (!scrollRef) return\n\n    updateMaxScroll()\n    const resizeObserver = new ResizeObserver(updateMaxScroll)\n    resizeObserver.observe(scrollRef)\n\n    return () => resizeObserver.disconnect()\n  }, [scrollRef, updateMaxScroll])\n\n  const discoverType = parseQuery(location.search).type\n\n  useEffect(() => {\n    // Scroll to top search bar when re-navigating to Discover page while already on it\n    if (\n      navigationType === NavigationType.Replace &&\n      location.pathname === Routes.Discover &&\n      scrollRef\n    ) {\n      if (scrollRef.scrollTop === 0) return\n      springScrollTo(0, scrollRef)\n    }\n\n    // Scroll to top when navigating to Recommendation page from Discover page\n    if (\n      navigationType === NavigationType.Push &&\n      location.pathname.startsWith(Routes.Discover) &&\n      scrollRef\n    ) {\n      springScrollTo(0, scrollRef)\n    }\n  }, [location.pathname, discoverType, scrollRef, navigationType])\n\n  useEffect(() => {\n    const $scroll = scrollRef\n\n    if (!$scroll) return\n\n    springScrollTo(0, $scroll)\n    const handler = () => {\n      setScrollY($scroll.scrollTop)\n    }\n    $scroll.addEventListener(\"scroll\", handler, { passive: true })\n    return () => {\n      $scroll.removeEventListener(\"scroll\", handler)\n    }\n  }, [scrollRef])\n\n  const { t } = useTranslation()\n\n  // electron window has pt-[calc(var(--fo-window-padding-top)_-10px)]\n  const isElectronWindows = ELECTRON_BUILD && getOS() === \"Windows\"\n\n  const backHandler = () => {\n    if (prevLocation.pathname === location.pathname) {\n      navigate({ pathname: \"\" })\n    } else {\n      navigate(-1)\n    }\n  }\n\n  useHotkeys(\"Escape\", backHandler, {\n    enabled: useGlobalFocusableHasScope(HotkeyScope.SubLayer),\n  })\n\n  return (\n    <div className=\"relative flex size-full\">\n      {/* Enhanced Header with smooth transitions */}\n      <div\n        className={cn(\n          \"absolute inset-x-0 top-0 z-10 overflow-hidden transition-all duration-300 ease-out\",\n          isHeaderElevated && isElectronWindows && \"-top-5\",\n        )}\n      >\n        <m.div className={cn(\"flex items-center gap-3 p-4\", \"relative\")}>\n          <LinearBlur className=\"absolute inset-0 z-[-1]\" tint=\"var(--fo-background)\" side=\"top\" />\n          {/* Left: Back button (circular, glass) */}\n          <GlassButton\n            testId=\"subview-back\"\n            description={t(\"words.back\", { ns: \"common\" })}\n            onClick={backHandler}\n            className={cn(\n              \"no-drag-region shrink-0\",\n              isHeaderElevated ? \"opacity-100\" : \"opacity-80\",\n            )}\n            size=\"md\"\n          >\n            <i className=\"i-mingcute-left-line\" />\n          </GlassButton>\n\n          {/* Center: Content area block (rounded, glass) */}\n          <div className=\"pointer-events-none flex min-h-10 flex-1 items-center justify-center\">\n            {title ? (\n              <div\n                className={clsx(\n                  \"pointer-events-auto inline-flex max-w-[60%] items-center justify-center\",\n                  \"px-8 py-2 text-center duration-200\",\n                  isTitleVisible ? \"opacity-100\" : \"opacity-0\",\n                )}\n              >\n                <div className=\"truncate font-semibold text-text\">{title}</div>\n              </div>\n            ) : null}\n          </div>\n\n          {/* Right: Button group block (rounded, glass) */}\n\n          <SubViewHeaderRightView isHeaderElevated={isHeaderElevated} />\n        </m.div>\n      </div>\n\n      {/* Content Area */}\n      <ScrollArea.ScrollArea\n        mask={false}\n        flex\n        ref={setRef}\n        rootClassName=\"w-full\"\n        viewportClassName=\"pb-12 pt-24 [&>div]:items-center\"\n        onUpdateMaxScroll={updateMaxScroll}\n      >\n        <Outlet />\n      </ScrollArea.ScrollArea>\n\n      <RootPortal>\n        <ScrollProgressFAB scrollY={scrollY} scrollRef={scrollRef} maxScroll={maxScroll} />\n      </RootPortal>\n    </div>\n  )\n}\n\nconst SubViewHeaderRightView = ({ isHeaderElevated }: { isHeaderElevated: boolean }) => {\n  const rightView = useSubViewRightView()\n\n  if (!rightView) return null\n\n  if (isValidElement(rightView) && (rightView as any).type === HeaderActionGroup) {\n    const groupChildren = (rightView as any).props?.children\n    const childrenArray = Array.isArray(groupChildren) ? groupChildren : [groupChildren]\n\n    const items = childrenArray\n      .map((child: any) => {\n        if (isValidElement(child) && (child as any).type === HeaderActionButton) {\n          const { onClick, disabled, loading, icon, children: label } = (child as any).props\n          const key = (child as any).key ?? icon ?? (typeof label === \"string\" ? label : undefined)\n\n          return (\n            <GlassButton\n              key={key}\n              description={typeof label === \"string\" ? label : undefined}\n              onClick={() => {\n                if (!disabled && !loading) onClick?.()\n              }}\n              className={cn(disabled || loading ? \"cursor-not-allowed opacity-50\" : \"\")}\n              size=\"md\"\n              theme=\"auto\"\n            >\n              <i className={cn(icon || (loading ? \"i-mgc-loading-3-cute-re animate-spin\" : \"\"))} />\n            </GlassButton>\n          )\n        }\n        return null\n      })\n      .filter(Boolean)\n\n    return (\n      <div\n        className={cn(\n          \"-mt-2 inline-flex items-center gap-1.5 rounded-full bg-fill p-2 backdrop-blur-background duration-200\",\n          \"has-[:nth-child(1)]:bg-transparent\",\n          !isHeaderElevated && items.length > 1 ? \"bg-material-ultra-thin\" : \"bg-material-medium\",\n        )}\n      >\n        {items}\n      </div>\n    )\n  }\n\n  return (\n    <div\n      className={cn(\n        \"ml-auto inline-flex shrink-0 items-center gap-1.5 rounded-xl border p-1.5\",\n        \"opacity-0 duration-200\",\n        isHeaderElevated\n          ? \"border-border/50 bg-material-ultra-thin opacity-100 shadow-sm backdrop-blur-xl\"\n          : \"border-transparent bg-material-medium\",\n      )}\n    >\n      <div className=\"inline-flex items-center\">{rightView}</div>\n    </div>\n  )\n}\n\nconst ScrollProgressFAB = ({\n  scrollY,\n  scrollRef,\n  maxScroll,\n}: {\n  scrollY: number\n  scrollRef: any\n  maxScroll: number\n}) => {\n  const progress = maxScroll > 0 ? Math.min(100, (scrollY / maxScroll) * 100) : 0\n  const showProgress = scrollY > 100 && maxScroll > 100\n\n  return (\n    <div\n      className={cn(\n        \"group/fab fixed bottom-6 right-6 z-50 duration-200\",\n        showProgress && \"visible opacity-100\",\n        !showProgress && \"invisible opacity-0\",\n      )}\n    >\n      <div className=\"relative\">\n        <svg className=\"size-12 -rotate-90\" viewBox=\"0 0 40 40\">\n          <circle\n            cx=\"20\"\n            cy=\"20\"\n            r=\"16\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            fill=\"none\"\n            className=\"text-border/30\"\n          />\n          <circle\n            cx=\"20\"\n            cy=\"20\"\n            r=\"16\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            fill=\"none\"\n            strokeDasharray={`${progress} 100`}\n            className=\"text-accent\"\n          />\n        </svg>\n        <div className=\"absolute inset-0 flex items-center justify-center opacity-100 transition-opacity duration-200 group-hover/fab:opacity-0\">\n          <span className=\"text-xs font-medium text-text-secondary\">{Math.round(progress)}</span>\n        </div>\n        <button\n          onClick={() => {\n            springScrollTo(0, scrollRef)\n          }}\n          type=\"button\"\n          className=\"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover/fab:opacity-100\"\n        >\n          <i className=\"i-mingcute-arrow-to-up-line\" />\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/subview/hooks.ts",
    "content": "import { useTitle } from \"@follow/hooks\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport type { ReactNode } from \"react\"\nimport { useEffect } from \"react\"\n\nimport { useI18n } from \"~/hooks/common\"\n\nconst titleAtom = atom<string | ReactNode | null>(null)\n\nconst rightViewAtom = atom<ReactNode | null>(null)\n\nexport function useSubViewTitle(title: I18nKeys): void\nexport function useSubViewTitle(title: ReactNode, fallbackTitleString: string): void\n\nexport function useSubViewTitle(title: I18nKeys | ReactNode, fallbackTitleString?: string) {\n  const t = useI18n()\n  useTitle(typeof title === \"string\" ? t(title as I18nKeys) : fallbackTitleString)\n\n  const setTitle = useSetAtom(titleAtom)\n  useEffect(() => {\n    setTitle(typeof title === \"string\" ? t(title as I18nKeys) : title)\n  }, [setTitle, t, title])\n}\n\nexport const useSubViewTitleValue = () => useAtomValue(titleAtom)\n\nexport const useSubViewRightView = () => useAtomValue(rightViewAtom)\n\nexport const useSetSubViewRightView = () => useSetAtom(rightViewAtom)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-layout/subview/index.tsx",
    "content": "export { SubviewLayout } from \"./SubviewLayout\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/AICopilotMedia.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Folo } from \"@follow/components/icons/folo.js\"\nimport { getEditorStateJSONString } from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { AISpline } from \"~/modules/ai-chat/components/3d-models/AISpline\"\nimport { AIMarkdownStreamingMessage } from \"~/modules/ai-chat/components/message/AIMarkdownMessage\"\nimport { UserMessageParts } from \"~/modules/ai-chat/components/message/UserMessageParts\"\nimport type { BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\ntype PreviewMessage = {\n  id: string\n  role: \"user\" | \"assistant\"\n  text: string\n}\n\nconst buildUserBizMessage = (id: string, text: string): BizUIMessage => {\n  return {\n    id,\n    role: \"user\",\n    parts: [\n      {\n        type: \"data-rich-text\",\n        data: {\n          state: getEditorStateJSONString(text),\n          text,\n        },\n      },\n    ],\n    createdAt: new Date(),\n  }\n}\n\nconst chatScript: PreviewMessage[] = [\n  {\n    id: \"m1\",\n    role: \"user\",\n    text: \"Help me follow the latest AI trends for frontend.\",\n  },\n  {\n    id: \"m2\",\n    role: \"assistant\",\n    text:\n      \"Sure — here are key updates this week:\\n\\n\" +\n      \"- React 19 RC brings Actions and built-in async APIs.\\n\" +\n      \"- Vite 6 improves SSR and dev server performance.\\n\" +\n      \"- AI tooling: better model routers and eval kits.\\n\",\n  },\n  {\n    id: \"m3\",\n    role: \"user\",\n    text: \"Great. Recommend some feeds to follow?\",\n  },\n  {\n    id: \"m4\",\n    role: \"assistant\",\n    text:\n      \"Absolutely — I’ve picked a few high-signal sources for you. \" +\n      \"You can follow them in one click below.\",\n  },\n]\n\nexport const AICopilotMedia: React.FC = () => {\n  const [visibleCount, setVisibleCount] = React.useState(0)\n  const [showRecommendations, setShowRecommendations] = React.useState(false)\n\n  React.useEffect(() => {\n    let disposed = false\n    const stepDelays = [0, 900, 1600, 2400] // stagger message reveals\n    const timers: number[] = []\n\n    const start = () => {\n      setVisibleCount(0)\n      setShowRecommendations(false)\n      for (let i = 0; i < chatScript.length; i++) {\n        timers.push(\n          window.setTimeout(() => {\n            if (disposed) return\n            setVisibleCount((v) => Math.min(v + 1, chatScript.length))\n          }, stepDelays[i]),\n        )\n      }\n      // Show recommendations after last message\n      const lastMessageDelay = stepDelays[chatScript.length - 1] || 2400\n      timers.push(\n        window.setTimeout(() => {\n          if (disposed) return\n          setShowRecommendations(true)\n        }, lastMessageDelay + 800),\n      )\n    }\n\n    start()\n    return () => {\n      disposed = true\n      for (const t of timers) {\n        clearTimeout(t)\n      }\n    }\n  }, [])\n\n  const messagesToRender = chatScript.slice(0, visibleCount)\n  const lastVisible = messagesToRender.at(-1)\n\n  return (\n    <div className=\"flex size-full flex-col gap-4 p-6\">\n      <Header />\n\n      <ScrollArea\n        flex\n        rootClassName=\"-mx-6 -mb-6 flex min-h-0 flex-1\"\n        viewportClassName=\"flex-col gap-4 pb-6 px-6\"\n      >\n        <ChatPreview\n          messages={messagesToRender}\n          streamingId={lastVisible?.id}\n          showRecommendations={showRecommendations}\n        />\n      </ScrollArea>\n    </div>\n  )\n}\n\nconst Header: React.FC = () => {\n  return (\n    <m.div\n      initial={{ opacity: 0, y: -20 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={Spring.smooth(0.4)}\n      className=\"relative\"\n    >\n      <div className=\"relative flex items-center justify-between\">\n        <div className=\"flex items-center gap-3\">\n          {/* AI Icon with glow effect */}\n          <m.div\n            className=\"relative\"\n            initial={{ scale: 0.9, opacity: 0 }}\n            animate={{ scale: 1, opacity: 1 }}\n            transition={Spring.bouncy(0.5, 0.1)}\n          >\n            <AISpline className=\"size-8\" />\n          </m.div>\n\n          {/* Text content */}\n          <m.div\n            initial={{ opacity: 0, x: -10 }}\n            animate={{ opacity: 1, x: 0 }}\n            transition={Spring.snappy(0.4)}\n            className=\"flex flex-col\"\n          >\n            <div className=\"flex items-center gap-2\">\n              <Folo className=\"size-7\" />\n              <span className=\"text-sm font-semibold text-text\">AI</span>\n            </div>\n            <span className=\"text-xs text-text-secondary\">\n              Summarize, search, and curate for you\n            </span>\n          </m.div>\n        </div>\n      </div>\n    </m.div>\n  )\n}\n\nconst ChatPreview: React.FC<{\n  messages: PreviewMessage[]\n  streamingId?: string\n  showRecommendations?: boolean\n}> = ({ messages, streamingId, showRecommendations }) => {\n  const feeds = React.useMemo(\n    () => [\n      {\n        id: \"vercel-blog\",\n        type: \"feed\" as const,\n        title: \"Vercel Blog\",\n        url: \"https://vercel.com/blog\",\n        siteUrl: \"https://vercel.com\",\n        description: \"Frontend, AI, and infra updates.\",\n      },\n      {\n        id: \"react\",\n        type: \"feed\" as const,\n        title: \"React\",\n        url: \"https://react.dev/blog\",\n        siteUrl: \"https://react.dev\",\n        description: \"Official updates and releases.\",\n      },\n      {\n        id: \"ai-engineering\",\n        type: \"feed\" as const,\n        title: \"AI Engineering\",\n        url: \"https://aie.sh\",\n        siteUrl: \"https://aie.sh\",\n        description: \"Practical AI for builders.\",\n      },\n    ],\n    [],\n  )\n\n  return (\n    <div className=\"relative flex min-h-[240px] flex-1 flex-col overflow-hidden\">\n      <div className=\"relative flex-1\">\n        <div className=\"flex flex-col gap-4\">\n          {messages.map((message, index) => {\n            const isUser = message.role === \"user\"\n            const delay = index * 0.1\n\n            return (\n              <m.div\n                key={message.id}\n                initial={{ opacity: 0, y: 12, scale: 0.98 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                transition={Spring.snappy(0.5, 0.05)}\n                className={cn(\"flex\", isUser ? \"justify-end\" : \"justify-start\")}\n                style={{\n                  animationDelay: `${delay}s`,\n                }}\n              >\n                <div className={cn(\"flex gap-2\", isUser ? \"flex-row-reverse\" : \"flex-row\")}>\n                  {/* Message bubble */}\n                  <m.div\n                    initial={{ scale: 0.95, opacity: 0 }}\n                    animate={{ scale: 1, opacity: 1 }}\n                    transition={Spring.smooth(0.4)}\n                    className={cn(\n                      \"group relative max-w-[85%] overflow-hidden rounded-2xl\",\n                      isUser ? \"border-fill bg-fill/50\" : \"border-fill/30 shadow-sm\",\n                    )}\n                  >\n                    <div className=\"relative px-3.5 py-2.5\">\n                      {isUser ? (\n                        <UserMessageParts message={buildUserBizMessage(message.id, message.text)} />\n                      ) : (\n                        <>\n                          <AIMarkdownStreamingMessage\n                            text={message.text}\n                            isStreaming={message.id === streamingId}\n                            className=\"text-text\"\n                          />\n                        </>\n                      )}\n                    </div>\n                  </m.div>\n                </div>\n              </m.div>\n            )\n          })}\n\n          {/* Recommendations section - appears after conversation */}\n          {showRecommendations && (\n            <m.div\n              initial={{ opacity: 0, y: 20, scale: 0.95 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              transition={Spring.smooth(0.5)}\n              className=\"mt-2\"\n            >\n              {/* AI Avatar + Recommendation Card Container */}\n              <div className=\"flex gap-2\">\n                {/* Recommendation Cards */}\n                <div className=\"min-w-0 flex-1 overflow-hidden rounded-2xl border border-fill/30 shadow-sm\">\n                  {/* Header */}\n                  <div className=\"border-b border-fill/20 bg-gradient-to-r from-orange/5 via-pink/5 to-purple/5 px-3 py-2.5\">\n                    <m.div\n                      initial={{ opacity: 0, x: -10 }}\n                      animate={{ opacity: 1, x: 0 }}\n                      transition={Spring.snappy(0.4)}\n                      className=\"flex items-center gap-2\"\n                    >\n                      <m.div\n                        animate={{\n                          rotate: [0, 10, -10, 0],\n                          scale: [1, 1.1, 1],\n                        }}\n                        transition={{\n                          repeat: Infinity,\n                          duration: 3,\n                          ease: \"easeInOut\",\n                        }}\n                      >\n                        <i className=\"i-mingcute-sparkles-2-line size-4 text-orange\" />\n                      </m.div>\n                      <span className=\"text-xs font-semibold text-text\">Recommended Feeds</span>\n                    </m.div>\n                  </div>\n\n                  {/* Feed cards */}\n                  <div className=\"min-w-0 divide-y divide-fill/10 p-2\">\n                    {feeds.map((feed, index) => (\n                      <m.div\n                        key={feed.id}\n                        initial={{ opacity: 0, x: -20 }}\n                        animate={{ opacity: 1, x: 0 }}\n                        transition={Spring.snappy(0.4, 0.05)}\n                        style={{\n                          animationDelay: `${index * 0.15}s`,\n                        }}\n                        whileHover={{ scale: 1.005 }}\n                        className=\"group relative overflow-hidden rounded-lg p-2.5 transition-colors hover:bg-material-thin/50\"\n                      >\n                        {/* Icon with gradient background */}\n                        <div className=\"relative flex items-start gap-2.5\">\n                          <m.div\n                            whileHover={{ rotate: [0, -5, 5, 0] }}\n                            transition={{ duration: 0.5 }}\n                            className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-fill to-fill-secondary ring-1 ring-fill/50\"\n                          >\n                            <i className=\"i-mingcute-rss-line size-4 text-text\" />\n                          </m.div>\n\n                          <div className=\"min-w-0 flex-1\">\n                            {/* Feed title */}\n                            <h4 className=\"mb-0.5 truncate text-xs font-semibold text-text\">\n                              {feed.title}\n                            </h4>\n                            {/* Feed description */}\n                            <p className=\"mb-1 line-clamp-2 text-[11px] leading-snug text-text-secondary\">\n                              {feed.description}\n                            </p>\n                            {/* URL hint */}\n                            <div className=\"flex items-center gap-1 text-[10px] text-text-tertiary\">\n                              <i className=\"i-mgc-link-cute-re size-2.5\" />\n                              <span className=\"truncate\">{feed.siteUrl}</span>\n                            </div>\n                          </div>\n                        </div>\n                      </m.div>\n                    ))}\n                  </div>\n                </div>\n              </div>\n            </m.div>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/AppTipDialog.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\nimport { PlainWithAnimationModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { DeclarativeModal } from \"~/components/ui/modal/stacked/declarative-modal\"\n\nimport { AppTipMediaPreview } from \"./AppTipMediaPreview\"\nimport type { AppTipStep } from \"./types\"\n\ntype AppTipDialogProps = {\n  hasNextStep: boolean\n  steps: AppTipStep[]\n  activeStep: AppTipStep\n  activeStepIndex: number\n  onSelectStep: (index: number) => void\n  onDismiss: () => void\n  open: boolean\n}\n\nexport function AppTipDialog({\n  steps,\n  activeStep,\n  activeStepIndex,\n  onSelectStep,\n  onDismiss,\n  open,\n  hasNextStep,\n}: AppTipDialogProps) {\n  const { t } = useTranslation()\n\n  const handleNextStep = () => {\n    if (hasNextStep) {\n      onSelectStep(activeStepIndex + 1)\n    } else {\n      onDismiss()\n    }\n  }\n\n  return (\n    <DeclarativeModal\n      id=\"ai-onboarding\"\n      title={t(\"new_user_dialog.title\")}\n      CustomModalComponent={PlainWithAnimationModal}\n      modalContainerClassName=\"flex items-center justify-center\"\n      modalClassName=\"w-full max-w-5xl\"\n      open={open}\n      overlay={false}\n      canClose={false}\n      clickOutsideToDismiss={false}\n    >\n      <section className=\"shadow-modal relative grid min-h-[500px] overflow-hidden rounded-lg border border-border bg-background text-text lg:grid-cols-[1.2fr,1fr]\">\n        <div className=\"relative flex aspect-square items-center justify-center overflow-hidden bg-background\">\n          {activeStep.media?.reactNode ? (\n            <div className=\"absolute inset-0 aspect-square w-full overflow-hidden bg-material-medium\">\n              {activeStep.media?.reactNode}\n            </div>\n          ) : (\n            <AppTipMediaPreview media={activeStep.media} />\n          )}\n        </div>\n\n        <div className=\"relative flex w-[500px] flex-col border-t border-border bg-background lg:border-l lg:border-t-0\">\n          <GlassButton\n            onClick={onDismiss}\n            variant=\"flat\"\n            className=\"absolute right-4 top-4 z-10\"\n            aria-label={t(\"new_user_dialog.actions.close\")}\n          >\n            <i className=\"i-mgc-close-cute-re\" />\n          </GlassButton>\n          <div className=\"flex flex-1 flex-col gap-6 p-8\">\n            <div className=\"flex flex-col gap-3\">\n              <h2 className=\"text-2xl font-semibold leading-tight text-text\">{activeStep.title}</h2>\n              <p className=\"text-sm leading-relaxed text-text-secondary\">\n                {activeStep.description}\n              </p>\n            </div>\n\n            <ul className=\"space-y-2.5 text-sm text-text-secondary\">\n              {activeStep.highlights.map((point, idx) => (\n                <li key={`${activeStep.id}-${idx}`} className=\"flex items-start gap-2.5\">\n                  <span className=\"mt-2 flex size-1.5 rounded-full bg-text-tertiary\" />\n                  <span className=\"leading-relaxed\">{point}</span>\n                </li>\n              ))}\n            </ul>\n\n            {activeStep.extra}\n          </div>\n\n          <div className=\"border-t border-border p-4\">\n            <div className=\"flex items-center justify-between gap-3\">\n              <div className=\"flex items-center gap-2\">\n                {steps.map((step, idx) => (\n                  <button\n                    type=\"button\"\n                    key={step.id}\n                    onClick={() => onSelectStep(idx)}\n                    aria-label={step.title}\n                    aria-current={idx === activeStepIndex}\n                    className={cn(\n                      \"size-2 cursor-pointer rounded-full transition-colors\",\n                      idx === activeStepIndex\n                        ? \"bg-text\"\n                        : \"bg-fill-tertiary hover:bg-fill-secondary\",\n                    )}\n                  />\n                ))}\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                <Button\n                  size=\"sm\"\n                  variant={\"outline\"}\n                  buttonClassName=\"h-8\"\n                  onClick={activeStep.onPrimaryAction}\n                >\n                  {activeStep.primaryActionLabel}\n                </Button>\n\n                <Button size=\"sm\" buttonClassName=\"h-8\" onClick={handleNextStep}>\n                  {hasNextStep\n                    ? t(\"words.next\", { ns: \"common\" })\n                    : t(\"new_user_dialog.actions.finish\")}\n                </Button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n    </DeclarativeModal>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/AppTipMediaPreview.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { AppTipStepMedia } from \"./types\"\n\ntype AppTipMediaPreviewProps = {\n  media?: AppTipStepMedia\n}\n\nexport function AppTipMediaPreview({ media }: AppTipMediaPreviewProps) {\n  const [hasError, setHasError] = useState(false)\n  const [showReplay, setShowReplay] = useState(false)\n  const videoRef = useRef<HTMLVideoElement>(null)\n  const { t } = useTranslation()\n  const mediaKind = media?.kind ?? \"video\"\n  const isVideo = mediaKind === \"video\"\n\n  useEffect(() => {\n    setHasError(false)\n    setShowReplay(false)\n  }, [media?.src, mediaKind])\n\n  useEffect(() => {\n    if (!isVideo) return\n    const video = videoRef.current\n    if (!video) return\n\n    const handleEnded = () => setShowReplay(true)\n    const handlePlay = () => setShowReplay(false)\n\n    video.addEventListener(\"ended\", handleEnded)\n    video.addEventListener(\"play\", handlePlay)\n\n    return () => {\n      video.removeEventListener(\"ended\", handleEnded)\n      video.removeEventListener(\"play\", handlePlay)\n    }\n  }, [isVideo, media?.src])\n\n  const handleReplay = () => {\n    if (isVideo && videoRef.current) {\n      videoRef.current.currentTime = 0\n      videoRef.current.play()\n    }\n  }\n\n  if (!media?.src || hasError) {\n    const fallbackIcon = isVideo ? \"i-mgc-video-cute-re\" : \"i-mgc-photo-album-cute-re\"\n    return (\n      <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-fill px-6 py-10 text-center\">\n        <i className={`${fallbackIcon} mb-3 text-2xl text-text-tertiary`} />\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"absolute inset-0 aspect-square w-full overflow-hidden bg-material-medium\">\n      {isVideo ? (\n        <>\n          <video\n            ref={videoRef}\n            key={media.src}\n            className=\"size-full object-cover\"\n            src={media.src}\n            poster={media.poster}\n            playsInline\n            muted\n            autoPlay\n            preload=\"metadata\"\n            onError={() => setHasError(true)}\n          />\n\n          <AnimatePresence>\n            {showReplay && (\n              <m.button\n                initial={{ opacity: 0, scale: 0.8 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.8 }}\n                transition={Spring.presets.snappy}\n                onClick={handleReplay}\n                className=\"absolute right-3 top-3 flex size-10 items-center justify-center rounded-full bg-material-ultra-thick backdrop-blur-background transition-colors hover:bg-material-thick\"\n                aria-label={t(\"new_user_dialog.replay_video\")}\n              >\n                <i className=\"i-mgc-refresh-2-cute-re text-lg text-text\" />\n              </m.button>\n            )}\n          </AnimatePresence>\n        </>\n      ) : (\n        <img\n          key={media.src}\n          className=\"size-full object-cover\"\n          src={media.src}\n          alt={media.caption ?? \"\"}\n          loading=\"lazy\"\n          decoding=\"async\"\n          onError={() => setHasError(true)}\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/AppTipModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useCallback, useMemo, useState } from \"react\"\nimport { jsx } from \"react/jsx-runtime\"\nimport { useTranslation } from \"react-i18next\"\nimport { useNavigate } from \"react-router\"\n\nimport { setAISetting, useAISettingKey } from \"~/atoms/settings/ai\"\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { OpmlAbstractGraphic } from \"../discover/OpmlAbstractGraphic\"\nimport { AICopilotMedia } from \"./AICopilotMedia\"\nimport { AppTipMediaPreview } from \"./AppTipMediaPreview\"\nimport { OverviewMedia } from \"./OverviewMedia\"\nimport type { AppTipStep } from \"./types\"\nimport { useNewUserGuideState } from \"./useNewUserGuideState\"\n\ntype AppTipModalContentProps = {\n  initialStep?: number\n}\n\nexport function AppTipModalContent({ initialStep = 0 }: AppTipModalContentProps) {\n  const { dismiss } = useCurrentModal()\n  const navigate = useNavigate()\n  const { t } = useTranslation()\n  const { persistDismissState } = useNewUserGuideState()\n\n  const [activeStep, setActiveStep] = useState(initialStep)\n\n  const completeOnboarding = useCallback(() => {\n    dismiss()\n    persistDismissState(true)\n  }, [dismiss, persistDismissState])\n\n  const handleNavigateAndClose = useCallback(\n    (path: string) => {\n      completeOnboarding()\n      navigate(path)\n    },\n    [completeOnboarding, navigate],\n  )\n\n  const handleLaunchAiGuide = useCallback(() => {\n    completeOnboarding()\n    // Import and show AI onboarding modal\n    Promise.all([\n      import(\"~/modules/ai-onboarding/ai-onboarding-modal-content\"),\n      import(\"~/components/ui/modal/stacked/custom-modal\"),\n    ]).then(([m, { PlainModal }]) => {\n      window.presentModal({\n        title: \"AI Onboarding\",\n        content: ({ dismiss }) => (\n          <m.AiOnboardingModalContent\n            onClose={() => {\n              dismiss()\n            }}\n          />\n        ),\n        CustomModalComponent: PlainModal,\n        modalContainerClassName: \"flex items-center justify-center\",\n        canClose: false,\n        clickOutsideToDismiss: false,\n        overlay: true,\n      })\n    })\n  }, [completeOnboarding])\n\n  const steps = useMemo<AppTipStep[]>(() => {\n    return [\n      {\n        id: \"overview\",\n        title: t(\"new_user_dialog.overview.title\"),\n        description: t(\"new_user_dialog.overview.description\"),\n        highlights: [\n          t(\"new_user_dialog.overview.highlight_1\"),\n          t(\"new_user_dialog.overview.highlight_2\"),\n          t(\"new_user_dialog.overview.highlight_3\"),\n        ],\n        media: {\n          reactNode: jsx(OverviewMedia, {}),\n        },\n        primaryActionLabel: t(\"new_user_dialog.overview.primary\"),\n        onPrimaryAction: () => handleNavigateAndClose(\"/discover?type=search\"),\n      },\n      {\n        id: \"ai\",\n        title: t(\"new_user_dialog.ai.title\"),\n        description: t(\"new_user_dialog.ai.description\"),\n        highlights: [\n          t(\"new_user_dialog.ai.highlight_1\"),\n          t(\"new_user_dialog.ai.highlight_2\"),\n          t(\"new_user_dialog.ai.highlight_3\"),\n        ],\n        media: {\n          reactNode: jsx(AICopilotMedia, {}),\n        },\n        primaryActionLabel: t(\"new_user_dialog.ai.primary\"),\n        onPrimaryAction: handleLaunchAiGuide,\n        extra: jsx(AiSplineIndicatorToggle, {}),\n      },\n      {\n        id: \"import\",\n        title: t(\"new_user_dialog.import.title\"),\n        description: t(\"new_user_dialog.import.description\"),\n        highlights: [\n          t(\"new_user_dialog.import.highlight_1\"),\n          t(\"new_user_dialog.import.highlight_2\"),\n          t(\"new_user_dialog.import.highlight_3\"),\n        ],\n        media: {\n          reactNode: jsx(OpmlAbstractGraphic, {}),\n        },\n        primaryActionLabel: t(\"new_user_dialog.import.primary\"),\n        onPrimaryAction: () => handleNavigateAndClose(\"/discover?type=import\"),\n      },\n    ]\n  }, [handleLaunchAiGuide, handleNavigateAndClose, t])\n\n  const activeStepData = steps[activeStep] ?? steps[0] ?? null\n  const hasNextStep = activeStep < steps.length - 1\n\n  if (!activeStepData) return null\n\n  const handleNextStep = () => {\n    if (hasNextStep) {\n      setActiveStep(activeStep + 1)\n    } else {\n      completeOnboarding()\n    }\n  }\n\n  return (\n    <section className=\"shadow-modal relative grid min-h-[500px] overflow-hidden rounded-lg border border-border bg-background text-text lg:grid-cols-[1.2fr,1fr]\">\n      <div className=\"relative flex aspect-square items-center justify-center overflow-hidden bg-background\">\n        {activeStepData.media?.reactNode ? (\n          <div className=\"absolute inset-0 aspect-square w-full overflow-hidden bg-material-medium\">\n            {activeStepData.media?.reactNode}\n          </div>\n        ) : (\n          <AppTipMediaPreview media={activeStepData.media} />\n        )}\n      </div>\n\n      <div className=\"relative flex w-[500px] flex-col border-t border-border bg-background lg:border-l lg:border-t-0\">\n        <GlassButton\n          onClick={completeOnboarding}\n          variant=\"flat\"\n          className=\"absolute right-4 top-4 z-10\"\n          aria-label={t(\"new_user_dialog.actions.close\")}\n        >\n          <i className=\"i-mgc-close-cute-re\" />\n        </GlassButton>\n        <div className=\"flex flex-1 flex-col gap-6 p-8\">\n          <div className=\"flex flex-col gap-3\">\n            <h2 className=\"text-2xl font-semibold leading-tight text-text\">\n              {activeStepData.title}\n            </h2>\n            <p className=\"text-sm leading-relaxed text-text-secondary\">\n              {activeStepData.description}\n            </p>\n          </div>\n\n          <ul className=\"space-y-2.5 text-sm text-text-secondary\">\n            {activeStepData.highlights.map((point, idx) => (\n              <li key={`${activeStepData.id}-${idx}`} className=\"flex items-start gap-2.5\">\n                <span className=\"mt-2 flex size-1.5 rounded-full bg-text-tertiary\" />\n                <span className=\"leading-relaxed\">{point}</span>\n              </li>\n            ))}\n          </ul>\n\n          {activeStepData.extra}\n        </div>\n\n        <div className=\"border-t border-border p-4\">\n          <div className=\"flex items-center justify-between gap-3\">\n            <div className=\"flex items-center gap-2\">\n              {steps.map((step, idx) => (\n                <button\n                  type=\"button\"\n                  key={step.id}\n                  onClick={() => setActiveStep(idx)}\n                  aria-label={step.title}\n                  aria-current={idx === activeStep}\n                  className={cn(\n                    \"size-2 cursor-pointer rounded-full transition-colors\",\n                    idx === activeStep ? \"bg-text\" : \"bg-fill-tertiary hover:bg-fill-secondary\",\n                  )}\n                />\n              ))}\n            </div>\n\n            <div className=\"flex items-center gap-2\">\n              <Button\n                size=\"sm\"\n                variant={\"outline\"}\n                buttonClassName=\"h-8\"\n                onClick={activeStepData.onPrimaryAction}\n              >\n                {activeStepData.primaryActionLabel}\n              </Button>\n\n              <Button size=\"sm\" buttonClassName=\"h-8\" onClick={handleNextStep}>\n                {hasNextStep\n                  ? t(\"words.next\", { ns: \"common\" })\n                  : t(\"new_user_dialog.actions.finish\")}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  )\n}\n\nconst AiSplineIndicatorToggle = () => {\n  const { t } = useTranslation(\"ai\")\n  const showSplineButton = useAISettingKey(\"showSplineButton\")\n\n  return (\n    <div className=\"border-t pt-4\">\n      <div className=\"flex items-center justify-between gap-4\">\n        <div className=\"space-y-1\">\n          <Label className=\"text-sm font-medium text-text\">\n            {t(\"settings.showSplineButton.label\")}\n          </Label>\n          <p className=\"text-xs leading-relaxed text-text-secondary\">\n            {t(\"settings.showSplineButton.description\")}\n          </p>\n        </div>\n\n        <Switch\n          checked={showSplineButton}\n          onCheckedChange={(v) => setAISetting(\"showSplineButton\", v)}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/OverviewMedia.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { getViewList } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nconst seededRandom = (seed: number) => {\n  // Mulberry32\n  let t = (seed + 0x6d2b79f5) | 0\n  t = Math.imul(t ^ (t >>> 15), t | 1)\n  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n}\n\nexport const OverviewMedia: React.FC = () => {\n  const containerRef = React.useRef<HTMLDivElement | null>(null)\n  const logoRef = React.useRef<HTMLDivElement | null>(null)\n  const iconRefs = React.useRef<(HTMLDivElement | null)[]>([])\n\n  const [paths, setPaths] = React.useState<{ d: string; color: string; shadow: string }[]>([])\n  const [ready, setReady] = React.useState(false)\n  const completedKeysRef = React.useRef<Set<string>>(new Set())\n\n  const views = React.useMemo(() => getViewList(), [])\n\n  const computePaths = React.useCallback(() => {\n    const container = containerRef.current\n    const logoEl = logoRef.current\n    if (!container || !logoEl) return\n\n    const containerRect = container.getBoundingClientRect()\n    const logoRect = logoEl.getBoundingClientRect()\n\n    const startX = logoRect.left - containerRect.left + logoRect.width / 2\n    const startY = logoRect.top - containerRect.top + logoRect.height / 2\n\n    const newPaths: { d: string; color: string; shadow: string }[] = []\n\n    iconRefs.current.forEach((el, idx) => {\n      if (!el) return\n      const r = el.getBoundingClientRect()\n      const endX = r.left - containerRect.left + r.width / 2\n      const endY = r.top - containerRect.top + r.height / 2\n\n      // Generate a slightly wobbly path between start and end\n      const dx = endX - startX\n      const dy = endY - startY\n      const distance = Math.hypot(dx, dy)\n      const segments = Math.max(6, Math.min(12, Math.round(distance / 70)))\n      const amplitude = Math.min(18, Math.max(6, distance * 0.06))\n\n      // Build points along the straight line and offset them by a seeded noise\n      const points: Array<{ x: number; y: number }> = [{ x: startX, y: startY }]\n      for (let i = 1; i < segments; i++) {\n        const t = i / segments\n        const baseX = startX + dx * t\n        const baseY = startY + dy * t\n        // Perpendicular vector\n        const px = -dy\n        const py = dx\n        const plen = Math.hypot(px, py) || 1\n        const nx = px / plen\n        const ny = py / plen\n        // Taper near the ends\n        const taper = Math.sin(Math.PI * t)\n        const rand = (seededRandom((idx + 1) * 9973 + i * 53) - 0.5) * 2 // [-1, 1]\n        const offset = rand * amplitude * taper\n        points.push({ x: baseX + nx * offset, y: baseY + ny * offset })\n      }\n      points.push({ x: endX, y: endY })\n\n      // Convert to a smooth path using quadratic curves\n      let d = `M ${points[0]!.x} ${points[0]!.y}`\n      for (let i = 1; i < points.length - 1; i++) {\n        const p1 = points[i]!\n        const p2 = points[i + 1]!\n        // Midpoint smoothing\n        const cx = p1.x\n        const cy = p1.y\n        const mx = (p1.x + p2.x) / 2\n        const my = (p1.y + p2.y) / 2\n        d += ` Q ${cx} ${cy} ${mx} ${my}`\n      }\n\n      // Use view's active color\n      const view = views[idx]\n      const color = view?.activeColor ?? \"#999999\"\n      const shadow = `${color}40`\n\n      newPaths.push({ d, color, shadow })\n    })\n\n    setPaths(newPaths)\n  }, [views])\n\n  // Compute when animations are completed and on resize thereafter\n  React.useLayoutEffect(() => {\n    if (!ready) return\n    // Two rafs to ensure transforms are fully flushed\n    const id = requestAnimationFrame(() => {\n      const id2 = requestAnimationFrame(() => {\n        computePaths()\n      })\n      return () => cancelAnimationFrame(id2)\n    })\n    return () => cancelAnimationFrame(id)\n  }, [ready, computePaths])\n\n  React.useEffect(() => {\n    if (!ready) return\n    const onResize = () => computePaths()\n    window.addEventListener(\"resize\", onResize)\n    return () => window.removeEventListener(\"resize\", onResize)\n  }, [ready, computePaths])\n\n  const markAnimationDone = React.useCallback(\n    (key: string) => {\n      const set = completedKeysRef.current\n      if (set.has(key)) return\n      set.add(key)\n      if (set.size >= views.length + 1) {\n        setReady(true)\n      }\n    },\n    [views.length],\n  )\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\"relative aspect-square w-full overflow-hidden bg-material-medium\")}\n    >\n      {/* Grid background */}\n      <div\n        className=\"absolute inset-0\"\n        style={{\n          background: `\n            linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),\n            linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)\n          `,\n          backgroundSize: \"40px 40px\",\n        }}\n      />\n\n      {/* Top centered Logo */}\n      <m.div\n        ref={logoRef}\n        className=\"absolute left-1/2 top-[20%] z-10 -translate-x-1/2\"\n        animate={{ opacity: 1 }}\n        initial={{ opacity: 0 }}\n        onAnimationComplete={() => markAnimationDone(\"logo\")}\n      >\n        <div className=\"relative\">\n          {/* Logo glow effect */}\n          <div\n            className=\"absolute inset-0 -z-10 blur-2xl\"\n            style={{\n              background: \"radial-gradient(circle, rgba(255, 92, 0, 0.3) 0%, transparent 70%)\",\n            }}\n          />\n          <Logo className=\"size-20 drop-shadow-lg\" />\n        </div>\n      </m.div>\n\n      {/* Bottom view icons */}\n      {views.map((view, index) => {\n        const totalViews = views.length\n        // Distribute icons evenly with equal margins on both sides\n        const margin = 10 // Margin from edges (10%)\n        const startPosition = margin // First icon center position\n        const endPosition = 100 - margin // Last icon center position\n        const totalWidth = endPosition - startPosition // Available width\n        const spacing = totalViews > 1 ? totalWidth / (totalViews - 1) : 0\n        const xPosition = startPosition + spacing * index // Evenly spaced, symmetric\n\n        return (\n          <m.div\n            key={view.name}\n            className=\"absolute bottom-[20%] -translate-x-1/2\"\n            style={{\n              left: `${xPosition}%`,\n            }}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={{\n              ...Spring.presets.smooth,\n              delay: 0.1 + index * 0.08,\n            }}\n            onAnimationComplete={() => markAnimationDone(`icon-${index}`)}\n          >\n            {/* Icon container */}\n            <div\n              ref={(el) => {\n                iconRefs.current[index] = el\n              }}\n              className=\"relative flex size-12 items-center justify-center rounded-xl backdrop-blur-sm\"\n              style={{\n                backgroundColor: `${view.activeColor}20`,\n                borderWidth: \"1px\",\n                borderStyle: \"solid\",\n                borderColor: `${view.activeColor}40`,\n                boxShadow: `0 4px 12px ${view.activeColor}20`,\n              }}\n            >\n              <div className={cn(view.className, \"flex\")}>{view.icon}</div>\n            </div>\n          </m.div>\n        )\n      })}\n\n      {/* Hand-drawn connector lines */}\n      <svg className=\"pointer-events-none absolute inset-0\" width=\"100%\" height=\"100%\">\n        <defs>\n          {/* Slight wobble via displacement map to enhance sketch feeling (subtle) */}\n          <filter id=\"scribble-wobble-overview\" x=\"-5%\" y=\"-5%\" width=\"110%\" height=\"110%\">\n            <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.9\" numOctaves=\"1\" seed=\"2\" />\n            <feDisplacementMap in=\"SourceGraphic\" scale=\"0.7\" />\n          </filter>\n        </defs>\n        {paths.map((p, index) => {\n          // Keep reveal order in sync with icon animations\n          const iconDelay = index * 0.08\n          const revealDelay = iconDelay + 0.1 // start after icon settles a bit\n          return (\n            <g key={p.d} filter=\"url(#scribble-wobble-overview)\">\n              {/* Underlay shadow to suggest marker bleed */}\n              <m.path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.shadow}\n                strokeWidth={5}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: 0, opacity: 0 }}\n                animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n                transition={{ ...Spring.presets.smooth, delay: revealDelay }}\n              />\n              {/* Main line */}\n              <m.path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.color}\n                strokeWidth={2.5}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: 0, opacity: 0 }}\n                animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n                transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.05 }}\n              />\n              {/* A second, lighter stroke with slight dash to mimic hand-drawn */}\n              <m.path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.color}\n                strokeOpacity={0.7}\n                strokeWidth={1.4}\n                strokeDasharray=\"6 7\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: 0, opacity: 0 }}\n                animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n                transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.1 }}\n              />\n            </g>\n          )\n        })}\n      </svg>\n\n      {/* Ambient background glow */}\n      <div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{\n          background:\n            \"radial-gradient(circle at 50% 50%, rgba(255, 92, 0, 0.08) 0%, transparent 50%)\",\n        }}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/constants.ts",
    "content": "export const APP_TIP_STORAGE_PREFIX = \"follow:ai-onboarding:dismissed\"\n\nexport const APP_TIP_DEBUG_EVENT = \"follow:ai-onboarding:debug-open\"\n\nexport const APP_TIP_DISMISS_EVENT = \"follow:ai-onboarding:dismiss-change\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/index.ts",
    "content": "export { AppTipModalContent } from \"./AppTipModalContent\"\nexport { APP_TIP_DEBUG_EVENT } from \"./constants\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/types.ts",
    "content": "export type AppTipDebugOpenEventDetail = {\n  step?: number\n  openAiGuide?: boolean\n}\n\nexport type AppTipStepMedia = {\n  src?: string\n  poster?: string\n  caption?: string\n  kind?: \"video\" | \"image\"\n\n  reactNode?: React.ReactNode\n}\n\nexport type AppTipStep = {\n  id: string\n  title: string\n  description: string\n  highlights: string[]\n  media?: AppTipStepMedia\n  primaryActionLabel: string\n  onPrimaryAction: () => void\n  secondaryActionLabel?: string\n  onSecondaryAction?: () => void\n  extra?: React.ReactNode\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/app-tip/useNewUserGuideState.ts",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\n\nimport { useAuthQuery } from \"~/hooks/common/useBizQuery\"\nimport { settings } from \"~/queries/settings\"\n\nimport { APP_TIP_DISMISS_EVENT, APP_TIP_STORAGE_PREFIX } from \"./constants\"\n\nexport type AppTipDismissChangeDetail = {\n  key: string\n  dismissed: boolean\n}\n\nexport const useNewUserGuideState = () => {\n  const user = useWhoami()\n  const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {})\n\n  const dismissKey = useMemo(() => (user ? `${APP_TIP_STORAGE_PREFIX}:${user.id}` : null), [user])\n  const [hasDismissed, setHasDismissed] = useState(() => readDismissed(dismissKey))\n\n  useEffect(() => {\n    setHasDismissed(readDismissed(dismissKey))\n  }, [dismissKey])\n\n  useEffect(() => {\n    if (!dismissKey || typeof window === \"undefined\") return\n    const listener: EventListener = (event) => {\n      const { detail } = event as CustomEvent<AppTipDismissChangeDetail>\n      if (!detail || detail.key !== dismissKey) {\n        return\n      }\n      setHasDismissed(detail.dismissed)\n    }\n    window.addEventListener(APP_TIP_DISMISS_EVENT, listener)\n    return () => {\n      window.removeEventListener(APP_TIP_DISMISS_EVENT, listener)\n    }\n  }, [dismissKey])\n\n  const persistDismissState = useCallback(\n    (next: boolean) => {\n      if (!dismissKey || typeof window === \"undefined\") return\n\n      if (next) {\n        window.localStorage.setItem(dismissKey, \"1\")\n      } else {\n        window.localStorage.removeItem(dismissKey)\n      }\n      window.dispatchEvent(\n        new CustomEvent<AppTipDismissChangeDetail>(APP_TIP_DISMISS_EVENT, {\n          detail: { key: dismissKey, dismissed: next },\n        }),\n      )\n    },\n    [dismissKey],\n  )\n\n  const isNewUser =\n    !isLoading && remoteSettings && Object.keys(remoteSettings.updated ?? {}).length === 0\n  const eligibleForGuide = Boolean(user && isNewUser)\n  const shouldShowNewUserGuide = eligibleForGuide && !hasDismissed\n\n  return {\n    user,\n    isNewUser,\n    eligibleForGuide,\n    shouldShowNewUserGuide,\n    hasDismissed,\n    setHasDismissed,\n    persistDismissState,\n    dismissKey,\n    isLoading,\n  }\n}\n\nfunction readDismissed(key: string | null) {\n  if (!key) return false\n  try {\n    return window.localStorage.getItem(key) === \"1\"\n  } catch {\n    return false\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/auth/Form.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.js\"\nimport { Input } from \"@follow/components/ui/input/Input.js\"\nimport type { LoginRuntime } from \"@follow/shared/auth\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useRecaptchaToken } from \"~/hooks/common\"\nimport { loginHandler, signUp, twoFactor } from \"~/lib/auth\"\nimport { ipcServices } from \"~/lib/client\"\nimport { setAuthSessionToken } from \"~/lib/client-session\"\nimport { handleSessionChanges } from \"~/queries/auth\"\n\nimport { TOTPForm } from \"../profile/two-factor\"\n\nconst formSchema = z.object({\n  email: z.string().email(),\n  password: IN_ELECTRON ? z.string().min(8).max(128) : z.string().min(8).max(128).or(z.literal(\"\")),\n})\n\nconst getAuthTokenFromResult = (result: unknown) => {\n  if (!result || typeof result !== \"object\") {\n    return null\n  }\n\n  if (\"sessionToken\" in result && typeof result.sessionToken === \"string\") {\n    return result.sessionToken\n  }\n\n  if (\"token\" in result && typeof result.token === \"string\") {\n    return result.token\n  }\n\n  if (\n    \"data\" in result &&\n    result.data &&\n    typeof result.data === \"object\" &&\n    (\"sessionToken\" in result.data || \"token\" in result.data)\n  ) {\n    const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown }\n    if (typeof sessionToken === \"string\") {\n      return sessionToken\n    }\n    return typeof token === \"string\" ? token : null\n  }\n\n  return null\n}\n\ntype ElectronAuthResult = {\n  data?: Record<string, unknown>\n  error?: {\n    message: string\n    status?: number\n  } | null\n}\n\nconst normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => {\n  if (!result || typeof result !== \"object\") {\n    return {}\n  }\n\n  return result as ElectronAuthResult\n}\n\nconst setElectronSessionToken = async (token: string) => {\n  if (!ipcServices) {\n    return\n  }\n\n  const authService = ipcServices.auth as\n    | (typeof ipcServices.auth & {\n        setSessionToken?: (token: string) => Promise<void>\n      })\n    | undefined\n\n  await authService?.setSessionToken?.(token)\n}\n\nconst getElectronAuthService = () => {\n  if (!ipcServices) {\n    return null\n  }\n\n  return ipcServices.auth as typeof ipcServices.auth & {\n    setSessionToken?: (token: string) => Promise<void>\n    signInWithCredential?: (payload: {\n      email: string\n      password: string\n      headers?: Record<string, string>\n    }) => Promise<unknown>\n    signUpWithCredential?: (payload: {\n      email: string\n      password: string\n      name: string\n      callbackURL: string\n      headers?: Record<string, string>\n    }) => Promise<unknown>\n  }\n}\n\nexport function LoginWithPassword({\n  runtime,\n  onLoginStateChange,\n}: {\n  runtime: LoginRuntime\n  onLoginStateChange: (state: \"register\" | \"login\") => void\n}) {\n  const { t } = useTranslation(\"app\")\n  const { t: tSettings } = useTranslation(\"settings\")\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      email: \"\",\n      password: \"\",\n    },\n    mode: \"all\",\n  })\n\n  const { present } = useModalStack()\n  const requestRecaptchaToken = useRecaptchaToken()\n\n  async function onSubmit(values: z.infer<typeof formSchema>) {\n    const recaptchaToken = await requestRecaptchaToken(\"desktop_login\")\n    const headers = recaptchaToken\n      ? {\n          \"x-token\": `r3:${recaptchaToken}`,\n        }\n      : undefined\n\n    if (!IN_ELECTRON && (!values.password || values.password.trim() === \"\")) {\n      const result = await loginHandler(\"magicLink\", runtime, {\n        email: values.email,\n        headers,\n      })\n\n      if (result?.error) {\n        toast.error(result.error.message)\n        return\n      }\n\n      toast.success(t(\"login.magic_link_sent\"))\n      return\n    }\n\n    // Use password authentication\n    const res = IN_ELECTRON\n      ? normalizeElectronAuthResult(\n          await getElectronAuthService()?.signInWithCredential?.({\n            email: values.email,\n            password: values.password,\n            headers,\n          }),\n        )\n      : await loginHandler(\"credential\", runtime, {\n          email: values.email,\n          password: values.password,\n          headers,\n        })\n    if (res?.error) {\n      toast.error(res.error.message)\n      return\n    }\n\n    if ((res?.data as any)?.twoFactorRedirect) {\n      present({\n        title: tSettings(\"profile.totp_code.title\"),\n        content: () => {\n          return (\n            <TOTPForm\n              onSubmitMutationFn={async (values) => {\n                const { data, error } = await twoFactor.verifyTotp({ code: values.code })\n                if (!data || error) {\n                  throw new Error(error?.message ?? \"Invalid TOTP code\")\n                }\n              }}\n              onSuccess={() => {\n                handleSessionChanges()\n              }}\n            />\n          )\n        },\n      })\n    } else {\n      if (IN_ELECTRON) {\n        const token = getAuthTokenFromResult(res)\n        if (token) {\n          setAuthSessionToken(token)\n          await setElectronSessionToken(token)\n        }\n      }\n      handleSessionChanges()\n    }\n  }\n\n  return (\n    <Form {...form}>\n      <form data-testid=\"login-form\" onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n        <FormField\n          control={form.control}\n          name=\"email\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t(\"login.email\")}</FormLabel>\n              <FormControl>\n                <Input\n                  data-testid=\"login-email-input\"\n                  type=\"email\"\n                  autoCapitalize=\"none\"\n                  autoComplete=\"email\"\n                  autoCorrect=\"off\"\n                  inputMode=\"email\"\n                  spellCheck={false}\n                  {...field}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"password\"\n          render={({ field }) => (\n            <FormItem className=\"mt-4\">\n              <FormLabel className=\"flex items-center justify-between\">\n                <span>\n                  {IN_ELECTRON\n                    ? t(\"login.password\")\n                    : `${t(\"login.password\")} (${t(\"login.password_optional\")})`}\n                </span>\n                <a\n                  href={`${env.VITE_WEB_URL}/forget-password`}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  tabIndex={-1}\n                  className=\"block py-1 text-xs text-accent hover:underline\"\n                >\n                  {t(\"login.forget_password.note\")}\n                </a>\n              </FormLabel>\n              <FormControl>\n                <Input\n                  data-testid=\"login-password-input\"\n                  type=\"password\"\n                  autoCapitalize=\"none\"\n                  autoComplete={IN_ELECTRON ? \"current-password\" : \"new-password\"}\n                  autoCorrect=\"off\"\n                  spellCheck={false}\n                  {...field}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"flex flex-col space-y-3\">\n          <Button\n            data-testid=\"login-submit\"\n            type=\"submit\"\n            isLoading={form.formState.isSubmitting}\n            disabled={!form.formState.isValid}\n            size=\"lg\"\n          >\n            {IN_ELECTRON || (form.watch(\"password\") && form.watch(\"password\")?.trim() !== \"\")\n              ? t(\"login.continueWith\", { provider: t(\"words.email\") })\n              : t(\"login.send_magic_link\")}\n          </Button>\n        </div>\n      </form>\n\n      <Divider className=\"my-4\" />\n\n      <div className=\"pb-2 text-center text-sm text-text-secondary\">\n        <Trans\n          t={t}\n          i18nKey=\"login.no_account\"\n          components={{\n            strong: (\n              <button\n                data-testid=\"login-switch-register\"\n                type=\"button\"\n                className=\"inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n                onClick={() => onLoginStateChange(\"register\")}\n              />\n            ),\n          }}\n        />\n      </div>\n    </Form>\n  )\n}\n\nconst registerFormSchema = z\n  .object({\n    email: z.string().email(),\n    password: IN_ELECTRON\n      ? z.string().min(8).max(128)\n      : z.string().min(8).max(128).or(z.literal(\"\")),\n    confirmPassword: IN_ELECTRON ? z.string() : z.string().or(z.literal(\"\")),\n  })\n  .refine((data) => data.password === data.confirmPassword, {\n    message: \"Passwords don't match\",\n    path: [\"confirmPassword\"],\n  })\n\nexport function RegisterForm({\n  runtime,\n  onLoginStateChange,\n}: {\n  runtime: LoginRuntime\n  onLoginStateChange: (state: \"register\" | \"login\") => void\n}) {\n  const { t } = useTranslation(\"app\")\n\n  const form = useForm<z.infer<typeof registerFormSchema>>({\n    resolver: zodResolver(registerFormSchema),\n    defaultValues: {\n      email: \"\",\n      password: \"\",\n      confirmPassword: \"\",\n    },\n    mode: \"all\",\n  })\n\n  const requestRecaptchaToken = useRecaptchaToken()\n\n  async function onSubmit(values: z.infer<typeof registerFormSchema>) {\n    const recaptchaToken = await requestRecaptchaToken(\"desktop_register\")\n    const headers = recaptchaToken\n      ? {\n          \"x-token\": `r3:${recaptchaToken}`,\n        }\n      : undefined\n\n    if (!IN_ELECTRON && (!values.password || values.password.trim() === \"\")) {\n      const result = await loginHandler(\"magicLink\", runtime, {\n        email: values.email,\n        headers,\n      })\n\n      if (result?.error) {\n        toast.error(result.error.message)\n        return\n      }\n\n      toast.success(t(\"register.magic_link_sent\"))\n      return\n    }\n\n    const result = IN_ELECTRON\n      ? normalizeElectronAuthResult(\n          await getElectronAuthService()?.signUpWithCredential?.({\n            email: values.email,\n            password: values.password,\n            name: values.email.split(\"@\")[0]!,\n            callbackURL: \"/\",\n            headers,\n          }),\n        )\n      : await signUp.email(\n          {\n            email: values.email,\n            password: values.password,\n            name: values.email.split(\"@\")[0]!,\n            callbackURL: \"/\",\n          },\n          {\n            onError(context) {\n              toast.error(context.error.message)\n            },\n            headers,\n          },\n        )\n\n    if (result?.error) {\n      return result\n    }\n\n    if (IN_ELECTRON) {\n      const token = getAuthTokenFromResult(result)\n      if (token) {\n        setAuthSessionToken(token)\n        await setElectronSessionToken(token)\n      }\n    }\n\n    handleSessionChanges()\n\n    return result\n  }\n\n  return (\n    <div className=\"relative\">\n      <Form {...form}>\n        <form\n          data-testid=\"register-form\"\n          onSubmit={form.handleSubmit(onSubmit)}\n          className=\"space-y-4\"\n        >\n          <FormField\n            control={form.control}\n            name=\"email\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t(\"register.email\")}</FormLabel>\n                <FormControl>\n                  <Input\n                    data-testid=\"register-email-input\"\n                    type=\"email\"\n                    autoCapitalize=\"none\"\n                    autoComplete=\"email\"\n                    autoCorrect=\"off\"\n                    inputMode=\"email\"\n                    spellCheck={false}\n                    {...field}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"password\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {IN_ELECTRON\n                    ? t(\"register.password\")\n                    : `${t(\"register.password\")} (${t(\"register.password_optional\")})`}\n                </FormLabel>\n                <FormControl>\n                  <Input\n                    data-testid=\"register-password-input\"\n                    type=\"password\"\n                    autoCapitalize=\"none\"\n                    autoComplete=\"new-password\"\n                    autoCorrect=\"off\"\n                    spellCheck={false}\n                    {...field}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"confirmPassword\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {IN_ELECTRON\n                    ? t(\"register.confirm_password\")\n                    : `${t(\"register.confirm_password\")} (${t(\"register.password_optional\")})`}\n                </FormLabel>\n                <FormControl>\n                  <Input\n                    data-testid=\"register-confirm-password-input\"\n                    type=\"password\"\n                    autoCapitalize=\"none\"\n                    autoComplete=\"new-password\"\n                    autoCorrect=\"off\"\n                    spellCheck={false}\n                    {...field}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <Button\n            data-testid=\"register-submit\"\n            type=\"submit\"\n            buttonClassName=\"w-full\"\n            size=\"lg\"\n            isLoading={form.formState.isSubmitting}\n            disabled={!form.formState.isValid}\n          >\n            {IN_ELECTRON || (form.watch(\"password\") && form.watch(\"password\")?.trim() !== \"\")\n              ? t(\"register.submit\")\n              : t(\"register.send_magic_link\")}\n          </Button>\n        </form>\n      </Form>\n      <Divider className=\"my-4\" />\n\n      <div className=\"pb-2 text-center text-sm text-text-secondary\">\n        <Trans\n          t={t}\n          i18nKey=\"login.have_account\"\n          components={{\n            strong: (\n              <button\n                data-testid=\"register-switch-login\"\n                type=\"button\"\n                className=\"inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n                onClick={() => onLoginStateChange(\"login\")}\n              />\n            ),\n          }}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { Folo } from \"@follow/components/icons/folo.js\"\nimport { Logo } from \"@follow/components/icons/logo.js\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { IN_ELECTRON } from \"@follow/shared\"\nimport type { LoginRuntime } from \"@follow/shared/auth\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport { Trans, useTranslation } from \"react-i18next\"\n\nimport { useCurrentModal, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { authClient, loginHandler } from \"~/lib/auth\"\nimport { useSession } from \"~/queries/auth\"\nimport { useAuthProviders } from \"~/queries/users\"\n\nimport { LoginWithPassword, RegisterForm } from \"./Form\"\nimport { TokenModalContent } from \"./TokenModal\"\n\ninterface LoginModalContentProps {\n  runtime: LoginRuntime\n  canClose?: boolean\n}\n\nexport const LoginModalContent = (props: LoginModalContentProps) => {\n  const modal = useCurrentModal()\n  const { present } = useModalStack()\n\n  const { canClose = true, runtime } = props\n\n  const { t } = useTranslation([\"app\", \"common\"])\n  const { data: authProviders, isLoading } = useAuthProviders()\n  const { status } = useSession()\n\n  const isMobile = useMobile()\n\n  const providers = Object.entries(authProviders || {})\n  const effectiveProviders = useMemo(() => {\n    if (providers.some(([key]) => key === \"credential\")) {\n      return providers\n    }\n\n    return providers.concat([\n      [\n        \"credential\",\n        {\n          name: t(\"words.email\"),\n          id: \"credential\",\n          color: \"\",\n          icon: \"\",\n          icon64: \"\",\n        },\n      ],\n    ])\n  }, [providers, t])\n  const visibleProviders = useMemo(\n    () =>\n      isLoading ? effectiveProviders.filter(([key]) => key === \"credential\") : effectiveProviders,\n    [effectiveProviders, isLoading],\n  )\n\n  const [isRegister, setIsRegister] = useState(true)\n  const [isEmail, setIsEmail] = useState(false)\n\n  const handleOpenLegal = (type: \"privacy\" | \"tos\") => {\n    const path = {\n      privacy: \"privacy-policy\",\n      tos: \"terms-of-service\",\n    }\n\n    window.open(`https://folo.is/${path[type]}`, \"_blank\")\n  }\n\n  const handleOpenToken = () => {\n    present({\n      id: \"token\",\n      title: t(\"login.enter_token\"),\n      content: () => <TokenModalContent />,\n    })\n  }\n\n  const isDark = useIsDark()\n\n  const handleLoginStateChange = (state: \"register\" | \"login\") => {\n    setIsRegister(state === \"register\")\n  }\n\n  const [lastMethod, setLastMethod] = useState<string | null>(null)\n  useEffect(() => {\n    let lastMethodValue = authClient.getLastUsedLoginMethod()\n    if (lastMethodValue === \"email\") {\n      lastMethodValue = \"credential\"\n    }\n    if (lastMethodValue) {\n      setIsRegister(false)\n      setLastMethod(lastMethodValue)\n    }\n  }, [lastMethod])\n\n  useEffect(() => {\n    if (status === \"authenticated\") {\n      modal.dismiss()\n    }\n  }, [modal, status])\n\n  const Inner = (\n    <>\n      {isEmail && (\n        <m.div\n          className=\"absolute -left-3 top-0 z-30\"\n          initial={{ opacity: 0, x: -10 }}\n          animate={{ opacity: 1, x: 0 }}\n          transition={Spring.presets.smooth}\n        >\n          <MotionButtonBase\n            data-testid=\"auth-back\"\n            className=\"flex cursor-button items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium duration-200 hover:bg-fill-secondary\"\n            onClick={() => setIsEmail(false)}\n          >\n            <i className=\"i-mgc-left-cute-fi size-4\" />\n            <span>{t(\"login.back\")}</span>\n          </MotionButtonBase>\n        </m.div>\n      )}\n\n      {/* Header Section */}\n      <div className=\"mb-6 flex flex-col items-center gap-3\">\n        <m.div\n          initial={{ scale: 0.9, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          transition={Spring.presets.smooth}\n        >\n          <Logo className=\"size-16\" />\n        </m.div>\n        {isRegister ? (\n          <m.div\n            className=\"flex flex-col items-center gap-2\"\n            initial={{ y: 10, opacity: 0 }}\n            animate={{ y: 0, opacity: 1 }}\n            transition={Spring.presets.smooth}\n          >\n            <h1 className=\"text-2xl font-semibold\">{t(\"login.title\")}</h1>\n          </m.div>\n        ) : (\n          <m.div\n            className=\"flex items-center gap-2\"\n            initial={{ y: 10, opacity: 0 }}\n            animate={{ y: 0, opacity: 1 }}\n            transition={Spring.presets.smooth}\n          >\n            <span className=\"text-2xl font-semibold\">{t(\"signin.sign_in_to\")}</span>\n            <Folo className=\"size-12\" />\n          </m.div>\n        )}\n      </div>\n      {!IN_ELECTRON && (\n        <button\n          type=\"button\"\n          aria-label={t(\"words.close\", { ns: \"common\" })}\n          className=\"absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent transition-colors hover:bg-fill/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n          onClick={modal.dismiss}\n        >\n          <i aria-hidden className=\"i-mgc-close-cute-re pointer-events-none size-4\" />\n        </button>\n      )}\n      {isEmail ? (\n        <m.div\n          initial={{ opacity: 0, y: 10 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={Spring.presets.smooth}\n        >\n          {isRegister ? (\n            <RegisterForm runtime={runtime} onLoginStateChange={handleLoginStateChange} />\n          ) : (\n            <LoginWithPassword runtime={runtime} onLoginStateChange={handleLoginStateChange} />\n          )}\n        </m.div>\n      ) : (\n        <div className=\"flex flex-col gap-4\">\n          {/* Login Providers */}\n          <div className=\"flex flex-col gap-2.5\">\n            {visibleProviders.map(([key, provider]) => (\n              <div key={key}>\n                <button\n                  data-testid={`login-provider-${key}`}\n                  type=\"button\"\n                  onClick={() => {\n                    if (key === \"credential\") {\n                      setIsEmail(true)\n                    } else {\n                      loginHandler(key, \"app\")\n                    }\n                  }}\n                  className=\"group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-colors duration-200 hover:border-folo/30 hover:bg-folo/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n                >\n                  {provider.icon64 ? (\n                    <img\n                      className={cn(\n                        \"pointer-events-none absolute left-7 size-5 object-contain\",\n                        !provider.iconDark64 &&\n                          \"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert\",\n                      )}\n                      src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}\n                      alt=\"\"\n                      aria-hidden=\"true\"\n                    />\n                  ) : (\n                    <i\n                      aria-hidden\n                      className=\"i-mgc-mail-cute-re pointer-events-none absolute left-7 size-5 text-text-secondary\"\n                    />\n                  )}\n                  <span className=\"pointer-events-none relative z-10\">\n                    {t(\"login.continueWith\", { provider: provider.name })}\n                  </span>\n\n                  {lastMethod === key && (\n                    <m.div\n                      className=\"pointer-events-none absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white\"\n                      initial={{ scale: 0, opacity: 0 }}\n                      animate={{ scale: 1, opacity: 1 }}\n                      transition={Spring.presets.bouncy}\n                    >\n                      {t(\"login.lastUsed\")}\n                    </m.div>\n                  )}\n                </button>\n              </div>\n            ))}\n          </div>\n\n          {/* Footer Links */}\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"text-center text-xs leading-relaxed text-text-tertiary\">\n              <button\n                type=\"button\"\n                onClick={() => handleOpenToken()}\n                className=\"inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n              >\n                <i aria-hidden className=\"i-mgc-key-2-cute-re size-3.5\" />\n                <span>{t(\"login.enter_token\")}</span>\n              </button>\n            </div>\n            <div className=\"text-center text-xs leading-relaxed text-text-tertiary\">\n              <span>{t(\"login.agree_to\")} </span>\n              <button\n                type=\"button\"\n                onClick={() => handleOpenLegal(\"tos\")}\n                className=\"text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n              >\n                {t(\"login.terms\")}\n              </button>\n              <span> & </span>\n              <button\n                type=\"button\"\n                onClick={() => handleOpenLegal(\"privacy\")}\n                className=\"text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2\"\n              >\n                {t(\"login.privacy\")}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {!isEmail && (\n        <>\n          {/* Gradient Divider */}\n          <div\n            className=\"my-4 h-px\"\n            style={{\n              background:\n                \"linear-gradient(to right, transparent, rgba(255, 92, 0, 0.2), transparent)\",\n            }}\n          />\n\n          {/* Switch Account Type */}\n          <m.button\n            data-testid={isRegister ? \"register-switch-login\" : \"login-switch-register\"}\n            className=\"group w-full cursor-pointer pb-2 text-center text-sm font-medium transition-colors\"\n            onClick={() => setIsRegister(!isRegister)}\n            whileHover={{ scale: 1.02 }}\n            whileTap={{ scale: 0.98 }}\n          >\n            <Trans\n              t={t}\n              i18nKey={isRegister ? \"login.have_account\" : \"login.no_account\"}\n              components={{\n                strong: (\n                  <span className=\"text-accent transition-colors group-hover:text-accent/80\" />\n                ),\n              }}\n            />\n          </m.button>\n        </>\n      )}\n    </>\n  )\n  if (isMobile) {\n    return Inner\n  }\n\n  return (\n    <div className=\"center flex h-full\" onClick={canClose ? modal.dismiss : undefined}>\n      <m.div\n        initial={{ opacity: 0, y: 20, scale: 0.95 }}\n        animate={{ opacity: 1, y: 0, scale: 1 }}\n        exit={{ opacity: 0, y: 20, scale: 0.95 }}\n        transition={Spring.presets.smooth}\n      >\n        <div\n          onClick={stopPropagation}\n          tabIndex={-1}\n          data-testid=\"login-modal\"\n          className=\"relative w-[28rem] overflow-hidden rounded-2xl border border-folo/20 bg-background p-6 shadow-2xl shadow-folo/10 backdrop-blur-xl\"\n        >\n          {/* Inner glow layer */}\n          <div\n            className=\"pointer-events-none absolute inset-0\"\n            style={{\n              background:\n                \"radial-gradient(circle at 50% 0%, rgba(255, 92, 0, 0.08), transparent 60%)\",\n            }}\n          />\n\n          {/* Content */}\n          <div className=\"relative\">{Inner}</div>\n        </div>\n      </m.div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useState } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { oneTimeToken } from \"~/lib/auth\"\nimport { handleSessionChanges } from \"~/queries/auth\"\n\nconst formSchema = z.object({\n  token: z.string().min(1),\n})\n\nexport const TokenModalContent = () => {\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n  })\n  const { t } = useTranslation(\"common\")\n  const [isLoading, setIsLoading] = useState(false)\n\n  async function onSubmit(values: z.infer<typeof formSchema>) {\n    setIsLoading(true)\n    try {\n      const inputToken = values.token.trim()\n      let token = inputToken\n      if (URL.canParse(inputToken)) {\n        // If the input is a valid URL, extract the token from the URL\n        const urlObj = new URL(inputToken)\n        if (urlObj.searchParams.has(\"token\")) {\n          token = urlObj.searchParams.get(\"token\") || \"\"\n        }\n      } else if (inputToken.startsWith(\"auth?token=\")) {\n        token = inputToken.slice(\"auth?token=\".length)\n      }\n      await oneTimeToken.apply({ token })\n      handleSessionChanges()\n    } catch (e) {\n      console.error(\"Failed to apply one-time token:\", e)\n      toast.error(\"Failed to apply one-time token\")\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  return (\n    <div className=\"size-full overflow-hidden\">\n      <Form {...form}>\n        <form\n          onSubmit={form.handleSubmit(onSubmit)}\n          className=\"w-[512px] max-w-full overflow-hidden px-0.5\"\n        >\n          <FormField\n            control={form.control}\n            name=\"token\"\n            render={({ field }) => (\n              <FormItem className=\"flex flex-col items-center gap-2 md:block\">\n                <FormControl>\n                  <Input\n                    autoFocus\n                    className=\"mt-1 dark:text-zinc-200\"\n                    placeholder=\"folo://auth?token=xxx\"\n                    autoCapitalize=\"none\"\n                    autoCorrect=\"off\"\n                    spellCheck={false}\n                    {...field}\n                  />\n                </FormControl>\n                <div className=\"h-6\">\n                  <FormMessage />\n                </div>\n              </FormItem>\n            )}\n          />\n          <div className=\"center relative flex\">\n            <Button\n              variant=\"primary\"\n              type=\"submit\"\n              disabled={!form.formState.isValid}\n              isLoading={isLoading}\n            >\n              {t(\"words.submit\")}\n            </Button>\n          </div>\n        </form>\n      </Form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/claim/feed-claim-modal.tsx",
    "content": "import { AutoResizeHeight } from \"@follow/components/ui/auto-resize-height/index.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { Card, CardHeader } from \"@follow/components/ui/card/index.jsx\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@follow/components/ui/tabs/index.jsx\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport type { FC } from \"react\"\nimport { useEffect } from \"react\"\nimport { Trans, useTranslation } from \"react-i18next\"\n\nimport { CopyButton } from \"~/components/ui/button/CopyButton\"\nimport { ShikiHighLighter } from \"~/components/ui/code-highlighter\"\nimport { useShikiDefaultTheme } from \"~/components/ui/code-highlighter/shiki/hooks\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { FollowSummary } from \"~/modules/feed/feed-summary\"\nimport { feed as feedQuery, useClaimFeedMutation } from \"~/queries/feed\"\n\nexport const FeedClaimModalContent: FC<{\n  feedId: string\n}> = ({ feedId }) => {\n  const { t } = useTranslation()\n  const feed = useFeedById(feedId)\n  const {\n    data: claimMessage,\n    isLoading,\n    error,\n  } = useAuthQuery(feedQuery.claimMessage({ feedId }), {\n    enabled: !!feed,\n  })\n  const { setClickOutSideToDismiss } = useCurrentModal()\n\n  const { mutateAsync: claim, isPending, isSuccess } = useClaimFeedMutation(feedId)\n\n  useEffect(() => {\n    setClickOutSideToDismiss(!isPending)\n  }, [isPending, setClickOutSideToDismiss])\n\n  const shikiTheme = useShikiDefaultTheme()\n\n  if (!feed) return null\n\n  if (isLoading) {\n    return (\n      <div className=\"center h-32 w-[650px]\">\n        <LoadingCircle size=\"large\" />\n      </div>\n    )\n  }\n\n  if (error) {\n    return <div>{t(\"feed_claim_modal.failed_to_load\")}</div>\n  }\n\n  return (\n    <div className=\"mx-auto w-[650px]\">\n      <Card className=\"mb-2\">\n        <CardHeader>\n          <FollowSummary feed={feed} />\n        </CardHeader>\n      </Card>\n      <p>{t(\"feed_claim_modal.verify_ownership\")}</p>\n      <p>{t(\"feed_claim_modal.choose_verification_method\")}</p>\n      <Tabs defaultValue=\"content\" className=\"mt-4\">\n        <TabsList className=\"w-full justify-start\">\n          <TabsTrigger value=\"content\">{t(\"feed_claim_modal.tab_content\")}</TabsTrigger>\n          <TabsTrigger value=\"description\">{t(\"feed_claim_modal.tab_description\")}</TabsTrigger>\n          <TabsTrigger value=\"rss\">{t(\"feed_claim_modal.tab_rss\")}</TabsTrigger>\n        </TabsList>\n        <AutoResizeHeight duration={0.1} className=\"px-2\">\n          <TabsContent className=\"mt-0 pt-3\" value=\"content\">\n            <p>{t(\"feed_claim_modal.content_instructions\")}</p>\n            {feed.url.startsWith(\"rsshub://\") && (\n              <p className=\"mt-2 text-sm leading-tight text-orange-800 dark:text-orange-500/70\">\n                {t(\"feed_claim_modal.rsshub_notice\")}\n              </p>\n            )}\n            <BaseCodeBlock>{claimMessage?.data.content || \"\"}</BaseCodeBlock>\n          </TabsContent>\n          <TabsContent className=\"mt-0 pt-3\" value=\"description\">\n            <p className=\"mb-2 leading-none\">{t(\"feed_claim_modal.description_current\")}</p>\n            <p className=\"my-2 text-xs text-zinc-500\">{feed.description}</p>\n            <Trans\n              i18nKey=\"feed_claim_modal.description_instructions\"\n              components={{ code: <code className=\"text-sm\">{\"<description />\"}</code> }}\n            />\n            {feed.url.startsWith(\"rsshub://\") && (\n              <p className=\"mt-1 leading-tight text-orange-800\">\n                {t(\"feed_claim_modal.rsshub_notice\")}\n              </p>\n            )}\n            <BaseCodeBlock>{claimMessage?.data.description || \"\"}</BaseCodeBlock>\n          </TabsContent>\n          <TabsContent className=\"mt-0 pt-3\" value=\"rss\">\n            <div className=\"space-y-3\">\n              <p>{t(\"feed_claim_modal.rss_instructions\")}</p>\n              <p>{t(\"feed_claim_modal.rss_format_choice\")}</p>\n              <p>\n                <b>{t(\"feed_claim_modal.rss_xml_format\")}</b>\n              </p>\n              <ShikiHighLighter\n                transparent\n                theme={shikiTheme}\n                className=\"group relative mt-3 cursor-auto select-text whitespace-pre break-words rounded-lg border border-border bg-zinc-100 text-sm dark:bg-neutral-800 [&_pre]:whitespace-pre [&_pre]:break-words [&_pre]:!p-0\"\n                code={claimMessage?.data.xml || \"\"}\n                language=\"xml\"\n              />\n              <p>\n                <b>{t(\"feed_claim_modal.rss_json_format\")}</b>\n              </p>\n              <ShikiHighLighter\n                transparent\n                theme={shikiTheme}\n                className=\"group relative mt-3 cursor-auto select-text whitespace-pre break-words rounded-lg border border-border bg-zinc-100 text-sm dark:bg-neutral-800 [&_pre]:whitespace-pre [&_pre]:break-words [&_pre]:!p-0\"\n                code={JSON.stringify(JSON.parse(claimMessage?.data.json || \"{}\"), null, 2)}\n                language=\"json\"\n              />\n            </div>\n          </TabsContent>\n        </AutoResizeHeight>\n      </Tabs>\n\n      <div className=\"mt-4 flex justify-end\">\n        <Button\n          disabled={isSuccess}\n          isLoading={isPending}\n          onClick={() => claim()}\n          variant={isSuccess ? \"outline\" : \"primary\"}\n        >\n          {isSuccess && <i className=\"i-mgc-check-circle-filled mr-2 bg-green-500\" />}\n          <span>{t(\"feed_claim_modal.claim_button\")}</span>\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nconst BaseCodeBlock: FC<{\n  children: string\n}> = ({ children }) => (\n  <pre className=\"group relative mt-3 cursor-auto select-text whitespace-pre-line break-words rounded-lg border border-border bg-zinc-100 p-2 text-sm dark:bg-neutral-800\">\n    <code>{children}</code>\n    <CopyButton\n      value={children}\n      className=\"absolute bottom-2 right-2 z-[3] opacity-0 group-hover:opacity-100\"\n    />\n  </pre>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/claim/hooks.ts",
    "content": "import { getFeedById } from \"@follow/store/feed/getter\"\nimport { createElement, useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { FeedClaimModalContent } from \"./feed-claim-modal\"\n\nexport const useFeedClaimModal = () => {\n  const { present } = useModalStack()\n  const { t } = useTranslation()\n\n  return useCallback(\n    ({ feedId }: { feedId?: string }) => {\n      if (!feedId) return\n\n      const feed = getFeedById(feedId)\n\n      if (!feed) return\n\n      present({\n        title: t(\"feed_claim_modal.title\"),\n        content: () => createElement(FeedClaimModalContent, { feedId }),\n      })\n    },\n    [present, t],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/claim/index.ts",
    "content": "export * from \"./feed-claim-modal\"\nexport * from \"./hooks\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/README.md",
    "content": "# Folo Command Abstractions\n\nthis module encapsulates the command abstractions specifically designed for the Folo feature, which is leveraged through CMD-K.\n\nThe architectural design of the API takes inspiration from the [VSCode Command Abstractions](https://code.visualstudio.com/api/references/contribution-points#contributes.commands).\n\nThe registry component has been adapted from the innovative work found in [AFFiNE](https://github.com/toeverything/AFFiNE/blob/de81527e294ee99865ae7218fa4d22ad0660bf34/packages/frontend/core/src/commands/registry/README.md).\n\nFurthermore, the hook design principles draw inspiration from the ideas proposed by [Supabase](https://github.com/supabase/supabase/pull/27044).\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/command-button.test-d.ts",
    "content": "import { assertType, test } from \"vitest\"\n\nimport { CommandActionButton, CommandIdButton } from \"./command-button\"\nimport type { CopyLinkCommand, OpenInBrowserCommand } from \"./commands/entry\"\nimport { COMMAND_ID } from \"./commands/id\"\n\ntest(\"CommandActionButton types\", () => {\n  const mockCommand = {} as OpenInBrowserCommand\n  assertType(\n    CommandActionButton({\n      command: mockCommand,\n      args: [{ entryId: \"\" }],\n    }),\n  )\n\n  assertType(\n    CommandActionButton({\n      command: {} as CopyLinkCommand,\n      args: [\n        {\n          // @ts-expect-error - invalid entryId type\n          entryId: false,\n        },\n      ],\n    }),\n  )\n\n  assertType(\n    CommandActionButton({\n      command: mockCommand,\n      // @ts-expect-error - missing required options\n      args: [],\n    }),\n  )\n\n  assertType(\n    CommandActionButton({\n      command: mockCommand,\n      // @ts-expect-error - invalid args type\n      args: [1],\n    }),\n  )\n\n  assertType(\n    CommandActionButton({\n      command: mockCommand,\n      // @ts-expect-error - redundant args\n      args: [\"\", \"\"],\n    }),\n  )\n\n  assertType(\n    CommandActionButton({\n      command: mockCommand,\n      // @ts-expect-error - invalid args type\n      args: [{}],\n    }),\n  )\n\n  assertType(\n    CommandActionButton({\n      command: mockCommand,\n      // @ts-expect-error - invalid args type\n      args: [\"\"],\n    }),\n  )\n})\n\ntest(\"CommandIdButton types\", () => {\n  const commandId = COMMAND_ID.entry.openInBrowser\n  assertType(\n    CommandIdButton({\n      commandId,\n      args: [{ entryId: \"\" }],\n    }),\n  )\n\n  assertType(\n    CommandIdButton({\n      commandId,\n      // @ts-expect-error - missing required options\n      args: [],\n    }),\n  )\n\n  assertType(\n    CommandIdButton({\n      commandId,\n      // @ts-expect-error - invalid args type\n      args: [1],\n    }),\n  )\n\n  assertType(\n    CommandIdButton({\n      commandId,\n      // @ts-expect-error - invalid args type\n      args: [{}],\n    }),\n  )\n\n  assertType(\n    CommandIdButton({\n      commandId,\n      // @ts-expect-error - invalid args type\n      args: [\"\"],\n    }),\n  )\n\n  assertType(\n    CommandIdButton({\n      commandId,\n      // @ts-expect-error - redundant args\n      args: [\"\", \"\"],\n    }),\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/command-button.tsx",
    "content": "import type { ActionButtonProps } from \"@follow/components/ui/button/index.js\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\n\nimport { useCommand } from \"./hooks/use-command\"\nimport type { FollowCommand, FollowCommandId, FollowCommandMap } from \"./types\"\n\ninterface CommandButtonProps<T extends FollowCommand> extends ActionButtonProps {\n  command: T\n  args: Parameters<T[\"run\"]>\n  shortcut?: string\n}\n\nexport interface CommandIdButtonProps<\n  T extends FollowCommandId = FollowCommandId,\n> extends ActionButtonProps {\n  commandId: T\n  args: Parameters<FollowCommandMap[T][\"run\"]>\n  shortcut?: string\n}\n\n/**\n * @deprecated\n */\nexport const CommandActionButton = <T extends FollowCommand>({\n  command,\n  args,\n  shortcut,\n  ...props\n}: CommandButtonProps<T>) => {\n  return (\n    <ActionButton\n      icon={command.icon}\n      shortcut={shortcut}\n      tooltip={command.label.title}\n      // @ts-expect-error - The type should be discriminated\n      onClick={() => command.run(...args)}\n      {...props}\n    />\n  )\n}\n\n/**\n * @deprecated\n */\nexport const CommandIdButton = <T extends FollowCommandId>({\n  commandId,\n  ...props\n}: CommandIdButtonProps<T>) => {\n  const cmd = useCommand(commandId)\n  if (!cmd) return\n  return <CommandActionButton command={cmd} {...props} />\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/command-manager.ts",
    "content": "import { useRegisterEntryCommands } from \"./commands/entry\"\nimport { useRegisterEntryRenderCommand } from \"./commands/entry-render\"\nimport { useRegisterGlobalCommands } from \"./commands/global\"\nimport { useRegisterIntegrationCommands } from \"./commands/integration\"\nimport { useRegisterLayoutCommands } from \"./commands/layout\"\nimport { useRegisterListCommands } from \"./commands/list\"\nimport { useRegisterSettingsCommands } from \"./commands/settings\"\nimport { useRegisterSubscriptionCommands } from \"./commands/subscription\"\nimport { useRegisterTimelineCommand } from \"./commands/timeline\"\n\nexport const FollowCommandManager = () => {\n  useRegisterSettingsCommands()\n  useRegisterListCommands()\n  useRegisterEntryCommands()\n  useRegisterIntegrationCommands()\n  useRegisterGlobalCommands()\n  useRegisterLayoutCommands()\n  useRegisterTimelineCommand()\n  useRegisterEntryRenderCommand()\n  useRegisterSubscriptionCommands()\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface EventBusMap {\n    \"entry-render:scroll-down\": never\n    \"entry-render:scroll-up\": never\n    \"entry-render:next-entry\": never\n    \"entry-render:previous-entry\": never\n  }\n}\n\nconst category: CommandCategory = \"category.entry_render\"\nexport const useRegisterEntryRenderCommand = () => {\n  const { t } = useTranslation(\"shortcuts\")\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.entryRender.scrollDown,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.entryRender.scrollDown)\n      },\n      category,\n      label: {\n        title: t(\"command.entry.scroll_down.title\"),\n        description: t(\"command.entry.scroll_down.description\"),\n      },\n    },\n    {\n      id: COMMAND_ID.entryRender.scrollUp,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.entryRender.scrollUp)\n      },\n      category,\n      label: {\n        title: t(\"command.entry.scroll_up.title\"),\n        description: t(\"command.entry.scroll_up.description\"),\n      },\n    },\n    {\n      id: COMMAND_ID.entryRender.nextEntry,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.timeline.switchToNext)\n        EventBus.dispatch(COMMAND_ID.entryRender.nextEntry)\n      },\n      category,\n      label: {\n        title: t(\"command.entry.next_entry.title\"),\n        description: t(\"command.entry.next_entry.description\"),\n      },\n    },\n    {\n      id: COMMAND_ID.entryRender.previousEntry,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)\n        EventBus.dispatch(COMMAND_ID.entryRender.previousEntry)\n      },\n      category,\n      label: {\n        title: t(\"command.entry.previous_entry.title\"),\n        description: t(\"command.entry.previous_entry.description\"),\n      },\n    },\n  ])\n}\n\ntype EntryScrollDownCommand = Command<{\n  id: typeof COMMAND_ID.entryRender.scrollDown\n  fn: () => void\n}>\n\ntype EntryScrollUpCommand = Command<{\n  id: typeof COMMAND_ID.entryRender.scrollUp\n  fn: () => void\n}>\n\ntype EntryNextEntryCommand = Command<{\n  id: typeof COMMAND_ID.entryRender.nextEntry\n  fn: () => void\n}>\n\ntype EntryPreviousEntryCommand = Command<{\n  id: typeof COMMAND_ID.entryRender.previousEntry\n  fn: () => void\n}>\n\nexport type EntryRenderCommand =\n  | EntryScrollDownCommand\n  | EntryScrollUpCommand\n  | EntryNextEntryCommand\n  | EntryPreviousEntryCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx",
    "content": "import { getMousePosition } from \"@follow/components/hooks/useMouse.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { isEntryStarred } from \"@follow/store/collection/getter\"\nimport { collectionSyncService } from \"@follow/store/collection/store\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport { entrySyncServices } from \"@follow/store/entry/store\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { cn, resolveUrlWithBase } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { AudioPlayer, getAudioPlayerAtomValue } from \"~/atoms/player\"\nimport { showPopover } from \"~/atoms/popover\"\nimport {\n  getShowSourceContent,\n  toggleShowSourceContent,\n  useSourceContentModal,\n} from \"~/atoms/source-content\"\nimport { SharePanel } from \"~/components/common/SharePanel\"\nimport { toggleEntryReadability } from \"~/hooks/biz/useEntryActions\"\nimport { navigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\nimport { markAllByRoute } from \"~/modules/entry-column/hooks/useMarkAll\"\nimport { useGalleryModal } from \"~/modules/entry-content/hooks\"\nimport { playEntryTts } from \"~/modules/player/entry-tts\"\n\nimport { useRegisterFollowCommand } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\nconst category: CommandCategory = \"category.entry\"\nconst useCollect = () => {\n  const { t } = useTranslation()\n  return useMutation({\n    mutationFn: async ({ entryId, view }: { entryId: string; view: FeedViewType }) => {\n      const { isCollection } = getRouteParams()\n      return collectionSyncService.starEntry({\n        entryId,\n        view,\n        invalidate: !isCollection,\n      })\n    },\n    onSuccess: () => {\n      toast.success(t(\"entry_actions.starred\"), {\n        duration: 1000,\n      })\n    },\n  })\n}\n\nconst useUnCollect = () => {\n  const { t } = useTranslation()\n  return useMutation({\n    mutationFn: async (entryId: string) => {\n      const { isCollection } = getRouteParams()\n      return collectionSyncService.unstarEntry({ entryId, invalidate: !isCollection })\n    },\n\n    onSuccess: () => {\n      toast.success(t(\"entry_actions.unstarred\"), {\n        duration: 1000,\n      })\n    },\n  })\n}\n\nconst useDeleteInboxEntry = () => {\n  const { t } = useTranslation()\n  return useMutation({\n    mutationFn: async (entryId: string) => {\n      await entrySyncServices.deleteInboxEntry(entryId)\n    },\n    onSuccess: () => {\n      toast.success(t(\"entry_actions.deleted\"))\n    },\n    onError: () => {\n      toast.error(t(\"entry_actions.failed_to_delete\"))\n    },\n  })\n}\n\nexport const useRead = () =>\n  useMutation({\n    mutationFn: async ({ entryId }: { entryId: string }) =>\n      unreadSyncService.markEntryAsRead(entryId),\n  })\n\nexport const useUnread = () =>\n  useMutation({\n    mutationFn: async ({ entryId }: { entryId: string }) =>\n      unreadSyncService.markEntryAsUnread(entryId),\n  })\n\nexport const useRegisterEntryCommands = () => {\n  const { t } = useTranslation()\n  const collect = useCollect()\n  const uncollect = useUnCollect()\n  const deleteInboxEntry = useDeleteInboxEntry()\n  const showSourceContentModal = useSourceContentModal()\n  const openGalleryModal = useGalleryModal()\n  const read = useRead()\n  const unread = useUnread()\n\n  useRegisterFollowCommand(\n    [\n      {\n        id: COMMAND_ID.entry.star,\n        label: t(\"entry_actions.star\"),\n        category,\n        icon: (props) => (\n          <i\n            className={cn(\n              props?.isActive ? \"i-mgc-star-cute-fi text-orange-500\" : \"i-mgc-star-cute-re\",\n            )}\n          />\n        ),\n        run: ({ entryId, view }) => {\n          const entry = getEntry(entryId)\n          const isStarred = isEntryStarred(entryId)\n          if (!entry) {\n            toast.error(\"Failed to star: entry is not available\", { duration: 3000 })\n            return\n          }\n\n          if (isStarred) {\n            uncollect.mutate(entry.id)\n          } else {\n            collect.mutate({ entryId, view })\n          }\n        },\n      },\n      {\n        id: COMMAND_ID.entry.delete,\n        label: t(\"entry_actions.delete\"),\n        icon: <i className=\"i-mgc-delete-2-cute-re\" />,\n        category,\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n          if (!entry) {\n            toast.error(\"Failed to delete: entry is not available\", { duration: 3000 })\n            return\n          }\n          deleteInboxEntry.mutate(entry.id)\n        },\n      },\n      {\n        id: COMMAND_ID.entry.copyLink,\n        label: t(\"entry_actions.copy_link\"),\n        icon: <i className=\"i-mgc-link-cute-re\" />,\n        category,\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n          if (!entry) {\n            toast.error(\"Failed to copy link: entry is not available\", { duration: 3000 })\n            return\n          }\n          if (!entry.url) return\n          copyToClipboard(entry.url)\n          toast(t(\"entry_actions.copied_notify\", { which: t(\"words.link\") }), {\n            duration: 1000,\n          })\n        },\n      },\n      {\n        id: COMMAND_ID.entry.exportAsPDF,\n        label: t(\"entry_actions.export_as_pdf\"),\n        icon: <i className=\"i-mgc-pdf-cute-re\" />,\n        category,\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n\n          if (!entry) {\n            toast.error(\"Failed to export as pdf: entry is not available\", { duration: 3000 })\n            return\n          }\n\n          window.print()\n        },\n      },\n      {\n        id: COMMAND_ID.entry.copyTitle,\n        label: t(\"entry_actions.copy_title\"),\n        icon: <i className=\"i-mgc-copy-cute-re\" />,\n        category,\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n          if (!entry) {\n            toast.error(\"Failed to copy link: entry is not available\", { duration: 3000 })\n            return\n          }\n          if (!entry.title) return\n          copyToClipboard(entry.title)\n          toast(t(\"entry_actions.copied_notify\", { which: t(\"words.title\") }), {\n            duration: 1000,\n          })\n        },\n      },\n      {\n        id: COMMAND_ID.entry.openInBrowser,\n        label: t(\"entry_actions.open_in_browser\", {\n          which: t(IN_ELECTRON ? \"words.browser\" : \"words.newTab\"),\n        }),\n        category,\n        icon: <i className=\"i-mgc-world-2-cute-re\" />,\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n          if (!entry || !entry.url) {\n            toast.error(\"Failed to open in browser: url is not available\", { duration: 3000 })\n            return\n          }\n          window.open(entry.url, \"_blank\")\n        },\n      },\n      {\n        id: COMMAND_ID.entry.viewSourceContent,\n        label: {\n          title: t(\"entry_actions.view_source_content\"),\n          description: t(\"entry_actions.view_source_content_description\"),\n        },\n        icon: <i className=\"i-mgc-web-cute-re\" />,\n        category,\n        run: ({ entryId, siteUrl }) => {\n          if (!getShowSourceContent()) {\n            const entry = getEntry(entryId)\n            if (!entry || !entry.url) {\n              toast.error(\"Failed to view source content: url is not available\", { duration: 3000 })\n              return\n            }\n            const routeParams = getRouteParams()\n            const viewPreviewInModal = [\n              FeedViewType.SocialMedia,\n              FeedViewType.Videos,\n              FeedViewType.Pictures,\n            ].includes(routeParams.view)\n            if (viewPreviewInModal) {\n              showSourceContentModal({\n                title: entry.title ?? undefined,\n                src: siteUrl ? resolveUrlWithBase(entry.url, siteUrl) : entry.url,\n              })\n              return\n            }\n            const layoutEntryId = routeParams.entryId\n            if (layoutEntryId !== entry.id) {\n              navigateEntry({ entryId: entry.id })\n            }\n          }\n          toggleShowSourceContent()\n        },\n      },\n      {\n        id: COMMAND_ID.entry.share,\n        label: t(\"entry_actions.share\"),\n        icon: <i className=\"i-mgc-share-forward-cute-re\" />,\n        category,\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n          if (!entry || !entry.url) {\n            toast.error(\"Failed to share: url is not available\", { duration: 3000 })\n            return\n          }\n\n          const xy = getMousePosition()\n          showPopover(\n            {\n              x: xy.x,\n              y: xy.y + 20,\n            },\n            <SharePanel entryId={entry.id} />,\n          )\n        },\n      },\n      {\n        id: COMMAND_ID.entry.readAbove,\n        label: t(\"entry_actions.mark_above_as_read\"),\n        category,\n        run: ({ publishedAt }: { publishedAt: string }) => {\n          return markAllByRoute(getRouteParams(), {\n            startTime: new Date(publishedAt).getTime() + 1,\n            endTime: Date.now(),\n          })\n        },\n      },\n      {\n        id: COMMAND_ID.entry.read,\n        label: t(\"entry_actions.mark_as_read\"),\n        category,\n        icon: (props) => (\n          <i className={cn(props?.isActive ? \"i-mgc-round-cute-re\" : \"i-mgc-round-cute-fi\")} />\n        ),\n        run: ({ entryId }) => {\n          const entry = getEntry(entryId)\n          if (!entry) {\n            toast.error(\"Failed to mark as unread: feed is not available\", { duration: 3000 })\n            return\n          }\n          if (entry.read) {\n            unread.mutate({ entryId })\n          } else {\n            read.mutate({ entryId })\n          }\n        },\n      },\n      {\n        id: COMMAND_ID.entry.readBelow,\n        label: t(\"entry_actions.mark_below_as_read\"),\n        category,\n        run: ({ publishedAt }: { publishedAt: string }) => {\n          return markAllByRoute(getRouteParams(), {\n            startTime: 1,\n            endTime: new Date(publishedAt).getTime() - 1,\n          })\n        },\n      },\n      {\n        id: COMMAND_ID.entry.imageGallery,\n        label: {\n          title: t(\"entry_actions.image_gallery\"),\n          description: t(\"entry_actions.image_gallery_description\"),\n        },\n        icon: <i className=\"i-mgc-pic-cute-fi\" />,\n        category,\n        run: ({ entryId }) => {\n          openGalleryModal(entryId)\n        },\n      },\n      {\n        id: COMMAND_ID.entry.tts,\n        label: {\n          title: t(\"entry_content.header.play_tts\"),\n          description: t(\"entry_content.header.play_tts_description\"),\n        },\n        category,\n        icon: <i className=\"i-mgc-voice-cute-re\" />,\n        run: async ({ entryId }) => {\n          if (getAudioPlayerAtomValue().entryId === entryId) {\n            AudioPlayer.togglePlayAndPause()\n            return\n          }\n\n          await playEntryTts(entryId, {\n            toastTitle: t(\"entry_content.header.play_tts\"),\n          })\n        },\n      },\n      {\n        id: COMMAND_ID.entry.readability,\n        category,\n        label: {\n          title: t(\"entry_content.header.readability\"),\n          description: t(\"entry_content.header.readability_description\"),\n        },\n        icon: (props) => (\n          <i className={props?.isActive ? \"i-mgc-docment-cute-fi\" : \"i-mgc-docment-cute-re\"} />\n        ),\n        run: async ({ entryId, entryUrl }) => {\n          return toggleEntryReadability({\n            id: entryId,\n            url: entryUrl,\n          })\n        },\n      },\n    ],\n    {},\n  )\n}\n\nexport type StarCommand = Command<{\n  id: typeof COMMAND_ID.entry.star\n  fn: (data: { entryId: string; view: FeedViewType }) => void\n}>\n\nexport type DeleteCommand = Command<{\n  id: typeof COMMAND_ID.entry.delete\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type CopyLinkCommand = Command<{\n  id: typeof COMMAND_ID.entry.copyLink\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type ExportAsPDFCommand = Command<{\n  id: typeof COMMAND_ID.entry.exportAsPDF\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type CopyTitleCommand = Command<{\n  id: typeof COMMAND_ID.entry.copyTitle\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type OpenInBrowserCommand = Command<{\n  id: typeof COMMAND_ID.entry.openInBrowser\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type ViewSourceContentCommand = Command<{\n  id: typeof COMMAND_ID.entry.viewSourceContent\n  fn: (data: { entryId: string; siteUrl?: string | null | undefined }) => void\n}>\n\nexport type ShareCommand = Command<{\n  id: typeof COMMAND_ID.entry.share\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type ReadCommand = Command<{\n  id: typeof COMMAND_ID.entry.read\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type ReadAboveCommand = Command<{\n  id: typeof COMMAND_ID.entry.readAbove\n  fn: (data: { publishedAt: string }) => void\n}>\n\nexport type ReadBelowCommand = Command<{\n  id: typeof COMMAND_ID.entry.readBelow\n  fn: (data: { publishedAt: string }) => void\n}>\n\nexport type ToggleAITranslationCommand = Command<{\n  id: typeof COMMAND_ID.entry.toggleAITranslation\n  fn: () => void\n}>\n\nexport type ImageGalleryCommand = Command<{\n  id: typeof COMMAND_ID.entry.imageGallery\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type TTSCommand = Command<{\n  id: typeof COMMAND_ID.entry.tts\n  fn: (data: { entryId: string }) => void\n}>\n\nexport type ReadabilityCommand = Command<{\n  id: typeof COMMAND_ID.entry.readability\n  fn: (data: { entryId: string; entryUrl: string }) => void\n}>\n\nexport type EntryCommand =\n  | StarCommand\n  | DeleteCommand\n  | CopyLinkCommand\n  | ExportAsPDFCommand\n  | CopyTitleCommand\n  | OpenInBrowserCommand\n  | ViewSourceContentCommand\n  | ShareCommand\n  | ReadCommand\n  | ReadAboveCommand\n  | ReadBelowCommand\n  | ToggleAITranslationCommand\n  | ImageGalleryCommand\n  | TTSCommand\n  | ReadabilityCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/global.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setAppSearchOpen } from \"~/atoms/app\"\nimport { getAIPanelVisibility, setAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport { useShortcutsModal } from \"~/modules/modal/hooks/useShortcutsModal\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface EventBusMap {\n    \"global:toggle-corner-play\": void\n    \"global:quick-add\": void\n  }\n}\n\nconst category: CommandCategory = \"category.global\"\nexport const useRegisterGlobalCommands = () => {\n  const showShortcuts = useShortcutsModal()\n  const { t } = useTranslation(\"shortcuts\")\n\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.global.showShortcuts,\n      label: {\n        title: t(\"command.global.show_shortcuts.title\"),\n        description: t(\"command.global.show_shortcuts.description\"),\n      },\n\n      run: () => {\n        showShortcuts()\n      },\n      category,\n    },\n    {\n      id: COMMAND_ID.global.toggleCornerPlay,\n      label: {\n        title: t(\"command.global.toggle_corner_play.title\"),\n        description: t(\"command.global.toggle_corner_play.description\"),\n      },\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.global.toggleCornerPlay)\n      },\n      category,\n    },\n    {\n      id: COMMAND_ID.global.quickAdd,\n      label: {\n        title: t(\"command.global.quick_add.title\"),\n        description: t(\"command.global.quick_add.description\"),\n      },\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.global.quickAdd)\n      },\n      category,\n    },\n\n    {\n      id: COMMAND_ID.global.quickSearch,\n      label: {\n        title: t(\"command.global.quick_search.title\"),\n        description: t(\"command.global.quick_search.description\"),\n      },\n      run: () => {\n        setAppSearchOpen(true)\n      },\n      category,\n    },\n\n    {\n      id: COMMAND_ID.global.toggleAIChat,\n      label: {\n        title: t(\"command.global.toggle_ai_chat.title\"),\n        description: t(\"command.global.toggle_ai_chat.description\"),\n      },\n      run: () => {\n        const isVisible = getAIPanelVisibility()\n        setAIPanelVisibility(!isVisible)\n      },\n\n      category,\n    },\n  ])\n}\n\nexport type ShowShortcutsCommand = Command<{\n  id: typeof COMMAND_ID.global.showShortcuts\n  fn: () => void\n}>\n\nexport type ToggleCornerPlayCommand = Command<{\n  id: typeof COMMAND_ID.global.toggleCornerPlay\n  fn: () => void\n}>\n\nexport type QuickAddCommand = Command<{\n  id: typeof COMMAND_ID.global.quickAdd\n  fn: () => void\n}>\n\nexport type ToggleAIChatCommand = Command<{\n  id: typeof COMMAND_ID.global.toggleAIChat\n  fn: (ctx?: { entryId?: string }) => void\n}>\n\nexport type QuickSearchCommand = Command<{\n  id: typeof COMMAND_ID.global.quickSearch\n  fn: () => void\n}>\n\nexport type GlobalCommand =\n  | ShowShortcutsCommand\n  | ToggleCornerPlayCommand\n  | QuickAddCommand\n  | ToggleAIChatCommand\n  | QuickSearchCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/id.ts",
    "content": "export const COMMAND_ID = {\n  entry: {\n    read: \"entry:read\",\n    readAbove: \"entry:read-above\",\n    readBelow: \"entry:read-below\",\n    viewSourceContent: \"entry:view-source-content\",\n    readability: \"entry:ability\",\n    openInBrowser: \"entry:open-in-browser\",\n    star: \"entry:star\",\n    toggleAITranslation: \"entry:toggle-ai-translation\",\n    imageGallery: \"entry:image-gallery\",\n    copyLink: \"entry:copy-link\",\n    copyTitle: \"entry:copy-title\",\n    tts: \"entry:tts\",\n    exportAsPDF: \"entry:export-as-pdf\",\n    delete: \"entry:delete\",\n    share: \"entry:share\",\n  },\n  integration: {\n    saveToEagle: \"integration:save-to-eagle\",\n    saveToReadwise: \"integration:save-to-readwise\",\n    saveToInstapaper: \"integration:save-to-instapaper\",\n    saveToObsidian: \"integration:save-to-obsidian\",\n    saveToOutline: \"integration:save-to-outline\",\n    saveToReadeck: \"integration:save-to-readeck\",\n    saveToCubox: \"integration:save-to-cubox\",\n    saveToZotero: \"integration:save-to-zotero\",\n    saveToQBittorrent: \"integration:save-to-qbittorrent\",\n\n    custom: \"integration:custom\",\n  },\n  list: {\n    edit: \"list:edit\",\n    unfollow: \"list:unfollow\",\n    navigateTo: \"list:navigate-to\",\n    openInBrowser: \"list:open-in-browser\",\n    copyUrl: \"list:copy-url\",\n    copyId: \"list:copy-id\",\n  },\n  settings: {\n    changeThemeToAuto: \"follow:change-color-mode-to-auto\",\n    changeThemeToDark: \"follow:change-color-mode-to-dark\",\n    changeThemeToLight: \"follow:change-color-mode-to-light\",\n    customizeToolbar: \"follow:customize-toolbar\",\n  },\n  global: {\n    showShortcuts: \"global:show-shortcuts\",\n    toggleCornerPlay: \"global:toggle-corner-play\",\n    quickAdd: \"global:quick-add\",\n    toggleAIChat: \"global:toggle-ai-chat\",\n    quickSearch: \"global:quick-search\",\n  },\n  layout: {\n    toggleSubscriptionColumn: \"layout:toggle-subscription-column\",\n    focusToTimeline: \"layout:focus-to-timeline\",\n    focusToSubscription: \"layout:focus-to-subscription\",\n    focusToEntryRender: \"layout:focus-to-entry-render\",\n  },\n  timeline: {\n    switchToNext: \"timeline:switch-to-next\",\n    switchToPrevious: \"timeline:switch-to-previous\",\n    refetch: \"timeline:refetch\",\n    unreadOnly: \"timeline:unread-only\",\n  },\n  entryRender: {\n    scrollDown: \"entry-render:scroll-down\",\n    scrollUp: \"entry-render:scroll-up\",\n    nextEntry: \"entry-render:next-entry\",\n    previousEntry: \"entry-render:previous-entry\",\n  },\n  subscription: {\n    switchTabToNext: \"subscription:switch-tab-to-next\",\n    switchTabToPrevious: \"subscription:switch-tab-to-previous\",\n    switchTabToArticle: \"subscription:switch-tab-to-article\",\n    switchTabToSocial: \"subscription:switch-tab-to-social\",\n    switchTabToPicture: \"subscription:switch-tab-to-picture\",\n    switchTabToVideo: \"subscription:switch-tab-to-video\",\n    switchTabToAudio: \"subscription:switch-tab-to-audio\",\n    switchTabToNotification: \"subscription:switch-tab-to-notification\",\n\n    nextSubscription: \"subscription:next\",\n    previousSubscription: \"subscription:previous\",\n\n    toggleFolderCollapse: \"subscription:toggle-folder-collapse\",\n    markAllAsRead: \"subscription:mark-all-as-read\",\n    openInBrowser: \"subscription:open-in-browser\",\n    openSiteInBrowser: \"subscription:open-site-in-browser\",\n  },\n} as const\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx",
    "content": "import {\n  SimpleIconsCubox,\n  SimpleIconsEagle,\n  SimpleIconsInstapaper,\n  SimpleIconsObsidian,\n  SimpleIconsOutline,\n  SimpleIconsReadeck,\n  SimpleIconsReadwise,\n  SimpleIconsZotero,\n} from \"@follow/components/ui/platform-icon/icons.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { getSummary } from \"@follow/store/summary/getters\"\nimport { tracker } from \"@follow/tracker\"\nimport { useMutation, useQuery } from \"@tanstack/react-query\"\nimport type { FetchError } from \"ofetch\"\nimport { ofetch } from \"ofetch\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { getReadabilityStatus, ReadabilityStatus } from \"~/atoms/readability\"\nimport { getActionLanguage } from \"~/atoms/settings/general\"\nimport { getIntegrationSettings, useIntegrationSettingKey } from \"~/atoms/settings/integration\"\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { ipcServices } from \"~/lib/client\"\nimport { parseHtml } from \"~/lib/parse-html\"\nimport { CustomIntegrationManager } from \"~/modules/integration/custom-integration-manager\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport { defineFollowCommand } from \"../registry/command\"\nimport type { Command, CommandCategory, FollowCommandId } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\nexport const useRegisterIntegrationCommands = () => {\n  useRegisterEagleCommands()\n  useRegisterReadwiseCommands()\n  useRegisterInstapaperCommands()\n  useRegisterObsidianCommands()\n  useRegisterOutlineCommands()\n  useRegisterReadeckCommands()\n  useRegisterCuboxCommands()\n  useRegisterZoteroCommands()\n  useRegisterQBittorrentCommands()\n  useRegisterCustomIntegrationCommands()\n}\n\nconst category: CommandCategory = \"category.integration\"\nconst useRegisterEagleCommands = () => {\n  const { t } = useTranslation()\n  const { view } = useRouteParams()\n\n  const enableEagle = useIntegrationSettingKey(\"enableEagle\")\n\n  const checkEagle = useQuery({\n    queryKey: [\"check-eagle\"],\n    enabled: ELECTRON && enableEagle && view !== undefined,\n    queryFn: async () => {\n      try {\n        await ofetch(\"http://localhost:41595\", {\n          mode: \"no-cors\",\n        })\n        return true\n      } catch (error: unknown) {\n        return (error as FetchError).data?.code === 401\n      }\n    },\n    refetchOnMount: false,\n    refetchOnWindowFocus: false,\n  })\n\n  const isEagleAvailable = enableEagle && (checkEagle.isLoading ? false : !!checkEagle.data)\n\n  useRegisterCommandEffect(\n    !isEagleAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToEagle,\n          label: t(\"entry_actions.save_media_to_eagle\"),\n          icon: <SimpleIconsEagle />,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Eagle: entry is not available\", {\n                duration: 3000,\n              })\n              return\n            }\n            if (!entry.url || !entry.media?.length) {\n              toast.error('Failed to save to Eagle: \"url\" or \"media\" is not available', {\n                duration: 3000,\n              })\n              return\n            }\n            const response = await ipcServices?.integration.saveToEagle({\n              url: entry.url,\n              mediaUrls: entry.media.map((m) => m.url),\n            })\n            if (response?.status === \"success\") {\n              toast.success(t(\"entry_actions.saved_to_eagle\"), {\n                duration: 3000,\n              })\n            } else {\n              toast.error(t(\"entry_actions.failed_to_save_to_eagle\"), {\n                duration: 3000,\n              })\n            }\n          },\n        }),\n    {\n      deps: [isEagleAvailable],\n    },\n  )\n}\n\nconst useRegisterReadwiseCommands = () => {\n  const { t } = useTranslation()\n\n  const enableReadwise = useIntegrationSettingKey(\"enableReadwise\")\n  const readwiseToken = useIntegrationSettingKey(\"readwiseToken\")\n\n  const isReadwiseAvailable = enableReadwise && !!readwiseToken\n\n  useRegisterCommandEffect(\n    !isReadwiseAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToReadwise,\n          label: t(\"entry_actions.save_to_readwise\"),\n          icon: <SimpleIconsReadwise />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Readwise: entry is not available\", { duration: 3000 })\n              return\n            }\n            try {\n              tracker.integration({\n                type: \"readwise\",\n                event: \"save\",\n              })\n              const data = await ofetch(\"https://readwise.io/api/v3/save/\", {\n                method: \"POST\",\n                headers: {\n                  Authorization: `Token ${readwiseToken}`,\n                },\n                body: {\n                  url: entry.url,\n                  html: entry.content || undefined,\n                  title: entry.title || undefined,\n                  author: entry.author || undefined,\n                  summary: entry.description || undefined,\n                  published_date: entry.publishedAt || undefined,\n                  image_url: entry.media?.[0]?.url || undefined,\n                  saved_using: \"Follow\",\n                },\n              })\n\n              toast.success(\n                <>\n                  {t(\"entry_actions.saved_to_readwise\")},{\" \"}\n                  <a target=\"_blank\" className=\"underline\" href={data.url}>\n                    view\n                  </a>\n                </>,\n                {\n                  duration: 3000,\n                },\n              )\n            } catch {\n              toast.error(t(\"entry_actions.failed_to_save_to_readwise\"), {\n                duration: 3000,\n              })\n            }\n          },\n        }),\n    {\n      deps: [isReadwiseAvailable, readwiseToken],\n    },\n  )\n}\n\nconst useRegisterInstapaperCommands = () => {\n  const { t } = useTranslation()\n\n  const enableInstapaper = useIntegrationSettingKey(\"enableInstapaper\")\n  const instapaperUsername = useIntegrationSettingKey(\"instapaperUsername\")\n  const instapaperPassword = useIntegrationSettingKey(\"instapaperPassword\")\n\n  const isInstapaperAvailable = enableInstapaper && !!instapaperPassword && !!instapaperUsername\n\n  useRegisterCommandEffect(\n    !isInstapaperAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToInstapaper,\n          label: t(\"entry_actions.save_to_instapaper\"),\n          icon: <SimpleIconsInstapaper />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Instapaper: entry is not available\", {\n                duration: 3000,\n              })\n              return\n            }\n\n            try {\n              tracker.integration({\n                type: \"instapaper\",\n                event: \"save\",\n              })\n              const data = await ofetch(\"https://www.instapaper.com/api/add\", {\n                query: {\n                  url: entry.url,\n                  title: entry.title,\n                },\n                method: \"POST\",\n                headers: {\n                  Authorization: `Basic ${btoa(`${instapaperUsername}:${instapaperPassword}`)}`,\n                },\n                parseResponse: JSON.parse,\n              })\n\n              toast.success(\n                <>\n                  {t(\"entry_actions.saved_to_instapaper\")},{\" \"}\n                  <a\n                    target=\"_blank\"\n                    className=\"underline\"\n                    href={`https://www.instapaper.com/read/${data.bookmark_id}`}\n                  >\n                    view\n                  </a>\n                </>,\n                {\n                  duration: 3000,\n                },\n              )\n            } catch {\n              toast.error(t(\"entry_actions.failed_to_save_to_instapaper\"), {\n                duration: 3000,\n              })\n            }\n          },\n        }),\n    {\n      deps: [isInstapaperAvailable, instapaperUsername, instapaperPassword],\n    },\n  )\n}\n\nconst getEntryContentAsMarkdown = async (entry: EntryModel) => {\n  const isReadabilityReady = getReadabilityStatus()[entry.id] === ReadabilityStatus.SUCCESS\n  const content = (isReadabilityReady ? entry.readabilityContent || \"\" : entry.content) || \"\"\n  const [toMarkdown, toMdast, gfmTableToMarkdown] = await Promise.all([\n    import(\"mdast-util-to-markdown\").then((m) => m.toMarkdown),\n    import(\"hast-util-to-mdast\").then((m) => m.toMdast),\n    import(\"mdast-util-gfm-table\").then((m) => m.gfmTableToMarkdown),\n  ])\n  return toMarkdown(toMdast(parseHtml(content).hastTree), {\n    extensions: [gfmTableToMarkdown()],\n  })\n}\n\nconst useRegisterObsidianCommands = () => {\n  const { t } = useTranslation()\n\n  const enableObsidian = useIntegrationSettingKey(\"enableObsidian\")\n  const obsidianVaultPath = useIntegrationSettingKey(\"obsidianVaultPath\")\n  const isObsidianAvailable = enableObsidian && !!obsidianVaultPath\n\n  const saveToObsidian = useMutation({\n    mutationKey: [\"save-to-obsidian\"],\n    mutationFn: async (data: {\n      url: string\n      title: string\n      content: string\n      author: string\n      publishedAt: string\n      vaultPath: string\n    }) => {\n      return await ipcServices?.integration.saveToObsidian(data)\n    },\n    onSuccess: (data) => {\n      if (data?.success) {\n        toast.success(t(\"entry_actions.saved_to_obsidian\"), {\n          duration: 3000,\n        })\n      } else {\n        toast.error(`${t(\"entry_actions.failed_to_save_to_obsidian\")}: ${data?.error}`, {\n          duration: 3000,\n        })\n      }\n    },\n  })\n\n  useRegisterCommandEffect(\n    !IN_ELECTRON || !isObsidianAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToObsidian,\n          label: t(\"entry_actions.save_to_obsidian\"),\n          icon: <SimpleIconsObsidian />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Obsidian: entry is not available\", { duration: 3000 })\n              return\n            }\n            const markdownContent = await getEntryContentAsMarkdown(entry)\n            tracker.integration({\n              type: \"obsidian\",\n              event: \"save\",\n            })\n            saveToObsidian.mutate({\n              url: entry.url || \"\",\n              title: entry.title || \"\",\n              content: markdownContent,\n              author: entry.author || \"\",\n              publishedAt: entry.publishedAt.toISOString() || \"\",\n              vaultPath: obsidianVaultPath,\n            })\n          },\n        }),\n    {\n      deps: [isObsidianAvailable, obsidianVaultPath],\n    },\n  )\n}\n\nconst useRegisterOutlineCommands = () => {\n  const { t } = useTranslation()\n\n  const enableOutline = useIntegrationSettingKey(\"enableOutline\")\n  const outlineEndpoint = useIntegrationSettingKey(\"outlineEndpoint\")\n  const outlineToken = useIntegrationSettingKey(\"outlineToken\")\n  const outlineCollection = useIntegrationSettingKey(\"outlineCollection\")\n  const outlineAvailable =\n    enableOutline && !!outlineToken && !!outlineEndpoint && !!outlineCollection\n\n  useRegisterCommandEffect(\n    !IN_ELECTRON || !outlineAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToOutline,\n          label: t(\"entry_actions.save_to_outline\"),\n          icon: <SimpleIconsOutline />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Outline: entry is not available\", { duration: 3000 })\n              return\n            }\n\n            try {\n              const request = async (method: string, params: Record<string, unknown>) => {\n                return await ofetch(`${outlineEndpoint.replace(/\\/$/, \"\")}/${method}`, {\n                  method: \"POST\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                    Authorization: `Bearer ${outlineToken}`,\n                  },\n                  body: params,\n                })\n              }\n              let collectionId = outlineCollection\n              if (!/^[a-f\\d]{8}(?:-[a-f\\d]{4}){3}-[a-f\\d]{12}$/i.test(collectionId)) {\n                const collection = await request(\"collections.info\", {\n                  id: collectionId,\n                })\n                collectionId = collection.data.id\n              }\n              const markdownContent = await getEntryContentAsMarkdown(entry)\n              await request(\"documents.create\", {\n                title: entry.title,\n                text: markdownContent,\n                collectionId,\n                publish: true,\n              })\n              toast.success(t(\"entry_actions.saved_to_outline\"), {\n                duration: 3000,\n              })\n            } catch {\n              toast.error(t(\"entry_actions.failed_to_save_to_outline\"), {\n                duration: 3000,\n              })\n            }\n          },\n        }),\n    {\n      deps: [outlineAvailable, outlineToken, outlineEndpoint, outlineCollection],\n    },\n  )\n}\n\nconst useRegisterReadeckCommands = () => {\n  const { t } = useTranslation()\n\n  const enableReadeck = useIntegrationSettingKey(\"enableReadeck\")\n  const readeckEndpoint = useIntegrationSettingKey(\"readeckEndpoint\")\n  const readeckToken = useIntegrationSettingKey(\"readeckToken\")\n  const readeckAvailable = enableReadeck && !!readeckEndpoint && !!readeckToken\n\n  useRegisterCommandEffect(\n    !readeckAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToReadeck,\n          label: t(\"entry_actions.save_to_readeck\"),\n          icon: <SimpleIconsReadeck />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Readeck: entry is not available\", { duration: 3000 })\n              return\n            }\n            try {\n              tracker.integration({\n                type: \"readeck\",\n                event: \"save\",\n              })\n              const data = new FormData()\n              if (entry.url) {\n                data.set(\"url\", entry.url)\n              }\n              if (entry.title) {\n                data.set(\"title\", entry.title)\n              }\n              const response = await ofetch.raw(\n                `${readeckEndpoint.replace(/\\/$/, \"\")}/api/bookmarks`,\n                {\n                  method: \"POST\",\n                  body: data,\n                  headers: {\n                    Authorization: `Bearer ${readeckToken}`,\n                  },\n                },\n              )\n\n              toast.success(\n                <>\n                  {t(\"entry_actions.saved_to_readeck\")},{\" \"}\n                  <a target=\"_blank\" className=\"underline\" href={response.headers.get(\"Location\")!}>\n                    view\n                  </a>\n                </>,\n                {\n                  duration: 3000,\n                },\n              )\n            } catch {\n              toast.error(t(\"entry_actions.failed_to_save_to_readeck\"), {\n                duration: 3000,\n              })\n            }\n          },\n        }),\n    {\n      deps: [readeckAvailable, readeckToken, readeckEndpoint],\n    },\n  )\n}\n\nconst useRegisterCuboxCommands = () => {\n  const { t } = useTranslation()\n\n  const enableCubox = useIntegrationSettingKey(\"enableCubox\")\n  const cuboxToken = useIntegrationSettingKey(\"cuboxToken\")\n  const enableCuboxAutoMemo = useIntegrationSettingKey(\"enableCuboxAutoMemo\")\n  const cuboxAvailable = enableCubox && !!cuboxToken\n\n  useRegisterCommandEffect(\n    !cuboxAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToCubox,\n          label: t(\"entry_actions.save_to_cubox\"),\n          icon: <SimpleIconsCubox />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Cubox: entry is not available\", { duration: 3000 })\n              return\n            }\n            try {\n              tracker.integration({\n                type: \"cubox\",\n                event: \"save\",\n              })\n\n              const selectedText = window.getSelection()?.toString() || \"\"\n\n              const requestBody =\n                selectedText && enableCuboxAutoMemo\n                  ? buildMemoRequestBody(entry, selectedText)\n                  : buildUrlRequestBody(entry)\n\n              await ofetch(cuboxToken, {\n                method: \"POST\",\n                body: requestBody,\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n              })\n\n              toast.success(t(\"entry_actions.saved_to_cubox\"), {\n                duration: 3000,\n              })\n            } catch (error) {\n              toast.error(\n                `${t(\"entry_actions.failed_to_save_to_cubox\")}: ${(error as FetchError)?.message || \"\"}`,\n                {\n                  duration: 3000,\n                },\n              )\n            }\n          },\n        }),\n    {\n      deps: [cuboxAvailable, cuboxToken, enableCuboxAutoMemo],\n    },\n  )\n}\n\nconst useRegisterZoteroCommands = () => {\n  const { t } = useTranslation()\n\n  const enableZotero = useIntegrationSettingKey(\"enableZotero\")\n  const zoteroUserID = useIntegrationSettingKey(\"zoteroUserID\")\n  const zoteroToken = useIntegrationSettingKey(\"zoteroToken\")\n  const zoterAvailable = enableZotero && !!zoteroUserID && !!zoteroToken\n\n  // GET https://api.zotero.org/items/new?itemType=webpage\n  const buildZoteroWebpageRequestBody = (entry: EntryModel) => {\n    // Zotero API only support ISO 8601 format and without millsecond\n    const accessDate = `${entry.insertedAt.toISOString().slice(0, 19)}Z`\n    // should return an array, because this API endpoint also support multi-item upload\n    return [\n      {\n        itemType: \"webpage\",\n        title: entry.title || \"\",\n        creators: [\n          {\n            creatorType: \"author\",\n            firstName: entry.author || \"\",\n            lastName: \"\",\n          },\n        ],\n        abstractNote: entry.description || \"\",\n        websiteTitle: entry.title || \"\",\n        websiteType: \"\",\n        date: entry.publishedAt || \"\",\n        shortTitle: \"\",\n        url: entry.url || \"\",\n        accessDate: accessDate || \"\",\n        language: entry.language || \"\",\n        rights: \"\",\n        extra: \"\",\n        tags: [],\n        collections: [],\n        relations: {},\n      },\n    ]\n  }\n\n  useRegisterCommandEffect(\n    !zoterAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToZotero,\n          label: t(\"entry_actions.save_to_zotero\"),\n          icon: <SimpleIconsZotero />,\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to Zotero: entry is not available\", { duration: 3000 })\n              return\n            }\n            try {\n              tracker.integration({\n                type: \"zotero\",\n                event: \"save\",\n              })\n\n              const requestBody = buildZoteroWebpageRequestBody(entry)\n\n              const response = await ofetch(`https://api.zotero.org/users/${zoteroUserID}/items`, {\n                method: \"POST\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                  \"Zotero-API-Key\": zoteroToken,\n                },\n                body: requestBody,\n              })\n\n              if (response.failed && Object.keys(response.failed).length > 0) {\n                response.failed.forEach((failedObj) => {\n                  toast.error(failedObj.message, { duration: 3000 })\n                })\n              }\n              if (response.success && Object.keys(response.success).length > 0) {\n                toast.success(t(\"entry_actions.saved_to_zotero\"), {\n                  duration: 3000,\n                })\n              }\n            } catch (error) {\n              const errorObj = error as FetchError\n              switch (errorObj.statusCode) {\n                case 400: {\n                  toast.error(\n                    `${t(\"entry_actions.failed_to_save_to_zotero\")}: Invalid type/field; unparseable JSON`,\n                    {\n                      duration: 3000,\n                    },\n                  )\n\n                  break\n                }\n                case 409: {\n                  toast.error(\n                    `${t(\"entry_actions.failed_to_save_to_zotero\")}: The target library is locked.`,\n                    {\n                      duration: 3000,\n                    },\n                  )\n\n                  break\n                }\n                case 412: {\n                  toast.error(\n                    `${t(\"entry_actions.failed_to_save_to_zotero\")}: The version provided in If-Unmodified-Since-Version is out of date, or the provided Zotero-Write-Token has already been submitted.`,\n                    {\n                      duration: 3000,\n                    },\n                  )\n\n                  break\n                }\n                case 413: {\n                  toast.error(\n                    `${t(\"entry_actions.failed_to_save_to_zotero\")}: Too many items submitted`,\n                    {\n                      duration: 3000,\n                    },\n                  )\n\n                  break\n                }\n                default: {\n                  toast.error(\n                    `${t(\"entry_actions.failed_to_save_to_zotero\")}: ${errorObj.message} || \"\"`,\n                    {\n                      duration: 3000,\n                    },\n                  )\n                }\n              }\n            }\n          },\n        }),\n  )\n}\n\nconst getDescription = (entry: EntryModel) => {\n  const { saveSummaryAsDescription } = getIntegrationSettings()\n  const actionLanguage = getActionLanguage()\n\n  if (!saveSummaryAsDescription) {\n    return entry.description || \"\"\n  }\n  const summary = getSummary(entry.id, actionLanguage)\n  return summary?.readabilitySummary || summary?.summary || entry.description || \"\"\n}\n\nconst buildUrlRequestBody = (entry: EntryModel) => {\n  return {\n    type: \"url\",\n    content: entry.url || \"\",\n    title: entry.title || \"\",\n    description: getDescription(entry),\n    tags: [],\n    folder: \"\",\n  }\n}\n\nconst buildMemoRequestBody = (entry: EntryModel, selectedText: string) => {\n  return {\n    type: \"memo\",\n    content: selectedText,\n    title: entry.title || \"\",\n    description: getDescription(entry),\n    tags: [],\n    folder: \"\",\n    source_url: entry.url,\n  }\n}\n\nfunction extractQBittorrentUrls(entry: EntryModel) {\n  const attachments = entry.attachments?.filter(\n    (attachment) => attachment.mime_type === \"application/x-bittorrent\" && attachment.url,\n  )\n\n  if (!attachments || attachments.length === 0) {\n    return\n  }\n\n  return attachments.map((attachment) => attachment.url)\n}\n\nconst useRegisterQBittorrentCommands = () => {\n  const { t } = useTranslation()\n\n  const enableQBittorrent = useIntegrationSettingKey(\"enableQBittorrent\")\n  const qbittorrentHost = useIntegrationSettingKey(\"qbittorrentHost\")\n  const qbittorrentUsername = useIntegrationSettingKey(\"qbittorrentUsername\")\n  const qbittorrentPassword = useIntegrationSettingKey(\"qbittorrentPassword\")\n  const qbittorrentAvailable =\n    enableQBittorrent && !!qbittorrentHost && !!qbittorrentUsername && !!qbittorrentPassword\n\n  useRegisterCommandEffect(\n    !qbittorrentAvailable\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.saveToQBittorrent,\n          label: t(\"entry_actions.save_to_qbittorrent\"),\n          icon: \"i-simple-icons-qbittorrent\",\n          category,\n          run: async ({ entryId }) => {\n            const entry = getEntry(entryId)\n            if (!entry) {\n              toast.error(\"Failed to save to qBittorrent: entry is not available\")\n              return\n            }\n            try {\n              tracker.integration({\n                type: \"qbittorrent\",\n                event: \"save\",\n              })\n\n              const urls = extractQBittorrentUrls(entry)\n              if (!urls) {\n                toast.error(t(\"entry_actions.no_bittorrent_urls_found\"))\n                return\n              }\n\n              let errorMessage = await ipcServices?.integration.loginToQBittorrent({\n                host: qbittorrentHost,\n                username: qbittorrentUsername,\n                password: qbittorrentPassword,\n              })\n\n              if (errorMessage) {\n                toast.error(`${t(\"entry_actions.failed_to_login_to_qbittorrent\")}: ${errorMessage}`)\n                return\n              }\n\n              errorMessage = await ipcServices?.integration.addMagnet({\n                host: qbittorrentHost,\n                urls,\n              })\n              if (errorMessage) {\n                toast.error(`${t(\"entry_actions.failed_to_save_to_qbittorrent\")}: ${errorMessage}`)\n              } else {\n                toast.success(t(\"entry_actions.saved_to_qbittorrent\"))\n              }\n            } catch (error) {\n              const errorObj = error as Error\n              toast.error(\n                `${t(\"entry_actions.failed_to_save_to_qbittorrent\")}: ${errorObj.message || \"\"}`,\n              )\n              return\n            }\n          },\n        }),\n  )\n}\n\nconst useRegisterCustomIntegrationCommands = () => {\n  const customIntegrations = useIntegrationSettingKey(\"customIntegration\")\n  const enableCustomIntegration = useIntegrationSettingKey(\"enableCustomIntegration\")\n\n  // Register main custom integration command\n  useRegisterCommandEffect(\n    !enableCustomIntegration || !customIntegrations || customIntegrations.length === 0\n      ? []\n      : defineFollowCommand({\n          id: COMMAND_ID.integration.custom,\n          label: \"Custom Integration\",\n          icon: <i className=\"i-mgc-webhook-cute-re\" />,\n          category,\n          run: async () => {},\n        }),\n    {\n      deps: [customIntegrations, enableCustomIntegration],\n    },\n  )\n\n  useRegisterCustomIntegrationVisualCommands()\n}\n\nconst useRegisterCustomIntegrationVisualCommands = () => {\n  const customIntegrations = useIntegrationSettingKey(\"customIntegration\")\n  const enableCustomIntegration = useIntegrationSettingKey(\"enableCustomIntegration\")\n\n  const visualCommands = useMemo(() => {\n    if (!enableCustomIntegration || !customIntegrations || customIntegrations.length === 0) {\n      return []\n    }\n    return customIntegrations.map((integration) => {\n      return defineFollowCommand({\n        id: `integration:custom:${integration.id}` as FollowCommandId,\n        label: integration.name,\n        icon: <i className={integration.icon} />,\n\n        category,\n        run: async ({ entryId }: { entryId: string }) => {\n          const entry = getEntry(entryId)\n          if (!entry) {\n            toast.error(`Failed to save to ${integration.name}: entry is not available`, {\n              duration: 3000,\n            })\n            return\n          }\n\n          await CustomIntegrationManager.executeWithToast(integration, entry)\n        },\n      })\n    })\n  }, [customIntegrations, enableCustomIntegration])\n\n  useRegisterCommandEffect(visualCommands, {\n    deps: [visualCommands],\n  })\n}\n\nexport type SaveToEagleCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToEagle\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToReadwiseCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToReadwise\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToInstapaperCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToInstapaper\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToObsidianCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToObsidian\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToOutlineCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToOutline\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToReadeckCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToReadeck\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToCuboxCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToCubox\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToZoteroCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToZotero\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type SaveToQBittorrentCommand = Command<{\n  id: typeof COMMAND_ID.integration.saveToQBittorrent\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type CustomIntegrationCommand = Command<{\n  id: typeof COMMAND_ID.integration.custom\n  fn: (payload: { entryId: string }) => void\n}>\n\nexport type IntegrationCommand =\n  | SaveToEagleCommand\n  | SaveToReadwiseCommand\n  | SaveToInstapaperCommand\n  | SaveToObsidianCommand\n  | SaveToOutlineCommand\n  | SaveToReadeckCommand\n  | SaveToCuboxCommand\n  | SaveToZoteroCommand\n  | SaveToQBittorrentCommand\n  | CustomIntegrationCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setTimelineColumnShow } from \"~/atoms/sidebar\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\ninterface FocusEvent {\n  highlightBoundary: boolean\n}\ndeclare module \"@follow/utils/event-bus\" {\n  interface EventBusMap {\n    \"layout:focus-to-timeline\": FocusEvent\n    \"layout:focus-to-subscription\": FocusEvent\n    \"layout:focus-to-entry-render\": FocusEvent\n  }\n}\n\nconst category: CommandCategory = \"category.layout\"\nexport const useRegisterLayoutCommands = () => {\n  const { t } = useTranslation(\"shortcuts\")\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.layout.toggleSubscriptionColumn,\n      label: {\n        title: t(\"command.layout.toggle_subscription_column.title\"),\n        description: t(\"command.layout.toggle_subscription_column.description\"),\n      },\n      category,\n      run: () => {\n        setTimelineColumnShow((show) => !show)\n      },\n    },\n    {\n      id: COMMAND_ID.layout.focusToTimeline,\n      label: t(\"command.layout.focus_to_timeline.title\"),\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.layout.focusToTimeline, { highlightBoundary: true })\n      },\n    },\n    {\n      id: COMMAND_ID.layout.focusToSubscription,\n      label: t(\"command.layout.focus_to_subscription.title\"),\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.layout.focusToSubscription, { highlightBoundary: true })\n      },\n    },\n    {\n      id: COMMAND_ID.layout.focusToEntryRender,\n      label: t(\"command.layout.focus_to_entry_render.title\"),\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: true })\n      },\n    },\n  ])\n}\n\nexport type FocusToSubscriptionCommand = Command<{\n  id: typeof COMMAND_ID.layout.focusToSubscription\n  fn: () => void\n}>\n\nexport type ToggleTimelineColumnCommand = Command<{\n  id: typeof COMMAND_ID.layout.toggleSubscriptionColumn\n  fn: () => void\n}>\n\nexport type FocusToTimelineCommand = Command<{\n  id: typeof COMMAND_ID.layout.focusToTimeline\n  fn: () => void\n}>\nexport type FocusToEntryRenderCommand = Command<{\n  id: typeof COMMAND_ID.layout.focusToEntryRender\n  fn: () => void\n}>\n\nexport type LayoutCommand =\n  | ToggleTimelineColumnCommand\n  | FocusToTimelineCommand\n  | FocusToSubscriptionCommand\n  | FocusToEntryRenderCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/list.tsx",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useDeleteSubscription } from \"~/hooks/biz/useSubscriptionActions\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { ListForm } from \"~/modules/discover/ListForm\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\nconst category: CommandCategory = \"category.list\"\nexport const useRegisterListCommands = () => {\n  const { t } = useTranslation()\n\n  const { mutateAsync: deleteSubscription } = useDeleteSubscription()\n  const navigateEntry = useNavigateEntry()\n  const { present } = useModalStack()\n\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.list.edit,\n      label: t(\"sidebar.feed_actions.edit\"),\n      category,\n      run: ({ listId }) => {\n        if (!listId) return\n        present({\n          title: t(\"sidebar.feed_actions.edit_list\"),\n          content: ({ dismiss }) => <ListForm id={listId} onSuccess={dismiss} />,\n        })\n      },\n    },\n    {\n      id: COMMAND_ID.list.unfollow,\n      label: t(\"sidebar.feed_actions.unfollow\"),\n      category,\n      run: ({ subscription }) => deleteSubscription({ subscription }),\n    },\n    {\n      id: COMMAND_ID.list.navigateTo,\n      label: t(\"sidebar.feed_actions.navigate_to_list\"),\n      category,\n      run: ({ listId }) => {\n        if (!listId) return\n        navigateEntry({ listId })\n      },\n    },\n    {\n      id: COMMAND_ID.list.openInBrowser,\n      label: t(\"sidebar.feed_actions.open_list_in_browser\", {\n        which: IN_ELECTRON ? t(\"words.browser\") : t(\"words.newTab\"),\n      }),\n      category,\n      run: ({ listId }) => {\n        if (!listId) return\n        const { view } = getRouteParams()\n        window.open(UrlBuilder.shareList(listId, view), \"_blank\")\n      },\n    },\n    {\n      id: COMMAND_ID.list.copyUrl,\n      label: t(\"sidebar.feed_actions.copy_list_url\"),\n      category,\n      run: async ({ listId }) => {\n        if (!listId) return\n        const { view } = getRouteParams()\n        await copyToClipboard(UrlBuilder.shareList(listId, view))\n        toast.success(\"copy success!\", {\n          duration: 1000,\n        })\n      },\n    },\n    {\n      id: COMMAND_ID.list.copyId,\n      label: t(\"sidebar.feed_actions.copy_list_id\"),\n      category,\n      run: async ({ listId }) => {\n        if (!listId) return\n        await copyToClipboard(listId)\n        toast.success(\"copy success!\", {\n          duration: 1000,\n        })\n      },\n    },\n  ])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx",
    "content": "import { useThemeAtomValue } from \"@follow/hooks\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useSetTheme } from \"~/hooks/common\"\nimport { useShowCustomizeToolbarModal } from \"~/modules/customize-toolbar/modal\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\nexport const useRegisterSettingsCommands = () => {\n  useCustomizeToolbarCommand()\n  useRegisterThemeCommands()\n}\n\nconst category: CommandCategory = \"category.settings\"\nconst useCustomizeToolbarCommand = () => {\n  const [t] = useTranslation(\"settings\")\n  const showModal = useShowCustomizeToolbarModal()\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.settings.customizeToolbar,\n      label: t(\"customizeToolbar.title\"),\n      category,\n      icon: <i className=\"i-mgc-settings-7-cute-re\" />,\n      run() {\n        showModal()\n      },\n    },\n  ])\n}\n\nconst useRegisterThemeCommands = () => {\n  const [t] = useTranslation(\"settings\")\n  const theme = useThemeAtomValue()\n  const setTheme = useSetTheme()\n\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.settings.changeThemeToAuto,\n      label: `To ${t(\"appearance.theme.system\")}`,\n      category,\n      icon: <i className=\"i-mgc-settings-7-cute-re\" />,\n      when: theme !== \"system\",\n      run() {\n        setTheme(\"system\")\n      },\n    },\n    {\n      id: COMMAND_ID.settings.changeThemeToDark,\n      label: `To ${t(\"appearance.theme.dark\")}`,\n      category,\n      icon: <i className=\"i-mingcute-moon-line\" />,\n      when: theme !== \"dark\",\n      run() {\n        setTheme(\"dark\")\n      },\n    },\n    {\n      id: COMMAND_ID.settings.changeThemeToLight,\n      label: `To ${t(\"appearance.theme.light\")}`,\n      category,\n      icon: <i className=\"i-mingcute-sun-line\" />,\n      when: theme !== \"light\",\n      run() {\n        setTheme(\"light\")\n      },\n    },\n  ])\n}\n\nexport type CustomizeToolbarCommand = Command<{\n  id: typeof COMMAND_ID.settings.customizeToolbar\n  fn: () => void\n}>\n\nexport type SettingsCommand = CustomizeToolbarCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { BizRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface EventBusMap {\n    \"subscription:switch-tab-to-next\": never\n    \"subscription:switch-tab-to-previous\": never\n    \"subscription:switch-tab-to-article\": never\n    \"subscription:switch-tab-to-social\": never\n    \"subscription:switch-tab-to-picture\": never\n    \"subscription:switch-tab-to-video\": never\n    \"subscription:switch-tab-to-audio\": never\n    \"subscription:switch-tab-to-notification\": never\n\n    \"subscription:next\": never\n    \"subscription:previous\": never\n    \"subscription:toggle-folder-collapse\": never\n    \"subscription:mark-all-as-read\": BizRouteParams\n    \"subscription:open-in-browser\": never\n    \"subscription:open-site-in-browser\": never\n  }\n}\n\nconst category: CommandCategory = \"category.subscription\"\nexport const useRegisterSubscriptionCommands = () => {\n  const { t } = useTranslation(\"shortcuts\")\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.subscription.switchTabToNext,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_next.title\"),\n        description: t(\"command.subscription.switch_tab_to_next.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToNext)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToPrevious,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_previous.title\"),\n        description: t(\"command.subscription.switch_tab_to_previous.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToPrevious)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToArticle,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_article.title\"),\n        description: t(\"command.subscription.switch_tab_to_article.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToArticle)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToSocial,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_social.title\"),\n        description: t(\"command.subscription.switch_tab_to_social.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToSocial)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToPicture,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_picture.title\"),\n        description: t(\"command.subscription.switch_tab_to_picture.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToPicture)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToVideo,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_video.title\"),\n        description: t(\"command.subscription.switch_tab_to_video.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToVideo)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToAudio,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_audio.title\"),\n        description: t(\"command.subscription.switch_tab_to_audio.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToAudio)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.switchTabToNotification,\n      label: {\n        title: t(\"command.subscription.switch_tab_to_notification.title\"),\n        description: t(\"command.subscription.switch_tab_to_notification.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.switchTabToNotification)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.nextSubscription,\n      label: {\n        title: t(\"command.subscription.next_subscription.title\"),\n        description: t(\"command.subscription.next_subscription.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.nextSubscription)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.previousSubscription,\n      label: {\n        title: t(\"command.subscription.previous_subscription.title\"),\n        description: t(\"command.subscription.previous_subscription.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.previousSubscription)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.toggleFolderCollapse,\n      label: {\n        title: t(\"command.subscription.toggle_folder_collapse.title\"),\n        description: t(\"command.subscription.toggle_folder_collapse.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.toggleFolderCollapse)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.markAllAsRead,\n      label: {\n        title: t(\"command.subscription.mark_all_as_read.title\"),\n      },\n      category,\n      run: () => {\n        const routeParams = getRouteParams()\n        EventBus.dispatch(COMMAND_ID.subscription.markAllAsRead, routeParams)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.openInBrowser,\n      label: {\n        title: t(\"command.subscription.open_in_browser.title\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.openInBrowser)\n      },\n    },\n    {\n      id: COMMAND_ID.subscription.openSiteInBrowser,\n      label: {\n        title: t(\"command.subscription.open_site_in_browser.title\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(COMMAND_ID.subscription.openSiteInBrowser)\n      },\n    },\n  ])\n}\n\ntype SwitchTabToNextCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToNext\n  fn: () => void\n}>\n\ntype SwitchTabToPreviousCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToPrevious\n  fn: () => void\n}>\n\ntype SwitchTabToArticleCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToArticle\n  fn: () => void\n}>\n\ntype SwitchTabToSocialCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToSocial\n  fn: () => void\n}>\n\ntype SwitchTabToPictureCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToPicture\n  fn: () => void\n}>\n\ntype SwitchTabToVideoCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToVideo\n  fn: () => void\n}>\n\ntype SwitchTabToAudioCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToAudio\n  fn: () => void\n}>\n\ntype SwitchTabToNotificationCommand = Command<{\n  id: typeof COMMAND_ID.subscription.switchTabToNotification\n  fn: () => void\n}>\n\ntype NextSubscriptionCommand = Command<{\n  id: typeof COMMAND_ID.subscription.nextSubscription\n  fn: () => void\n}>\n\ntype PreviousSubscriptionCommand = Command<{\n  id: typeof COMMAND_ID.subscription.previousSubscription\n  fn: () => void\n}>\n\ntype ToggleFolderCollapseCommand = Command<{\n  id: typeof COMMAND_ID.subscription.toggleFolderCollapse\n  fn: () => void\n}>\n\ntype MarkAllAsReadCommand = Command<{\n  id: typeof COMMAND_ID.subscription.markAllAsRead\n  fn: () => void\n}>\n\ntype OpenInBrowserCommand = Command<{\n  id: typeof COMMAND_ID.subscription.openInBrowser\n  fn: () => void\n}>\n\ntype OpenSiteInBrowserCommand = Command<{\n  id: typeof COMMAND_ID.subscription.openSiteInBrowser\n  fn: () => void\n}>\n\nexport type SubscriptionCommand =\n  | SwitchTabToNextCommand\n  | SwitchTabToPreviousCommand\n  | SwitchTabToArticleCommand\n  | SwitchTabToSocialCommand\n  | SwitchTabToPictureCommand\n  | SwitchTabToVideoCommand\n  | SwitchTabToAudioCommand\n  | SwitchTabToNotificationCommand\n  | NextSubscriptionCommand\n  | PreviousSubscriptionCommand\n  | ToggleFolderCollapseCommand\n  | MarkAllAsReadCommand\n  | OpenInBrowserCommand\n  | OpenSiteInBrowserCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setGeneralSetting } from \"~/atoms/settings/general\"\n\nimport { useRegisterCommandEffect } from \"../hooks/use-register-command\"\nimport type { Command, CommandCategory } from \"../types\"\nimport { COMMAND_ID } from \"./id\"\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface EventBusMap {\n    \"timeline:switch-to-next\": never\n    \"timeline:switch-to-previous\": never\n    \"timeline:refetch\": never\n    \"timeline:enter\": never\n  }\n}\n\nconst category: CommandCategory = \"category.timeline\"\nexport const useRegisterTimelineCommand = () => {\n  const { t } = useTranslation(\"shortcuts\")\n  useRegisterCommandEffect([\n    {\n      id: COMMAND_ID.timeline.switchToNext,\n      label: {\n        title: t(\"command.timeline.switch_to_next.title\"),\n        description: t(\"command.timeline.switch_to_next.description\"),\n      },\n      category,\n\n      run: () => {\n        EventBus.dispatch(\"timeline:switch-to-next\")\n      },\n    },\n    {\n      id: COMMAND_ID.timeline.switchToPrevious,\n      label: {\n        title: t(\"command.timeline.switch_to_previous.title\"),\n        description: t(\"command.timeline.switch_to_previous.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(\"timeline:switch-to-previous\")\n      },\n    },\n    {\n      id: COMMAND_ID.timeline.refetch,\n      label: {\n        title: t(\"command.timeline.refetch.title\"),\n        description: t(\"command.timeline.refetch.description\"),\n      },\n      category,\n      run: () => {\n        EventBus.dispatch(\"timeline:refetch\")\n      },\n    },\n    {\n      id: COMMAND_ID.timeline.unreadOnly,\n      label: {\n        title: t(\"command.timeline.toggle_unread_only.title\"),\n        description: t(\"command.timeline.toggle_unread_only.description\"),\n      },\n      category,\n      run: (unreadOnly: boolean) => {\n        setGeneralSetting(\"unreadOnly\", unreadOnly)\n      },\n    },\n  ])\n}\n\nexport type SwitchToNextTimelineCommand = Command<{\n  id: typeof COMMAND_ID.timeline.switchToNext\n  fn: () => void\n}>\n\nexport type SwitchToPreviousTimelineCommand = Command<{\n  id: typeof COMMAND_ID.timeline.switchToPrevious\n  fn: () => void\n}>\n\nexport type RefetchTimelineCommand = Command<{\n  id: typeof COMMAND_ID.timeline.refetch\n  fn: () => void\n}>\n\nexport type UnreadOnlyTimelineCommand = Command<{\n  id: typeof COMMAND_ID.timeline.unreadOnly\n  fn: (unreadOnly: boolean) => void\n}>\n\nexport type TimelineCommand =\n  | SwitchToNextTimelineCommand\n  | SwitchToPreviousTimelineCommand\n  | RefetchTimelineCommand\n  | UnreadOnlyTimelineCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/commands/types.ts",
    "content": "// Entry commands\n\nimport type { EntryCommand } from \"./entry\"\nimport type { EntryRenderCommand } from \"./entry-render\"\nimport type { GlobalCommand } from \"./global\"\nimport type { IntegrationCommand } from \"./integration\"\nimport type { LayoutCommand } from \"./layout\"\nimport type { SettingsCommand } from \"./settings\"\nimport type { SubscriptionCommand } from \"./subscription\"\nimport type { TimelineCommand } from \"./timeline\"\n\nexport type BasicCommand =\n  | EntryCommand\n  | SettingsCommand\n  | IntegrationCommand\n  | GlobalCommand\n  | LayoutCommand\n  | TimelineCommand\n  | EntryRenderCommand\n  | SubscriptionCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-command-binding.ts",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { transformShortcut } from \"@follow/utils/utils\"\nimport { useAtomValue, useSetAtom } from \"jotai\"\nimport { atomWithStorage, selectAtom } from \"jotai/utils\"\nimport { useCallback, useMemo } from \"react\"\n\nimport { COMMAND_ID } from \"../commands/id\"\nimport type { CommandCategory, FollowCommandId } from \"../types\"\nimport { getCommand } from \"./use-command\"\nimport type { RegisterHotkeyOptions } from \"./use-register-hotkey\"\nimport { useCommandHotkey } from \"./use-register-hotkey\"\n\nexport const defaultCommandShortcuts = {\n  // Layout commands\n  [COMMAND_ID.layout.toggleSubscriptionColumn]: transformShortcut(\"$mod+B\"),\n\n  // Subscription commands\n  [COMMAND_ID.subscription.markAllAsRead]: transformShortcut(\"Shift+$mod+A\"),\n  [COMMAND_ID.subscription.openInBrowser]: \"O\",\n  [COMMAND_ID.subscription.openSiteInBrowser]: transformShortcut(\"$mod+O\"),\n  [COMMAND_ID.subscription.previousSubscription]: \"K, ArrowUp\",\n  [COMMAND_ID.subscription.nextSubscription]: \"J, ArrowDown\",\n  [COMMAND_ID.subscription.switchTabToNext]: \"Tab\",\n  [COMMAND_ID.subscription.switchTabToPrevious]: transformShortcut(\"Shift+Tab\"),\n  [COMMAND_ID.subscription.toggleFolderCollapse]: \"Z\",\n\n  // Timeline commands\n  [COMMAND_ID.timeline.refetch]: \"R\",\n  [COMMAND_ID.timeline.unreadOnly]: \"U\",\n  [COMMAND_ID.timeline.switchToPrevious]: \"K, ArrowUp\",\n  [COMMAND_ID.timeline.switchToNext]: \"J, ArrowDown\",\n\n  // Entry commands\n  [COMMAND_ID.entry.copyLink]: transformShortcut(\"Shift+$mod+C\"),\n  [COMMAND_ID.entry.copyTitle]: transformShortcut(\"Shift+$mod+B\"),\n  [COMMAND_ID.entry.openInBrowser]: \"B\",\n  [COMMAND_ID.entry.read]: \"M\",\n  [COMMAND_ID.entry.share]: transformShortcut(\"$mod+Alt+S\"),\n  [COMMAND_ID.entry.star]: \"S\",\n  [COMMAND_ID.entry.tts]: transformShortcut(\"Shift+$mod+V\"),\n\n  // Entry render commands\n  [COMMAND_ID.entryRender.nextEntry]: \"L, ArrowRight\",\n  [COMMAND_ID.entryRender.previousEntry]: \"H, ArrowLeft\",\n  [COMMAND_ID.entryRender.scrollUp]: \"K, ArrowUp\",\n  [COMMAND_ID.entryRender.scrollDown]: \"J, ArrowDown\",\n\n  // Global commands\n  [COMMAND_ID.global.toggleCornerPlay]: \"Space\",\n  [COMMAND_ID.global.quickAdd]: transformShortcut(\"$mod+N\"),\n  [COMMAND_ID.global.showShortcuts]: \"?\",\n  [COMMAND_ID.global.toggleAIChat]: transformShortcut(\"$mod+I\"),\n  [COMMAND_ID.global.quickSearch]: transformShortcut(\"$mod+K\"),\n} as const\n\nconst overrideCommandShortcutsAtom = atomWithStorage<\n  Partial<Record<AllowCustomizeCommandId, string>>\n>(getStorageNS(\"command-shortcuts\"), {}, undefined, {\n  getOnInit: true,\n})\n\nexport const useCommandShortcutItems = () => {\n  const commandShortcuts = useCommandShortcuts()\n\n  return useMemo(() => {\n    const groupedCommands = {} as Record<CommandCategory, FollowCommandId[]>\n    for (const commandKey in commandShortcuts) {\n      const command = getCommand(commandKey as FollowCommandId)\n\n      if (!command) {\n        continue\n      }\n\n      groupedCommands[command.category] ??= []\n      groupedCommands[command.category].push(commandKey as FollowCommandId)\n    }\n\n    return groupedCommands\n  }, [commandShortcuts])\n}\nexport const allowCustomizeCommands = new Set([\n  COMMAND_ID.layout.toggleSubscriptionColumn,\n\n  COMMAND_ID.subscription.markAllAsRead,\n\n  COMMAND_ID.subscription.openInBrowser,\n  COMMAND_ID.subscription.openSiteInBrowser,\n\n  COMMAND_ID.subscription.switchTabToNext,\n  COMMAND_ID.subscription.switchTabToPrevious,\n  COMMAND_ID.subscription.toggleFolderCollapse,\n\n  COMMAND_ID.timeline.refetch,\n  COMMAND_ID.timeline.unreadOnly,\n\n  COMMAND_ID.entry.copyLink,\n  COMMAND_ID.entry.copyTitle,\n  COMMAND_ID.entry.openInBrowser,\n  COMMAND_ID.entry.read,\n  COMMAND_ID.entry.share,\n  COMMAND_ID.entry.star,\n  COMMAND_ID.entry.tts,\n] as const)\ntype ExtractSetType<T extends Set<unknown>> = T extends Set<infer U> ? U : never\nexport type AllowCustomizeCommandId = ExtractSetType<typeof allowCustomizeCommands>\nexport type BindingCommandId = keyof typeof defaultCommandShortcuts\n\nconst __commandShortcutAtom = (commandId: BindingCommandId) =>\n  selectAtom(overrideCommandShortcutsAtom, (v) => {\n    return v[commandId] ?? defaultCommandShortcuts[commandId]\n  })\nexport const useCommandShortcut = (commandId: BindingCommandId): string => {\n  return useAtomValue(useMemo(() => __commandShortcutAtom(commandId), [commandId]))\n}\n\nexport const getCommandShortcut = (commandId: BindingCommandId) => {\n  return jotaiStore.get(__commandShortcutAtom(commandId))\n}\n\nexport const useSetCustomCommandShortcut = () => {\n  const setOverrideCommandShortcuts = useSetAtom(overrideCommandShortcutsAtom)\n\n  return useCallback(\n    (commandId: AllowCustomizeCommandId, shortcut: string | null) => {\n      setOverrideCommandShortcuts((prev) => {\n        if (shortcut === null) {\n          const { [commandId]: _, ...rest } = prev\n\n          return rest\n        }\n        return { ...prev, [commandId]: shortcut }\n      })\n    },\n    [setOverrideCommandShortcuts],\n  )\n}\n\n/**\n *\n * @deprecated Use `useCommandShortcut` for more granular control\n */\nexport const useCommandShortcuts = () => {\n  const overrideCommandShortcuts = useAtomValue(overrideCommandShortcutsAtom)\n\n  return {\n    ...defaultCommandShortcuts,\n    ...overrideCommandShortcuts,\n  }\n}\n\nexport const useIsShortcutConflict = (\n  shortcut: string,\n  excludeCommandId?: AllowCustomizeCommandId,\n) => {\n  const overrideCommandShortcuts = useAtomValue(overrideCommandShortcutsAtom)\n\n  return useMemo(() => {\n    const allShortcuts = {\n      ...defaultCommandShortcuts,\n      ...overrideCommandShortcuts,\n    }\n\n    // Check if the shortcut conflicts with any existing shortcuts\n    for (const [commandId, existingShortcut] of Object.entries(allShortcuts)) {\n      // Skip the command we're excluding (useful when editing an existing shortcut)\n      if (excludeCommandId && commandId === excludeCommandId) {\n        continue\n      }\n\n      // Normalize shortcuts for comparison (handle multiple shortcuts separated by comma)\n      const normalizedShortcut = shortcut.trim().toLowerCase()\n      const normalizedExisting = existingShortcut.toLowerCase()\n\n      // Check if shortcuts match exactly or if one is contained in the other's alternatives\n      const shortcutAlternatives = normalizedShortcut.split(\",\").map((s) => s.trim())\n      const existingAlternatives = normalizedExisting.split(\",\").map((s) => s.trim())\n\n      for (const shortcutAlt of shortcutAlternatives) {\n        for (const existingAlt of existingAlternatives) {\n          if (shortcutAlt === existingAlt) {\n            return {\n              hasConflict: true,\n              conflictingCommandId: commandId as FollowCommandId,\n            }\n          }\n        }\n      }\n    }\n\n    return {\n      hasConflict: false,\n      conflictingCommandId: null,\n    }\n  }, [shortcut, excludeCommandId, overrideCommandShortcuts])\n}\n\nexport const useCommandBinding = <T extends BindingCommandId>({\n  commandId,\n  when = true,\n  args,\n}: Omit<RegisterHotkeyOptions<T>, \"shortcut\">) => {\n  const commandShortcut = useCommandShortcut(commandId)\n\n  return useCommandHotkey({\n    shortcut: commandShortcut,\n    commandId,\n    when,\n    args,\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-command.test-d.ts",
    "content": "import { assertType, expectTypeOf, test } from \"vitest\"\n\nimport type { CopyLinkCommand } from \"../commands/entry\"\nimport { COMMAND_ID } from \"../commands/id\"\nimport { getCommand, useCommand, useRunCommandFn } from \"./use-command\"\n\ntest(\"getCommand types work properly\", () => {\n  expectTypeOf(getCommand(COMMAND_ID.entry.copyLink)).toMatchTypeOf<CopyLinkCommand | null>()\n\n  // @ts-expect-error - get an unknown command should throw an error\n  assertType(getCmd(\"unknown command\"))\n})\n\ntest(\"useCommand types work properly\", () => {\n  const copyCmd = useCommand(COMMAND_ID.entry.copyLink)\n  expectTypeOf(copyCmd).toMatchTypeOf<CopyLinkCommand | null>()\n\n  // @ts-expect-error - get an unknown command should throw an error\n  assertType(useCommand(\"unknown command\"))\n})\n\ntest(\"useRunCommandFn types work properly\", () => {\n  const runCmdFn = useRunCommandFn()\n  expectTypeOf(runCmdFn).toBeFunction()\n\n  assertType(runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId: \"1\" }]))\n  // @ts-expect-error - invalid argument type\n  assertType(runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId: 1 }]))\n  // @ts-expect-error - invalid argument type\n  assertType(runCmdFn(COMMAND_ID.entry.copyLink, []))\n  // @ts-expect-error - invalid argument type\n  assertType(runCmdFn(COMMAND_ID.entry.copyLink, [1]))\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-command.ts",
    "content": "import { jotaiStore } from \"@follow/utils/jotai\"\nimport { useAtomValue } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { useMemo } from \"react\"\n\nimport { CommandRegistry } from \"../registry/registry\"\nimport type { FollowCommandId, FollowCommandMap } from \"../types\"\n\nexport const hasCommand = <T extends FollowCommandId>(id: T) => {\n  const commands = jotaiStore.get(CommandRegistry.atom) as FollowCommandMap\n  return id in commands\n}\n\nexport const getCommand = <T extends FollowCommandId>(id: T) => {\n  const commands = jotaiStore.get(CommandRegistry.atom) as FollowCommandMap\n  return id in commands ? commands[id] : null\n}\n\nexport const useCommands = () => useAtomValue(CommandRegistry.atom)\nexport function useCommand<T extends FollowCommandId>(id: T): FollowCommandMap[T] | null {\n  const commands = useAtomValue(\n    useMemo(() => selectAtom(CommandRegistry.atom, (commands) => commands[id]), [id]),\n  )\n  return commands as FollowCommandMap[T] | null\n}\n\nconst noop = () => {}\nconst runCommand = <T extends FollowCommandId>(\n  id: T,\n  args: Parameters<FollowCommandMap[T][\"run\"]>,\n) => {\n  const cmd = getCommand(id)\n\n  if (!cmd) return noop\n  // @ts-expect-error - The type should be discriminated\n  return () => cmd.run(...args)\n}\nexport function useRunCommandFn() {\n  return runCommand\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-register-command.test-d.ts",
    "content": "import { assertType, expectTypeOf, test } from \"vitest\"\n\nimport { COMMAND_ID } from \"../commands/id\"\nimport { useRegisterFollowCommand } from \"./use-register-command\"\n\ntest(\"useRegisterFollowCommand types\", () => {\n  assertType(\n    useRegisterFollowCommand({\n      id: COMMAND_ID.entry.openInBrowser,\n      label: \"\",\n      run: ({ entryId }) => {\n        expectTypeOf(entryId).toEqualTypeOf<string>()\n      },\n    }),\n  )\n\n  assertType(\n    useRegisterFollowCommand({\n      id: \"unknown id\",\n      label: \"\",\n      run: (...args) => {\n        expectTypeOf(args).toEqualTypeOf<[]>()\n      },\n    }),\n  )\n\n  assertType(\n    useRegisterFollowCommand([\n      {\n        id: COMMAND_ID.entry.star,\n        label: \"\",\n        run: ({ entryId }) => {\n          expectTypeOf(entryId).toEqualTypeOf<string>()\n        },\n      },\n    ]),\n  )\n\n  assertType(\n    useRegisterFollowCommand([\n      {\n        id: \"unknown id\",\n        label: \"\",\n        run: (...args) => {\n          expectTypeOf(args).toEqualTypeOf<[]>()\n        },\n      },\n    ]),\n  )\n\n  assertType(\n    useRegisterFollowCommand([\n      {\n        id: \"unknown id\",\n        label: \"\",\n        run: (...args) => {\n          expectTypeOf(args).toEqualTypeOf<[]>()\n        },\n      },\n      {\n        id: COMMAND_ID.entry.star,\n        label: \"\",\n        run: ({ entryId }) => {\n          expectTypeOf(entryId).toEqualTypeOf<string>()\n        },\n      },\n    ]),\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-register-command.ts",
    "content": "import { useEffect } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { registerCommand } from \"../registry/registry\"\nimport type { CommandOptions, FollowCommandId, FollowCommandMap } from \"../types\"\n\nexport type RegisterOptions = {\n  deps?: unknown[]\n  enabled?: boolean\n  // forceMountSection?: boolean\n  // sectionMeta?: Record<string, unknown>\n  // orderSection?: OrderSectionInstruction\n  // orderCommands?: OrderCommandsInstruction\n}\n\nexport const useRegisterCommandEffect = (\n  options: CommandOptions | CommandOptions[],\n  registerOptions?: RegisterOptions,\n) => {\n  const { t } = useTranslation()\n  useEffect(() => {\n    if (!Array.isArray(options)) {\n      const unsubscribe = registerCommand(options)\n      return () => unsubscribe()\n    }\n\n    const unsubscribes = options.map((option) => registerCommand(option))\n    return () => {\n      unsubscribes.forEach((unsubscribe) => unsubscribe())\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this effect only once\n  }, [t, ...(registerOptions?.deps ?? [])])\n}\n\n/**\n * Register a follow command.\n */\nexport function useRegisterFollowCommand<T extends FollowCommandId>(\n  options: CommandOptions<{ id: T; fn: FollowCommandMap[T][\"run\"] }>,\n  registerOptions?: RegisterOptions,\n): void\n/**\n * Register a unknown command.\n */\nexport function useRegisterFollowCommand<T extends string>(\n  options: CommandOptions<{ id: T; fn: () => void }>,\n  registerOptions?: RegisterOptions,\n): void\n/**\n * Register multiple follow commands or unknown commands.\n */\nexport function useRegisterFollowCommand<T extends (FollowCommandId | string)[]>(\n  options: [\n    ...{\n      [K in keyof T]: T[K] extends FollowCommandId\n        ? CommandOptions<{ id: T[K]; fn: FollowCommandMap[T[K]][\"run\"] }>\n        : CommandOptions<{ id: T[K]; fn: () => void }>\n    },\n  ],\n  registerOptions?: RegisterOptions,\n): void\nexport function useRegisterFollowCommand(\n  options: CommandOptions | CommandOptions[],\n  registerOptions?: RegisterOptions,\n) {\n  return useRegisterCommandEffect(options as CommandOptions | CommandOptions[], registerOptions)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.test-d.ts",
    "content": "import { assertType, test } from \"vitest\"\n\nimport { COMMAND_ID } from \"../commands/id\"\nimport { useCommandHotkey } from \"./use-register-hotkey\"\n\ntest(\"useRegisterHotkey types\", () => {\n  assertType(\n    useCommandHotkey({\n      shortcut: \"\",\n      commandId: COMMAND_ID.entry.openInBrowser,\n      args: [{ entryId: \"\" }],\n    }),\n  )\n\n  assertType(\n    useCommandHotkey({\n      shortcut: \"\",\n      commandId: COMMAND_ID.entry.openInBrowser,\n      // @ts-expect-error - missing required options\n      args: [],\n    }),\n  )\n\n  assertType(\n    useCommandHotkey({\n      shortcut: \"\",\n      commandId: COMMAND_ID.entry.openInBrowser,\n      // @ts-expect-error - invalid args type\n      args: [1],\n    }),\n  )\n\n  assertType(\n    useCommandHotkey({\n      shortcut: \"\",\n      commandId: COMMAND_ID.entry.openInBrowser,\n      // @ts-expect-error - invalid args number\n      args: [\"\", \"\"],\n    }),\n  )\n\n  assertType(\n    useCommandHotkey({\n      shortcut: \"\",\n      // @ts-expect-error - invalid command id\n      commandId: \"unknown command\",\n    }),\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts",
    "content": "import { useRefValue } from \"@follow/hooks\"\nimport { checkIsEditableElement } from \"@follow/utils/dom\"\nimport { useEffect } from \"react\"\nimport { tinykeys } from \"tinykeys\"\n\nimport type { FollowCommand, FollowCommandId } from \"../types\"\nimport { getCommand } from \"./use-command\"\n\nexport interface HotkeyOptions {\n  forceInputElement?: true\n}\nexport interface RegisterHotkeyOptions<T extends FollowCommandId> {\n  shortcut: string\n  commandId: T\n  args?: Parameters<Extract<FollowCommand, { id: T }>[\"run\"]>\n  when?: boolean\n\n  options?: HotkeyOptions\n}\n\nconst SPECIAL_KEYS_MAPPINGS = {\n  \"?\": \"Shift+Slash\",\n}\n\nexport const useCommandHotkey = <T extends FollowCommandId>({\n  shortcut,\n  commandId,\n  when,\n  args,\n  options,\n}: RegisterHotkeyOptions<T>) => {\n  const argsRef = useRefValue(args)\n  useEffect(() => {\n    if (!when) {\n      return\n    }\n\n    if (!shortcut) {\n      return\n    }\n\n    // Handle comma-separated shortcuts\n    const shortcuts = shortcut.split(\",\").map((s) => s.trim())\n    const keyMap: Record<string, (event: KeyboardEvent) => void> = {}\n\n    // Create a handler for each shortcut\n    shortcuts.forEach((key) => {\n      let nextKey = key\n\n      if (SPECIAL_KEYS_MAPPINGS[key]) {\n        nextKey = SPECIAL_KEYS_MAPPINGS[key]\n      }\n\n      keyMap[nextKey] = (event) => {\n        const { target } = event\n\n        if (!options?.forceInputElement && checkIsEditableElement(target as HTMLElement)) {\n          return\n        }\n\n        event.preventDefault()\n        event.stopPropagation()\n\n        const command = getCommand(commandId)\n        if (!command) return\n        const args = argsRef.current\n        if (Array.isArray(args)) {\n          // It should be safe to spread the args here because we are checking if it is an array\n          // @ts-expect-error - A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)\n          command.run(...args)\n          return\n        }\n\n        if (args === undefined) {\n          // @ts-expect-error\n          command.run()\n          return\n        }\n\n        console.error(\"Invalid args\", typeof args, args)\n      }\n    })\n\n    return tinykeys(document.documentElement, keyMap)\n  }, [shortcut, commandId, when, argsRef, options?.forceInputElement])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/mutation-command-ids.ts",
    "content": "import { COMMAND_ID } from \"./commands/id\"\nimport type { FollowCommandId } from \"./types\"\n\nconst MUTATION_COMMAND_IDS = new Set<FollowCommandId>([\n  COMMAND_ID.entry.star,\n  COMMAND_ID.entry.toggleAITranslation,\n  COMMAND_ID.entry.read,\n  COMMAND_ID.entry.readAbove,\n  COMMAND_ID.entry.readBelow,\n  COMMAND_ID.entry.delete,\n  COMMAND_ID.entry.readability,\n  COMMAND_ID.integration.saveToEagle,\n  COMMAND_ID.integration.saveToReadwise,\n  COMMAND_ID.integration.saveToInstapaper,\n  COMMAND_ID.integration.saveToObsidian,\n  COMMAND_ID.integration.saveToOutline,\n  COMMAND_ID.integration.saveToReadeck,\n  COMMAND_ID.integration.saveToCubox,\n  COMMAND_ID.integration.saveToZotero,\n  COMMAND_ID.integration.saveToQBittorrent,\n  COMMAND_ID.integration.custom,\n])\n\nconst MUTATION_PREFIXES = [\"integration:custom:\"]\n\nexport const isMutationCommandId = (id: string | undefined) => {\n  if (!id) return false\n  if (MUTATION_COMMAND_IDS.has(id as FollowCommandId)) {\n    return true\n  }\n  return MUTATION_PREFIXES.some((prefix) => id.startsWith(prefix))\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/registry/command.test-d.ts",
    "content": "import { assertType, expectTypeOf, test } from \"vitest\"\n\nimport { COMMAND_ID } from \"../commands/id\"\nimport { defineFollowCommand } from \"./command\"\n\ntest(\"defineFollowCommand types\", () => {\n  assertType(\n    defineFollowCommand({\n      id: COMMAND_ID.entry.openInBrowser,\n      label: \"\",\n      run: (data) => {\n        expectTypeOf(data).toEqualTypeOf<{\n          entryId: string\n        }>()\n      },\n    }),\n  )\n\n  assertType(\n    defineFollowCommand({\n      id: COMMAND_ID.entry.openInBrowser,\n      label: \"\",\n      // @ts-expect-error - redundant parameters\n      run: (url, _b: number) => console.info(url),\n    }),\n  )\n\n  assertType(\n    defineFollowCommand({\n      // @ts-expect-error - unknown id\n      id: \"unknown id\",\n      label: \"\",\n      run: () => {},\n    }),\n  )\n\n  assertType(\n    defineFollowCommand({\n      id: COMMAND_ID.entry.openInBrowser,\n      label: \"\",\n      // @ts-expect-error - invalid type\n      run: (_n: number) => {},\n    }),\n  )\n})\n\ntest(\"defineFollowCommand with keyBinding types\", () => {\n  assertType(\n    defineFollowCommand({\n      id: COMMAND_ID.entry.viewSourceContent,\n      label: \"\",\n      when: true,\n      // @ts-expect-error - only simple command can set keybinding\n      keyBinding: \"\",\n      run: ({ entryId }) => {\n        expectTypeOf(entryId).toEqualTypeOf<string>()\n      },\n    }),\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/registry/command.ts",
    "content": "import { createElement } from \"react\"\n\nimport type {\n  Command,\n  CommandOptions,\n  FollowCommand,\n  FollowCommandId,\n  FollowCommandMap,\n} from \"../types\"\n\nexport function createCommand<\n  T extends { id: string; fn: (...args: any[]) => unknown } = {\n    id: string\n    fn: (...args: unknown[]) => unknown\n  },\n>(options: CommandOptions<T>): Command<T> {\n  return {\n    id: options.id,\n    run: options.run,\n    icon:\n      typeof options.icon === \"string\"\n        ? createElement(\"i\", { className: options.icon })\n        : options.icon,\n    category: options.category ?? \"category.global\",\n    get label() {\n      let { label } = options\n      label = typeof label === \"function\" ? label?.() : label\n      label = typeof label === \"string\" ? { title: label } : label\n      return label\n    },\n  }\n}\n\n// Follow command\n\nexport function createFollowCommand<T extends FollowCommand>(\n  options: CommandOptions<{ id: T[\"id\"]; fn: T[\"run\"] }>,\n) {\n  return createCommand(options)\n}\n\nexport function defineFollowCommand<T extends FollowCommandId>(\n  options: CommandOptions<{ id: T; fn: FollowCommandMap[T][\"run\"] }>,\n) {\n  return options as CommandOptions\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/registry/registry.ts",
    "content": "import { atom } from \"jotai\"\n\n// import { createKeybindingsHandler } from \"tinykeys\"\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport type { Command, CommandOptions } from \"../types\"\nimport { createCommand } from \"./command\"\n\nexport const CommandRegistry = new (class {\n  readonly atom = atom<Record<string, Command>>({})\n\n  get commands() {\n    return {\n      get: (id: string) => jotaiStore.get(this.atom)[id],\n      set: (id: string, value: Command) =>\n        jotaiStore.set(this.atom, (prev) => ({ ...prev, [id]: value })),\n      has: (id: string) => !!jotaiStore.get(this.atom)[id],\n      delete: (id: string) =>\n        jotaiStore.set(this.atom, (prev) => {\n          const next = { ...prev }\n          delete next[id]\n          return next\n        }),\n      values: () => Object.values(jotaiStore.get(this.atom)) as Command[],\n    }\n  }\n\n  register(options: CommandOptions) {\n    if (this.commands.has(options.id)) {\n      console.warn(`Command ${options.id} already registered.`)\n      return () => {}\n    }\n    const command = createCommand(options)\n    this.commands.set(command.id, command)\n\n    return () => {\n      this.commands.delete(command.id)\n    }\n  }\n\n  has(id: string): boolean {\n    return this.commands.has(id)\n  }\n\n  get(id: string): Command | undefined {\n    if (!this.commands.has(id)) {\n      console.warn(`Command ${id} not registered.`)\n      return undefined\n    }\n    return this.commands.get(id)\n  }\n\n  getAll(): Command[] {\n    return Array.from(this.commands.values())\n  }\n\n  run(id: string, ...args: unknown[]) {\n    const command = this.get(id)\n    if (!command) return\n\n    command.run(args)\n  }\n})()\n\nexport function registerCommand(options: CommandOptions) {\n  return CommandRegistry.register(options)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/shortcuts/SettingShortcuts.tsx",
    "content": "import { KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useCommand } from \"../hooks/use-command\"\nimport { useCommandShortcutItems, useCommandShortcuts } from \"../hooks/use-command-binding\"\nimport type { CommandCategory, FollowCommandId } from \"../types\"\n\nexport const ShortcutsGuideline = () => {\n  const { t } = useTranslation(\"shortcuts\")\n  const commandShortcuts = useCommandShortcutItems()\n\n  return (\n    <div className=\"mt-4 space-y-6\">\n      {Object.entries(commandShortcuts).map(([type, commands]) => (\n        <section key={type}>\n          <div className=\"mb-2 pl-3 text-sm font-medium capitalize text-text-secondary\">\n            {t(type as CommandCategory)}\n          </div>\n          <div className=\"rounded-md border text-[13px] text-text\">\n            {commands.map((commandId) => (\n              <CommandShortcutItem key={commandId} commandId={commandId} />\n            ))}\n          </div>\n        </section>\n      ))}\n    </div>\n  )\n}\n\nconst CommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {\n  const command = useCommand(commandId)\n  const commandShortcuts = useCommandShortcuts()\n\n  if (!command) return null\n  return (\n    <div className={\"flex h-9 items-center justify-between px-3 py-1.5 odd:bg-fill-quinary\"}>\n      <div>{command.label.title}</div>\n      <div>\n        <KbdCombined joint>{commandShortcuts[commandId]}</KbdCombined>\n      </div>\n    </div>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/command/types.ts",
    "content": "import type { ReactNode } from \"react\"\n\nimport type { BasicCommand } from \"./commands/types\"\n\ntype ExtractCategory<T extends string> = T extends `category.${string}` ? T : never\nexport type CommandCategory = ExtractCategory<Parameters<typeof tShortcuts>[0]>\n\nexport interface KeybindingOptions {\n  binding: string\n  capture?: boolean\n  // some keybindings are already registered in other places\n  // we can skip the registration of these keybindings __FOR NOW__\n  // skipRegister?: boolean\n}\n\nexport interface Command<\n  T extends { id: string; fn: (...args: any[]) => unknown } = {\n    id: string\n    fn: (...args: unknown[]) => unknown\n  },\n> {\n  readonly id: T[\"id\"]\n  readonly label: {\n    title: string\n    description?: string\n  }\n  readonly icon?: ReactNode | ((props?: { isActive?: boolean }) => ReactNode)\n  readonly category: CommandCategory\n  readonly run: T[\"fn\"]\n}\n\nexport type SimpleCommand<T extends string> = Command<{ id: T; fn: () => void }>\n\nexport interface CommandOptions<\n  T extends { id: string; fn: (...args: any[]) => unknown } = {\n    id: string\n    fn: (...args: any[]) => unknown\n  },\n> {\n  id: T[\"id\"]\n  // main text on the left..\n  // make text a function so that we can do i18n and interpolation when we need to\n  label:\n    | string\n    | (() => string)\n    | { title: string; description?: string }\n    | (() => { title: string; description?: string })\n  icon?: string | ReactNode | ((props?: { isActive?: boolean }) => ReactNode)\n  category?: CommandCategory\n  run: T[\"fn\"]\n\n  when?: boolean\n}\n\nexport type FollowCommandMap = {\n  [K in FollowCommand[\"id\"]]: Extract<FollowCommand, { id: K }>\n  // [K in FollowCommand[\"id\"]]: K extends UnknownCommand[\"id\"]\n  //   ? UnknownCommand\n  //   : Extract<FollowCommand, { id: K }>\n}\n\n// type AnyCommand = Command<string & {}, (...args: any[]) => void>\nexport type UnknownCommand = Command<{\n  id: string & { __brand: true }\n  fn: (...args: unknown[]) => void\n}>\n\nexport type FollowCommandId = FollowCommand[\"id\"]\nexport type FollowCommand = BasicCommand | UnknownCommand\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/customize-toolbar/constant.ts",
    "content": "import type { UniqueIdentifier } from \"@dnd-kit/core\"\n\nimport { COMMAND_ID } from \"../command/commands/id\"\n\nexport interface ToolbarActionOrder {\n  main: UniqueIdentifier[]\n  more: UniqueIdentifier[]\n}\n\nexport const ENTRY_ITEM_HIDE_IN_HEADER = new Set<UniqueIdentifier>([\n  COMMAND_ID.entry.readAbove,\n  COMMAND_ID.entry.readBelow,\n  COMMAND_ID.settings.customizeToolbar,\n])\n\nconst MAIN_ACTIONS = [\n  COMMAND_ID.entry.read,\n  COMMAND_ID.entry.star,\n\n  COMMAND_ID.entry.readability,\n\n  COMMAND_ID.entry.viewSourceContent,\n  COMMAND_ID.entry.share,\n]\nconst MAIN_ACTIONS_SET = new Set<UniqueIdentifier>(MAIN_ACTIONS)\n\nexport const DEFAULT_ACTION_ORDER: ToolbarActionOrder = {\n  main: MAIN_ACTIONS,\n  more: [\n    ...Object.values(COMMAND_ID.integration),\n    ...Object.values(COMMAND_ID.entry).filter((id) => !MAIN_ACTIONS_SET.has(id)),\n  ],\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/customize-toolbar/dnd.tsx",
    "content": "import type { UniqueIdentifier } from \"@dnd-kit/core\"\nimport { useSortable } from \"@dnd-kit/sortable\"\nimport { CSS } from \"@dnd-kit/utilities\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { IN_ELECTRON } from \"@follow/shared\"\nimport type { ReactNode } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { COMMAND_ID } from \"../command/commands/id\"\nimport { getCommand } from \"../command/hooks/use-command\"\nimport type { FollowCommandId } from \"../command/types\"\n\nconst SortableItem = ({ id, children }: { id: UniqueIdentifier; children: ReactNode }) => {\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n    id,\n    animateLayoutChanges: () => true, // Enable layout animations\n    transition: {\n      duration: 400,\n      easing: \"cubic-bezier(0.25, 1, 0.5, 1)\",\n    },\n  })\n\n  const style = useMemo(() => {\n    return {\n      transform: CSS.Transform.toString(transform),\n      transition,\n      width: \"100px\", // Fixed width\n      height: \"80px\", // Fixed height\n      zIndex: isDragging ? 999 : undefined,\n    }\n  }, [transform, transition, isDragging])\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      className={` ${isDragging ? \"cursor-grabbing opacity-90\" : \"cursor-grab\"} transition-colors duration-200`}\n      {...attributes}\n      {...listeners}\n    >\n      {children}\n    </div>\n  )\n}\n\nconst warningActionButton: Partial<\n  Record<\n    FollowCommandId,\n    {\n      show: boolean\n      info: string\n    }\n  >\n> = {\n  [COMMAND_ID.entry.tts]: {\n    show: !IN_ELECTRON,\n    info: \"entry_actions.warn_info_for_desktop\",\n  },\n}\n\nexport const SortableActionButton = ({ id }: { id: UniqueIdentifier }) => {\n  const cmd = getCommand(id as FollowCommandId)\n  const warnInfo = warningActionButton[id as FollowCommandId]\n  const { t } = useTranslation()\n  if (!cmd) return null\n  return (\n    <SortableItem id={id}>\n      <div className=\"flex flex-col items-center rounded-lg p-2 hover:bg-material-opaque\">\n        <div className=\"flex size-8 items-center justify-center text-xl\">\n          {typeof cmd.icon === \"function\" ? cmd.icon({ isActive: false }) : cmd.icon}\n        </div>\n        <div className=\"mt-1 text-center text-callout text-text-secondary\">\n          {warnInfo?.show && (\n            <Tooltip>\n              <TooltipTrigger>\n                <i className=\"i-mgc-information-cute-re mr-1 translate-y-[2px]\" />\n              </TooltipTrigger>\n              <TooltipPortal>\n                <TooltipContent>{t(warnInfo.info as any)}</TooltipContent>\n              </TooltipPortal>\n            </Tooltip>\n          )}\n          {cmd.label.title}\n        </div>\n      </div>\n    </SortableItem>\n  )\n}\n\nexport function DroppableContainer({ children }: { children: ReactNode }) {\n  return (\n    <div className=\"flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border border-border bg-material-ultra-thin p-2 shadow-sm\">\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/customize-toolbar/hooks.ts",
    "content": "import type { UniqueIdentifier } from \"@dnd-kit/core\"\nimport { useMemo } from \"react\"\n\nimport { useUISettingSelector } from \"~/atoms/settings/ui\"\n\nimport { DEFAULT_ACTION_ORDER, ENTRY_ITEM_HIDE_IN_HEADER } from \"./constant\"\n\nexport const useActionOrder = () => {\n  const actionOrderSetting = useUISettingSelector((s) => s.toolbarOrder)\n\n  return useMemo(() => {\n    const { main, more } = actionOrderSetting\n    const missingMainActions = DEFAULT_ACTION_ORDER.main.filter(\n      (id) => !main.includes(id) && !more.includes(id),\n    )\n    const missingMoreActions = DEFAULT_ACTION_ORDER.more.filter(\n      (id) => !main.includes(id) && !more.includes(id),\n    )\n\n    return {\n      main: [...actionOrderSetting.main, ...missingMainActions].filter(\n        (id) => !ENTRY_ITEM_HIDE_IN_HEADER.has(id as string),\n      ),\n      more: [...actionOrderSetting.more, ...missingMoreActions].filter(\n        (id) => !ENTRY_ITEM_HIDE_IN_HEADER.has(id as string),\n      ),\n    }\n  }, [actionOrderSetting])\n}\n\nexport const useToolbarOrderMap = () => {\n  const actionOrder = useActionOrder()\n\n  const actionOrderMap = useMemo(() => {\n    const actionOrderMap = new Map<\n      UniqueIdentifier,\n      {\n        type: \"main\" | \"more\"\n        order: number\n      }\n    >()\n    actionOrder.main.forEach((id, index) =>\n      actionOrderMap.set(id, {\n        type: \"main\",\n        order: index,\n      }),\n    )\n    actionOrder.more.forEach((id, index) =>\n      actionOrderMap.set(id, {\n        type: \"more\",\n        order: index,\n      }),\n    )\n    return actionOrderMap\n  }, [actionOrder])\n\n  return actionOrderMap\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/customize-toolbar/modal.tsx",
    "content": "import type { DragOverEvent } from \"@dnd-kit/core\"\nimport {\n  closestCenter,\n  DndContext,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\"\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { useCallback, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setUISetting } from \"~/atoms/settings/ui\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { DEFAULT_ACTION_ORDER } from \"./constant\"\nimport { DroppableContainer, SortableActionButton } from \"./dnd\"\nimport { useActionOrder } from \"./hooks\"\n\nconst CustomizeToolbar = () => {\n  const { t } = useTranslation(\"settings\")\n  const actionOrder = useActionOrder()\n\n  const sensors = useSensors(\n    useSensor(PointerSensor),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  )\n\n  const handleDragOver = useCallback(\n    ({ active, over }: DragOverEvent) => {\n      if (!over) return\n      const activeId = active.id\n      const overId = over.id\n      const isActiveInMain = actionOrder.main.includes(activeId)\n      const isOverInMain = overId === \"container-main\" || actionOrder.main.includes(overId)\n      const isCrossContainer = isActiveInMain !== isOverInMain\n\n      if (isCrossContainer) {\n        // Moving between containers\n        const sourceList = isActiveInMain ? \"main\" : \"more\"\n        const targetList = isActiveInMain ? \"more\" : \"main\"\n        const newIndexOfOver = actionOrder[targetList].indexOf(overId)\n        setUISetting(\"toolbarOrder\", {\n          ...actionOrder,\n          [sourceList]: actionOrder[sourceList].filter((item) => item !== activeId),\n          [targetList]: [\n            ...actionOrder[targetList].slice(0, newIndexOfOver),\n            activeId,\n            ...actionOrder[targetList].slice(newIndexOfOver),\n          ],\n        })\n        return\n      }\n      // Reordering within container\n      const list = isActiveInMain ? \"main\" : \"more\"\n      const items = actionOrder[list]\n      const oldIndex = items.indexOf(activeId)\n      const newIndex = items.indexOf(overId)\n\n      setUISetting(\"toolbarOrder\", {\n        ...actionOrder,\n        [list]: arrayMove(items, oldIndex, newIndex),\n      })\n    },\n    [actionOrder],\n  )\n\n  const resetActionOrder = useRef(() => {\n    setUISetting(\"toolbarOrder\", DEFAULT_ACTION_ORDER)\n  }).current\n\n  return (\n    <div\n      className=\"mx-auto w-full max-w-[800px] space-y-4 overflow-hidden\"\n      onPointerDown={(event) => event.stopPropagation()}\n    >\n      <div className=\"mb-4\">\n        <h2 className=\"text-title2 font-semibold text-text\">\n          {t(\"customizeToolbar.quick_actions.title\")}\n        </h2>\n        <p className=\"text-headline text-text-secondary\">\n          {t(\"customizeToolbar.quick_actions.description\")}\n        </p>\n      </div>\n      {/* Refer to https://github.com/clauderic/dnd-kit/blob/master/stories/2%20-%20Presets/Sortable/MultipleContainers.tsx */}\n      <DndContext sensors={sensors} collisionDetection={closestCenter} onDragOver={handleDragOver}>\n        <div className=\"space-y-4\">\n          {/* Main toolbar */}\n\n          <DroppableContainer>\n            <SortableContext\n              items={actionOrder.main.map((item) => item)}\n              strategy={verticalListSortingStrategy}\n            >\n              {actionOrder.main.map((id) => (\n                <SortableActionButton key={id} id={id} />\n              ))}\n            </SortableContext>\n          </DroppableContainer>\n\n          {/* More panel */}\n          <div className=\"mb-4\">\n            <h2 className=\"text-title2 font-semibold text-text\">\n              {t(\"customizeToolbar.more_actions.title\")}\n            </h2>\n            <p className=\"text-headline text-text-secondary\">\n              {t(\"customizeToolbar.more_actions.description\")}\n            </p>\n          </div>\n\n          <DroppableContainer>\n            <SortableContext\n              items={actionOrder.more.map((item) => item)}\n              strategy={verticalListSortingStrategy}\n            >\n              {actionOrder.more.map((id) => (\n                <SortableActionButton key={id} id={id} />\n              ))}\n            </SortableContext>\n          </DroppableContainer>\n        </div>\n      </DndContext>\n\n      <div className=\"flex justify-end\">\n        <Button variant=\"outline\" onClick={resetActionOrder}>\n          {t(\"customizeToolbar.reset_layout\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nexport const useShowCustomizeToolbarModal = () => {\n  const [t] = useTranslation(\"settings\")\n  const { present } = useModalStack()\n\n  return useCallback(() => {\n    present({\n      id: \"customize-toolbar\",\n      title: t(\"customizeToolbar.title\"),\n      content: () => <CustomizeToolbar />,\n      overlay: true,\n      clickOutsideToDismiss: true,\n    })\n  }, [present, t])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/debug/registry.ts",
    "content": "class Registry {\n  private actions: Record<string, () => void> = {}\n\n  add(key: string, action: () => void) {\n    this.actions[key] = action\n\n    return () => {\n      delete this.actions[key]\n    }\n  }\n\n  remove(key: string) {\n    delete this.actions[key]\n  }\n\n  getAll() {\n    return this.actions\n  }\n\n  get(key: string) {\n    return this.actions[key]\n  }\n}\n\nexport const DebugRegistry = new Registry()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Card, CardContent, CardHeader } from \"@follow/components/ui/card/index.jsx\"\nimport { RelativeTime } from \"@follow/components/ui/datetime/index.js\"\nimport { useIsSubscribed } from \"@follow/store/subscription/hooks\"\nimport { getBackgroundGradient } from \"@follow/utils/color\"\nimport { cn, formatNumber } from \"@follow/utils/utils\"\nimport type { DiscoveryItem, TrendingFeedItem } from \"@follow-app/client-sdk\"\nimport type { FC } from \"react\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useLocation } from \"react-router\"\n\nimport { Media } from \"~/components/ui/media/Media\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { navigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useFeedSafeUrl } from \"~/hooks/common/useFeedSafeUrl\"\n\nimport { FollowSummary } from \"../feed/feed-summary\"\n\nexport function FeedCardActions<T extends TrendingFeedItem | DiscoveryItem>({\n  item,\n  onSuccess,\n  isSubscribed,\n  followButtonVariant,\n  followedButtonVariant = \"ghost\",\n  followButtonClassName,\n  followedButtonClassName,\n}: {\n  item: T\n  onSuccess?: (item: T) => void\n  isSubscribed: boolean\n  followButtonVariant?: \"ghost\" | \"outline\"\n  followedButtonVariant?: \"ghost\" | \"outline\"\n  followButtonClassName?: string\n  followedButtonClassName?: string\n}) {\n  const follow = useFollow()\n  const { t } = useTranslation()\n  const location = useLocation()\n\n  return (\n    <div className=\"flex items-center justify-between gap-2\">\n      {!isSubscribed && (\n        <Button\n          variant=\"ghost\"\n          disabled={!item.feed?.id}\n          buttonClassName=\"rounded-lg px-3 font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-white\"\n          onClick={() => {\n            if (!item.feed?.id) return\n            navigateEntry({\n              feedId: item.feed.id,\n              view: item.analytics?.view ?? 0,\n              backPath: `${location.pathname}${location.search}`,\n            })\n          }}\n        >\n          {t(\"discover.preview\")}\n        </Button>\n      )}\n      <Button\n        variant={isSubscribed ? followedButtonVariant : followButtonVariant}\n        onClick={() => {\n          follow({\n            isList: \"list\" in item && !!item.list?.id,\n            id: (\"list\" in item && item.list?.id) || item.feed?.id,\n            url: item.feed?.url,\n            onSuccess() {\n              onSuccess?.(item)\n            },\n          })\n        }}\n        buttonClassName={cn(\n          \"relative overflow-hidden rounded-lg font-medium transition-all duration-300\",\n          isSubscribed ? \"border-zinc-200/80 px-3 text-zinc-400 dark:border-zinc-700/80\" : \"\",\n          isSubscribed ? followedButtonClassName : followButtonClassName,\n        )}\n      >\n        {isSubscribed ? t(\"feed.actions.followed\") : t(\"feed.actions.follow\")}\n      </Button>\n    </div>\n  )\n}\n\ninterface DiscoverFeedCardProps {\n  item: DiscoveryItem\n  onSuccess?: (item: DiscoveryItem) => void\n  onUnSubscribed?: (item: DiscoveryItem) => void\n\n  className?: string\n}\n\nexport const DiscoverFeedCard: FC<DiscoverFeedCardProps> = memo(\n  ({ item, onSuccess, className }) => {\n    const { t } = useTranslation(\"common\")\n\n    const isSubscribed = useIsSubscribed(item.feed?.id || item.list?.id || \"\")\n\n    return (\n      <Card\n        data-feed-id={item.feed?.id || item.list?.id}\n        className={cn(\n          \"select-text overflow-hidden border border-zinc-200/50 bg-white/80 backdrop-blur-xl transition-all duration-300 dark:border-zinc-800/50 dark:bg-neutral-800/50\",\n          className,\n        )}\n      >\n        <CardHeader className=\"p-4 pb-2\">\n          <FollowSummary feed={(item.feed || item.list!) as any} docs={item.docs} />\n        </CardHeader>\n        <CardContent className=\"px-4\">\n          {item.docs ? (\n            <a href={item.docs} target=\"_blank\" rel=\"noreferrer\">\n              <Button buttonClassName=\"rounded-full bg-zinc-900 px-6 text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-zinc-900\">\n                View Docs\n              </Button>\n            </a>\n          ) : (\n            <>\n              {!!item.entries?.length && (\n                <div className=\"mt-2\">\n                  <div className=\"grid grid-cols-2 gap-3 md:grid-cols-4\">\n                    {item.entries\n                      .filter((e) => !!e)\n                      .map((entry) => (\n                        <SearchResultContent key={entry.id} entry={entry} />\n                      ))}\n                  </div>\n                </div>\n              )}\n              <div className=\"mt-4 flex justify-between gap-4\">\n                <div className=\"flex items-center gap-3 text-sm text-text-secondary\">\n                  {!!item.analytics?.subscriptionCount && (\n                    <div className=\"flex items-center gap-1.5\">\n                      <i className=\"i-mgc-user-3-cute-re\" />\n\n                      <span>\n                        {formatNumber(item.analytics.subscriptionCount)}{\" \"}\n                        {t(\"feed.follower\", { count: item.analytics.subscriptionCount })}\n                      </span>\n                    </div>\n                  )}\n                  {item.analytics?.updatesPerWeek ? (\n                    <div className=\"flex items-center gap-1.5\">\n                      <i className=\"i-mgc-safety-certificate-cute-re\" />\n                      <span>\n                        {t(\"feed.entry_week\", { count: item.analytics.updatesPerWeek ?? 0 })}\n                      </span>\n                    </div>\n                  ) : item.analytics?.latestEntryPublishedAt ? (\n                    <div className=\"flex items-center gap-1.5\">\n                      <i className=\"i-mgc-safe-alert-cute-re\" />\n                      <span>{t(\"feed.updated_at\")}</span>\n                      <RelativeTime\n                        date={item.analytics.latestEntryPublishedAt}\n                        displayAbsoluteTimeAfterDay={Infinity}\n                      />\n                    </div>\n                  ) : null}\n                </div>\n                <FeedCardActions item={item} onSuccess={onSuccess} isSubscribed={isSubscribed} />\n              </div>\n            </>\n          )}\n        </CardContent>\n      </Card>\n    )\n  },\n)\n\nexport const SearchResultContent: FC<{\n  entry: NonUndefined<DiscoveryItem[\"entries\"]>[number]\n}> = memo(({ entry }) => {\n  const safeUrl = useFeedSafeUrl(entry.id)\n  return (\n    <a\n      key={entry.id}\n      href={safeUrl ?? \"#\"}\n      target=\"_blank\"\n      className=\"group relative flex flex-col overflow-hidden rounded-lg bg-zinc-50/50 shadow-zinc-100 transition-all duration-200 hover:-translate-y-px hover:shadow-md dark:bg-zinc-800/50 dark:shadow-neutral-700/50\"\n      rel=\"noreferrer\"\n    >\n      <div className=\"aspect-[3/2] w-full overflow-hidden\">\n        <FeedCardMediaThumbnail entry={entry} />\n      </div>\n      <div className=\"flex flex-1 flex-col justify-between p-3\">\n        {entry.title ? (\n          <div className=\"line-clamp-2 text-xs font-medium leading-4 text-zinc-900 group-hover:text-black dark:text-zinc-200 dark:group-hover:text-white\">\n            {entry.title}\n          </div>\n        ) : (\n          <div className=\"flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400\">\n            <i className=\"i-mgc-link-cute-re shrink-0 translate-y-px self-start text-[14px]\" />\n            <span className=\"line-clamp-2 break-all\">{entry.url || \"Untitled\"}</span>\n          </div>\n        )}\n        <div className=\"mt-1 text-xs text-zinc-500 dark:text-zinc-400\">\n          <RelativeTime date={entry.publishedAt} displayAbsoluteTimeAfterDay={Infinity} />\n        </div>\n      </div>\n    </a>\n  )\n})\n\nconst FeedCardMediaThumbnail: FC<{\n  entry: NonUndefined<DiscoveryItem[\"entries\"]>[number]\n}> = ({ entry }) => {\n  const [, , , bgAccent, bgAccentLight] = getBackgroundGradient(\n    entry.title || entry.url || \"Untitled\",\n  )\n\n  if (entry.media?.[0]) {\n    return (\n      <div className=\"relative size-full bg-zinc-100 dark:bg-zinc-800\">\n        <Media\n          src={entry.media[0].url}\n          type={entry.media[0].type}\n          previewImageUrl={entry.media[0].preview_image_url}\n          className=\"size-full object-cover\"\n          mediaContainerClassName=\"size-full object-cover\"\n        />\n        <div className=\"absolute inset-0 bg-gradient-to-t from-black/5 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100\" />\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"relative size-full bg-zinc-100 dark:bg-zinc-800\">\n      <div\n        className=\"absolute inset-0 transition-transform duration-200 group-hover:scale-[1.01]\"\n        style={{\n          background: `linear-gradient(145deg, ${bgAccent}, ${bgAccentLight})`,\n        }}\n      />\n      <div className=\"absolute inset-0 flex items-center justify-center\">\n        {entry.title ? (\n          <div className=\"text-base font-medium text-white/90\">\n            {entry.title.slice(0, 1).toUpperCase()}\n          </div>\n        ) : (\n          <i className=\"i-mingcute-news-line text-xl text-white/80\" />\n        )}\n      </div>\n      <div className=\"absolute inset-0 bg-black/0 transition-colors duration-200 group-hover:bg-black/5\" />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedForm.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { Form, FormField, FormItem, FormLabel } from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@follow/components/ui/select/index.jsx\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport {\n  MissingOptionalParamError,\n  parseFullPathParams,\n  parseRegexpPathParams,\n  regexpPathToPath,\n} from \"@follow/utils/path-parser\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { RSSHubRouteMetadata } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { m } from \"motion/react\"\nimport type { FC } from \"react\"\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from \"react\"\nimport type { UseFormReturn } from \"react-hook-form\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { CopyButton } from \"~/components/ui/button/CopyButton\"\nimport { Markdown } from \"~/components/ui/markdown/Markdown\"\nimport {\n  useCurrentModal,\n  useIsInModal,\n  useIsTopModal,\n  useModalStack,\n} from \"~/components/ui/modal/stacked/hooks\"\n\nimport { FeedForm } from \"./FeedForm\"\nimport { normalizeRSSHubParameters } from \"./utils\"\n\nconst FeedMaintainers = ({ maintainers }: { maintainers?: string[] }) => {\n  if (!maintainers || maintainers.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"mb-2 flex flex-col gap-x-1 text-sm text-text\">\n      <Trans\n        i18nKey=\"discover.feed_maintainers\"\n        components={{\n          maintainers: (\n            <span className=\"inline-flex flex-wrap items-center gap-2\">\n              {maintainers.map((maintainer) => (\n                <a\n                  href={`https://github.com/${maintainer}`}\n                  key={maintainer}\n                  target=\"_blank\"\n                  rel=\"noreferrer noopener\"\n                  className=\"inline-flex cursor-pointer items-center text-text-secondary duration-200 hover:text-accent\"\n                >\n                  @{maintainer}\n                  <i className=\"i-mgc-external-link-cute-re ml-0.5\" />\n                </a>\n              ))}\n            </span>\n          ),\n        }}\n      />\n    </div>\n  )\n}\n\nconst routeParamsKeyPrefix = \"route-params-\"\n\nexport type RouteParams = Record<\n  string,\n  {\n    description: string\n    default?: string\n  }\n>\n\nexport const DiscoverFeedForm = ({\n  route,\n  routePrefix,\n  noDescription,\n  routeParams,\n  viewportClassName,\n  rootClassName,\n}: {\n  route: RSSHubRouteMetadata\n  routePrefix: string\n  noDescription?: boolean\n  routeParams?: RouteParams\n  viewportClassName?: string\n  rootClassName?: string\n}) => {\n  const { t } = useTranslation()\n  const keys = useMemo(() => parseRegexpPathParams(route.path), [route.path])\n\n  const formPlaceholder = useMemo<Record<string, string>>(() => {\n    if (!route.example) return {}\n    return parseFullPathParams(route.example.replace(`/${routePrefix}`, \"\"), route.path)\n  }, [route.example, route.path, routePrefix])\n  const dynamicFormSchema = useMemo(\n    () =>\n      z.object({\n        ...Object.fromEntries(\n          keys\n            .map((keyItem) => [\n              keyItem.name,\n              keyItem.optional ? z.string().optional().nullable() : z.string().min(1),\n            ])\n            .concat(\n              routeParams\n                ? Object.entries(routeParams).map(([key]) => [\n                    `${routeParamsKeyPrefix}${key}`,\n                    z.string(),\n                  ])\n                : [],\n            ),\n        ),\n      }),\n    [keys, routeParams],\n  )\n\n  const defaultValue = useMemo(() => {\n    const ret = {}\n    if (!route.parameters) return ret\n    for (const key in route.parameters) {\n      const params = normalizeRSSHubParameters(route.parameters[key]!)\n      if (!params) continue\n      ret[key] = params.default || \"\"\n    }\n    return ret\n  }, [route.parameters])\n\n  const form = useForm<z.infer<typeof dynamicFormSchema>>({\n    resolver: zodResolver(dynamicFormSchema),\n    defaultValues: defaultValue,\n    mode: \"all\",\n  }) as UseFormReturn<any>\n\n  const { present, dismissAll } = useModalStack()\n  const rootContainerRef = useRef<HTMLDivElement>(null)\n  const isInModal = useIsInModal()\n\n  const onSubmit = useCallback(\n    (_data: Record<string, string>) => {\n      const data = Object.fromEntries(\n        Object.entries(_data).filter(([key]) => !key.startsWith(routeParamsKeyPrefix)),\n      )\n\n      try {\n        const routeParamsPath = encodeURIComponent(\n          Object.entries(_data)\n            .filter(([key, value]) => key.startsWith(routeParamsKeyPrefix) && value)\n            .map(([key, value]) => [key.slice(routeParamsKeyPrefix.length), value])\n            .map(([key, value]) => `${key}=${value}`)\n            .join(\"&\"),\n        )\n\n        const fillRegexpPath = regexpPathToPath(\n          routeParams && routeParamsPath\n            ? route.path.slice(0, route.path.indexOf(\"/:routeParams\"))\n            : route.path,\n          data,\n        )\n        const url = `rsshub://${routePrefix}${fillRegexpPath}`\n\n        const finalUrl = routeParams && routeParamsPath ? `${url}/${routeParamsPath}` : url\n\n        present({\n          title: t(\"feed_form.add_feed\"),\n          modalContentClassName: \"overflow-visible\",\n          content: () => <FeedForm url={finalUrl} onSuccess={dismissAll} />,\n        })\n      } catch (err: unknown) {\n        if (err instanceof MissingOptionalParamError) {\n          toast.error(err.message)\n          const idx = keys.findIndex((item) => item.name === err.param)\n\n          form.setFocus(keys[idx === 0 ? 0 : idx - 1]!.name, {\n            shouldSelect: true,\n          })\n        }\n      }\n    },\n    [dismissAll, form, keys, present, route, routeParams, routePrefix],\n  )\n\n  const formElRef = useRef<HTMLFormElement>(null)\n  const isTop = useIsTopModal()\n  useLayoutEffect(() => {\n    if (!isTop) return\n    const $form = formElRef.current\n    if (!$form) return\n    $form.querySelectorAll(\"input\")[0]?.focus()\n  }, [formElRef, isTop])\n\n  const modal = useCurrentModal()\n\n  useEffect(() => {\n    modal.setClickOutSideToDismiss(!form.formState.isDirty)\n  }, [form.formState.isDirty, modal])\n\n  return (\n    <div className={cn(\"flex h-full flex-col\", \"mx-auto\")} ref={rootContainerRef}>\n      <Form {...form}>\n        <ScrollArea.ScrollArea\n          rootClassName={cn(isInModal && \"-mx-4 -mt-4\", rootClassName)}\n          viewportClassName={cn(\"pt-4 px-4 max-h-[calc(100vh-200px)]\", viewportClassName)}\n        >\n          <div className=\"flex\">\n            <div className=\"w-0 grow truncate\">\n              {!noDescription && (\n                <PreviewUrl\n                  watch={form.watch}\n                  path={route.path}\n                  routePrefix={`rsshub://${routePrefix}`}\n                />\n              )}\n              <form\n                id=\"discover-feed-form\"\n                onSubmit={form.handleSubmit(onSubmit)}\n                className=\"flex flex-col gap-4 px-1\"\n                ref={formElRef}\n              >\n                {keys.map((keyItem) => {\n                  const parameters = normalizeRSSHubParameters(route.parameters?.[keyItem.name]!)\n\n                  const { ref } = form.register(keyItem.name)\n\n                  return (\n                    <FormField\n                      control={form.control}\n                      key={keyItem.name}\n                      name={keyItem.name}\n                      render={({ field }) => (\n                        <FormItem className=\"flex flex-col space-y-2\">\n                          <FormLabel className=\"pl-3 text-headline capitalize text-text\">\n                            {keyItem.name}\n                            {!keyItem.optional && <sup className=\"ml-1 align-sub text-red\">*</sup>}\n                          </FormLabel>\n                          {parameters?.options ? (\n                            <Select\n                              {...field}\n                              onValueChange={(value) => {\n                                field.onChange(value)\n                              }}\n                              defaultValue={parameters.default || void 0}\n                            >\n                              <SelectTrigger ref={ref}>\n                                <SelectValue placeholder={t(\"discover.select_placeholder\")} />\n                              </SelectTrigger>\n                              <SelectContent>\n                                {parameters.options.map((option) => (\n                                  <SelectItem key={option.value} value={option.value || \"\"}>\n                                    {option.label}\n                                    {parameters.default === option.value &&\n                                      t(\"discover.default_option\")}\n                                  </SelectItem>\n                                ))}\n                              </SelectContent>\n                            </Select>\n                          ) : (\n                            <Input\n                              {...field}\n                              onBlur={() => {\n                                nextFrame(() => {\n                                  field.onBlur()\n                                })\n                              }}\n                              placeholder={\n                                (parameters?.default ?? formPlaceholder[keyItem.name])\n                                  ? `e.g. ${formPlaceholder[keyItem.name]}`\n                                  : void 0\n                              }\n                            />\n                          )}\n                          {!!parameters && (\n                            <Markdown className=\"w-full max-w-full whitespace-normal break-all pl-3 text-footnote text-text-secondary\">\n                              {parameters.description}\n                            </Markdown>\n                          )}\n                        </FormItem>\n                      )}\n                    />\n                  )\n                })}\n                {routeParams && (\n                  <div className=\"grid grid-cols-1 gap-x-2 gap-y-5 sm:grid-cols-2\">\n                    {Object.entries(routeParams).map(([key, value]) => (\n                      <FormItem\n                        key={`${routeParamsKeyPrefix}${key}`}\n                        className=\"flex flex-col space-y-2\"\n                      >\n                        <FormLabel className=\"pl-3 text-headline capitalize text-text\">\n                          {key}\n                        </FormLabel>\n                        <Input\n                          {...form.register(`${routeParamsKeyPrefix}${key}`)}\n                          placeholder={value.default}\n                          className=\"grow-0\"\n                        />\n                        {!!value.description && (\n                          <Markdown className=\"w-full max-w-full text-wrap pl-3 text-footnote text-text-secondary\">\n                            {value.description}\n                          </Markdown>\n                        )}\n                      </FormItem>\n                    ))}\n                  </div>\n                )}\n                {!noDescription && (\n                  <>\n                    <FeedMaintainers maintainers={route.maintainers} />\n                  </>\n                )}\n\n                <RootPortal to={rootContainerRef.current}>\n                  <div className=\"flex items-center justify-end gap-4 pt-2\">\n                    <Button form=\"discover-feed-form\" type=\"submit\">\n                      {t(\"discover.preview\")}\n                    </Button>\n                  </div>\n                </RootPortal>\n              </form>\n            </div>\n          </div>\n        </ScrollArea.ScrollArea>\n      </Form>\n\n      {!noDescription && <ReadmeAside description={route.description} />}\n    </div>\n  )\n}\n\nconst ReadmeAside = ({ description }: { description?: string }) => {\n  const { modalElementRef } = useCurrentModal()\n  const { t } = useTranslation()\n  useLayoutEffect(() => {\n    if (!modalElementRef.current) return\n    modalElementRef.current.style.overflow = \"visible\"\n    modalElementRef.current.style.zIndex = \"2\"\n  }, [modalElementRef])\n\n  if (!description) return null\n  return (\n    <RootPortal to={modalElementRef.current}>\n      <div className=\"absolute inset-y-0 -right-px z-0\">\n        <m.div\n          className=\"absolute inset-y-0 left-0 z-[-1] flex w-[40ch] flex-col rounded-md border border-border bg-background pt-4 shadow-lg\"\n          initial={{ x: \"-50px\" }}\n          animate={{ x: \"0px\" }}\n          transition={Spring.presets.smooth}\n        >\n          <h3 className=\"mb-2 shrink-0 px-4 text-headline font-medium\">\n            {t(\"discover.feed_description\")}\n          </h3>\n          <ScrollArea.ScrollArea viewportClassName=\"px-4 pb-4\" rootClassName=\"h-0 grow\">\n            <div className=\"pr-4\">\n              <Markdown className=\"w-full cursor-text select-text break-words prose-p:my-1\">\n                {/* Fix markdown directive */}\n                {description.replaceAll(\"::: \", \":::\")}\n              </Markdown>\n            </div>\n          </ScrollArea.ScrollArea>\n        </m.div>\n      </div>\n    </RootPortal>\n  )\n}\n\nconst PreviewUrl: FC<{\n  watch: UseFormReturn<any>[\"watch\"]\n  path: string\n  routePrefix: string\n}> = ({ watch, path, routePrefix }) => {\n  const data = watch()\n\n  const fullPath = useMemo(() => {\n    try {\n      return regexpPathToPath(path, data)\n    } catch (err: unknown) {\n      console.info((err as Error).message)\n      return path\n    }\n  }, [path, data])\n\n  const renderedPath = `${routePrefix}${fullPath}`\n  return (\n    <div className=\"group relative min-w-0 px-1 pb-2\">\n      <pre className=\"relative w-full whitespace-pre-line break-words rounded bg-material-medium p-2 text-xs text-text-secondary\">\n        {renderedPath}\n        <div className=\"absolute right-1 top-1/2 -translate-y-1/2\">\n          <CopyButton\n            variant=\"outline\"\n            value={renderedPath}\n            className=\"opacity-0 duration-200 group-hover:opacity-100\"\n          />\n        </div>\n      </pre>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverForm.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { Button, MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport type { DiscoveryItem } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { repository } from \"@pkg\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { produce } from \"immer\"\nimport { atom, useAtomValue, useStore } from \"jotai\"\nimport type { ChangeEvent, CompositionEvent } from \"react\"\nimport { startTransition, useCallback, useEffect } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { useSearchParams } from \"react-router\"\nimport { z } from \"zod\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { followClient } from \"~/lib/api-client\"\n\nimport { DiscoverFeedCard } from \"./DiscoverFeedCard\"\nimport { FeedForm } from \"./FeedForm\"\n\nconst isFeedLikeUrl = (value: string) => /^(?:https?:\\/\\/|folo:\\/\\/|follow:\\/\\/)/.test(value.trim())\n\nconst FEED_DISCOVERY_INFO = {\n  search: {\n    label: \"discover.any_url_or_keyword\",\n    schema: z.object({\n      keyword: z.string().min(1),\n      target: z.enum([\"feeds\", \"lists\"]),\n    }),\n  },\n  rss: {\n    label: \"discover.rss_url\",\n    default: \"https://\",\n    prefix: [\"https://\", \"http://\"],\n    showModal: true,\n    labelSuffix: (\n      <a\n        href={`${repository.url}/wiki/Folo-Flavored-Feed-Spec`}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        className=\"inline-flex w-auto items-center gap-1 rounded-full border border-accent px-2 py-px text-sm font-normal text-accent\"\n      >\n        <i className=\"i-mgc-book-6-cute-re\" />\n        <span>Folo Flavored Feed Spec</span>\n      </a>\n    ),\n    schema: z.object({\n      keyword: z.string().refine(isFeedLikeUrl, {\n        message: \"Invalid RSS URL\",\n      }),\n    }),\n  },\n  rsshub: {\n    label: \"discover.rss_hub_route\",\n    prefix: [\"rsshub://\"],\n    default: \"rsshub://\",\n    showModal: true,\n    labelSuffix: (\n      <a\n        href=\"https://docs.rsshub.app/\"\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        className=\"inline-flex w-auto items-center gap-1 rounded-full border border-accent px-2 py-px text-sm font-normal text-accent\"\n      >\n        <i className=\"i-mgc-book-6-cute-re\" />\n        <span>RSSHub Docs</span>\n      </a>\n    ),\n    schema: z.object({\n      keyword: z.string().url().startsWith(\"rsshub://\"),\n    }),\n  },\n} satisfies Record<\n  string,\n  {\n    label: I18nKeys\n    prefix?: string[]\n    showModal?: boolean\n    default?: string\n    labelSuffix?: React.ReactNode\n    schema?: any\n  }\n>\n\nconst discoverSearchDataAtom = atom<Record<string, DiscoveryItem[]>>()\n\nexport function DiscoverForm({ type = \"search\" }: { type?: string }) {\n  const {\n    prefix,\n    default: defaultValue,\n    schema: formSchema,\n    label,\n    labelSuffix,\n    showModal,\n  } = FEED_DISCOVERY_INFO[type]!\n\n  const [searchParams, setSearchParams] = useSearchParams()\n  const keywordFromSearch = searchParams.get(\"keyword\") || \"\"\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      keyword: defaultValue || keywordFromSearch || \"\",\n      target: \"feeds\",\n    },\n    mode: \"all\",\n  })\n  const { watch, trigger } = form\n\n  // validate default value from search params\n  useEffect(() => {\n    if (!keywordFromSearch) {\n      return\n    }\n    trigger(\"keyword\")\n  }, [trigger, keywordFromSearch])\n\n  const target = watch(\"target\")\n  const atomKey = keywordFromSearch + target\n  const { t } = useTranslation()\n  const { ensureLogin } = useRequireLogin()\n\n  const jotaiStore = useStore()\n  const mutation = useMutation({\n    mutationFn: async ({ keyword, target }: { keyword: string; target: \"feeds\" | \"lists\" }) => {\n      const { data } = await followClient.api.discover.discover({\n        keyword: keyword.trim(),\n        target,\n      })\n\n      jotaiStore.set(discoverSearchDataAtom, (prev) => ({\n        ...prev,\n        [atomKey]: data,\n      }))\n\n      return data\n    },\n  })\n\n  const discoverSearchData = useAtomValue(discoverSearchDataAtom)?.[atomKey] || []\n\n  const { present, dismissAll } = useModalStack()\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    if (!ensureLogin()) {\n      return\n    }\n    if (FEED_DISCOVERY_INFO[type]!.showModal) {\n      present({\n        title: t(\"feed_form.add_feed\"),\n        content: () => <FeedForm url={values.keyword} onSuccess={dismissAll} />,\n      })\n    } else {\n      mutation.mutate(values)\n    }\n  }\n\n  const normalizeAndSet = useCallback(\n    (rawValue: string) => {\n      startTransition(() => {\n        const trimmedKeyword = rawValue.trimStart()\n        if (!prefix) {\n          setValue(trimmedKeyword)\n          return\n        }\n        const isValidPrefix = prefix.find((p) => trimmedKeyword.startsWith(p))\n        if (!isValidPrefix) {\n          setValue(prefix[0]!)\n          return\n        }\n        if (trimmedKeyword.startsWith(`${isValidPrefix}${isValidPrefix}`)) {\n          setValue(trimmedKeyword.slice(isValidPrefix.length))\n          return\n        }\n        setValue(trimmedKeyword)\n\n        function setValue(value: string) {\n          form.setValue(\"keyword\", value, { shouldValidate: true })\n          syncKeyword(value)\n        }\n\n        function syncKeyword(keyword: string) {\n          setSearchParams(\n            (prev) => {\n              const newParams = new URLSearchParams(prev)\n              if (keyword) {\n                newParams.set(\"keyword\", keyword)\n              } else {\n                newParams.delete(\"keyword\")\n              }\n              return newParams\n            },\n            {\n              replace: true,\n            },\n          )\n        }\n      })\n    },\n    [form, prefix, setSearchParams],\n  )\n\n  const handleKeywordChange = useCallback(\n    (event: ChangeEvent<HTMLInputElement>) => {\n      const { value } = event.currentTarget\n      // During composition, update raw value without normalization or syncing URL params\n      if ((event.nativeEvent as InputEvent)?.isComposing) {\n        form.setValue(\"keyword\", value, { shouldValidate: false })\n        return\n      }\n      normalizeAndSet(value)\n    },\n    [form, normalizeAndSet],\n  )\n  const handleCompositionEnd = useCallback(\n    (event: CompositionEvent<HTMLInputElement>) => {\n      normalizeAndSet(event.currentTarget.value)\n    },\n    [normalizeAndSet],\n  )\n\n  const handleSuccess = useCallback(\n    (item: DiscoveryItem) => {\n      const currentData = jotaiStore.get(discoverSearchDataAtom)\n      if (!currentData) return\n      jotaiStore.set(\n        discoverSearchDataAtom,\n        produce(currentData, (draft) => {\n          const sub = (draft[atomKey] || []).find((i) => {\n            if (item.feed) {\n              return i.feed?.id === item.feed.id\n            }\n            if (item.list) {\n              return i.list?.id === item.list.id\n            }\n            return false\n          })\n          if (!sub) return\n          sub.subscriptionCount = -~(sub.subscriptionCount as number)\n        }),\n      )\n    },\n    [atomKey, jotaiStore],\n  )\n\n  const handleUnSubscribed = useCallback(\n    (item: DiscoveryItem) => {\n      const currentData = jotaiStore.get(discoverSearchDataAtom)\n      if (!currentData) return\n      jotaiStore.set(\n        discoverSearchDataAtom,\n        produce(currentData, (draft) => {\n          const sub = (draft[atomKey] || []).find(\n            (i) => i.feed?.id === item.feed?.id || i.list?.id === item.list?.id,\n          )\n          if (!sub) return\n          sub.subscriptionCount = Number.isNaN(sub.subscriptionCount)\n            ? 0\n            : (sub.subscriptionCount as number) - 1\n        }),\n      )\n    },\n    [atomKey, jotaiStore],\n  )\n\n  const handleTargetChange = useCallback(\n    (value: string) => {\n      form.setValue(\"target\", value as \"feeds\" | \"lists\")\n    },\n    [form],\n  )\n\n  const isMobile = useMobile()\n\n  return (\n    <>\n      <Form {...form}>\n        <form\n          onSubmit={form.handleSubmit(onSubmit)}\n          className=\"w-full max-w-[540px]\"\n          data-testid=\"discover-form\"\n        >\n          <div className=\"p-5\">\n            <FormField\n              control={form.control}\n              name=\"keyword\"\n              render={({ field }) => (\n                <FormItem className=\"mb-4\">\n                  <FormLabel className=\"mb-2 flex items-center gap-2 pl-2 text-headline font-bold text-text\">\n                    {t(label)}\n                    {labelSuffix}\n                  </FormLabel>\n                  <FormControl>\n                    <Input\n                      autoFocus\n                      data-testid=\"discover-form-input\"\n                      {...field}\n                      onChange={handleKeywordChange}\n                      onCompositionEnd={handleCompositionEnd}\n                      placeholder={type === \"search\" ? \"Enter URL or keyword...\" : undefined}\n                    />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            {type === \"search\" && (\n              <FormField\n                control={form.control}\n                name=\"target\"\n                render={({ field }) => (\n                  <FormItem className=\"mb-4 pl-2\">\n                    <div className=\"mb-2 flex items-center justify-between\">\n                      <FormLabel className=\"text-headline font-medium text-text-secondary\">\n                        {t(\"discover.target.label\")}\n                      </FormLabel>\n                      <FormControl>\n                        <div className=\"flex\">\n                          {isMobile ? (\n                            <ResponsiveSelect\n                              size=\"sm\"\n                              value={field.value}\n                              onValueChange={handleTargetChange}\n                              items={[\n                                { label: t(\"discover.target.feeds\"), value: \"feeds\" },\n                                { label: t(\"discover.target.lists\"), value: \"lists\" },\n                              ]}\n                            />\n                          ) : (\n                            <SegmentGroup\n                              className=\"-mt-2 h-8\"\n                              value={field.value}\n                              onValueChanged={handleTargetChange}\n                            >\n                              <SegmentItem value=\"feeds\" label={t(\"discover.target.feeds\")} />\n                              <SegmentItem value=\"lists\" label={t(\"discover.target.lists\")} />\n                            </SegmentGroup>\n                          )}\n                        </div>\n                      </FormControl>\n                    </div>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n            )}\n            <div className=\"center flex\" data-testid=\"discover-form-actions\">\n              <Button\n                data-testid=\"discover-form-submit\"\n                disabled={!form.formState.isValid}\n                type=\"submit\"\n                isLoading={mutation.isPending}\n              >\n                {showModal ? t(\"discover.preview\") : t(\"words.search\")}\n              </Button>\n            </div>\n          </div>\n        </form>\n      </Form>\n\n      <div className=\"mt-8 w-full max-w-lg\">\n        {(mutation.isSuccess || !!discoverSearchData?.length) && (\n          <div className=\"mb-4 flex items-center gap-2 text-sm text-zinc-500\">\n            {t(\"discover.search.results\", { count: discoverSearchData?.length || 0 })}\n\n            {discoverSearchData && discoverSearchData.length > 0 && (\n              <MotionButtonBase\n                className=\"flex cursor-button items-center justify-between gap-2 hover:text-accent\"\n                type=\"button\"\n                onClick={() => {\n                  jotaiStore.set(discoverSearchDataAtom, {\n                    ...jotaiStore.get(discoverSearchDataAtom),\n                    [atomKey]: [],\n                  })\n                  mutation.reset()\n                }}\n              >\n                <i className=\"i-mgc-close-cute-re\" />\n              </MotionButtonBase>\n            )}\n          </div>\n        )}\n        <div className=\"space-y-4 text-sm\">\n          {discoverSearchData?.map((item) => (\n            <DiscoverFeedCard\n              key={item.feed?.id || item.list?.id}\n              item={item}\n              onSuccess={handleSuccess}\n              onUnSubscribed={handleUnSubscribed}\n              className=\"last:border-b-0\"\n            />\n          ))}\n        </div>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverImport.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { CollapseCss, CollapseCssGroup } from \"@follow/components/ui/collapse/index.js\"\nimport { DropZone } from \"@follow/components/ui/drop-zone/index.js\"\nimport { Form, FormControl, FormField, FormItem } from \"@follow/components/ui/form/index.jsx\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { Fragment } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { z } from \"zod\"\n\nimport { Media } from \"~/components/ui/media/Media\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { followClient } from \"~/lib/api-client\"\nimport { toastFetchError } from \"~/lib/error-parser\"\n\nimport { OpmlSelectionModal } from \"./OpmlSelectionModal\"\n\nconst parseOpmlFile = async (file: File) => {\n  const data = await followClient.api.subscriptions.parseOpml(await file.arrayBuffer())\n\n  return data.data\n}\n\nconst formSchema = z.object({\n  file: z\n    .instanceof(File)\n    .refine((file) => file.size < 500_000, {\n      message: \"Your OPML file must be less than 500KB.\",\n    })\n    .refine((file) => file.name.endsWith(\".opml\") || file.name.endsWith(\".xml\"), {\n      message: \"Your OPML file must be in OPML or XML format.\",\n    }),\n})\n\nexport function DiscoverImport() {\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n  })\n\n  const { present } = useModalStack()\n\n  const parseOpmlMutation = useMutation({\n    mutationFn: parseOpmlFile,\n    async onError(err) {\n      toastFetchError(err)\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    parseOpmlMutation.mutate(values.file, {\n      onSuccess: (parsedData) => {\n        present({\n          title: t(\"discover.import.preview_opml_content\"),\n          content: () => <OpmlSelectionModal file={values.file} parsedData={parsedData} />,\n          clickOutsideToDismiss: false,\n          modalClassName: \"max-w-2xl w-full h-[80vh]\",\n          modalContentClassName: \"flex flex-col h-full\",\n        })\n      },\n    })\n  }\n\n  const { t } = useTranslation()\n\n  return (\n    <div className=\"flex flex-col\">\n      <div className=\"mb-2 font-medium\">1. {t(\"discover.import.opml_step1\")}</div>\n      <div className=\"mb-6\">\n        <CollapseCssGroup defaultOpenId=\"inoreader\">\n          <CollapseCss\n            collapseId=\"inoreader\"\n            title={\n              <div className=\"flex h-14 items-center justify-normal gap-2 border-border font-medium\">\n                <Media\n                  className=\"size-5\"\n                  src=\"https://inoreader.com/favicon.ico\"\n                  alt=\"inoreader\"\n                  type=\"photo\"\n                />\n                {t(\"discover.import.opml_step1_inoreader\")}\n                <div className=\"absolute inset-x-0 bottom-0 h-px bg-border\" />\n              </div>\n            }\n            contentClassName=\"flex flex-col gap-1\"\n            innerClassName=\"p-4\"\n          >\n            <p>\n              <Trans\n                ns=\"app\"\n                i18nKey=\"discover.import.opml_step1_inoreader_step1\"\n                components={{\n                  Link: (\n                    <a\n                      href=\"https://www.inoreader.com/preferences/content\"\n                      className=\"underline\"\n                      target=\"_blank\"\n                      rel=\"noreferrer\"\n                    >\n                      inoreader.com/preferences/content\n                    </a>\n                  ),\n                }}\n              />\n            </p>\n            <p>{t(\"discover.import.opml_step1_inoreader_step2\")}</p>\n            <p>{t(\"discover.import.opml_step1_inoreader_step3\")}</p>\n          </CollapseCss>\n          <CollapseCss\n            collapseId=\"feedly\"\n            title={\n              <div className=\"flex h-14 items-center justify-normal gap-2 border-border font-medium\">\n                <Media\n                  className=\"size-5\"\n                  src=\"https://feedly.com/favicon.ico\"\n                  alt=\"feedly\"\n                  type=\"photo\"\n                />\n                {t(\"discover.import.opml_step1_feedly\")}\n                <div className=\"absolute inset-x-0 bottom-0 h-px bg-border\" />\n              </div>\n            }\n            contentClassName=\"flex flex-col gap-1\"\n            innerClassName=\"p-4\"\n          >\n            <p>\n              <Trans\n                ns=\"app\"\n                i18nKey=\"discover.import.opml_step1_feedly_step1\"\n                components={{\n                  Link: (\n                    <a\n                      href=\"https://feedly.com/i/opml\"\n                      className=\"underline\"\n                      target=\"_blank\"\n                      rel=\"noreferrer\"\n                    >\n                      feedly.com/i/opml\n                    </a>\n                  ),\n                }}\n              />\n            </p>\n            <p>{t(\"discover.import.opml_step1_feedly_step2\")}</p>\n          </CollapseCss>\n          <CollapseCss\n            collapseId=\"other\"\n            title={\n              <div className=\"flex h-14 items-center justify-normal gap-2 border-border font-medium\">\n                <i className=\"i-mgc-rss-cute-fi ml-[-0.14rem] size-6 text-orange-500\" />\n                {t(\"discover.import.opml_step1_other\")}\n                <div className=\"absolute inset-x-0 bottom-0 h-px bg-border\" />\n              </div>\n            }\n            contentClassName=\"flex flex-col gap-1\"\n            className=\"border-b-0\"\n            innerClassName=\"p-4\"\n          >\n            {t(\"discover.import.opml_step1_other_step1\")}\n          </CollapseCss>\n        </CollapseCssGroup>\n      </div>\n\n      <div className=\"mb-4 font-medium\">2. {t(\"discover.import.opml_step2\")}</div>\n      <Form {...form}>\n        <form onSubmit={form.handleSubmit(onSubmit)} className=\"w-full space-y-8\">\n          <FormField\n            control={form.control}\n            name=\"file\"\n            render={({ field: { value, onChange } }) => (\n              <FormItem>\n                <FormControl>\n                  <DropZone\n                    id=\"upload-file\"\n                    accept=\".opml,.xml\"\n                    onDrop={(fileList) => onChange(fileList[0])}\n                  >\n                    {form.formState.dirtyFields.file ? (\n                      <Fragment>\n                        <i className=\"i-mgc-file-upload-cute-re size-5\" />\n                        <span className=\"ml-2 text-sm font-semibold opacity-80\">{value.name}</span>\n                      </Fragment>\n                    ) : (\n                      <Fragment>\n                        <i className=\"i-mgc-file-upload-cute-re size-10 text-text-tertiary\" />\n                        <span className=\"ml-2 text-title2 text-text-tertiary\">\n                          {t(\"discover.import.click_to_upload\")}\n                        </span>\n                      </Fragment>\n                    )}\n                  </DropZone>\n                </FormControl>\n              </FormItem>\n            )}\n          />\n          <div className=\"center flex\">\n            <Button\n              type=\"submit\"\n              disabled={!form.formState.dirtyFields.file}\n              isLoading={parseOpmlMutation.isPending}\n            >\n              {t(\"words.import\")}\n            </Button>\n          </div>\n        </form>\n      </Form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverInboxList.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { repository } from \"@pkg\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { InboxTable } from \"./Inbox\"\nimport { InboxForm } from \"./InboxForm\"\n\nexport function DiscoverInboxList() {\n  const { t } = useTranslation()\n\n  const { present } = useModalStack()\n\n  return (\n    <div className=\"mx-auto w-full\">\n      <div className=\"mb-4 flex flex-wrap items-center gap-2 text-sm text-zinc-500\">\n        <span>{t(\"discover.inbox.description\")}</span>\n        <a\n          href={`${repository.url}/wiki/Inbox#webhooks`}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          className=\"inline-flex w-auto items-center gap-1 rounded-full border border-accent px-2 py-px text-sm text-accent\"\n        >\n          <i className=\"i-mgc-book-6-cute-re\" />\n          <span>{t(\"discover.inbox.webhooks_docs\")}</span>\n        </a>\n      </div>\n      <InboxTable />\n      <div className=\"center mt-4 flex\">\n        {/* New Inbox */}\n        <Button\n          textClassName=\"flex items-center gap-2\"\n          onClick={() =>\n            present({\n              title: t(\"sidebar.feed_actions.new_inbox\"),\n              content: () => <InboxForm asWidget />,\n            })\n          }\n        >\n          <i className=\"i-mgc-add-cute-re\" />\n          {t(\"discover.inbox_create\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverTransform.tsx",
    "content": "import { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\n\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { Queries } from \"~/queries\"\n\nimport type { RouteParams } from \"./DiscoverFeedForm\"\nimport { DiscoverFeedForm } from \"./DiscoverFeedForm\"\n\nconst transformRouteParams: RouteParams = {\n  title: { description: \"The title of the RSS\", default: \"Extract from <title>\" },\n  item: { description: \"The HTML elements as item using CSS selector\", default: \"html\" },\n  itemTitle: {\n    description: \"The HTML elements as title in item using CSS selector\",\n    default: \"item element\",\n  },\n  itemTitleAttr: {\n    description: \"The attributes of title element as title\",\n    default: \"Element text\",\n  },\n  itemLink: {\n    description: \"The HTML elements as link in item using CSS selector\",\n    default: \"item element\",\n  },\n  itemLinkAttr: { description: \"The attributes of link element as link\", default: \"href\" },\n  itemDesc: {\n    description: \"The HTML elements as description in item using CSS selector\",\n    default: \"item element\",\n  },\n  itemDescAttr: {\n    description: \"The attributes of description element as description\",\n    default: \"Element html\",\n  },\n  itemPubDate: {\n    description: \"The HTML elements as pubDate in item using CSS selector\",\n    default: \"item element\",\n  },\n  itemPubDateAttr: {\n    description: \"The attributes of pubDate element as pubDate\",\n    default: \"Element html\",\n  },\n  itemContent: {\n    description:\n      \"The HTML elements as description in item using CSS selector ( in itemLink page for full content )\",\n  },\n  encoding: { description: \"The encoding of the HTML content\", default: \"utf-8\" },\n}\n\nexport function DiscoverTransform() {\n  const { data, isLoading } = useAuthQuery(\n    Queries.discover.rsshubNamespace({\n      namespace: \"rsshub\",\n    }),\n    {\n      meta: {\n        persist: true,\n      },\n    },\n  )\n\n  if (isLoading) {\n    return (\n      <div className=\"center mt-12 flex w-full flex-col gap-8\">\n        <LoadingCircle size=\"large\" />\n      </div>\n    )\n  }\n\n  return (\n    <>\n      {data?.rsshub!.routes && (\n        <div className=\"w-full pt-6\">\n          <DiscoverFeedForm\n            routePrefix=\"rsshub\"\n            route={data?.rsshub.routes[\"/transform/html/:url/:routeParams\"]!}\n            routeParams={transformRouteParams}\n            noDescription\n            viewportClassName=\"pt-0 max-h-none\"\n          />\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoverUser.tsx",
    "content": "import { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\n\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { Queries } from \"~/queries\"\n\nimport { DiscoverFeedForm } from \"./DiscoverFeedForm\"\n\nexport function DiscoverUser() {\n  const { data, isLoading } = useAuthQuery(\n    Queries.discover.rsshubNamespace({\n      namespace: \"follow\",\n    }),\n    {\n      meta: {\n        persist: true,\n      },\n    },\n  )\n\n  if (isLoading) {\n    return (\n      <div className=\"center mt-12 flex w-full flex-col gap-8\">\n        <LoadingCircle size=\"large\" />\n      </div>\n    )\n  }\n\n  return (\n    <>\n      {data?.follow!.routes && (\n        <div className=\"w-full pt-2\">\n          <DiscoverFeedForm\n            routePrefix=\"follow\"\n            route={data.follow.routes[Object.keys(data.follow.routes)[0]!]!}\n            noDescription\n          />\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx",
    "content": "import { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport type { ResponsiveSelectItem } from \"@follow/components/ui/select/responsive.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setUISetting, useUISettingKey } from \"~/atoms/settings/ui\"\n\nimport { Trending } from \"../trending\"\nimport { Recommendations } from \"./recommendations\"\n\nconst LanguageOptions = [\n  {\n    label: \"words.all\",\n    value: \"all\",\n  },\n  {\n    label: \"words.english\",\n    value: \"eng\",\n  },\n  {\n    label: \"words.french\",\n    value: \"fra\",\n  },\n  {\n    label: \"words.chinese\",\n    value: \"cmn\",\n  },\n] satisfies ResponsiveSelectItem[]\n\ntype Language = \"all\" | \"eng\" | \"cmn\" | \"fra\"\ntype DiscoveryView = \"trending\" | \"categories\"\n\nexport function DiscoveryContent() {\n  const { t } = useTranslation()\n  const { t: tCommon } = useTranslation(\"common\")\n  const lang = useUISettingKey(\"discoverLanguage\")\n  const [activeView, setActiveView] = useState<DiscoveryView>(\"trending\")\n\n  const handleLangChange = (value: string) => {\n    setUISetting(\"discoverLanguage\", value as Language)\n  }\n\n  return (\n    <div className=\"relative mx-auto w-full max-w-[880px] space-y-5\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <SegmentGroup\n          value={activeView}\n          onValueChanged={(value) => setActiveView(value as DiscoveryView)}\n          className=\"h-9\"\n        >\n          <SegmentItem\n            value=\"trending\"\n            label={\n              <span className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-trending-up-cute-re size-4\" />\n                <span>{t(\"words.trending\")}</span>\n              </span>\n            }\n          />\n          <SegmentItem\n            value=\"categories\"\n            label={\n              <span className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-grid-2-cute-re size-4\" />\n                <span>{t(\"words.categories\")}</span>\n              </span>\n            }\n          />\n        </SegmentGroup>\n\n        <div className=\"flex items-center gap-2\">\n          <span className=\"shrink-0 text-sm font-medium text-text-secondary\">\n            {t(\"words.language\")}:\n          </span>\n          <ResponsiveSelect\n            value={lang}\n            onValueChange={handleLangChange}\n            triggerClassName=\"h-8 rounded border-0 bg-material-ultra-thin\"\n            size=\"sm\"\n            items={LanguageOptions}\n            renderItem={(item) => tCommon(item.label as any)}\n            renderValue={(item) => tCommon(item.label as any)}\n          />\n        </div>\n      </div>\n\n      <div className=\"min-h-[400px] rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm\">\n        {activeView === \"trending\" ? (\n          <Trending center limit={20} hideHeader />\n        ) : (\n          <Recommendations />\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { FeedViewType, UserRole } from \"@follow/constants\"\nimport { useFeedByIdOrUrl } from \"@follow/store/feed/hooks\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport { useCategories, useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { useIsLoggedIn, useUserRole } from \"@follow/store/user/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FeedAnalyticsModel, ParsedEntry } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useCallback, useEffect, useMemo, useRef } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useIsPaymentEnabled } from \"~/atoms/server-configs\"\nimport { Autocomplete } from \"~/components/ui/auto-completion\"\nimport { useCurrentModal, useIsInModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useI18n } from \"~/hooks/common\"\nimport { toastFetchError } from \"~/lib/error-parser\"\nimport { useSettingModal } from \"~/modules/settings/modal/useSettingModal\"\nimport { feed as feedQuery, useFeedQuery } from \"~/queries/feed\"\n\nimport { ViewSelectorRadioGroup } from \"../shared/ViewSelectorRadioGroup\"\nimport { FeedSummary } from \"./FeedSummary\"\n\nconst formSchema = z.object({\n  view: z.string(),\n  category: z.string().nullable().optional(),\n  isPrivate: z.boolean().optional(),\n  hideFromTimeline: z.boolean().optional(),\n  title: z.string().optional(),\n})\nexport type FeedFormDataValuesType = z.infer<typeof formSchema>\n\nexport const PaidBadge = () => {\n  const { t } = useTranslation(\"settings\")\n  const settingModalPresent = useSettingModal()\n  const isPaymentEnabled = useIsPaymentEnabled()\n\n  const handleClick = useCallback(\n    (e) => {\n      e.preventDefault()\n      settingModalPresent(\"plan\")\n    },\n    [settingModalPresent],\n  )\n\n  if (!isPaymentEnabled) {\n    return null\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <i className=\"i-mgc-power block text-accent\" onClick={handleClick} />\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent>{t(\"control.paid_badge.basic_or_higher\")}</TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n\nexport const FeedForm: Component<{\n  url?: string\n  id?: string\n  defaultValues?: FeedFormDataValuesType\n\n  onSuccess?: () => void\n}> = ({ id: _id, defaultValues, url, onSuccess }) => {\n  const queryParams = { id: _id, url }\n\n  const feedQuery = useFeedQuery(queryParams)\n\n  const id = feedQuery.data?.feed.id || _id\n  const feed = useFeedByIdOrUrl({\n    id,\n    url,\n  }) as FeedModel\n\n  const { t } = useTranslation()\n\n  const isInModal = useIsInModal()\n  const placeholderRef = useRef<HTMLDivElement | null>(null)\n\n  useEffect(() => {\n    if (!feedQuery.isLoading) {\n      tracker.subscribeModalOpened({\n        feedId: id,\n        feedUrl: feedQuery.data?.feed.url || url,\n        isError: feedQuery.isError,\n      })\n    }\n  }, [feedQuery.isLoading])\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full max-h-[calc(100vh-300px)] flex-col\",\n        \"mx-auto min-h-[420px] w-full max-w-[550px] lg:min-w-[550px]\",\n      )}\n    >\n      {useMemo(() => {\n        switch (true) {\n          case !!feed: {\n            return (\n              <ScrollArea.ScrollArea\n                flex\n                rootClassName={cn(isInModal && \"-mx-4 px-4 -mt-4\", \"h-[500px] grow\")}\n                viewportClassName=\"pt-4\"\n              >\n                {/* // Workaround for the issue with the scroll area viewport setting the display to\n                table // Learn more about the issue here: //\n                https://github.com/radix-ui/primitives/issues/926\n                https://github.com/radix-ui/primitives/issues/3129\n                https://github.com/radix-ui/primitives/pull/3225 */}\n                <div className=\"flex\">\n                  <div className=\"w-0 grow truncate\">\n                    <FeedInnerForm\n                      {...{\n                        defaultValues,\n                        id,\n                        url,\n\n                        onSuccess,\n                        isLoading: feedQuery.isLoading,\n                        subscriptionData: feedQuery.data?.subscription,\n                        entries: feedQuery.data?.entries,\n                        feed,\n                        analytics: feedQuery.data?.analytics,\n                        placeholderRef,\n                      }}\n                    />\n                  </div>\n                </div>\n              </ScrollArea.ScrollArea>\n            )\n          }\n          case feedQuery.isLoading: {\n            return (\n              <div className=\"flex flex-1 items-center justify-center\">\n                <LoadingCircle size=\"large\" />\n              </div>\n            )\n          }\n          case !!feedQuery.error: {\n            return (\n              <div className=\"center grow flex-col gap-3\">\n                <i className=\"i-mgc-close-cute-re size-7 text-red\" />\n                <p>{t(\"feed_form.error_fetching_feed\")}</p>\n              </div>\n            )\n          }\n          default: {\n            return (\n              <div className=\"center h-full grow flex-col\">\n                <i className=\"i-mgc-question-cute-re mb-6 size-12 text-zinc-500\" />\n                <p>{t(\"feed_form.feed_not_found\")}</p>\n              </div>\n            )\n          }\n        }\n      }, [\n        defaultValues,\n        feed,\n        feedQuery.data?.analytics,\n        feedQuery.data?.entries,\n        feedQuery.data?.subscription,\n        feedQuery.error,\n        feedQuery.isLoading,\n        id,\n        isInModal,\n        onSuccess,\n        t,\n        url,\n      ])}\n      <div ref={placeholderRef} />\n    </div>\n  )\n}\n\nconst FeedInnerForm = ({\n  defaultValues,\n  id,\n\n  onSuccess,\n  subscriptionData,\n  feed,\n  entries,\n  analytics,\n\n  placeholderRef,\n  isLoading,\n}: {\n  defaultValues?: z.infer<typeof formSchema>\n  id?: string\n\n  onSuccess?: () => void\n  subscriptionData?: {\n    view?: number\n    category?: string | null\n    isPrivate?: boolean\n    title?: string | null\n    hideFromTimeline?: boolean | null\n  }\n  feed: FeedModel\n  entries?: ParsedEntry[]\n  analytics?: FeedAnalyticsModel\n\n  placeholderRef: React.RefObject<HTMLDivElement | null>\n  isLoading: boolean\n}) => {\n  const subscription = useSubscriptionByFeedId(id || \"\") || subscriptionData\n  const isSubscribed = !!subscription\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: defaultValues || {\n      view: getRouteParams().view.toString() || FeedViewType.Articles.toString(),\n    },\n  })\n\n  const { setClickOutSideToDismiss, dismiss } = useCurrentModal()\n\n  useEffect(() => {\n    setClickOutSideToDismiss(!form.formState.isDirty)\n  }, [form.formState.isDirty])\n\n  useEffect(() => {\n    if (subscription) {\n      form.setValue(\"view\", `${subscription?.view}`)\n      subscription?.category && form.setValue(\"category\", subscription.category)\n      typeof subscription.isPrivate === \"boolean\" &&\n        form.setValue(\"isPrivate\", subscription.isPrivate)\n      typeof subscription.hideFromTimeline === \"boolean\" &&\n        form.setValue(\"hideFromTimeline\", subscription.hideFromTimeline)\n      subscription?.title && form.setValue(\"title\", subscription.title)\n    }\n  }, [subscription])\n\n  useEffect(() => {\n    if (\n      typeof analytics?.view === \"number\" &&\n      !subscription &&\n      typeof defaultValues?.view !== \"number\"\n    ) {\n      form.setValue(\"view\", `${analytics.view}`)\n    }\n  }, [analytics, subscription, defaultValues?.view])\n\n  const followMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof formSchema>) => {\n      const userId = whoami()?.id || \"\"\n      const body = {\n        url: feed.url,\n        view: Number.parseInt(values.view),\n        category: values.category,\n        isPrivate: values.isPrivate || false,\n        hideFromTimeline: values.hideFromTimeline,\n        title: values.title,\n        feedId: feed.id,\n        userId,\n        type: \"feed\",\n        listId: undefined,\n      } as const\n\n      if (isSubscribed) {\n        return subscriptionSyncService.edit(body)\n      } else {\n        return subscriptionSyncService.subscribe(body)\n      }\n    },\n    onSuccess: () => {\n      const feedId = feed.id\n      if (feedId) {\n        feedQuery.byId({ id: feedId }).invalidate()\n      }\n      toast(isSubscribed ? t(\"feed_form.updated\") : t(\"feed_form.followed\"), {\n        duration: 1000,\n      })\n\n      onSuccess?.()\n    },\n    onError(err) {\n      toastFetchError(err)\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    followMutation.mutate(values)\n  }\n\n  const t = useI18n()\n\n  const isLoggedIn = useIsLoggedIn()\n\n  const categories = useCategories()\n\n  const suggestions = useMemo(\n    () =>\n      (\n        categories?.map((i) => ({\n          name: i,\n          value: i,\n        })) || []\n      ).sort((a, b) => a.name.localeCompare(b.name)),\n    [categories],\n  )\n\n  const fillDefaultTitle = useCallback(() => {\n    form.setValue(\"title\", feed.title || \"\")\n  }, [feed.title, form])\n\n  const role = useUserRole()\n  const isPaymentEnabled = useIsPaymentEnabled()\n  const disabledForRole = role === UserRole.Free && isPaymentEnabled\n\n  return (\n    <div className=\"flex flex-1 flex-col gap-y-4\">\n      <FeedSummary isLoading={isLoading} feed={feed} analytics={analytics} showAnalytics />\n      <Form {...form}>\n        <form\n          id=\"feed-form\"\n          data-testid=\"feed-form\"\n          onSubmit={form.handleSubmit(onSubmit)}\n          className=\"flex flex-1 flex-col gap-y-4 px-1\"\n        >\n          <FormField\n            control={form.control}\n            name=\"title\"\n            render={({ field }) => (\n              <FormItem>\n                <div>\n                  <FormLabel>{t(\"feed_form.title\")}</FormLabel>\n                  <FormDescription>{t(\"feed_form.title_description\")}</FormDescription>\n                </div>\n                <FormControl>\n                  <div className=\"flex gap-2\">\n                    <Input\n                      data-testid=\"feed-form-title-input\"\n                      placeholder={feed.title || undefined}\n                      {...field}\n                    />\n                    <Button\n                      buttonClassName=\"shrink-0\"\n                      type=\"button\"\n                      variant=\"outline\"\n                      onClick={fillDefaultTitle}\n                      disabled={field.value === feed.title}\n                    >\n                      {t(\"feed_form.fill_default\")}\n                    </Button>\n                  </div>\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"category\"\n            render={({ field }) => (\n              <FormItem>\n                <div>\n                  <FormLabel>{t(\"feed_form.category\")}</FormLabel>\n                  <FormDescription>{t(\"feed_form.category_description\")}</FormDescription>\n                </div>\n                <FormControl>\n                  <div>\n                    <Autocomplete\n                      maxHeight={window.innerHeight < 600 ? 120 : 240}\n                      suggestions={suggestions}\n                      {...(field as any)}\n                      onSuggestionSelected={(suggestion) => {\n                        if (suggestion) {\n                          field.onChange(suggestion.value)\n                        }\n                      }}\n                    />\n                  </div>\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"isPrivate\"\n            render={({ field }) => (\n              <FormItem>\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <FormLabel className=\"flex items-center gap-1\">\n                      <span>{t(\"feed_form.private_follow\")}</span>\n                      <PaidBadge />\n                    </FormLabel>\n                    <FormDescription>{t(\"feed_form.private_follow_description\")}</FormDescription>\n                  </div>\n                  <FormControl>\n                    <Switch\n                      className=\"shrink-0\"\n                      checked={field.value}\n                      onCheckedChange={field.onChange}\n                      disabled={disabledForRole}\n                    />\n                  </FormControl>\n                </div>\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"hideFromTimeline\"\n            render={({ field }) => (\n              <FormItem>\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <FormLabel className=\"flex items-center gap-1\">\n                      <span>{t(\"feed_form.hide_from_timeline\")}</span>\n                      <PaidBadge />\n                    </FormLabel>\n                    <FormDescription>\n                      {t(\"feed_form.hide_from_timeline_description\")}\n                    </FormDescription>\n                  </div>\n                  <FormControl>\n                    <Switch\n                      className=\"shrink-0\"\n                      checked={field.value}\n                      onCheckedChange={field.onChange}\n                      disabled={disabledForRole}\n                    />\n                  </FormControl>\n                </div>\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"view\"\n            render={() => (\n              <FormItem className=\"mb-4\">\n                <FormLabel>{t(\"feed_form.view\")}</FormLabel>\n\n                <ViewSelectorRadioGroup\n                  {...form.register(\"view\")}\n                  entries={entries}\n                  feed={feed}\n                  view={Number(form.getValues(\"view\"))}\n                />\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        </form>\n      </Form>\n      <RootPortal to={placeholderRef.current}>\n        <div className=\"flex items-center justify-end gap-4 pt-2\">\n          {isSubscribed && (\n            <Button\n              disabled={!isLoggedIn}\n              data-testid=\"feed-form-cancel\"\n              type=\"button\"\n              variant=\"ghost\"\n              onClick={() => {\n                dismiss()\n              }}\n            >\n              {t.common(\"words.cancel\")}\n            </Button>\n          )}\n          <Button\n            disabled={!isLoggedIn}\n            data-testid=\"feed-form-submit\"\n            form=\"feed-form\"\n            type=\"submit\"\n            isLoading={followMutation.isPending}\n          >\n            {isSubscribed ? t(\"feed_form.update\") : t(\"feed_form.follow\")}\n          </Button>\n        </div>\n      </RootPortal>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/FeedSummary.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.js\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { formatNumber } from \"@follow/utils\"\nimport type { FeedAnalyticsModel, ListAnalyticsSchema } from \"@follow-app/client-sdk\"\nimport type { FC } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\n\nimport { FollowSummary } from \"../feed/feed-summary\"\n\nexport interface FeedSummaryProps {\n  feed: FeedModel | ListModel\n\n  analytics?: FeedAnalyticsModel | ListAnalyticsSchema\n\n  showAnalytics?: boolean\n  isLoading?: boolean\n}\nexport const FeedSummary: FC<FeedSummaryProps> = ({\n  feed,\n  analytics,\n  showAnalytics = true,\n  isLoading,\n}) => {\n  const { t } = useTranslation(\"common\")\n  const showSkeleton = isLoading && !analytics && !(\"updatedAt\" in feed && feed.updatedAt)\n\n  if (!showAnalytics) {\n    return (\n      <div>\n        <FollowSummary feed={feed} />\n      </div>\n    )\n  }\n\n  return (\n    <div>\n      <FollowSummary feed={feed} />\n\n      <div className=\"mt-2 flex h-6 justify-between gap-4 pl-10 text-callout\">\n        {showSkeleton ? (\n          <Skeleton className=\"mt-1 h-5 w-40\" />\n        ) : (\n          <div className=\"flex items-center gap-3 text-text-secondary\">\n            {!!analytics?.subscriptionCount && (\n              <div className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-user-3-cute-re\" />\n\n                <span>\n                  {formatNumber(analytics.subscriptionCount)}{\" \"}\n                  {t(\"feed.follower\", { count: analytics.subscriptionCount })}\n                </span>\n              </div>\n            )}\n            {analytics && \"updatesPerWeek\" in analytics && analytics?.updatesPerWeek ? (\n              <div className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-safety-certificate-cute-re\" />\n                <span>{t(\"feed.entry_week\", { count: analytics.updatesPerWeek ?? 0 })}</span>\n              </div>\n            ) : analytics &&\n              \"latestEntryPublishedAt\" in analytics &&\n              analytics?.latestEntryPublishedAt ? (\n              <div className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-safe-alert-cute-re\" />\n                <span>{t(\"feed.updated_at\")}</span>\n                <RelativeTime\n                  date={analytics.latestEntryPublishedAt}\n                  displayAbsoluteTimeAfterDay={Infinity}\n                />\n              </div>\n            ) : null}\n            {\"updatedAt\" in feed && feed.updatedAt ? (\n              <div className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-safety-certificate-cute-re\" />\n                <span>{t(\"feed.updated_at\")}</span>\n                <RelativeTime date={feed.updatedAt} displayAbsoluteTimeAfterDay={Infinity} />\n              </div>\n            ) : null}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/Inbox/ConfirmDestroyModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { inboxSyncService } from \"@follow/store/inbox/store\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { createErrorToaster } from \"~/lib/error-parser\"\n\nexport const ConfirmDestroyModalContent = ({ id }: { id: string }) => {\n  const { t } = useTranslation()\n  const { dismiss } = useCurrentModal()\n\n  const mutationDestroy = useMutation({\n    mutationFn: async (id: string) => {\n      return inboxSyncService.deleteInbox(id)\n    },\n    onSuccess: () => {\n      toast.success(t(\"discover.inbox_destroy_success\"))\n    },\n    onMutate: () => {\n      dismiss()\n    },\n    onError: createErrorToaster(t(\"discover.inbox_destroy_error\")),\n  })\n\n  return (\n    <div className=\"w-full max-w-[540px]\">\n      <div className=\"mb-4\">\n        <i className=\"i-mingcute-warning-fill -mb-1 mr-1 size-5 text-red-500\" />\n        {t(\"discover.inbox_destroy_warning\")}\n      </div>\n      <div className=\"flex justify-end\">\n        <Button buttonClassName=\"bg-red-600\" onClick={() => mutationDestroy.mutate(id)}>\n          {t(\"words.confirm\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/Inbox/InboxActions.tsx",
    "content": "import { ActionButton } from \"@follow/components/ui/button/action-button.js\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { InboxForm } from \"../InboxForm\"\nimport { ConfirmDestroyModalContent } from \"./ConfirmDestroyModalContent\"\n\nexport const InboxActions = ({ id }: { id: string }) => {\n  const { t } = useTranslation()\n  const { present } = useModalStack()\n  return (\n    <>\n      <ActionButton\n        size=\"sm\"\n        tooltip={t(\"discover.inbox_destroy\")}\n        onClick={() =>\n          present({\n            title: t(\"discover.inbox_destroy_confirm\"),\n            content: () => <ConfirmDestroyModalContent id={id} />,\n          })\n        }\n      >\n        <i className=\"i-mgc-delete-2-cute-re\" />\n      </ActionButton>\n      <ActionButton\n        size=\"sm\"\n        onClick={() => {\n          present({\n            title: t(\"sidebar.feed_actions.edit_inbox\"),\n            content: () => <InboxForm asWidget id={id} />,\n          })\n        }}\n      >\n        <i className=\"i-mgc-edit-cute-re\" />\n      </ActionButton>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/Inbox/InboxEmail.tsx",
    "content": "import { env } from \"@follow/shared/env.desktop\"\n\nimport { CopyButton } from \"~/components/ui/button/CopyButton\"\n\nexport const InboxEmail = ({ id }: { id: string }) => {\n  return (\n    <div className=\"group relative flex w-fit items-center gap-2\">\n      <span className=\"shrink-0\">\n        {id}\n        {env.VITE_INBOXES_EMAIL}\n      </span>\n      <CopyButton\n        value={`${id}${env.VITE_INBOXES_EMAIL}`}\n        className=\"p-1 lg:absolute lg:-right-6 lg:opacity-0 lg:group-hover:opacity-100 [&_i]:size-3\"\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/Inbox/InboxSecret.tsx",
    "content": "import { CopyButton } from \"~/components/ui/button/CopyButton\"\n\nexport const InboxSecret = ({ secret }: { secret: string }) => {\n  return (\n    <div className=\"group relative flex w-fit items-center gap-2 font-mono\">\n      <span className=\"shrink-0\">****</span>\n      <CopyButton\n        value={secret}\n        className=\"p-1 lg:absolute lg:-right-6 lg:opacity-0 lg:group-hover:opacity-100 [&_i]:size-3\"\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/Inbox/InboxTable.tsx",
    "content": "import {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@follow/components/ui/table/index.jsx\"\nimport { useInboxById, useInboxList } from \"@follow/store/inbox/hooks\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { InboxActions } from \"./InboxActions\"\nimport { InboxEmail } from \"./InboxEmail\"\nimport { InboxSecret } from \"./InboxSecret\"\n\nexport const InboxTable = () => {\n  const { t } = useTranslation()\n  const inboxes = useInboxList()\n  if (inboxes.length === 0)\n    return (\n      <div className=\"center mb-4 flex flex-col gap-2 text-sm text-text-secondary\">\n        <i className=\"i-mingcute-empty-box-line text-3xl\" />\n        <span className=\"center max-w-sm text-balance text-center text-sm text-text-secondary\">\n          {t(\"discover.inbox.no_inbox\")}\n        </span>\n      </div>\n    )\n  return (\n    <Table containerClassName=\"overflow-auto mb-8\">\n      <TableHeader>\n        <TableRow>\n          <TableHead className=\"pl-0 pr-6\">{t(\"discover.inbox.handle\")}</TableHead>\n          <TableHead className=\"pl-0 pr-6\">{t(\"discover.inbox.email\")}</TableHead>\n          <TableHead className=\"pl-0 pr-6\">{t(\"discover.inbox.title\")}</TableHead>\n          <TableHead className=\"pl-0 pr-6\">{t(\"discover.inbox.secret\")}</TableHead>\n          <TableHead className=\"center px-0\">{t(\"discover.inbox.actions\")}</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {inboxes?.map((inbox) => (\n          <Row id={inbox.id} key={inbox.id} />\n        ))}\n      </TableBody>\n    </Table>\n  )\n}\n\nconst Row = memo(({ id }: { id: string }) => {\n  const inbox = useInboxById(id)\n  if (!inbox) return null\n  return (\n    <TableRow key={inbox.id}>\n      <TableCell size=\"sm\">{inbox.id}</TableCell>\n      <TableCell size=\"sm\">\n        <InboxEmail id={inbox.id} />\n      </TableCell>\n      <TableCell size=\"sm\">{inbox.title}</TableCell>\n      <TableCell size=\"sm\">\n        <InboxSecret secret={inbox.secret} />\n      </TableCell>\n      <TableCell size=\"sm\" className=\"center\">\n        <InboxActions id={inbox.id} />\n      </TableCell>\n    </TableRow>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/Inbox/index.ts",
    "content": "export { InboxTable } from \"./InboxTable\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/InboxForm.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { Card, CardHeader } from \"@follow/components/ui/card/index.jsx\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { inboxSyncService } from \"@follow/store/inbox/store\"\nimport type { InboxModel } from \"@follow/store/inbox/types\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { createErrorToaster } from \"~/lib/error-parser\"\nimport { FollowSummary } from \"~/modules/feed/feed-summary\"\n\nexport const InboxForm: Component<{\n  id?: string\n  asWidget?: boolean\n}> = ({ id, asWidget }) => {\n  const inbox = useInboxById(id)\n\n  const isSubscribed = true\n\n  const { t } = useTranslation()\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full flex-col\",\n        asWidget ? \"mx-auto min-h-[210px] w-full max-w-[550px]\" : \"px-[18px] pb-[18px] pt-12\",\n      )}\n    >\n      {!asWidget && (\n        <div className=\"mb-4 mt-2 flex items-center gap-2 text-[22px] font-bold\">\n          <Logo className=\"size-8\" />\n          {isSubscribed ? t(\"feed_form.update_follow\") : t(\"feed_form.add_follow\")}\n        </div>\n      )}\n      <InboxInnerForm\n        {...{\n          inbox,\n        }}\n      />\n    </div>\n  )\n}\n\nconst inboxHandleSchema = z\n  .string()\n  .min(3)\n  .max(32)\n  .regex(/^[a-z0-9_-]+$/)\n\nconst formSchema = z.object({\n  handle: inboxHandleSchema,\n  title: z.string(),\n})\n\nconst InboxInnerForm = ({ inbox }: { inbox?: Nullable<InboxModel> }) => {\n  const currentModal = useCurrentModal()\n\n  const { t } = useTranslation()\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      handle: inbox?.id,\n      title: inbox?.title || \"\",\n    },\n  })\n\n  const mutationCreate = useMutation({\n    mutationFn: async ({ handle, title }: { handle: string; title: string }) => {\n      await inboxSyncService.createInbox({\n        handle,\n        title,\n      })\n    },\n    onSuccess: (_) => {\n      toast.success(t(\"discover.inbox_create_success\"))\n    },\n    onError: createErrorToaster(t(\"discover.inbox_create_error\")),\n  })\n\n  const mutationChange = useMutation({\n    mutationFn: async ({ handle, title }: { handle: string; title: string }) => {\n      await inboxSyncService.updateInbox({\n        handle,\n        title,\n      })\n    },\n    onSuccess: () => {\n      toast.success(t(\"discover.inbox_update_success\"))\n    },\n    onError: createErrorToaster(t(\"discover.inbox_update_error\")),\n  })\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    if (inbox) {\n      mutationChange.mutate({ handle: values.handle, title: values.title })\n    } else {\n      mutationCreate.mutate({ handle: values.handle, title: values.title })\n    }\n    currentModal.dismiss?.()\n  }\n\n  return (\n    <div className=\"flex flex-1 flex-col gap-y-4\">\n      {inbox && (\n        <Card>\n          <CardHeader>\n            <FollowSummary feed={inbox} />\n          </CardHeader>\n        </Card>\n      )}\n      <Form {...form}>\n        <form\n          onSubmit={form.handleSubmit(onSubmit)}\n          className={cn(\"space-y-4\")}\n          data-testid=\"discover-form\"\n        >\n          {!inbox && (\n            <FormField\n              control={form.control}\n              name=\"handle\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t(\"discover.inbox_handle\")}</FormLabel>\n                  <FormControl>\n                    <div className={cn(\"flex w-64 items-center gap-2\")}>\n                      <Input autoFocus {...field} />\n                      <span className=\"text-zinc-500\">{env.VITE_INBOXES_EMAIL}</span>\n                    </div>\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n          )}\n          <FormField\n            control={form.control}\n            name=\"title\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t(\"discover.inbox_title\")}</FormLabel>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <div className={cn(\"center flex justify-end gap-4\")} data-testid=\"discover-form-actions\">\n            <Button type=\"submit\" isLoading={mutationCreate.isPending}>\n              {t(inbox ? \"discover.inbox_update\" : \"discover.inbox_create\")}\n            </Button>\n          </div>\n        </form>\n      </Form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/ListForm.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useListById, usePrefetchListById } from \"@follow/store/list/hooks\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { tracker } from \"@follow/tracker\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ListAnalyticsSchema } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useEffect, useRef } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { useI18n } from \"~/hooks/common\"\nimport { getFetchErrorMessage, toastFetchError } from \"~/lib/error-parser\"\nimport { getNewIssueUrl } from \"~/lib/issues\"\n\nimport { useTOTPModalWrapper } from \"../profile/hooks\"\nimport { ViewSelectorRadioGroup } from \"../shared/ViewSelectorRadioGroup\"\nimport { FeedSummary } from \"./FeedSummary\"\n\nconst formSchema = z.object({\n  view: z.string(),\n  category: z.string().nullable().optional(),\n  isPrivate: z.boolean().optional(),\n  hideFromTimeline: z.boolean().optional(),\n  title: z.string().optional(),\n})\n\nconst defaultValue = { view: FeedViewType.Articles.toString() } as z.infer<typeof formSchema>\n\nexport type ListFormDataValuesType = z.infer<typeof formSchema>\nexport const ListForm: Component<{\n  id?: string\n\n  defaultValues?: ListFormDataValuesType\n\n  onSuccess?: () => void\n}> = ({ id: _id, defaultValues = defaultValue, onSuccess }) => {\n  const feedQuery = usePrefetchListById(_id)\n\n  const id = feedQuery.data?.list.id || _id\n  const list = useListById(id)\n\n  const { t } = useTranslation()\n\n  useEffect(() => {\n    if (!feedQuery.isLoading) {\n      tracker.subscribeModalOpened({\n        listId: id,\n        isError: feedQuery.isError,\n      })\n    }\n  }, [feedQuery.isLoading])\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full flex-col\",\n        \"mx-auto min-h-[420px] w-full max-w-[550px] lg:min-w-[550px]\",\n      )}\n    >\n      {list ? (\n        <ListInnerForm\n          {...{\n            defaultValues,\n            id,\n\n            onSuccess,\n            subscriptionData: feedQuery.data?.subscription,\n            analytics: feedQuery.data?.analytics,\n            list,\n            isLoading: feedQuery.isLoading,\n          }}\n        />\n      ) : feedQuery.isLoading ? (\n        <div className=\"flex flex-1 items-center justify-center\">\n          <LoadingCircle size=\"large\" />\n        </div>\n      ) : feedQuery.error ? (\n        <div className=\"center grow flex-col gap-3\">\n          <i className=\"i-mgc-close-cute-re size-7 text-red-500\" />\n          <p>{t(\"feed_form.error_fetching_feed\")}</p>\n          <p className=\"cursor-text select-text break-all px-8 text-center\">\n            {getFetchErrorMessage(feedQuery.error)}\n          </p>\n\n          <div className=\"flex items-center gap-4\">\n            <Button\n              variant=\"text\"\n              onClick={() => {\n                feedQuery.refetch()\n              }}\n            >\n              {t(\"feed_form.retry\")}\n            </Button>\n\n            <Button\n              variant=\"primary\"\n              onClick={() => {\n                window.open(\n                  getNewIssueUrl({\n                    target: \"discussion\",\n                    category: \"list-expired\",\n                    body: [\n                      \"### Info:\",\n                      \"\",\n                      \"List ID:\",\n                      \"```\",\n                      id,\n                      \"```\",\n                      \"\",\n                      \"Error:\",\n                      \"```\",\n                      getFetchErrorMessage(feedQuery.error),\n                      \"```\",\n                    ].join(\"\\n\"),\n                    title: `Error in fetching list: ${id}`,\n                  }),\n                  \"_blank\",\n                )\n              }}\n            >\n              {t(\"feed_form.feedback\")}\n            </Button>\n          </div>\n        </div>\n      ) : (\n        <div className=\"center h-full grow flex-col\">\n          <i className=\"i-mgc-question-cute-re mb-6 size-12 text-zinc-500\" />\n          <p>{t(\"feed_form.feed_not_found\")}</p>\n          <p>{id}</p>\n        </div>\n      )}\n    </div>\n  )\n}\n\nconst ListInnerForm = ({\n  defaultValues,\n  id,\n\n  onSuccess,\n  subscriptionData,\n  list,\n  analytics,\n  isLoading,\n}: {\n  defaultValues?: z.infer<typeof formSchema>\n  id?: string\n\n  onSuccess?: () => void\n  subscriptionData?: {\n    view?: number\n    category?: string | null\n    isPrivate?: boolean\n    title?: string | null\n    hideFromTimeline?: boolean | null\n  }\n  list: ListModel\n  analytics?: ListAnalyticsSchema\n  isLoading: boolean\n}) => {\n  const subscription = useSubscriptionByFeedId(id || \"\") || subscriptionData\n  const isSubscribed = !!subscription\n  const buttonRef = useRef<HTMLButtonElement>(null)\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      ...defaultValues,\n      view: list.view.toString(),\n    },\n  })\n\n  const { setClickOutSideToDismiss, dismiss } = useCurrentModal()\n\n  useEffect(() => {\n    setClickOutSideToDismiss(!form.formState.isDirty)\n  }, [form.formState.isDirty])\n\n  useEffect(() => {\n    if (subscription) {\n      form.setValue(\"view\", `${subscription?.view}`)\n      typeof subscription.isPrivate === \"boolean\" &&\n        form.setValue(\"isPrivate\", subscription.isPrivate)\n      subscription?.title && form.setValue(\"title\", subscription.title)\n      typeof subscription.hideFromTimeline === \"boolean\" &&\n        form.setValue(\"hideFromTimeline\", subscription.hideFromTimeline)\n    }\n  }, [subscription])\n\n  const followMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof formSchema> & { TOTPCode?: string }) => {\n      const userId = whoami()?.id || \"\"\n      const body = {\n        listId: list.id,\n        view: Number.parseInt(values.view),\n        category: values.category,\n        isPrivate: values.isPrivate || false,\n        hideFromTimeline: values.hideFromTimeline,\n        title: values.title,\n        TOTPCode: values.TOTPCode,\n        userId,\n        type: \"list\",\n        url: undefined,\n        feedId: undefined,\n      } as const\n\n      if (isSubscribed) {\n        return subscriptionSyncService.edit(body)\n      } else {\n        return subscriptionSyncService.subscribe(body)\n      }\n    },\n    onSuccess: () => {\n      toast(isSubscribed ? t(\"feed_form.updated\") : t(\"feed_form.followed\"), {\n        duration: 1000,\n      })\n\n      onSuccess?.()\n    },\n    async onError(err) {\n      toastFetchError(err)\n    },\n  })\n\n  const preset = useTOTPModalWrapper(followMutation.mutateAsync)\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    if (isSubscribed) {\n      followMutation.mutate(values)\n    } else {\n      preset(values)\n    }\n  }\n\n  const t = useI18n()\n\n  return (\n    <div className=\"flex flex-1 flex-col gap-y-4\">\n      <FeedSummary isLoading={isLoading} feed={list} analytics={analytics} showAnalytics />\n      <Form {...form}>\n        <form onSubmit={form.handleSubmit(onSubmit)} className=\"flex flex-1 flex-col gap-y-4\">\n          <FormField\n            control={form.control}\n            name=\"view\"\n            render={() => (\n              <FormItem>\n                <FormLabel>{t(\"feed_form.view\")}</FormLabel>\n\n                <ViewSelectorRadioGroup\n                  {...form.register(\"view\")}\n                  disabled={true}\n                  className=\"opacity-60\"\n                />\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"title\"\n            render={({ field }) => (\n              <FormItem>\n                <div>\n                  <FormLabel>{t(\"feed_form.title\")}</FormLabel>\n                  <FormDescription>{t(\"feed_form.title_description\")}</FormDescription>\n                </div>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"isPrivate\"\n            render={({ field }) => (\n              <FormItem>\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <FormLabel>{t(\"feed_form.private_follow\")}</FormLabel>\n                    <FormDescription>{t(\"feed_form.private_follow_description\")}</FormDescription>\n                  </div>\n                  <FormControl>\n                    <Switch\n                      className=\"shrink-0\"\n                      checked={field.value}\n                      onCheckedChange={field.onChange}\n                    />\n                  </FormControl>\n                </div>\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"hideFromTimeline\"\n            render={({ field }) => (\n              <FormItem>\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <FormLabel>{t(\"feed_form.hide_from_timeline\")}</FormLabel>\n                    <FormDescription>\n                      {t(\"feed_form.hide_from_timeline_description\")}\n                    </FormDescription>\n                  </div>\n                  <FormControl>\n                    <Switch\n                      className=\"shrink-0\"\n                      checked={field.value}\n                      onCheckedChange={field.onChange}\n                    />\n                  </FormControl>\n                </div>\n              </FormItem>\n            )}\n          />\n          <div className=\"flex flex-1 items-center justify-end gap-4\">\n            {isSubscribed && (\n              <Button\n                type=\"button\"\n                ref={buttonRef}\n                variant=\"text\"\n                onClick={() => {\n                  dismiss()\n                }}\n              >\n                {t.common(\"words.cancel\")}\n              </Button>\n            )}\n            <Button ref={buttonRef} type=\"submit\" isLoading={followMutation.isPending}>\n              {isSubscribed ? t(\"feed_form.update\") : t(\"feed_form.follow\")}\n            </Button>\n          </div>\n        </form>\n      </Form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/OpmlAbstractGraphic.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\n// Popular RSS reader services\nconst RSS_READERS = [\n  { icon: \"i-simple-icons-feedly\", name: \"Feedly\", color: \"#2BB24C\" },\n  { icon: \"i-simple-icons-inoreader\", name: \"Inoreader\", color: \"#007BC5\" },\n  { icon: \"i-simple-icons-freshrss\", name: \"FreshRSS\", color: \"#FF9800\" },\n]\nconst seededRandom = (seed: number) => {\n  // Mulberry32\n  let t = (seed + 0x6d2b79f5) | 0\n  t = Math.imul(t ^ (t >>> 15), t | 1)\n  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n}\n\nexport function OpmlAbstractGraphic({ className }: { className?: string }) {\n  const containerRef = React.useRef<HTMLDivElement | null>(null)\n  const logoRef = React.useRef<HTMLDivElement | null>(null)\n  const iconRefs = React.useRef<(HTMLDivElement | null)[]>([])\n\n  const [paths, setPaths] = React.useState<{ d: string; color: string; shadow: string }[]>([])\n  const [ready, setReady] = React.useState(false)\n  const completedKeysRef = React.useRef<Set<string>>(new Set())\n\n  // Deterministic pseudo-random with seed\n\n  const computePaths = React.useCallback(() => {\n    const container = containerRef.current\n    const logoEl = logoRef.current\n    if (!container || !logoEl) return\n\n    const containerRect = container.getBoundingClientRect()\n    const logoRect = logoEl.getBoundingClientRect()\n\n    const endX = logoRect.left - containerRect.left + logoRect.width / 2\n    const endY = logoRect.top - containerRect.top + logoRect.height / 2\n\n    const newPaths: { d: string; color: string; shadow: string }[] = []\n\n    iconRefs.current.forEach((el, idx) => {\n      if (!el) return\n      const r = el.getBoundingClientRect()\n      const startX = r.left - containerRect.left + r.width / 2\n      const startY = r.top - containerRect.top + r.height / 2\n\n      // Generate a slightly wobbly path between start and end\n      const dx = endX - startX\n      const dy = endY - startY\n      const distance = Math.hypot(dx, dy)\n      const segments = Math.max(6, Math.min(12, Math.round(distance / 70)))\n      const amplitude = Math.min(18, Math.max(6, distance * 0.06))\n\n      // Build points along the straight line and offset them by a seeded noise\n      const points: Array<{ x: number; y: number }> = [{ x: startX, y: startY }]\n      for (let i = 1; i < segments; i++) {\n        const t = i / segments\n        const baseX = startX + dx * t\n        const baseY = startY + dy * t\n        // Perpendicular vector\n        const px = -dy\n        const py = dx\n        const plen = Math.hypot(px, py) || 1\n        const nx = px / plen\n        const ny = py / plen\n        // Taper near the ends\n        const taper = Math.sin(Math.PI * t)\n        const rand = (seededRandom((idx + 1) * 9973 + i * 53) - 0.5) * 2 // [-1, 1]\n        const offset = rand * amplitude * taper\n        points.push({ x: baseX + nx * offset, y: baseY + ny * offset })\n      }\n      points.push({ x: endX, y: endY })\n\n      // Convert to a smooth path using quadratic curves\n      let d = `M ${points[0]!.x} ${points[0]!.y}`\n      for (let i = 1; i < points.length - 1; i++) {\n        const p1 = points[i]!\n        const p2 = points[i + 1]!\n        // Midpoint smoothing\n        const cx = p1.x\n        const cy = p1.y\n        const mx = (p1.x + p2.x) / 2\n        const my = (p1.y + p2.y) / 2\n        d += ` Q ${cx} ${cy} ${mx} ${my}`\n      }\n\n      // Accent shadow color\n      const color = RSS_READERS[idx]?.color ?? \"#999999\"\n      const shadow =\n        idx === 0\n          ? \"rgba(43,178,76,0.25)\"\n          : idx === 1\n            ? \"rgba(0,123,197,0.25)\"\n            : \"rgba(255,152,0,0.25)\"\n\n      newPaths.push({ d, color, shadow })\n    })\n\n    setPaths(newPaths)\n  }, [])\n\n  // Compute when animations are completed and on resize thereafter\n  React.useLayoutEffect(() => {\n    if (!ready) return\n    // Two rafs to ensure transforms are fully flushed\n    const id = requestAnimationFrame(() => {\n      const id2 = requestAnimationFrame(() => {\n        computePaths()\n      })\n      return () => cancelAnimationFrame(id2)\n    })\n    return () => cancelAnimationFrame(id)\n  }, [ready, computePaths])\n\n  React.useEffect(() => {\n    if (!ready) return\n    const onResize = () => computePaths()\n    window.addEventListener(\"resize\", onResize)\n    return () => window.removeEventListener(\"resize\", onResize)\n  }, [ready, computePaths])\n\n  const markAnimationDone = React.useCallback(\n    (key: string) => {\n      const set = completedKeysRef.current\n      if (set.has(key)) return\n      set.add(key)\n      if (set.size >= RSS_READERS.length + 1) {\n        setReady(true)\n      }\n    },\n    [setReady],\n  )\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\"relative aspect-square w-full overflow-hidden bg-material-medium\", className)}\n    >\n      {/* Right side Logo */}\n      <m.div\n        ref={logoRef}\n        className=\"absolute right-[15%] top-1/2 z-10 -translate-y-1/2\"\n        initial={{ scale: 0, opacity: 0, x: 50 }}\n        animate={{ scale: 1, opacity: 1, x: 0 }}\n        transition={{ ...Spring.presets.smooth, delay: 0.3 }}\n        onAnimationComplete={() => markAnimationDone(\"logo\")}\n      >\n        <div className=\"relative\">\n          {/* Logo glow effect */}\n          <div\n            className=\"absolute inset-0 -z-10 blur-2xl\"\n            style={{\n              background: \"radial-gradient(circle, rgba(255, 92, 0, 0.3) 0%, transparent 70%)\",\n            }}\n          />\n          <Logo className=\"size-20 drop-shadow-lg\" />\n        </div>\n      </m.div>\n\n      {/* Left side RSS Reader Icons in vertical layout */}\n      {RSS_READERS.map((reader, index) => {\n        const totalReaders = RSS_READERS.length\n        const spacing = 70 / (totalReaders + 1) // Distribute vertically within 70% of height\n        const yPosition = 15 + spacing * (index + 1) // Start at 15%, space evenly\n\n        return (\n          <m.div\n            key={reader.name}\n            className=\"absolute left-[15%]\"\n            style={{\n              top: `${yPosition}%`,\n            }}\n            initial={{ scale: 0, opacity: 0, x: -50 }}\n            animate={{ scale: 1, opacity: 1, x: 0 }}\n            transition={{\n              ...Spring.presets.smooth,\n              delay: 0.1 + index * 0.08,\n            }}\n            onAnimationComplete={() => markAnimationDone(`icon-${index}`)}\n          >\n            {/* Icon container */}\n            <div\n              ref={(el) => {\n                iconRefs.current[index] = el\n              }}\n              className=\"relative flex size-12 items-center justify-center rounded-xl backdrop-blur-sm\"\n              style={{\n                backgroundColor: `${reader.color}20`,\n                borderWidth: \"1px\",\n                borderStyle: \"solid\",\n                borderColor: `${reader.color}40`,\n                boxShadow: `0 4px 12px ${reader.color}20`,\n              }}\n            >\n              <i className={cn(reader.icon, \"size-6\")} style={{ color: reader.color }} />\n            </div>\n          </m.div>\n        )\n      })}\n\n      {/* Hand-drawn connector lines */}\n      <svg className=\"pointer-events-none absolute inset-0\" width=\"100%\" height=\"100%\">\n        <defs>\n          {/* Slight wobble via displacement map to enhance sketch feeling (subtle) */}\n          <filter id=\"scribble-wobble\" x=\"-5%\" y=\"-5%\" width=\"110%\" height=\"110%\">\n            <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.9\" numOctaves=\"1\" seed=\"2\" />\n            <feDisplacementMap in=\"SourceGraphic\" scale=\"0.7\" />\n          </filter>\n        </defs>\n        {paths.map((p, index) => {\n          // Keep reveal order in sync with icon animations\n          const iconDelay = index * 0.08\n          const revealDelay = iconDelay + 0.1 // start after icon settles a bit\n          return (\n            <g key={p.d} filter=\"url(#scribble-wobble)\">\n              {/* Underlay shadow to suggest marker bleed */}\n              <m.path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.shadow}\n                strokeWidth={5}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: 0, opacity: 0 }}\n                animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n                transition={{ ...Spring.presets.smooth, delay: revealDelay }}\n              />\n              {/* Main line */}\n              <m.path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.color}\n                strokeWidth={2.5}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: 0, opacity: 0 }}\n                animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n                transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.05 }}\n              />\n              {/* A second, lighter stroke with slight dash to mimic hand-drawn */}\n              <m.path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.color}\n                strokeOpacity={0.7}\n                strokeWidth={1.4}\n                strokeDasharray=\"6 7\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: 0, opacity: 0 }}\n                animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}\n                transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.1 }}\n              />\n            </g>\n          )\n        })}\n      </svg>\n\n      {/* Ambient background glow on the right */}\n      <div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{\n          background:\n            \"radial-gradient(circle at 85% 50%, rgba(255, 92, 0, 0.08) 0%, transparent 50%)\",\n        }}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/OpmlSelectionModal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ExtractResponseData, SubscriptionParseOpmlResponse } from \"@follow-app/client-sdk\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport Fuse from \"fuse.js\"\nimport { useCallback, useMemo, useState } from \"react\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { followClient } from \"~/lib/api-client\"\nimport { toastFetchError } from \"~/lib/error-parser\"\n\nimport type { ParsedFeedItem } from \"./types\"\n\nexport const OpmlSelectionModal = ({\n  parsedData,\n\n  file,\n}: {\n  parsedData: ExtractResponseData<SubscriptionParseOpmlResponse>\n\n  file: File\n}) => {\n  const { dismiss } = useCurrentModal()\n\n  const importMutation = useMutation({\n    mutationFn: async (selectedItems: ParsedFeedItem[]) => {\n      const formData = new FormData()\n\n      formData.append(\"file\", file)\n      formData.append(\"items\", JSON.stringify(selectedItems.map((i) => i.url)))\n\n      const { data } = await followClient.api.subscriptions.import(formData)\n\n      return data\n    },\n    onSuccess: (data) => {\n      subscriptionSyncService.fetch()\n\n      const { successfulItems, conflictItems, parsedErrorItems } = data\n\n      if (parsedErrorItems.length > 0) {\n        toast.warning(t(\"discover.import.import_completed_with_issues\"), {\n          description: (\n            <Trans\n              ns=\"app\"\n              i18nKey=\"discover.import.result\"\n              components={{\n                SuccessfulNum: <NumberDisplay value={successfulItems.length} />,\n                ConflictNum: <NumberDisplay value={conflictItems.length} />,\n                ErrorNum: <NumberDisplay value={parsedErrorItems.length} />,\n                br: <br />,\n              }}\n            />\n          ),\n          duration: 5000,\n        })\n      } else {\n        dismiss()\n        // Show success if everything went well\n        toast.success(t(\"discover.import.import_successful\"), {\n          description: (\n            <Trans\n              ns=\"app\"\n              i18nKey=\"discover.import.result\"\n              components={{\n                SuccessfulNum: <NumberDisplay value={successfulItems.length} />,\n                ConflictNum: <NumberDisplay value={conflictItems.length} />,\n                ErrorNum: <NumberDisplay value={parsedErrorItems.length} />,\n                br: <br />,\n              }}\n            />\n          ),\n          duration: 5000,\n        })\n      }\n    },\n    async onError(err) {\n      toastFetchError(err)\n    },\n  })\n\n  const { t } = useTranslation()\n  const [searchQuery, setSearchQuery] = useState(\"\")\n  const [selectedItems, setSelectedItems] = useState<Set<string>>(\n    () => new Set(parsedData.subscriptions.map((_, index) => index.toString())),\n  )\n\n  const fuse = useMemo(() => {\n    return new Fuse(parsedData.subscriptions, {\n      keys: [\n        { name: \"title\", weight: 0.7 },\n        { name: \"url\", weight: 0.2 },\n        { name: \"category\", weight: 0.1 },\n      ],\n      threshold: 0.3,\n      includeMatches: true,\n      minMatchCharLength: 2,\n    })\n  }, [parsedData.subscriptions])\n\n  const filteredSubscriptions = useMemo(() => {\n    if (!searchQuery.trim()) {\n      return parsedData.subscriptions.map((item, index) => ({ item, refIndex: index }))\n    }\n\n    return fuse.search(searchQuery).map((result) => ({\n      item: result.item,\n      refIndex: result.refIndex,\n    }))\n  }, [fuse, searchQuery, parsedData.subscriptions])\n\n  const selectedCount = selectedItems.size\n  const isQuotaExceeded = selectedCount > parsedData.remaining\n  const quotaWarningThreshold = Math.max(1, Math.floor(parsedData.remaining * 0.8)) // 80% of quota\n\n  const toggleItem = useCallback((index: string) => {\n    setSelectedItems((prev) => {\n      const newSet = new Set(prev)\n      if (newSet.has(index)) {\n        newSet.delete(index)\n      } else {\n        newSet.add(index)\n      }\n      return newSet\n    })\n  }, [])\n\n  const toggleAll = useCallback(\n    (checked: boolean) => {\n      if (checked) {\n        // Select all filtered items, but respect quota\n        const filteredIndices = filteredSubscriptions.map(({ refIndex }) => refIndex.toString())\n        setSelectedItems((prev) => {\n          const newSet = new Set(prev)\n\n          // If we would exceed quota, only select up to the remaining limit\n          let addedCount = 0\n          for (const index of filteredIndices) {\n            if (newSet.size + addedCount >= parsedData.remaining) {\n              break\n            }\n            if (!newSet.has(index)) {\n              addedCount++\n            }\n            newSet.add(index)\n          }\n          return newSet\n        })\n      } else {\n        // Deselect all filtered items - no quota restrictions for deselection\n        const filteredIndices = new Set(\n          filteredSubscriptions.map(({ refIndex }) => refIndex.toString()),\n        )\n        setSelectedItems((prev) => {\n          const newSet = new Set(prev)\n          filteredIndices.forEach((index) => newSet.delete(index))\n          return newSet\n        })\n      }\n    },\n    [filteredSubscriptions, parsedData.remaining],\n  )\n\n  const handleImport = useCallback(() => {\n    const selected = parsedData.subscriptions.filter((_, index) =>\n      selectedItems.has(index.toString()),\n    )\n    importMutation.mutate(selected)\n  }, [parsedData.subscriptions, selectedItems, importMutation])\n\n  // Calculate selection states for filtered items\n  const filteredSelectedCount = filteredSubscriptions.filter(({ refIndex }) =>\n    selectedItems.has(refIndex.toString()),\n  ).length\n\n  const allFilteredSelected =\n    filteredSelectedCount === filteredSubscriptions.length && filteredSubscriptions.length > 0\n  const someFilteredSelected =\n    filteredSelectedCount > 0 && filteredSelectedCount < filteredSubscriptions.length\n\n  return (\n    <div className=\"flex h-full max-w-full flex-col\">\n      <div className=\"mb-4\">\n        <h3 className=\"mb-2 text-lg font-semibold\">\n          {t(\"discover.import.select_feeds_to_import\")}\n        </h3>\n        <p className=\"text-sm text-text-secondary\">\n          {t(\"discover.import.select_feeds_description\")}\n        </p>\n      </div>\n\n      {/* Quota Status */}\n      <div\n        className={cn(\n          \"mb-4 flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2\",\n          isQuotaExceeded\n            ? \"border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950\"\n            : selectedCount >= quotaWarningThreshold\n              ? \"border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950\"\n              : \"border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950\",\n        )}\n      >\n        <div className=\"flex items-center gap-2\">\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <div\n                className={cn(\n                  \"flex size-4 items-center justify-center rounded-full\",\n                  isQuotaExceeded\n                    ? \"text-red\"\n                    : selectedCount >= quotaWarningThreshold\n                      ? \"text-yellow\"\n                      : \"text-green\",\n                )}\n              >\n                <i\n                  className={cn(\n                    isQuotaExceeded\n                      ? \"i-mgc-close-cute-re\"\n                      : selectedCount >= quotaWarningThreshold\n                        ? \"i-mgc-warning-cute-re\"\n                        : \"i-mgc-check-circle-cute-re\",\n                  )}\n                />\n              </div>\n            </TooltipTrigger>\n            <TooltipContent>\n              {isQuotaExceeded ? (\n                <p>{t(\"discover.import.quota_exceeded_warning\")}</p>\n              ) : selectedCount >= quotaWarningThreshold ? (\n                <p>\n                  {t(\"discover.import.quota_warning\", {\n                    remaining: parsedData.remaining - selectedCount,\n                  })}\n                </p>\n              ) : (\n                <p>\n                  {t(\"discover.import.remaining_quota\", {\n                    remaining: parsedData.remaining - selectedCount,\n                  })}\n                </p>\n              )}\n            </TooltipContent>\n          </Tooltip>\n          <span className=\"text-sm font-medium\">\n            {t(\"discover.import.quota_status\")} {selectedCount}/{parsedData.remaining}\n          </span>\n        </div>\n      </div>\n\n      {/* Search Input */}\n      <div className=\"mb-4\">\n        <Input\n          placeholder={t(\"discover.import.search_feeds_placeholder\", \"Search feeds...\")}\n          value={searchQuery}\n          onChange={(e) => setSearchQuery(e.target.value)}\n          className=\"w-full\"\n        />\n      </div>\n\n      <label\n        className=\"mb-4 flex items-center gap-3 rounded-lg px-1 py-2\"\n        htmlFor=\"select-all-filtered-feeds\"\n      >\n        <Checkbox\n          id=\"select-all-filtered-feeds\"\n          checked={allFilteredSelected}\n          onCheckedChange={toggleAll}\n          indeterminate={someFilteredSelected}\n        />\n        <span className=\"font-medium\">\n          {searchQuery.trim() ? (\n            <>\n              {t(\"discover.import.select_all_filtered\", \"Select all filtered\")} (\n              {filteredSelectedCount}/{filteredSubscriptions.length})\n              {filteredSubscriptions.length < parsedData.subscriptions.length && (\n                <span className=\"ml-1 text-text-secondary\">\n                  of {parsedData.subscriptions.length} total\n                </span>\n              )}\n            </>\n          ) : (\n            <>\n              {t(\"discover.import.select_all_feeds\")} ({selectedItems.size}/\n              {parsedData.subscriptions.length})\n            </>\n          )}\n        </span>\n      </label>\n\n      <ScrollArea.ScrollArea rootClassName=\"-mx-4 flex-1 px-2\">\n        <div className=\"space-y-2\">\n          {filteredSubscriptions.length === 0 && searchQuery.trim() ? (\n            <div className=\"py-8 text-center text-text-secondary\">\n              {t(\"discover.import.no_feeds_found\", \"No feeds found matching your search.\")}\n            </div>\n          ) : (\n            filteredSubscriptions.map(({ item, refIndex }) => {\n              const isSelected = selectedItems.has(refIndex.toString())\n\n              const wouldExceedQuota = !isSelected && selectedCount >= parsedData.remaining\n\n              return (\n                <div\n                  key={`${item.url}-${refIndex}`}\n                  className={cn(\n                    \"flex cursor-button items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-material-medium\",\n                    isSelected ? \"border-material-thick bg-material-thick\" : \"border-background\",\n                    wouldExceedQuota && \"cursor-not-allowed opacity-50\",\n                  )}\n                  onClick={() => !wouldExceedQuota && toggleItem(refIndex.toString())}\n                >\n                  <Checkbox checked={isSelected} disabled={wouldExceedQuota} />\n                  <div className=\"min-w-0 flex-1 shrink\">\n                    <div className=\"truncate font-medium\">{item.title || \"Untitled Feed\"}</div>\n                    <div className=\"truncate text-sm text-text-secondary\">{item.url}</div>\n                    {item.category && (\n                      <div className=\"mt-1 text-xs text-text-secondary opacity-80\">\n                        {item.category}\n                      </div>\n                    )}\n                  </div>\n                </div>\n              )\n            })\n          )}\n        </div>\n      </ScrollArea.ScrollArea>\n\n      <div className=\"mt-4 flex justify-end gap-3\">\n        <Button variant=\"outline\" onClick={dismiss} disabled={importMutation.isPending}>\n          Cancel\n        </Button>\n        <Button\n          onClick={handleImport}\n          disabled={selectedItems.size === 0 || isQuotaExceeded || importMutation.isPending}\n          isLoading={importMutation.isPending}\n        >\n          {t(\"words.import\")} ({selectedItems.size})\n          {isQuotaExceeded && (\n            <span className=\"ml-1 text-xs\">- {t(\"discover.import.quota_exceeded\")}</span>\n          )}\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nconst NumberDisplay = ({ value }) => <span className=\"font-bold text-text\">{value ?? 0}</span>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/RecommendationContent.tsx",
    "content": "import type { RSSHubRouteMetadata } from \"@follow-app/client-sdk\"\n\nimport { DiscoverFeedForm } from \"./DiscoverFeedForm\"\n\nexport const RecommendationContent = ({\n  route,\n  routePrefix,\n}: {\n  route: RSSHubRouteMetadata\n  routePrefix: string\n}) => (\n  <div className=\"mx-auto w-full max-w-[700px] sm:min-w-[550px]\">\n    <DiscoverFeedForm route={route} routePrefix={routePrefix} />\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/TrendingFeedCard.tsx",
    "content": "import { useIsSubscribed } from \"@follow/store/subscription/hooks\"\nimport { formatNumber } from \"@follow/utils\"\nimport type { TrendingFeedItem } from \"@follow-app/client-sdk\"\nimport type { FC } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { FollowSummary } from \"../feed/feed-summary\"\nimport { FeedCardActions } from \"./DiscoverFeedCard\"\n\nexport const TrendingFeedCard: FC<{\n  item: TrendingFeedItem\n}> = ({ item }) => {\n  const { t } = useTranslation(\"common\")\n  const { analytics } = item\n  const isSubscribed = useIsSubscribed(item.feed?.id || \"\")\n  return (\n    <div>\n      <FollowSummary simple feed={item.feed! as any} />\n\n      <div className=\"mt-2 flex items-center justify-between text-body text-text-secondary\">\n        {analytics?.subscriptionCount ? (\n          <div className=\"flex items-center gap-1.5\">\n            <i className=\"i-mgc-user-3-cute-re\" />\n\n            <span>\n              {formatNumber(analytics.subscriptionCount)}{\" \"}\n              {t(\"feed.follower\", { count: analytics.subscriptionCount })}\n            </span>\n          </div>\n        ) : (\n          <div />\n        )}\n\n        <FeedCardActions\n          followButtonVariant=\"ghost\"\n          followedButtonClassName=\"px-3 -mr-3\"\n          followButtonClassName=\"border-accent text-accent px-3 -mr-3\"\n          isSubscribed={isSubscribed}\n          item={item}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { Button, MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { DiscoveryItem } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { repository } from \"@pkg\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { produce } from \"immer\"\nimport type { ChangeEvent, CompositionEvent } from \"react\"\nimport { startTransition, useCallback, useEffect, useMemo, useRef } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { useSearchParams } from \"react-router\"\nimport { z } from \"zod\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { followClient } from \"~/lib/api-client\"\n\nimport {\n  getDiscoverSearchData,\n  setDiscoverSearchData,\n  useDiscoverSearchData,\n} from \"./atoms/discover\"\nimport { DiscoverFeedCard } from \"./DiscoverFeedCard\"\nimport { DiscoverImport } from \"./DiscoverImport\"\nimport { DiscoverInboxList } from \"./DiscoverInboxList\"\nimport { DiscoverTransform } from \"./DiscoverTransform\"\nimport { DiscoverUser } from \"./DiscoverUser\"\nimport { FeedForm } from \"./FeedForm\"\n\nconst isFeedLikeUrl = (value: string) => {\n  const trimmed = value.trim()\n  return /^(?:https?:\\/\\/|rsshub:\\/\\/|folo:\\/\\/|follow:\\/\\/)/.test(trimmed)\n}\n\n// Auto-detect input type\nfunction detectInputType(value: string): \"rss\" | \"rsshub\" | \"search\" {\n  const trimmed = value.trim()\n  if (trimmed.startsWith(\"rsshub://\")) {\n    return \"rsshub\"\n  }\n  if (isFeedLikeUrl(trimmed) && !trimmed.startsWith(\"rsshub://\")) {\n    return \"rss\"\n  }\n  return \"search\"\n}\n\nconst searchSchema = z.object({\n  keyword: z.string().min(1),\n  target: z.enum([\"feeds\", \"lists\"]),\n})\n\nconst rssSchema = z.object({\n  keyword: z.string().refine(isFeedLikeUrl, {\n    message: \"Invalid RSS URL\",\n  }),\n})\n\nconst rsshubSchema = z.object({\n  keyword: z.string().url().startsWith(\"rsshub://\"),\n})\n\ntype SearchFormData = z.infer<typeof searchSchema>\n\n// Compact Tool Link Component\ninterface ToolLinkProps {\n  icon: string\n  label: string\n  onClick: () => void\n}\n\nfunction ToolLink({ icon, label, onClick }: ToolLinkProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors\",\n        \"text-text-secondary hover:bg-fill-secondary hover:text-text\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1\",\n      )}\n    >\n      <i className={cn(icon, \"size-3.5 shrink-0\")} />\n      <span>{label}</span>\n    </button>\n  )\n}\n\nexport function UnifiedDiscoverForm() {\n  const [searchParams, setSearchParams] = useSearchParams()\n  const keywordFromSearch = searchParams.get(\"keyword\") || \"\"\n  const { t } = useTranslation()\n  const { ensureLogin } = useRequireLogin()\n  const { present, dismissAll } = useModalStack()\n  const isMobile = useMobile()\n\n  // Auto-detect input type based on current value\n  const detectedType = useMemo(() => {\n    if (keywordFromSearch) {\n      return detectInputType(keywordFromSearch)\n    }\n    return \"search\"\n  }, [keywordFromSearch])\n\n  // Use search form by default, but validate based on detected type\n  const form = useForm<SearchFormData>({\n    resolver: zodResolver(searchSchema),\n    defaultValues: {\n      keyword: keywordFromSearch || \"\",\n      target: \"feeds\",\n    },\n    mode: \"all\",\n  })\n\n  const { watch, trigger } = form\n  const target = watch(\"target\")\n  const atomKey = useRef(keywordFromSearch + target)\n\n  // Validate default value from search params\n  useEffect(() => {\n    if (!keywordFromSearch) {\n      return\n    }\n    trigger(\"keyword\")\n  }, [trigger, keywordFromSearch])\n\n  const discoverSearchData = useDiscoverSearchData()?.[atomKey.current] || []\n\n  const mutation = useMutation({\n    mutationFn: async ({ keyword, target }: { keyword: string; target: \"feeds\" | \"lists\" }) => {\n      const inputType = detectInputType(keyword)\n\n      // For RSS/RSSHub, validate and show feed form modal directly\n      if (inputType === \"rss\") {\n        const validated = rssSchema.safeParse({ keyword })\n        if (!validated.success) {\n          throw new Error(\"Invalid RSS URL\")\n        }\n        present({\n          title: t(\"feed_form.add_feed\"),\n          content: () => <FeedForm url={keyword} onSuccess={dismissAll} />,\n        })\n        return []\n      }\n\n      if (inputType === \"rsshub\") {\n        const validated = rsshubSchema.safeParse({ keyword })\n        if (!validated.success) {\n          throw new Error(\"Invalid RSSHub route\")\n        }\n        present({\n          title: t(\"feed_form.add_feed\"),\n          content: () => <FeedForm url={keyword} onSuccess={dismissAll} />,\n        })\n        return []\n      }\n\n      // For search, perform discovery\n      const { data } = await followClient.api.discover.discover({\n        keyword: keyword.trim(),\n        target,\n      })\n\n      setDiscoverSearchData((prev) => ({\n        ...prev,\n        [atomKey.current]: data,\n      }))\n\n      return data\n    },\n  })\n\n  const handleKeywordChange = useCallback(\n    (event: ChangeEvent<HTMLInputElement>) => {\n      const { value } = event.currentTarget\n      // During composition, update raw value without validation\n      if ((event.nativeEvent as InputEvent)?.isComposing) {\n        form.setValue(\"keyword\", value, { shouldValidate: false })\n        return\n      }\n\n      startTransition(() => {\n        form.setValue(\"keyword\", value, { shouldValidate: true })\n        setSearchParams(\n          (prev) => {\n            const newParams = new URLSearchParams(prev)\n            if (value.trim()) {\n              newParams.set(\"keyword\", value.trim())\n            } else {\n              newParams.delete(\"keyword\")\n            }\n            return newParams\n          },\n          {\n            replace: true,\n          },\n        )\n      })\n    },\n    [form, setSearchParams],\n  )\n\n  const handleCompositionEnd = useCallback(\n    (event: CompositionEvent<HTMLInputElement>) => {\n      const { value } = event.currentTarget\n      form.setValue(\"keyword\", value, { shouldValidate: true })\n      setSearchParams(\n        (prev) => {\n          const newParams = new URLSearchParams(prev)\n          if (value.trim()) {\n            newParams.set(\"keyword\", value.trim())\n          } else {\n            newParams.delete(\"keyword\")\n          }\n          return newParams\n        },\n        {\n          replace: true,\n        },\n      )\n    },\n    [form, setSearchParams],\n  )\n\n  const handleSuccess = useCallback(\n    (item: DiscoveryItem) => {\n      const currentData = getDiscoverSearchData()\n      if (!currentData) return\n      setDiscoverSearchData(\n        produce(currentData, (draft) => {\n          const sub = (draft[atomKey.current] || []).find((i) => {\n            if (item.feed) {\n              return i.feed?.id === item.feed.id\n            }\n            if (item.list) {\n              return i.list?.id === item.list.id\n            }\n            return false\n          })\n          if (!sub) return\n          sub.subscriptionCount = -~(sub.subscriptionCount as number)\n        }),\n      )\n    },\n    [atomKey],\n  )\n\n  const handleUnSubscribed = useCallback(\n    (item: DiscoveryItem) => {\n      const currentData = getDiscoverSearchData()\n      if (!currentData) return\n      setDiscoverSearchData(\n        produce(currentData, (draft) => {\n          const sub = (draft[atomKey.current] || []).find(\n            (i) => i.feed?.id === item.feed?.id || i.list?.id === item.list?.id,\n          )\n          if (!sub) return\n          sub.subscriptionCount = Number.isNaN(sub.subscriptionCount)\n            ? 0\n            : (sub.subscriptionCount as number) - 1\n        }),\n      )\n    },\n    [atomKey],\n  )\n\n  const handleTargetChange = useCallback(\n    (value: string) => {\n      form.setValue(\"target\", value as \"feeds\" | \"lists\")\n    },\n    [form],\n  )\n\n  function onSubmit(values: SearchFormData) {\n    if (!ensureLogin()) {\n      return\n    }\n    atomKey.current = values.keyword + values.target\n    mutation.mutate({ keyword: values.keyword, target: values.target })\n  }\n\n  const showTargetSelector = detectedType === \"search\"\n\n  return (\n    <>\n      <Form {...form}>\n        <form\n          onSubmit={form.handleSubmit(onSubmit)}\n          className=\"w-full max-w-2xl\"\n          data-testid=\"discover-form\"\n        >\n          <div className=\"rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm\">\n            <FormField\n              control={form.control}\n              name=\"keyword\"\n              render={({ field }) => (\n                <FormItem className=\"mb-4\">\n                  <FormLabel className=\"mb-2 text-headline font-bold text-text\">\n                    {t(\"discover.any_url_or_keyword\")}\n                  </FormLabel>\n                  <FormControl>\n                    <Input\n                      autoFocus\n                      data-testid=\"discover-form-input\"\n                      {...field}\n                      value={field.value || \"\"}\n                      onChange={handleKeywordChange}\n                      onCompositionEnd={handleCompositionEnd}\n                      placeholder=\"Enter URL, RSSHub route, or keyword...\"\n                      className=\"h-12 text-base\"\n                    />\n                  </FormControl>\n                  <FormMessage />\n                  <div className=\"mt-2 flex flex-wrap gap-2 text-xs text-text-tertiary\">\n                    <span>💡 {t(\"discover.tips.auto_detect\")}</span>\n                    {detectedType === \"search\" && (\n                      <>\n                        <span>•</span>\n                        <span>{t(\"discover.tips.search_keyword\")}</span>\n                      </>\n                    )}\n                    {detectedType === \"rss\" && (\n                      <>\n                        <span>•</span>\n                        <a\n                          href={`${repository.url}/wiki/Folo-Flavored-Feed-Spec`}\n                          target=\"_blank\"\n                          rel=\"noreferrer\"\n                          className=\"inline-flex items-center gap-1 rounded-full border border-accent px-2 py-px text-accent hover:bg-accent/10\"\n                        >\n                          <i className=\"i-mgc-book-6-cute-re\" />\n                          <span>Folo Flavored Feed Spec</span>\n                        </a>\n                      </>\n                    )}\n                    {detectedType === \"rsshub\" && (\n                      <>\n                        <span>•</span>\n                        <a\n                          href=\"https://docs.rsshub.app/\"\n                          target=\"_blank\"\n                          rel=\"noreferrer\"\n                          className=\"inline-flex items-center gap-1 rounded-full border border-accent px-2 py-px text-accent hover:bg-accent/10\"\n                        >\n                          <i className=\"i-mgc-book-6-cute-re\" />\n                          <span>RSSHub Docs</span>\n                        </a>\n                      </>\n                    )}\n                  </div>\n                </FormItem>\n              )}\n            />\n            {showTargetSelector && (\n              <FormField\n                control={form.control}\n                name=\"target\"\n                render={({ field }) => (\n                  <FormItem className=\"mb-4\">\n                    <div className=\"mb-2 flex items-center justify-between\">\n                      <FormLabel className=\"text-headline font-medium text-text-secondary\">\n                        {t(\"discover.target.label\")}\n                      </FormLabel>\n                      <FormControl>\n                        <div className=\"flex\">\n                          {isMobile ? (\n                            <ResponsiveSelect\n                              size=\"sm\"\n                              value={field.value}\n                              onValueChange={handleTargetChange}\n                              items={[\n                                { label: t(\"discover.target.feeds\"), value: \"feeds\" },\n                                { label: t(\"discover.target.lists\"), value: \"lists\" },\n                              ]}\n                            />\n                          ) : (\n                            <SegmentGroup\n                              className=\"-mt-2 h-8\"\n                              value={field.value}\n                              onValueChanged={handleTargetChange}\n                            >\n                              <SegmentItem value=\"feeds\" label={t(\"discover.target.feeds\")} />\n                              <SegmentItem value=\"lists\" label={t(\"discover.target.lists\")} />\n                            </SegmentGroup>\n                          )}\n                        </div>\n                      </FormControl>\n                    </div>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n            )}\n            <div className=\"center flex flex-col gap-3\" data-testid=\"discover-form-actions\">\n              <Button\n                data-testid=\"discover-form-submit\"\n                disabled={!form.formState.isValid}\n                type=\"submit\"\n                isLoading={mutation.isPending}\n              >\n                {detectedType === \"search\" ? t(\"words.search\") : t(\"discover.preview\")}\n              </Button>\n\n              {/* Compact Tools */}\n              <div className=\"mt-5 flex items-center justify-center gap-3 text-xs\">\n                <ToolLink\n                  icon=\"i-mgc-file-upload-cute-re\"\n                  label={t(\"discover.tools.import\")}\n                  onClick={() => {\n                    present({\n                      title: t(\"discover.tools.import\"),\n                      content: () => <DiscoverImport />,\n                      modalClassName: \"max-w-2xl w-full\",\n                    })\n                  }}\n                />\n                <ToolLink\n                  icon=\"i-mgc-web-cute-re\"\n                  label={t(\"discover.tools.transform\")}\n                  onClick={() => {\n                    present({\n                      title: t(\"discover.tools.transform\"),\n                      content: () => <DiscoverTransform />,\n                      modalClassName: \"max-w-2xl w-full\",\n                    })\n                  }}\n                />\n                <ToolLink\n                  icon=\"i-mgc-inbox-cute-re\"\n                  label={t(\"discover.tools.inbox\")}\n                  onClick={() => {\n                    present({\n                      title: t(\"words.inbox\"),\n                      content: () => <DiscoverInboxList />,\n                      modalClassName: \"max-w-2xl w-full\",\n                    })\n                  }}\n                />\n                <ToolLink\n                  icon=\"i-mgc-user-3-cute-re\"\n                  label={t(\"discover.tools.user\")}\n                  onClick={() => {\n                    present({\n                      title: t(\"words.user\"),\n                      content: () => <DiscoverUser />,\n                      modalClassName: \"max-w-2xl w-full\",\n                    })\n                  }}\n                />\n              </div>\n            </div>\n          </div>\n        </form>\n      </Form>\n\n      <div className=\"mt-8 w-full max-w-2xl\">\n        {(mutation.isSuccess || !!discoverSearchData?.length) && (\n          <div className=\"mb-4 flex items-center gap-2 text-sm text-text-secondary\">\n            {t(\"discover.search.results\", { count: discoverSearchData?.length || 0 })}\n\n            {discoverSearchData && discoverSearchData.length > 0 && (\n              <MotionButtonBase\n                className=\"flex cursor-button items-center justify-between gap-2 hover:text-accent\"\n                type=\"button\"\n                onClick={() => {\n                  setDiscoverSearchData({})\n                  mutation.reset()\n                }}\n              >\n                <i className=\"i-mgc-close-cute-re\" />\n              </MotionButtonBase>\n            )}\n          </div>\n        )}\n        <div className=\"space-y-4 text-sm\">\n          {discoverSearchData?.map((item) => (\n            <DiscoverFeedCard\n              key={item.feed?.id || item.list?.id}\n              item={item}\n              onSuccess={handleSuccess}\n              onUnSubscribed={handleUnSubscribed}\n              className=\"last:border-b-0\"\n            />\n          ))}\n        </div>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/atoms/discover.ts",
    "content": "import { createAtomHooks } from \"@follow/utils\"\nimport type { DiscoveryItem } from \"@follow-app/client-sdk\"\nimport { atom, useAtomValue } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { useMemo } from \"react\"\n\nconst internalAtom = atom<Record<string, DiscoveryItem[]>>({})\nexport const [, , useDiscoverSearchData, , getDiscoverSearchData, setDiscoverSearchData] =\n  createAtomHooks(internalAtom)\n\nexport const useHasDiscoverSearchData = () => {\n  return useAtomValue(\n    useMemo(() => selectAtom(internalAtom, (data) => Object.keys(data).length > 0), []),\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/example-data.json",
    "content": "{\n  \"apnews\": {\n    \"routes\": {\n      \"/rss/:category?\": {\n        \"path\": \"/rss/:category?\",\n        \"categories\": [\"traditional-media\", \"popular\"],\n        \"example\": \"/apnews/rss/business\",\n        \"parameters\": {\n          \"category\": {\n            \"description\": \"Category from the first segment of the corresponding site, or `index` for the front page.\",\n            \"default\": \"index\"\n          }\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"apnews.com/:rss\"],\n            \"target\": \"/rss/:rss\"\n          }\n        ],\n        \"name\": \"News\",\n        \"maintainers\": [\"zoenglinghou\", \"mjysci\", \"TonyRL\"],\n        \"location\": \"rss.ts\"\n      }\n    },\n    \"name\": \"AP News\",\n    \"url\": \"apnews.com\"\n  },\n  \"bilibili\": {\n    \"routes\": {\n      \"/user/dynamic/:uid/:routeParams?\": {\n        \"path\": \"/user/dynamic/:uid/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/bilibili/user/dynamic/2267573\",\n        \"parameters\": {\n          \"uid\": \"用户 id, 可在 UP 主主页中找到\",\n          \"routeParams\": \"额外参数；请参阅以下说明和表格\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"BILIBILI_COOKIE_*\",\n              \"optional\": true,\n              \"description\": \"如果没有此配置，那么必须开启 puppeteer 支持；BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由，对应 uid 的 b 站用户登录后的 Cookie 值，`{uid}` 替换为 uid，如 `BILIBILI_COOKIE_2267573`，获取方式：\\n1.  打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)\\n2.  打开控制台，切换到 Network 面板，刷新\\n3.  点击 dynamic_new 请求，找到 Cookie\\n4.  视频和专栏，UP 主粉丝及关注只要求 `SESSDATA` 字段，动态需复制整段 Cookie\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": true,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"space.bilibili.com/:uid\"],\n            \"target\": \"/user/dynamic/:uid\"\n          }\n        ],\n        \"name\": \"UP 主动态\",\n        \"maintainers\": [\"DIYgod\", \"zytomorrow\", \"CaoMeiYouRen\", \"JimenezLi\"],\n        \"description\": \"| 键           | 含义                              | 接受的值       | 默认值 |\\n  | ------------ | --------------------------------- | -------------- | ------ |\\n  | showEmoji    | 显示或隐藏表情图片                | 0/1/true/false | false  |\\n  | disableEmbed | 关闭内嵌视频                      | 0/1/true/false | false  |\\n  | useAvid      | 视频链接使用 AV 号 (默认为 BV 号) | 0/1/true/false | false  |\\n  | directLink   | 使用内容直链                      | 0/1/true/false | false  |\\n\\n  用例：`/bilibili/user/dynamic/2267573/showEmoji=1&disableEmbed=1&useAvid=1`\\n\\n  :::tip 动态的专栏显示全文\\n  动态的专栏显示全文请使用通用参数里的 `mode=fulltext`\\n\\n  举例：bilibili 专栏全文输出 /bilibili/user/dynamic/2267573/?mode=fulltext\\n  :::\",\n        \"location\": \"dynamic.ts\"\n      },\n      \"/ranking/:rid?/:day?/:arc_type?/:disableEmbed?\": {\n        \"path\": \"/ranking/:rid?/:day?/:arc_type?/:disableEmbed?\",\n        \"name\": \"排行榜\",\n        \"maintainers\": [\"DIYgod\"],\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/bilibili/ranking/0/3/1\",\n        \"parameters\": {\n          \"rid\": {\n            \"description\": \"排行榜分区 id\",\n            \"default\": \"0\",\n            \"options\": [\n              {\n                \"label\": \"全站\",\n                \"value\": \"0\"\n              },\n              {\n                \"label\": \"动画\",\n                \"value\": \"1\"\n              },\n              {\n                \"label\": \"国创相关\",\n                \"value\": \"168\"\n              },\n              {\n                \"label\": \"音乐\",\n                \"value\": \"3\"\n              },\n              {\n                \"label\": \"舞蹈\",\n                \"value\": \"129\"\n              },\n              {\n                \"label\": \"游戏\",\n                \"value\": \"4\"\n              },\n              {\n                \"label\": \"科技\",\n                \"value\": \"36\"\n              },\n              {\n                \"label\": \"数码\",\n                \"value\": \"188\"\n              },\n              {\n                \"label\": \"生活\",\n                \"value\": \"160\"\n              },\n              {\n                \"label\": \"鬼畜\",\n                \"value\": \"119\"\n              },\n              {\n                \"label\": \"时尚\",\n                \"value\": \"155\"\n              },\n              {\n                \"label\": \"娱乐\",\n                \"value\": \"5\"\n              },\n              {\n                \"label\": \"影视\",\n                \"value\": \"181\"\n              }\n            ]\n          },\n          \"day\": {\n            \"description\": \"时间跨度\",\n            \"options\": [\n              {\n                \"value\": \"1\",\n                \"label\": \"1 日\"\n              },\n              {\n                \"value\": \"3\",\n                \"label\": \"3 日\"\n              },\n              {\n                \"value\": \"7\",\n                \"label\": \"7 日\"\n              },\n              {\n                \"value\": \"30\",\n                \"label\": \"30 日\"\n              }\n            ]\n          },\n          \"arc_type\": {\n            \"description\": \"投稿时间\",\n            \"default\": \"1\",\n            \"options\": [\n              {\n                \"value\": \"0\",\n                \"label\": \"全部投稿\"\n              },\n              {\n                \"value\": \"1\",\n                \"label\": \"近期投稿\"\n              }\n            ]\n          },\n          \"disableEmbed\": {\n            \"description\": \"默认为开启内嵌视频，任意值为关闭\"\n          }\n        },\n        \"location\": \"ranking.ts\"\n      },\n      \"/user/video/:uid/:disableEmbed?\": {\n        \"path\": \"/user/video/:uid/:disableEmbed?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/bilibili/user/video/2267573\",\n        \"parameters\": {\n          \"uid\": \"用户 id, 可在 UP 主主页中找到\",\n          \"disableEmbed\": \"默认为开启内嵌视频，任意值为关闭\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": true,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"space.bilibili.com/:uid\"],\n            \"target\": \"/user/video/:uid\"\n          }\n        ],\n        \"name\": \"UP 主投稿\",\n        \"maintainers\": [\"DIYgod\"],\n        \"description\": \":::tip 动态的专栏显示全文\\n  可以使用 [UP 主动态](#bilibili-up-zhu-dong-tai) 路由作为代替绕过反爬限制\\n  :::\",\n        \"location\": \"video.ts\"\n      }\n    },\n    \"name\": \"Bilibili\",\n    \"url\": \"www.bilibili.com\"\n  },\n  \"dockerhub\": {\n    \"routes\": {\n      \"/build/:owner/:image/:tag?\": {\n        \"path\": \"/build/:owner/:image/:tag?\",\n        \"categories\": [\"program-update\", \"popular\"],\n        \"example\": \"/dockerhub/build/wangqiru/ttrss\",\n        \"parameters\": {\n          \"owner\": \"Image owner\",\n          \"image\": \"Image name\",\n          \"tag\": {\n            \"description\": \"Image tag\",\n            \"default\": \"latest\"\n          }\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"Image New Build\",\n        \"maintainers\": [\"HenryQW\"],\n        \"description\": \":::warning\\n  The owner of the official image fills in the library, for example: [https://rsshub.app/dockerhub/build/library/mysql](https://rsshub.app/dockerhub/build/library/mysql)\\n  :::\",\n        \"location\": \"build.ts\"\n      }\n    },\n    \"name\": \"Docker Hub\",\n    \"url\": \"hub.docker.com\"\n  },\n  \"douban\": {\n    \"routes\": {\n      \"/group/:groupid/:type?\": {\n        \"path\": \"/group/:groupid/:type?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/douban/group/648102\",\n        \"parameters\": {\n          \"groupid\": \"豆瓣小组的 id\",\n          \"type\": {\n            \"description\": \"类型\",\n            \"options\": [\n              {\n                \"label\": \"最新\",\n                \"value\": \"\"\n              },\n              {\n                \"label\": \"最热\",\n                \"value\": \"essence\"\n              },\n              {\n                \"label\": \"精华\",\n                \"value\": \"elite\"\n              }\n            ]\n          }\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"www.douban.com/group/:groupid\"],\n            \"target\": \"/group/:groupid\"\n          }\n        ],\n        \"name\": \"豆瓣小组\",\n        \"maintainers\": [\"DIYgod\"],\n        \"location\": \"other/group.ts\"\n      }\n    },\n    \"name\": \"豆瓣\",\n    \"url\": \"www.douban.com\"\n  },\n  \"github\": {\n    \"routes\": {\n      \"/issue/:user/:repo/:state?/:labels?\": {\n        \"path\": \"/issue/:user/:repo/:state?/:labels?\",\n        \"categories\": [\"programming\", \"popular\"],\n        \"example\": \"/github/issue/vuejs/core/all/wontfix\",\n        \"parameters\": {\n          \"user\": \"GitHub username\",\n          \"repo\": \"GitHub repo name\",\n          \"state\": {\n            \"description\": \"the state of the issues.\",\n            \"default\": \"open\",\n            \"options\": [\n              {\n                \"label\": \"Open\",\n                \"value\": \"open\"\n              },\n              {\n                \"label\": \"Closed\",\n                \"value\": \"closed\"\n              },\n              {\n                \"label\": \"All\",\n                \"value\": \"all\"\n              }\n            ]\n          },\n          \"labels\": \"a list of comma separated label names\"\n        },\n        \"radar\": [\n          {\n            \"source\": [\n              \"github.com/:user/:repo/issues\",\n              \"github.com/:user/:repo/issues/:id\",\n              \"github.com/:user/:repo\"\n            ],\n            \"target\": \"/issue/:user/:repo\"\n          }\n        ],\n        \"name\": \"Repo Issues\",\n        \"maintainers\": [\"HenryQW\", \"AndreyMZ\"],\n        \"location\": \"issue.ts\"\n      }\n    },\n    \"name\": \"GitHub\",\n    \"url\": \"github.com\",\n    \"description\": \":::tip\\nGitHub provides some official RSS feeds:\\n\\n-   Repo releases: `https://github.com/:owner/:repo/releases.atom`\\n-   Repo commits: `https://github.com/:owner/:repo/commits.atom`\\n-   User activities: `https://github.com/:user.atom`\\n-   Private feed: `https://github.com/:user.private.atom?token=:secret` (You can find **Subscribe to your news feed** in [dashboard](https://github.com) page after login)\\n-   Wiki history: `https://github.com/:owner/:repo/wiki.atom`\\n:::\"\n  },\n  \"instagram\": {\n    \"routes\": {\n      \"/:category/:key\": {\n        \"path\": \"/:category/:key\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/instagram/user/stefaniejoosten\",\n        \"parameters\": {\n          \"category\": \"Feed category, see table above\",\n          \"key\": \"Username / Hashtag name\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"IG_PROXY\",\n              \"optional\": true,\n              \"description\": \"\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": true,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"User Profile / Hashtag - Private API\",\n        \"maintainers\": [\"oppilate\", \"DIYgod\"],\n        \"description\": \":::warning\\nDue to [Instagram Private API](https://github.com/dilame/instagram-private-api) restrictions, you have to setup your credentials on the server. 2FA is not supported. See [deployment guide](https://docs.rsshub.app/deploy/) for more.\\n:::\",\n        \"location\": \"private-api/index.ts\"\n      }\n    },\n    \"name\": \"Instagram\",\n    \"url\": \"www.instagram.com\",\n    \"description\": \":::tip\\nIt's highly recommended to deploy with Redis cache enabled.\\n:::\"\n  },\n  \"javbus\": {\n    \"routes\": {\n      \"/:path{.+}?\": {\n        \"path\": \"/:path{.+}?\",\n        \"radar\": [\n          {\n            \"source\": [\"www.javbus.com/:path*\"],\n            \"target\": \"/:path\"\n          }\n        ],\n        \"name\": \"Works\",\n        \"maintainers\": [\"MegrezZhu\", \"CoderTonyChan\", \"nczitzk\", \"Felix2yu\"],\n        \"categories\": [\"multimedia\", \"popular\"],\n        \"url\": \"www.javbus.com\",\n        \"example\": \"/javbus/star/rwt\",\n        \"parameters\": {\n          \"path\": {\n            \"description\": \"Any path of list page on javbus\"\n          }\n        },\n        \"location\": \"index.ts\"\n      }\n    },\n    \"name\": \"JavBus\",\n    \"url\": \"www.javbus.com\",\n    \"description\": \":::warning\\nRequests from non-Asia areas will be redirected to login page.\\n:::\\n\\n:::tip Language\\nYou can change the language of each route to the languages listed below.\\n\\n| English | 日本语 | 한국의 | 中文             |\\n| ------- | ------ | ------ | ---------------- |\\n| en      | ja     | ko     | (leave it empty) |\\n:::\\n\\n:::tip\\nJavBus has multiple backup domains, these routes use default domain `https://javbus.com`. If the domain is unreachable, you can add `?domain=<domain>` to the end of the route to specify the domain to visit. Let say you want to use the backup domain `https://javsee.icu`, you can add `?domain=javsee.icu` to the end of the route, then the route will be [`/javbus/en?domain=javsee.icu`](https://rsshub.app/javbus?domain=javsee.icu)\\n\\n**Note**: **Western** has different domain than the main site, the backup domains are also different. The default domain is `https://javbus.org` and you can add `?western_domain=<domain>` to the end of the route to specify the domain to visit. Let say you want to use the backup domain `https://javsee.one`, you can add `?western_domain=javsee.one` to the end of the route, then the route will be [`/javbus/western/en?western_domain=javsee.one`](https://rsshub.app/javbus/western?western_domain=javsee.one)\\n:::\"\n  },\n  \"jike\": {\n    \"routes\": {\n      \"/topic/:id/:showUid?\": {\n        \"path\": \"/topic/:id/:showUid?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/jike/topic/556688fae4b00c57d9dd46ee\",\n        \"parameters\": {\n          \"id\": \"圈子 id, 可在即刻 web 端圈子页或 APP 分享出来的圈子页 URL 中找到\",\n          \"showUid\": \"是否在内容中显示用户信息，设置为 1 则开启\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"web.okjike.com/topic/:id\"],\n            \"target\": \"/topic/:id\"\n          }\n        ],\n        \"name\": \"圈子\",\n        \"maintainers\": [\"DIYgod\", \"prnake\"],\n        \"location\": \"topic.ts\"\n      },\n      \"/user/:id\": {\n        \"path\": \"/user/:id\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/jike/user/3EE02BC9-C5B3-4209-8750-4ED1EE0F67BB\",\n        \"parameters\": {\n          \"id\": \"用户 id, 可在即刻分享出来的单条动态页点击用户头像进入个人主页，然后在个人主页的 URL 中找到，或者在单条动态页使用 RSSHub Radar 插件\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"web.okjike.com/u/:uid\"],\n            \"target\": \"/user/:uid\"\n          }\n        ],\n        \"name\": \"用户动态\",\n        \"maintainers\": [\"DIYgod\", \"prnake\"],\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"即刻\",\n    \"url\": \"m.okjike.com\"\n  },\n  \"lofter\": {\n    \"routes\": {\n      \"/user/:name?\": {\n        \"path\": \"/user/:name?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/lofter/user/i\",\n        \"parameters\": {\n          \"name\": \"Lofter user name, can be found in the URL\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"User\",\n        \"maintainers\": [\"hondajojo\", \"nczitzk\", \"LucunJi\"],\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"Lofter\",\n    \"url\": \"www.lofter.com\"\n  },\n  \"pixiv\": {\n    \"routes\": {\n      \"/ranking/:mode/:date?\": {\n        \"path\": \"/ranking/:mode/:date?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/pixiv/ranking/week\",\n        \"parameters\": {\n          \"mode\": \"rank type\",\n          \"date\": \"format: `2018-4-25`\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"Rankings\",\n        \"maintainers\": [\"EYHN\"],\n        \"description\": \"| daily rank | weekly rank | monthly rank | male rank | female rank | AI-generated work Rankings | original rank  | rookie user rank |\\n  | ---------- | ----------- | ------------ | --------- | ----------- | -------------------------- | -------------- | ---------------- |\\n  | day        | week        | month        | day_male | day_female | day_ai                    | week_original | week_rookie     |\\n\\n  | R-18 daily rank | R-18 AI-generated work | R-18 male rank | R-18 female rank | R-18 weekly rank | R-18G rank |\\n  | --------------- | ---------------------- | -------------- | ---------------- | ---------------- | ---------- |\\n  | day_r18        | day_r18_ai           | day_male_r18 | day_female_r18 | week_r18        | week_r18g |\",\n        \"location\": \"ranking.ts\"\n      },\n      \"/search/:keyword/:order?/:mode?\": {\n        \"path\": \"/search/:keyword/:order?/:mode?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/pixiv/search/Nezuko/popular\",\n        \"parameters\": {\n          \"keyword\": \"keyword\",\n          \"order\": {\n            \"description\": \"rank mode, empty or other for time order, popular for popular order\",\n            \"default\": \"date\",\n            \"options\": [\n              {\n                \"label\": \"time order\",\n                \"value\": \"date\"\n              },\n              {\n                \"label\": \"popular order\",\n                \"value\": \"popular\"\n              }\n            ]\n          },\n          \"mode\": {\n            \"description\": \"filte R18 content\",\n            \"default\": \"no\",\n            \"options\": [\n              {\n                \"label\": \"only not R18\",\n                \"value\": \"safe\"\n              },\n              {\n                \"label\": \"only R18\",\n                \"value\": \"r18\"\n              },\n              {\n                \"label\": \"no filter\",\n                \"value\": \"no\"\n              }\n            ]\n          }\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"Keyword\",\n        \"maintainers\": [\"DIYgod\"],\n        \"location\": \"search.ts\"\n      },\n      \"/user/:id\": {\n        \"path\": \"/user/:id\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/pixiv/user/15288095\",\n        \"parameters\": {\n          \"id\": \"user id, available in user's homepage URL\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"www.pixiv.net/users/:id\"]\n          }\n        ],\n        \"name\": \"User Activity\",\n        \"maintainers\": [\"DIYgod\"],\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"pixiv\",\n    \"url\": \"www.pixiv.net\"\n  },\n  \"rsshub\": {\n    \"routes\": {\n      \"/routes/:lang?\": {\n        \"path\": \"/routes/:lang?\",\n        \"categories\": [\"program-update\", \"popular\"],\n        \"example\": \"/rsshub/routes/en\",\n        \"parameters\": {\n          \"lang\": {\n            \"description\": \"Language\",\n            \"options\": [\n              {\n                \"label\": \"Chinese\",\n                \"value\": \"zh\"\n              },\n              {\n                \"label\": \"English\",\n                \"value\": \"en\"\n              }\n            ],\n            \"default\": \"en\"\n          }\n        },\n        \"radar\": [\n          {\n            \"source\": [\"docs.rsshub.app/*\"],\n            \"target\": \"/routes\"\n          }\n        ],\n        \"name\": \"New routes\",\n        \"maintainers\": [\"DIYgod\"],\n        \"url\": \"docs.rsshub.app/*\",\n        \"location\": \"routes.ts\"\n      }\n    },\n    \"name\": \"RSSHub\",\n    \"url\": \"docs.rsshub.app\"\n  },\n  \"sehuatang\": {\n    \"routes\": {\n      \"/user/:uid\": {\n        \"path\": \"/user/:uid\",\n        \"categories\": [\"multimedia\", \"popular\"],\n        \"example\": \"/sehuatang/user/411096\",\n        \"parameters\": {\n          \"uid\": \"用户 uid, 可在用户主页 URL 中找到\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"SEHUATANG_COOKIE\",\n              \"description\": \"\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"作者文章\",\n        \"maintainers\": [\"JamYiz\"],\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"色花堂\",\n    \"url\": \"sehuatang.net\"\n  },\n  \"telegram\": {\n    \"routes\": {\n      \"/channel/:username/:routeParams?\": {\n        \"path\": \"/channel/:username/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/telegram/channel/awesomeDIYgod/searchQuery=twitter\",\n        \"parameters\": {\n          \"username\": \"channel username\",\n          \"routeParams\": \"extra parameters, see the table below\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"t.me/s/:username\"],\n            \"target\": \"/channel/:username\"\n          }\n        ],\n        \"name\": \"Channel\",\n        \"maintainers\": [\"DIYgod\", \"Rongronggg9\"],\n        \"description\": \"\\n  | Key                    | Description                                                           | Accepts                                            | Defaults to  |\\n  | ---------------------- | --------------------------------------------------------------------- | -------------------------------------------------- | ------------ |\\n  | showLinkPreview        | Show the link preview from Telegram                                   | 0/1/true/false                                     | true         |\\n  | showViaBot             | For messages sent via bot, show the bot                               | 0/1/true/false                                     | true         |\\n  | showReplyTo            | For reply messages, show the target of the reply                      | 0/1/true/false                                     | true         |\\n  | showFwdFrom            | For forwarded messages, show the forwarding source                    | 0/1/true/false                                     | true         |\\n  | showFwdFromAuthor      | For forwarded messages, show the author of the forwarding source      | 0/1/true/false                                     | true         |\\n  | showInlineButtons      | Show inline buttons                                                   | 0/1/true/false                                     | false        |\\n  | showMediaTagInTitle    | Show media tags in the title                                          | 0/1/true/false                                     | true         |\\n  | showMediaTagAsEmoji    | Show media tags as emoji                                              | 0/1/true/false                                     | true         |\\n  | showHashtagAsHyperlink | Show hashtags as hyperlinks (`https://t.me/s/channel?q=%23hashtag`) | 0/1/true/false                                     | true         |\\n  | includeFwd             | Include forwarded messages                                            | 0/1/true/false                                     | true         |\\n  | includeReply           | Include reply messages                                                | 0/1/true/false                                     | true         |\\n  | includeServiceMsg      | Include service messages (e.g. message pinned, channel photo updated) | 0/1/true/false                                     | true         |\\n  | includeUnsupportedMsg  | Include messages unsupported by t.me                                  | 0/1/true/false                                     | false        |\\n  | searchQuery            | search query                                                          | keywords; replace `#hashtag` with `%23hashtag` | (no keyword) |\\n\\n  Specify different option values than default values can meet different needs, URL\\n\\n  ```\\n  https://rsshub.app/telegram/channel/NewlearnerChannel/showLinkPreview=0&showViaBot=0&showReplyTo=0&showFwdFrom=0&showFwdFromAuthor=0&showInlineButtons=0&showMediaTagInTitle=1&showMediaTagAsEmoji=1&includeFwd=0&includeReply=1&includeServiceMsg=0&includeUnsupportedMsg=0\\n  ```\\n\\n  generates an RSS without any link previews and annoying metadata, with emoji media tags in the title, without forwarded messages (but with reply messages), and without messages you don't care about (service messages and unsupported messages), for people who prefer pure subscriptions.\\n\\n  :::tip\\n  For backward compatibility reasons, invalid `routeParams` will be treated as `searchQuery` .\\n\\n  Due to Telegram restrictions, some channels involving pornography, copyright, and politics cannot be subscribed. You can confirm by visiting `https://t.me/s/:username`.\\n  :::\",\n        \"location\": \"channel.ts\"\n      }\n    },\n    \"name\": \"Telegram\",\n    \"url\": \"t.me\"\n  },\n  \"twitter\": {\n    \"routes\": {\n      \"/keyword/:keyword/:routeParams?\": {\n        \"path\": \"/keyword/:keyword/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/twitter/keyword/RSSHub\",\n        \"parameters\": {\n          \"keyword\": \"keyword\",\n          \"routeParams\": \"extra parameters, see the table above\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"TWITTER_USERNAME\",\n              \"description\": \"Please see above for details.\"\n            },\n            {\n              \"name\": \"TWITTER_PASSWORD\",\n              \"description\": \"Please see above for details.\"\n            },\n            {\n              \"name\": \"TWITTER_COOKIE\",\n              \"description\": \"Please see above for details.\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"Keyword\",\n        \"maintainers\": [\"DIYgod\", \"yindaheng98\", \"Rongronggg9\"],\n        \"radar\": [\n          {\n            \"source\": [\"x.com/search\"]\n          }\n        ],\n        \"location\": \"keyword.ts\"\n      },\n      \"/media/:id/:routeParams?\": {\n        \"path\": \"/media/:id/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/twitter/media/DIYgod\",\n        \"parameters\": {\n          \"id\": \"username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`\",\n          \"routeParams\": \"extra parameters, see the table above.\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"TWITTER_USERNAME\",\n              \"description\": \"Please see above for details.\"\n            },\n            {\n              \"name\": \"TWITTER_PASSWORD\",\n              \"description\": \"Please see above for details.\"\n            },\n            {\n              \"name\": \"TWITTER_COOKIE\",\n              \"description\": \"Please see above for details.\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"User media\",\n        \"maintainers\": [\"DIYgod\", \"yindaheng98\", \"Rongronggg9\"],\n        \"radar\": [\n          {\n            \"source\": [\"x.com/:id/media\"],\n            \"target\": \"/media/:id\"\n          }\n        ],\n        \"location\": \"media.ts\"\n      },\n      \"/user/:id/:routeParams?\": {\n        \"path\": \"/user/:id/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/twitter/user/DIYgod\",\n        \"parameters\": {\n          \"id\": \"username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`\",\n          \"routeParams\": \"extra parameters, see the table above; particularly when `routeParams=exclude_replies`, replies are excluded; `routeParams=exclude_rts` excludes retweets,`routeParams=exclude_rts_replies` exclude replies and retweets; for default include all.\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"TWITTER_USERNAME\",\n              \"description\": \"Please see above for details.\"\n            },\n            {\n              \"name\": \"TWITTER_PASSWORD\",\n              \"description\": \"Please see above for details.\"\n            },\n            {\n              \"name\": \"TWITTER_AUTHENTICATION_SECRET\",\n              \"description\": \"TOTP 2FA secret, please see above for details.\",\n              \"optional\": true\n            },\n            {\n              \"name\": \"TWITTER_COOKIE\",\n              \"description\": \"Please see above for details.\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"User timeline\",\n        \"maintainers\": [\"DIYgod\", \"yindaheng98\", \"Rongronggg9\"],\n        \"radar\": [\n          {\n            \"source\": [\"x.com/:id\"],\n            \"target\": \"/user/:id\"\n          }\n        ],\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"X (Twitter)\",\n    \"url\": \"x.com\",\n    \"description\": \"Specify options (in the format of query string) in parameter `routeParams` to control some extra features for Tweets\\n\\n| Key                            | Description                                                                                                                          | Accepts                | Defaults to                               |\\n| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | ----------------------------------------- |\\n| `readable`                     | Enable readable layout                                                                                                               | `0`/`1`/`true`/`false` | `false`                                   |\\n| `authorNameBold`               | Display author name in bold                                                                                                          | `0`/`1`/`true`/`false` | `false`                                   |\\n| `showAuthorInTitle`            | Show author name in title                                                                                                            | `0`/`1`/`true`/`false` | `false` (`true` in `/twitter/followings`) |\\n| `showAuthorInDesc`             | Show author name in description (RSS body)                                                                                           | `0`/`1`/`true`/`false` | `false` (`true` in `/twitter/followings`) |\\n| `showQuotedAuthorAvatarInDesc` | Show avatar of quoted Tweet's author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | `0`/`1`/`true`/`false` | `false`                                   |\\n| `showAuthorAvatarInDesc`       | Show avatar of author in description (RSS body) (Not recommended if your RSS reader extracts images from description)                | `0`/`1`/`true`/`false` | `false`                                   |\\n| `showEmojiForRetweetAndReply`  | Use \\\"🔁\\\" instead of \\\"RT\\\", \\\"↩️\\\" & \\\"💬\\\" instead of \\\"Re\\\"                                                                                | `0`/`1`/`true`/`false` | `false`                                   |\\n| `showSymbolForRetweetAndReply` | Use \\\" RT \\\" instead of \\\"\\\", \\\" Re \\\" instead of \\\"\\\"                                                                                       | `0`/`1`/`true`/`false` | `true`                                    |\\n| `showRetweetTextInTitle`       | Show quote comments in title (if `false`, only the retweeted tweet will be shown in the title)                                       | `0`/`1`/`true`/`false` | `true`                                    |\\n| `addLinkForPics`               | Add clickable links for Tweet pictures                                                                                               | `0`/`1`/`true`/`false` | `false`                                   |\\n| `showTimestampInDescription`   | Show timestamp in description                                                                                                        | `0`/`1`/`true`/`false` | `false`                                   |\\n| `showQuotedInTitle`            | Show quoted tweet in title                                                                                                           | `0`/`1`/`true`/`false` | `false`                                   |\\n| `widthOfPics`                  | Width of Tweet pictures                                                                                                              | Unspecified/Integer    | Unspecified                               |\\n| `heightOfPics`                 | Height of Tweet pictures                                                                                                             | Unspecified/Integer    | Unspecified                               |\\n| `sizeOfAuthorAvatar`           | Size of author's avatar                                                                                                              | Integer                | `48`                                      |\\n| `sizeOfQuotedAuthorAvatar`     | Size of quoted tweet's author's avatar                                                                                               | Integer                | `24`                                      |\\n| `excludeReplies`               | Exclude replies, only available in `/twitter/user`                                                                                   | `0`/`1`/`true`/`false` | `false`                                   |\\n| `includeRts`                   | Include retweets, only available in `/twitter/user`                                                                                  | `0`/`1`/`true`/`false` | `true`                                    |\\n| `forceWebApi`                  | Force using Web API even if Developer API is configured, only available in `/twitter/user` and `/twitter/keyword`                    | `0`/`1`/`true`/`false` | `false`                                   |\\n| `count`                        | `count` parameter passed to Twitter API, only available in `/twitter/user`                                                           | Unspecified/Integer    | Unspecified                               |\\n\\nSpecify different option values than default values to improve readability. The URL\\n\\n```\\nhttps://rsshub.app/twitter/user/durov/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showQuotedAuthorAvatarInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweetAndReply=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showQuotedInTitle=1&heightOfPics=150\\n```\\n\\ngenerates\\n\\n<img loading=\\\"lazy\\\" src=\\\"/img/readable-twitter.png\\\" alt=\\\"Readable Twitter RSS of Durov\\\" />\\n\\nCurrently supports two authentication methods:\\n\\n- Using `TWITTER_COOKIE` (recommended): Configure the cookies of logged-in Twitter Web, at least including the fields auth_token and ct0. RSSHub will use this information to directly access Twitter's web API to obtain data.\\n\\n- Using `TWITTER_USERNAME` `TWITTER_PASSWORD` and `TWITTER_AUTHENTICATION_SECRET`: Configure the Twitter username and password. RSSHub will use this information to log in to Twitter and obtain data using the mobile API. Please note that if you have not logged in with the current IP address before, it is easy to trigger Twitter's risk control mechanism.\\n\"\n  },\n  \"weibo\": {\n    \"routes\": {\n      \"/keyword/:keyword/:routeParams?\": {\n        \"path\": \"/keyword/:keyword/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/weibo/keyword/DIYgod\",\n        \"parameters\": {\n          \"keyword\": \"你想订阅的微博关键词\",\n          \"routeParams\": \"额外参数；请参阅上面的说明和表格\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"name\": \"关键词\",\n        \"maintainers\": [\"DIYgod\", \"Rongronggg9\"],\n        \"location\": \"keyword.ts\"\n      },\n      \"/user/:uid/:routeParams?\": {\n        \"path\": \"/user/:uid/:routeParams?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/weibo/user/1195230310\",\n        \"parameters\": {\n          \"uid\": \"用户 id, 博主主页打开控制台执行 `$CONFIG.oid` 获取\",\n          \"routeParams\": \"额外参数；请参阅上面的说明和表格；特别地，当 `routeParams=1` 时开启微博视频显示\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": true,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"m.weibo.cn/u/:uid\", \"m.weibo.cn/profile/:uid\", \"www.weibo.com/u/:uid\"],\n            \"target\": \"/user/:uid\"\n          }\n        ],\n        \"name\": \"博主\",\n        \"maintainers\": [\"DIYgod\", \"iplusx\", \"Rongronggg9\"],\n        \"description\": \":::warning\\n  部分博主仅登录可见，未提供 Cookie 的情况下不支持订阅，可以通过打开 `https://m.weibo.cn/u/:uid` 验证。如需要订阅该部分博主，可配置 Cookie 后订阅。\\n\\n  未提供 Cookie 的情况下偶尔会触发反爬限制，提供 Cookie 可缓解该情况。\\n\\n  微博用户 Cookie 的配置可参照部署文档\\n  :::\",\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"微博绿洲\",\n    \"url\": \"weibo.com\",\n    \"description\": \":::warning\\n微博会针对请求的来源地区返回不同的结果。一个已知的例子为：部分视频因未知原因仅限中国大陆境内访问 (CDN 域名为 `locallimit.us.sinaimg.cn` 而非 `f.video.weibocdn.com`)。若一条微博含有这种视频且 RSSHub 实例部署在境外，抓取到的微博可能不含视频。将 RSSHub 部署在境内有助于抓取这种视频，但阅读器也必须处于境内网络环境以加载视频。\\n:::\\n\\n对于微博内容，在 `routeParams` 参数中以 query string 格式指定选项，可以控制输出的样式\\n\\n| 键                         | 含义                                                               | 接受的值       | 默认值                              |\\n| -------------------------- | ------------------------------------------------------------------ | -------------- | ----------------------------------- |\\n| readable                   | 是否开启细节排版可读性优化                                         | 0/1/true/false | false                               |\\n| authorNameBold             | 是否加粗作者名字                                                   | 0/1/true/false | false                               |\\n| showAuthorInTitle          | 是否在标题处显示作者                                               | 0/1/true/false | false（`/weibo/keyword/` 中为 true） |\\n| showAuthorInDesc           | 是否在正文处显示作者                                               | 0/1/true/false | false（`/weibo/keyword/` 中为 true） |\\n| showAuthorAvatarInDesc     | 是否在正文处显示作者头像（若阅读器会提取正文图片，不建议开启）     | 0/1/true/false | false                               |\\n| showEmojiForRetweet        | 显示“🔁”取代“转发”两个字                                       | 0/1/true/false | false                               |\\n| showRetweetTextInTitle     | 在标题出显示转发评论（置为 false 则在标题只显示被转发微博）        | 0/1/true/false | true                                |\\n| addLinkForPics             | 为图片添加可点击的链接                                             | 0/1/true/false | false                               |\\n| showTimestampInDescription | 在正文处显示被转发微博的时间戳                                     | 0/1/true/false | false                               |\\n| widthOfPics                | 微博配图宽（生效取决于阅读器）                                     | 不指定 / 数字  | 不指定                              |\\n| heightOfPics               | 微博配图高（生效取决于阅读器）                                     | 不指定 / 数字  | 不指定                              |\\n| sizeOfAuthorAvatar         | 作者头像大小                                                       | 数字           | 48                                  |\\n| displayVideo               | 是否直接显示微博视频和 Live Photo，只在博主或个人时间线 RSS 中有效 | 0/1/true/false | true                                |\\n| displayArticle             | 是否直接显示微博文章，只在博主或个人时间线 RSS 中有效              | 0/1/true/false | false                               |\\n| displayComments            | 是否直接显示热门评论，只在博主或个人时间线 RSS 中有效              | 0/1/true/false | false                               |\\n| showEmojiInDescription     | 是否展示正文中的微博表情，关闭则替换为 `[表情名]`                  | 0/1/true/false | true                                |\\n| showLinkIconInDescription  | 是否展示正文中的链接图标                                           | 0/1/true/false | true                                |\\n| preferMobileLink           | 是否使用移动版链接（默认使用 PC 版）                               | 0/1/true/false | false                               |\\n\\n指定更多与默认值不同的参数选项可以改善 RSS 的可读性，如\\n\\n[https://rsshub.app/weibo/user/1642909335/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweet=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showTimestampInDescription=1&heightOfPics=150](https://rsshub.app/weibo/user/1642909335/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweet=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showTimestampInDescription=1&heightOfPics=150)\\n\\n的效果为\\n\\n<img loading=\\\"lazy\\\" src=\\\"/img/readable-weibo.png\\\" alt=\\\"微博小秘书的可读微博 RSS\\\" />\"\n  },\n  \"xiaoyuzhou\": {\n    \"routes\": {\n      \"/podcast/:id\": {\n        \"path\": \"/podcast/:id\",\n        \"categories\": [\"multimedia\", \"popular\"],\n        \"example\": \"/xiaoyuzhou/podcast/6021f949a789fca4eff4492c\",\n        \"parameters\": {\n          \"id\": \"播客 id，可以在小宇宙播客的 URL 中找到\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"xiaoyuzhoufm.com/podcast/:id\"]\n          }\n        ],\n        \"name\": \"播客\",\n        \"maintainers\": [\"hondajojo\", \"jtsang4\"],\n        \"url\": \"xiaoyuzhoufm.com/\",\n        \"location\": \"podcast.ts\"\n      }\n    },\n    \"name\": \"小宇宙\",\n    \"url\": \"xiaoyuzhoufm.com\"\n  },\n  \"ximalaya\": {\n    \"routes\": {\n      \"/:type/:id/:all/:shownote?\": {\n        \"path\": [\"/:type/:id/:all/:shownote?\"],\n        \"categories\": [\"multimedia\", \"popular\"],\n        \"example\": \"/ximalaya/album/299146\",\n        \"parameters\": {\n          \"type\": \"专辑类型，通常可以使用 `album`，可在对应专辑页面的 URL 中找到\",\n          \"id\": \"专辑 id, 可在对应专辑页面的 URL 中找到\",\n          \"all\": \"是否需要获取全部节目，填入 `1`、`true`、`all` 视为获取所有节目，填入其他则不获取。\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"XIMALAYA_TOKEN\",\n              \"description\": \"\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": true,\n          \"supportScihub\": false\n        },\n        \"name\": \"专辑\",\n        \"maintainers\": [\"lengthmin\", \"jjeejj\", \"prnake\"],\n        \"description\": \"目前喜马拉雅的 API 只能一集一集的获取各节目上的 ShowNote，会极大的占用系统资源，所以默认为不获取节目的 ShowNote。\\n\\n  :::warning\\n  专辑类型即 url 中的分类拼音，使用通用分类 `album` 通常是可行的，专辑 id 是跟在**分类拼音**后的那个 id, 不要输成某集的 id 了\\n\\n  **付费内容需要配置好已购买账户的 token 才能收听，详情见部署页面的配置模块**\\n  :::\",\n        \"location\": \"album.ts\"\n      }\n    },\n    \"name\": \"喜马拉雅\",\n    \"url\": \"ximalaya.com\"\n  },\n  \"youtube\": {\n    \"routes\": {\n      \"/user/:username/:embed?\": {\n        \"path\": \"/user/:username/:embed?\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/youtube/user/@JFlaMusic\",\n        \"parameters\": {\n          \"username\": \"YouTuber username with @\",\n          \"embed\": \"Default to embed the video, set to any value to disable embedding\"\n        },\n        \"features\": {\n          \"requireConfig\": [\n            {\n              \"name\": \"YOUTUBE_KEY\",\n              \"description\": \" YouTube API Key, support multiple keys, split them with `,`, [API Key application](https://console.developers.google.com/)\"\n            }\n          ],\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": false,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"www.youtube.com/user/:username\"],\n            \"target\": \"/user/:username\"\n          }\n        ],\n        \"name\": \"Channel with username\",\n        \"maintainers\": [\"DIYgod\"],\n        \"location\": \"user.ts\"\n      }\n    },\n    \"name\": \"YouTube\",\n    \"url\": \"youtube.com\"\n  },\n  \"zhihu\": {\n    \"routes\": {\n      \"/people/activities/:id\": {\n        \"path\": \"/people/activities/:id\",\n        \"categories\": [\"social-media\", \"popular\"],\n        \"example\": \"/zhihu/people/activities/diygod\",\n        \"parameters\": {\n          \"id\": \"作者 id，可在用户主页 URL 中找到\"\n        },\n        \"features\": {\n          \"requireConfig\": false,\n          \"requirePuppeteer\": false,\n          \"antiCrawler\": true,\n          \"supportBT\": false,\n          \"supportPodcast\": false,\n          \"supportScihub\": false\n        },\n        \"radar\": [\n          {\n            \"source\": [\"www.zhihu.com/people/:id\"]\n          }\n        ],\n        \"name\": \"用户动态\",\n        \"maintainers\": [\"DIYgod\"],\n        \"location\": \"activities.ts\"\n      }\n    },\n    \"name\": \"知乎\",\n    \"url\": \"www.zhihu.com\"\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/recommendations.tsx",
    "content": "import { Card, CardContent } from \"@follow/components/ui/card/index.jsx\"\nimport { CategoryMap, RSSHubCategories } from \"@follow/constants\"\nimport { useTranslation } from \"react-i18next\"\nimport { Link } from \"react-router\"\n\nexport function Recommendations() {\n  const { t } = useTranslation()\n\n  return (\n    <div className=\"mx-auto mt-4 w-full max-w-[800px] space-y-6\">\n      <div className=\"grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4\">\n        {RSSHubCategories.map((cat) => (\n          <Link to={`/discover/category/${cat}`} key={cat}>\n            <Card\n              className=\"cursor-pointer transition-all duration-200 hover:scale-[102%] hover:shadow-lg\"\n              style={{\n                backgroundImage: `linear-gradient(-135deg, ${CategoryMap[cat]?.color}80, ${CategoryMap[cat]?.color})`,\n              }}\n            >\n              <CardContent className=\"group relative flex aspect-square flex-col overflow-hidden p-0\">\n                <div className=\"absolute right-2 top-2 size-12 rotate-12 opacity-20\">\n                  <div className=\"text-5xl\">{CategoryMap[cat]?.emoji}</div>\n                </div>\n                <div className=\"flex size-full flex-col items-start justify-end p-6 text-left\">\n                  <div className=\"mb-3 text-4xl transition-transform duration-300 group-hover:scale-[1.2]\">\n                    {CategoryMap[cat]?.emoji}\n                  </div>\n                  <div className=\"text-lg font-bold text-white drop-shadow-sm\">\n                    {t(`discover.category.${cat}`, { ns: \"common\" })}\n                  </div>\n                </div>\n              </CardContent>\n            </Card>\n          </Link>\n        ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/types.ts",
    "content": "export type ParsedFeedItem = {\n  url: string\n  title: string | null\n  category?: string | null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/discover/utils.ts",
    "content": "import type { RSSHubParameter, RSSHubParameterObject } from \"@follow/models/rsshub\"\n\nexport const normalizeRSSHubParameters = (\n  parameters: RSSHubParameter,\n): RSSHubParameterObject | null =>\n  parameters\n    ? typeof parameters === \"string\"\n      ? { description: parameters, default: null }\n      : parameters\n    : null\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/download/index.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.jsx\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { APP_STORE_URLS } from \"@follow/constants\"\nimport { getMobilePlatform, isMobileDevice } from \"@follow/utils\"\nimport { useEffect } from \"react\"\n\nexport function DownloadPage() {\n  const openDownloadPage = () => {\n    window.open(\"https://folo.is/download\", \"_blank\", \"noopener,noreferrer\")\n  }\n\n  const mobilePlatform = getMobilePlatform()\n  const isMobile = isMobileDevice()\n\n  useEffect(() => {\n    if (isMobile && mobilePlatform && APP_STORE_URLS[mobilePlatform]) {\n      window.location.href = APP_STORE_URLS[mobilePlatform]\n    }\n  }, [isMobile, mobilePlatform])\n\n  const handleMobileDownload = () => {\n    if (mobilePlatform && APP_STORE_URLS[mobilePlatform]) {\n      window.location.href = APP_STORE_URLS[mobilePlatform]\n    } else {\n      openDownloadPage()\n    }\n  }\n\n  return (\n    <div className=\"flex min-h-screen flex-col items-center justify-center bg-background px-6\">\n      {/* Logo Section */}\n      <div className=\"mb-8 flex flex-col items-center text-center\">\n        <div className=\"mb-4 flex items-center space-x-4\">\n          <Logo className=\"size-12\" />\n          <Folo className=\"w-12 text-text\" />\n        </div>\n        <p className=\"text-base text-text-secondary\">Follow everything in one place</p>\n      </div>\n\n      {/* Main Content */}\n      <div className=\"w-full max-w-xs space-y-6 text-center\">\n        <div>\n          <h1 className=\"mb-3 text-xl font-semibold text-text\">Download Folo</h1>\n          <p className=\"text-sm text-text-secondary\">\n            {isMobile\n              ? mobilePlatform\n                ? `Get the ${mobilePlatform} app for the best experience`\n                : \"Get the mobile app for the best experience\"\n              : \"Get the mobile app for the best experience\"}\n          </p>\n        </div>\n\n        {/* Download Button */}\n        <Button onClick={isMobile ? handleMobileDownload : openDownloadPage}>\n          <i className=\"i-mgc-download-2-cute-re mr-2 text-lg\" />\n          <span>\n            {isMobile && mobilePlatform ? `Download for ${mobilePlatform}` : \"Go to Download Page\"}\n          </span>\n        </Button>\n\n        {/* Hint */}\n        <p className=\"text-xs text-text-tertiary\">\n          {isMobile\n            ? mobilePlatform\n              ? `Redirecting to ${mobilePlatform === \"iOS\" ? \"App Store\" : \"Google Play\"}...`\n              : \"Available for iOS, Android, Windows, macOS & Linux\"\n            : \"Available for iOS, Android, Windows, macOS & Linux\"}\n        </p>\n      </div>\n    </div>\n  )\n}\n\nexport default DownloadPage\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/editor/css-editor.tsx",
    "content": "import { TextArea } from \"@follow/components/ui/input/TextArea.js\"\nimport { useInputComposition, useIsDark } from \"@follow/hooks\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { createPlainShiki } from \"plain-shiki\"\nimport { useLayoutEffect, useMemo, useRef } from \"react\"\nimport css from \"shiki/langs/css.mjs\"\nimport githubDark from \"shiki/themes/github-dark.mjs\"\nimport githubLight from \"shiki/themes/github-light.mjs\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { shiki } from \"~/components/ui/code-highlighter/shiki/shared\"\n\nshiki.loadLanguageSync(css)\nshiki.loadThemeSync(githubDark)\nshiki.loadThemeSync(githubLight)\n\nexport const CSSEditor: Component<{\n  onChange: (value: string) => void\n  defaultValue?: string\n}> = ({ onChange, className, defaultValue }) => {\n  const ref = useRef<HTMLDivElement>(null)\n\n  const isDark = useIsDark()\n\n  useLayoutEffect(() => {\n    let dispose: () => void\n    if (ref.current) {\n      ref.current.focus()\n      // Move cursor to the end of the content\n      const selection = window.getSelection()\n      const range = document.createRange()\n      range.selectNodeContents(ref.current)\n      range.collapse(false)\n      selection?.removeAllRanges()\n      selection?.addRange(range)\n\n      ref.current.textContent = defaultValue ?? \"\"\n      const { dispose: disposeShiki } = createPlainShiki(shiki).mount(ref.current, {\n        lang: \"css\",\n        themes: {\n          light: \"github-light\",\n          dark: \"github-dark\",\n        },\n        defaultTheme: isDark ? \"dark\" : \"light\",\n      })\n\n      dispose = disposeShiki\n    }\n    return () => dispose?.()\n  }, [isDark])\n  const props = useInputComposition<HTMLInputElement>({\n    onKeyDown: (e) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault()\n      }\n      if (e.key === \"Tab\") {\n        e.preventDefault()\n        const selection = window.getSelection()\n        if (selection && selection.rangeCount > 0) {\n          const range = selection.getRangeAt(0)\n          const tabNode = document.createTextNode(\"\\u00A0\\u00A0\\u00A0\\u00A0\") // Using four non-breaking spaces as a tab\n          range.insertNode(tabNode)\n          range.setStartAfter(tabNode)\n          range.setEndAfter(tabNode)\n          selection.removeAllRanges()\n          selection.addRange(range)\n        }\n      }\n      nextFrame(() => {\n        onChange(ref.current?.textContent ?? \"\")\n      })\n    },\n  })\n  const handleInput = useEventCallback(() => {\n    onChange(ref.current?.textContent ?? \"\")\n  })\n\n  const isSupportPlainTextOnly = useIsSupportPlainTextOnly()\n  if (!isSupportPlainTextOnly) {\n    return (\n      <div className=\"flex size-full flex-col\">\n        <div className=\"-mt-2 mb-1 text-center text-sm text-text-tertiary\">\n          <i className=\"i-mingcute-warning-line mr-0.5 translate-y-[2px]\" />\n          Your browser does not support highlight CSS.\n        </div>\n        <div className=\"relative h-0 grow\">\n          <div className=\"absolute inset-0\">\n            <TextArea\n              className=\"font-mono\"\n              defaultValue={defaultValue}\n              onChange={(e) => onChange(e.target.value)}\n            />\n          </div>\n        </div>\n      </div>\n    )\n  }\n  return (\n    <div\n      className={cn(\n        \"size-full\",\n\n        \"ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2\",\n        \"focus:!bg-accent/5\",\n        \"border border-border\",\n        \"placeholder:text-text-tertiary dark:bg-zinc-700/[0.15] dark:text-zinc-200\",\n        \"overflow-auto whitespace-pre hover:border-accent/60\",\n        className,\n      )}\n      ref={ref}\n      onInput={handleInput}\n      contentEditable={isSupportPlainTextOnly ? \"plaintext-only\" : \"true\"}\n      tabIndex={0}\n      {...props}\n    />\n  )\n}\nconst useIsSupportPlainTextOnly = () => {\n  const isSupportPlainTextOnly = useMemo(() => {\n    if (typeof document === \"undefined\") return false\n\n    const div = document.createElement(\"div\")\n\n    try {\n      div.contentEditable = \"plaintext-only\"\n    } catch {\n      return false\n    }\n\n    return div.contentEditable === \"plaintext-only\"\n  }, [])\n\n  return isSupportPlainTextOnly\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx",
    "content": "import {\n  useFocusActions,\n  useGlobalFocusableScopeSelector,\n} from \"@follow/components/common/Focusable/hooks.js\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { useRefValue } from \"@follow/hooks\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport type { FC } from \"react\"\nimport { memo, useEffect } from \"react\"\nimport { toast } from \"sonner\"\n\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams, useRouteEntryId } from \"~/hooks/biz/useRouteParams\"\n\nimport { COMMAND_ID } from \"../command/commands/id\"\nimport { useCommandBinding } from \"../command/hooks/use-command-binding\"\nimport { useCommandHotkey } from \"../command/hooks/use-register-hotkey\"\n\nexport const EntryColumnShortcutHandler: FC<{\n  refetch: () => void\n  data: readonly string[]\n  handleScrollTo: (index: number) => void\n}> = memo(({ data, refetch, handleScrollTo }) => {\n  const dataRef = useRefValue(data!)\n\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isTimeline)\n\n  const currentEntryIdRef = useRefValue(useRouteEntryId())\n  const navigate = useNavigateEntry()\n\n  useCommandBinding({\n    commandId: COMMAND_ID.timeline.switchToNext,\n    when,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.timeline.switchToPrevious,\n    when,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.timeline.refetch,\n    when,\n  })\n\n  useCommandHotkey({\n    commandId: COMMAND_ID.layout.focusToEntryRender,\n    shortcut: \"Enter, L, ArrowRight\",\n    when,\n  })\n\n  useCommandHotkey({\n    commandId: COMMAND_ID.layout.focusToSubscription,\n    shortcut: \"Backspace, Escape, H, ArrowLeft\",\n    when,\n  })\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.timeline.switchToNext, () => {\n      const data = dataRef.current\n      const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || \"\")\n\n      const nextIndex = Math.min(currentActiveEntryIndex + 1, data.length - 1)\n\n      if (currentActiveEntryIndex === nextIndex) {\n        toast.info(\"You are already at the last entry\")\n        return\n      }\n\n      handleScrollTo(nextIndex)\n      const nextId = data![nextIndex]\n      const { view } = getRouteParams()\n\n      navigate({\n        entryId: nextId,\n        view,\n      })\n    })\n  }, [currentEntryIdRef, dataRef, handleScrollTo, navigate, when])\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, () => {\n      const data = dataRef.current\n      const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || \"\")\n\n      const nextIndex =\n        currentActiveEntryIndex === -1 ? data.length - 1 : Math.max(0, currentActiveEntryIndex - 1)\n\n      if (currentActiveEntryIndex === nextIndex) {\n        toast.info(\"You are already at the first entry\")\n        return\n      }\n\n      handleScrollTo(nextIndex)\n      const nextId = data![nextIndex]\n\n      const { view } = getRouteParams()\n\n      navigate({\n        entryId: nextId,\n        view,\n      })\n    })\n  }, [currentEntryIdRef, dataRef, handleScrollTo, navigate])\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.timeline.refetch, () => {\n      refetch()\n    })\n  }, [refetch])\n\n  const $scrollArea = useScrollViewElement()\n  const { highlightBoundary } = useFocusActions()\n  useEffect(() => {\n    return EventBus.subscribe(\n      COMMAND_ID.layout.focusToTimeline,\n      ({ highlightBoundary: highlight }) => {\n        $scrollArea?.focus()\n        if (highlight) {\n          nextFrame(highlightBoundary)\n        }\n      },\n    )\n  }, [$scrollArea, highlightBoundary])\n\n  return null\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx",
    "content": "import { LoadingCircle } from \"@follow/components/ui/loading/index.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { getView } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FC } from \"react\"\nimport { memo } from \"react\"\n\nimport { getSkeletonItemComponentByView } from \"./Items/getSkeletonItemComponentByView\"\nimport { girdClassNames } from \"./styles\"\n\nconst LoadingCircleFallback = (\n  <div className=\"center mt-2\">\n    <LoadingCircle size=\"medium\" />\n  </div>\n)\nexport const EntryItemSkeleton: FC<{\n  view: FeedViewType\n  count?: number\n}> = memo(({ view, count = 10 }) => {\n  const SkeletonItem = getSkeletonItemComponentByView(view)\n\n  if (!SkeletonItem) {\n    return LoadingCircleFallback\n  }\n\n  if (count === 1) {\n    return SkeletonItem\n  }\n\n  return (\n    <div className={cn(getView(view)?.gridMode ? girdClassNames : \"flex flex-col\")}>\n      {Array.from({ length: count }).map((_, index) => (\n        // eslint-disable-next-line @eslint-react/no-array-index-key -- index is unique\n        <div key={index}>{SkeletonItem}</div>\n      ))}\n    </div>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipRoot,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useCollectionEntry, useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { transformVideoUrl } from \"@follow/utils/url-for-video\"\nimport { cn, isSafari } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\nimport { titleCase } from \"title-case\"\n\nimport { AudioPlayer, useAudioPlayerAtomSelector } from \"~/atoms/player\"\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { FEED_COLLECTION_LIST } from \"~/constants\"\nimport { useEntryIsRead } from \"~/hooks/biz/useAsRead\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { EntryTranslation } from \"~/modules/entry-column/translation\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { StarIcon } from \"../star-icon\"\nimport { readableContentMaxWidth } from \"../styles\"\nimport type { EntryItemStatelessProps, UniversalItemProps } from \"../types\"\n\nconst ViewTag = IN_ELECTRON ? \"webview\" : \"iframe\"\n\nconst entrySelector = (state: EntryModel) => {\n  /// keep-sorted\n  const { authorAvatar, authorUrl, description, feedId, inboxHandle, publishedAt, title } = state\n\n  const audios = state.attachments?.filter((a) => a.mime_type?.startsWith(\"audio\") && a.url)\n  const video = transformVideoUrl({\n    url: state?.url ?? \"\",\n    isIframe: !IN_ELECTRON,\n    attachments: state?.attachments,\n    mini: true,\n  })\n  const firstAudio = audios?.[0]\n  const media = state.media || []\n  const photo = media.find((a) => a.type === \"photo\")\n  const firstPhotoUrl = photo?.url\n\n  /// keep-sorted\n  return {\n    authorAvatar,\n    authorUrl,\n    description,\n    feedId,\n    firstAudio,\n    firstPhotoUrl,\n    inboxId: inboxHandle,\n    publishedAt,\n    title,\n    video,\n  }\n}\n\nexport function AllItem({ entryId, translation, currentFeedTitle }: UniversalItemProps) {\n  const entry = useEntry(entryId, entrySelector)\n  const simple = true\n\n  const isInCollection = useIsEntryStarred(entryId)\n  const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt\n\n  const isRead = useEntryIsRead(entryId)\n\n  const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST)\n\n  const feed = useFeedById(entry?.feedId, (feed) => {\n    return {\n      type: feed.type,\n      ownerUserId: feed.ownerUserId,\n      id: feed.id,\n      title: feed.title,\n      url: (feed as any).url || \"\",\n      image: feed.image,\n      siteUrl: feed.siteUrl,\n    }\n  })\n\n  const inbox = useInboxById(entry?.inboxId)\n\n  const bilingual = useGeneralSettingKey(\"translationMode\") === \"bilingual\"\n\n  const iconEntry: FeedIconEntry = useMemo(\n    () => ({\n      firstPhotoUrl: entry?.firstPhotoUrl,\n      authorAvatar: entry?.authorAvatar,\n    }),\n    [entry?.firstPhotoUrl, entry?.authorAvatar],\n  )\n\n  const titleEntry = useMemo(\n    () => ({\n      authorUrl: entry?.authorUrl,\n    }),\n    [entry?.authorUrl],\n  )\n\n  const lineClamp = useMemo(() => {\n    const envIsSafari = isSafari()\n    let lineClampTitle = 1\n    let lineClampDescription = 2\n\n    if (translation?.title && !simple && bilingual) {\n      lineClampTitle += 1\n    }\n    if (translation?.description && !simple && bilingual) {\n      lineClampDescription += 1\n    }\n\n    // FIXME: Safari bug, not support line-clamp cross elements\n    return {\n      global: !envIsSafari\n        ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]`\n        : \"\",\n      title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : \"\",\n      description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : \"\",\n    }\n  }, [simple, translation?.description, translation?.title, bilingual])\n\n  const dimRead = useGeneralSettingKey(\"dimRead\")\n  // NOTE: prevent 0 height element, react virtuoso will not stop render any more\n  if (!entry || !(feed || inbox)) return null\n\n  const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt\n\n  const related = feed || inbox\n\n  const thisFeedTitle = getPreferredTitle(related, titleEntry)\n  return (\n    <div\n      className={cn(\n        \"group relative flex cursor-menu items-center py-2\",\n        !isRead &&\n          \"before:absolute before:-left-4 before:top-[14px] before:block before:size-2 before:rounded-full before:bg-accent\",\n      )}\n    >\n      {currentFeedTitle !== thisFeedTitle && (\n        <FeedIcon target={related} fallback entry={iconEntry} size={16} />\n      )}\n      <div className={cn(\"flex h-fit min-w-0 flex-1 items-center truncate text-sm leading-tight\")}>\n        {entry.firstAudio && <AudioIcon entryId={entryId} src={entry.firstAudio.url} />}\n        {entry.video && <VideoIcon src={entry.video} />}\n        <div\n          className={cn(\n            \"relative flex items-center\",\n            \"text-text\",\n            !!isInCollection && \"pr-5\",\n            entry?.title ? \"font-medium\" : \"text-[13px]\",\n            isRead && dimRead && \"text-text-secondary\",\n          )}\n        >\n          <EllipsisHorizontalTextWithTooltip>\n            {entry?.title ? (\n              <EntryTranslation\n                className={cn(\n                  \"inline-flex min-w-0 items-center hyphens-auto font-medium\",\n                  lineClamp.title,\n                )}\n                source={titleCase(entry?.title ?? \"\")}\n                target={titleCase(translation?.title ?? \"\")}\n              />\n            ) : (\n              <EntryTranslation\n                className={cn(\"inline-flex items-center hyphens-auto\", lineClamp.description)}\n                source={entry?.description}\n                target={translation?.description}\n              />\n            )}\n          </EllipsisHorizontalTextWithTooltip>\n          {!!isInCollection && <StarIcon className=\"absolute right-0 top-0\" />}\n        </div>\n        <div\n          className={cn(\n            \"ml-4 truncate text-[13px]\",\n            \"text-text-secondary\",\n            isRead && dimRead && \"text-text-tertiary\",\n          )}\n        >\n          <EntryTranslation\n            className={cn(\"hyphens-auto\", lineClamp.description)}\n            source={entry?.description}\n            target={translation?.description}\n          />\n        </div>\n      </div>\n\n      <div className=\"ml-4 shrink-0 text-xs text-text-secondary\">\n        {!!displayTime && <RelativeTime date={displayTime} postfix=\"\" />}\n      </div>\n    </div>\n  )\n}\n\nAllItem.wrapperClassName = \"pl-5 pr-4 @[700px]:pl-6 @[1024px]:pr-5\"\n\nexport function AllItemStateLess({ entry, feed }: EntryItemStatelessProps) {\n  return (\n    <div className=\"group relative flex cursor-menu py-4\">\n      <FeedIcon target={feed} fallback className=\"mr-2 size-5\" />\n      <div className=\"-mt-0.5 min-w-0 flex-1 text-sm leading-tight\">\n        <div className=\"flex gap-1 text-[10px] font-bold text-text-secondary\">\n          <FeedTitle feed={feed} />\n          <span>·</span>\n          <span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>\n        </div>\n        <div className=\"relative my-0.5 truncate break-words font-medium text-text\">\n          {entry.title}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport const AllItemSkeleton = (\n  <div className={`relative w-full select-none ${readableContentMaxWidth}`}>\n    <div className=\"group relative flex py-4\">\n      <Skeleton className=\"mr-2 size-5 shrink-0 overflow-hidden\" />\n      <div className=\"-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight\">\n        <div className=\"flex gap-1 text-[10px] font-bold text-material-opaque\">\n          <Skeleton className=\"h-3 w-32 truncate\" />\n          <span>·</span>\n          <Skeleton className=\"h-3 w-12 shrink-0\" />\n        </div>\n        <div className=\"relative my-0.5 break-words\">\n          <Skeleton className=\"h-4 w-full\" />\n          <Skeleton className=\"mt-2 h-4 w-3/4\" />\n        </div>\n      </div>\n    </div>\n  </div>\n)\n\nfunction AudioIcon({ entryId, src }: { entryId: string; src: string }) {\n  const playStatus = useAudioPlayerAtomSelector((playerValue) =>\n    playerValue.src === src && playerValue.show ? playerValue.status : false,\n  )\n\n  const handleClickPlay = (e: React.MouseEvent<HTMLDivElement>) => {\n    e.stopPropagation()\n    e.preventDefault()\n    if (!playStatus) {\n      // switch this to play\n      AudioPlayer.mount({\n        type: \"audio\",\n        entryId,\n        src,\n        currentTime: 0,\n      })\n    } else {\n      // switch between play and pause\n      AudioPlayer.togglePlayAndPause()\n    }\n  }\n\n  return (\n    <div className=\"relative mr-1 flex shrink-0 items-center text-[15px]\">\n      <div\n        className={cn(\"center w-full transition-all duration-200 ease-in-out\")}\n        onClick={handleClickPlay}\n      >\n        <button type=\"button\" className=\"center text-text/90\">\n          <i\n            className={cn({\n              \"i-mingcute-pause-fill\": playStatus && playStatus === \"playing\",\n              \"i-mingcute-loading-fill animate-spin\": playStatus && playStatus === \"loading\",\n              \"i-mgc-music-2-cute-fi\": !playStatus || playStatus === \"paused\",\n            })}\n          />\n        </button>\n      </div>\n    </div>\n  )\n}\n\nfunction VideoIcon({ src }: { src: string }) {\n  return (\n    <Tooltip>\n      <TooltipRoot>\n        <TooltipTrigger asChild>\n          <i className=\"i-mgc-video-cute-fi mr-1 shrink-0 text-base text-text/90\" />\n        </TooltipTrigger>\n        <TooltipPortal>\n          <TooltipContent className=\"flex-col gap-1\" side={\"bottom\"}>\n            <div className=\"flex items-center gap-1\">\n              <ViewTag\n                referrerPolicy=\"strict-origin-when-cross-origin\"\n                src={src}\n                className={cn(\n                  \"pointer-events-none aspect-video w-[575px] shrink-0 rounded-md bg-black object-cover\",\n                )}\n              />\n            </div>\n          </TooltipContent>\n        </TooltipPortal>\n      </TooltipRoot>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/article-item.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport { ListItem } from \"~/modules/entry-column/templates/list-item-template\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\nimport { readableContentMaxWidth } from \"../styles\"\nimport type { EntryItemStatelessProps, UniversalItemProps } from \"../types\"\n\nexport function ArticleItem({ entryId, translation }: UniversalItemProps) {\n  return <ListItem entryId={entryId} translation={translation} />\n}\n\nArticleItem.wrapperClassName = cn(readableContentMaxWidth, \"pl-4 pr-3\")\n\nexport function ArticleItemStateLess({ entry, feed }: EntryItemStatelessProps) {\n  return (\n    <div className=\"group relative flex py-4\">\n      <FeedIcon target={feed} fallback className=\"mr-2 size-5\" />\n      <div className=\"-mt-0.5 min-w-0 flex-1 text-sm leading-tight\">\n        <div className=\"flex gap-1 text-[10px] font-bold text-text-secondary\">\n          <FeedTitle feed={feed} />\n          <span>·</span>\n          <span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>\n        </div>\n        <div className=\"relative my-0.5 truncate break-words font-medium text-text\">\n          {entry.title}\n        </div>\n        <div className=\"truncate text-[13px] text-text-secondary\">{entry.description}</div>\n      </div>\n      {entry.media?.[0] && (\n        <Media\n          thumbnail\n          src={entry.media[0].url}\n          type={entry.media[0].type}\n          previewImageUrl={entry.media[0].preview_image_url}\n          className=\"ml-2 size-20 shrink-0 overflow-hidden rounded\"\n          mediaContainerClassName=\"w-auto h-auto rounded\"\n          loading=\"lazy\"\n          proxy={{\n            width: 160,\n            height: 160,\n          }}\n          height={entry.media[0].height}\n          width={entry.media[0].width}\n          blurhash={entry.media[0].blurhash}\n        />\n      )}\n    </div>\n  )\n}\n\nexport const ArticleItemSkeleton = (\n  <div className={`relative h-[120px] rounded-md ${readableContentMaxWidth}`}>\n    <div className=\"relative\">\n      <div className=\"group relative flex py-4\">\n        <Skeleton className=\"mr-2 size-5 rounded-sm\" />\n        <div className=\"-mt-0.5 flex-1 text-sm leading-tight\">\n          <div className=\"flex gap-1 text-[10px] font-bold text-material-opaque\">\n            <Skeleton className=\"h-3 w-24\" />\n            <span>·</span>\n            <Skeleton className=\"h-3 w-12 shrink-0\" />\n          </div>\n          <div className=\"relative my-1 break-words font-medium\">\n            <Skeleton className=\"h-3.5 w-full\" />\n            <Skeleton className=\"mt-1 h-3.5 w-3/4\" />\n          </div>\n          <div className=\"mt-1.5 text-[13px] text-material-opaque\">\n            <Skeleton className=\"h-3 w-full\" />\n            <Skeleton className=\"mt-1 h-3 w-4/5\" />\n          </div>\n        </div>\n        <Skeleton className=\"ml-2 size-20 overflow-hidden rounded\" />\n      </div>\n    </div>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/audio-item.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\n\nimport { ListItem } from \"~/modules/entry-column/templates/list-item-template\"\n\nimport { readableContentMaxWidth } from \"../styles\"\nimport type { UniversalItemProps } from \"../types\"\n\nexport function AudioItem({ entryId, translation }: UniversalItemProps) {\n  return <ListItem entryId={entryId} translation={translation} />\n}\n\nAudioItem.wrapperClassName = readableContentMaxWidth\n\nexport const AudioItemSkeleton = (\n  <div className={`relative mx-auto w-full select-none rounded-md ${readableContentMaxWidth}`}>\n    <div className=\"relative\">\n      <div className=\"group relative flex py-4\">\n        <div className=\"-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight\">\n          <div className=\"flex gap-1 text-[10px] font-bold text-material-opaque\">\n            <Skeleton className=\"h-3 w-20\" />\n            <span>·</span>\n            <Skeleton className=\"h-3 w-10\" />\n          </div>\n          <div className=\"relative my-0.5 break-words font-medium\">\n            <Skeleton className=\"h-4 w-full\" />\n            <Skeleton className=\"mt-2 h-4 w-3/4\" />\n          </div>\n        </div>\n        <div className=\"relative ml-2 size-20 shrink-0\">\n          <Skeleton className=\"mr-2 size-20 shrink-0 overflow-hidden rounded-sm\" />\n        </div>\n      </div>\n    </div>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/getItemComponentByView.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\n\nimport { AllItem } from \"./all-item\"\nimport { ArticleItem } from \"./article-item\"\nimport { AudioItem } from \"./audio-item\"\nimport { NotificationItem } from \"./notification-item\"\nimport { PictureItem } from \"./picture-item\"\nimport { SocialMediaItem } from \"./social-media-item\"\nimport { VideoItem } from \"./video-item\"\n\nconst ItemMap = {\n  [FeedViewType.All]: AllItem,\n  [FeedViewType.Articles]: ArticleItem,\n  [FeedViewType.SocialMedia]: SocialMediaItem,\n  [FeedViewType.Pictures]: PictureItem,\n  [FeedViewType.Videos]: VideoItem,\n  [FeedViewType.Audios]: AudioItem,\n  [FeedViewType.Notifications]: NotificationItem,\n}\nexport const getItemComponentByView = (view: FeedViewType) => {\n  return ItemMap[view] || ArticleItem\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/getSkeletonItemComponentByView.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\n\nimport { ArticleItemSkeleton } from \"./article-item\"\nimport { AudioItemSkeleton } from \"./audio-item\"\nimport { ListItemSkeleton } from \"./list-item\"\nimport { NotificationItemSkeleton } from \"./notification-item\"\nimport { PictureItemSkeleton } from \"./picture-item-skeleton\"\nimport { SocialMediaItemSkeleton } from \"./social-media-item\"\nimport { VideoItemSkeleton } from \"./video-item\"\n\nconst SkeletonItemMap = {\n  [FeedViewType.All]: ListItemSkeleton,\n  [FeedViewType.Articles]: ArticleItemSkeleton,\n  [FeedViewType.SocialMedia]: SocialMediaItemSkeleton,\n  [FeedViewType.Pictures]: PictureItemSkeleton,\n  [FeedViewType.Videos]: VideoItemSkeleton,\n  [FeedViewType.Audios]: AudioItemSkeleton,\n  [FeedViewType.Notifications]: NotificationItemSkeleton,\n}\n\nexport const getSkeletonItemComponentByView = (view: FeedViewType) => {\n  return SkeletonItemMap[view]\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/list-item.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.js\"\n\nexport const ListItemSkeleton = (\n  <div className=\"relative mt-1 flex h-9 w-full shrink-0 items-center overflow-hidden\">\n    <Skeleton className=\"size-full overflow-hidden rounded-none\" />\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/media-gallery.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\n\nimport { usePreviewMedia } from \"~/components/ui/media/hooks\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport { useGetImageProxyUrl } from \"~/lib/img-proxy\"\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport { socialMediaContentWidthAtom } from \"../atoms/social-media-content-width\"\n\nexport const MediaGallery = ({\n  entryId,\n  containerWidth,\n}: {\n  entryId: string\n  containerWidth?: number\n}) => {\n  const getImageProxyUrl = useGetImageProxyUrl()\n  const entry = useEntry(entryId, (state) => ({ media: state.media }))\n  const media = useMemo(() => entry?.media || [], [entry?.media])\n\n  const previewMedia = usePreviewMedia()\n\n  const isAllMediaSameRatio = useMemo(() => {\n    let ratio = 0\n    for (const m of media) {\n      if (m?.height && m?.width) {\n        const currentRatio = m.height / m.width\n        if (ratio === 0) {\n          ratio = currentRatio\n        } else if (ratio !== currentRatio) {\n          return false\n        }\n      } else {\n        return false\n      }\n    }\n    return true\n  }, [media])\n\n  if (media.length === 0) return null\n\n  // all media has same ratio, use horizontal layout\n  if (isAllMediaSameRatio) {\n    return (\n      <div className=\"mt-4 flex gap-[8px] overflow-x-auto pb-2\">\n        {media.map((media, i, mediaList) => {\n          const style: Partial<{\n            width: string\n            height: string\n          }> = {}\n          const boundsWidth = containerWidth || jotaiStore.get(socialMediaContentWidthAtom)\n          if (media.height && media.width) {\n            // has 1 picture, max width is container width, but max height is less than window height: 2/3\n            if (mediaList.length === 1) {\n              style.width = `${boundsWidth}px`\n              style.height = `${(boundsWidth * media.height) / media.width}px`\n              if (Number.parseInt(style.height) > (window.innerHeight * 2) / 3) {\n                style.height = `${(window.innerHeight * 2) / 3}px`\n                style.width = `${(Number.parseInt(style.height) * media.width) / media.height}px`\n              }\n            }\n            // has 2 pictures, max width is container half width, and - gap 8px\n            else if (mediaList.length === 2) {\n              style.width = `${(boundsWidth - 8) / 2}px`\n              style.height = `${(((boundsWidth - 8) / 2) * media.height) / media.width}px`\n            }\n            // has over 2 pictures, max width is container 1/3 width\n            else if (mediaList.length > 2) {\n              style.width = `${boundsWidth / 3}px`\n              style.height = `${((boundsWidth / 3) * media.height) / media.width}px`\n            }\n          }\n\n          const proxySize = {\n            width: Number.parseInt(style.width || \"0\") * 2 || 0,\n            height: Number.parseInt(style.height || \"0\") * 2 || 0,\n          }\n          return (\n            <Media\n              style={style}\n              key={media.url}\n              src={media.url}\n              type={media.type}\n              previewImageUrl={media.preview_image_url}\n              blurhash={media.blurhash}\n              className=\"size-28 shrink-0 cursor-zoom-in data-[state=loading]:!bg-material-ultra-thick\"\n              loading=\"lazy\"\n              proxy={proxySize}\n              onClick={(e) => {\n                e.stopPropagation()\n                previewMedia(\n                  mediaList.map((m) => ({\n                    url: m.url,\n                    type: m.type,\n                    blurhash: m.blurhash,\n                    fallbackUrl:\n                      m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }),\n                  })),\n                  i,\n                )\n              }}\n            />\n          )\n        })}\n      </div>\n    )\n  }\n\n  // all media has different ratio, use grid layout\n  return (\n    <div className=\"mt-4\">\n      <div\n        className={cn(\n          \"grid gap-2\",\n          media.length === 2 && \"grid-cols-2\",\n          media.length === 3 && \"grid-cols-2\",\n          media.length === 4 && \"grid-cols-2\",\n          media.length >= 5 && \"grid-cols-3\",\n        )}\n      >\n        {media.map((m, i) => {\n          const proxySize = {\n            width: 400,\n            height: 400,\n          }\n\n          const style = media.length === 3 && i === 2 ? { gridRow: \"span 2\" } : {}\n\n          return (\n            <Media\n              style={style}\n              key={m.url}\n              src={m.url}\n              type={m.type}\n              previewImageUrl={m.preview_image_url}\n              blurhash={m.blurhash}\n              className=\"aspect-square w-full cursor-zoom-in rounded object-cover\"\n              loading=\"lazy\"\n              proxy={proxySize}\n              onClick={(e) => {\n                e.stopPropagation()\n                previewMedia(\n                  media.map((m) => ({\n                    url: m.url,\n                    type: m.type,\n                    blurhash: m.blurhash,\n                    fallbackUrl:\n                      m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }),\n                  })),\n                  i,\n                )\n              }}\n            />\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/notification-item.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { ListItem } from \"~/modules/entry-column/templates/list-item-template\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\nimport { readableContentMaxWidth } from \"../styles\"\nimport type { EntryItemStatelessProps, UniversalItemProps } from \"../types\"\n\nexport function NotificationItem({ entryId, translation }: UniversalItemProps) {\n  return <ListItem entryId={entryId} translation={translation} simple />\n}\n\nNotificationItem.wrapperClassName = readableContentMaxWidth\n\nexport function NotificationItemStateLess({ entry, feed }: EntryItemStatelessProps) {\n  return (\n    <div className=\"group relative flex cursor-menu py-4\">\n      <FeedIcon target={feed} fallback className=\"mr-2 size-5\" />\n      <div className=\"-mt-0.5 min-w-0 flex-1 text-sm leading-tight\">\n        <div className=\"flex gap-1 text-[10px] font-bold text-text-secondary\">\n          <FeedTitle feed={feed} />\n          <span>·</span>\n          <span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>\n        </div>\n        <div className=\"relative my-0.5 truncate break-words font-medium text-text\">\n          {entry.title}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport const NotificationItemSkeleton = (\n  <div className={`relative w-full select-none ${readableContentMaxWidth}`}>\n    <div className=\"group relative flex py-4\">\n      <Skeleton className=\"mr-2 size-5 shrink-0 overflow-hidden rounded-sm\" />\n      <div className=\"-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight\">\n        <div className=\"flex gap-1 text-[10px] font-bold text-material-opaque\">\n          <Skeleton className=\"h-3 w-32 truncate\" />\n          <span>·</span>\n          <Skeleton className=\"h-3 w-12 shrink-0\" />\n        </div>\n        <div className=\"relative my-0.5 break-words\">\n          <Skeleton className=\"h-4 w-full\" />\n          <Skeleton className=\"mt-2 h-4 w-3/4\" />\n        </div>\n      </div>\n    </div>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item-skeleton.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\n\nexport const PictureItemSkeleton = (\n  <div className=\"relative max-w-md rounded-md\">\n    <div className=\"relative\">\n      <div className=\"p-1.5\">\n        <div className=\"relative flex gap-2 overflow-x-auto\">\n          <div className=\"relative flex aspect-square w-full shrink-0 items-center overflow-hidden rounded-md\">\n            <Skeleton className=\"size-full overflow-hidden\" />\n          </div>\n        </div>\n        <div className=\"relative flex-1 px-2 pb-3 pt-1 text-sm\">\n          <div className=\"relative mb-1 mt-1.5 truncate font-medium leading-none\">\n            <Skeleton className=\"h-4 w-3/4\" />\n          </div>\n          <div className=\"mt-1 flex items-center gap-1 truncate text-[13px]\">\n            <Skeleton className=\"mr-0.5 size-4\" />\n            <Skeleton className=\"h-3 w-1/2\" />\n            <span className=\"text-material-opaque\">·</span>\n            <Skeleton className=\"h-3 w-12\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item-stateless.tsx",
    "content": "import {\n  MasonryItemsAspectRatioContext,\n  MasonryItemsAspectRatioSetterContext,\n  MasonryItemWidthContext,\n} from \"@follow/components/ui/masonry/contexts.jsx\"\nimport { useMeasure } from \"@follow/hooks\"\nimport { useState } from \"react\"\n\nimport { Media } from \"~/components/ui/media/Media\"\nimport { MediaContainerWidthProvider } from \"~/components/ui/media/MediaContainerWidthProvider\"\n\nimport type { EntryItemStatelessProps } from \"../types\"\n\nexport function PictureItemStateLess({ entry }: EntryItemStatelessProps) {\n  const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})\n\n  const [ref, bounds] = useMeasure()\n  const mediaItems =\n    entry.media?.map((item) => ({\n      url: item.url,\n      type: item.type,\n      previewImageUrl: item.preview_image_url,\n      height: item.height,\n      width: item.width,\n      blurhash: item.blurhash,\n    })) || []\n\n  const currentItemWidth = (bounds.width - 12) / 2\n\n  return (\n    <div className=\"relative w-full select-none text-text\" ref={ref}>\n      <MasonryItemWidthContext value={currentItemWidth}>\n        {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n        <MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>\n          <MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>\n            <MediaContainerWidthProvider width={currentItemWidth}>\n              <Media\n                thumbnail\n                src={mediaItems[0]?.url}\n                type={mediaItems[0]?.type || \"photo\"}\n                previewImageUrl={mediaItems[0]?.previewImageUrl}\n                className=\"size-full overflow-hidden\"\n                mediaContainerClassName={\"w-auto h-auto rounded\"}\n                loading=\"lazy\"\n                proxy={{\n                  width: 600,\n                  height: 0,\n                }}\n                blurhash={mediaItems[0]?.blurhash}\n              />\n            </MediaContainerWidthProvider>\n          </MasonryItemsAspectRatioSetterContext>\n        </MasonryItemsAspectRatioContext.Provider>\n      </MasonryItemWidthContext>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item.tsx",
    "content": "import {\n  MasonryIntersectionContext,\n  useMasonryItemRatio,\n  useMasonryItemWidth,\n  useSetStableMasonryItemRatio,\n} from \"@follow/components/ui/masonry/contexts.jsx\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { PropsWithChildren } from \"react\"\nimport { memo, use, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { SwipeMedia } from \"~/components/ui/media/SwipeMedia\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useImageDimensions } from \"~/store/image\"\n\nimport { EntryItemWrapper } from \"../layouts/EntryItemWrapper\"\nimport { GridItem, GridItemFooter } from \"../templates/grid-item-template\"\nimport type { UniversalItemProps } from \"../types\"\n\nexport function PictureItem({ entryId, translation }: UniversalItemProps) {\n  const entry = useEntry(entryId, (state) => ({ media: state.media, id: state.id }))\n  const entryMedia = entry?.media || []\n\n  const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)\n\n  const { t } = useTranslation()\n\n  if (!entry) return null\n  return (\n    <GridItem entryId={entryId} translation={translation}>\n      <div className=\"relative flex gap-2 overflow-x-auto\">\n        {entryMedia ? (\n          <SwipeMedia\n            media={entryMedia}\n            className={cn(\n              \"aspect-square\",\n              \"w-full shrink-0 rounded-md [&_img]:rounded-md\",\n              isActive && \"rounded-b-none\",\n            )}\n            imgClassName=\"object-cover\"\n            fitContainer\n          />\n        ) : (\n          <div className=\"center aspect-square w-full flex-col gap-1 rounded-md bg-material-medium text-xs text-text-secondary\">\n            <i className=\"i-mgc-sad-cute-re size-6\" />\n            {t(\"entry_content.no_content\")}\n          </div>\n        )}\n      </div>\n    </GridItem>\n  )\n}\n\nconst proxySize = {\n  width: 600,\n  height: 0,\n}\n\nexport const PictureWaterFallItem = memo(function PictureWaterFallItem({\n  entryId,\n  translation,\n  index,\n  className,\n}: UniversalItemProps & { index: number; className?: string }) {\n  const entry = useEntry(entryId, (state) => ({\n    media: state.media,\n    id: state.id,\n  }))\n\n  const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)\n  const itemWidth = useMasonryItemWidth()\n  const isImageOnly = useUISettingKey(\"pictureViewImageOnly\")\n\n  const [ref, setRef] = useState<HTMLDivElement | null>(null)\n  const intersectionObserver = use(MasonryIntersectionContext)\n\n  useEffect(() => {\n    if (!ref || !intersectionObserver) return\n\n    intersectionObserver.observe(ref)\n\n    return () => {\n      intersectionObserver.unobserve(ref)\n    }\n  }, [ref, intersectionObserver])\n\n  const media = entry?.media || []\n\n  if (media?.length === 0) return null\n  if (!entry) return null\n\n  return (\n    <div ref={setRef} data-entry-id={entryId} data-index={index} className={className}>\n      <EntryItemWrapper\n        view={FeedViewType.Pictures}\n        entryId={entryId}\n        itemClassName=\"group rounded-md hover:bg-transparent\"\n        style={{\n          width: itemWidth,\n        }}\n      >\n        {media && media.length > 0 ? (\n          <MasonryItemFixedDimensionWrapper url={media[0]!.url}>\n            <SwipeMedia\n              media={media}\n              className={cn(\n                \"w-full grow rounded-md after:pointer-events-none after:absolute after:inset-0 after:bg-transparent after:transition-colors after:duration-300 group-hover:after:bg-black/20\",\n                isActive && \"rounded-b-none\",\n              )}\n              proxySize={proxySize}\n              imgClassName=\"object-cover\"\n            />\n            {!isImageOnly && (\n              <div className=\"z-[3] shrink-0 overflow-hidden rounded-b-md pb-1\">\n                <GridItemFooter entryId={entryId} translation={translation} />\n              </div>\n            )}\n          </MasonryItemFixedDimensionWrapper>\n        ) : (\n          <div className=\"center aspect-video flex-col gap-1 rounded-md bg-material-medium text-xs text-text-secondary\">\n            <i className=\"i-mgc-sad-cute-re size-6\" />\n            No media available\n          </div>\n        )}\n      </EntryItemWrapper>\n    </div>\n  )\n})\n\nconst MasonryItemFixedDimensionWrapper = (\n  props: PropsWithChildren<{\n    url: string\n  }>,\n) => {\n  const { url, children } = props\n  const dim = useImageDimensions(url)\n  const itemWidth = useMasonryItemWidth()\n  const isImageOnly = useUISettingKey(\"pictureViewImageOnly\")\n\n  const stableRadio = useMemo(() => {\n    return dim ? dim.ratio : 1\n  }, [dim])\n  const setItemStableRatio = useSetStableMasonryItemRatio()\n\n  const stableRadioCtx = useMasonryItemRatio(url)\n\n  useEffect(() => {\n    if (dim) {\n      setItemStableRatio(url, stableRadio)\n    }\n  }, [setItemStableRatio, stableRadio, url, dim])\n\n  const finalRatio = stableRadioCtx || stableRadio\n  const style = useMemo(\n    () => ({\n      width: itemWidth,\n      height: itemWidth / finalRatio + +(!isImageOnly ? 60 : 0),\n    }),\n    [itemWidth, finalRatio, isImageOnly],\n  )\n\n  if (!style.height || style.height === Infinity) return null\n\n  return (\n    <div className=\"relative flex h-full flex-col overflow-x-auto overflow-y-hidden\" style={style}>\n      {children}\n    </div>\n  )\n}\n\nMasonryItemFixedDimensionWrapper.whyDidYouRender = {\n  logOnDifferentValues: true,\n}\n\nexport { PictureItemSkeleton } from \"./picture-item-skeleton\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx",
    "content": "import {\n  MasonryForceRerenderContext,\n  MasonryIntersectionContext,\n  MasonryItemsAspectRatioContext,\n  MasonryItemsAspectRatioSetterContext,\n  MasonryItemWidthContext,\n} from \"@follow/components/ui/masonry/contexts.jsx\"\nimport { useMasonryColumn } from \"@follow/components/ui/masonry/hooks.js\"\nimport { Masonry } from \"@follow/components/ui/masonry/index.js\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport { useRefValue } from \"@follow/hooks\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { clsx } from \"@follow/utils/utils\"\nimport type { RenderComponentProps } from \"masonic\"\nimport { useInfiniteLoader } from \"masonic\"\nimport type { FC, ReactNode } from \"react\"\nimport {\n  createContext,\n  startTransition,\n  use,\n  useCallback,\n  useDeferredValue,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { MediaContainerWidthProvider } from \"~/components/ui/media/MediaContainerWidthProvider\"\nimport type { StoreImageType } from \"~/store/image\"\nimport { imageActions } from \"~/store/image\"\n\nimport { batchMarkRead } from \"../hooks/useEntryMarkReadHandler\"\nimport { PictureWaterFallItem } from \"./picture-item\"\n\n// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5\n\nconst FirstScreenItemCount = 20\nconst FirstScreenReadyCountDown = 150\nconst FirstScreenReadyContext = createContext(false)\nconst gutter = 24\n\nexport const PictureMasonry: FC<MasonryProps> = (props) => {\n  const { data } = props\n  const cacheMap = useState(() => new Map<string, object>())[0]\n  const [isInitDim, setIsInitDim] = useState(false)\n  const [isInitLayout, setIsInitLayout] = useState(false)\n  const deferIsInitLayout = useDeferredValue(isInitLayout)\n  const restoreDimensions = useEventCallback(async () => {\n    const images = [] as string[]\n\n    data.forEach((entryId) => {\n      const entry = getEntry(entryId)\n      if (!entry) return\n\n      images.push(...imageActions.getImagesFromEntry(entry))\n    })\n    return imageActions.fetchDimensionsFromDb(images)\n  })\n  useLayoutEffect(() => {\n    restoreDimensions().finally(() => {\n      startTransition(() => {\n        setIsInitDim(true)\n      })\n    })\n  }, [restoreDimensions])\n\n  useLayoutEffect(() => {\n    const images: StoreImageType[] = []\n    data.forEach((entryId) => {\n      const entry = getEntry(entryId)\n      if (!entry) return\n\n      if (!entry.media) return\n      for (const media of entry.media) {\n        if (!media.height || !media.width) continue\n\n        images.push({\n          src: media.url,\n          width: media.width,\n          height: media.height,\n          ratio: media.width / media.height,\n        })\n      }\n    })\n    if (images.length > 0) {\n      imageActions.saveImages(images)\n    }\n  }, [JSON.stringify(data)])\n\n  const { containerRef, currentColumn, currentItemWidth } = useMasonryColumn(gutter, () => {\n    setIsInitLayout(true)\n  })\n\n  const items = useMemo(() => {\n    const result = data.map((entryId) => {\n      const cache = cacheMap.get(entryId)\n      if (cache) {\n        return cache\n      }\n\n      const ret = { entryId }\n      cacheMap.set(entryId, ret)\n      return ret\n    }) as { entryId: string; cache?: object }[]\n\n    // Disable placeholders in waterfall to prevent layout redraws on last page\n    // if (props.hasNextPage) {\n    //   for (let i = 0; i < 10; i++) {\n    //     result.push({\n    //       entryId: `placeholder${i}`,\n    //     })\n    //   }\n    // }\n\n    return result\n  }, [cacheMap, data, props.hasNextPage])\n\n  const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})\n  const maybeLoadMore = useInfiniteLoader(props.endReached, {\n    isItemLoaded: (index, items) => !!items[index],\n    minimumBatchSize: 32,\n    threshold: 3,\n  })\n\n  const currentRange = useRef<{ start: number; end: number }>(undefined)\n  const handleRender = useCallback(\n    (startIndex: number, stopIndex: number, items: any[]) => {\n      currentRange.current = { start: startIndex, end: stopIndex }\n      return maybeLoadMore(startIndex, stopIndex, items)\n    },\n    [maybeLoadMore],\n  )\n  const scrollElement = useScrollViewElement()\n\n  const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>(null!)\n  const renderMarkRead = useGeneralSettingKey(\"renderMarkUnread\")\n  const scrollMarkRead = useGeneralSettingKey(\"scrollMarkUnread\")\n\n  const dataRef = useRefValue(data)\n  useEffect(() => {\n    if (!renderMarkRead && !scrollMarkRead) return\n    if (!scrollElement) return\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        renderInViewMarkRead(entries)\n        scrollOutViewMarkRead(entries)\n\n        function scrollOutViewMarkRead(entries: IntersectionObserverEntry[]) {\n          if (!scrollMarkRead) return\n          if (!scrollElement) return\n          let minimumIndex = Number.MAX_SAFE_INTEGER\n          entries.forEach((entry) => {\n            if (entry.isIntersecting) {\n              return\n            }\n            const $target = entry.target as HTMLDivElement\n            const $targetScrollTop = $target.getBoundingClientRect().top\n\n            if ($targetScrollTop < 0) {\n              const { index } = (entry.target as HTMLDivElement).dataset\n              if (!index) return\n              const currentIndex = Number.parseInt(index)\n              // if index is 0, or not a number, then skip\n              if (!currentIndex) return\n              // It is possible that the end coordinates beyond the overscan range are still being calculated and the position is not actually determined. Filtering here\n              if (currentIndex > (currentRange.current?.end ?? 0)) {\n                return\n              }\n\n              minimumIndex = Math.min(minimumIndex, currentIndex)\n            }\n          })\n\n          if (minimumIndex !== Number.MAX_SAFE_INTEGER) {\n            batchMarkRead(dataRef.current.slice(0, minimumIndex))\n          }\n        }\n\n        function renderInViewMarkRead(entries: IntersectionObserverEntry[]) {\n          if (!renderMarkRead) return\n          const entryIds: string[] = []\n          entries.forEach((entry) => {\n            if (\n              entry.isIntersecting &&\n              entry.intersectionRatio >= 0.8 &&\n              entry.boundingClientRect.top >= entry.rootBounds!.top\n            ) {\n              entryIds.push((entry.target as HTMLDivElement).dataset.entryId as string)\n            }\n          })\n\n          batchMarkRead(entryIds)\n        }\n      },\n      {\n        rootMargin: \"0px\",\n        threshold: [0, 1],\n        root: scrollElement,\n      },\n    )\n    setIntersectionObserver(observer)\n    return () => {\n      observer.disconnect()\n    }\n  }, [scrollElement, renderMarkRead, scrollMarkRead, dataRef])\n\n  const [firstScreenReady, setFirstScreenReady] = useState(false)\n  useEffect(() => {\n    if (firstScreenReady) return\n    const timer = setTimeout(() => {\n      setFirstScreenReady(true)\n    }, FirstScreenReadyCountDown)\n    return () => {\n      clearTimeout(timer)\n    }\n  }, [])\n\n  const isImageOnly = useUISettingKey(\"pictureViewImageOnly\")\n  const [masonryForceRerender, setMasonrtForceRerender] = useState(0)\n  useEffect(() => {\n    setMasonrtForceRerender((i) => i + 1)\n  }, [isImageOnly, setMasonrtForceRerender])\n\n  return (\n    <div ref={containerRef} className=\"mx-4 pt-4\">\n      {isInitDim && deferIsInitLayout && (\n        <MasonryItemWidthContext value={currentItemWidth}>\n          {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n          <MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>\n            <MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>\n              <MasonryIntersectionContext value={intersectionObserver}>\n                <MasonryForceRerenderContext value={masonryForceRerender}>\n                  <MediaContainerWidthProvider width={currentItemWidth}>\n                    <FirstScreenReadyContext value={firstScreenReady}>\n                      <Masonry\n                        items={firstScreenReady ? items : items.slice(0, FirstScreenItemCount)}\n                        columnGutter={gutter}\n                        columnWidth={currentItemWidth}\n                        columnCount={currentColumn}\n                        overscanBy={2}\n                        render={MasonryRender}\n                        onRender={handleRender}\n                        itemKey={itemKey}\n                      />\n                      {props.Footer ? (\n                        typeof props.Footer === \"function\" ? (\n                          <div className=\"mb-4\">\n                            <props.Footer />\n                          </div>\n                        ) : (\n                          <div className=\"mb-4\">{props.Footer}</div>\n                        )\n                      ) : null}\n                    </FirstScreenReadyContext>\n                  </MediaContainerWidthProvider>\n                </MasonryForceRerenderContext>\n              </MasonryIntersectionContext>\n            </MasonryItemsAspectRatioSetterContext>\n          </MasonryItemsAspectRatioContext.Provider>\n        </MasonryItemWidthContext>\n      )}\n    </div>\n  )\n}\n\nconst itemKey = (item: { entryId: string }) => item.entryId\nconst MasonryRender: React.ComponentType<\n  RenderComponentProps<{\n    entryId: string\n  }>\n> = ({ data, index }) => {\n  const firstScreenReady = use(FirstScreenReadyContext)\n  const enableTranslation = useGeneralSettingKey(\"translation\")\n  const actionLanguage = useActionLanguage()\n  const translation = useEntryTranslation({\n    entryId: data.entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n\n  if (data.entryId.startsWith(\"placeholder\")) {\n    return <LoadingSkeletonItem />\n  }\n\n  return (\n    <PictureWaterFallItem\n      className={clsx(\n        firstScreenReady ? \"opacity-100\" : \"opacity-0\",\n        \"transition-opacity duration-200\",\n      )}\n      entryId={data.entryId}\n      index={index}\n      translation={translation}\n    />\n  )\n}\ninterface MasonryProps {\n  data: string[]\n  endReached: () => any\n  hasNextPage: boolean\n  Footer?: FC | ReactNode\n}\n\nconst LoadingSkeletonItem = () => {\n  // random height, between 100-400px\n  const randomHeight = useState(() => Math.random() * 300 + 100)[0]\n  return (\n    <div className=\"relative flex gap-2 overflow-x-auto\">\n      <div\n        className=\"relative flex w-full shrink-0 items-center overflow-hidden rounded-md\"\n        style={{ height: `${randomHeight}px` }}\n      >\n        <Skeleton className=\"size-full overflow-hidden\" />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx",
    "content": "import { PassviseFragment } from \"@follow/components/common/Fragment.js\"\nimport { AutoResizeHeight } from \"@follow/components/ui/auto-resize-height/index.js\"\nimport { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { LRUCache } from \"@follow/utils/lru-cache\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { HTML } from \"~/components/ui/markdown/HTML\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport { useEntryIsRead } from \"~/hooks/biz/useAsRead\"\nimport { useRenderStyle } from \"~/hooks/biz/useRenderStyle\"\nimport { jotaiStore } from \"~/lib/jotai\"\nimport { parseSocialMedia } from \"~/lib/parsers\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\nimport { socialMediaContentWidthAtom } from \"../atoms/social-media-content-width\"\nimport { StarIcon } from \"../star-icon\"\nimport { readableContentMaxWidth } from \"../styles\"\nimport type { EntryItemStatelessProps, EntryListItemFC } from \"../types\"\nimport { MediaGallery } from \"./media-gallery\"\n\nexport const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => {\n  const entry = useEntry(entryId, (state) => {\n    /// keep-sorted\n    const {\n      author,\n      authorAvatar,\n      authorUrl,\n      content,\n      description,\n      feedId,\n      guid,\n      publishedAt,\n      url,\n    } = state\n\n    const media = state.media || []\n    const photo = media.find((a) => a.type === \"photo\")\n    const firstPhotoUrl = photo?.url\n\n    /// keep-sorted\n    return {\n      author,\n      authorAvatar,\n      authorUrl,\n      content,\n      description,\n      feedId,\n      firstPhotoUrl,\n      guid,\n      publishedAt,\n      url,\n    }\n  })\n\n  const isInCollection = useIsEntryStarred(entryId)\n\n  const asRead = useEntryIsRead(entryId)\n  const feed = useFeedById(entry?.feedId)\n\n  const iconEntry: FeedIconEntry = useMemo(\n    () => ({\n      firstPhotoUrl: entry?.firstPhotoUrl,\n      authorAvatar: entry?.authorAvatar,\n    }),\n    [entry?.firstPhotoUrl, entry?.authorAvatar],\n  )\n\n  const ref = useRef<HTMLDivElement>(null)\n\n  useLayoutEffect(() => {\n    if (ref.current) {\n      jotaiStore.set(socialMediaContentWidthAtom, ref.current.offsetWidth)\n    }\n  }, [])\n  const autoExpandLongSocialMedia = useGeneralSettingKey(\"autoExpandLongSocialMedia\")\n  const renderStyle = useRenderStyle({ baseFontSize: 14, baseLineHeight: 1.625 })\n\n  const titleRef = useRef<HTMLDivElement>(null)\n  if (!entry || !feed) return null\n\n  const content = entry.content || entry.description\n\n  const parsed = parseSocialMedia(entry.authorUrl || entry.url || entry.guid)\n  const EntryContentWrapper = autoExpandLongSocialMedia\n    ? PassviseFragment\n    : CollapsedSocialMediaItem\n\n  return (\n    <div\n      className={cn(\n        \"relative flex py-4\",\n        \"group\",\n        !asRead &&\n          \"before:absolute before:-left-3 before:top-8 before:block before:size-2 before:rounded-full before:bg-accent\",\n      )}\n    >\n      <FeedIcon fallback target={feed} entry={iconEntry} size={32} className=\"mt-1\" />\n      <div ref={ref} className=\"ml-2 min-w-0 flex-1\">\n        <div className=\"-mt-0.5 flex-1 text-sm\">\n          <div className=\"flex select-none flex-wrap space-x-1 leading-6\" ref={titleRef}>\n            <span className=\"inline-flex min-w-0 items-center gap-1 text-base font-semibold\">\n              <FeedTitle feed={feed} title={entry.author || feed.title} />\n              {parsed?.type === \"x\" && (\n                <i className=\"i-mgc-twitter-cute-fi size-3 text-[#4A99E9]\" />\n              )}\n            </span>\n\n            {parsed?.type === \"x\" && (\n              <a\n                href={`https://x.com/${parsed.meta.handle}`}\n                target=\"_blank\"\n                className=\"text-zinc-500\"\n              >\n                @{parsed.meta.handle}\n              </a>\n            )}\n            <span className=\"text-zinc-500\">·</span>\n            <span className=\"text-zinc-500\">\n              <RelativeTime date={entry.publishedAt} />\n            </span>\n          </div>\n          <div className={cn(\"relative mt-1 text-base\", isInCollection && \"pr-5\")}>\n            <EntryContentWrapper entryId={entryId}>\n              <HTML\n                as=\"div\"\n                className={cn(\n                  \"prose align-middle dark:prose-invert\",\n                  \"cursor-auto select-text text-sm leading-relaxed prose-blockquote:mt-0\",\n                )}\n                noMedia\n                style={renderStyle}\n              >\n                {translation?.content || content}\n              </HTML>\n            </EntryContentWrapper>\n            {isInCollection && <StarIcon className=\"absolute right-0 top-0\" />}\n          </div>\n        </div>\n        <MediaGallery entryId={entryId} />\n      </div>\n    </div>\n  )\n}\n\nSocialMediaItem.wrapperClassName = cn(\n  readableContentMaxWidth,\n  \"pl-4 pr-3 @[700px]:pl-6 @[1024px]:pr-4\",\n)\n\nexport function SocialMediaItemStateLess({ entry, feed }: EntryItemStatelessProps) {\n  return (\n    <div className=\"relative flex py-4\">\n      <FeedIcon fallback target={feed} size={32} className=\"mr-2 mt-1\" />\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"-mt-0.5 flex-1 text-sm\">\n          <div className=\"flex select-none flex-wrap space-x-1 leading-6\">\n            <span className=\"inline-flex min-w-0 items-center gap-1 text-base font-semibold\">\n              <FeedTitle feed={feed} />\n            </span>\n            <span className=\"text-zinc-500\">·</span>\n            <span className=\"text-zinc-500\">\n              <RelativeTime date={entry.publishedAt} />\n            </span>\n          </div>\n          <div className=\"relative mt-1 text-base\">\n            <div className=\"prose cursor-auto select-text truncate align-middle text-sm leading-relaxed dark:prose-invert prose-blockquote:mt-0\">\n              {entry.description}\n            </div>\n          </div>\n        </div>\n        {entry.media && entry.media.length > 0 && (\n          <div className=\"mt-4 flex gap-[8px] overflow-x-auto pb-2\">\n            {entry.media.slice(0, 3).map((media) => (\n              <Media\n                key={media.url}\n                thumbnail\n                src={media.url}\n                type={media.type}\n                previewImageUrl={media.preview_image_url}\n                className=\"size-28 shrink-0 rounded object-cover\"\n                mediaContainerClassName=\"w-auto h-auto rounded\"\n                loading=\"lazy\"\n                proxy={{\n                  width: 160,\n                  height: 160,\n                }}\n                height={media.height}\n                width={media.width}\n                blurhash={media.blurhash}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport const SocialMediaItemSkeleton = (\n  <div className={`relative m-auto rounded-md ${readableContentMaxWidth}`}>\n    <div className=\"relative\">\n      <div className=\"group relative flex py-6\">\n        <Skeleton className=\"mr-2 size-9\" />\n        <div className=\"ml-2 min-w-0 flex-1\">\n          <div className=\"-mt-0.5 line-clamp-5 flex-1 text-sm\">\n            <div className=\"flex w-[calc(100%-10rem)] space-x-1\">\n              <Skeleton className=\"h-4 w-16\" />\n              <span className=\"text-material-opaque\">·</span>\n              <Skeleton className=\"h-4 w-12\" />\n            </div>\n            <div className=\"relative mt-0.5 text-sm\">\n              <Skeleton className=\"h-4 w-full\" />\n              <Skeleton className=\"mt-1.5 h-4 w-full\" />\n              <Skeleton className=\"mt-1.5 h-4 w-3/4\" />\n            </div>\n          </div>\n          <div className=\"mt-2 flex gap-2 overflow-x-auto\">\n            <Skeleton className=\"size-28 overflow-hidden rounded\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n)\n\nconst collapsedHeight = 300\nconst collapsedItemCache = new LRUCache<string, boolean>(100)\nconst CollapsedSocialMediaItem: Component<{\n  entryId: string\n}> = ({ children, entryId }) => {\n  const { t } = useTranslation()\n  const [isOverflow, setIsOverflow] = useState(false)\n  const [isShowMore, setIsShowMore] = useState(() => collapsedItemCache.get(entryId) ?? false)\n  const ref = useRef<HTMLDivElement>(null)\n\n  useLayoutEffect(() => {\n    if (ref.current) {\n      setIsOverflow(ref.current.scrollHeight > collapsedHeight)\n    }\n  }, [children])\n\n  return (\n    <AutoResizeHeight className=\"relative\">\n      <div\n        className={cn(\n          \"relative\",\n          !isShowMore && \"max-h-[300px] overflow-hidden\",\n          isShowMore && \"h-auto\",\n          !isShowMore && isOverflow && \"mask-b-2xl\",\n        )}\n        ref={ref}\n      >\n        {children}\n      </div>\n      {isOverflow && !isShowMore && (\n        <div className=\"absolute inset-x-0 -bottom-2 flex select-none justify-center py-2 duration-200\">\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation()\n              setIsShowMore(true)\n              collapsedItemCache.put(entryId, true)\n            }}\n            aria-hidden\n            className=\"flex items-center justify-center text-xs duration-200 hover:text-text\"\n          >\n            <i className=\"i-mingcute-arrow-to-down-line\" />\n            <span className=\"ml-2\">{t(\"words.show_more\")}</span>\n          </button>\n        </div>\n      )}\n    </AutoResizeHeight>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.tsx",
    "content": "import { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { formatDuration } from \"@follow/utils/duration\"\nimport { transformVideoUrl } from \"@follow/utils/url-for-video\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useHover } from \"@use-gesture/react\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\nimport { GridItem } from \"../templates/grid-item-template\"\nimport type { EntryItemStatelessProps, UniversalItemProps } from \"../types\"\n\nconst ViewTag = IN_ELECTRON ? \"webview\" : \"iframe\"\n\nexport function VideoItem({ entryId, translation }: UniversalItemProps) {\n  const entry = useEntry(entryId, (state) => {\n    const { id, url } = state\n\n    const attachments = state.attachments || []\n    const { duration_in_seconds } =\n      attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}\n    const seconds = duration_in_seconds\n      ? Number.parseInt(duration_in_seconds.toString())\n      : undefined\n    const duration = formatDuration(seconds)\n\n    const media = state.media || []\n    const firstMedia = media[0]\n\n    return { attachments, duration, firstMedia, id, url, media }\n  })\n\n  const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)\n\n  const [miniIframeSrc] = useMemo(\n    () => [\n      transformVideoUrl({\n        url: entry?.url ?? \"\",\n        mini: true,\n        isIframe: !IN_ELECTRON,\n        attachments: entry?.attachments,\n      }),\n      transformVideoUrl({\n        url: entry?.url ?? \"\",\n        isIframe: !IN_ELECTRON,\n        attachments: entry?.attachments,\n      }),\n    ],\n    [entry?.attachments, entry?.url],\n  )\n\n  const ref = useRef<HTMLDivElement>(null)\n  const [hovered, setHovered] = useState(false)\n  useHover(\n    (event) => {\n      setHovered(event.active)\n    },\n    {\n      target: ref,\n    },\n  )\n\n  const [showPreview, setShowPreview] = useState(false)\n  useEffect(() => {\n    if (hovered) {\n      const timer = setTimeout(() => {\n        setShowPreview(true)\n      }, 500)\n      return () => clearTimeout(timer)\n    } else {\n      setShowPreview(false)\n      return () => {}\n    }\n  }, [hovered])\n\n  if (!entry) return null\n  return (\n    <GridItem entryId={entryId} translation={translation}>\n      <div className=\"w-full cursor-card\">\n        <div className=\"relative overflow-x-auto\" ref={ref}>\n          {miniIframeSrc && showPreview ? (\n            <ViewTag\n              src={miniIframeSrc}\n              referrerPolicy=\"strict-origin-when-cross-origin\"\n              className={cn(\n                \"pointer-events-none aspect-video w-full shrink-0 rounded-md bg-black object-cover\",\n                isActive && \"rounded-b-none\",\n              )}\n            />\n          ) : entry.firstMedia ? (\n            <Media\n              key={entry.firstMedia.url}\n              src={entry.firstMedia.url}\n              type={entry.firstMedia.type}\n              previewImageUrl={entry.firstMedia.preview_image_url}\n              width={entry.firstMedia.width}\n              height={entry.firstMedia.height}\n              blurhash={entry.firstMedia.blurhash}\n              className={cn(\n                \"aspect-video w-full shrink-0 rounded-md object-cover\",\n                isActive && \"rounded-b-none\",\n              )}\n              loading=\"lazy\"\n              proxy={{\n                width: 640,\n                height: 360,\n              }}\n              showFallback={true}\n              fitContainer\n            />\n          ) : (\n            <div className=\"center aspect-video w-full flex-col gap-1 rounded-md bg-material-medium text-xs text-text-secondary\">\n              <i className=\"i-mgc-sad-cute-re size-6\" />\n              No media available\n            </div>\n          )}\n          {!!entry.duration && (\n            <div className=\"absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white\">\n              {entry.duration}\n            </div>\n          )}\n        </div>\n      </div>\n    </GridItem>\n  )\n}\n\nexport function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) {\n  return (\n    <div className=\"p-1.5\">\n      <div className=\"w-full\">\n        <div className=\"relative overflow-x-auto\">\n          {entry.media?.[0] ? (\n            <Media\n              thumbnail\n              src={entry.media[0].url}\n              type={entry.media[0].type}\n              previewImageUrl={entry.media[0].preview_image_url}\n              className=\"aspect-video w-full shrink-0 rounded-md object-cover\"\n              mediaContainerClassName=\"w-auto h-auto rounded\"\n              loading=\"lazy\"\n              proxy={{\n                width: 640,\n                height: 360,\n              }}\n              height={entry.media[0].height}\n              width={entry.media[0].width}\n              blurhash={entry.media[0].blurhash}\n              fitContainer\n            />\n          ) : (\n            <div className=\"center aspect-video w-full flex-col gap-1 rounded-md bg-material-medium text-xs text-text-secondary\">\n              <i className=\"i-mgc-sad-cute-re size-6\" />\n              No media available\n            </div>\n          )}\n        </div>\n      </div>\n      <div className=\"relative px-2 text-sm\">\n        <div className=\"flex items-center\">\n          <div className=\"mr-1 size-1.5 shrink-0 self-center rounded-full bg-accent duration-200\" />\n          <div className=\"relative mb-1 mt-1.5 flex w-full items-center gap-1 truncate font-medium\">\n            <span className=\"min-w-0 grow truncate\">{entry.title}</span>\n          </div>\n        </div>\n        <div className=\"flex items-center gap-1 truncate text-[13px]\">\n          <FeedIcon fallback noMargin className=\"flex\" target={feed} size={18} />\n          <span className=\"min-w-0 truncate pl-1\">\n            <FeedTitle feed={feed} />\n          </span>\n          <span className=\"text-zinc-500\">·</span>\n          <span className=\"text-zinc-500\">\n            {!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}\n          </span>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport const VideoItemSkeleton = (\n  <div className=\"relative mx-auto w-full max-w-lg rounded-md\">\n    <div className=\"relative\">\n      <div className=\"p-1.5\">\n        <div className=\"w-full\">\n          <div className=\"overflow-x-auto\">\n            <Skeleton className=\"aspect-video w-full shrink-0 overflow-hidden\" />\n          </div>\n        </div>\n        <div className=\"relative flex-1 px-2 pb-3 pt-1 text-sm\">\n          <div className=\"relative mb-1 mt-1.5 truncate font-medium leading-none\">\n            <Skeleton className=\"h-4 w-3/4\" />\n          </div>\n          <div className=\"mt-1 flex items-center gap-1 truncate text-[13px]\">\n            <Skeleton className=\"mr-0.5 size-4\" />\n            <Skeleton className=\"h-3 w-1/2\" />\n            <span className=\"text-material-opaque\">·</span>\n            <Skeleton className=\"h-3 w-12\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/atoms/ai-timeline.ts",
    "content": "import { atom } from \"jotai\"\n\nexport const aiTimelineEnabledAtom = atom(false)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/atoms/social-media-content-width.ts",
    "content": "import { atom } from \"jotai\"\n\nexport const socialMediaContentWidthAtom = atom(0)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/DateItem.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { memo, useMemo, useState } from \"react\"\nimport { Trans } from \"react-i18next\"\nimport { useDebounceCallback } from \"usehooks-ts\"\n\nimport { SafeFragment } from \"~/components/common/Fragment\"\nimport { RelativeDay } from \"~/components/ui/datetime\"\nimport { useShowEntryDetailsColumn } from \"~/hooks/biz/useShowEntryDetailsColumn\"\n\nimport { readableContentMaxWidth } from \"../styles\"\n\ninterface DateItemInnerProps {\n  date: Date\n  className?: string\n  Wrapper?: FC<PropsWithChildren>\n  isSticky?: boolean\n}\n\ntype DateItemProps = Pick<DateItemInnerProps, \"isSticky\"> & {\n  view: FeedViewType\n  date: string\n  className?: string\n}\nconst useParseDate = (date: string) =>\n  useMemo(() => {\n    const dateObj = new Date(date)\n    return {\n      dateObj,\n      startOfDay: new Date(dateObj.setHours(0, 0, 0, 0)).getTime(),\n      endOfDay: new Date(dateObj.setHours(23, 59, 59, 999)).getTime(),\n    }\n  }, [date])\n\nconst dateItemclassName = tw`relative flex items-center text-sm lg:text-base gap-1 px-3 font-bold text-text h-9`\nexport const DateItem = memo(({ date, view, isSticky }: DateItemProps) => {\n  const showEntryDetailsColumn = useShowEntryDetailsColumn()\n\n  if (view === FeedViewType.SocialMedia || !showEntryDetailsColumn) {\n    return <SocialMediaDateItem date={date} className={dateItemclassName} isSticky={isSticky} />\n  }\n  return <UniversalDateItem date={date} className={dateItemclassName} isSticky={isSticky} />\n})\nconst UniversalDateItem = ({ date, className, isSticky }: Omit<DateItemProps, \"view\">) => {\n  const { dateObj } = useParseDate(date)\n\n  return (\n    <DateItemInner\n      className={clsx(className, readableContentMaxWidth)}\n      date={dateObj}\n      isSticky={isSticky}\n    />\n  )\n}\n\nconst DateItemInner: FC<DateItemInnerProps> = ({ date, className, Wrapper, isSticky }) => {\n  const [confirmMark, setConfirmMark] = useState(false)\n  const removeConfirm = useDebounceCallback(\n    () => {\n      setConfirmMark(false)\n    },\n    1000,\n    {\n      leading: false,\n    },\n  )\n\n  const W = Wrapper ?? SafeFragment\n\n  const RelativeElement = (\n    <span key=\"b\" className=\"inline-flex items-center\">\n      <RelativeDay date={date} />\n    </span>\n  )\n  return (\n    <div\n      className={cn(\n        className,\n        \"border-b border-transparent bg-background pl-7\",\n        isSticky && \"border-border\",\n      )}\n      onClick={stopPropagation}\n      onMouseEnter={removeConfirm.cancel}\n      onMouseLeave={removeConfirm}\n    >\n      <W>\n        {confirmMark ? (\n          <div className=\"animate-mask-in\" key=\"a\">\n            <Trans\n              i18nKey=\"mark_all_read_button.confirm_mark_all\"\n              components={{\n                which: <>{RelativeElement}</>,\n              }}\n            />\n          </div>\n        ) : (\n          RelativeElement\n        )}\n      </W>\n    </div>\n  )\n}\nconst SocialMediaDateItem = ({\n  date,\n  className,\n  isSticky,\n}: {\n  date: string\n  className?: string\n  isSticky?: boolean\n}) => {\n  const { dateObj } = useParseDate(date)\n\n  return (\n    <DateItemInner\n      Wrapper={({ children }) => (\n        <div\n          className={cn(\n            \"m-auto flex w-full max-w-[645px] select-none gap-3 pl-2 text-base lg:text-lg\",\n          )}\n        >\n          {children}\n        </div>\n      )}\n      className={className}\n      date={dateObj}\n      isSticky={isSticky}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/FooterMarkItem.tsx",
    "content": "import { FeedViewType, getView } from \"@follow/constants\"\n\nimport { readableContentMaxWidthClassName } from \"~/constants/ui\"\n\nimport { FlatMarkAllReadButton } from \"./mark-all-button\"\n\nexport const FooterMarkItem = ({\n  view,\n  fetchedTime,\n}: {\n  view: FeedViewType\n  fetchedTime?: number\n}) => {\n  const filter = fetchedTime\n    ? {\n        insertedBefore: fetchedTime,\n      }\n    : undefined\n\n  if (view === FeedViewType.SocialMedia) {\n    return <SocialMediaFooterMarkItem filter={filter} />\n  } else if (getView(view)?.gridMode || view === FeedViewType.All) {\n    return <GridFooterMarkItem filter={filter} />\n  }\n  return <CommonFooterMarkItem filter={filter} />\n}\n\ninterface FooterMarkItemProps {\n  filter?: {\n    insertedBefore: number\n  }\n}\n\nconst SocialMediaFooterMarkItem = ({ filter }: FooterMarkItemProps) => {\n  return (\n    <div className=\"relative flex w-full\">\n      <FlatMarkAllReadButton\n        className=\"justify-center\"\n        buttonClassName=\"w-[645px] mx-auto mb-4 pl-4 py-4 @[700px]:pl-6\"\n        iconClassName=\"mr-1 text-lg\"\n        which=\"above\"\n        filter={filter}\n      />\n    </div>\n  )\n}\n\nconst GridFooterMarkItem = ({ filter }: FooterMarkItemProps) => {\n  return (\n    <div className=\"relative flex w-full\">\n      <FlatMarkAllReadButton\n        buttonClassName=\"w-full py-4\"\n        iconClassName=\"mr-1 text-base\"\n        which=\"above\"\n        filter={filter}\n      />\n    </div>\n  )\n}\n\nconst CommonFooterMarkItem = ({ filter }: FooterMarkItemProps) => {\n  return (\n    <div className={`relative flex w-full ${readableContentMaxWidthClassName} mx-auto`}>\n      <FlatMarkAllReadButton\n        className=\"justify-start\"\n        buttonClassName=\"w-full px-4 pl-3 py-4\"\n        iconClassName=\"w-7 mr-3 text-base\"\n        which=\"above\"\n        filter={filter}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/VirtualRowItem.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useIsListSubscription } from \"@follow/store/subscription/hooks\"\nimport { clsx } from \"@follow/utils/utils\"\nimport type { FC, Key } from \"react\"\nimport { Fragment, memo, useMemo } from \"react\"\n\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { EntryVirtualListItem } from \"../item\"\nimport { DateItem } from \"./DateItem\"\n\ninterface VirtualRowItemProps {\n  virtualRowKey: Key\n  entriesIds: string[]\n  virtualRowIndex: number\n  view: any\n  transform: string\n  isStickyItem: boolean\n  isActiveStickyItem: boolean\n  measureElement: (element: Element | null) => void\n  currentFeedTitle?: string\n}\n\nconst EntryHeadDateItem: FC<{\n  entryId: string\n  isSticky?: boolean\n}> = ({ entryId, isSticky }) => {\n  const entry = useEntry(entryId, (state) => {\n    const { insertedAt, publishedAt } = state\n\n    return { insertedAt, publishedAt }\n  })\n\n  const routeParams = useRouteParams()\n  const { feedId, view } = routeParams\n  const isList = useIsListSubscription(feedId)\n\n  if (!entry) return null\n  const date = new Date(isList ? entry.insertedAt : entry.publishedAt).toDateString()\n\n  return <DateItem isSticky={isSticky} date={date} view={view} />\n}\n\nexport const VirtualRowItem: FC<VirtualRowItemProps> = memo(\n  ({\n    virtualRowKey,\n    entriesIds,\n    virtualRowIndex,\n    view,\n    transform,\n    isStickyItem,\n    isActiveStickyItem,\n    measureElement,\n    currentFeedTitle,\n  }) => {\n    return (\n      <Fragment key={virtualRowKey}>\n        {isStickyItem && (\n          <div\n            className={clsx(\n              isActiveStickyItem\n                ? \"sticky top-0 z-[1]\"\n                : \"absolute left-0 top-0 w-full will-change-transform\",\n            )}\n            style={\n              !isActiveStickyItem\n                ? {\n                    transform,\n                  }\n                : undefined\n            }\n          >\n            <EntryHeadDateItem\n              entryId={entriesIds[virtualRowIndex]!}\n              isSticky={isActiveStickyItem}\n            />\n          </div>\n        )}\n\n        <EntryVirtualListItem\n          currentFeedTitle={currentFeedTitle}\n          entryId={entriesIds[virtualRowIndex]!}\n          view={view}\n          data-index={virtualRowIndex}\n          style={useMemo(\n            () => ({\n              transform,\n              paddingTop: isStickyItem ? \"2rem\" : undefined,\n            }),\n            [transform, isStickyItem],\n          )}\n          ref={measureElement}\n        />\n      </Fragment>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.css",
    "content": ".ai-timeline-loading {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n  overflow: hidden;\n  border-radius: inherit;\n}\n\n.ai-timeline-loading__backdrop {\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(\n    135deg,\n    rgba(255, 255, 255, 0.02),\n    rgba(255, 92, 0, 0.08),\n    rgba(0, 0, 0, 0.1)\n  );\n  opacity: 0.75;\n  animation: aiTimelineGlassPulse 2.6s ease-in-out infinite;\n}\n\n.ai-timeline-loading__glow {\n  position: absolute;\n  inset: 10% 8% 16% 8%;\n  border-radius: 48px;\n  background: radial-gradient(\n    circle at 70% 25%,\n    rgba(255, 92, 0, 0.35),\n    rgba(255, 140, 0, 0.15),\n    transparent 65%\n  );\n  filter: blur(42px);\n  opacity: 0.8;\n  animation: aiTimelineGlow 3.4s ease-in-out infinite;\n}\n\n.ai-timeline-loading__beam {\n  position: absolute;\n  top: -5%;\n  bottom: -5%;\n  width: 32%;\n  border-radius: 999px;\n  background: linear-gradient(\n    120deg,\n    rgba(255, 255, 255, 0.2),\n    rgba(255, 92, 0, 0.15),\n    rgba(255, 92, 0, 0)\n  );\n  filter: blur(18px);\n  opacity: 0;\n  animation: aiTimelineBeam 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;\n}\n\n.ai-timeline-loading__beam--left {\n  left: -15%;\n  animation-delay: 0s;\n}\n\n.ai-timeline-loading__beam--right {\n  right: -15%;\n  animation-delay: -1.2s;\n}\n\n.ai-timeline-loading__sparkles {\n  position: absolute;\n  inset: 0;\n  filter: blur(0.5px);\n}\n\n.ai-timeline-loading__sparkles span {\n  position: absolute;\n  width: 10px;\n  height: 10px;\n  border-radius: 999px;\n  background: rgba(255, 255, 255, 0.45);\n  box-shadow:\n    0 0 18px rgba(255, 92, 0, 0.4),\n    0 0 8px rgba(255, 255, 255, 0.3);\n  opacity: 0;\n  animation: aiTimelineSparkle 3s ease-in-out infinite;\n}\n\n.ai-timeline-loading__sparkles span:nth-child(1) {\n  top: 25%;\n  left: 32%;\n  animation-delay: 0s;\n}\n\n.ai-timeline-loading__sparkles span:nth-child(2) {\n  top: 55%;\n  left: 62%;\n  animation-delay: -0.9s;\n}\n\n.ai-timeline-loading__sparkles span:nth-child(3) {\n  top: 38%;\n  left: 78%;\n  animation-delay: -1.8s;\n}\n\n.ai-timeline-loading__badge {\n  position: absolute;\n  right: 1.25rem;\n  bottom: 1.5rem;\n  padding: 0.5rem 1rem;\n  border-radius: 999px;\n  background: linear-gradient(\n    120deg,\n    rgba(16, 16, 16, 0.65),\n    rgba(32, 24, 20, 0.65),\n    rgba(255, 92, 0, 0.25)\n  );\n  border: 1px solid rgba(255, 255, 255, 0.08);\n  box-shadow:\n    0 15px 40px rgba(0, 0, 0, 0.35),\n    inset 0 1px 1px rgba(255, 255, 255, 0.15);\n  backdrop-filter: blur(12px) saturate(140%);\n  -webkit-backdrop-filter: blur(12px) saturate(140%);\n  animation: aiTimelineBadgePulse 2.2s ease-in-out infinite;\n}\n\n.ai-timeline-loading__badge::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: linear-gradient(\n    120deg,\n    rgba(255, 255, 255, 0.05),\n    rgba(255, 92, 0, 0.35),\n    rgba(255, 255, 255, 0.05)\n  );\n  opacity: 0.35;\n  pointer-events: none;\n}\n\n.ai-timeline-loading__badge span {\n  color: rgba(255, 255, 255, 0.9);\n  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);\n}\n\n@keyframes aiTimelineGlassPulse {\n  0%,\n  100% {\n    opacity: 0.65;\n  }\n  50% {\n    opacity: 0.85;\n  }\n}\n\n@keyframes aiTimelineGlow {\n  0%,\n  100% {\n    transform: scale(0.95);\n    opacity: 0.6;\n  }\n  50% {\n    transform: scale(1.05);\n    opacity: 0.85;\n  }\n}\n\n@keyframes aiTimelineBeam {\n  0% {\n    opacity: 0;\n    transform: translateX(-35%) scaleX(0.8);\n  }\n  40% {\n    opacity: 0.4;\n  }\n  70% {\n    opacity: 0.65;\n    transform: translateX(20%) scaleX(1.05);\n  }\n  100% {\n    opacity: 0;\n    transform: translateX(30%) scaleX(1.2);\n  }\n}\n\n@keyframes aiTimelineSparkle {\n  0%,\n  100% {\n    opacity: 0;\n    transform: scale(0.4) translate3d(0, 0, 0);\n  }\n  35% {\n    opacity: 0.85;\n    transform: scale(1) translate3d(4px, -6px, 0);\n  }\n  65% {\n    opacity: 0.35;\n    transform: scale(0.6) translate3d(-4px, 6px, 0);\n  }\n}\n\n@keyframes aiTimelineBadgePulse {\n  0%,\n  100% {\n    transform: translateY(0);\n    box-shadow:\n      0 15px 40px rgba(0, 0, 0, 0.35),\n      inset 0 1px 1px rgba(255, 255, 255, 0.15);\n  }\n  50% {\n    transform: translateY(-3px);\n    box-shadow:\n      0 20px 45px rgba(0, 0, 0, 0.4),\n      inset 0 1px 1px rgba(255, 255, 255, 0.25);\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.tsx",
    "content": "import \"./AITimelineLoadingOverlay.css\"\n\nimport { Spring } from \"@follow/components/constants/spring.js\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC } from \"react\"\n\ntype Props = {\n  visible: boolean\n  label: string\n}\n\nexport const AITimelineLoadingOverlay: FC<Props> = ({ visible, label }) => (\n  <AnimatePresence>\n    {visible ? (\n      <m.div\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0 }}\n        transition={Spring.presets.smooth}\n        className=\"ai-timeline-loading\"\n      >\n        <div className=\"ai-timeline-loading__backdrop\" />\n        <div className=\"ai-timeline-loading__glow\" />\n        <div className=\"ai-timeline-loading__beam ai-timeline-loading__beam--left\" />\n        <div className=\"ai-timeline-loading__beam ai-timeline-loading__beam--right\" />\n        <div className=\"ai-timeline-loading__sparkles\">\n          <span />\n          <span />\n          <span />\n        </div>\n\n        <div className=\"ai-timeline-loading__badge flex items-center gap-2 px-4 py-1.5 text-xs font-semibold text-text\">\n          <span className=\"inline-flex size-2 rounded-full bg-orange-400 shadow-[0_0_12px_rgba(251,146,60,0.7)]\" />\n          <span className=\"i-mgc-robot-2-cute-re text-base text-orange-400 dark:text-orange-300\" />\n          <span>{label}</span>\n        </div>\n      </m.div>\n    ) : null}\n  </AnimatePresence>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/EntryColumnWrapper.tsx",
    "content": "import { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { getView } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\nimport type { EntryColumnWrapperProps } from \"./types\"\n\nconst styles = tw`relative h-0 grow`\nconst animationStyles = tw`duration-300 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:animate-none`\n\nexport const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapperProps) => {\n  const view = useRouteParamsSelector((state) => state.view)\n\n  return (\n    <div className={cn(styles, animationStyles)}>\n      <ScrollArea\n        scrollbarClassName={cn(!getView(view)?.wideMode ? \"w-[5px] p-0\" : \"\", \"z-[3]\")}\n        mask={false}\n        ref={ref}\n        rootClassName=\"h-full\"\n        viewportClassName={\"[&>div]:grow flex\"}\n        onScroll={onScroll}\n      >\n        {children}\n      </ScrollArea>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/index.ts",
    "content": "export * from \"./EntryColumnWrapper\"\nexport * from \"./types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/types.tsx",
    "content": "export interface EntryColumnWrapperProps extends ComponentType {\n  onScroll?: (e: React.UIEvent<HTMLDivElement>) => void\n\n  ref?: React.Ref<HTMLDivElement | null>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { styledButtonVariant } from \"@follow/components/ui/button/variants.js\"\nimport { Kbd, KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { useCountdown } from \"@follow/hooks\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FC, ReactNode } from \"react\"\nimport { useCallback, useEffect, useState } from \"react\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { HotkeyScope } from \"~/constants\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useI18n } from \"~/hooks/common\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandBinding, useCommandShortcuts } from \"~/modules/command/hooks/use-command-binding\"\n\nimport type { MarkAllFilter } from \"../hooks/useMarkAll\"\nimport { markAllByRoute } from \"../hooks/useMarkAll\"\n\ninterface MarkAllButtonProps {\n  className?: string\n  which?: ReactNode\n  shortcut?: boolean\n}\n\nexport const MarkAllReadButton = ({\n  ref,\n  className,\n  which = \"all\",\n  shortcut,\n}: MarkAllButtonProps & { ref?: React.Ref<HTMLButtonElement | null> }) => {\n  const { t } = useTranslation()\n  const { t: commonT } = useTranslation(\"common\")\n  const { ensureLogin } = useRequireLogin()\n\n  // const activeScope = useGlobalFocusableScope()\n  const when = useGlobalFocusableScopeSelector(\n    // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback\n    useCallback(\n      (activeScope) => activeScope.or(HotkeyScope.Timeline, HotkeyScope.SubscriptionList),\n      [],\n    ),\n  )\n  useCommandBinding({\n    commandId: COMMAND_ID.subscription.markAllAsRead,\n    when,\n  })\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.subscription.markAllAsRead, () => {\n      if (!ensureLogin()) {\n        return\n      }\n      let cancel = false\n      const undo = () => {\n        toast.dismiss(id)\n        if (cancel) return\n        cancel = true\n      }\n      const routerParams = getRouteParams()\n      const id = toast.warning(\"\", {\n        description: <ConfirmMarkAllReadInfo undo={undo} />,\n        duration: 3000,\n        onAutoClose() {\n          if (cancel) return\n          markAllByRoute(routerParams)\n        },\n        action: {\n          label: (\n            <span className=\"flex items-center gap-1\">\n              {t(\"mark_all_read_button.undo\")}\n              <Kbd className=\"inline-flex items-center border border-border bg-transparent text-white\">\n                $mod+z\n              </Kbd>\n            </span>\n          ),\n          onClick: undo,\n        },\n      })\n    })\n  }, [ensureLogin, t])\n\n  const markAllAsReadShortcut = useCommandShortcuts()[COMMAND_ID.subscription.markAllAsRead]\n  return (\n    <ActionButton\n      tooltip={\n        <>\n          <Trans\n            i18nKey=\"mark_all_read_button.mark_as_read\"\n            components={{\n              which: <>{commonT(`words.which.${which}` as any)}</>,\n            }}\n          />\n          {shortcut && (\n            <div className=\"ml-1\">\n              <KbdCombined className=\"text-text-secondary\">{markAllAsReadShortcut}</KbdCombined>\n            </div>\n          )}\n        </>\n      }\n      className={className}\n      ref={ref}\n      onClick={() => {\n        if (!ensureLogin()) return\n        markAllByRoute(getRouteParams())\n      }}\n    >\n      <i className=\"i-mgc-check-circle-cute-re\" />\n    </ActionButton>\n  )\n}\n\nconst ConfirmMarkAllReadInfo = ({ undo }: { undo: () => any }) => {\n  const { t } = useTranslation()\n  const [countdown] = useCountdown({ countStart: 3 })\n\n  useHotkeys(\"ctrl+z,meta+z\", undo, {\n    preventDefault: true,\n  })\n\n  return (\n    <div className=\"flex flex-col text-text\">\n      <span>{t(\"mark_all_read_button.confirm_mark_all_info\")}</span>\n      <span className=\"text-text-secondary\">\n        {t(\"mark_all_read_button.auto_confirm_info\", { countdown })}\n      </span>\n    </div>\n  )\n}\n\nexport const FlatMarkAllReadButton: FC<\n  MarkAllButtonProps & {\n    filter?: MarkAllFilter\n    buttonClassName?: string\n    iconClassName?: string\n    text?: string\n  }\n> = (props) => {\n  const t = useI18n()\n  const { ensureLogin } = useRequireLogin()\n\n  const { className, filter, which, buttonClassName, iconClassName } = props\n  const [status, setStatus] = useState<\"initial\" | \"confirm\" | \"done\">(\"initial\")\n\n  return (\n    <button\n      type=\"button\"\n      disabled={status === \"done\"}\n      className={cn(\n        styledButtonVariant({ variant: \"ghost\" }),\n        \"rounded-none\",\n        className,\n        buttonClassName,\n      )}\n      onClick={() => {\n        if (!ensureLogin()) return\n        markAllByRoute(getRouteParams(), filter)\n          .then(() => setStatus(\"done\"))\n          .catch(() => setStatus(\"initial\"))\n      }}\n    >\n      <i key={2} className={cn(\"i-mgc-check-circle-cute-re\", iconClassName)} />\n      <span className=\"duration-200\">\n        {status === \"done\" ? (\n          t(\"mark_all_read_button.done\")\n        ) : (\n          <Trans\n            i18nKey=\"mark_all_read_button.mark_as_read\"\n            components={{\n              which: (\n                <>{typeof which === \"string\" ? t.common(`words.which.${which}` as any) : which}</>\n              ),\n            }}\n          />\n        )}\n      </span>\n    </button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { createContext, use, useCallback, useLayoutEffect, useMemo, useRef } from \"react\"\n\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { useEntriesByView } from \"../hooks/useEntriesByView\"\n\ntype EntriesStateContextValue = {\n  type: \"remote\" | \"local\"\n  entriesIds: string[]\n  groupedCounts?: number[]\n  hasNextPage: boolean\n  isFetchingNextPage: boolean\n  isFetching: boolean\n  isLoading: boolean\n  error: unknown | null\n  view: FeedViewType\n  fetchedTime?: number\n}\n\ntype EntriesActionsContextValue = {\n  fetchNextPage: () => void | Promise<void>\n  refetch: () => void | Promise<void>\n  setOnReset: (cb: (() => void) | null) => void\n  getNeighbors: (entryId: string) => {\n    hasPrev: boolean\n    hasNext: boolean\n    prevId: string | null\n    nextId: string | null\n  }\n}\n\nconst EntriesStateContext = createContext<EntriesStateContextValue | undefined>(undefined)\nconst EntriesActionsContext = createContext<EntriesActionsContextValue | undefined>(undefined)\n\nexport const EntriesProvider: React.FC<React.PropsWithChildren> = ({ children }) => {\n  const onResetRef = useRef<(() => void) | null>(null)\n  const { view } = useRouteParams()\n\n  const entries = useEntriesByView({\n    onReset: () => {\n      onResetRef.current?.()\n    },\n  })\n\n  const { type: syncType } = entries\n\n  const idToIndex = useMemo(() => {\n    const map = new Map<string, number>()\n    let i = 0\n    for (const id of entries.entriesIds) {\n      map.set(id, i)\n      i++\n    }\n    return map\n  }, [entries.entriesIds])\n\n  // Keep latest dynamic values in refs for stable actions\n  const entriesIdsRef = useRef(entries.entriesIds)\n  useLayoutEffect(() => {\n    entriesIdsRef.current = entries.entriesIds\n  }, [entries.entriesIds])\n\n  const idToIndexRef = useRef(idToIndex)\n  useLayoutEffect(() => {\n    idToIndexRef.current = idToIndex\n  }, [idToIndex])\n\n  const fetchNextPageRef = useRef(entries.fetchNextPage)\n  useLayoutEffect(() => {\n    fetchNextPageRef.current = entries.fetchNextPage\n  }, [entries.fetchNextPage])\n\n  const refetchRef = useRef(entries.refetch)\n  useLayoutEffect(() => {\n    refetchRef.current = entries.refetch\n  }, [entries.refetch])\n\n  // Stable actions that reference latest refs\n  const fetchNextPageStable = useCallback(() => fetchNextPageRef.current?.(), [])\n  const refetchStable = useCallback(() => refetchRef.current?.(), [])\n  const setOnResetStable = useCallback((cb: (() => void) | null) => {\n    onResetRef.current = cb\n  }, [])\n  const getNeighborsStable = useCallback<EntriesActionsContextValue[\"getNeighbors\"]>((entryId) => {\n    const index = idToIndexRef.current.get(entryId)\n    if (index == null) {\n      return { hasPrev: false, hasNext: false, prevId: null, nextId: null }\n    }\n    const ids = entriesIdsRef.current\n    const prevIndex = index - 1\n    const nextIndex = index + 1\n    const prevId = prevIndex >= 0 ? (ids[prevIndex] ?? null) : null\n    const nextId = nextIndex < ids.length ? (ids[nextIndex] ?? null) : null\n    return {\n      hasPrev: prevId != null,\n      hasNext: nextId != null,\n      prevId,\n      nextId,\n    }\n  }, [])\n\n  const stateValue: EntriesStateContextValue = useMemo(\n    () => ({\n      type: syncType,\n      entriesIds: entries.entriesIds,\n      groupedCounts: entries.groupedCounts,\n      hasNextPage: entries.hasNextPage,\n      isFetchingNextPage: entries.isFetchingNextPage,\n      isFetching: entries.isFetching,\n      isLoading: entries.isLoading,\n      error: entries.error ?? null,\n      view: view!,\n      fetchedTime: entries.fetchedTime,\n    }),\n    [entries, view, syncType],\n  )\n\n  const actionsValue: EntriesActionsContextValue = useMemo(\n    () => ({\n      fetchNextPage: fetchNextPageStable,\n      refetch: refetchStable,\n      setOnReset: setOnResetStable,\n      getNeighbors: getNeighborsStable,\n    }),\n    [fetchNextPageStable, refetchStable, setOnResetStable, getNeighborsStable],\n  )\n\n  return (\n    <EntriesStateContext value={stateValue}>\n      <EntriesActionsContext value={actionsValue}>{children}</EntriesActionsContext>\n    </EntriesStateContext>\n  )\n}\n\nexport const useEntriesState = () => {\n  const ctx = use(EntriesStateContext)\n  if (!ctx) throw new Error(\"useEntriesState must be used within EntriesProvider\")\n  return ctx\n}\n\nexport const useEntriesActions = () => {\n  const ctx = use(EntriesActionsContext)\n  if (!ctx) throw new Error(\"useEntriesActions must be used within EntriesProvider\")\n  return ctx\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { LRUCache } from \"@follow/utils/lru-cache\"\nimport type { Range, VirtualItem, Virtualizer } from \"@tanstack/react-virtual\"\nimport { useVirtualizer } from \"@tanstack/react-virtual\"\nimport type { FC, MutableRefObject } from \"react\"\nimport {\n  Fragment,\n  startTransition,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { MediaContainerWidthProvider } from \"~/components/ui/media/MediaContainerWidthProvider\"\n\nimport { EntryItemSkeleton } from \"./EntryItemSkeleton\"\nimport { EntryItem } from \"./item\"\nimport { PictureMasonry } from \"./Items/picture-masonry\"\nimport type { EntryListProps } from \"./list\"\n\nexport const EntryColumnGrid: FC<EntryListProps> = (props) => {\n  const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props\n\n  const isMobile = useMobile()\n  const masonry = useUISettingKey(\"pictureViewMasonry\") || isMobile\n\n  if (masonry && view === FeedViewType.Pictures) {\n    return (\n      <PictureMasonry\n        key={feedId}\n        hasNextPage={hasNextPage}\n        endReached={fetchNextPage}\n        data={entriesIds}\n        Footer={props.Footer}\n      />\n    )\n  }\n  return <VirtualGrid {...props} entriesIds={entriesIds} />\n}\n\nconst capacity = 3 * 2\nconst offsetCache = new LRUCache<string, number>(capacity)\nconst measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)\n\nconst ratioMap = {\n  [FeedViewType.Pictures]: 1,\n  //  16:9\n  [FeedViewType.Videos]: 16 / 9,\n}\n\nconst VirtualGrid: FC<EntryListProps> = (props) => {\n  const scrollRef = useScrollViewElement()\n  const [containerWidth, setContainerWidth] = useState(0)\n\n  useEffect(() => {\n    if (!scrollRef) return\n    const handler = () => {\n      const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize)\n      const width = scrollRef.clientWidth - 2 * rem\n      setContainerWidth(width)\n\n      measureRef.current?.()\n    }\n\n    const observer = new ResizeObserver(handler)\n    handler()\n    observer.observe(scrollRef)\n    return () => {\n      observer.disconnect()\n    }\n  }, [scrollRef])\n\n  const measureRef = useRef<() => void>(undefined)\n\n  if (!containerWidth) return null\n\n  return <VirtualGridImpl {...props} containerWidth={containerWidth} measureRef={measureRef} />\n}\n\nconst VirtualGridImpl: FC<\n  EntryListProps & {\n    containerWidth: number\n    measureRef: MutableRefObject<(() => void) | undefined>\n  }\n> = (props) => {\n  const {\n    entriesIds,\n    feedId,\n    onRangeChange,\n    fetchNextPage,\n    view,\n    Footer,\n    hasNextPage,\n    listRef,\n    measureRef,\n    containerWidth,\n  } = props\n  const scrollRef = useScrollViewElement()\n\n  const columns = useMemo(() => {\n    const width = containerWidth\n    const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize)\n    let columnCount = 1 // default (grid-cols-1)\n    if (width >= 32 * rem) columnCount = 2 // @lg (32rem)\n    if (width >= 48 * rem) columnCount = 3 // @3xl (48rem)\n    if (width >= 72 * rem) columnCount = 4 // @6xl (72rem)\n    if (width >= 80 * rem) columnCount = 5 // @8xl (80rem)\n\n    return Array.from({ length: columnCount }).fill(width / columnCount) as number[]\n  }, [containerWidth])\n\n  const pictureViewImageOnly = useUISettingKey(\"pictureViewImageOnly\")\n  const isImageOnly = view === FeedViewType.Pictures && pictureViewImageOnly\n\n  // Calculate rows based on entries\n  const rows = useMemo(() => {\n    const itemsPerRow = columns.length\n    const rowCount = Math.ceil(entriesIds.length / itemsPerRow)\n    return Array.from({ length: rowCount }, (_, index) =>\n      entriesIds.slice(index * itemsPerRow, (index + 1) * itemsPerRow),\n    )\n  }, [entriesIds, columns.length])\n\n  const rowCacheKey = `${feedId}-row`\n  const columnCacheKey = `${feedId}-column`\n\n  const columnVirtualizer = useVirtualizer({\n    horizontal: true,\n    count: columns.length,\n    getScrollElement: () => scrollRef,\n    estimateSize: (i) => columns[i]!,\n    overscan: 5,\n    initialOffset: offsetCache.get(columnCacheKey) ?? 0,\n    initialMeasurementsCache: measurementsCache.get(columnCacheKey) ?? [],\n    onChange: useTypeScriptHappyCallback(\n      (virtualizer: Virtualizer<HTMLElement, Element>) => {\n        if (!virtualizer.isScrolling) {\n          measurementsCache.put(columnCacheKey, virtualizer.measurementsCache)\n          offsetCache.put(columnCacheKey, virtualizer.scrollOffset ?? 0)\n        }\n      },\n      [columnCacheKey],\n    ),\n  })\n\n  const rowVirtualizer = useVirtualizer({\n    count: rows.length + (hasNextPage ? 1 : 0) + (Footer ? 1 : 0),\n    estimateSize: () => {\n      return columns[0]! / ratioMap[view] + (!isImageOnly ? 58 : 0)\n    },\n    overscan: 5,\n    gap: 8,\n    getScrollElement: () => scrollRef,\n    initialOffset: offsetCache.get(rowCacheKey) ?? 0,\n    initialMeasurementsCache: measurementsCache.get(rowCacheKey) ?? [],\n    paddingEnd: 32,\n    onChange: useTypeScriptHappyCallback(\n      (virtualizer: Virtualizer<HTMLElement, Element>) => {\n        if (!virtualizer.isScrolling) {\n          measurementsCache.put(rowCacheKey, virtualizer.measurementsCache)\n          offsetCache.put(rowCacheKey, virtualizer.scrollOffset ?? 0)\n        }\n\n        if (!virtualizer.range) return\n\n        const columnCount = columns.length\n        const realRange = {\n          startIndex: virtualizer.range.startIndex * columnCount,\n          endIndex: virtualizer.range.endIndex * columnCount,\n        }\n\n        onRangeChange?.(realRange as Range)\n      },\n      [rowCacheKey, columns.length],\n    ),\n  })\n\n  useEffect(() => {\n    if (!listRef) return\n    listRef.current = rowVirtualizer\n  }, [rowVirtualizer, listRef])\n\n  useLayoutEffect(() => {\n    measureRef.current = () => {\n      rowVirtualizer.measure()\n      columnVirtualizer.measure()\n    }\n    measureRef.current?.()\n  }, [columnVirtualizer, measureRef, rowVirtualizer, isImageOnly])\n\n  const virtualItems = rowVirtualizer.getVirtualItems()\n  useEffect(() => {\n    if (!hasNextPage) return\n\n    const lastItem = virtualItems.at(-1)\n\n    if (!lastItem) {\n      return\n    }\n\n    const isLoaderRow = lastItem.index >= rows.length\n\n    if (isLoaderRow) {\n      fetchNextPage()\n    }\n  }, [fetchNextPage, hasNextPage, rows.length, virtualItems])\n\n  const [ready, setReady] = useState(false)\n\n  useEffect(() => {\n    startTransition(() => {\n      setReady(true)\n    })\n  }, [])\n\n  return (\n    <div\n      className=\"relative mx-4\"\n      style={{\n        height: `${rowVirtualizer.getTotalSize()}px`,\n      }}\n    >\n      {rowVirtualizer.getVirtualItems().map((virtualRow) => {\n        const footerRowIndex = rows.length + (hasNextPage ? 1 : 0)\n        const isFooterRow = Footer && virtualRow.key === footerRowIndex\n\n        if (isFooterRow && ready) {\n          return (\n            <div\n              key={virtualRow.key}\n              className=\"absolute left-0 top-4 w-full\"\n              style={{\n                height: `${virtualRow.size}px`,\n                transform: `translateY(${virtualRow.start}px)`,\n              }}\n            >\n              {typeof Footer === \"function\" ? <Footer /> : Footer}\n            </div>\n          )\n        }\n\n        return (\n          <Fragment key={virtualRow.key}>\n            {columnVirtualizer.getVirtualItems().map((virtualColumn) => {\n              const currentRow = rows[virtualRow.index] || []\n              const columnIndex = virtualColumn.index\n              const isDataRow = virtualRow.index < rows.length\n              const hasEntry = currentRow[columnIndex]\n              const isSkeletonRow = virtualRow.index === rows.length\n\n              let content: React.ReactNode = null\n              if (isDataRow && hasEntry) {\n                content = ready && <EntryItem entryId={currentRow[columnIndex]!} view={view} />\n              } else if (\n                (isDataRow && !hasEntry && hasNextPage) ||\n                (isSkeletonRow && hasNextPage && ready)\n              ) {\n                content = ready && <EntryItemSkeleton view={view} count={1} />\n              }\n\n              if (!content) return null\n\n              return (\n                <div\n                  ref={columnVirtualizer.measureElement}\n                  key={virtualColumn.key}\n                  data-index={virtualColumn.index}\n                  className=\"absolute left-0 top-4\"\n                  style={{\n                    height: `${virtualRow.size}px`,\n                    width: `${virtualColumn.size}px`,\n                    transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,\n                  }}\n                >\n                  <MediaContainerWidthProvider width={columns[virtualColumn.index] ?? 0}>\n                    {content}\n                  </MediaContainerWidthProvider>\n                </div>\n              )\n            })}\n          </Fragment>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useAttachScrollBeyond.tsx",
    "content": "import { useSetAtom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { useEntryRootState } from \"../store/EntryColumnContext\"\n\nconst DEFAULT_THRESHOLD = 30\n\nexport const useAttachScrollBeyond = (threshold: number = DEFAULT_THRESHOLD) => {\n  const { isScrolledBeyondThreshold } = useEntryRootState()\n  const setIsScrolledBeyondThreshold = useSetAtom(isScrolledBeyondThreshold)\n\n  const handleScroll = useCallback(\n    (event: React.UIEvent<HTMLDivElement>) => {\n      const { scrollTop } = event.currentTarget\n      setIsScrolledBeyondThreshold(scrollTop > threshold)\n    },\n    [setIsScrolledBeyondThreshold, threshold],\n  )\n\n  return { handleScroll }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts",
    "content": "import { FeedViewType, getView } from \"@follow/constants\"\nimport { useCollectionEntryList } from \"@follow/store/collection/hooks\"\nimport { isOnboardingEntryUrl } from \"@follow/store/constants/onboarding\"\nimport {\n  useEntriesQuery,\n  useEntryIdsByFeedId,\n  useEntryIdsByFeedIds,\n  useEntryIdsByInboxId,\n  useEntryIdsByListId,\n  useEntryIdsByView,\n} from \"@follow/store/entry/hooks\"\nimport { entryActions, entrySyncServices, useEntryStore } from \"@follow/store/entry/store\"\nimport type { UseEntriesReturn } from \"@follow/store/entry/types\"\nimport { fallbackReturn } from \"@follow/store/entry/utils\"\nimport { useFolderFeedsByFeedId } from \"@follow/store/subscription/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { nextFrame } from \"@follow/utils\"\nimport { isBizId } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { debounce } from \"es-toolkit/compat\"\nimport { useAtomValue } from \"jotai\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { ROUTE_FEED_PENDING } from \"~/constants/app\"\nimport { useFeature } from \"~/hooks/biz/useFeature\"\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { aiTimelineEnabledAtom } from \"../atoms/ai-timeline\"\nimport { useIsPreviewFeed } from \"./useIsPreviewFeed\"\n\nconst useRemoteEntries = (): UseEntriesReturn => {\n  const { feedId, view, inboxId, listId } = useRouteParams()\n  const isPreview = useIsPreviewFeed()\n\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(\n    \"hidePrivateSubscriptionsInTimeline\",\n  )\n  const aiTimelineEnabled = useAtomValue(aiTimelineEnabledAtom)\n  const aiEnabled = useFeature(\"ai\")\n\n  const folderIds = useFolderFeedsByFeedId({\n    feedId,\n    view,\n  })\n\n  const entriesOptions = useMemo(() => {\n    const params = {\n      feedId: folderIds?.join(\",\") || feedId,\n      inboxId,\n      listId,\n      view,\n      ...(unreadOnly === true && !isPreview && { unreadOnly: true }),\n      ...(hidePrivateSubscriptionsInTimeline === true && {\n        hidePrivateSubscriptionsInTimeline: true,\n      }),\n      ...(view === FeedViewType.All && { limit: 40 }),\n      ...(aiTimelineEnabled && aiEnabled && { aiSort: true }),\n    }\n\n    if (feedId && listId && isBizId(feedId)) {\n      delete params.listId\n    }\n\n    return params\n  }, [\n    feedId,\n    folderIds,\n    inboxId,\n    listId,\n    unreadOnly,\n    isPreview,\n    view,\n    hidePrivateSubscriptionsInTimeline,\n    aiTimelineEnabled,\n    aiEnabled,\n  ])\n  const query = useEntriesQuery(entriesOptions)\n\n  const [fetchedTime, setFetchedTime] = useState<number>()\n  useEffect(() => {\n    if (!query.isFetching) {\n      setFetchedTime(Date.now())\n    }\n  }, [query.isFetching])\n\n  const refetch = useCallback(async () => void query.refetch(), [query])\n  const fetchNextPage = useCallback(async () => void query.fetchNextPage(), [query])\n\n  if (!query.data || query.isLoading) {\n    return fallbackReturn\n  }\n  return {\n    entriesIds: query.entriesIds,\n    hasNext: query.hasNextPage,\n    refetch,\n\n    fetchNextPage,\n    isLoading: query.isFetching,\n    isRefetching: query.isRefetching,\n    isReady: query.isSuccess,\n    isFetchingNextPage: query.isFetchingNextPage,\n    isFetching: query.isFetching,\n    hasNextPage: query.hasNextPage,\n    error: query.isError ? query.error : null,\n    fetchedTime,\n    queryKey: query.queryKey,\n  }\n}\n\nfunction getEntryIdsFromMultiplePlace(...entryIds: Array<string[] | undefined | null>) {\n  return entryIds.find((ids) => ids?.length) ?? []\n}\n\nconst useLocalEntries = (): UseEntriesReturn => {\n  const { feedId, view, inboxId, listId, isCollection } = useRouteParams()\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(\n    \"hidePrivateSubscriptionsInTimeline\",\n  )\n\n  const folderIds = useFolderFeedsByFeedId({\n    feedId,\n    view,\n  })\n  const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline)\n  const entryIdsByCollections = useCollectionEntryList(view)\n  const entryIdsByFeedId = useEntryIdsByFeedId(feedId)\n  const entryIdsByCategory = useEntryIdsByFeedIds(folderIds)\n  const entryIdsByListId = useEntryIdsByListId(listId)\n  const entryIdsByInboxId = useEntryIdsByInboxId(inboxId)\n\n  const showEntriesByView =\n    (!feedId || feedId === ROUTE_FEED_PENDING) &&\n    folderIds.length === 0 &&\n    !isCollection &&\n    !inboxId &&\n    !listId\n\n  const allEntries = useEntryStore(\n    useCallback(\n      (state) => {\n        const ids = isCollection\n          ? entryIdsByCollections\n          : showEntriesByView\n            ? (entryIdsByView ?? [])\n            : (getEntryIdsFromMultiplePlace(\n                entryIdsByFeedId,\n                entryIdsByCategory,\n                entryIdsByListId,\n                entryIdsByInboxId,\n              ) ?? [])\n\n        return ids\n          .map((id) => {\n            const entry = state.data[id]\n            if (!entry) return null\n            if (unreadOnly && entry.read) {\n              return null\n            }\n            return entry.id\n          })\n          .filter((id) => typeof id === \"string\")\n      },\n      [\n        entryIdsByCategory,\n        entryIdsByCollections,\n        entryIdsByFeedId,\n        entryIdsByInboxId,\n        entryIdsByListId,\n        entryIdsByView,\n        isCollection,\n        showEntriesByView,\n        unreadOnly,\n      ],\n    ),\n  )\n\n  const [page, setPage] = useState(0)\n  const pageSize = 30\n  const totalPage = useMemo(\n    () => (allEntries ? Math.ceil(allEntries.length / pageSize) : 0),\n    [allEntries],\n  )\n\n  const entries = useMemo(() => {\n    return allEntries?.slice(0, (page + 1) * pageSize) || []\n  }, [allEntries, page, pageSize])\n\n  const hasNext = useMemo(() => {\n    return entries.length < (allEntries?.length || 0)\n  }, [entries.length, allEntries])\n\n  const refetch = useCallback(async () => {\n    setPage(0)\n  }, [])\n\n  const fetchNextPage = useCallback(\n    debounce(async () => {\n      setPage(page + 1)\n    }, 300),\n    [page],\n  )\n\n  useEffect(() => {\n    setPage(0)\n  }, [view, feedId])\n\n  return {\n    entriesIds: entries,\n    hasNext,\n    refetch,\n    fetchNextPage: fetchNextPage as () => Promise<void>,\n    isLoading: false,\n    isRefetching: false,\n    isReady: true,\n    isFetchingNextPage: false,\n    isFetching: false,\n    hasNextPage: page < totalPage,\n    error: null,\n  }\n}\n\nexport const useEntriesByView = ({ onReset }: { onReset?: () => void }) => {\n  const { view, listId } = useRouteParams()\n\n  const remoteQuery = useRemoteEntries()\n  const localQuery = useLocalEntries()\n\n  useFetchEntryContentByStream(remoteQuery.entriesIds)\n\n  // If remote data is not available, we use the local data, get the local data length\n  // FIXME: remote first, then local store data\n  // NOTE: We still can't use the store's data handling directly.\n  // Imagine that the local data may be persistent, and then if there are incremental updates to the data on the server side,\n  // then we have no way to incrementally update the data.\n  // We need to add an interface to incrementally update the data based on the version hash.\n\n  const query = remoteQuery.isReady ? remoteQuery : localQuery\n  const entryIds: string[] = query.entriesIds\n\n  const isFetchingFirstPage = remoteQuery.isFetching && !remoteQuery.isFetchingNextPage\n\n  useEffect(() => {\n    if (isFetchingFirstPage) {\n      nextFrame(() => {\n        onReset?.()\n      })\n    }\n  }, [isFetchingFirstPage, query.queryKey])\n\n  const groupByDate = useGeneralSettingKey(\"groupByDate\")\n  const groupedCounts: number[] | undefined = useMemo(() => {\n    const viewDefinition = getView(view)\n    if (viewDefinition?.gridMode || view === FeedViewType.All) {\n      return\n    }\n    if (!groupByDate) {\n      return\n    }\n    const entriesId2Map = entryActions.getFlattenMapEntries()\n    const counts = [] as number[]\n    let lastDate = \"\"\n    for (const id of entryIds) {\n      const entry = entriesId2Map[id]\n      if (!entry) {\n        continue\n      }\n      if (isOnboardingEntryUrl(entry.url)) {\n        continue\n      }\n      const date = new Date(listId ? entry.insertedAt : entry.publishedAt).toDateString()\n      if (date !== lastDate) {\n        counts.push(1)\n        lastDate = date\n      } else {\n        const last = counts.pop()\n        if (last) counts.push(last + 1)\n      }\n    }\n\n    return counts\n  }, [groupByDate, listId, entryIds, view])\n\n  return {\n    ...query,\n\n    type: remoteQuery.isReady ? (\"remote\" as const) : (\"local\" as const),\n    refetch: useCallback(() => {\n      const promise = query.refetch()\n      unreadSyncService.resetFromRemote()\n      return promise\n    }, [query]),\n    entriesIds: entryIds,\n    groupedCounts,\n    isFetching: remoteQuery.isFetching,\n    isFetchingNextPage: remoteQuery.isFetchingNextPage,\n    isLoading: remoteQuery.isLoading,\n  }\n}\n\nconst useFetchEntryContentByStream = (remoteEntryIds?: string[]) => {\n  const { mutate: updateEntryContent } = useMutation({\n    mutationKey: [\"stream-entry-content\", remoteEntryIds],\n    mutationFn: (remoteEntryIds: string[]) =>\n      entrySyncServices.fetchEntryContentByStream(remoteEntryIds),\n  })\n\n  useEffect(() => {\n    if (!remoteEntryIds) return\n    updateEntryContent(remoteEntryIds)\n  }, [remoteEntryIds, updateEntryContent])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryIdListSnap.ts",
    "content": "import { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { useEffect, useMemo, useState } from \"react\"\n\n/**\n * This is a global atom to store the current entry column's entry id list snapshot.\n * It used to get current entry id list(keep sorted) in other components.\n */\nconst globalEntryIdListSnapAtom = atom<string[]>([])\nexport const useSnapEntryIdList = (ids: string[]) => {\n  const set = useSetAtom(globalEntryIdListSnapAtom)\n  useEffect(() => {\n    set(ids)\n  }, [ids, set])\n}\n\nexport const useGetEntryIdInRange = (id: string, range: [number, number]) => {\n  const snap = useAtomValue(globalEntryIdListSnapAtom)\n  const [stableRange] = useState(range)\n  return useMemo(() => {\n    const index = snap.indexOf(id)\n\n    return snap.slice(\n      Math.max(0, index - stableRange[0]),\n      Math.min(snap.length, index + stableRange[1]),\n    )\n  }, [id, snap, stableRange])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx",
    "content": "import { getView } from \"@follow/constants\"\nimport { entryActions } from \"@follow/store/entry/store\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport type { Range } from \"@tanstack/react-virtual\"\nimport { useMemo } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\nexport const useEntryMarkReadHandler = (entriesIds: string[]) => {\n  const renderAsRead = useGeneralSettingKey(\"renderMarkUnread\")\n  const scrollMarkUnread = useGeneralSettingKey(\"scrollMarkUnread\")\n  const feedView = useRouteParamsSelector((params) => params.view)\n\n  const processedEntryIds = useMemo(() => new Set<string>(), [entriesIds])\n\n  const handleRenderAsRead = useEventCallback(\n    ({ startIndex, endIndex }: Range, enabled?: boolean) => {\n      if (!enabled) return\n      const idSlice = entriesIds?.slice(startIndex, endIndex)\n      if (!idSlice) return\n\n      // Filter out entries that have already been processed\n      const newEntries = idSlice.filter((id) => !processedEntryIds.has(id))\n      if (newEntries.length === 0) return\n\n      // Mark these entries as processed to avoid duplicate processing\n      newEntries.forEach((id) => processedEntryIds.add(id))\n\n      batchMarkRead(newEntries)\n    },\n  )\n\n  return useMemo(() => {\n    if (getView(feedView)?.wideMode && renderAsRead) {\n      return handleRenderAsRead\n    }\n\n    if (scrollMarkUnread) {\n      return handleRenderAsRead\n    }\n    return\n  }, [feedView, handleRenderAsRead, renderAsRead, scrollMarkUnread])\n}\n\nexport function batchMarkRead(ids: string[]) {\n  const batchLikeIds = [] as string[]\n  const entriesId2Map = entryActions.getFlattenMapEntries()\n  for (const id of ids) {\n    const entry = entriesId2Map[id]\n\n    if (!entry) continue\n    const isRead = entry.read\n    if (!isRead && entry.feedId) {\n      batchLikeIds.push(id)\n    }\n  }\n\n  if (batchLikeIds.length > 0) {\n    for (const id of batchLikeIds) {\n      unreadSyncService.markEntryAsRead(id)\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryVirtualization.ts",
    "content": "import { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { LRUCache } from \"@follow/utils/lru-cache\"\nimport type { Range, VirtualItem, Virtualizer } from \"@tanstack/react-virtual\"\nimport { useVirtualizer } from \"@tanstack/react-virtual\"\nimport type { RefObject } from \"react\"\nimport { useCallback, useEffect, useMemo } from \"react\"\n\ninterface UseEntryVirtualizationOptions {\n  count: number\n  estimateSize?: () => number\n  overscan?: number\n  gap?: number\n  cacheKey?: string\n  onRangeChange?: (range: Range) => void\n  scrollToIndex?: number | { index: number; align?: \"start\" | \"center\" | \"end\" | \"auto\" }\n  scrollElement?: RefObject<HTMLElement> | (() => HTMLElement | null)\n}\n\nconst capacity = 3\nconst offsetCache = new LRUCache<string, number>(capacity)\nconst measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)\n\nexport const useEntryVirtualization = ({\n  count,\n  estimateSize = () => 112,\n  overscan = 5,\n  gap,\n  cacheKey = \"entry-list\",\n  onRangeChange,\n  scrollToIndex,\n  scrollElement,\n}: UseEntryVirtualizationOptions) => {\n  const defaultScrollRef = useScrollViewElement()\n\n  const getScrollElement = useCallback(() => {\n    if (scrollElement) {\n      return typeof scrollElement === \"function\" ? scrollElement() : scrollElement.current\n    }\n    return defaultScrollRef\n  }, [scrollElement, defaultScrollRef])\n\n  const rowVirtualizer = useVirtualizer({\n    count,\n    estimateSize,\n    overscan,\n    gap,\n    getScrollElement,\n    initialOffset: offsetCache.get(cacheKey) ?? 0,\n    initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [],\n    onChange: useTypeScriptHappyCallback(\n      (virtualizer: Virtualizer<HTMLElement, Element>) => {\n        if (!virtualizer.isScrolling) {\n          measurementsCache.put(cacheKey, virtualizer.measurementsCache)\n          offsetCache.put(cacheKey, virtualizer.scrollOffset ?? 0)\n        }\n\n        onRangeChange?.(virtualizer.range as Range)\n      },\n      [cacheKey],\n    ),\n  })\n\n  // Handle scroll to index with viewport check\n  useEffect(() => {\n    if (scrollToIndex !== undefined) {\n      const targetIndex = typeof scrollToIndex === \"number\" ? scrollToIndex : scrollToIndex.index\n\n      // Check if target index is already in viewport\n      const { range } = rowVirtualizer\n      if (range && targetIndex >= range.startIndex && targetIndex <= range.endIndex) {\n        // Target is already visible, no need to scroll\n        return\n      }\n\n      if (typeof scrollToIndex === \"number\") {\n        rowVirtualizer.scrollToIndex(scrollToIndex)\n      } else {\n        rowVirtualizer.scrollToIndex(scrollToIndex.index, { align: scrollToIndex.align })\n      }\n    }\n  }, [scrollToIndex, rowVirtualizer])\n\n  const virtualItems = rowVirtualizer.getVirtualItems()\n\n  // Create render data with common transformations\n  const renderData = useMemo(() => {\n    return virtualItems.map((virtualRow) => ({\n      key: virtualRow.key,\n      index: virtualRow.index,\n      start: virtualRow.start,\n      size: virtualRow.size,\n      transform: `translateY(${virtualRow.start}px)`,\n    }))\n  }, [virtualItems])\n\n  // Scroll to specific index programmatically with viewport check\n  const scrollTo = useCallback(\n    (index: number, align?: \"start\" | \"center\" | \"end\" | \"auto\") => {\n      // Check if target index is already in viewport\n      const { range } = rowVirtualizer\n      if (range && index >= range.startIndex && index <= range.endIndex) {\n        // Target is already visible, no need to scroll\n        return\n      }\n\n      rowVirtualizer.scrollToIndex(index, { align })\n    },\n    [rowVirtualizer],\n  )\n\n  return {\n    virtualizer: rowVirtualizer,\n    virtualItems,\n    renderData,\n    totalSize: rowVirtualizer.getTotalSize(),\n    scrollTo,\n    measureElement: rowVirtualizer.measureElement,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useIsPreviewFeed.ts",
    "content": "import { getSubscriptionByFeedId } from \"@follow/store/subscription/getter\"\nimport { isBizId } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\n\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\n\nexport const useIsPreviewFeed = () => {\n  const listId = useRouteParamsSelector((s) => s.listId)\n  const feedId = useRouteParamsSelector((s) => s.feedId)\n\n  return useMemo(() => {\n    let isPreview = false\n    if (listId) {\n      isPreview = !getSubscriptionByFeedId(listId)\n    } else if (feedId) {\n      isPreview = isBizId(feedId) && !getSubscriptionByFeedId(feedId)\n    }\n    return isPreview\n  }, [listId, feedId])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useLocalEntries.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useCollectionEntryList } from \"@follow/store/collection/hooks\"\nimport {\n  useEntryIdsByFeedId,\n  useEntryIdsByFeedIds,\n  useEntryIdsByInboxId,\n  useEntryIdsByListId,\n  useEntryIdsByView,\n} from \"@follow/store/entry/hooks\"\nimport { useEntryStore } from \"@follow/store/entry/store\"\nimport type { UseEntriesReturn } from \"@follow/store/entry/types\"\nimport { useFolderFeedsByFeedId } from \"@follow/store/subscription/hooks\"\nimport { debounce } from \"es-toolkit/compat\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { ROUTE_FEED_PENDING } from \"~/constants/app\"\n\ninterface UseLocalEntriesOptions {\n  feedId?: string\n  view?: FeedViewType\n  inboxId?: string\n  listId?: string\n  isCollection?: boolean\n  pageSize?: number\n}\n\nfunction getEntryIdsFromMultiplePlace(...entryIds: Array<string[] | undefined | null>) {\n  return entryIds.find((ids) => ids?.length) ?? []\n}\n\nexport const useLocalEntries = ({\n  feedId,\n  view = FeedViewType.All,\n  inboxId,\n  listId,\n  isCollection,\n  pageSize = 30,\n}: UseLocalEntriesOptions = {}): UseEntriesReturn => {\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(\n    \"hidePrivateSubscriptionsInTimeline\",\n  )\n\n  const folderIds = useFolderFeedsByFeedId({\n    feedId,\n    view,\n  })\n  const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline)\n  const entryIdsByCollections = useCollectionEntryList(view)\n  const entryIdsByFeedId = useEntryIdsByFeedId(feedId)\n  const entryIdsByCategory = useEntryIdsByFeedIds(folderIds)\n  const entryIdsByListId = useEntryIdsByListId(listId)\n  const entryIdsByInboxId = useEntryIdsByInboxId(inboxId)\n\n  const showEntriesByView =\n    (!feedId || feedId === ROUTE_FEED_PENDING) &&\n    folderIds.length === 0 &&\n    !isCollection &&\n    !inboxId &&\n    !listId\n\n  const allEntries = useEntryStore(\n    useCallback(\n      (state) => {\n        const ids = isCollection\n          ? entryIdsByCollections\n          : showEntriesByView\n            ? (entryIdsByView ?? [])\n            : (getEntryIdsFromMultiplePlace(\n                entryIdsByFeedId,\n                entryIdsByCategory,\n                entryIdsByListId,\n                entryIdsByInboxId,\n              ) ?? [])\n\n        return ids\n          .map((id) => {\n            const entry = state.data[id]\n            if (!entry) return null\n            if (unreadOnly && entry.read) {\n              return null\n            }\n            return entry.id\n          })\n          .filter((id) => typeof id === \"string\")\n      },\n      [\n        entryIdsByCategory,\n        entryIdsByCollections,\n        entryIdsByFeedId,\n        entryIdsByInboxId,\n        entryIdsByListId,\n        entryIdsByView,\n        isCollection,\n        showEntriesByView,\n        unreadOnly,\n      ],\n    ),\n  )\n\n  const [page, setPage] = useState(0)\n  const totalPage = useMemo(\n    () => (allEntries ? Math.ceil(allEntries.length / pageSize) : 0),\n    [allEntries, pageSize],\n  )\n\n  const entries = useMemo(() => {\n    return allEntries?.slice(0, (page + 1) * pageSize) || []\n  }, [allEntries, page, pageSize])\n\n  const hasNext = useMemo(() => {\n    return entries.length < (allEntries?.length || 0)\n  }, [entries.length, allEntries])\n\n  const refetch = useCallback(async () => {\n    setPage(0)\n  }, [])\n\n  const fetchNextPage = useCallback(() => {\n    const debouncedFetch = debounce(() => {\n      setPage((prev) => prev + 1)\n    }, 300)\n    debouncedFetch()\n  }, [])\n\n  useEffect(() => {\n    setPage(0)\n  }, [view, feedId])\n\n  return {\n    entriesIds: entries,\n    hasNext,\n    refetch,\n    fetchNextPage,\n    isLoading: false,\n    isRefetching: false,\n    isReady: true,\n    isFetchingNextPage: false,\n    isFetching: false,\n    hasNextPage: page < totalPage,\n    error: null,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useMarkAll.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { getCategoryFeedIds } from \"@follow/store/subscription/getter\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\n\nimport { getGeneralSettings } from \"~/atoms/settings/general\"\n\nexport type MarkAllFilter =\n  | {\n      startTime: number\n      endTime: number\n    }\n  | {\n      insertedBefore: number\n    }\n\nexport const markAllByRoute = async (\n  data: {\n    feedId?: string | undefined\n    view: FeedViewType\n    inboxId?: string | undefined\n    listId?: string | undefined\n\n    isAllFeeds?: boolean\n  },\n  time?: MarkAllFilter,\n) => {\n  const { feedId, view, inboxId, listId, isAllFeeds } = data\n  const folderIds = getCategoryFeedIds(feedId, view)\n\n  if (!feedId) return\n\n  const { hidePrivateSubscriptionsInTimeline: excludePrivate } = getGeneralSettings()\n  if (typeof feedId === \"number\" || isAllFeeds) {\n    unreadSyncService.markBatchAsRead({\n      view,\n      time,\n      excludePrivate,\n    })\n  } else if (inboxId) {\n    unreadSyncService.markBatchAsRead({\n      filter: {\n        inboxId,\n      },\n      view,\n      time,\n      excludePrivate,\n    })\n  } else if (listId) {\n    unreadSyncService.markBatchAsRead({\n      filter: {\n        listId,\n      },\n      view,\n      time,\n      excludePrivate,\n    })\n  } else if (folderIds?.length) {\n    unreadSyncService.markBatchAsRead({\n      filter: {\n        feedIdList: folderIds,\n      },\n      view,\n      time,\n      excludePrivate,\n    })\n  } else if (feedId) {\n    unreadSyncService.markBatchAsRead({\n      filter: {\n        feedIdList: feedId?.split(\",\"),\n      },\n      view,\n      time,\n      excludePrivate,\n    })\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useNavigateFirstEntry.tsx",
    "content": "import type { FeedViewType } from \"@follow-app/client-sdk\"\nimport { useEffect, useRef } from \"react\"\n\nimport { ROUTE_ENTRY_PENDING } from \"~/constants\"\nimport type { NavigateEntryOptions } from \"~/hooks/biz/useNavigateEntry\"\nimport { useNewUserGuideState } from \"~/modules/app-tip/useNewUserGuideState\"\n\nimport { useEntriesState } from \"../context/EntriesContext\"\n\nexport const useNavigateFirstEntry = (\n  entriesIds: string[],\n  activeEntryId: string | undefined,\n  view: FeedViewType,\n  navigate: (options: NavigateEntryOptions) => void,\n) => {\n  const state = useEntriesState()\n  const isRemoteSource = state.type === \"remote\"\n  const hasAutoNavigatedRef = useRef(false)\n  const { shouldShowNewUserGuide } = useNewUserGuideState()\n  useEffect(() => {\n    if (!shouldShowNewUserGuide) return\n    if (!isRemoteSource) return\n    if (hasAutoNavigatedRef.current) return\n    if (state.isLoading || state.isFetching) return\n    if (entriesIds.length === 0) return\n    if (activeEntryId && activeEntryId !== ROUTE_ENTRY_PENDING) return\n\n    const firstEntryId = entriesIds[0]\n    if (!firstEntryId) return\n\n    hasAutoNavigatedRef.current = true\n    navigate({\n      view,\n      entryId: firstEntryId,\n    })\n  }, [\n    activeEntryId,\n    entriesIds,\n    navigate,\n    shouldShowNewUserGuide,\n    state.isFetching,\n    state.isLoading,\n    view,\n    isRemoteSource,\n  ])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/hooks/useWheelGestureClose.ts",
    "content": "import { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { throttle } from \"es-toolkit\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\ninterface UseWheelGestureCloseOptions {\n  /** Whether the gesture is enabled */\n  enabled: boolean\n  /** Callback to execute when close gesture is triggered */\n  onClose: () => void\n}\n\ninterface UseWheelGestureCloseReturn {\n  /** Whether to show scroll hint indicator */\n  showScrollHint: boolean\n}\n\n/**\n * Custom hook for handling wheel gesture to close entry\n * Handles trackpad/mouse wheel upward scroll when at top of content\n */\nexport const useWheelGestureClose = ({\n  enabled,\n  onClose: handleCloseGesture,\n}: UseWheelGestureCloseOptions): UseWheelGestureCloseReturn => {\n  const $scrollAreaElement = useScrollViewElement()\n  const accumulatedDelta = useRef(0)\n  const isScrollingAtTop = useRef(false)\n  const [showScrollHint, setShowScrollHint] = useState(false)\n\n  const handleWheel = useEventCallback(\n    throttle((e: WheelEvent) => {\n      if (!enabled) return\n\n      // Find the actual scroll viewport element with correct Radix UI attribute\n\n      const scrollElement = $scrollAreaElement\n\n      // Check if we're at the top of the content\n      const scrollTop = scrollElement?.scrollTop || 0\n\n      isScrollingAtTop.current = scrollTop === 0\n      setShowScrollHint(scrollTop === 0)\n\n      // Handle trackpad/mouse wheel: upward scroll (deltaY < 0) or downward swipe gesture\n      // On macOS trackpad, natural scrolling makes upward finger movement negative deltaY\n      if (e.deltaY < 0 && isScrollingAtTop.current) {\n        e.preventDefault()\n        accumulatedDelta.current += Math.abs(e.deltaY)\n\n        // Close when accumulated scroll exceeds threshold (150px for trackpad sensitivity)\n        if (accumulatedDelta.current > 1000) {\n          handleCloseGesture()\n          accumulatedDelta.current = 0\n        }\n      } else {\n        // Reset accumulation when scrolling down or not at top\n        accumulatedDelta.current = 0\n      }\n    }, 16),\n  )\n\n  useEffect(() => {\n    if (!$scrollAreaElement) return\n    // Find the scroll area viewport element with correct Radix UI attribute\n\n    // Add wheel event listener to both the main container and scroll viewport\n    // This ensures the gesture works in both header area and scrollable content\n    const elementsToListen: HTMLElement[] = [$scrollAreaElement]\n\n    elementsToListen.forEach((el) => {\n      el.addEventListener(\"wheel\", handleWheel, { passive: false })\n    })\n\n    // Initial scroll position check for hint visibility\n    const initialCheckScrollPosition = () => {\n      if (!$scrollAreaElement) return\n      const scrollTop = $scrollAreaElement.scrollTop || 0\n      setShowScrollHint(scrollTop === 0)\n    }\n\n    // Check initial position\n    initialCheckScrollPosition()\n\n    // Add scroll listener for hint visibility\n\n    $scrollAreaElement.addEventListener(\"scroll\", initialCheckScrollPosition, { passive: true })\n\n    return () => {\n      elementsToListen.forEach((el) => {\n        el.removeEventListener(\"wheel\", handleWheel)\n      })\n      $scrollAreaElement.removeEventListener(\"scroll\", initialCheckScrollPosition)\n    }\n  }, [$scrollAreaElement, handleWheel])\n\n  return {\n    showScrollHint,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/index.tsx",
    "content": "import { FeedViewType, getView } from \"@follow/constants\"\nimport { useTitle } from \"@follow/hooks\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { isBizId } from \"@follow/utils/utils\"\nimport type { Range, Virtualizer } from \"@tanstack/react-virtual\"\nimport { atom, useAtomValue } from \"jotai\"\nimport { memo, useCallback, useEffect, useMemo, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { FeedNotFound } from \"~/components/errors/FeedNotFound\"\nimport { FEED_COLLECTION_LIST, HotkeyScope, ROUTE_FEED_PENDING } from \"~/constants\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useRouteParams, useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useFeedQuery } from \"~/queries/feed\"\nimport { useFeedHeaderTitle } from \"~/store/feed/hooks\"\n\nimport { aiTimelineEnabledAtom } from \"./atoms/ai-timeline\"\nimport { AITimelineLoadingOverlay } from \"./components/ai-timeline-loading/AITimelineLoadingOverlay\"\nimport { EntryColumnWrapper } from \"./components/entry-column-wrapper/EntryColumnWrapper\"\nimport { FooterMarkItem } from \"./components/FooterMarkItem\"\nimport { useEntriesActions, useEntriesState } from \"./context/EntriesContext\"\nimport { EntryItemSkeleton } from \"./EntryItemSkeleton\"\nimport { EntryColumnGrid } from \"./grid\"\nimport { useAttachScrollBeyond } from \"./hooks/useAttachScrollBeyond\"\nimport { useSnapEntryIdList } from \"./hooks/useEntryIdListSnap\"\nimport { useEntryMarkReadHandler } from \"./hooks/useEntryMarkReadHandler\"\nimport { useNavigateFirstEntry } from \"./hooks/useNavigateFirstEntry\"\nimport { EntryListHeader } from \"./layouts/EntryListHeader\"\nimport { EntryEmptyList, EntryList } from \"./list\"\nimport { EntryRootStateContext } from \"./store/EntryColumnContext\"\n\nfunction EntryColumnContent() {\n  const listRef = useRef<Virtualizer<HTMLElement, Element>>(undefined)\n  const { t } = useTranslation()\n  const state = useEntriesState()\n\n  const actions = useEntriesActions()\n  // Register reset handler to keep scroll behavior when data resets\n  useEffect(() => {\n    actions.setOnReset(() => {\n      listRef.current?.scrollToIndex(0)\n    })\n    return () => actions.setOnReset(null)\n  }, [actions])\n\n  const { entriesIds, groupedCounts } = state\n  useSnapEntryIdList(entriesIds)\n\n  const {\n    entryId: activeEntryId,\n    view,\n    feedId: routeFeedId,\n    isPendingEntry,\n    isCollection,\n  } = useRouteParams()\n\n  const entry = useEntry(activeEntryId, (state) => {\n    const { feedId } = state\n    return { feedId }\n  })\n  const feed = useFeedById(routeFeedId)\n  const title = useFeedHeaderTitle()\n  useTitle(title)\n  const isLoggedIn = useIsLoggedIn()\n\n  useEffect(() => {\n    if (!activeEntryId) return\n\n    if (isCollection || isPendingEntry) return\n    if (!entry?.feedId) return\n\n    if (!isLoggedIn) return\n    unreadSyncService.markEntryAsRead(activeEntryId)\n  }, [activeEntryId, entry?.feedId, isCollection, isPendingEntry, isLoggedIn])\n\n  const isInteracted = useRef(false)\n\n  const handleMarkReadInRange = useEntryMarkReadHandler(entriesIds)\n\n  const handleScroll = useCallback(() => {\n    if (!isInteracted.current) {\n      isInteracted.current = true\n    }\n\n    if (!routeFeedId) return\n\n    const [first, second] = rangeQueueRef.current\n    if (first && second && second.startIndex - first.startIndex > 0) {\n      handleMarkReadInRange?.(\n        {\n          startIndex: first.startIndex,\n          endIndex: second.startIndex,\n        } as Range,\n        isInteracted.current,\n      )\n    }\n  }, [handleMarkReadInRange, routeFeedId])\n\n  const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond()\n  const handleCombinedScroll = useCallback(\n    (e: React.UIEvent<HTMLDivElement>) => {\n      handleScrollBeyond(e)\n      handleScroll()\n    },\n    [handleScrollBeyond, handleScroll],\n  )\n\n  const navigate = useNavigateEntry()\n\n  const rangeQueueRef = useRef<Range[]>([])\n  const isRefreshing = state.isFetching && !state.isFetchingNextPage\n  const aiTimelineEnabled = useAtomValue(aiTimelineEnabledAtom)\n  const showAiTimelineLoading = aiTimelineEnabled && state.isLoading && !state.isFetchingNextPage\n  const renderAsRead = useGeneralSettingKey(\"renderMarkUnread\")\n  const handleRangeChange = useCallback(\n    (e: Range) => {\n      const [_, second] = rangeQueueRef.current\n      if (second?.startIndex === e.startIndex) {\n        return\n      }\n      rangeQueueRef.current.push(e)\n      if (rangeQueueRef.current.length > 2) {\n        rangeQueueRef.current.shift()\n      }\n\n      if (!renderAsRead) return\n      if (!getView(view)?.wideMode) {\n        return\n      }\n      // For gird, render as mark read logic\n      handleMarkReadInRange?.(e, isInteracted.current)\n    },\n    [handleMarkReadInRange, renderAsRead, view],\n  )\n\n  const fetchNextPage = useCallback(() => {\n    if (state.hasNextPage && !state.isFetchingNextPage) {\n      actions.fetchNextPage()\n    }\n  }, [actions, state.hasNextPage, state.isFetchingNextPage])\n\n  const ListComponent = getView(view)?.gridMode ? EntryColumnGrid : EntryList\n\n  useNavigateFirstEntry(entriesIds, activeEntryId, view, navigate)\n\n  return (\n    <Focusable\n      scope={HotkeyScope.Timeline}\n      data-hide-in-print\n      className=\"relative flex h-full flex-1 flex-col @container\"\n      onClick={() =>\n        navigate({\n          view,\n          entryId: null,\n        })\n      }\n    >\n      {entriesIds.length === 0 &&\n        !state.isLoading &&\n        !state.error &&\n        (!feed || feed?.type === \"feed\") && <AddFeedHelper />}\n\n      <EntryListHeader refetch={actions.refetch} isRefreshing={isRefreshing} />\n\n      <EntryColumnWrapper onScroll={handleCombinedScroll} key={`${routeFeedId}-${view}`}>\n        {entriesIds.length === 0 ? (\n          state.isLoading ? (\n            <EntryItemSkeleton view={view} />\n          ) : (\n            <EntryEmptyList />\n          )\n        ) : (\n          <ListComponent\n            gap={view === FeedViewType.SocialMedia ? 10 : undefined}\n            listRef={listRef}\n            onRangeChange={handleRangeChange}\n            hasNextPage={state.hasNextPage}\n            view={view}\n            feedId={routeFeedId || \"\"}\n            entriesIds={entriesIds}\n            fetchNextPage={fetchNextPage}\n            refetch={actions.refetch}\n            groupCounts={groupedCounts}\n            syncType={state.type}\n            Footer={\n              isCollection ? void 0 : <FooterMarkItem view={view} fetchedTime={state.fetchedTime} />\n            }\n          />\n        )}\n      </EntryColumnWrapper>\n\n      <AITimelineLoadingOverlay\n        visible={showAiTimelineLoading}\n        label={t(\"entry_list_header.ai_timeline_loading\")}\n      />\n    </Focusable>\n  )\n}\n\nfunction EntryColumnImpl() {\n  return (\n    <EntryRootStateContext\n      value={useMemo(\n        () => ({\n          isScrolledBeyondThreshold: atom(false),\n        }),\n        [],\n      )}\n    >\n      <EntryColumnContent />\n    </EntryRootStateContext>\n  )\n}\n\nconst AddFeedHelper = () => {\n  const feedId = useRouteParamsSelector((s) => s.feedId)\n  const feedQuery = useFeedQuery({ id: feedId })\n\n  const hasSubscription = useSubscriptionByFeedId(feedId || \"\")\n\n  if (hasSubscription) {\n    return null\n  }\n\n  if (!feedId) {\n    return\n  }\n  if (feedId === FEED_COLLECTION_LIST || feedId === ROUTE_FEED_PENDING) {\n    return null\n  }\n  if (!isBizId(feedId)) {\n    return null\n  }\n\n  if (feedQuery.error && feedQuery.error.statusCode === 404) {\n    throw new FeedNotFound()\n  }\n}\n\nexport const EntryColumn = memo(EntryColumnImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/item-stateless.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport type { FC } from \"react\"\nimport { memo } from \"react\"\n\nimport { AllItemStateLess } from \"./Items/all-item\"\nimport { ArticleItemStateLess } from \"./Items/article-item\"\nimport { NotificationItemStateLess } from \"./Items/notification-item\"\nimport { PictureItemStateLess } from \"./Items/picture-item-stateless\"\nimport { SocialMediaItemStateLess } from \"./Items/social-media-item\"\nimport { VideoItemStateLess } from \"./Items/video-item\"\nimport type { EntryItemStatelessProps } from \"./types\"\n\nconst StatelessItemMap = {\n  [FeedViewType.All]: AllItemStateLess,\n  [FeedViewType.Articles]: ArticleItemStateLess,\n  [FeedViewType.SocialMedia]: SocialMediaItemStateLess,\n  [FeedViewType.Pictures]: PictureItemStateLess,\n  [FeedViewType.Videos]: VideoItemStateLess,\n  [FeedViewType.Audios]: ArticleItemStateLess,\n  [FeedViewType.Notifications]: NotificationItemStateLess,\n}\n\nconst getStatelessItemComponentByView = (view: FeedViewType) => {\n  return StatelessItemMap[view] || ArticleItemStateLess\n}\n\nexport const EntryItemStateless: FC<EntryItemStatelessProps> = memo((props) => {\n  const Item = getStatelessItemComponentByView(props.view as FeedViewType)\n\n  return <Item {...props} />\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/item.tsx",
    "content": "import { FeedViewType, isFreeRole } from \"@follow/constants\"\nimport { useHasEntry } from \"@follow/store/entry/hooks\"\nimport { useEntryTranslation, usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { FC } from \"react\"\nimport { memo } from \"react\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"~/atoms/settings/general\"\n\nimport { getItemComponentByView } from \"./Items/getItemComponentByView\"\nimport { EntryItemWrapper } from \"./layouts/EntryItemWrapper\"\nimport type { EntryListItemFC } from \"./types\"\n\ninterface EntryItemProps {\n  entryId: string\n  view: FeedViewType\n  currentFeedTitle?: string\n  isFirstItem?: boolean\n}\nconst EntryItemImpl = memo(function EntryItemImpl({\n  entryId,\n  view,\n  currentFeedTitle,\n  isFirstItem,\n}: EntryItemProps) {\n  const enableTranslation = useGeneralSettingKey(\"translation\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const actionLanguage = useActionLanguage()\n  const userRole = useUserRole()\n  const shouldPrefetchTranslation = enableTranslation && !isFreeRole(userRole)\n  const translation = useEntryTranslation({\n    entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n  usePrefetchEntryTranslation({\n    entryIds: [entryId],\n    enabled: shouldPrefetchTranslation,\n    language: actionLanguage,\n    withContent: view === FeedViewType.SocialMedia,\n    mode: translationMode,\n  })\n\n  const Item: EntryListItemFC = getItemComponentByView(view)\n\n  return (\n    <EntryItemWrapper\n      itemClassName={Item.wrapperClassName}\n      entryId={entryId}\n      view={view}\n      isFirstItem={isFirstItem}\n    >\n      <Item entryId={entryId} translation={translation} currentFeedTitle={currentFeedTitle} />\n    </EntryItemWrapper>\n  )\n})\n\nexport const EntryItem: FC<EntryItemProps> = memo(({ entryId, view, currentFeedTitle }) => {\n  const hasEntry = useHasEntry(entryId)\n\n  if (!hasEntry) return null\n  return <EntryItemImpl entryId={entryId} view={view} currentFeedTitle={currentFeedTitle} />\n})\n\nexport const EntryVirtualListItem = ({\n  ref,\n  entryId,\n  view,\n  className,\n  currentFeedTitle,\n  ...props\n}: EntryItemProps &\n  React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {\n    ref?: React.Ref<HTMLDivElement | null>\n  }) => {\n  const hasEntry = useHasEntry(entryId)\n\n  if (!hasEntry) return <div ref={ref} {...props} style={undefined} />\n\n  const isFirstItem = props[\"data-index\"] === 0\n\n  return (\n    <div className=\"absolute left-0 top-0 w-full will-change-transform\" ref={ref} {...props}>\n      <EntryItemImpl\n        entryId={entryId}\n        view={view}\n        currentFeedTitle={currentFeedTitle}\n        isFirstItem={isFirstItem}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/layouts/AppendTaildingDivider.tsx",
    "content": "import { DividerVertical } from \"@follow/components/ui/divider/Divider.js\"\nimport * as React from \"react\"\n\nexport const AppendTaildingDivider = ({ children }: { children: React.ReactNode }) => (\n  <>\n    {children}\n    {React.Children.toArray(children).filter(Boolean).length > 0 && (\n      <DividerVertical className=\"mx-2 w-px\" />\n    )}\n  </>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { getMousePosition } from \"@follow/components/hooks/useMouse.js\"\nimport { ActionButton } from \"@follow/components/ui/button/action-button.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { cn } from \"@follow/utils/utils\"\nimport { AnimatePresence } from \"motion/react\"\nimport type { FC, MouseEvent, PropsWithChildren, TouchEvent } from \"react\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\nimport { NavLink } from \"react-router\"\nimport { useDebounceCallback } from \"usehooks-ts\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { CommandActionButton } from \"~/components/ui/button/CommandActionButton\"\nimport { useEntryIsRead } from \"~/hooks/biz/useAsRead\"\nimport { useContextMenuActionShortCutTrigger } from \"~/hooks/biz/useContextMenuActionShortCutTrigger\"\nimport {\n  EntryActionMenuItem,\n  HIDE_ACTIONS_IN_ENTRY_TOOLBAR_ACTIONS,\n  useEntryActions,\n  useSortedEntryActions,\n} from \"~/hooks/biz/useEntryActions\"\nimport { useEntryContextMenu } from \"~/hooks/biz/useEntryContextMenu\"\nimport { getNavigateEntryPath, useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams, useRouteParams, useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useShowEntryDetailsColumn } from \"~/hooks/biz/useShowEntryDetailsColumn\"\nimport { useFeedSafeUrl } from \"~/hooks/common/useFeedSafeUrl\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\n\nexport const EntryItemWrapper: FC<\n  {\n    entryId: string\n    view: FeedViewType\n    isFirstItem?: boolean\n    itemClassName?: string\n    style?: React.CSSProperties\n  } & PropsWithChildren\n> = ({ entryId, view, children, itemClassName, style, isFirstItem }) => {\n  const entry = useEntry(entryId, (state) => {\n    const { feedId, inboxHandle } = state\n\n    const { id, url } = state\n    return { feedId, id, inboxId: inboxHandle, url }\n  })\n  const actionConfigs = useEntryActions({ entryId, view })\n  const isMobile = useMobile()\n\n  const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id, [entry?.id])\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isTimeline)\n  useContextMenuActionShortCutTrigger(actionConfigs, isActive && when)\n  const showEntryDetailsColumn = useShowEntryDetailsColumn()\n\n  const asRead = useEntryIsRead(entryId)\n  const hoverMarkUnread = useGeneralSettingKey(\"hoverMarkUnread\")\n\n  const [showAction, setShowAction] = useState(false)\n  const handleMouseEnterMarkRead = useDebounceCallback(\n    () => {\n      if (!hoverMarkUnread) return\n      if (!document.hasFocus()) return\n      if (asRead) return\n      if (!entry?.feedId) return\n\n      unreadSyncService.markEntryAsRead(entry.id)\n    },\n    233,\n    {\n      leading: false,\n    },\n  )\n\n  const handleMouseEnter = useMemo(() => {\n    return () => {\n      setShowAction(true)\n      handleMouseEnterMarkRead()\n    }\n  }, [handleMouseEnterMarkRead])\n  const handleMouseLeave = useMemo(() => {\n    return (e: React.MouseEvent) => {\n      handleMouseEnterMarkRead.cancel()\n      // If the mouse is over the action bar, don't hide the action bar\n      const { relatedTarget, currentTarget } = e\n      if (relatedTarget && relatedTarget instanceof Node && currentTarget.contains(relatedTarget)) {\n        return\n      }\n      setShowAction(false)\n    }\n  }, [handleMouseEnterMarkRead])\n\n  const isDropdownMenuOpen = useGlobalFocusableScopeSelector(\n    FocusablePresets.isNotFloatingLayerScope,\n  )\n\n  useEffect(() => {\n    // Hide the action bar when dropdown menu is open and click outside\n    if (isDropdownMenuOpen) {\n      setShowAction(false)\n    }\n  }, [isDropdownMenuOpen])\n\n  const navigate = useNavigateEntry()\n  const navigationPath = useMemo(() => {\n    if (!entry?.id) return \"#\"\n    return getNavigateEntryPath({\n      entryId: entry?.id,\n    })\n  }, [entry?.id])\n\n  const populatedFullHref = useFeedSafeUrl(entryId)\n\n  const handleDoubleClick = useCallback(\n    (e: MouseEvent<HTMLElement>) => {\n      e.preventDefault()\n      e.stopPropagation()\n      if (!entry?.url) return\n      if (!entry?.id) return\n\n      if (!populatedFullHref) return\n      window.open(populatedFullHref, \"_blank\", \"noopener,noreferrer\")\n    },\n    [entry?.id, entry?.url, populatedFullHref],\n  )\n\n  const handleClick = useCallback(\n    (e: TouchEvent<HTMLElement> | MouseEvent<HTMLElement>) => {\n      e.preventDefault()\n      e.stopPropagation()\n\n      const shouldNavigate = getRouteParams().entryId !== entry?.id\n\n      if (!shouldNavigate) return\n      if (!entry?.feedId) return\n      if (!asRead) {\n        unreadSyncService.markEntryAsRead(entry.id)\n      }\n\n      navigate({\n        view,\n        entryId: entry.id,\n      })\n    },\n    [asRead, entry?.id, entry?.feedId, navigate, view],\n  )\n  const { contextMenuProps, isContextMenuOpen, openContextMenuAt } = useEntryContextMenu({\n    entryId,\n    view,\n    feedId: entry?.feedId || entry?.inboxId || \"\",\n  })\n\n  const isWide = !showEntryDetailsColumn\n\n  const Link = view === FeedViewType.SocialMedia ? \"article\" : NavLink\n  const isAll = view === FeedViewType.All\n  return (\n    <div\n      data-entry-id={entry?.id}\n      data-read={asRead ? \"true\" : \"false\"}\n      data-active={isActive ? \"true\" : \"false\"}\n      style={style}\n    >\n      <Link\n        to={navigationPath}\n        className={cn(\n          \"relative block cursor-button overflow-visible duration-200 hover:bg-theme-item-hover\",\n          !isWide ? \"rounded-none @[650px]:rounded-md\" : \"rounded-md\",\n          isAll && \"!rounded-none\",\n          (isActive || isContextMenuOpen) && \"!bg-theme-item-active\",\n          itemClassName,\n        )}\n        onClick={handleClick}\n        onDoubleClick={handleDoubleClick}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        {...contextMenuProps}\n        {...(!isMobile ? { onTouchStart: handleClick } : {})}\n      >\n        {children}\n        <AnimatePresence>\n          {showAction && isWide && (\n            <ActionBar\n              openContextMenu={() => {\n                const { x, y } = getMousePosition()\n                void openContextMenuAt(x, y)\n              }}\n              entryId={entryId}\n              isFirstItem={!!isFirstItem}\n            />\n          )}\n        </AnimatePresence>\n      </Link>\n    </div>\n  )\n}\n\nconst ActionBar = ({\n  entryId,\n  openContextMenu,\n  isFirstItem,\n}: {\n  entryId: string\n  openContextMenu: () => void\n  isFirstItem: boolean\n}) => {\n  const { view } = useRouteParams()\n\n  const { mainAction } = useSortedEntryActions({ entryId, view })\n  const { withLoginGuard } = useRequireLogin()\n\n  return (\n    <div\n      className={cn(\n        \"absolute -right-2 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900\",\n        isFirstItem && \"-right-2 top-4\",\n        view === FeedViewType.All && \"right-1 top-1/2\",\n      )}\n      onClick={(e) => {\n        e.stopPropagation()\n        e.preventDefault()\n      }}\n    >\n      <div className=\"flex items-center gap-1\">\n        {(\n          mainAction.filter(\n            (item) =>\n              item instanceof EntryActionMenuItem &&\n              !HIDE_ACTIONS_IN_ENTRY_TOOLBAR_ACTIONS.includes(item.id),\n          ) as EntryActionMenuItem[]\n        ).map((item) => {\n          const handler = item.requiresLogin ? withLoginGuard(item.onClick) : item.onClick\n          return (\n            <CommandActionButton key={item.id} onClick={handler} size=\"xs\" commandId={item.id} />\n          )\n        })}\n\n        <ActionButton\n          onClick={openContextMenu}\n          size=\"xs\"\n          icon={<i className=\"i-mingcute-more-1-fill\" />}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx",
    "content": "import { ActionButton, MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { DividerVertical } from \"@follow/components/ui/divider/index.js\"\nimport { RotatingRefreshIcon } from \"@follow/components/ui/loading/index.jsx\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { FeedViewType, getView } from \"@follow/constants\"\nimport { useIsOnline } from \"@follow/hooks\"\nimport { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from \"@follow/shared/settings/defaults\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useIsLoggedIn, useWhoami } from \"@follow/store/user/hooks\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { clsx, cn, isBizId } from \"@follow/utils/utils\"\nimport { useAtom, useAtomValue } from \"jotai\"\nimport type { FC } from \"react\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useNavigate } from \"react-router\"\n\nimport { previewBackPath } from \"~/atoms/preview\"\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useSubscriptionColumnShow } from \"~/atoms/sidebar\"\nimport { ROUTE_ENTRY_PENDING } from \"~/constants\"\nimport { useFeature } from \"~/hooks/biz/useFeature\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { getRouteParams, useRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useLoginModal } from \"~/hooks/common\"\nimport { useSendAIShortcut } from \"~/modules/ai-chat/hooks/useSendAIShortcut\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useRunCommandFn } from \"~/modules/command/hooks/use-command\"\nimport { useCommandShortcut } from \"~/modules/command/hooks/use-command-binding\"\nimport { EntryHeader } from \"~/modules/entry-content/components/entry-header\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { useRefreshFeedMutation } from \"~/queries/feed\"\nimport { useFeedHeaderIcon, useFeedHeaderTitle } from \"~/store/feed/hooks\"\n\nimport { aiTimelineEnabledAtom } from \"../atoms/ai-timeline\"\nimport { MarkAllReadButton } from \"../components/mark-all-button\"\nimport { useIsPreviewFeed } from \"../hooks/useIsPreviewFeed\"\nimport { useEntryRootState } from \"../store/EntryColumnContext\"\nimport { AppendTaildingDivider } from \"./AppendTaildingDivider\"\nimport { SwitchToMasonryButton } from \"./buttons/SwitchToMasonryButton\"\n\nexport const EntryListHeader: FC<{\n  refetch: () => void\n  isRefreshing: boolean\n}> = ({ refetch, isRefreshing }) => {\n  const routerParams = useRouteParams()\n  const { t } = useTranslation()\n\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const [aiTimelineEnabled, setAiTimelineEnabled] = useAtom(aiTimelineEnabledAtom)\n  const aiEnabled = useFeature(\"ai\")\n\n  const { feedId, entryId, view, isCollection } = routerParams\n  const isPreview = useIsPreviewFeed()\n  const isWideMode = !!getView(view)?.wideMode\n\n  const headerTitle = useFeedHeaderTitle()\n  const feedIcon = useFeedHeaderIcon()\n\n  const titleInfo = !!headerTitle && (\n    <div\n      className={clsx(\n        \"flex min-w-0 items-center break-all text-lg font-bold leading-tight\",\n        \"-ml-3\",\n      )}\n    >\n      {feedIcon && <FeedIcon target={feedIcon} fallback size={20} className=\"mr-4\" />}\n      <EllipsisHorizontalTextWithTooltip className=\"inline-block !w-auto max-w-full\">\n        {headerTitle}\n      </EllipsisHorizontalTextWithTooltip>\n    </div>\n  )\n  const { mutateAsync: refreshFeed, isPending } = useRefreshFeedMutation(feedId)\n\n  const user = useWhoami()\n  const isOnline = useIsOnline()\n\n  const feed = useFeedById(feedId)\n\n  const titleStyleBasedView = {\n    [FeedViewType.All]: \"pl-7\",\n    [FeedViewType.Articles]: \"pl-7\",\n    [FeedViewType.Pictures]: \"pl-7\",\n    [FeedViewType.Videos]: \"pl-7\",\n    [FeedViewType.SocialMedia]: \"px-5\",\n    [FeedViewType.Audios]: \"pl-6\",\n    [FeedViewType.Notifications]: \"pl-6\",\n  }\n\n  const feedColumnShow = useSubscriptionColumnShow()\n  const toggleUnreadOnlyShortcut = useCommandShortcut(COMMAND_ID.timeline.unreadOnly)\n  const runCmdFn = useRunCommandFn()\n\n  const { isScrolledBeyondThreshold } = useEntryRootState()\n  const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)\n  const { sendAIShortcut } = useSendAIShortcut()\n  const summarizeTimeline = useCallback(() => {\n    void sendAIShortcut({\n      shortcutId: DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID,\n      ensureNewChat: true,\n    })\n  }, [sendAIShortcut])\n  const showEntryHeader = isWideMode && !!entryId && entryId !== ROUTE_ENTRY_PENDING\n  const showTimelineSummaryButton = isWideMode && aiEnabled\n  const showAiTimelineToggle = aiEnabled\n\n  const handleAiTimelineButtonClick = useCallback(() => {\n    setAiTimelineEnabled((prev) => !prev)\n  }, [setAiTimelineEnabled])\n\n  const renderAiTimelineButton = () => {\n    if (!showAiTimelineToggle) return null\n    return (\n      <ActionButton\n        tooltip={t(\"entry_list_header.ai_timeline\")}\n        active={aiTimelineEnabled}\n        onClick={handleAiTimelineButtonClick}\n      >\n        {aiTimelineEnabled ? (\n          <i className=\"i-mgc-refresh-4-ai-cute-re text-purple-600 dark:text-purple-400\" />\n        ) : (\n          <i className=\"i-mgc-refresh-4-ai-cute-re text-purple-600 dark:text-purple-400\" />\n        )}\n      </ActionButton>\n    )\n  }\n\n  const renderTimelineSummaryButton = () => {\n    if (!showTimelineSummaryButton) return null\n    return (\n      <ActionButton tooltip={t(\"entry_list_header.timeline_summary\")} onClick={summarizeTimeline}>\n        <i className=\"i-mgc-paint-brush-ai-cute-re text-purple-600 dark:text-purple-400\" />\n      </ActionButton>\n    )\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full flex-col pr-2.5 pt-2 @[700px]:pr-3 @[1024px]:pr-4\",\n        !feedColumnShow && \"macos:mt-4 macos:pt-margin-macos-traffic-light-y\",\n        titleStyleBasedView[view],\n        isPreview\n          ? \"h-top-header-in-preview-with-border-b px-2.5 @[700px]:px-3 @[1024px]:px-4\"\n          : \"h-top-header-with-border-b\",\n        view === FeedViewType.All &&\n          \"border-b border-transparent data-[scrolled-beyond-threshold=true]:border-b-border\",\n      )}\n      data-scrolled-beyond-threshold={isScrolledBeyondThresholdValue}\n    >\n      <div className={\"flex w-full justify-between\"}>\n        {isPreview ? <PreviewHeaderInfoWrapper>{titleInfo}</PreviewHeaderInfoWrapper> : titleInfo}\n        {!isPreview && (\n          <div\n            className={cn(\n              \"relative z-[1] flex items-center gap-2 self-baseline text-text-secondary\",\n              !headerTitle && \"opacity-0 [&_*]:!pointer-events-none\",\n\n              \"translate-x-[6px]\",\n            )}\n            onClick={stopPropagation}\n          >\n            {isWideMode &&\n              (showEntryHeader || showTimelineSummaryButton || showAiTimelineToggle) && (\n                <>\n                  {showEntryHeader && <EntryHeader entryId={entryId} />}\n                  {(showAiTimelineToggle || showTimelineSummaryButton) && (\n                    <div className=\"flex items-center gap-2\">\n                      {aiTimelineEnabled && renderAiTimelineButton()}\n                      {renderTimelineSummaryButton()}\n                    </div>\n                  )}\n                  <DividerVertical className=\"mx-2 w-px\" />\n                </>\n              )}\n\n            {!isWideMode && aiTimelineEnabled && renderAiTimelineButton()}\n\n            <AppendTaildingDivider>\n              {view === FeedViewType.Pictures && <SwitchToMasonryButton />}\n            </AppendTaildingDivider>\n\n            {isOnline &&\n              (feed?.ownerUserId === user?.id &&\n              isBizId(routerParams.feedId!) &&\n              feed?.type === \"feed\" ? (\n                <ActionButton\n                  tooltip=\"Refresh\"\n                  onClick={() => {\n                    refreshFeed()\n                  }}\n                >\n                  <RotatingRefreshIcon isRefreshing={isPending} />\n                </ActionButton>\n              ) : (\n                <ActionButton\n                  tooltip={t(\"entry_list_header.refetch\")}\n                  onClick={() => {\n                    refetch()\n                  }}\n                >\n                  <RotatingRefreshIcon isRefreshing={isRefreshing} />\n                </ActionButton>\n              ))}\n            {!isCollection && (\n              <>\n                <ActionButton\n                  tooltip={\n                    !unreadOnly\n                      ? t(\"entry_list_header.show_unread_only\")\n                      : t(\"entry_list_header.show_all\")\n                  }\n                  shortcut={toggleUnreadOnlyShortcut}\n                  onClick={() => runCmdFn(COMMAND_ID.timeline.unreadOnly, [!unreadOnly])()}\n                >\n                  {unreadOnly ? (\n                    <i className=\"i-mgc-round-cute-fi\" />\n                  ) : (\n                    <i className=\"i-mgc-round-cute-re\" />\n                  )}\n                </ActionButton>\n                <MarkAllReadButton shortcut />\n              </>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nconst PreviewHeaderInfoWrapper: Component = ({ children }) => {\n  const { t: tCommon } = useTranslation(\"common\")\n  const follow = useFollow()\n\n  const navigate = useNavigate()\n  const isLoggedIn = useIsLoggedIn()\n  const presentLoginModal = useLoginModal()\n\n  return (\n    <div className=\"flex w-full flex-col pt-1.5\">\n      <div className=\"grid w-full grid-cols-[1fr_auto_1fr] items-center gap-2\">\n        <MotionButtonBase\n          onClick={(e) => {\n            e.stopPropagation()\n            navigate(previewBackPath() || \"/\")\n          }}\n          className=\"no-drag-region mr-1 inline-flex items-center gap-1 whitespace-nowrap duration-200 hover:text-accent\"\n        >\n          <i className=\"i-mingcute-left-line\" />\n          <span className=\"text-sm font-medium\">{tCommon(\"words.back\")}</span>\n        </MotionButtonBase>\n        {children}\n        <div />\n      </div>\n\n      <button\n        type=\"button\"\n        className=\"-mx-4 mt-3.5 flex animate-gradient-x cursor-button place-items-center justify-center gap-1 bg-gradient-to-r from-accent/10 via-accent/15 to-accent/20 px-3 py-2 font-semibold text-accent transition-all duration-300 hover:bg-accent hover:text-white\"\n        onClick={() => {\n          if (!isLoggedIn) {\n            presentLoginModal()\n            return\n          }\n          const { feedId, listId } = getRouteParams()\n          const feed = getFeedById(feedId)\n          follow({\n            isList: !!listId,\n            id: listId ?? feedId,\n            url: feed?.type === \"feed\" ? feed.url : undefined,\n          })\n        }}\n      >\n        <i className=\"i-mgc-add-cute-fi size-4\" />\n        {tCommon(\"words.follow\")}\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/layouts/buttons/SwitchToMasonryButton.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.js\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardPortal,\n  HoverCardTrigger,\n} from \"@radix-ui/react-hover-card\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setUISetting, useUISettingKey } from \"~/atoms/settings/ui\"\n\nexport const SwitchToMasonryButton = () => {\n  const isMasonry = useUISettingKey(\"pictureViewMasonry\")\n  const isImageOnly = useUISettingKey(\"pictureViewImageOnly\")\n  const { t } = useTranslation()\n  const isMobile = useMobile()\n\n  if (isMobile) return null\n  return (\n    <HoverCard openDelay={100}>\n      <HoverCardTrigger>\n        <ActionButton>\n          <i className={cn(!isMasonry ? \"i-mgc-grid-cute-re\" : \"i-mgc-grid-2-cute-re\")} />\n        </ActionButton>\n      </HoverCardTrigger>\n      <HoverCardPortal>\n        <HoverCardContent\n          sideOffset={12}\n          side=\"bottom\"\n          className={clsx(\n            \"z-10 rounded-xl border bg-background drop-shadow\",\n            \"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n            \"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n            \"data-[state=closed]:slide-out-to-top-5 data-[state=open]:slide-in-from-top-5\",\n            \"data-[state=closed]:slide-in-from-top-0 data-[state=open]:slide-in-from-top-0\",\n            \"transition-all duration-200 ease-in-out\",\n            \"p-3\",\n          )}\n        >\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"flex items-center\">\n              <label className=\"mr-2 w-[120px] text-sm\">\n                {t(\"entry_list_header.preview_mode\")}\n              </label>\n              <SegmentGroup\n                className=\"h-8\"\n                value={isMasonry ? \"masonry\" : \"grid\"}\n                onValueChanged={(v) => {\n                  setUISetting(\"pictureViewMasonry\", v === \"masonry\")\n                }}\n              >\n                <SegmentItem\n                  key=\"Grid\"\n                  value=\"grid\"\n                  label={\n                    <div className=\"flex items-center gap-1 text-sm\">\n                      <i className=\"i-mgc-grid-cute-re\" />\n                      <span>{t(\"entry_list_header.grid\")}</span>\n                    </div>\n                  }\n                />\n                <SegmentItem\n                  key=\"Masonry\"\n                  value=\"masonry\"\n                  label={\n                    <div className=\"flex items-center gap-1 text-sm\">\n                      <i className=\"i-mgc-grid-2-cute-re\" />\n                      <span>{t(\"entry_list_header.masonry\")}</span>\n                    </div>\n                  }\n                />\n              </SegmentGroup>\n            </div>\n            <div className=\"flex items-center justify-between\">\n              <label className=\"mr-2 w-[120px] text-sm\">{t(\"entry_list_header.image_only\")}</label>\n              <Switch\n                checked={isImageOnly}\n                onCheckedChange={(checked) => {\n                  setUISetting(\"pictureViewImageOnly\", checked)\n                }}\n              />\n            </div>\n          </div>\n        </HoverCardContent>\n      </HoverCardPortal>\n    </HoverCard>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/list.tsx",
    "content": "import { EmptyIcon } from \"@follow/components/icons/empty.jsx\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { LRUCache } from \"@follow/utils/lru-cache\"\nimport type { Range, VirtualItem, Virtualizer } from \"@tanstack/react-virtual\"\nimport { defaultRangeExtractor, useVirtualizer } from \"@tanstack/react-virtual\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport type { FC, MutableRefObject, ReactNode } from \"react\"\nimport { memo, startTransition, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { m } from \"~/components/common/Motion\"\nimport { useFeedHeaderTitle } from \"~/store/feed/hooks\"\n\nimport { VirtualRowItem } from \"./components/VirtualRowItem\"\nimport { EntryColumnShortcutHandler } from \"./EntryColumnShortcutHandler\"\nimport { EntryItemSkeleton } from \"./EntryItemSkeleton\"\n\nexport const EntryEmptyList = ({\n  ref,\n  ...props\n}: HTMLMotionProps<\"div\"> & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const { t } = useTranslation()\n  return (\n    <m.div\n      className=\"absolute -mt-6 flex size-full grow flex-col items-center justify-center gap-2 text-zinc-400\"\n      {...props}\n      ref={ref}\n    >\n      {unreadOnly ? (\n        <>\n          <i className=\"i-mgc-celebrate-cute-re -mt-11 text-3xl\" />\n          <span className=\"text-base\">{t(\"entry_list.zero_unread\")}</span>\n        </>\n      ) : (\n        <div className=\"flex -translate-y-6 flex-col items-center justify-center gap-2\">\n          <EmptyIcon className=\"size-[30px]\" />\n          <span className=\"text-base\">{t(\"words.zero_items\")}</span>\n        </div>\n      )}\n    </m.div>\n  )\n}\n\nexport type EntryListProps = {\n  syncType: \"remote\" | \"local\"\n  feedId: string\n  entriesIds: string[]\n  view: FeedViewType\n\n  hasNextPage: boolean\n  fetchNextPage: () => void\n  refetch: () => void\n\n  groupCounts?: number[]\n  gap?: number\n\n  Footer?: FC | ReactNode\n\n  onRangeChange?: (range: Range) => void\n\n  listRef?: MutableRefObject<Virtualizer<HTMLElement, Element> | undefined>\n}\n\nconst capacity = 3\nconst offsetCache = new LRUCache<string, number>(capacity)\nconst measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)\n// Prevent scroll list move when press up/down key, the up/down key should be taken over by the shortcut key we defined.\nconst handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {\n  if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n    e.preventDefault()\n  }\n}\nexport const EntryList: FC<EntryListProps> = memo(\n  ({\n    feedId,\n    view,\n    entriesIds,\n    fetchNextPage,\n    refetch,\n    hasNextPage,\n    groupCounts,\n    Footer,\n    listRef,\n    onRangeChange,\n    gap,\n    syncType,\n  }) => {\n    const scrollRef = useScrollViewElement()\n\n    const stickyIndexes = useMemo(\n      () =>\n        groupCounts\n          ? groupCounts.reduce(\n              (acc, count, index) => {\n                acc[index + 1] = acc[index]! + count\n                return acc\n              },\n              [0],\n            )\n          : [],\n      [groupCounts],\n    )\n\n    const cacheKey = `${view}-${feedId}`\n    const rowVirtualizer = useVirtualizer({\n      count: entriesIds.length + 1,\n      estimateSize: () => 112,\n      overscan: 5,\n      gap,\n      getScrollElement: () => scrollRef,\n      initialOffset: offsetCache.get(cacheKey) ?? 0,\n      initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [],\n      onChange: useTypeScriptHappyCallback(\n        (virtualizer: Virtualizer<HTMLElement, Element>) => {\n          if (!virtualizer.isScrolling) {\n            measurementsCache.put(cacheKey, virtualizer.measurementsCache)\n            offsetCache.put(cacheKey, virtualizer.scrollOffset ?? 0)\n          }\n\n          onRangeChange?.(virtualizer.range as Range)\n        },\n        [cacheKey],\n      ),\n      rangeExtractor: useTypeScriptHappyCallback(\n        (range: Range) => {\n          activeStickyIndexRef.current =\n            [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0\n\n          const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])\n\n          return [...next].sort((a, b) => a - b)\n        },\n        [stickyIndexes],\n      ),\n    })\n\n    useEffect(() => {\n      if (!listRef) return\n      listRef.current = rowVirtualizer\n    }, [rowVirtualizer, listRef])\n\n    const handleScrollTo = useEventCallback((index: number) => {\n      rowVirtualizer.scrollToIndex(index)\n    })\n\n    const activeStickyIndexRef = useRef(0)\n    const checkIsActiveSticky = (index: number) => activeStickyIndexRef.current === index\n    const checkIsStickyItem = (index: number) => stickyIndexes.includes(index)\n\n    const virtualItems = rowVirtualizer.getVirtualItems()\n    useEffect(() => {\n      const lastItem = virtualItems.at(-1)\n\n      if (!lastItem) {\n        return\n      }\n\n      const isPlaceholderRow = lastItem.index === entriesIds.length\n\n      if (isPlaceholderRow && hasNextPage) {\n        fetchNextPage()\n      }\n    }, [entriesIds.length, fetchNextPage, hasNextPage, virtualItems, syncType])\n\n    const [isScrollTop, setIsScrollTop] = useState(true)\n\n    useEffect(() => {\n      const $scrollRef = scrollRef\n      if (!$scrollRef) return\n      const handleScroll = () => {\n        setIsScrollTop($scrollRef.scrollTop <= 0)\n      }\n      $scrollRef.addEventListener(\"scroll\", handleScroll)\n\n      return () => {\n        $scrollRef.removeEventListener(\"scroll\", handleScroll)\n      }\n    }, [scrollRef])\n\n    const [ready, setReady] = useState(false)\n\n    useEffect(() => {\n      startTransition(() => {\n        setReady(true)\n      })\n    }, [])\n\n    const currentFeedTitle = useFeedHeaderTitle()!\n\n    return (\n      <>\n        <div\n          onKeyDown={handleKeyDown}\n          className={\"relative w-full select-none\"}\n          style={{\n            height: `${rowVirtualizer.getTotalSize()}px`,\n          }}\n        >\n          {rowVirtualizer.getVirtualItems().map((virtualRow) => {\n            if (!ready) return null\n            // Last placeholder row\n            const isLoaderRow = virtualRow.index === entriesIds.length\n\n            const transform = `translateY(${virtualRow.start}px)`\n            if (isLoaderRow) {\n              const Content = hasNextPage ? (\n                <EntryItemSkeleton view={view} count={6} />\n              ) : Footer ? (\n                typeof Footer === \"function\" ? (\n                  <Footer />\n                ) : (\n                  Footer\n                )\n              ) : null\n\n              return (\n                <div\n                  ref={rowVirtualizer.measureElement}\n                  className=\"absolute left-0 top-0 w-full will-change-transform\"\n                  key={virtualRow.key}\n                  data-index={virtualRow.index}\n                  style={{\n                    transform,\n                  }}\n                >\n                  {Content}\n                </div>\n              )\n            }\n            const isStickyItem = checkIsStickyItem(virtualRow.index)\n            const isActiveStickyItem = !isScrollTop && checkIsActiveSticky(virtualRow.index)\n            return (\n              <VirtualRowItem\n                key={virtualRow.key}\n                virtualRowKey={virtualRow.key}\n                entriesIds={entriesIds}\n                virtualRowIndex={virtualRow.index}\n                view={view}\n                transform={transform}\n                isStickyItem={isStickyItem}\n                isActiveStickyItem={isActiveStickyItem}\n                measureElement={rowVirtualizer.measureElement}\n                currentFeedTitle={currentFeedTitle}\n              />\n            )\n          })}\n        </div>\n\n        <EntryColumnShortcutHandler\n          refetch={refetch}\n          data={entriesIds}\n          handleScrollTo={handleScrollTo}\n        />\n      </>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/star-icon.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nexport const StarIcon: Component = ({ className }) => (\n  <i className={cn(\"i-mgc-star-cute-fi text-base text-orange-400\", className)} />\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/store/EntryColumnContext.ts",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext, use } from \"react\"\n\nexport type EntryRootStateContext = {\n  isScrolledBeyondThreshold: PrimitiveAtom<boolean>\n}\n\nexport const EntryRootStateContext = createContext<EntryRootStateContext>(null!)\n\nexport const useEntryRootState = () => {\n  const context = use(EntryRootStateContext)\n  if (!context) {\n    throw new Error(\"useEntryRootState must be used within a EntryRootStateContext\")\n  }\n  return context\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/styles.ts",
    "content": "import { readableContentMaxWidthClassName } from \"~/constants/ui\"\n\nexport const girdClassNames = tw`grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 gap-1.5`\n\n// Shared max-width styles for readable content\nexport const readableContentMaxWidth = tw`${readableContentMaxWidthClassName} mx-auto px-3`\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/templates/grid-item-template.tsx",
    "content": "import { TitleMarquee } from \"@follow/components/ui/marquee/index.jsx\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { useEntry, useHasEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport dayjs from \"dayjs\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { useEntryIsRead } from \"~/hooks/biz/useAsRead\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { EntryTranslation } from \"~/modules/entry-column/translation\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\nimport { StarIcon } from \"../star-icon\"\nimport type { UniversalItemProps } from \"../types\"\n\ninterface GridItemProps extends UniversalItemProps {\n  children?: React.ReactNode\n  wrapperClassName?: string\n}\nexport function GridItem(props: GridItemProps) {\n  const { entryId, wrapperClassName, children, translation } = props\n  const hasEntry = useHasEntry(entryId)\n\n  if (!hasEntry) return null\n  return (\n    <div className={cn(\"p-1.5\", wrapperClassName)}>\n      {children}\n      <GridItemFooter entryId={entryId} translation={translation} />\n    </div>\n  )\n}\n\nexport const GridItemFooter = ({\n  entryId,\n  translation,\n  titleClassName,\n  descriptionClassName,\n  timeClassName,\n}: Pick<GridItemProps, \"entryId\" | \"translation\"> & {\n  titleClassName?: string\n  descriptionClassName?: string\n  timeClassName?: string\n}) => {\n  const entry = useEntry(entryId, (state) => {\n    /// keep-sorted\n    const { authorAvatar, feedId, publishedAt, title } = state\n\n    const media = state.media || []\n    const photo = media.find((a) => a.type === \"photo\")\n    const firstPhotoUrl = photo?.url\n\n    /// keep-sorted\n    return {\n      authorAvatar,\n      feedId,\n      firstPhotoUrl,\n      publishedAt,\n      title,\n    }\n  })\n\n  const isInCollection = useIsEntryStarred(entryId)\n\n  const feeds = useFeedById(entry?.feedId)\n\n  const asRead = useEntryIsRead(entryId)\n\n  const iconEntry: FeedIconEntry = useMemo(\n    () => ({\n      firstPhotoUrl: entry?.firstPhotoUrl,\n      authorAvatar: entry?.authorAvatar,\n    }),\n    [entry?.firstPhotoUrl, entry?.authorAvatar],\n  )\n\n  const { t } = useTranslation(\"common\")\n\n  const isImageOnly = useUISettingKey(\"pictureViewImageOnly\")\n  const view = useRouteParamsSelector(({ view }) => view)\n  const shouldHideFooter = view === FeedViewType.Pictures && isImageOnly\n  if (shouldHideFooter) return null\n\n  if (!entry) return null\n  return (\n    <div className={cn(\"relative px-2 text-sm\")}>\n      <div className=\"flex items-center\">\n        <div\n          className={cn(\n            \"mr-1 size-1.5 shrink-0 self-center rounded-full bg-accent duration-200\",\n            asRead && \"mr-0 w-0\",\n          )}\n        />\n        <div\n          className={cn(\n            \"relative mb-1 mt-1.5 flex w-full items-center gap-1 truncate font-medium\",\n            titleClassName,\n          )}\n        >\n          <TitleMarquee className=\"min-w-0 grow\">\n            <EntryTranslation source={entry?.title} target={translation?.title} />\n          </TitleMarquee>\n          {isInCollection && (\n            <div className=\"h-0 shrink-0 -translate-y-2\">\n              <StarIcon />\n            </div>\n          )}\n        </div>\n      </div>\n      <div className=\"flex items-center gap-1 truncate text-[13px]\">\n        <FeedIcon fallback noMargin className=\"flex\" target={feeds} entry={iconEntry} size={18} />\n        <span className={cn(\"min-w-0 truncate pl-1\", descriptionClassName)}>\n          <FeedTitle feed={feeds} />\n        </span>\n        <span className={cn(\"text-zinc-500\", timeClassName)}>·</span>\n        <span className={cn(\"text-zinc-500\", timeClassName)}>\n          {dayjs.duration(dayjs(entry?.publishedAt).diff(dayjs(), \"minute\"), \"minute\").humanize()}\n          {t(\"space\")}\n          {t(\"words.ago\")}\n        </span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { useCollectionEntry, useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\nimport { titleCase } from \"title-case\"\n\nimport { AudioPlayer, useAudioPlayerAtomSelector } from \"~/atoms/player\"\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport { FEED_COLLECTION_LIST } from \"~/constants\"\nimport { useEntryIsRead } from \"~/hooks/biz/useAsRead\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { EntryTranslation } from \"~/modules/entry-column/translation\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { StarIcon } from \"../star-icon\"\nimport type { UniversalItemProps } from \"../types\"\n\nconst entrySelector = (state: EntryModel) => {\n  /// keep-sorted\n  const { authorAvatar, authorUrl, description, feedId, inboxHandle, publishedAt, title } = state\n\n  const audios = state.attachments?.filter((a) => a.mime_type?.startsWith(\"audio\") && a.url)\n  const firstAudio = audios?.[0]\n  const media = state.media || []\n  const firstMedia = media?.[0]\n  const photo = media.find((a) => a.type === \"photo\")\n  const firstPhotoUrl = photo?.url\n\n  /// keep-sorted\n  return {\n    authorAvatar,\n    authorUrl,\n    description,\n    feedId,\n    firstAudio,\n    firstMedia,\n    firstPhotoUrl,\n    inboxId: inboxHandle,\n    publishedAt,\n    title,\n  }\n}\n\nexport function ListItem({\n  entryId,\n  translation,\n  simple,\n}: UniversalItemProps & {\n  simple?: boolean\n}) {\n  const isMobile = useMobile()\n  const entry = useEntry(entryId, entrySelector)\n\n  const isInCollection = useIsEntryStarred(entryId)\n  const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt\n\n  const isRead = useEntryIsRead(entryId)\n\n  const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST)\n\n  const feed = useFeedById(entry?.feedId, (feed) => {\n    return {\n      type: feed.type,\n      ownerUserId: feed.ownerUserId,\n      id: feed.id,\n      title: feed.title,\n      url: (feed as any).url || \"\",\n      image: feed.image,\n      siteUrl: feed.siteUrl,\n    }\n  })\n\n  const inbox = useInboxById(entry?.inboxId)\n\n  const thumbnailRatio = useUISettingKey(\"thumbnailRatio\")\n  const rid = `list-item-${entryId}`\n\n  const bilingual = useGeneralSettingKey(\"translationMode\") === \"bilingual\"\n\n  const iconEntry: FeedIconEntry = useMemo(\n    () => ({\n      firstPhotoUrl: entry?.firstPhotoUrl,\n      authorAvatar: entry?.authorAvatar,\n    }),\n    [entry?.firstPhotoUrl, entry?.authorAvatar],\n  )\n\n  const titleEntry = useMemo(\n    () => ({\n      authorUrl: entry?.authorUrl,\n    }),\n    [entry?.authorUrl],\n  )\n\n  const lineClamp = useMemo(() => {\n    const envIsSafari = isSafari()\n    let lineClampTitle = 1\n    let lineClampDescription = 2\n\n    if (translation?.title && translation?.title !== entry?.title && !simple && bilingual) {\n      lineClampTitle += 1\n    }\n    if (\n      translation?.description &&\n      translation?.description !== entry?.description &&\n      !simple &&\n      bilingual\n    ) {\n      lineClampDescription += 1\n    }\n\n    // FIXME: Safari bug, not support line-clamp cross elements\n    return {\n      global: !envIsSafari\n        ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]`\n        : \"\",\n      title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : \"\",\n      description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : \"\",\n    }\n  }, [\n    simple,\n    translation?.description,\n    translation?.title,\n    entry?.description,\n    entry?.title,\n    bilingual,\n  ])\n\n  const dimRead = useGeneralSettingKey(\"dimRead\")\n  // NOTE: prevent 0 height element, react virtuoso will not stop render any more\n  if (!entry || !(feed || inbox)) return null\n\n  const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt\n\n  const related = feed || inbox\n\n  const hasAudio = simple ? false : !!entry.firstAudio?.url\n  const hasMedia = simple ? false : !!entry.firstMedia?.url\n\n  const marginWidth = 8 * (isMobile ? 1.125 : 1)\n  // calculate the max width to have a correct truncation\n  // FIXME: this is not easy to maintain, need to refactor\n  const feedIconWidth = 20 + marginWidth\n  const audioCoverWidth = 80 + marginWidth\n  const mediaWidth = 80 * (isMobile ? 1.125 : 1) + marginWidth\n\n  let savedWidth = 0\n\n  savedWidth += feedIconWidth\n\n  if (hasAudio) {\n    savedWidth += audioCoverWidth\n  }\n  if (hasMedia && !hasAudio) {\n    savedWidth += mediaWidth\n  }\n\n  return (\n    <div\n      className={cn(\n        \"group relative flex cursor-menu py-3.5\",\n        !isRead &&\n          \"before:absolute before:-left-3 before:top-5 before:block before:size-2 before:rounded-full before:bg-accent\",\n      )}\n    >\n      <FeedIcon target={related} fallback entry={iconEntry} size={24} />\n      <div\n        className={cn(\"-mt-0.5 ml-1 h-fit flex-1 text-sm leading-tight\", lineClamp.global)}\n        style={{\n          maxWidth: `calc(100% - ${savedWidth}px)`,\n        }}\n      >\n        <div\n          className={cn(\n            \"flex min-w-0 items-center gap-1 text-[10px] font-bold\",\n            \"text-text-secondary\",\n            isInCollection && \"text-text-secondary\",\n            isRead && dimRead && \"text-text-tertiary\",\n          )}\n        >\n          <EllipsisHorizontalTextWithTooltip className=\"truncate\">\n            <FeedTitle\n              feed={related}\n              title={getPreferredTitle(related, titleEntry)}\n              className=\"space-x-0.5\"\n            />\n          </EllipsisHorizontalTextWithTooltip>\n          <span className=\"shrink-0\">·</span>\n          <span className=\"shrink-0\">{!!displayTime && <RelativeTime date={displayTime} />}</span>\n        </div>\n        <div\n          className={cn(\n            \"relative my-0.5 break-words\",\n            \"text-text\",\n            !!isInCollection && \"pr-5\",\n            entry?.title ? \"font-medium\" : \"text-[13px]\",\n            isRead && dimRead && \"text-text-secondary\",\n          )}\n        >\n          {entry?.title ? (\n            <EntryTranslation\n              className={cn(\"autospace-normal hyphens-auto font-medium\", lineClamp.title)}\n              source={titleCase(entry?.title ?? \"\")}\n              target={titleCase(translation?.title ?? \"\")}\n            />\n          ) : (\n            <EntryTranslation\n              className={cn(\"autospace-normal hyphens-auto\", lineClamp.description)}\n              source={entry?.description}\n              target={translation?.description}\n            />\n          )}\n          {!!isInCollection && <StarIcon className=\"absolute right-0 top-0\" />}\n        </div>\n        {!simple && (\n          <div\n            className={cn(\n              \"text-[13px]\",\n              \"text-text-secondary\",\n              isRead && dimRead && \"text-text-tertiary\",\n            )}\n          >\n            <EntryTranslation\n              className={cn(\"autospace-normal hyphens-auto\", lineClamp.description)}\n              source={entry?.description}\n              target={translation?.description}\n            />\n          </div>\n        )}\n      </div>\n\n      {hasAudio && entry.firstAudio && (\n        <AudioCover\n          entryId={entryId}\n          src={entry.firstAudio.url}\n          durationInSeconds={entry.firstAudio.duration_in_seconds}\n          feedIcon={\n            <FeedIcon\n              fallback={true}\n              fallbackElement={\n                <div className={clsx(\"bg-material-ultra-thick\", \"size-[80px]\", \"rounded\")} />\n              }\n              target={feed || inbox}\n              entry={iconEntry}\n              size={80}\n              className=\"m-0 rounded\"\n              useMedia\n              noMargin\n            />\n          }\n        />\n      )}\n\n      {!simple && !hasAudio && entry.firstMedia && (\n        <Media\n          thumbnail\n          src={entry.firstMedia.url}\n          type={entry.firstMedia.type}\n          previewImageUrl={entry.firstMedia.preview_image_url}\n          className={cn(\"center ml-2 flex shrink-0 rounded\", \"size-20\")}\n          mediaContainerClassName={cn(\n            \"size-auto rounded\",\n            thumbnailRatio === \"square\" && \"aspect-square object-cover\",\n          )}\n          loading=\"lazy\"\n          key={`${rid}-media-${thumbnailRatio}`}\n          proxy={{\n            width: 160,\n            height: thumbnailRatio === \"square\" ? 160 : 0,\n          }}\n          height={entry.firstMedia.height}\n          width={entry.firstMedia.width}\n          blurhash={entry.firstMedia.blurhash}\n        />\n      )}\n    </div>\n  )\n}\n\nfunction AudioCover({\n  entryId,\n  src,\n  durationInSeconds,\n  feedIcon,\n}: {\n  entryId: string\n  src: string\n  durationInSeconds?: number | string\n  feedIcon: React.ReactNode\n}) {\n  const isMobile = useMobile()\n  const playStatus = useAudioPlayerAtomSelector((playerValue) =>\n    playerValue.src === src && playerValue.show ? playerValue.status : false,\n  )\n\n  const language = useGeneralSettingKey(\"language\")\n  const isChinese = useMemo(() => {\n    return language === \"zh-CN\"\n  }, [language])\n\n  const seconds = formatTimeToSeconds(durationInSeconds)\n  const estimatedMins = seconds && Math.floor(seconds / 60)\n\n  const handleClickPlay = (e: React.MouseEvent<HTMLDivElement>) => {\n    e.stopPropagation()\n    e.preventDefault()\n    if (!playStatus) {\n      // switch this to play\n      AudioPlayer.mount({\n        type: \"audio\",\n        entryId,\n        src,\n        currentTime: 0,\n      })\n    } else {\n      // switch between play and pause\n      AudioPlayer.togglePlayAndPause()\n    }\n  }\n\n  return (\n    <div className=\"relative ml-2 shrink-0\">\n      {feedIcon}\n\n      <div\n        className={cn(\n          \"center absolute inset-0 w-full transition-all duration-200 ease-in-out group-hover:opacity-100\",\n          playStatus || isMobile ? \"opacity-100\" : \"opacity-0\",\n        )}\n        onClick={handleClickPlay}\n      >\n        <button\n          type=\"button\"\n          className=\"center size-10 rounded-full bg-material-opaque opacity-95 hover:bg-accent hover:text-white hover:opacity-100\"\n        >\n          <i\n            className={cn(\"size-6\", {\n              \"i-mingcute-pause-fill\": playStatus && playStatus === \"playing\",\n              \"i-mingcute-loading-fill animate-spin\": playStatus && playStatus === \"loading\",\n              \"i-mingcute-play-fill\": !playStatus || playStatus === \"paused\",\n            })}\n          />\n        </button>\n      </div>\n\n      {!!estimatedMins && (\n        <div className=\"absolute bottom-0 w-full overflow-hidden rounded-b-sm text-center\">\n          <div\n            className={cn(\n              \"absolute left-0 top-0 size-full bg-material-ultra-thick opacity-0 duration-200 group-hover:opacity-100\",\n              isMobile && \"opacity-100\",\n            )}\n          />\n          <div\n            className={cn(\n              \"text-body opacity-0 backdrop-blur-none duration-200 group-hover:opacity-100 group-hover:backdrop-blur-background\",\n              isMobile && \"opacity-100 backdrop-blur-background\",\n            )}\n          >\n            {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/translation.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { HTML } from \"~/components/ui/markdown/HTML\"\n\nexport const EntryTranslation: Component<{\n  source?: string | null\n  target?: string | null\n  isHTML?: boolean\n  inline?: boolean\n  bilingual?: boolean\n}> = ({ source, target, className, isHTML, inline = true, bilingual }) => {\n  const bilingualFinal = useGeneralSettingKey(\"translationMode\") === \"bilingual\" || bilingual\n\n  const nextTarget = useMemo(() => {\n    if (!target || source === target) {\n      return \"\"\n    }\n    return target\n  }, [source, target])\n\n  if (!source) {\n    return null\n  }\n\n  if (!bilingualFinal) {\n    return (\n      <div>\n        {isHTML ? (\n          <HTML as=\"div\" className={cn(\"prose dark:prose-invert\", className)} noMedia>\n            {nextTarget || source}\n          </HTML>\n        ) : (\n          <div className={className}>{nextTarget || source}</div>\n        )}\n      </div>\n    )\n  }\n\n  const SourceTag = inline ? \"span\" : \"p\"\n\n  return (\n    <>\n      {isHTML ? (\n        <HTML as=\"div\" className={cn(\"prose dark:prose-invert\", className)} noMedia>\n          {nextTarget || source}\n        </HTML>\n      ) : (\n        <div className={cn(inline && \"inline align-middle\", className)}>\n          {nextTarget && inline && (\n            <>\n              <span className=\"align-middle\">{nextTarget}</span>\n              <i className=\"i-mgc-translate-2-ai-cute-re mx-2 align-middle\" />\n            </>\n          )}\n          <SourceTag className={cn(inline && \"align-middle\")}>{source}</SourceTag>\n          {nextTarget && !inline && <p>{nextTarget}</p>}\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-column/types.ts",
    "content": "import type { FeedModel } from \"@follow/store/feed/types\"\nimport type { EntryTranslation } from \"@follow/store/translation/types\"\nimport type { ParsedEntry } from \"@follow-app/client-sdk\"\nimport type { FC } from \"react\"\n\nexport type UniversalItemProps = {\n  entryId: string\n  translation?: EntryTranslation\n  currentFeedTitle?: string\n}\n\nexport type EntryListItemFC<P extends object = object> = FC<P & UniversalItemProps> & {\n  wrapperClassName?: string\n}\n\nexport type EntryItemStatelessProps = {\n  feed: FeedModel\n  entry: ParsedEntry\n  view?: number\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/EntryContent.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useTitle } from \"@follow/hooks\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport { useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { thenable } from \"@follow/utils\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { JSAnimation } from \"motion/react\"\nimport { useAnimationControls } from \"motion/react\"\nimport * as React from \"react\"\nimport { memo, useEffect, useRef, useState } from \"react\"\n\nimport { useShowAITranslation } from \"~/atoms/ai-translation\"\nimport { useEntryIsInReadability } from \"~/atoms/readability\"\nimport { useActionLanguage } from \"~/atoms/settings/general\"\nimport { AppErrorBoundary } from \"~/components/common/AppErrorBoundary\"\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { m } from \"~/components/common/Motion\"\nimport { ErrorComponentType } from \"~/components/errors/enum\"\nimport { GlassButton } from \"~/components/ui/button/GlassButton\"\nimport { HotkeyScope } from \"~/constants\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useFeedSafeUrl } from \"~/hooks/common/useFeedSafeUrl\"\nimport { useBlockActions } from \"~/modules/ai-chat/store/hooks\"\nimport { BlockSliceAction } from \"~/modules/ai-chat/store/slices/block.slice\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\n\nimport { setEntryContentScrollToTop } from \"./atoms\"\nimport { ApplyEntryActions } from \"./components/ApplyEntryActions\"\nimport { EntryCommandShortcutRegister } from \"./components/entry-content/EntryCommandShortcutRegister\"\nimport { EntryContentFallback } from \"./components/entry-content/EntryContentFallback\"\nimport { EntryContentLoading } from \"./components/entry-content/EntryContentLoading\"\nimport { EntryNoContent } from \"./components/entry-content/EntryNoContent\"\nimport { EntryScrollingAndNavigationHandler } from \"./components/entry-content/EntryScrollingAndNavigationHandler.js\"\nimport { EntryTitleMetaHandler } from \"./components/entry-content/EntryTitleMetaHandler\"\nimport type { EntryContentProps } from \"./components/entry-content/types\"\nimport { getEntryContentLayout } from \"./components/layouts\"\nimport type { EntryLayoutProps } from \"./components/layouts/types\"\nimport { SourceContentPanel } from \"./components/SourceContentView\"\nimport { useEntryContent } from \"./hooks\"\n\nconst contentVariants = {\n  initial: { opacity: 0, y: 30 },\n  animate: { opacity: 1, y: 0 },\n  exit: { opacity: 0, y: 30 },\n}\nconst EntryContentImpl: Component<EntryContentProps> = ({\n  entryId,\n  noMedia,\n  className,\n  compact,\n}) => {\n  const entry = useEntry(entryId, (state) => {\n    const { feedId, inboxHandle } = state\n    const { title, url } = state\n\n    return { feedId, inboxId: inboxHandle, title, url }\n  })\n\n  if (!entry) throw thenable\n\n  useTitle(entry.title)\n  const feed = useFeedById(entry.feedId)\n  const subscription = useSubscriptionByFeedId(entry.feedId)\n\n  const isInbox = useIsInbox(entry.inboxId)\n  const isInReadabilityMode = useEntryIsInReadability(entryId)\n\n  const { error, content, isPending } = useEntryContent(entryId)\n  const enableTranslation = useShowAITranslation()\n  const actionLanguage = useActionLanguage()\n  const entryTranslation = useEntryTranslation({\n    entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n\n  const routeView = useRouteParamsSelector((route) => route.view)\n  const subscriptionView = subscription?.view\n  const view = typeof subscriptionView === \"number\" ? subscriptionView : routeView\n  const [scrollerRef, setScrollerRef] = useState<HTMLDivElement | null>(null)\n  const safeUrl = useFeedSafeUrl(entryId)\n\n  const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)\n\n  const scrollAnimationRef = useRef<JSAnimation<any> | null>(null)\n\n  const isInHasTimelineView = ![\n    FeedViewType.Pictures,\n    FeedViewType.SocialMedia,\n    FeedViewType.Videos,\n  ].includes(view)\n\n  const { addOrUpdateBlock, removeBlock } = useBlockActions()\n  useEffect(() => {\n    addOrUpdateBlock({\n      id: BlockSliceAction.SPECIAL_TYPES.mainEntry,\n      type: \"mainEntry\",\n      value: entryId,\n    })\n    return () => {\n      removeBlock(BlockSliceAction.SPECIAL_TYPES.mainEntry)\n    }\n  }, [addOrUpdateBlock, entryId, removeBlock])\n  const animationController = useAnimationControls()\n\n  const focusableRef = useRef<HTMLDivElement>(null)\n  useEffect(() => {\n    animationController.set(contentVariants.exit)\n    animationController.start(contentVariants.animate)\n\n    // Scroll to top\n    if (scrollerRef) {\n      scrollerRef.scrollTop = 0\n    }\n    focusableRef.current?.focus()\n    return () => {\n      animationController.stop()\n    }\n  }, [animationController, entryId, scrollerRef])\n\n  useEffect(() => {\n    setEntryContentScrollToTop(true)\n  }, [entryId])\n  useEffect(() => {\n    if (!scrollerRef) return\n\n    const handler = () => {\n      setEntryContentScrollToTop(scrollerRef.scrollTop < 50)\n    }\n    scrollerRef.addEventListener(\"scroll\", handler)\n\n    return () => {\n      scrollerRef.removeEventListener(\"scroll\", handler)\n    }\n  }, [scrollerRef])\n\n  const scrollerRefObject = React.useMemo(() => ({ current: scrollerRef }), [scrollerRef])\n  const layoutTranslation = React.useMemo(\n    () =>\n      entryTranslation\n        ? {\n            content: entryTranslation.content ?? undefined,\n            title: entryTranslation.title ?? undefined,\n          }\n        : undefined,\n    [entryTranslation?.content, entryTranslation?.title],\n  )\n  return (\n    <div className={cn(className, \"flex flex-col @container\")}>\n      <EntryTitleMetaHandler entryId={entryId} />\n      <EntryCommandShortcutRegister entryId={entryId} view={view} />\n\n      <div className=\"w-full\" ref={setPanelPortalElement} />\n\n      <Focusable\n        ref={focusableRef}\n        scope={HotkeyScope.EntryRender}\n        className=\"relative flex min-h-0 w-full flex-1 flex-col overflow-hidden @container print:size-auto print:overflow-visible\"\n      >\n        <RootPortal to={panelPortalElement}>\n          <EntryScrollingAndNavigationHandler\n            scrollAnimationRef={scrollAnimationRef}\n            scrollerRef={scrollerRefObject}\n          />\n        </RootPortal>\n\n        <EntryScrollArea scrollerRef={setScrollerRef}>\n          {/* Indicator for the entry */}\n          {isInHasTimelineView && (\n            <>\n              <div className=\"absolute inset-y-0 left-0 z-[9] flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100 group-hover:opacity-40\">\n                <GlassButton\n                  size=\"sm\"\n                  className=\"!-translate-y-12 !bg-material-opaque !opacity-100 hover:!bg-material-opaque\"\n                  onClick={() => {\n                    EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)\n                  }}\n                >\n                  <i className=\"i-mgc-left-small-sharp size-6\" />\n                </GlassButton>\n              </div>\n\n              <div className=\"absolute inset-y-0 right-0 z-[9] flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100 group-hover:opacity-40\">\n                <GlassButton\n                  size=\"sm\"\n                  className=\"!-translate-y-12 !bg-material-opaque !opacity-100 hover:!bg-material-opaque\"\n                  onClick={() => {\n                    EventBus.dispatch(COMMAND_ID.timeline.switchToNext)\n                  }}\n                >\n                  <i className=\"i-mgc-right-small-sharp size-6\" />\n                </GlassButton>\n              </div>\n            </>\n          )}\n          <m.div\n            lcpOptimization\n            className=\"select-text\"\n            initial={{ opacity: 0, y: 30 }}\n            animate={animationController}\n            transition={Spring.presets.smooth}\n          >\n            <article\n              data-testid=\"entry-render\"\n              onContextMenu={stopPropagation}\n              className={\"relative w-full min-w-0 pb-10 pt-12\"}\n            >\n              <ApplyEntryActions entryId={entryId} key={entryId} />\n\n              {!content && !isInReadabilityMode ? (\n                <div className=\"center mt-16 min-w-0\">\n                  {isPending ? (\n                    <EntryContentLoading\n                      icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}\n                    />\n                  ) : error ? (\n                    <div className=\"center mt-36 flex flex-col items-center gap-3\">\n                      <i className=\"i-mgc-warning-cute-re text-4xl text-red\" />\n                      <span className=\"text-balance text-center text-sm\">Network Error</span>\n                      <pre className=\"mt-6 w-full overflow-auto whitespace-pre-wrap break-all\">\n                        {error.message}\n                      </pre>\n                    </div>\n                  ) : (\n                    <EntryNoContent id={entryId} url={entry.url ?? \"\"} />\n                  )}\n                </div>\n              ) : (\n                <AdaptiveContentRenderer\n                  entryId={entryId}\n                  view={view}\n                  compact={compact}\n                  noMedia={noMedia}\n                  translation={layoutTranslation}\n                />\n              )}\n            </article>\n          </m.div>\n        </EntryScrollArea>\n        <SourceContentPanel src={safeUrl ?? \"#\"} />\n      </Focusable>\n    </div>\n  )\n}\nexport const EntryContent: Component<EntryContentProps> = memo((props) => {\n  return (\n    <AppErrorBoundary errorType={ErrorComponentType.EntryNotFound}>\n      <EntryContentFallback entryId={props.entryId}>\n        <EntryContentImpl {...props} />\n      </EntryContentFallback>\n    </AppErrorBoundary>\n  )\n})\n\nconst EntryScrollArea: Component<{\n  scrollerRef: React.Ref<HTMLDivElement | null>\n  viewportClassName?: string\n}> = ({ children, className, scrollerRef, viewportClassName }) => {\n  return (\n    <ScrollArea.ScrollArea\n      focusable\n      mask={false}\n      flex\n      rootClassName={cn(\n        \"flex-1 min-h-0 relative z-[1] overflow-y-auto print:h-auto print:overflow-visible\",\n        className,\n      )}\n      scrollbarClassName=\"mr-[1.5px] print:hidden\"\n      ref={scrollerRef}\n      viewportClassName={viewportClassName}\n      scrollbarProps={{\n        className: \"mt-16 z-[999]\",\n      }}\n    >\n      {children}\n    </ScrollArea.ScrollArea>\n  )\n}\n\nconst AdaptiveContentRenderer: React.FC<{\n  entryId: string\n  view: FeedViewType\n  compact?: boolean\n  noMedia?: boolean\n  translation?: EntryLayoutProps[\"translation\"]\n}> = ({ entryId, view, compact = false, noMedia = false, translation }) => {\n  const LayoutComponent = getEntryContentLayout(view)\n\n  return (\n    <LayoutComponent\n      entryId={entryId}\n      compact={compact}\n      noMedia={noMedia}\n      translation={translation}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/EntryContentForPreview.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport { useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { thenable } from \"@follow/utils\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { clsx } from \"@follow/utils/utils\"\nimport * as React from \"react\"\nimport { memo } from \"react\"\n\nimport { useEntryIsInReadability } from \"~/atoms/readability\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { ErrorBoundary } from \"~/components/common/ErrorBoundary\"\nimport { ShadowDOM } from \"~/components/common/ShadowDOM\"\nimport { useRenderStyle } from \"~/hooks/biz/useRenderStyle\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { EntryContentHTMLRenderer } from \"~/modules/renderer/html\"\nimport { WrappedElementProvider } from \"~/providers/wrapped-element-provider\"\n\nimport { EntryContentFallback } from \"./components/entry-content/EntryContentFallback\"\nimport { EntryContentLoading } from \"./components/entry-content/EntryContentLoading\"\nimport { EntryNoContent } from \"./components/entry-content/EntryNoContent\"\nimport { EntryRenderError } from \"./components/entry-content/EntryRenderError\"\nimport type { EntryContentProps } from \"./components/entry-content/types\"\nimport { EntryAttachments } from \"./components/EntryAttachments\"\nimport { EntryTitle } from \"./components/EntryTitle\"\nimport { useEntryContent, useEntryMediaInfo } from \"./hooks\"\n\nconst EntryContentImpl: Component<EntryContentProps> = ({\n  entryId,\n  noMedia,\n\n  compact,\n}) => {\n  const entry = useEntry(entryId, (state) => {\n    const { feedId, inboxHandle } = state\n    const { title, url } = state\n\n    return { feedId, inboxId: inboxHandle, title, url }\n  })\n  if (!entry) throw thenable\n\n  const feed = useFeedById(entry?.feedId)\n\n  const isInbox = useIsInbox(entry?.inboxId)\n  const isInReadabilityMode = useEntryIsInReadability(entryId)\n\n  const { error, content, isPending } = useEntryContent(entryId)\n\n  const view = useRouteParamsSelector((route) => route.view)\n\n  return (\n    <div className=\"relative flex size-full flex-col @container print:size-auto print:overflow-visible\">\n      <article\n        onContextMenu={stopPropagation}\n        className={clsx(\"relative m-auto min-w-0 select-text\", \"w-full max-w-full\")}\n      >\n        <EntryTitle entryId={entryId} compact={compact} noRecentReader />\n\n        <WrappedElementProvider boundingDetection>\n          <div className=\"mx-auto my-8 max-w-full cursor-auto\">\n            <ErrorBoundary fallback={EntryRenderError}>\n              <ShadowDOM injectHostStyles={!isInbox}>\n                <Renderer\n                  entryId={entryId}\n                  view={view}\n                  feedId={feed?.id || \"\"}\n                  noMedia={noMedia}\n                  content={content}\n                />\n              </ShadowDOM>\n            </ErrorBoundary>\n          </div>\n        </WrappedElementProvider>\n\n        {!content && !isInReadabilityMode && (\n          <div className=\"center mt-16 min-w-0\">\n            {isPending ? (\n              <EntryContentLoading icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined} />\n            ) : error ? (\n              <div className=\"center mt-36 flex flex-col items-center gap-3\">\n                <i className=\"i-mgc-warning-cute-re text-4xl text-red\" />\n                <span className=\"text-balance text-center text-sm\">Network Error</span>\n                <pre className=\"mt-6 w-full overflow-auto whitespace-pre-wrap break-all\">\n                  {error.message}\n                </pre>\n              </div>\n            ) : (\n              <EntryNoContent id={entryId} url={entry.url ?? \"\"} />\n            )}\n          </div>\n        )}\n\n        <EntryAttachments entryId={entryId} />\n      </article>\n    </div>\n  )\n}\nexport const EntryContentForPreview: Component<EntryContentProps> = memo((props) => {\n  return (\n    <EntryContentFallback entryId={props.entryId}>\n      <EntryContentImpl {...props} />\n    </EntryContentFallback>\n  )\n})\n\nconst Renderer: React.FC<{\n  entryId: string\n  view: FeedViewType\n  feedId: string\n  noMedia?: boolean\n  content?: Nullable<string>\n}> = React.memo(({ entryId, view, feedId, noMedia = false, content = \"\" }) => {\n  const mediaInfo = useEntryMediaInfo(entryId)\n\n  const readerRenderInlineStyle = useUISettingKey(\"readerRenderInlineStyle\")\n\n  const stableRenderStyle = useRenderStyle()\n\n  return (\n    <EntryContentHTMLRenderer\n      view={view}\n      feedId={feedId}\n      entryId={entryId}\n      mediaInfo={mediaInfo}\n      noMedia={noMedia}\n      as=\"article\"\n      className=\"prose !max-w-full hyphens-auto dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold\"\n      style={stableRenderStyle}\n      renderInlineStyle={readerRenderInlineStyle}\n    >\n      {content}\n    </EntryContentHTMLRenderer>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx",
    "content": "import { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { memo, useCallback } from \"react\"\n\nimport { MenuItemText } from \"~/atoms/context-menu\"\nimport { CommandActionButton } from \"~/components/ui/button/CommandActionButton\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { EntryActionDropdownItem, useSortedEntryActions } from \"~/hooks/biz/useEntryActions\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { useCommand } from \"~/modules/command/hooks/use-command\"\nimport type { FollowCommandId } from \"~/modules/command/types\"\n\nexport const EntryHeaderActions = ({ entryId, view }: { entryId: string; view: FeedViewType }) => {\n  const { mainAction: actionConfigs } = useSortedEntryActions({ entryId, view })\n  const { withLoginGuard } = useRequireLogin()\n  const resolveClick = useCallback(\n    (action: MenuItemText | EntryActionDropdownItem) =>\n      action.requiresLogin ? withLoginGuard(action.onClick) : action.onClick,\n    [withLoginGuard],\n  )\n\n  return actionConfigs\n    .filter((item) => item instanceof MenuItemText || item instanceof EntryActionDropdownItem)\n    .map((config) => {\n      const clickHandler = resolveClick(config)\n      const baseTrigger = (\n        <CommandActionButton\n          active={config.active}\n          key={config.id}\n          // Handle shortcut globally\n          disableTriggerShortcut\n          commandId={config.id}\n          onClick={clickHandler}\n          shortcut={config.shortcut!}\n          clickableDisabled={config.disabled}\n          highlightMotion={config.notice}\n          id={`${config.entryId}/${config.id}`}\n        />\n      )\n\n      if (config instanceof EntryActionDropdownItem && config.hasChildren) {\n        return (\n          <DropdownMenu key={config.id}>\n            <DropdownMenuTrigger asChild>{baseTrigger}</DropdownMenuTrigger>\n            <RootPortal>\n              <DropdownMenuContent>\n                {config.enabledChildren.map((child) => (\n                  <CommandDropdownMenuItem\n                    key={child.id}\n                    commandId={child.id}\n                    onClick={resolveClick(child)!}\n                    active={child.active}\n                    disabled={child.disabled}\n                  />\n                ))}\n              </DropdownMenuContent>\n            </RootPortal>\n          </DropdownMenu>\n        )\n      }\n\n      if (config instanceof MenuItemText) {\n        return baseTrigger\n      }\n\n      return null\n    })\n}\n\nconst CommandDropdownMenuItem = memo(\n  ({\n    commandId,\n    onClick,\n    active,\n    disabled,\n  }: {\n    commandId: FollowCommandId\n    onClick: () => void\n    active?: boolean\n    disabled?: boolean\n  }) => {\n    const command = useCommand(commandId)\n\n    if (!command) return null\n\n    return (\n      <DropdownMenuItem\n        key={command.id}\n        className=\"pl-3\"\n        icon={command.icon}\n        onSelect={disabled ? undefined : onClick}\n        active={active}\n        disabled={disabled}\n      >\n        {command.label.title}\n      </DropdownMenuItem>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx",
    "content": "import { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { useCallback, useMemo } from \"react\"\n\nimport { MenuItemText } from \"~/atoms/context-menu\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport {\n  EntryActionDropdownItem,\n  EntryActionMenuItem,\n  useSortedEntryActions,\n} from \"~/hooks/biz/useEntryActions\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { hasCommand, useCommand, useRunCommandFn } from \"~/modules/command/hooks/use-command\"\nimport type { FollowCommandId } from \"~/modules/command/types\"\n\nexport const MoreActions = ({\n  entryId,\n  view,\n  showMainAction = false,\n  hideCustomizeToolbar = false,\n}: {\n  entryId: string\n  view: FeedViewType\n  showMainAction?: boolean\n  hideCustomizeToolbar?: boolean\n}) => {\n  const { moreAction, mainAction } = useSortedEntryActions({ entryId, view })\n  const { withLoginGuard } = useRequireLogin()\n  const resolveClick = useCallback(\n    (action: MenuItemText | EntryActionDropdownItem | EntryActionMenuItem) =>\n      action.requiresLogin ? withLoginGuard(action.onClick) : action.onClick,\n    [withLoginGuard],\n  )\n\n  const actionConfigs = useMemo(\n    () =>\n      moreAction.filter(\n        (action) =>\n          (action instanceof MenuItemText || action instanceof EntryActionDropdownItem) &&\n          hasCommand(action.id),\n      ),\n    [moreAction],\n  )\n\n  const availableActions = useMemo(\n    () =>\n      actionConfigs.filter(\n        (item) =>\n          (item instanceof MenuItemText || item instanceof EntryActionDropdownItem) &&\n          item.id !== COMMAND_ID.settings.customizeToolbar,\n      ),\n    [actionConfigs],\n  )\n\n  const runCmdFn = useRunCommandFn()\n  const extraAction: EntryActionMenuItem[] = useMemo(\n    () =>\n      !hideCustomizeToolbar\n        ? [\n            new EntryActionMenuItem({\n              id: COMMAND_ID.settings.customizeToolbar,\n              onClick: runCmdFn(COMMAND_ID.settings.customizeToolbar, []),\n              entryId,\n            }),\n          ]\n        : [],\n    [entryId, hideCustomizeToolbar, runCmdFn],\n  )\n\n  if (availableActions.length === 0 && extraAction.length === 0) {\n    return null\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <ActionButton icon={<i className=\"i-mingcute-more-1-fill\" />} />\n      </DropdownMenuTrigger>\n      <RootPortal>\n        <DropdownMenuContent align=\"end\">\n          {showMainAction && (\n            <div>\n              {mainAction\n                .filter((config) => config instanceof MenuItemText)\n                .map((config) => {\n                  const handler = resolveClick(config)\n                  return (\n                    <CommandDropdownMenuItem\n                      key={config.id}\n                      commandId={config.id}\n                      onClick={handler!}\n                      active={config.active}\n                      disabled={config.disabled}\n                    />\n                  )\n                })}\n              <DropdownMenuSeparator />\n            </div>\n          )}\n\n          {availableActions.map((config) => {\n            // Handle EntryActionI with sub-menu\n            if (config instanceof EntryActionDropdownItem && config.hasChildren) {\n              return (\n                <DropdownMenuSub key={config.id}>\n                  <DropdownMenuSubTrigger disabled={config.disabled}>\n                    <CommandDropdownMenuItem\n                      commandId={config.id}\n                      onClick={resolveClick(config)!}\n                      active={config.active}\n                      asSubTrigger\n                      disabled={config.disabled}\n                    />\n                  </DropdownMenuSubTrigger>\n                  <DropdownMenuSubContent>\n                    {config.enabledChildren.map((child) => (\n                      <CommandDropdownMenuItem\n                        key={child.id}\n                        commandId={child.id}\n                        onClick={resolveClick(child)!}\n                        active={child.active}\n                        disabled={child.disabled}\n                      />\n                    ))}\n                  </DropdownMenuSubContent>\n                </DropdownMenuSub>\n              )\n            }\n\n            // Handle regular MenuItemText\n            if (config instanceof MenuItemText) {\n              const handler = resolveClick(config)\n              return (\n                <CommandDropdownMenuItem\n                  key={config.id}\n                  commandId={config.id}\n                  onClick={handler!}\n                  active={config.active}\n                  disabled={config.disabled}\n                />\n              )\n            }\n\n            return null\n          })}\n          {availableActions.length > 0 && extraAction.length > 0 && <DropdownMenuSeparator />}\n          {extraAction\n            .filter((item) => item instanceof MenuItemText)\n            .map((config) => (\n              <CommandDropdownMenuItem\n                key={config.id}\n                commandId={config.id}\n                onClick={resolveClick(config)!}\n                active={config.active}\n                disabled={config.disabled}\n              />\n            ))}\n        </DropdownMenuContent>\n      </RootPortal>\n    </DropdownMenu>\n  )\n}\n\nexport const CommandDropdownMenuItem = ({\n  commandId,\n  onClick,\n  active,\n  asSubTrigger = false,\n  disabled = false,\n}: {\n  commandId: FollowCommandId\n  onClick: () => void\n  active?: boolean\n  asSubTrigger?: boolean\n  disabled?: boolean\n}) => {\n  const command = useCommand(commandId)\n\n  if (!command) return null\n\n  const content = (\n    <>\n      {command.icon}\n      {command.label.title}\n    </>\n  )\n\n  if (asSubTrigger) {\n    return content\n  }\n\n  return (\n    <DropdownMenuItem\n      key={command.id}\n      className=\"pl-3\"\n      icon={command.icon}\n      onSelect={disabled ? undefined : onClick}\n      active={active}\n      disabled={disabled}\n    >\n      {command.label.title}\n    </DropdownMenuItem>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx",
    "content": "import {\n  MasonryItemsAspectRatioContext,\n  MasonryItemsAspectRatioSetterContext,\n  MasonryItemWidthContext,\n  useMasonryItemWidth,\n} from \"@follow/components/ui/masonry/contexts.jsx\"\nimport { useMasonryColumn } from \"@follow/components/ui/masonry/hooks.js\"\nimport type { MediaModel } from \"@folo-services/drizzle\"\nimport type { RenderComponentProps } from \"masonic\"\nimport { Masonry } from \"masonic\"\nimport { useState } from \"react\"\n\nimport { Media } from \"~/components/ui/media/Media\"\nimport { MediaContainerWidthProvider } from \"~/components/ui/media/MediaContainerWidthProvider\"\n\nconst gutter = 24\n\nconst Render: React.ComponentType<\n  RenderComponentProps<{\n    url: string\n    type: \"photo\" | \"video\"\n    height?: number\n    width?: number\n    blurhash?: string\n  }>\n> = ({ data }) => {\n  const { url, type, height, width, blurhash } = data\n\n  const itemWidth = useMasonryItemWidth()\n\n  return (\n    <Media\n      thumbnail\n      popper\n      src={url}\n      type={type}\n      className=\"size-full overflow-hidden\"\n      mediaContainerClassName={\"w-auto h-auto rounded\"}\n      loading=\"lazy\"\n      proxy={{\n        width: itemWidth,\n        height: 0,\n      }}\n      height={height}\n      width={width}\n      blurhash={blurhash}\n    />\n  )\n}\nexport const ImageGallery = ({ images }: { images: MediaModel[] }) => {\n  const { containerRef, currentColumn, currentItemWidth } = useMasonryColumn(gutter)\n\n  const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})\n  return (\n    <div ref={containerRef}>\n      <MasonryItemWidthContext value={currentItemWidth}>\n        {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n        <MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>\n          <MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>\n            <MediaContainerWidthProvider width={currentItemWidth}>\n              <Masonry\n                items={images ?? []}\n                columnGutter={gutter}\n                columnWidth={currentItemWidth}\n                columnCount={currentColumn}\n                overscanBy={2}\n                render={Render}\n              />\n            </MediaContainerWidthProvider>\n          </MasonryItemsAspectRatioSetterContext>\n        </MasonryItemsAspectRatioContext.Provider>\n      </MasonryItemWidthContext>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/atoms.tsx",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { atom } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] = createAtomHooks(\n  atom(\n    null as Nullable<{\n      entryTitle: string\n      feedTitle: string\n\n      // id-set\n      feedId: string\n      entryId: string\n    }>,\n  ),\n)\n\nexport const [\n  ,\n  ,\n  useEntryContentScrollToTop,\n  ,\n  getEntryContentScrollToTop,\n  setEntryContentScrollToTop,\n] = createAtomHooks(atom(false))\n\nexport const [, , , , getTranslationCache, setTranslationCache] = createAtomHooks(\n  atomWithStorage(getStorageNS(\"translation-cache\"), {} as Record<string, string>),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/AISummary.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { usePrefetchSummary } from \"@follow/store/summary/hooks\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useShowAISummary } from \"~/atoms/ai-summary\"\nimport { useEntryIsInReadabilitySuccess } from \"~/atoms/readability\"\nimport {\n  AIChatPanelStyle,\n  setAIPanelVisibility,\n  useAIChatPanelStyle,\n  useAIPanelVisibility,\n} from \"~/atoms/settings/ai\"\nimport { useActionLanguage } from \"~/atoms/settings/general\"\nimport { AISummaryCardBase } from \"~/components/ui/ai-summary-card\"\n\nexport function AISummary({ entryId }: { entryId: string }) {\n  const { t } = useTranslation()\n  const summarySetting = useEntry(entryId, (state) => state.settings?.summary)\n  const isInReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)\n  const showAISummary = useShowAISummary(summarySetting)\n\n  const actionLanguage = useActionLanguage()\n\n  // AI Chat panel state\n  const aiChatPanelStyle = useAIChatPanelStyle()\n  const isAIPanelVisible = useAIPanelVisibility()\n\n  const summary = usePrefetchSummary({\n    actionLanguage,\n    entryId,\n    target: isInReadabilitySuccess ? \"readabilityContent\" : \"content\",\n    enabled: showAISummary,\n  })\n\n  // Show Ask AI button when:\n  // 1. Panel style is floating AND panel is not visible\n  // 2. OR panel style is fixed (since fixed panel can be toggled)\n  const shouldShowAskAI =\n    (aiChatPanelStyle === AIChatPanelStyle.Floating && !isAIPanelVisible) ||\n    aiChatPanelStyle === AIChatPanelStyle.Fixed\n\n  const handleAskAI = () => {\n    setAIPanelVisibility(true)\n  }\n\n  if (!showAISummary) {\n    return null\n  }\n\n  return (\n    <AISummaryCardBase\n      content={summary.data}\n      isLoading={summary.isLoading}\n      className=\"my-8\"\n      title={t(\"entry_content.ai_summary\")}\n      showAskAIButton={shouldShowAskAI}\n      onAskAI={handleAskAI}\n      error={summary.error}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/ApplyEntryActions.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useEffect } from \"react\"\n\nimport { enableShowSourceContent } from \"~/atoms/source-content\"\nimport { enableEntryReadability } from \"~/hooks/biz/useEntryActions\"\n\n/**\n * Handle Entry Actions\n * @returns\n */\nexport const ApplyEntryActions = ({ entryId }: { entryId: string }) => {\n  const entry = useEntry(entryId, (s) => {\n    if (!s.settings) return null\n    return {\n      readability: s.settings.readability,\n      sourceContent: s.settings.sourceContent,\n      url: s.url,\n    }\n  })\n\n  return (\n    <>\n      {entry?.sourceContent && <ViewSourceContentAutoToggleEffect id={entryId} />}\n      {entry?.readability && <ReadabilityAutoToggleEffect id={entryId} url={entry?.url ?? \"\"} />}\n    </>\n  )\n}\n\nconst ViewSourceContentAutoToggleEffect = ({ id }: { id: string }) => {\n  useEffect(() => {\n    enableShowSourceContent()\n  }, [id])\n  return null\n}\n\nexport const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: string }) => {\n  useEffect(() => {\n    enableEntryReadability({ id, url })\n  }, [id, url])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/EntryAttachments.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils\"\n\nconst SUPPORTED_MIME_TYPES = new Set([\"application/x-bittorrent\"])\n\nexport function EntryAttachments({ entryId }: { entryId: string }) {\n  const attachments = useEntry(entryId, (entry) => entry.attachments)\n  if (!attachments || attachments.length === 0) {\n    return null\n  }\n  return (\n    <div className=\"flex gap-2\">\n      {attachments\n        .filter(\n          (attachment) => attachment.mime_type && SUPPORTED_MIME_TYPES.has(attachment.mime_type),\n        )\n        .map((attachment) => (\n          <Tooltip key={attachment.url}>\n            <TooltipTrigger asChild>\n              <a\n                href={attachment.url}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className={cn(\n                  \"text-xl\",\n                  attachment.mime_type === \"application/x-bittorrent\" &&\n                    \"i-simple-icons-bittorrent\",\n                )}\n              />\n            </TooltipTrigger>\n            <TooltipPortal>\n              <TooltipContent>{attachment.url}</TooltipContent>\n            </TooltipPortal>\n          </Tooltip>\n        ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/EntryPlaceholderLogo.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  DEFAULT_RECOMMEND_FEEDS_SHORTCUT_ID,\n  DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID,\n} from \"@follow/shared/settings/defaults\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { useSetAtom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { useSendAIShortcut } from \"~/modules/ai-chat/hooks/useSendAIShortcut\"\nimport { aiTimelineEnabledAtom } from \"~/modules/entry-column/atoms/ai-timeline\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\n\nexport const EntryPlaceholderLogo = () => {\n  const { sendAIShortcut } = useSendAIShortcut()\n  const setAiTimelineEnabled = useSetAtom(aiTimelineEnabledAtom)\n  const settingModalPresent = useSettingModal()\n  const handleSummarizeTimeline = useCallback(() => {\n    void sendAIShortcut({\n      shortcutId: DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID,\n      ensureNewChat: true,\n    })\n  }, [sendAIShortcut])\n  const handleRecommendFeeds = useCallback(() => {\n    void sendAIShortcut({\n      shortcutId: DEFAULT_RECOMMEND_FEEDS_SHORTCUT_ID,\n      ensureNewChat: true,\n    })\n  }, [sendAIShortcut])\n  const handleToggleAiTimeline = useCallback(() => {\n    setAiTimelineEnabled((prev) => !prev)\n  }, [setAiTimelineEnabled])\n\n  const buttons = [\n    {\n      label: \"Summarize the current timeline\",\n      onClick: handleSummarizeTimeline,\n      icon: <i className=\"i-mgc-paint-brush-ai-cute-re text-base\" />,\n    },\n    {\n      label: \"Suggest me some new feeds\",\n      onClick: handleRecommendFeeds,\n      icon: <i className=\"i-mgc-search-ai-cute-re text-base\" />,\n    },\n    {\n      label: \"Sort the timeline by importance\",\n      onClick: handleToggleAiTimeline,\n      icon: <i className=\"i-mgc-refresh-4-ai-cute-re text-base\" />,\n    },\n    {\n      label: \"Personalize my Folo AI\",\n      onClick: () => settingModalPresent(\"ai\"),\n      icon: <i className=\"i-mgc-ai-cute-re text-base\" />,\n    },\n  ]\n\n  return (\n    <div\n      data-hide-in-print\n      onContextMenu={stopPropagation}\n      className={\n        \"flex w-full min-w-0 flex-col items-center justify-center gap-2 px-12 pb-6 text-center text-lg font-medium text-text-secondary duration-500\"\n      }\n    >\n      <i className=\"i-mgc-folo-bot-original size-16 text-text-tertiary\" />\n      <div>Where are we off to first?</div>\n      <div className=\"mt-4 flex flex-col gap-2\">\n        {buttons.map((button) => (\n          <Button\n            key={button.label}\n            type=\"button\"\n            onClick={button.onClick}\n            buttonClassName=\"justify-start\"\n            textClassName=\"flex items-center gap-2 text-purple-600 dark:text-purple-400\"\n            variant=\"ghost\"\n          >\n            {button.icon}\n            <span className=\"bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent dark:from-purple-400 dark:to-blue-400\">\n              {button.label}\n            </span>\n          </Button>\n        ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTimelineSidebar.tsx",
    "content": "import { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { TargetAndTransition } from \"motion/react\"\nimport { m } from \"motion/react\"\n\nimport { useEntryIsRead } from \"~/hooks/biz/useAsRead\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useGetEntryIdInRange } from \"~/modules/entry-column/hooks/useEntryIdListSnap\"\n\nexport const EntryTimelineSidebar = ({\n  entryId,\n  className,\n}: {\n  entryId: string\n  className?: string\n}) => {\n  const entryIds = useGetEntryIdInRange(entryId, [5, 5])\n\n  return (\n    <m.div\n      className={cn(\n        \"absolute left-8 top-28 z-[1] @lg:hidden @6xl:block @6xl:max-w-[200px] @7xl:max-w-[200px] @[90rem]:max-w-[250px]\",\n        className,\n      )}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1, transition: { delay: 0.5 } }}\n    >\n      {entryIds.map((id) => (\n        <TimelineItem key={id} id={id} />\n      ))}\n    </m.div>\n  )\n}\n\nconst initialButton: TargetAndTransition = {\n  opacity: 0.0001,\n}\nconst animateButton: TargetAndTransition = {\n  opacity: 1,\n}\n\nconst TimelineItem = ({ id }: { id: string }) => {\n  const entry = useEntry(id, (e) => ({\n    title: e.title,\n  }))\n  const asRead = useEntryIsRead(id)\n  const navigate = useNavigateEntry()\n\n  const isActive = useRouteParamsSelector((r) => r.entryId === id)\n\n  return (\n    <m.button\n      layoutId={`timeline-${id}`}\n      initial={initialButton}\n      animate={animateButton}\n      className={\"relative block min-w-0 max-w-full cursor-pointer text-xs leading-loose\"}\n      type=\"button\"\n      onClick={() => navigate({ entryId: id })}\n    >\n      {!asRead && (\n        <span className=\"absolute -left-4 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent opacity-50\" />\n      )}\n      <EllipsisHorizontalTextWithTooltip\n        className={cn(\n          \"truncate transition-[opacity,font-weight] duration-200\",\n          isActive ? \"font-medium opacity-100\" : \"opacity-60 hover:opacity-80\",\n        )}\n      >\n        {entry?.title}\n      </EllipsisHorizontalTextWithTooltip>\n    </m.button>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTitle.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { cn, formatEstimatedMins, formatTimeToSeconds } from \"@follow/utils\"\nimport { useMemo } from \"react\"\nimport { titleCase } from \"title-case\"\nimport { useShallow } from \"zustand/shallow\"\n\nimport { useShowAITranslation } from \"~/atoms/ai-translation\"\nimport { useActionLanguage } from \"~/atoms/settings/general\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useFeedSafeUrl } from \"~/hooks/common/useFeedSafeUrl\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { EntryTranslation } from \"../../entry-column/translation\"\nimport { EntryReadHistory } from \"./entry-read-history\"\n\ninterface EntryLinkProps {\n  entryId: string\n  compact?: boolean\n  containerClassName?: string\n  noRecentReader?: boolean\n}\n\nexport const EntryTitle = ({\n  entryId,\n  compact,\n  containerClassName,\n  noRecentReader,\n}: EntryLinkProps) => {\n  const entry = useEntry(\n    entryId,\n    useShallow((state) => {\n      /// keep-sorted\n      const { author, authorAvatar, authorUrl, feedId, inboxHandle, publishedAt, title } = state\n\n      const attachments = state.attachments || []\n      const { duration_in_seconds } =\n        attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}\n      const seconds = duration_in_seconds ? formatTimeToSeconds(duration_in_seconds) : undefined\n      const estimatedMins = seconds ? formatEstimatedMins(Math.floor(seconds / 60)) : undefined\n\n      const media = state.media || []\n      const firstPhoto = media.find((a) => a.type === \"photo\")\n      const firstPhotoUrl = firstPhoto?.url\n\n      /// keep-sorted\n      return {\n        author,\n        authorAvatar,\n        authorUrl,\n        estimatedMins,\n        feedId,\n        firstPhotoUrl,\n        inboxId: inboxHandle,\n        publishedAt,\n        title,\n      }\n    }),\n  )\n\n  const hideRecentReader = useUISettingKey(\"hideRecentReader\")\n\n  const feed = useFeedById(entry?.feedId)\n  const inbox = useInboxById(entry?.inboxId)\n  const populatedFullHref = useFeedSafeUrl(entryId)\n  const enableTranslation = useShowAITranslation()\n  const actionLanguage = useActionLanguage()\n  const translation = useEntryTranslation({\n    entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n\n  const dateFormat = useUISettingKey(\"dateFormat\")\n\n  const navigateEntry = useNavigateEntry()\n\n  const iconEntry: FeedIconEntry = useMemo(\n    () => ({\n      firstPhotoUrl: entry?.firstPhotoUrl,\n      authorAvatar: entry?.authorAvatar,\n    }),\n    [entry?.firstPhotoUrl, entry?.authorAvatar],\n  )\n\n  const titleEntry = useMemo(\n    () => ({\n      authorUrl: entry?.authorUrl,\n    }),\n    [entry?.authorUrl],\n  )\n\n  const LinkTarget = populatedFullHref ? \"a\" : \"span\"\n  const linkProps = populatedFullHref\n    ? { href: populatedFullHref, target: \"_blank\", rel: \"noopener noreferrer\" }\n    : {}\n  if (!entry) return null\n\n  return compact ? (\n    <div className=\"-mx-6 flex cursor-button items-center gap-2 rounded-lg p-6 transition-colors @sm:-mx-3 @sm:p-3\">\n      <FeedIcon fallback target={feed || inbox} entry={iconEntry} size={50} />\n      <div className=\"leading-6\">\n        <div className=\"flex items-center gap-1 text-base font-semibold\">\n          <span>{entry.author || feed?.title || inbox?.title}</span>\n        </div>\n        <div className=\"text-zinc-500\">\n          <RelativeTime date={entry.publishedAt} />\n        </div>\n      </div>\n    </div>\n  ) : (\n    <div className={cn(\"group relative block min-w-0\", containerClassName)}>\n      <div className=\"flex flex-col gap-3\">\n        <LinkTarget\n          {...linkProps}\n          className={cn(\n            \"inline-block cursor-link select-text break-words text-[1.7rem] font-bold leading-normal duration-200\",\n            populatedFullHref\n              ? \"cursor-link hover:multi-[scale-[1.01];opacity-95]\"\n              : \"cursor-default\",\n          )}\n        >\n          <EntryTranslation\n            source={titleCase(entry.title ?? \"\")}\n            target={titleCase(translation?.title ?? \"\")}\n            className=\"autospace-normal inline-block select-text hyphens-auto text-text duration-200\"\n            inline={false}\n          />\n        </LinkTarget>\n\n        {/* Meta Information with improved layout */}\n        <div className=\"flex flex-wrap items-center gap-x-6 gap-y-2 text-sm\">\n          <div className=\"flex flex-wrap items-center gap-4 text-text-secondary [&>div:hover]:multi-[text-text] [&>div]:transition-colors\">\n            <div\n              className=\"flex items-center text-xs font-medium\"\n              onClick={() =>\n                navigateEntry({\n                  feedId: feed?.id,\n                })\n              }\n            >\n              <FeedIcon fallback target={feed || inbox} entry={iconEntry} size={16} />\n              {getPreferredTitle(feed || inbox, titleEntry)}\n            </div>\n\n            {entry.author && (\n              <div className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-user-3-cute-re text-base\" />\n                {entry.authorUrl ? (\n                  <a\n                    href={entry.authorUrl}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"text-xs font-medium transition-colors hover:text-text\"\n                  >\n                    {entry.author}\n                  </a>\n                ) : (\n                  <span className=\"text-xs font-medium\">{entry.author}</span>\n                )}\n              </div>\n            )}\n\n            <div className=\"flex items-center gap-1.5\">\n              <i className=\"i-mgc-calendar-time-add-cute-re text-base\" />\n              <span className=\"text-xs tabular-nums\">\n                <RelativeTime date={entry.publishedAt} dateFormatTemplate={dateFormat} />\n              </span>\n            </div>\n\n            {entry.estimatedMins && (\n              <div className=\"flex items-center gap-1.5\">\n                <i className=\"i-mgc-time-cute-re text-base\" />\n                <span className=\"text-xs tabular-nums\">{entry.estimatedMins}</span>\n              </div>\n            )}\n          </div>\n        </div>\n        {/* Recent Readers */}\n        {!noRecentReader && !hideRecentReader && <EntryReadHistory entryId={entryId} />}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/ImageGalleryContent.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\n\nimport { ImageGallery } from \"../actions/picture-gallery\"\n\nexport const ImageGalleryContent = ({ entryId }: { entryId: string }) => {\n  const images = useEntry(entryId, (entry) => entry.media)\n  // images?.length && images.length > 5\n  // We don't need to check here, we already check in the action\n  return <ImageGallery images={images || []} />\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/SourceContentView.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useEffect, useRef, useState } from \"react\"\n\nimport { useShowSourceContent } from \"~/atoms/source-content\"\nimport { m } from \"~/components/common/Motion\"\n\nimport { EntryContentLoading } from \"./entry-content/EntryContentLoading\"\n\nconst ViewTag = IN_ELECTRON ? \"webview\" : \"iframe\"\n\nexport const SourceContentView = ({ src }: { src: string }) => {\n  const showSourceContent = useShowSourceContent()\n  const [loading, setLoading] = useState(true)\n  const webviewRef = useRef<HTMLIFrameElement | null>(null)\n\n  useEffect(() => {\n    const abortController = new AbortController()\n    const webview = webviewRef.current\n    if (!webview) return\n    const handleDidStopLoading = () => setLoading(false)\n\n    // See https://www.electronjs.org/docs/latest/api/webview-tag#example\n    webview.addEventListener(\"did-stop-loading\", handleDidStopLoading, {\n      signal: abortController.signal,\n    })\n\n    return () => {\n      abortController.abort()\n    }\n  }, [src, showSourceContent])\n\n  return (\n    <div className=\"relative flex size-full flex-col\">\n      {loading && (\n        <div className=\"center absolute inset-0 mt-16 min-w-0\">\n          <EntryContentLoading icon={src} />\n        </div>\n      )}\n      <m.div\n        className=\"size-full\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: loading ? 0 : 1 }}\n        transition={Spring.presets.smooth}\n      >\n        <ViewTag\n          ref={webviewRef}\n          className=\"size-full\"\n          src={src}\n          sandbox=\"allow-scripts allow-same-origin\"\n          // For iframe\n          onLoad={() => setLoading(false)}\n        />\n      </m.div>\n    </div>\n  )\n}\n\nexport const SourceContentPanel = ({ src }: { src: string | null }) => {\n  const showSourceContent = useShowSourceContent()\n  if (!showSourceContent || !src) return null\n  return (\n    <div data-hide-in-print className=\"absolute left-0 top-0 z-[1] size-full bg-theme-background\">\n      <SourceContentView src={src} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/WarnGoToExternalLink.tsx",
    "content": "import { IconButton } from \"@follow/components/ui/button/index.js\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.jsx\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@follow/components/ui/popover/index.jsx\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { parseSafeUrl } from \"@follow/utils/utils\"\nimport { Label } from \"@radix-ui/react-label\"\nimport { PopoverPortal } from \"@radix-ui/react-popover\"\nimport { atomWithStorage } from \"jotai/utils\"\nimport { useState } from \"react\"\n\nimport { useGeneralSettingKey, useGeneralSettingValue } from \"~/atoms/settings/general\"\nimport { jotaiStore } from \"~/lib/jotai\"\nimport { withSettingEnabled } from \"~/modules/settings/helper/withSettingEnable\"\n\nconst TrustedKey = getStorageNS(\"trusted-external-link\")\nconst trustedAtom = atomWithStorage(TrustedKey, [] as string[], undefined, {\n  getOnInit: true,\n})\n\nconst trustedDefaultLinks = new Set([\n  \"github.com\",\n  \"gitlab.com\",\n  \"google.com\",\n  \"sspai.com\",\n  \"x.com\",\n  \"twitter.com\",\n  \"diygod.me\",\n  \"diygod.cc\",\n\n  \"v2ex.com\",\n  \"pixiv.net\",\n  \"youtube.com\",\n\n  \"bilibili.com\",\n  \"xiaoyuzhoufm.com\",\n  \"xlog.app\",\n  \"rss3.io\",\n])\n\nconst getURLDomain = (url: string) => {\n  const urlObj = parseSafeUrl(url)\n  return urlObj?.hostname ?? null\n}\n\nconst WarnGoToExternalLinkImpl = ({\n  ref,\n  ...rest\n}: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement> & {\n  ref?: React.Ref<HTMLAnchorElement | null>\n}) => {\n  const [open, setOpen] = useState(false)\n  const [checked, setChecked] = useState<boolean | \"indeterminate\">(false)\n\n  const shouldWarn = useGeneralSettingKey(\"jumpOutLinkWarn\")\n  const handleOpen: React.MouseEventHandler<HTMLAnchorElement> = (e) => {\n    rest.onClick?.(e)\n    if (!shouldWarn) return\n    const { href } = rest\n    if (!href) return\n    const domain = getURLDomain(href)\n\n    if (\n      domain &&\n      !trustedDefaultLinks.has(domain) &&\n      !jotaiStore.get(trustedAtom).includes(domain)\n    ) {\n      setOpen(true)\n      e.preventDefault()\n    }\n  }\n  const handleGo = () => {\n    open()\n    if (!checked) {\n      return\n    }\n\n    const { href } = rest\n    if (!href) return\n\n    const domain = getURLDomain(href)\n    if (domain && !jotaiStore.get(trustedAtom).includes(domain)) {\n      jotaiStore.set(trustedAtom, (prev) => [...prev, domain])\n    }\n\n    function open() {\n      if (!rest.href) return\n      window.open(rest.href, \"_blank\", \"noopener,noreferrer\")\n      setOpen(false)\n    }\n  }\n  return (\n    <Popover open={open} onOpenChange={(v) => !v && setOpen(false)}>\n      <PopoverTrigger asChild>\n        <a ref={ref} {...rest} onClick={handleOpen} />\n      </PopoverTrigger>\n      <PopoverPortal>\n        <PopoverContent>\n          <p className=\"max-w-[50ch] text-sm\">\n            You are about to leave this site to go to an external page, do you trust this URL and go\n            to it?\n          </p>\n          <p className=\"mt-2 text-center text-sm underline\">{rest.href}</p>\n\n          <div className=\"mt-3 flex justify-between\">\n            <Label className=\"center flex\">\n              <Checkbox checked={checked} onCheckedChange={setChecked} />\n              <span className=\"ml-2 text-[13px]\">Trust this domain</span>\n            </Label>\n\n            <IconButton icon={<i className=\"i-mingcute-arrow-right-line\" />} onClick={handleGo}>\n              <span className=\"duration-200 group-hover:opacity-0\">Go</span>\n            </IconButton>\n          </div>\n        </PopoverContent>\n      </PopoverPortal>\n    </Popover>\n  )\n}\n\nexport const WarnGoToExternalLink = withSettingEnabled(\n  useGeneralSettingValue,\n  (s) => s.jumpOutLinkWarn,\n)(WarnGoToExternalLinkImpl, \"a\")\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryCommandShortcutRegister.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\n\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { useHasModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandBinding } from \"~/modules/command/hooks/use-command-binding\"\n/**\n * Centralized management of entry command shortcut key\n */\nexport const EntryCommandShortcutRegister = ({\n  entryId,\n  view,\n}: {\n  entryId: string\n  view: FeedViewType\n}) => {\n  const hasModal = useHasModal()\n  const entry = useEntry(entryId, (state) => ({ url: state.url }))\n\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)\n  const baseCondition = !hasModal && when\n\n  useCommandBinding({\n    when: baseCondition && !!entry?.url,\n    commandId: COMMAND_ID.entry.openInBrowser,\n    args: [{ entryId }],\n  })\n\n  useCommandBinding({\n    when: baseCondition && !!entry?.url,\n    commandId: COMMAND_ID.entry.copyLink,\n    args: [{ entryId }],\n  })\n\n  useCommandBinding({\n    when: baseCondition,\n    commandId: COMMAND_ID.entry.read,\n    args: [{ entryId }],\n  })\n\n  useCommandBinding({\n    when: baseCondition,\n    commandId: COMMAND_ID.entry.star,\n    args: [{ entryId, view }],\n  })\n\n  useCommandBinding({\n    when: baseCondition,\n    commandId: COMMAND_ID.entry.copyTitle,\n    args: [{ entryId }],\n  })\n\n  useCommandBinding({\n    when: baseCondition,\n    commandId: COMMAND_ID.entry.share,\n    args: [{ entryId }],\n  })\n\n  const navigate = useNavigateEntry()\n  useHotkeys(\n    \"Escape\",\n    () => {\n      navigate({ entryId: null })\n    },\n    {\n      enabled: baseCondition,\n    },\n  )\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContentFallback.tsx",
    "content": "import { usePrefetchEntryDetail } from \"@follow/store/entry/hooks\"\nimport { memo, Suspense } from \"react\"\n\nimport { EntryNotFound } from \"~/components/errors/EntryNotFound\"\n\nimport { EntryContentLoading } from \"./EntryContentLoading\"\n\ninterface EntryContentFallbackProps {\n  entryId: string\n  children: React.ReactNode\n}\n\n/**\n * Reusable fallback wrapper component that handles:\n * 1. Entry prefetching and 404 detection\n * 2. Suspense fallback with loading state\n * 3. Error boundary for entry not found cases\n */\nexport const EntryContentFallback = memo(({ entryId, children }: EntryContentFallbackProps) => {\n  const { data: realEntry, isPending: loadingRemoteEntry } = usePrefetchEntryDetail(entryId)\n\n  if (!loadingRemoteEntry && !realEntry) {\n    // 404\n    throw new EntryNotFound()\n  }\n\n  return (\n    <Suspense\n      fallback={\n        <div className=\"absolute inset-0 flex flex-1 items-center justify-center\">\n          <EntryContentLoading />\n        </div>\n      }\n    >\n      {children}\n    </Suspense>\n  )\n})\n\nEntryContentFallback.displayName = \"EntryContentFallback\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContentLoading.tsx",
    "content": "import { Avatar, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { LoadingCircle, LoadingWithIcon } from \"@follow/components/ui/loading/index.jsx\"\nimport { getUrlIcon } from \"@follow/utils/utils\"\n\nexport const EntryContentLoading = (props: { icon?: string | null }) => {\n  if (!props.icon) {\n    return <LoadingWithIcon size=\"large\" icon={<i className=\"i-mgc-docment-cute-re\" />} />\n  }\n  return (\n    <div className=\"center mb-14 flex flex-col gap-4\">\n      <Avatar className=\"animate-pulse rounded-sm\">\n        <AvatarImage src={getUrlIcon(props.icon).src} />\n      </Avatar>\n      <LoadingCircle size=\"medium\" />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryNoContent.tsx",
    "content": "import { WEB_BUILD } from \"@follow/shared/constants\"\nimport type { FC } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ReadabilityStatus, useEntryInReadabilityStatus } from \"~/atoms/readability\"\nimport { useShowSourceContent } from \"~/atoms/source-content\"\n\nimport { ReadabilityAutoToggleEffect } from \"../ApplyEntryActions\"\n\nexport const EntryNoContent: FC<{\n  id: string\n  url: string\n}> = ({ id, url }) => {\n  const status = useEntryInReadabilityStatus(id)\n  const showSourceContent = useShowSourceContent()\n  const { t } = useTranslation(\"app\")\n\n  if (status !== ReadabilityStatus.INITIAL && status !== ReadabilityStatus.FAILURE) {\n    return null\n  }\n  return (\n    <div className=\"center\">\n      <div className=\"space-y-2 text-balance text-center text-sm text-zinc-400\">\n        {(WEB_BUILD || status === ReadabilityStatus.FAILURE) && (\n          <span>{t(\"entry_content.no_content\")}</span>\n        )}\n        {!showSourceContent && url && <ReadabilityAutoToggleEffect url={url} id={id} />}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryRenderError.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { FallbackRender } from \"~/components/common/ErrorBoundary\"\nimport { getNewIssueUrl } from \"~/lib/issues\"\n\nexport const EntryRenderError: FallbackRender = ({ error }) => {\n  const { t } = useTranslation()\n  const nextError = typeof error === \"string\" ? new Error(error) : (error as Error)\n  return (\n    <div className=\"center mt-16 flex flex-col gap-2\">\n      <i className=\"i-mgc-close-cute-re text-3xl text-red\" />\n      <span className=\"font-sans text-sm\">\n        {t(\"entry_content.render_error\")} {nextError.message}\n      </span>\n      <Button\n        variant={\"outline\"}\n        onClick={() => {\n          window.open(\n            getNewIssueUrl({\n              template: \"bug_report.yml\",\n            }),\n          )\n        }}\n      >\n        {t(\"entry_content.report_issue\")}\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryScrollingAndNavigationHandler.tsx",
    "content": "import {\n  useFocusActions,\n  useGlobalFocusableScopeSelector,\n} from \"@follow/components/common/Focusable/index.js\"\nimport { Spring } from \"@follow/components/constants/spring.js\"\nimport { useSmoothScroll } from \"@follow/hooks\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { clsx, combineCleanupFunctions } from \"@follow/utils/utils\"\nimport type { JSAnimation } from \"motion/react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandBinding } from \"~/modules/command/hooks/use-command-binding\"\n\nexport const EntryScrollingAndNavigationHandler = ({\n  scrollerRef,\n  scrollAnimationRef,\n}: {\n  scrollerRef: React.RefObject<HTMLDivElement | null>\n  scrollAnimationRef: React.RefObject<JSAnimation<any> | null>\n}) => {\n  const isAlreadyScrolledBottomRef = useRef(false)\n  const [showKeepScrollingPanel, setShowKeepScrollingPanel] = useState(false)\n\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)\n\n  useCommandBinding({\n    commandId: COMMAND_ID.entryRender.scrollUp,\n    when,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.entryRender.scrollDown,\n    when,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.entryRender.nextEntry,\n    when,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.entryRender.previousEntry,\n    when,\n  })\n\n  const { highlightBoundary } = useFocusActions()\n  const smoothScrollTo = useSmoothScroll()\n  const navigateToNext = useEventCallback(() => {\n    EventBus.dispatch(COMMAND_ID.timeline.switchToNext)\n    setShowKeepScrollingPanel(false)\n    isAlreadyScrolledBottomRef.current = false\n    if (scrollerRef.current) {\n      smoothScrollTo(0, scrollerRef.current)\n    }\n  })\n  useEffect(() => {\n    const checkScrollBottom = ($scroller: HTMLDivElement) => {\n      const currentScroll = $scroller.scrollTop\n      const { scrollHeight, clientHeight } = $scroller\n\n      if (isAlreadyScrolledBottomRef.current) {\n        navigateToNext()\n        return\n      }\n\n      if (scrollHeight && clientHeight) {\n        isAlreadyScrolledBottomRef.current =\n          Math.abs(currentScroll + clientHeight - scrollHeight) < 2\n        setShowKeepScrollingPanel(isAlreadyScrolledBottomRef.current)\n      }\n    }\n\n    const cleanupScrollAnimation = () => {\n      scrollAnimationRef.current?.stop()\n      scrollAnimationRef.current = null\n    }\n    return combineCleanupFunctions(\n      cleanupScrollAnimation,\n      EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => {\n        const $scroller = scrollerRef.current\n        if (!$scroller) return\n\n        const currentScroll = $scroller.scrollTop\n        // Smart scroll distance: larger viewports get larger scroll distances\n        // But cap it at a reasonable maximum for very large screens\n        const viewportHeight = $scroller.clientHeight\n        const delta = Math.min(Math.max(120, viewportHeight * 0.25), 250)\n\n        cleanupScrollAnimation()\n        const targetScroll = Math.max(0, currentScroll - delta)\n        smoothScrollTo(targetScroll, $scroller)\n        checkScrollBottom($scroller)\n      }),\n\n      EventBus.subscribe(COMMAND_ID.entryRender.scrollDown, () => {\n        const $scroller = scrollerRef.current\n        if (!$scroller) return\n\n        const currentScroll = $scroller.scrollTop\n        // Smart scroll distance: larger viewports get larger scroll distances\n        // But cap it at a reasonable maximum for very large screens\n        const viewportHeight = $scroller.clientHeight\n        const delta = Math.min(Math.max(120, viewportHeight * 0.25), 250)\n\n        cleanupScrollAnimation()\n        const targetScroll = Math.min(\n          $scroller.scrollHeight - $scroller.clientHeight,\n          currentScroll + delta,\n        )\n        smoothScrollTo(targetScroll, $scroller)\n        checkScrollBottom($scroller)\n      }),\n      EventBus.subscribe(\n        COMMAND_ID.layout.focusToEntryRender,\n        ({ highlightBoundary: highlight }) => {\n          const $scroller = scrollerRef.current\n          if (!$scroller) {\n            return\n          }\n\n          $scroller.focus()\n          if (highlight) {\n            nextFrame(highlightBoundary)\n          }\n        },\n      ),\n    )\n  }, [highlightBoundary, navigateToNext, scrollAnimationRef, scrollerRef, smoothScrollTo])\n\n  return (\n    <AnimatePresence>\n      {showKeepScrollingPanel && (\n        <m.div\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={Spring.presets.smooth}\n          className={clsx(\n            \"pointer-events-none absolute !right-1/2 z-40 !translate-x-1/2\",\n            \"bottom-12\",\n            \"rounded-full border px-3.5 py-2 backdrop-blur-background\",\n            \"border-border/40 bg-material-ultra-thick shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)]\",\n            \"hover:border-border/60 hover:bg-material-thin/70 active:scale-[0.98]\",\n          )}\n        >\n          <button\n            onClick={navigateToNext}\n            type=\"button\"\n            className={\"group pointer-events-auto flex items-center gap-2\"}\n          >\n            <i className=\"i-mingcute-arrow-down-fill mr-1 size-5 text-text/90\" />\n            <span className=\"text-left text-[13px] font-medium text-text/90\">\n              Already scrolled to the bottom.\n              <br />\n              Keep pressing to jump to the next article\n            </span>\n          </button>\n        </m.div>\n      )}\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { useEffect } from \"react\"\n\nimport { setEntryTitleMeta } from \"../../atoms\"\n\nexport const EntryTitleMetaHandler: Component<{\n  entryId: string\n}> = ({ entryId }) => {\n  const entry = useEntry(entryId, (state) => {\n    const { feedId, inboxHandle } = state\n    const { title } = state\n\n    return { feedId, inboxId: inboxHandle, title }\n  })\n\n  const feed = useFeedById(entry?.feedId)\n  const inbox = useInboxById(entry?.inboxId)\n  const feedTitle = feed?.title || inbox?.title\n\n  useEffect(() => {\n    if (!entry?.feedId) return\n    setEntryTitleMeta({\n      entryTitle: entry?.title || \"\",\n      feedTitle: feedTitle || \"\",\n      feedId: entry?.feedId || \"\",\n      entryId,\n    })\n\n    return () => {\n      setEntryTitleMeta(null)\n    }\n  }, [entryId, entry?.title, feedTitle, entry?.feedId])\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/ReadabilityNotice.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingWithIcon } from \"@follow/components/ui/loading/index.jsx\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  ReadabilityStatus,\n  setReadabilityStatus,\n  useEntryInReadabilityStatus,\n  useEntryIsInReadability,\n} from \"~/atoms/readability\"\n\nexport const ReadabilityNotice = ({ entryId }: { entryId: string }) => {\n  const { t } = useTranslation()\n  const { t: T } = useTranslation(\"common\")\n  const result = useEntry(entryId, (state) => state.readabilityContent)\n  const isInReadability = useEntryIsInReadability(entryId)\n  const status = useEntryInReadabilityStatus(entryId)\n\n  if (!isInReadability) {\n    return null\n  }\n\n  return (\n    <div className=\"grow\">\n      {result ? (\n        <p className=\"mb-4 flex items-center gap-2 rounded-lg border border-blue-100 bg-blue-50/30 p-3 text-sm text-blue-700 shadow-sm dark:border-blue-800/30 dark:bg-blue-900/10 dark:text-blue-300\">\n          <i className=\"i-mgc-information-cute-re self-baseline text-lg\" />\n          {t(\"entry_content.readability_notice\")}\n        </p>\n      ) : (\n        <>\n          {status === ReadabilityStatus.FAILURE ? (\n            <div className=\"center mt-36 flex flex-col items-center gap-3\">\n              <i className=\"i-mgc-warning-cute-re text-4xl text-red\" />\n              <span className=\"text-balance text-center text-sm\">\n                {t(\"entry_content.fetching_content_failed\")}\n              </span>\n              <Button\n                variant=\"outline\"\n                onClick={() => {\n                  setReadabilityStatus({\n                    [entryId]: ReadabilityStatus.INITIAL,\n                  })\n                }}\n              >\n                {T(\"words.back\")}\n              </Button>\n            </div>\n          ) : status === ReadabilityStatus.WAITING ? (\n            <div className=\"center mt-32 flex flex-col gap-2\">\n              <LoadingWithIcon size=\"large\" icon={<i className=\"i-mgc-docment-cute-re\" />} />\n              <span className=\"text-sm\">{t(\"entry_content.fetching_content\")}</span>\n            </div>\n          ) : (\n            <div className=\"center mt-32\">\n              <span className=\"text-sm\">{t(\"entry_content.no_content\")}</span>\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/accessories/ContainerToc.tsx",
    "content": "import { getViewport } from \"@follow/components/hooks/useViewport.js\"\nimport { CircleProgress } from \"@follow/components/icons/Progress.js\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.jsx\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { springScrollTo } from \"@follow/utils/scroller\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useStore } from \"jotai\"\nimport { memo, useEffect, useMemo, useState } from \"react\"\n\nimport { setAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport type { TocRef } from \"~/components/ui/markdown/components/Toc\"\nimport { Toc } from \"~/components/ui/markdown/components/Toc\"\nimport { useFeature } from \"~/hooks/biz/useFeature\"\nimport { useWrappedElement, useWrappedElementSize } from \"~/providers/wrapped-element-provider\"\n\nconst useReadPercent = () => {\n  const y = 55\n  const { h } = useWrappedElementSize()\n\n  const scrollElement = useScrollViewElement()\n  const [scrollTop, setScrollTop] = useState(0)\n\n  useEffect(() => {\n    const handler = () => {\n      if (scrollElement) {\n        setScrollTop(scrollElement.scrollTop)\n      }\n    }\n    handler()\n    scrollElement?.addEventListener(\"scroll\", handler)\n    return () => {\n      scrollElement?.removeEventListener(\"scroll\", handler)\n    }\n  }, [scrollElement])\n\n  const store = useStore()\n  const readPercent = useMemo(() => {\n    const winHeight = getViewport(store).h\n    const deltaHeight = Math.min(scrollTop, winHeight)\n\n    return Math.floor(Math.min(Math.max(0, ((scrollTop - y + deltaHeight) / h) * 100), 100)) || 0\n  }, [store, scrollTop, h])\n\n  return [readPercent, scrollTop]\n}\n\nconst BackTopIndicator: Component = memo(({ className }) => {\n  const [readPercent] = useReadPercent()\n  const scrollElement = useScrollViewElement()\n  const aiEnabled = useFeature(\"ai\")\n\n  return (\n    <span\n      className={cn(\n        \"mt-2 flex grow flex-col px-2 font-sans text-sm text-gray-800 dark:text-neutral-300\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2 tabular-nums\">\n        <CircleProgress percent={readPercent!} size={14} strokeWidth={2} />\n        <span>{readPercent}%</span>\n        <br />\n      </div>\n      {aiEnabled && (\n        <MotionButtonBase\n          onClick={() => {\n            setAIPanelVisibility(true)\n          }}\n          className={cn(\n            \"mt-1 flex flex-nowrap items-center gap-2 text-sm opacity-50 transition-all duration-500 hover:opacity-100\",\n          )}\n        >\n          <i className=\"i-mgc-ai-cute-re\" />\n          <span>Ask AI</span>\n        </MotionButtonBase>\n      )}\n      <MotionButtonBase\n        onClick={() => {\n          springScrollTo(0, scrollElement!)\n        }}\n        className={cn(\n          \"mt-1 flex flex-nowrap items-center gap-2 opacity-50 transition-all duration-500 hover:opacity-100\",\n          readPercent! > 10 ? \"\" : \"pointer-events-none opacity-0\",\n        )}\n      >\n        <i className=\"i-mingcute-arrow-up-circle-line\" />\n        <span className=\"whitespace-nowrap\">Back Top</span>\n      </MotionButtonBase>\n    </span>\n  )\n})\n\nexport const ContainerToc = memo(\n  ({\n    ref,\n    className,\n    stickyClassName,\n  }: ComponentType & {\n    ref?: React.Ref<TocRef | null>\n    className?: string\n    stickyClassName?: string\n  }) => {\n    const wrappedElement = useWrappedElement()\n\n    return (\n      <RootPortal to={wrappedElement!}>\n        <div\n          className={cn(\n            \"group absolute right-[-130px] top-0 hidden h-full w-[100px] @[770px]:block\",\n            className,\n          )}\n          data-hide-in-print\n        >\n          <div className={cn(\"sticky top-0\", stickyClassName)}>\n            <Toc\n              ref={ref}\n              className={cn(\n                \"flex flex-col items-end animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft\",\n                \"max-h-[calc(100vh-100px)] overflow-auto scrollbar-none\",\n                \"@[700px]:-translate-x-12 @[800px]:-translate-x-4 @[900px]:translate-x-0 @[900px]:items-start\",\n              )}\n            />\n            <BackTopIndicator\n              className={\n                \"@[700px]:-translate-x-4 @[800px]:-translate-x-8 @[900px]:translate-x-0 @[900px]:items-start\"\n              }\n            />\n          </div>\n        </div>\n      </RootPortal>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/accessories/index.tsx",
    "content": "import type { Ref } from \"react\"\n\nimport type { TocRef } from \"~/components/ui/markdown/components/Toc\"\n\nimport { ContainerToc } from \"./ContainerToc\"\n\nexport type EntryContentAccessoriesRef = {\n  tocRef: Ref<TocRef | null>\n}\nexport const EntryContentAccessories = ({ ref }: { ref: EntryContentAccessoriesRef }) => {\n  return (\n    <>\n      <ContainerToc ref={ref.tocRef} />\n      {/* <EntryAIChatInput /> */}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/index.ts",
    "content": "export * from \"../../EntryContent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/types.tsx",
    "content": "// Export types that were defined in the original file\nexport interface EntryContentProps {\n  entryId: string\n  noMedia?: boolean\n  compact?: boolean\n  classNames?: EntryContentClassNames\n}\nexport interface EntryContentClassNames {\n  header?: string\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/AIEntryHeader.tsx",
    "content": "import { memo, useLayoutEffect, useRef, useState } from \"react\"\n\nimport { useEntryContentScrollToTop } from \"../../atoms\"\nimport { EntryHeaderRoot } from \"./internal/context\"\nimport { EntryHeaderActionsContainer } from \"./internal/EntryHeaderActionsContainer\"\nimport { EntryHeaderBreadcrumb } from \"./internal/EntryHeaderBreadcrumb\"\nimport type { EntryHeaderProps } from \"./types\"\n\nfunction EntryHeaderImpl({ entryId, className, compact }: EntryHeaderProps) {\n  const isAtTop = useEntryContentScrollToTop()\n  const headerRef = useRef<HTMLDivElement>(null)\n  const [isSmallWidth, setIsSmallWidth] = useState(false)\n  useLayoutEffect(() => {\n    const $header = headerRef.current\n    if (!$header) return\n    const handler = () => setIsSmallWidth($header.clientWidth <= 500)\n\n    const observer = new ResizeObserver(handler)\n    observer.observe($header)\n    handler()\n\n    return () => {\n      observer.disconnect()\n    }\n  }, [headerRef])\n  return (\n    <EntryHeaderRoot entryId={entryId} className={className} compact={compact}>\n      <nav\n        className=\"group/header relative z-10 flex h-top-header w-full items-center justify-between gap-3 bg-background px-4 @container\"\n        data-at-top={isAtTop}\n        data-hide-in-print\n        ref={headerRef}\n      >\n        <EntryHeaderBreadcrumb />\n        <EntryHeaderActionsContainer isSmallWidth={isSmallWidth} />\n      </nav>\n    </EntryHeaderRoot>\n  )\n}\n\nexport const AIEntryHeader = memo(EntryHeaderImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/EntryHeader.tsx",
    "content": "import { memo } from \"react\"\n\nimport { EntryHeaderRoot } from \"./internal/context\"\nimport { EntryHeaderActionsContainer } from \"./internal/EntryHeaderActionsContainer\"\nimport { EntryHeaderMeta } from \"./internal/EntryHeaderMeta\"\nimport { EntryHeaderReadHistory } from \"./internal/EntryHeaderReadHistory\"\nimport type { EntryHeaderProps } from \"./types\"\n\nfunction EntryHeaderImpl({ entryId, className, compact }: EntryHeaderProps) {\n  return (\n    <EntryHeaderRoot entryId={entryId} className={className} compact={compact}>\n      <EntryHeaderReadHistory />\n      <div\n        className=\"relative z-10 flex w-full items-center justify-between gap-3\"\n        data-hide-in-print\n      >\n        <EntryHeaderMeta />\n        <EntryHeaderActionsContainer />\n      </div>\n    </EntryHeaderRoot>\n  )\n}\n\nexport const EntryHeader = memo(EntryHeaderImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/index.ts",
    "content": "export * from \"./AIEntryHeader\"\nexport * from \"./EntryHeader\"\nexport * from \"./internal/context\"\nexport * from \"./internal/EntryHeaderActionsContainer\"\nexport * from \"./internal/EntryHeaderMeta\"\nexport * from \"./types\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderActionsContainer.tsx",
    "content": "import clsx from \"clsx\"\nimport { memo } from \"react\"\n\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { EntryHeaderActions } from \"../../../actions/header-actions\"\nimport { MoreActions } from \"../../../actions/more-actions\"\nimport { useEntryHeaderContext } from \"./context\"\n\nfunction EntryHeaderActionsContainerImpl({ isSmallWidth }: { isSmallWidth?: boolean }) {\n  const { entryId } = useEntryHeaderContext()\n  const { view } = useRouteParams()\n\n  return (\n    <div className={clsx(\"relative flex shrink-0 items-center justify-end gap-2\")}>\n      {!isSmallWidth && <EntryHeaderActions entryId={entryId} view={view} />}\n      <MoreActions entryId={entryId} view={view} showMainAction={isSmallWidth} />\n    </div>\n  )\n}\n\nexport const EntryHeaderActionsContainer = memo(EntryHeaderActionsContainerImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx",
    "content": "import { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport { getView } from \"@follow/constants\"\nimport { getEntry, getEntryIdsByFeedId } from \"@follow/store/entry/getter\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport {\n  getFeedSubscriptionByViewSelector,\n  getListSubscriptionByViewSelector,\n} from \"@follow/store/subscription/getter\"\nimport type {\n  useFeedSubscriptionByView,\n  useListSubscriptionByView,\n} from \"@follow/store/subscription/hooks\"\nimport { useSubscriptionStore } from \"@follow/store/subscription/store\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useForceUpdate } from \"motion/react\"\nimport { useCallback, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams, useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useShowEntryDetailsColumn } from \"~/hooks/biz/useShowEntryDetailsColumn\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { useEntryTitleMeta } from \"../../../atoms\"\nimport { useEntryHeaderContext } from \"./context\"\n\nconst Slash = (\n  <i className=\"i-mingcute-line-line size-4 shrink-0 rotate-[-25deg] text-text-tertiary\" />\n)\n\nfunction ViewSubscriptionsDropdown({\n  view,\n  onNavigate,\n}: {\n  view: number\n  onNavigate: ReturnType<typeof useNavigateEntry>\n}) {\n  const feedSubsRef = useRef<ReturnType<typeof useFeedSubscriptionByView>>([])\n  const listSubsRef = useRef<ReturnType<typeof useListSubscriptionByView>>([])\n  const [forceUpdate] = useForceUpdate()\n\n  const handleRefreshDropDownData = useCallback(\n    (open: boolean) => {\n      if (!open) return\n\n      // Get fresh data from store\n      const state = useSubscriptionStore.getState()\n      const feedSubs = getFeedSubscriptionByViewSelector(state)(view)\n      const listSubs = getListSubscriptionByViewSelector(state)(view)\n\n      feedSubsRef.current = feedSubs || []\n      listSubsRef.current = listSubs || []\n\n      forceUpdate()\n    },\n    [view, forceUpdate],\n  )\n\n  const routeParams = getRouteParams()\n  const { isAllFeeds, listId, feedId } = routeParams\n\n  // Check if there's any subscription data for this view (without causing re-render)\n  // This allows initial render to show the dropdown if subscriptions exist\n  const state = useSubscriptionStore.getState()\n  const initialFeedSubs = getFeedSubscriptionByViewSelector(state)(view)\n  const initialListSubs = getListSubscriptionByViewSelector(state)(view)\n  const hasAnyInitial = (initialFeedSubs?.length ?? 0) + (initialListSubs?.length ?? 0) > 0\n\n  if (!hasAnyInitial) return null\n\n  return (\n    <DropdownMenu onOpenChange={handleRefreshDropDownData}>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"no-drag-region -ml-1 inline-flex size-6 items-center justify-center rounded text-text-tertiary transition-colors hover:text-text focus-visible:bg-fill/60\"\n          aria-label=\"Open subscriptions of this view\"\n        >\n          <i className=\"i-mingcute-down-line size-4\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\" className=\"p-0\">\n        <ScrollArea.ScrollArea\n          flex\n          rootClassName=\"max-h-[60vh] min-h-0 relative min-w-64\"\n          viewportClassName=\"max-h-[60vh]\"\n        >\n          <div className=\"p-1\">\n            <DropdownMenuItem\n              onClick={() => onNavigate({ entryId: null, view })}\n              checked={isAllFeeds}\n            >\n              <span className=\"truncate\">All</span>\n            </DropdownMenuItem>\n            {listSubsRef.current && listSubsRef.current.length > 0 && (\n              <div className=\"px-2 py-1 text-xs text-text-tertiary\">Lists</div>\n            )}\n            {listSubsRef.current?.map((s) =>\n              s.listId ? (\n                <DropdownMenuItem\n                  checked={s.listId === listId}\n                  key={`list-${s.listId}`}\n                  onClick={() => s.listId && onNavigate({ entryId: null, listId: s.listId })}\n                >\n                  <ListNameItem listId={s.listId} />\n                </DropdownMenuItem>\n              ) : null,\n            )}\n            {feedSubsRef.current && feedSubsRef.current.length > 0 && (\n              <div className=\"px-2 py-1 text-xs text-text-tertiary\">Feeds</div>\n            )}\n            {feedSubsRef.current?.map((s) =>\n              s.feedId ? (\n                <DropdownMenuItem\n                  checked={s.feedId === feedId}\n                  key={`feed-${s.feedId}`}\n                  onClick={() => s.feedId && onNavigate({ entryId: null, feedId: s.feedId })}\n                >\n                  <FeedNameItem feedId={s.feedId} />\n                </DropdownMenuItem>\n              ) : null,\n            )}\n          </div>\n        </ScrollArea.ScrollArea>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n\nconst ListNameItem = ({ listId }: { listId: string }) => {\n  const name = useListById(listId, (s) => s?.title)\n  if (!name) return null\n  return <span className=\"truncate\">{name}</span>\n}\n\nconst FeedNameItem = ({ feedId }: { feedId: string }) => {\n  const feed = useFeedById(feedId)\n\n  if (!feed) return null\n  return <span className=\"truncate\">{getPreferredTitle(feed)}</span>\n}\n\nfunction FeedEntriesDropdown({\n  feedId,\n  currentEntryId,\n  onNavigate,\n}: {\n  feedId: string\n  currentEntryId: string\n  onNavigate: ReturnType<typeof useNavigateEntry>\n}) {\n  const siblingEntriesRef = useRef<{ id: string; title: string }[]>([])\n  const [forceUpdate] = useForceUpdate()\n\n  const handleRefreshDropDownData = useCallback(\n    (open: boolean) => {\n      if (!open) return\n\n      const entryIds = getEntryIdsByFeedId(feedId)\n      if (!entryIds) return\n\n      siblingEntriesRef.current = []\n      for (const entryId of entryIds) {\n        const entry = getEntry(entryId)\n        if (!entry) continue\n        const { title } = entry\n        if (!title) continue\n        siblingEntriesRef.current.push({ id: entryId, title })\n      }\n\n      forceUpdate()\n    },\n    [feedId, forceUpdate],\n  )\n\n  // Check if there are any entries for this feed\n  const entryIds = getEntryIdsByFeedId(feedId)\n  if (!entryIds || entryIds.length <= 1) return null\n\n  return (\n    <DropdownMenu onOpenChange={handleRefreshDropDownData}>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"no-drag-region -ml-2 inline-flex size-6 items-center justify-center rounded text-text-tertiary transition-colors hover:text-text focus-visible:bg-fill/60\"\n          aria-label=\"Open entries from this feed\"\n        >\n          <i className=\"i-mingcute-down-line size-4\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\" className=\"p-0\">\n        <ScrollArea.ScrollArea\n          rootClassName=\"max-h-[60vh] min-w-64\"\n          viewportClassName=\"max-h-[60vh]\"\n        >\n          <div className=\"p-1\">\n            {siblingEntriesRef.current.map((e) => (\n              <DropdownMenuItem\n                key={e.id}\n                onClick={() => onNavigate({ entryId: e.id })}\n                checked={e.id === currentEntryId}\n              >\n                <span className=\"truncate\" title={e.title}>\n                  {e.title}\n                </span>\n              </DropdownMenuItem>\n            ))}\n          </div>\n        </ScrollArea.ScrollArea>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n\nexport function EntryHeaderBreadcrumb() {\n  const meta = useEntryTitleMeta()\n\n  const navigate = useNavigateEntry()\n  const { entryId } = useEntryHeaderContext()\n\n  const { t } = useTranslation()\n  const view = useRouteParamsSelector((s) => s.view)\n  const viewName = getView(view)?.name\n  const showEntryDetailsColumn = useShowEntryDetailsColumn()\n  if (showEntryDetailsColumn && meta?.entryTitle) {\n    return (\n      <EllipsisHorizontalTextWithTooltip className=\"min-w-0 truncate px-1.5 py-0.5 text-lg font-bold leading-tight text-text opacity-0 transition-opacity duration-200 group-data-[at-top=false]/header:opacity-100\">\n        {meta.entryTitle}\n      </EllipsisHorizontalTextWithTooltip>\n    )\n  }\n  return (\n    <div className=\"flex min-w-0 flex-1 overflow-hidden\">\n      <nav\n        aria-label=\"Breadcrumb\"\n        className={\n          \"group/breadcrumb flex min-w-0 items-center gap-1 truncate leading-tight text-text-secondary\"\n        }\n      >\n        <div className=\"flex min-w-0 items-center gap-1\">\n          {/* Return Back Button  */}\n          <button\n            type=\"button\"\n            className=\"no-drag-region inline-flex shrink-0 items-center rounded-full bg-transparent p-2 text-text-secondary hover:bg-fill/50 hover:text-text focus-visible:bg-fill/60\"\n            onClick={() => navigate({ entryId: null, view })}\n          >\n            <i className=\"i-mingcute-close-line size-5\" />\n          </button>\n          {viewName && (\n            <div className=\"hidden items-center @[700px]:flex\">\n              <button\n                type=\"button\"\n                className={cn(\n                  \"no-drag-region inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm text-text-secondary transition-colors hover:bg-fill/50 hover:text-text focus-visible:bg-fill/60\",\n                )}\n                onClick={() => navigate({ entryId: null, view })}\n              >\n                <span className=\"text-sm text-text-secondary\">{t(viewName, { ns: \"common\" })}</span>\n              </button>\n\n              <ViewSubscriptionsDropdown view={view} onNavigate={navigate} />\n            </div>\n          )}\n          {meta && (\n            <>\n              <span className=\"hidden @[700px]:inline\">{Slash}</span>\n              <div className=\"hidden min-w-0 shrink items-center @[700px]:flex\">\n                <button\n                  type=\"button\"\n                  className={cn(\n                    \"no-drag-region inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm text-text-secondary transition-colors hover:bg-fill/50 hover:text-text focus-visible:bg-fill/60\",\n                  )}\n                  onClick={() => navigate({ entryId: null, feedId: meta.feedId })}\n                  title={meta.feedTitle}\n                >\n                  <span className=\"truncate\">{meta.feedTitle}</span>\n                </button>\n\n                <FeedEntriesDropdown\n                  feedId={meta.feedId}\n                  currentEntryId={entryId}\n                  onNavigate={navigate}\n                />\n              </div>\n\n              {!!meta.entryTitle && (\n                <>\n                  <span className=\"hidden shrink-0 @[700px]:inline\">{Slash}</span>\n                  <span\n                    className=\"min-w-0 max-w-[30vw] truncate px-1.5 py-0.5 text-sm text-text\"\n                    title={meta.entryTitle}\n                  >\n                    {meta.entryTitle}\n                  </span>\n                </>\n              )}\n            </>\n          )}\n        </div>\n      </nav>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderMeta.tsx",
    "content": "import { AnimatePresence, m } from \"motion/react\"\nimport { memo } from \"react\"\n\nimport { useEntryContentScrollToTop, useEntryTitleMeta } from \"../../../atoms\"\n\nfunction EntryHeaderMetaImpl() {\n  const entryTitleMeta = useEntryTitleMeta()\n  const isAtTop = useEntryContentScrollToTop()\n  const shouldShowMeta = !isAtTop && !!entryTitleMeta?.entryTitle\n  return (\n    <div className=\"flex min-w-0 shrink grow\">\n      <AnimatePresence>\n        {shouldShowMeta && entryTitleMeta && (\n          <m.div\n            initial={{ opacity: 0.01, y: 30 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0.01, y: 30 }}\n            className=\"flex min-w-0 flex-1 shrink items-end gap-2 truncate text-title3 leading-tight text-text\"\n          >\n            <span className=\"shrink truncate font-bold\">{entryTitleMeta.entryTitle}</span>\n            <i className=\"i-mgc-line-cute-re size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg] text-text-secondary\" />\n            <span className=\"shrink -translate-y-px truncate text-headline text-text-secondary\">\n              {entryTitleMeta.feedTitle}\n            </span>\n          </m.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nexport const EntryHeaderMeta = memo(EntryHeaderMetaImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderReadHistory.tsx",
    "content": "import { getView } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { memo } from \"react\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nimport { useEntryContentScrollToTop } from \"../../../atoms\"\nimport { EntryReadHistory } from \"../../entry-read-history\"\nimport { useEntryHeaderContext } from \"./context\"\n\nfunction EntryHeaderReadHistoryImpl({ className }: { className?: string }) {\n  const hideRecentReader = useUISettingKey(\"hideRecentReader\")\n  const { entryId } = useEntryHeaderContext()\n  const { view } = useRouteParams()\n  const isAtTop = useEntryContentScrollToTop()\n  const isWide = getView(view)?.wideMode\n  if (!isAtTop || hideRecentReader) return null\n\n  return (\n    <div\n      className={cn(\n        \"zen-mode-macos:left-12 absolute left-5 top-0 flex h-full items-center gap-2 text-body leading-none\",\n        \"visible z-[11]\",\n        isWide && \"static\",\n        className,\n      )}\n    >\n      <EntryReadHistory entryId={entryId} />\n    </div>\n  )\n}\n\nexport const EntryHeaderReadHistory = memo(EntryHeaderReadHistoryImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/context.tsx",
    "content": "import { useHasEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { MotionStyle } from \"motion/react\"\nimport type { ReactNode } from \"react\"\nimport { createContext, memo, use, useMemo } from \"react\"\n\nimport { m } from \"~/components/common/Motion\"\n\nimport { useEntryContentScrollToTop, useEntryTitleMeta } from \"../../../atoms\"\nimport type { EntryHeaderProps } from \"../types\"\n\ninterface EntryHeaderContextValue {\n  entryId: string\n}\n\nconst EntryHeaderContext = createContext<EntryHeaderContextValue | null>(null)\n\nexport function useEntryHeaderContext() {\n  const ctx = use(EntryHeaderContext)\n  if (!ctx) throw new Error(\"EntryHeader components must be used within <EntryHeaderRoot />\")\n  return ctx\n}\n\nexport interface EntryHeaderRootProps extends EntryHeaderProps {\n  children: ReactNode\n  style?: MotionStyle\n}\n\nfunction EntryHeaderRootImpl({\n  entryId,\n  className,\n  compact,\n  children,\n  style,\n}: EntryHeaderRootProps) {\n  const hasEntry = useHasEntry(entryId)\n  const entryTitleMeta = useEntryTitleMeta()\n  const isAtTop = !!useEntryContentScrollToTop()\n\n  const shouldShowMeta = !isAtTop && !!entryTitleMeta?.entryTitle\n\n  const contextValue = useMemo(() => ({ entryId, compact }), [entryId, compact])\n  if (!hasEntry) return null\n\n  return (\n    <EntryHeaderContext value={contextValue}>\n      <m.div\n        data-hide-in-print\n        className={cn(\n          \"relative flex min-w-0 items-center justify-between gap-3 overflow-hidden border-b border-transparent text-lg text-text-secondary duration-200 macos-left-column-hidden:pl-margin-macos-traffic-light-x\",\n          shouldShowMeta && \"border-border\",\n          className,\n        )}\n        style={style}\n      >\n        {children}\n      </m.div>\n    </EntryHeaderContext>\n  )\n}\n\nexport const EntryHeaderRoot = memo(EntryHeaderRootImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/types.tsx",
    "content": "export interface EntryHeaderProps {\n  entryId: string\n  className?: string\n  compact?: boolean\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.tsx",
    "content": "import { AvatarGroup } from \"@follow/components/ui/avatar-group/index.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useEntryReadHistory } from \"@follow/store/entry/hooks\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\n\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useAppLayoutGridContainerWidth } from \"~/providers/app-grid-layout-container-provider\"\n\nimport { EntryUser } from \"./EntryUser\"\n\nconst getLimit = (width: number): number => {\n  const routeParams = getRouteParams()\n  // social media view has four extra buttons\n  if (\n    [FeedViewType.SocialMedia, FeedViewType.Pictures, FeedViewType.Videos].includes(\n      routeParams.view,\n    )\n  ) {\n    if (width > 1100) return 15\n    if (width > 950) return 10\n    if (width > 800) return 5\n    return 3\n  }\n  if (width > 900) return 15\n  if (width > 600) return 10\n  return 5\n}\n\nexport const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) => {\n  const me = useWhoami()\n  const data = useEntryReadHistory(entryId)\n  const entryHistory = data?.entryReadHistories\n\n  const totalCount = data?.total || 0\n\n  const appGirdContainerWidth = useAppLayoutGridContainerWidth()\n\n  const LIMIT = getLimit(appGirdContainerWidth)\n\n  const placeholder = <div className=\"-mb-3 h-10\" />\n  if (!entryHistory) return placeholder\n  if (!me) return placeholder\n\n  const displayUsers = entryHistory.userIds.filter((id) => id !== me?.id).slice(0, LIMIT)\n\n  if (displayUsers.length === 0) return placeholder\n\n  return (\n    <div\n      className=\"-mb-3 hidden h-10 items-center duration-200 animate-in fade-in @md:flex\"\n      data-hide-in-print\n    >\n      <AvatarGroup>\n        {displayUsers.map((userId) => (\n          <EntryUser userId={userId} key={userId} />\n        ))}\n      </AvatarGroup>\n\n      {totalCount > LIMIT && (\n        <div\n          style={{\n            margin: \"-8px\",\n            zIndex: LIMIT + 1,\n          }}\n          className=\"no-drag-region relative flex size-7 items-center justify-center rounded-full border border-border bg-material-opaque ring-2 ring-background\"\n        >\n          <span className=\"text-[10px] font-medium tabular-nums text-text-secondary\">\n            +{Math.min(totalCount - LIMIT, 99)}\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryUser.tsx",
    "content": "import { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { TooltipContent, TooltipPortal } from \"@follow/components/ui/tooltip/index.jsx\"\nimport { useUserById } from \"@follow/store/user/hooks\"\nimport { getAvatarUrl } from \"@follow/utils\"\nimport { getNameInitials } from \"@follow/utils/cjk\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { usePresentUserProfileModal } from \"~/modules/profile/hooks\"\n\nexport const EntryUser: Component<{\n  userId: string\n  ref?: React.Ref<HTMLDivElement>\n}> = memo(({ userId, ref }) => {\n  const user = useUserById(userId)\n  const { t } = useTranslation()\n  const presentUserProfile = usePresentUserProfileModal(\"drawer\")\n  if (!user) return null\n  return (\n    <div className=\"no-drag-region center relative cursor-pointer hover:!z-[99999]\" ref={ref}>\n      <button\n        type=\"button\"\n        onClick={() => {\n          presentUserProfile(userId)\n        }}\n      >\n        <Avatar className=\"aspect-square size-6 border border-border ring-1 ring-background\">\n          <AvatarImage src={getAvatarUrl(user)} className=\"bg-material-ultra-thick\" />\n          <AvatarFallback className=\"text-xs\">{getNameInitials(user.name || \"\")}</AvatarFallback>\n        </Avatar>\n      </button>\n      <TooltipPortal>\n        <TooltipContent side=\"top\">\n          {t(\"entry_actions.recent_reader\")} {user.name}\n        </TooltipContent>\n      </TooltipPortal>\n    </div>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/index.ts",
    "content": "export { EntryReadHistory } from \"./EntryReadHistory\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { isOnboardingEntry } from \"@follow/store/constants/onboarding\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from \"~/atoms/settings/ai\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { ErrorBoundary } from \"~/components/common/ErrorBoundary\"\nimport { ShadowDOM } from \"~/components/common/ShadowDOM\"\nimport type { TocRef } from \"~/components/ui/markdown/components/Toc\"\nimport { useInPeekModal } from \"~/components/ui/modal/inspire/InPeekModal\"\nimport { readableContentMaxWidthClassName } from \"~/constants/ui\"\nimport { useRenderStyle } from \"~/hooks/biz/useRenderStyle\"\nimport { EntryContentHTMLRenderer } from \"~/modules/renderer/html\"\nimport { EntryContentMarkdownRenderer } from \"~/modules/renderer/markdown\"\nimport { WrappedElementProvider } from \"~/providers/wrapped-element-provider\"\n\nimport { useEntryContent, useEntryMediaInfo } from \"../../hooks\"\nimport { AISummary } from \"../AISummary\"\nimport { ContainerToc } from \"../entry-content/accessories/ContainerToc\"\nimport { EntryRenderError } from \"../entry-content/EntryRenderError\"\nimport { ReadabilityNotice } from \"../entry-content/ReadabilityNotice\"\nimport { EntryAttachments } from \"../EntryAttachments\"\nimport { EntryTitle } from \"../EntryTitle\"\nimport { MediaTranscript, TranscriptToggle, useTranscription } from \"./shared\"\nimport { ArticleAudioPlayer } from \"./shared/AudioPlayer\"\nimport type { EntryLayoutProps } from \"./types\"\n\nexport const ArticleLayout: React.FC<EntryLayoutProps> = ({\n  entryId,\n  compact = false,\n  noMedia = false,\n  translation,\n}) => {\n  const entry = useEntry(entryId, (state) => ({\n    feedId: state.feedId,\n    inboxId: state.inboxHandle,\n  }))\n  const { data: transcriptionData } = useTranscription(entryId)\n\n  const feed = useFeedById(entry?.feedId)\n  const isInbox = useIsInbox(entry?.inboxId)\n  const [showTranscript, setShowTranscript] = useState(false)\n\n  const { content } = useEntryContent(entryId)\n  const customCSS = useUISettingKey(\"customCSS\")\n\n  const aiChatPanelStyle = useAIChatPanelStyle()\n  const isAIPanelVisible = useAIPanelVisibility()\n\n  const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible\n\n  if (!entry) return null\n\n  return (\n    <div className={cn(readableContentMaxWidthClassName, \"mx-auto mt-1 px-4\")}>\n      <EntryTitle entryId={entryId} compact={compact} containerClassName=\"mt-12\" />\n\n      <ArticleAudioPlayer entryId={entryId} />\n\n      {/* Content Type Toggle */}\n      <TranscriptToggle\n        showTranscript={showTranscript}\n        onToggle={setShowTranscript}\n        hasTranscript={!!transcriptionData}\n      />\n\n      <WrappedElementProvider boundingDetection>\n        <div className=\"mx-auto mb-32 mt-6 max-w-full cursor-auto text-[0.94rem]\">\n          {shouldShowAISummary && <AISummary entryId={entryId} />}\n          <ErrorBoundary fallback={EntryRenderError}>\n            <ReadabilityNotice entryId={entryId} />\n            {showTranscript ? (\n              <MediaTranscript\n                className=\"prose !max-w-full dark:prose-invert\"\n                srt={transcriptionData}\n                entryId={entryId}\n                type=\"transcription\"\n              />\n            ) : (\n              <ShadowDOM injectHostStyles={!isInbox}>\n                {!!customCSS && <MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>}\n\n                <Renderer\n                  entryId={entryId}\n                  view={FeedViewType.Articles}\n                  feedId={feed?.id || \"\"}\n                  noMedia={noMedia}\n                  content={content}\n                  translation={translation}\n                />\n              </ShadowDOM>\n            )}\n          </ErrorBoundary>\n        </div>\n      </WrappedElementProvider>\n\n      <EntryAttachments entryId={entryId} />\n    </div>\n  )\n}\n\nconst Renderer: React.FC<{\n  entryId: string\n  view: FeedViewType\n  feedId: string\n  noMedia?: boolean\n  content?: Nullable<string>\n  translation?: {\n    content?: string\n    title?: string\n  }\n}> = ({ entryId, view, feedId, noMedia = false, content = \"\", translation }) => {\n  const mediaInfo = useEntryMediaInfo(entryId)\n  const isMarkdownEntry = useMemo(() => {\n    return isOnboardingEntry(entryId)\n  }, [entryId])\n  const readerRenderInlineStyle = useUISettingKey(\"readerRenderInlineStyle\")\n  const stableRenderStyle = useRenderStyle()\n  const isInPeekModal = useInPeekModal()\n\n  const tocRef = useRef<TocRef | null>(null)\n  const contentAccessories = useMemo(\n    () => (isInPeekModal ? undefined : <ContainerToc ref={tocRef} stickyClassName=\"top-48\" />),\n    [isInPeekModal],\n  )\n\n  useEffect(() => {\n    if (tocRef) {\n      tocRef.current?.refreshItems()\n    }\n  }, [content, tocRef])\n\n  const ContentRenderer = useMemo(() => {\n    return isMarkdownEntry ? EntryContentMarkdownRenderer : EntryContentHTMLRenderer\n  }, [isMarkdownEntry])\n  return (\n    <ContentRenderer\n      view={view}\n      feedId={feedId}\n      entryId={entryId}\n      mediaInfo={mediaInfo}\n      noMedia={noMedia}\n      accessory={contentAccessories}\n      as=\"article\"\n      className=\"autospace-normal prose !max-w-full hyphens-auto dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold\"\n      style={stableRenderStyle}\n      renderInlineStyle={readerRenderInlineStyle}\n    >\n      {translation?.content || content}\n    </ContentRenderer>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/MediaLayout.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { transformVideoUrl } from \"@follow/utils/url-for-video\"\n\nimport { ArticleLayout } from \"./ArticleLayout\"\nimport { PicturesLayout } from \"./PicturesLayout\"\nimport type { EntryLayoutProps } from \"./types\"\nimport { VideosLayout } from \"./VideosLayout\"\n\nexport const MediaLayout: React.FC<EntryLayoutProps> = (props) => {\n  const entry = useEntry(props.entryId, (state) => ({\n    media: state.media,\n    id: state.id,\n    url: state.url,\n    attachments: state.attachments,\n  }))\n\n  if (!entry) return null\n\n  // Detect media types - more comprehensive video detection\n  const hasVideoMedia = entry.media?.some((media) => media.type === \"video\")\n  const hasVideoUrl =\n    transformVideoUrl({\n      url: entry.url ?? \"\",\n      isIframe: true,\n      attachments: entry.attachments,\n    }) !== null\n  const hasVideo = hasVideoMedia || hasVideoUrl\n  const hasImages = entry.media?.some((media) => media.type === \"photo\")\n\n  // Video has absolute priority - show video whenever it exists, regardless of noMedia\n  const shouldShowVideo = hasVideo\n  const shouldShowImages = !hasVideo && hasImages && !props.noMedia\n\n  if (shouldShowVideo) {\n    // Use VideosLayout for video content\n    return <VideosLayout {...props} noMedia={false} />\n  }\n\n  if (shouldShowImages) {\n    // Use PicturesLayout for image content\n    return <PicturesLayout {...props} />\n  }\n\n  // Fallback: use ArticleLayout when no media content is detected\n  return <ArticleLayout {...props} />\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/PicturesLayout.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { usePreviewMedia } from \"~/components/ui/media/hooks\"\nimport { SwipeMedia } from \"~/components/ui/media/SwipeMedia\"\nimport { readableContentMaxWidthClassName } from \"~/constants/ui\"\n\nimport { AuthorHeader, ContentBody } from \"./shared\"\nimport type { EntryLayoutProps } from \"./types\"\n\nexport const PicturesLayout: React.FC<EntryLayoutProps> = ({\n  entryId,\n  compact = false,\n  noMedia = false,\n  translation,\n}) => {\n  const entry = useEntry(entryId, (state) => ({ media: state.media, id: state.id }))\n  const previewMedia = usePreviewMedia()\n\n  if (!entry) return null\n\n  return (\n    <div className=\"group mx-auto max-w-4xl space-y-6 p-6\">\n      {!noMedia && (\n        <SwipeMedia\n          media={entry?.media || []}\n          className={cn(\"aspect-square\", \"w-full shrink-0 rounded-md [&_img]:rounded-md\")}\n          imgClassName=\"object-contain\"\n          onPreview={previewMedia}\n          proxySize={null}\n        />\n      )}\n\n      {/* Single Author header without avatar */}\n      <AuthorHeader entryId={entryId} className={cn(\"mx-auto\", readableContentMaxWidthClassName)} />\n\n      {/* Text Content Section */}\n      <ContentBody\n        entryId={entryId}\n        translation={translation}\n        compact={compact}\n        noMedia={true}\n        className=\"mx-auto\"\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/SocialMediaLayout.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { Media } from \"~/components/ui/media/Media\"\nimport { readableContentMaxWidthClassName } from \"~/constants/ui\"\n\nimport { AuthorHeader } from \"./shared/AuthorHeader\"\nimport { ContentBody } from \"./shared/ContentBody\"\nimport type { EntryLayoutProps } from \"./types\"\n\nexport const SocialMediaLayout: React.FC<EntryLayoutProps> = ({\n  entryId,\n  compact = false,\n  noMedia = false,\n  translation,\n}) => {\n  const entry = useEntry(entryId, (state) => ({ feedId: state.feedId, media: state.media }))\n  const feed = useFeedById(entry?.feedId)\n\n  if (!entry || !feed) return null\n\n  return (\n    <div className={cn(readableContentMaxWidthClassName, \"mx-auto space-y-5 pt-12\")}>\n      {/* Single Author header without avatar */}\n      <AuthorHeader entryId={entryId} />\n\n      {/* Main content - direct ContentBody usage without show more logic */}\n      <ContentBody\n        entryId={entryId}\n        translation={translation}\n        compact={compact}\n        className=\"text-base leading-relaxed\"\n        noMedia={true}\n      />\n\n      {/* Media gallery */}\n      {entry.media &&\n        entry.media.length > 0 &&\n        !noMedia &&\n        entry.media.map((m) => (\n          <div key={m.url} className=\"mt-4 flex justify-center\">\n            <Media\n              src={m.url}\n              type={m.type}\n              previewImageUrl={m.preview_image_url}\n              blurhash={m.blurhash}\n            />\n          </div>\n        ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/VideosLayout.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useState } from \"react\"\n\nimport { EntryTitle } from \"../EntryTitle\"\nimport { ContentBody, MediaTranscript, TranscriptToggle, useTranscription } from \"./shared\"\nimport { VideoPlayer } from \"./shared/VideoPlayer\"\nimport type { EntryLayoutProps } from \"./types\"\n\nexport const VideosLayout: React.FC<EntryLayoutProps> = ({\n  entryId,\n  compact = false,\n  noMedia = false,\n  translation,\n}) => {\n  const entry = useEntry(entryId, (state) => state)\n  const { data: transcriptionData } = useTranscription(entryId)\n  const [showTranscript, setShowTranscript] = useState(false)\n\n  if (!entry) return null\n\n  return (\n    <div className=\"mx-auto flex h-full flex-col p-6\">\n      {/* Video player area */}\n      <div className=\"mb-6 w-full\">\n        {!noMedia ? (\n          <VideoPlayer\n            entryId={entryId}\n            showDuration={true}\n            preferFullSize={true}\n            translation={translation}\n            className=\"w-full\"\n          />\n        ) : (\n          <div className=\"center aspect-video w-full flex-col gap-1 rounded-md bg-material-medium text-sm text-text-secondary\">\n            <i className=\"i-mgc-video-cute-fi mb-2 size-12\" />\n            Video content not available\n          </div>\n        )}\n      </div>\n\n      {/* Content area below video */}\n      <div className=\"flex-1 space-y-4\">\n        {/* Title */}\n        <EntryTitle entryId={entryId} compact={compact} />\n\n        {/* Content Type Toggle */}\n        <TranscriptToggle\n          showTranscript={showTranscript}\n          onToggle={setShowTranscript}\n          hasTranscript={!!transcriptionData}\n        />\n\n        {/* Description/Content or Transcript */}\n        {showTranscript ? (\n          <MediaTranscript\n            className=\"prose !max-w-full dark:prose-invert\"\n            srt={transcriptionData}\n            entryId={entryId}\n            type=\"subtitle\"\n          />\n        ) : (\n          <ContentBody\n            entryId={entryId}\n            translation={translation}\n            compact={compact}\n            noMedia={true}\n            className=\"text-base\"\n          />\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/factory.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport type { FC } from \"react\"\n\nimport { ArticleLayout } from \"./ArticleLayout\"\nimport { MediaLayout } from \"./MediaLayout\"\nimport { SocialMediaLayout } from \"./SocialMediaLayout\"\nimport type { EntryLayoutProps } from \"./types\"\n\ntype EntryLayoutComponent = FC<EntryLayoutProps>\n\nconst EntryContentLayoutFactory: Record<FeedViewType, EntryLayoutComponent> = {\n  [FeedViewType.All]: ArticleLayout, // Use article layout as fallback for all view\n  [FeedViewType.Articles]: ArticleLayout,\n  [FeedViewType.SocialMedia]: SocialMediaLayout,\n  [FeedViewType.Pictures]: MediaLayout, // Use unified media layout for pictures\n  [FeedViewType.Videos]: MediaLayout, // Use unified media layout for videos\n  [FeedViewType.Audios]: ArticleLayout, // Use article layout as fallback for audio\n  [FeedViewType.Notifications]: ArticleLayout, // Use article layout as fallback for notifications\n}\n\nexport const getEntryContentLayout = (viewType: FeedViewType): EntryLayoutComponent => {\n  return EntryContentLayoutFactory[viewType] || ArticleLayout\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/index.ts",
    "content": "export { ArticleLayout } from \"./ArticleLayout\"\nexport { getEntryContentLayout } from \"./factory\"\nexport { PicturesLayout } from \"./PicturesLayout\"\nexport { SocialMediaLayout } from \"./SocialMediaLayout\"\nexport { VideosLayout } from \"./VideosLayout\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AudioPlayer.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as Slider from \"@radix-ui/react-slider\"\nimport dayjs from \"dayjs\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useCallback, useMemo, useState } from \"react\"\n\nimport { AudioPlayer, useAudioPlayerAtomSelector } from \"~/atoms/player\"\n\ninterface AudioPlayerProps {\n  entryId: string\n  className?: string\n}\n\n// Helper function to format duration\nconst formatDuration = (seconds: number) => {\n  if (!seconds || seconds === Infinity) return \"0:00\"\n  const duration = dayjs.duration(seconds, \"seconds\")\n  const hours = Math.floor(duration.asHours())\n  const minutes = duration.minutes()\n  const secs = duration.seconds()\n\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, \"0\")}:${secs.toString().padStart(2, \"0\")}`\n  }\n  return `${minutes}:${secs.toString().padStart(2, \"0\")}`\n}\n\nexport const ArticleAudioPlayer: React.FC<AudioPlayerProps> = ({ entryId, className }) => {\n  const entry = useEntry(entryId, (state) => ({\n    attachments: state.attachments,\n    feedId: state.feedId,\n  }))\n\n  // Find the first audio attachment\n  const audioAttachment = useMemo(() => {\n    return entry?.attachments?.find(\n      (attachment) => attachment.mime_type?.startsWith(\"audio/\") && attachment.url,\n    )\n  }, [entry?.attachments])\n\n  const currentPlayingEntryId = useAudioPlayerAtomSelector((v) => v.entryId)\n  const status = useAudioPlayerAtomSelector((v) => v.status)\n  const currentTime = useAudioPlayerAtomSelector((v) => v.currentTime)\n  const duration = useAudioPlayerAtomSelector((v) => v.duration)\n\n  // Use attachment duration as fallback when player duration is not available\n  const attachmentDuration = useMemo(() => {\n    if (!audioAttachment?.duration_in_seconds) return 0\n    const seconds = Number(audioAttachment.duration_in_seconds)\n    return Number.isFinite(seconds) ? seconds : 0\n  }, [audioAttachment?.duration_in_seconds])\n\n  const isCurrentAudio = currentPlayingEntryId === entryId\n  const isPlaying = isCurrentAudio && status === \"playing\"\n  const isLoading = isCurrentAudio && status === \"loading\"\n\n  // Slider drag state\n  const [isDragging, setIsDragging] = useState(false)\n  const [dragValue, setDragValue] = useState(0)\n\n  const handlePlayAudio = useCallback(() => {\n    if (!audioAttachment) return\n\n    if (isCurrentAudio) {\n      AudioPlayer.togglePlayAndPause()\n    } else {\n      AudioPlayer.mount({\n        entryId,\n        src: audioAttachment.url,\n        type: \"audio\",\n        currentTime: 0,\n      })\n    }\n  }, [audioAttachment, entryId, isCurrentAudio])\n\n  const handleDownload = useCallback(() => {\n    if (!audioAttachment?.url) return\n    window.open(audioAttachment.url, \"_blank\")\n  }, [audioAttachment?.url])\n\n  const handleBack = useCallback(() => {\n    if (!isCurrentAudio) return\n    AudioPlayer.back(10)\n  }, [isCurrentAudio])\n\n  const handleForward = useCallback(() => {\n    if (!isCurrentAudio) return\n    AudioPlayer.forward(10)\n  }, [isCurrentAudio])\n\n  // Only show progress for current audio, otherwise reset to 0\n  const displayCurrentTime = isCurrentAudio ? currentTime || 0 : 0\n  // Use player duration first, fallback to attachment duration, then 0\n  const displayDuration = isCurrentAudio\n    ? duration && duration > 0 && duration !== Infinity\n      ? duration\n      : attachmentDuration\n    : attachmentDuration || 0\n  const displayHasValidDuration =\n    displayDuration && displayDuration > 0 && displayDuration !== Infinity\n\n  const handleSliderValueChange = useCallback(\n    (value: number[]) => {\n      if (!isCurrentAudio || !displayHasValidDuration) return\n      setDragValue(value[0]!)\n    },\n    [isCurrentAudio, displayHasValidDuration],\n  )\n\n  const handleSliderValueCommit = useCallback(\n    (value: number[]) => {\n      if (!isCurrentAudio || !displayHasValidDuration) return\n      AudioPlayer.seek(value[0]!)\n      setIsDragging(false)\n    },\n    [isCurrentAudio, displayHasValidDuration],\n  )\n\n  // Don't render if no audio attachment\n  if (!audioAttachment) {\n    return null\n  }\n\n  // Calculate slider value - use drag value when dragging, otherwise use current time\n  const sliderValue = isDragging ? dragValue : displayCurrentTime\n  const currentTimeDisplay = formatDuration(sliderValue)\n  const durationDisplay = formatDuration(displayDuration)\n\n  return (\n    <AnimatePresence>\n      <m.div\n        initial={{ opacity: 0, y: 8 }}\n        animate={{ opacity: 1, y: 0 }}\n        exit={{ opacity: 0, y: 8 }}\n        transition={Spring.presets.smooth}\n        className={cn(\"relative my-4 w-full rounded-2xl border backdrop-blur-2xl\", className)}\n        style={{\n          backgroundImage:\n            \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n          borderColor: \"hsl(var(--fo-a) / 0.2)\",\n        }}\n      >\n        {/* Inner glow layer */}\n        <div\n          className=\"pointer-events-none absolute inset-0 rounded-2xl\"\n          style={{\n            background:\n              \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))\",\n          }}\n        />\n\n        {/* Content */}\n        <div className=\"relative p-5\">\n          {/* Control buttons and progress bar */}\n          <div className=\"flex items-center gap-4\">\n            {/* Control buttons */}\n            <div className=\"flex shrink-0 items-center gap-2\">\n              {/* Skip Back 10s */}\n              <button\n                type=\"button\"\n                onClick={handleBack}\n                disabled={!isCurrentAudio}\n                className={cn(\n                  \"group relative flex size-9 items-center justify-center rounded-full border\",\n                  \"transition-all duration-300\",\n                  !isCurrentAudio && \"cursor-not-allowed bg-transparent opacity-40\",\n                  isCurrentAudio &&\n                    \"hover:[background:linear-gradient(to_right,hsl(var(--fo-a)/0.08),hsl(var(--fo-a)/0.05))_!important] hover:[border-color:hsl(var(--fo-a)/0.25)_!important]\",\n                )}\n                style={{\n                  background: !isCurrentAudio\n                    ? undefined\n                    : \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.6), rgba(var(--color-background) / 0.4))\",\n                  borderColor: \"hsl(var(--fo-a) / 0.15)\",\n                }}\n                title=\"Back 10s\"\n              >\n                <i className=\"i-mgc-back-2-cute-re size-4 text-text-secondary transition-colors group-hover:text-text\" />\n              </button>\n\n              {/* Play/Pause Button */}\n              <button\n                type=\"button\"\n                onClick={handlePlayAudio}\n                disabled={!audioAttachment}\n                className={cn(\n                  \"group relative flex size-12 items-center justify-center rounded-full border\",\n                  \"transition-all duration-300\",\n                  !audioAttachment && \"cursor-not-allowed opacity-50\",\n                )}\n                style={{\n                  background:\n                    \"linear-gradient(135deg, hsl(var(--fo-a) / 0.9), hsl(var(--fo-a) / 0.75))\",\n                  borderColor: \"hsl(var(--fo-a) / 0.4)\",\n                }}\n                title={isPlaying ? \"Pause\" : \"Play\"}\n              >\n                {isLoading ? (\n                  <i className=\"i-mgc-loading-3-cute-re size-6 animate-spin text-white\" />\n                ) : isPlaying ? (\n                  <i className=\"i-mgc-pause-cute-fi size-6 text-white\" />\n                ) : (\n                  <i className=\"i-mgc-play-cute-fi size-6 text-white\" />\n                )}\n              </button>\n\n              {/* Skip Forward 10s */}\n              <button\n                type=\"button\"\n                onClick={handleForward}\n                disabled={!isCurrentAudio}\n                className={cn(\n                  \"group relative flex size-9 items-center justify-center rounded-full border\",\n                  \"transition-all duration-300\",\n                  !isCurrentAudio && \"cursor-not-allowed bg-transparent opacity-40\",\n                  isCurrentAudio &&\n                    \"hover:[background:linear-gradient(to_right,hsl(var(--fo-a)/0.08),hsl(var(--fo-a)/0.05))_!important] hover:[border-color:hsl(var(--fo-a)/0.25)_!important]\",\n                )}\n                style={{\n                  background: !isCurrentAudio\n                    ? undefined\n                    : \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.6), rgba(var(--color-background) / 0.4))\",\n                  borderColor: \"hsl(var(--fo-a) / 0.15)\",\n                }}\n                title=\"Forward 10s\"\n              >\n                <i className=\"i-mgc-forward-2-cute-re size-4 text-text-secondary transition-colors group-hover:text-text\" />\n              </button>\n            </div>\n\n            {/* Progress Bar Container */}\n            <div className=\"flex-1\">\n              {displayHasValidDuration ? (\n                <Slider.Root\n                  className=\"group relative flex h-2.5 w-full touch-none select-none items-center\"\n                  min={0}\n                  max={displayDuration}\n                  step={0.1}\n                  value={[sliderValue]}\n                  disabled={!isCurrentAudio}\n                  onPointerDown={() => {\n                    if (isCurrentAudio) {\n                      setIsDragging(true)\n                      setDragValue(displayCurrentTime)\n                    }\n                  }}\n                  onValueChange={handleSliderValueChange}\n                  onValueCommit={handleSliderValueCommit}\n                >\n                  <Slider.Track className=\"relative h-2.5 w-full grow overflow-hidden rounded-full border border-fill bg-fill-secondary\">\n                    <Slider.Range className=\"absolute inset-y-0 rounded-full bg-accent\" />\n                  </Slider.Track>\n\n                  <Slider.Thumb\n                    className=\"block size-3.5 rounded-full border-2 border-white bg-accent opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100\"\n                    aria-label=\"Progress\"\n                  />\n                </Slider.Root>\n              ) : (\n                <div\n                  className=\"relative h-2.5 w-full overflow-hidden rounded-full border\"\n                  style={{\n                    background:\n                      \"linear-gradient(to right, hsl(var(--fo-a) / 0.1), hsl(var(--fo-a) / 0.08))\",\n                    borderColor: \"hsl(var(--fo-a) / 0.15)\",\n                  }}\n                />\n              )}\n            </div>\n\n            {/* Time Display and Download */}\n            <div className=\"flex shrink-0 items-center gap-3\">\n              <div className=\"flex gap-1.5 text-xs\">\n                <span className=\"font-mono text-text-secondary\">{currentTimeDisplay}</span>\n                <span className=\"text-text-tertiary\">/</span>\n                <span className=\"font-mono text-text-secondary\">{durationDisplay}</span>\n              </div>\n\n              {/* Divider */}\n              <div\n                className=\"h-12 w-px\"\n                style={{\n                  background:\n                    \"linear-gradient(to bottom, transparent, hsl(var(--fo-a) / 0.2), transparent)\",\n                }}\n              />\n\n              {/* Download Button */}\n              <button\n                type=\"button\"\n                onClick={handleDownload}\n                className=\"group relative flex size-8 items-center justify-center rounded-full bg-transparent transition-all duration-300 hover:[background:linear-gradient(to_right,hsl(var(--fo-a)/0.08),hsl(var(--fo-a)/0.05))] hover:[border-color:hsl(var(--fo-a)/0.25)]\"\n                title=\"Download\"\n              >\n                <i className=\"i-mgc-download-2-cute-re size-4 text-text-secondary transition-colors group-hover:text-text\" />\n              </button>\n            </div>\n          </div>\n        </div>\n      </m.div>\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AuthorHeader.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { parseSocialMedia } from \"~/lib/parsers\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\ninterface AuthorHeaderProps {\n  entryId: string\n  className?: string\n  showAvatar?: boolean\n  avatarSize?: number\n}\n\nexport const AuthorHeader: React.FC<AuthorHeaderProps> = ({\n  entryId,\n  className,\n  showAvatar = true,\n  avatarSize = 40,\n}) => {\n  const entry = useEntry(entryId, (state) => {\n    const { feedId, author, authorAvatar, authorUrl, publishedAt, guid, url } = state\n\n    const media = state.media || []\n    const photo = media.find((a) => a.type === \"photo\")\n    const firstPhotoUrl = photo?.url\n    const iconEntry: FeedIconEntry = {\n      firstPhotoUrl,\n      authorAvatar,\n    }\n\n    return {\n      feedId,\n      author,\n      authorUrl,\n      publishedAt,\n      iconEntry,\n      guid,\n      url,\n    }\n  })\n\n  const feed = useFeedById(entry?.feedId)\n\n  if (!entry || !feed) return null\n\n  const parsed = parseSocialMedia(entry.authorUrl || entry.url || entry.guid)\n\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      {showAvatar && (\n        <FeedIcon\n          fallback\n          target={feed}\n          entry={entry.iconEntry}\n          size={avatarSize}\n          className=\"shrink-0\"\n        />\n      )}\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-center gap-1 text-base\">\n          <span className=\"font-semibold\">\n            <FeedTitle feed={feed} title={entry.author || feed.title} />\n          </span>\n          {parsed?.type === \"x\" && <i className=\"i-mgc-twitter-cute-fi size-3 text-[#4A99E9]\" />}\n        </div>\n        <div className=\"flex items-center gap-1 text-sm text-zinc-500\">\n          {parsed?.type === \"x\" && (\n            <>\n              <a\n                href={`https://x.com/${parsed.meta.handle}`}\n                target=\"_blank\"\n                className=\"hover:underline\"\n              >\n                @{parsed.meta.handle}\n              </a>\n              <span>·</span>\n            </>\n          )}\n          <RelativeTime date={entry.publishedAt} />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/ContentBody.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { HTML } from \"~/components/ui/markdown/HTML\"\nimport { readableContentMaxWidthClassName } from \"~/constants/ui\"\nimport { useRenderStyle } from \"~/hooks/biz/useRenderStyle\"\n\ninterface ContentBodyProps {\n  entryId: string\n  className?: string\n  compact?: boolean\n  noMedia?: boolean\n  translation?: {\n    content?: string\n    title?: string\n  }\n}\n\nexport const ContentBody: React.FC<ContentBodyProps> = ({\n  entryId,\n  className,\n  compact = false,\n  noMedia = false,\n  translation,\n}) => {\n  const entry = useEntry(entryId, (state) => ({\n    content: state.content,\n    description: state.description,\n  }))\n\n  const renderStyle = useRenderStyle({\n    baseFontSize: compact ? 14 : 16,\n    baseLineHeight: compact ? 1.625 : 1.7,\n  })\n\n  if (!entry) return null\n\n  const content = translation?.content || entry.content || entry.description\n\n  if (!content) return null\n\n  return (\n    <HTML\n      as=\"div\"\n      className={cn(\n        \"prose dark:prose-invert\",\n        \"prose-blockquote:mt-0\",\n        \"cursor-auto select-text\",\n        readableContentMaxWidthClassName,\n        compact ? \"text-sm leading-relaxed\" : \"text-base leading-relaxed\",\n        className,\n      )}\n      noMedia={noMedia}\n      style={renderStyle}\n    >\n      {content}\n    </HTML>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/MediaTranscript.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { checkLanguage } from \"@follow/utils/language\"\n\nimport { AudioPlayer, useAudioPlayerAtomSelector } from \"~/atoms/player\"\n\nconst MAX_PARAGRAPH_LENGTH = 300\n\ninterface SubtitleItem {\n  index: number\n  startTime: string\n  endTime: string\n  text: string\n  startTimeInSeconds: number\n  endTimeInSeconds: number\n}\n\ninterface MediaTranscriptProps {\n  className?: string\n  srt: string | undefined\n  entryId: string | undefined\n  style?: React.CSSProperties\n  /** Type of transcript: 'subtitle' disables jump and progress tracking, 'transcription' enables all features */\n  type?: \"subtitle\" | \"transcription\"\n}\n\n/**\n * Converts SRT time format (HH:MM:SS,mmm or HH:MM:SS.mmm) to seconds\n * @param timeString - Time string in HH:MM:SS,mmm or HH:MM:SS.mmm format\n * @returns Time in seconds\n */\nfunction srtTimeToSeconds(timeString: string): number {\n  const [hours, minutes, seconds] = timeString.split(\":\")\n  if (!hours || !minutes || !seconds) return 0\n\n  // Handle both comma and dot as decimal separator\n  const [secs, millisecs] = seconds.split(/[,.]/)\n  if (!secs) return 0\n\n  return (\n    Number.parseInt(hours, 10) * 3600 +\n    Number.parseInt(minutes, 10) * 60 +\n    Number.parseInt(secs, 10) +\n    Number.parseInt(millisecs || \"0\", 10) / 1000\n  )\n}\n\n/**\n * Converts seconds to SRT time format (HH:MM:SS,mmm)\n * @param seconds - Time in seconds\n * @returns Time string in SRT format\n */\nfunction formatTimeString(seconds: number): string {\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  const secs = Math.floor(seconds % 60)\n  const millisecs = Math.floor((seconds % 1) * 1000)\n  return `${hours.toString().padStart(2, \"0\")}:${minutes.toString().padStart(2, \"0\")}:${secs.toString().padStart(2, \"0\")},${millisecs.toString().padStart(3, \"0\")}`\n}\n\n/**\n * Processes English content by splitting into sentences and grouping by length\n */\nfunction processEnglishContent(allText: string): string[] {\n  // First, temporarily replace ellipsis with a placeholder to avoid splitting on them\n  const textWithPlaceholders = allText.replaceAll(/\\.{3,}/g, \"___ELLIPSIS___\")\n\n  // Split on sentence endings\n  const rawSentences = textWithPlaceholders\n    .split(/[.!?]+/)\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n    .map((s) => s.replaceAll(\"___ELLIPSIS___\", \"...\")) // Restore ellipsis\n\n  // Restore sentence endings by detecting the original punctuation\n  const sentences = rawSentences.map((sentence) => {\n    // If sentence already ends with ellipsis, don't add additional punctuation\n    if (sentence.endsWith(\"...\")) {\n      return sentence\n    }\n\n    const sentenceInText = allText.indexOf(sentence)\n    if (sentenceInText === -1) return `${sentence}.`\n\n    const afterSentence = allText.charAt(sentenceInText + sentence.length)\n    const endings = [\".\", \"!\", \"?\"]\n    const foundEnding = endings.find((ending) => afterSentence === ending)\n\n    return `${sentence}${foundEnding || \".\"}`\n  })\n\n  // Group sentences into paragraphs with reasonable length\n  const paragraphs: string[] = []\n  let currentParagraph = \"\"\n\n  for (const sentence of sentences) {\n    if (currentParagraph.length === 0) {\n      currentParagraph = sentence\n    } else if (currentParagraph.length + sentence.length + 1 <= MAX_PARAGRAPH_LENGTH) {\n      currentParagraph = `${currentParagraph} ${sentence}`\n    } else {\n      // Current paragraph is getting too long, start a new one\n      paragraphs.push(currentParagraph)\n      currentParagraph = sentence\n    }\n  }\n\n  // Don't forget the last paragraph\n  if (currentParagraph.length > 0) {\n    paragraphs.push(currentParagraph)\n  }\n\n  return paragraphs\n}\n\n/**\n * Calculates timing for English content paragraphs using character position mapping\n */\nfunction calculateEnglishTiming(\n  paragraphs: string[],\n  allText: string,\n  originalSubtitles: SubtitleItem[],\n): SubtitleItem[] {\n  // Create character position mapping\n  let charPosition = 0\n  const charToSubtitleMap: Array<{ charStart: number; charEnd: number; subtitle: SubtitleItem }> =\n    []\n\n  originalSubtitles.forEach((subtitle) => {\n    const textLength = subtitle.text.length\n    charToSubtitleMap.push({\n      charStart: charPosition,\n      charEnd: charPosition + textLength,\n      subtitle,\n    })\n    charPosition += textLength + 1 // +1 for the space we added when joining\n  })\n\n  const firstSubtitle = originalSubtitles[0]!\n  const lastSubtitle = originalSubtitles.at(-1)!\n  const reorganizedSubtitles: SubtitleItem[] = []\n  let textPosition = 0\n\n  paragraphs.forEach((paragraph, index) => {\n    const paragraphStart = allText.indexOf(paragraph, textPosition)\n    const paragraphEnd =\n      paragraphStart !== -1 ? paragraphStart + paragraph.length : textPosition + paragraph.length\n    const actualStart = paragraphStart !== -1 ? paragraphStart : textPosition\n    const actualEnd = paragraphEnd\n\n    // Find overlapping subtitles\n    const overlappingSubtitles = charToSubtitleMap.filter(\n      (mapping) =>\n        (actualStart >= mapping.charStart && actualStart < mapping.charEnd) ||\n        (actualEnd > mapping.charStart && actualEnd <= mapping.charEnd) ||\n        (actualStart <= mapping.charStart && actualEnd >= mapping.charEnd),\n    )\n\n    let startTimeInSeconds: number\n    let endTimeInSeconds: number\n\n    if (overlappingSubtitles.length > 0) {\n      const firstOverlapping = overlappingSubtitles[0]!\n      const lastOverlapping = overlappingSubtitles.at(-1)!\n\n      if (overlappingSubtitles.length === 1) {\n        // Single subtitle: interpolate within it\n        const sub = firstOverlapping.subtitle\n        const subTextLength = sub.text.length\n        const subDuration = sub.endTimeInSeconds - sub.startTimeInSeconds\n\n        if (subTextLength > 0 && subDuration > 0) {\n          const relativeStart = Math.max(0, actualStart - firstOverlapping.charStart)\n          const relativeEnd = Math.min(subTextLength, actualEnd - firstOverlapping.charStart)\n          const startRatio = relativeStart / subTextLength\n          const endRatio = relativeEnd / subTextLength\n\n          startTimeInSeconds = sub.startTimeInSeconds + startRatio * subDuration\n          endTimeInSeconds = sub.startTimeInSeconds + endRatio * subDuration\n        } else {\n          startTimeInSeconds = sub.startTimeInSeconds\n          endTimeInSeconds = sub.endTimeInSeconds\n        }\n      } else {\n        // Multiple subtitles: interpolate across the range\n        const firstOverlappingSub = firstOverlapping.subtitle\n        const lastOverlappingSub = lastOverlapping.subtitle\n\n        // Calculate the total character range across all overlapping subtitles\n        const totalCharStart = firstOverlapping.charStart\n        const totalCharEnd = lastOverlapping.charEnd\n        const totalCharLength = totalCharEnd - totalCharStart\n        const totalTimeDuration =\n          lastOverlappingSub.endTimeInSeconds - firstOverlappingSub.startTimeInSeconds\n\n        if (totalCharLength > 0 && totalTimeDuration > 0) {\n          // Calculate relative positions within the total overlapping range\n          const relativeStart = Math.max(0, actualStart - totalCharStart)\n          const relativeEnd = Math.min(totalCharLength, actualEnd - totalCharStart)\n          const startRatio = relativeStart / totalCharLength\n          const endRatio = relativeEnd / totalCharLength\n\n          startTimeInSeconds =\n            firstOverlappingSub.startTimeInSeconds + startRatio * totalTimeDuration\n          endTimeInSeconds = firstOverlappingSub.startTimeInSeconds + endRatio * totalTimeDuration\n        } else {\n          // Fallback to simple range if calculation fails\n          startTimeInSeconds = firstOverlappingSub.startTimeInSeconds\n          endTimeInSeconds = lastOverlappingSub.endTimeInSeconds\n        }\n      }\n    } else {\n      // Fallback: proportional calculation\n      const totalTextLength = allText.length\n      const totalDuration = lastSubtitle.endTimeInSeconds - firstSubtitle.startTimeInSeconds\n      const startRatio = actualStart / totalTextLength\n      const endRatio = actualEnd / totalTextLength\n\n      startTimeInSeconds = firstSubtitle.startTimeInSeconds + startRatio * totalDuration\n      endTimeInSeconds = firstSubtitle.startTimeInSeconds + endRatio * totalDuration\n    }\n\n    reorganizedSubtitles.push({\n      index: index + 1,\n      startTime: formatTimeString(startTimeInSeconds),\n      endTime: formatTimeString(endTimeInSeconds),\n      text: paragraph,\n      startTimeInSeconds,\n      endTimeInSeconds,\n    })\n\n    textPosition = actualEnd\n  })\n\n  return reorganizedSubtitles\n}\n\n/**\n * Parses SRT subtitle text with optional sentence reorganization\n * @param srtText - The SRT format text to parse\n * @returns Array of parsed subtitle items\n */\nfunction parseSrt(srtText: string): SubtitleItem[] {\n  // Split by double newlines (with optional whitespace) to separate subtitle blocks\n  const blocks = srtText.trim().split(/\\n\\s*\\n/)\n\n  // First, parse all original subtitle blocks to extract text and timing info\n  const originalSubtitles = blocks\n    .map((block) => {\n      const lines = block.trim().split(\"\\n\")\n\n      // Skip empty blocks\n      if (lines.length < 3 || !lines[0] || !lines[1]) {\n        return null\n      }\n\n      const index = Number.parseInt(lines[0].trim(), 10)\n\n      // Validate index\n      if (Number.isNaN(index)) {\n        return null\n      }\n\n      // More flexible time format matching (handles various SRT time formats)\n      const timeMatch = lines[1].match(\n        /(\\d{1,2}:\\d{2}:\\d{2}[,.]?\\d{0,3})\\s*-->\\s*(\\d{1,2}:\\d{2}:\\d{2}[,.]?\\d{0,3})/,\n      )\n\n      if (!timeMatch || !timeMatch[1] || !timeMatch[2]) {\n        return null\n      }\n\n      // Normalize time format (replace . with , for consistency)\n      const startTime = timeMatch[1].replace(\".\", \",\")\n      const endTime = timeMatch[2].replace(\".\", \",\")\n\n      // Join all text lines (from line 3 onwards) with newlines\n      const text = lines.slice(2).join(\"\\n\").trim()\n\n      // Skip if no text content\n      if (!text) {\n        return null\n      }\n\n      return {\n        index,\n        startTime,\n        endTime,\n        text,\n        startTimeInSeconds: srtTimeToSeconds(startTime),\n        endTimeInSeconds: srtTimeToSeconds(endTime),\n      }\n    })\n    .filter((subtitle): subtitle is SubtitleItem => subtitle !== null)\n\n  if (originalSubtitles.length === 0) {\n    return []\n  }\n\n  // Combine all text with timing information\n  const allText = originalSubtitles.map((sub) => sub.text).join(\" \")\n\n  // Check if content is English to determine processing strategy\n  const isEnglish = checkLanguage({ content: allText, language: \"en\" })\n\n  // For non-English content, return original subtitles without any processing\n  if (!isEnglish) {\n    return originalSubtitles\n  }\n\n  // For English content: split by sentences and reorganize\n  const paragraphs = processEnglishContent(allText)\n\n  // Get timing information from first and last subtitles\n  const firstSubtitle = originalSubtitles[0]\n  const lastSubtitle = originalSubtitles.at(-1)\n\n  if (!firstSubtitle || !lastSubtitle) {\n    return []\n  }\n\n  // For English content: use character position mapping for precise timing\n  return calculateEnglishTiming(paragraphs, allText, originalSubtitles)\n}\n\nfunction formatTime(timeString: string): string {\n  // Convert SRT time format (HH:MM:SS,mmm) to a more readable format\n  const time = timeString.replace(\",\", \".\")\n  const [hours, minutes, seconds] = time.split(\":\")\n\n  if (!hours || !minutes || !seconds) {\n    return timeString\n  }\n\n  if (hours === \"00\") {\n    const secondsPart = seconds.split(\".\")[0]\n    return `${minutes}:${secondsPart}`\n  }\n\n  const secondsPart = seconds.split(\".\")[0]\n  return `${hours}:${minutes}:${secondsPart}`\n}\n\nexport const MediaTranscript: React.FC<MediaTranscriptProps> = ({\n  className,\n  style,\n  srt,\n  entryId,\n  type = \"transcription\",\n}) => {\n  // Determine if jump and progress tracking should be disabled based on type\n  const disableJump = type === \"subtitle\"\n  const disableProgressTracking = type === \"subtitle\"\n\n  // Get current playing time from the audio player\n  const currentTime = useAudioPlayerAtomSelector((v) => v.currentTime) || 0\n  const status = useAudioPlayerAtomSelector((v) => v.status)\n  const playerEntryId = useAudioPlayerAtomSelector((v) => v.entryId)\n\n  // Get the audio URL for this entry to support cross-audio jumping\n  const entry = useEntry(entryId, (state) => ({\n    audioUrl: state.attachments?.find((att) => att.mime_type?.startsWith(\"audio/\"))?.url,\n  }))\n\n  // Check if the current playing audio matches this transcript's entry\n  const isCurrentAudio = playerEntryId === entryId\n\n  if (!srt) {\n    return (\n      <div className={cn(\"p-4 text-center text-text-secondary\", className)}>\n        No transcript available\n      </div>\n    )\n  }\n\n  let subtitles: SubtitleItem[]\n  try {\n    subtitles = parseSrt(srt)\n  } catch (error) {\n    return (\n      <div className={cn(\"p-4 text-center text-red\", className)}>\n        Error parsing transcript:{\" \"}\n        <span>{error instanceof Error ? error.message : \"Unknown error\"}</span>\n      </div>\n    )\n  }\n\n  // Find the current active subtitle based on current time\n  // Only show active state if this transcript matches the currently playing audio and progress tracking is enabled\n  const currentSubtitleIndex =\n    !disableProgressTracking && isCurrentAudio\n      ? subtitles.findIndex(\n          (subtitle) =>\n            currentTime >= subtitle.startTimeInSeconds && currentTime <= subtitle.endTimeInSeconds,\n        )\n      : -1\n\n  const handleTimeJump = (timeInSeconds: number) => {\n    if (disableJump) return\n\n    if (isCurrentAudio) {\n      // If this is the current audio, seek to the time\n      AudioPlayer.seek(timeInSeconds)\n\n      // If the audio was paused, resume playback\n      if (status === \"paused\") {\n        AudioPlayer.play()\n      }\n    } else {\n      // If this is a different audio, mount the new audio and seek to the time\n      if (entry?.audioUrl && entryId) {\n        AudioPlayer.mount({\n          entryId,\n          src: entry.audioUrl,\n          currentTime: timeInSeconds,\n          type: \"audio\",\n        })\n        // mount() automatically starts playing, so no need to call play() here\n      }\n    }\n  }\n\n  return (\n    <div className={cn(\"space-y-1\", className)} style={style}>\n      {subtitles.map((subtitle, index) => {\n        const isActive = index === currentSubtitleIndex\n        const isPast = isCurrentAudio && currentTime > subtitle.endTimeInSeconds\n\n        return (\n          <div\n            key={subtitle.index}\n            className={cn(\n              \"group relative rounded-lg border-l-4 px-3 py-2 transition-all duration-300 ease-out\",\n              !disableJump && \"cursor-pointer\",\n              isActive\n                ? \"border-accent bg-accent/5 shadow-sm\"\n                : \"border-transparent hover:bg-fill-secondary hover:shadow-sm\",\n              isPast && \"opacity-50\",\n            )}\n            onClick={() => !disableJump && handleTimeJump(subtitle.startTimeInSeconds)}\n          >\n            <div className=\"flex items-start gap-4\">\n              {/* Time indicator */}\n              <div className=\"flex-shrink-0 translate-y-3\">\n                {!disableJump ? (\n                  <button\n                    type=\"button\"\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      handleTimeJump(subtitle.startTimeInSeconds)\n                    }}\n                    className={cn(\n                      \"rounded-md px-2 py-1 font-mono text-xs leading-none transition-all duration-200\",\n                      isActive\n                        ? \"bg-accent/10 text-accent\"\n                        : \"text-text-tertiary hover:bg-fill-tertiary hover:text-text-secondary\",\n                    )}\n                    title=\"Jump to this time\"\n                  >\n                    {formatTime(subtitle.startTime)}\n                  </button>\n                ) : (\n                  <span\n                    className={cn(\n                      \"rounded-md px-2 py-1 font-mono text-xs leading-none\",\n                      isActive ? \"bg-accent/10 text-accent\" : \"bg-fill-tertiary text-text-tertiary\",\n                    )}\n                  >\n                    {formatTime(subtitle.startTime)}\n                  </span>\n                )}\n              </div>\n\n              {/* Content */}\n              <div className=\"min-w-0 flex-1\">\n                <p\n                  className={cn(\n                    \"text-sm leading-relaxed transition-all duration-300\",\n                    isActive ? \"text-text-secondary\" : \"text-text-secondary\",\n                    !disableJump && \"group-hover:text-text\",\n                  )}\n                >\n                  {subtitle.text}\n                </p>\n              </div>\n\n              {/* Active indicator */}\n              {type === \"transcription\" && (\n                <div className=\"flex w-6 flex-shrink-0 items-center justify-center\">\n                  {isActive && (\n                    <div className=\"duration-300 animate-in fade-in slide-in-from-right-2\">\n                      <div className=\"size-2 animate-pulse rounded-full bg-accent shadow-sm\" />\n                    </div>\n                  )}\n                </div>\n              )}\n            </div>\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/TranscriptToggle.tsx",
    "content": "import { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\n\ninterface TranscriptToggleProps {\n  showTranscript: boolean\n  onToggle: (showTranscript: boolean) => void\n  hasTranscript: boolean\n}\n\nexport const TranscriptToggle: React.FC<TranscriptToggleProps> = ({\n  showTranscript,\n  onToggle,\n  hasTranscript,\n}) => {\n  if (!hasTranscript) return null\n\n  return (\n    <div className=\"mb-6 mt-4 flex items-center gap-2\">\n      <SegmentGroup\n        value={showTranscript ? \"transcript\" : \"content\"}\n        onValueChanged={(value) => onToggle(value === \"transcript\")}\n      >\n        <SegmentItem value=\"content\" label=\"Content\" />\n        <SegmentItem value=\"transcript\" label=\"Transcript\" />\n      </SegmentGroup>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/VideoPlayer.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { formatDuration } from \"@follow/utils/duration\"\nimport { transformVideoUrl } from \"@follow/utils/url-for-video\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useHover } from \"@use-gesture/react\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { AudioPlayer } from \"~/atoms/player\"\nimport { m } from \"~/components/common/Motion\"\nimport { HTML } from \"~/components/ui/markdown/HTML\"\nimport { Media } from \"~/components/ui/media/Media\"\nimport type { ModalContentComponent } from \"~/components/ui/modal\"\nimport { FixedModalCloseButton } from \"~/components/ui/modal/components/close\"\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useRenderStyle } from \"~/hooks/biz/useRenderStyle\"\nimport { getDefaultLanguage } from \"~/lib/language\"\n\nconst ViewTag = IN_ELECTRON ? \"webview\" : \"iframe\"\n\ninterface VideoPlayerProps {\n  entryId: string\n  className?: string\n  showDuration?: boolean\n  preferFullSize?: boolean\n  translation?: {\n    content?: string\n    title?: string\n  }\n}\n\nexport const VideoPlayer: React.FC<VideoPlayerProps> = ({\n  entryId,\n  className,\n  showDuration = true,\n  preferFullSize = false,\n  translation,\n}) => {\n  const entry = useEntry(entryId, (state) => {\n    const { url, media } = state\n\n    const attachments = state.attachments || []\n    const { duration_in_seconds } =\n      attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}\n    const seconds = duration_in_seconds\n      ? Number.parseInt(duration_in_seconds.toString())\n      : undefined\n    const duration = formatDuration(seconds)\n\n    const firstMedia = media?.[0]\n\n    return { attachments, duration, firstMedia, url, media }\n  })\n\n  const lang = getDefaultLanguage()\n  const [miniIframeSrc, iframeSrc] = useMemo(\n    () => [\n      transformVideoUrl({\n        url: entry?.url ?? \"\",\n        mini: true,\n        isIframe: !IN_ELECTRON,\n        attachments: entry?.attachments,\n        lang,\n      }),\n      transformVideoUrl({\n        url: entry?.url ?? \"\",\n        isIframe: !IN_ELECTRON,\n        attachments: entry?.attachments,\n        lang,\n      }),\n    ],\n    [entry?.attachments, entry?.url],\n  )\n\n  const modalStack = useModalStack()\n\n  const ref = useRef<HTMLDivElement>(null)\n  const [hovered, setHovered] = useState(false)\n  useHover(\n    (event) => {\n      setHovered(event.active)\n    },\n    {\n      target: ref,\n    },\n  )\n\n  const [showPreview, setShowPreview] = useState(false)\n  useEffect(() => {\n    if (hovered) {\n      const timer = setTimeout(() => {\n        setShowPreview(true)\n      }, 500)\n      return () => clearTimeout(timer)\n    } else {\n      setShowPreview(false)\n      return () => {}\n    }\n  }, [hovered])\n\n  if (!entry) return null\n\n  return (\n    <div\n      className={cn(\"w-full cursor-pointer\", className)}\n      onClick={(e) => {\n        if (isMobile() && entry.url) {\n          window.open(entry.url, \"_blank\")\n          e.stopPropagation()\n          return\n        }\n        if (iframeSrc) {\n          modalStack.present({\n            title: \"\",\n            content: (props) => (\n              <PreviewVideoModalContent\n                src={iframeSrc}\n                entryId={entryId}\n                translation={translation}\n                {...props}\n              />\n            ),\n            clickOutsideToDismiss: true,\n            CustomModalComponent: PlainModal,\n            overlay: true,\n          })\n        }\n      }}\n    >\n      <div className=\"relative aspect-video w-full\" ref={ref}>\n        {preferFullSize && iframeSrc ? (\n          <ViewTag\n            src={iframeSrc}\n            referrerPolicy=\"strict-origin-when-cross-origin\"\n            className=\"aspect-video w-full rounded-md bg-black object-cover\"\n          />\n        ) : miniIframeSrc && showPreview ? (\n          <ViewTag\n            src={miniIframeSrc}\n            referrerPolicy=\"strict-origin-when-cross-origin\"\n            className=\"pointer-events-none aspect-video w-full rounded-md bg-black object-cover\"\n          />\n        ) : entry.firstMedia ? (\n          <Media\n            key={entry.firstMedia.url}\n            src={entry.firstMedia.url}\n            type={entry.firstMedia.type}\n            previewImageUrl={entry.firstMedia.preview_image_url}\n            className=\"aspect-video w-full rounded-md object-cover\"\n            videoClassName=\"object-contain\"\n            loading=\"lazy\"\n            proxy={{\n              width: 640,\n              height: 360,\n            }}\n            showFallback={true}\n          />\n        ) : (\n          <div className=\"center aspect-video w-full flex-col gap-1 rounded-md bg-material-medium text-xs text-text-secondary\">\n            <i className=\"i-mgc-sad-cute-re size-6\" />\n            No video available\n          </div>\n        )}\n        {!!entry.duration && showDuration && (\n          <div className=\"absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white\">\n            {entry.duration}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nconst PreviewVideoModalContent: ModalContentComponent<{\n  src: string\n  entryId: string\n  translation?: {\n    content?: string\n    title?: string\n  }\n}> = ({ dismiss, src, entryId, translation }) => {\n  const entry = useEntry(entryId, (state) => ({ content: state.content }))\n\n  const content = translation?.content || entry?.content\n  const currentAudioPlayerIsPlay = useRef(AudioPlayer.get().status === \"playing\")\n\n  const renderStyle = useRenderStyle()\n\n  useEffect(() => {\n    const currentValue = currentAudioPlayerIsPlay.current\n    if (currentValue) {\n      AudioPlayer.pause()\n    }\n    return () => {\n      if (currentValue) {\n        AudioPlayer.play()\n      }\n    }\n  }, [])\n\n  return (\n    <m.div exit={{ scale: 0.94, opacity: 0 }} className=\"size-full p-12\" onClick={() => dismiss()}>\n      <m.div\n        onFocusCapture={stopPropagation}\n        initial={true}\n        exit={{\n          opacity: 0,\n        }}\n        className=\"fixed right-4 flex items-center safe-inset-top-4\"\n      >\n        <FixedModalCloseButton onClick={dismiss} />\n      </m.div>\n\n      <ViewTag src={src} className=\"size-full\" />\n      {!!content && (\n        <div className=\"bg-background p-10 pt-5 backdrop-blur-sm\">\n          <HTML\n            as=\"div\"\n            className=\"prose !max-w-full dark:prose-invert\"\n            noMedia\n            style={renderStyle}\n          >\n            {content}\n          </HTML>\n        </div>\n      )}\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/index.ts",
    "content": "export { AuthorHeader } from \"./AuthorHeader\"\nexport { ContentBody } from \"./ContentBody\"\nexport { MediaTranscript } from \"./MediaTranscript\"\nexport { TranscriptToggle } from \"./TranscriptToggle\"\nexport { useTranscription } from \"./useTranscription\"\nexport { VideoPlayer } from \"./VideoPlayer\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/useTranscription.ts",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useQuery } from \"@tanstack/react-query\"\n\nimport { followClient } from \"~/lib/api-client\"\n\nexport const useTranscription = (entryId: string) => {\n  const entry = useEntry(entryId, (state) => {\n    return {\n      audioUrl: state.attachments?.find((att) => att.mime_type?.startsWith(\"audio/\"))?.url,\n      subtitleUrl: state.attachments?.find((att) => att.mime_type === \"text/srt\")?.url,\n    }\n  })\n\n  return useQuery({\n    queryKey: [\"transcription\", entryId],\n    queryFn: async () => {\n      if (entry?.subtitleUrl) {\n        return (await fetch(entry.subtitleUrl)).text()\n      }\n\n      if (entry?.audioUrl) {\n        const res = await followClient.api.entries.transcription({ url: entry.audioUrl })\n        return res.data?.srt || \"\"\n      }\n      return \"\"\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/types.ts",
    "content": "/**\n * Shared props interface for all entry content layout components\n */\nexport interface EntryLayoutProps {\n  entryId: string\n  compact?: boolean\n  noMedia?: boolean\n  translation?: {\n    content?: string\n    title?: string\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/selection/GlassButton.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport { m } from \"motion/react\"\n\ninterface GlassButtonProps extends HTMLMotionProps<\"button\"> {\n  variant?: \"primary\" | \"secondary\"\n  isLoading?: boolean\n  ref?: React.Ref<HTMLButtonElement>\n}\n\nexport const GlassButton = ({\n  className,\n  variant = \"primary\",\n  isLoading,\n  children,\n  disabled,\n  ref,\n  ...props\n}: GlassButtonProps) => {\n  const isPrimary = variant === \"primary\"\n\n  return (\n    <m.button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled || isLoading}\n      className={cn(\n        \"relative flex items-center gap-2 overflow-hidden rounded-full border px-4 py-1.5 text-sm font-semibold backdrop-blur-md\",\n        \"transition-all duration-300\",\n        \"disabled:cursor-not-allowed disabled:opacity-50\",\n        isPrimary\n          ? \"border-accent/30 text-white\"\n          : \"border-text/10 bg-fill/5 text-text hover:bg-fill/10\",\n        className,\n      )}\n      style={\n        isPrimary\n          ? {\n              background: \"linear-gradient(135deg, hsl(var(--fo-a) / 0.9), hsl(var(--fo-a) / 0.8))\",\n              boxShadow:\n                \"0 4px 16px hsl(var(--fo-a) / 0.2), 0 2px 8px hsl(var(--fo-a) / 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.2)\",\n            }\n          : undefined\n      }\n      whileHover={\n        !disabled && !isLoading\n          ? {\n              scale: 1.05,\n              boxShadow: isPrimary\n                ? \"0 6px 24px hsl(var(--fo-a) / 0.3), 0 4px 12px hsl(var(--fo-a) / 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.3)\"\n                : \"0 4px 12px rgba(0,0,0,0.05)\",\n            }\n          : undefined\n      }\n      whileTap={!disabled && !isLoading ? { scale: 0.95 } : undefined}\n      {...props}\n    >\n      {/* Hover shine effect for primary */}\n      {isPrimary && (\n        <div className=\"absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 hover:translate-x-full\" />\n      )}\n      {children as React.ReactNode}\n    </m.button>\n  )\n}\n\nGlassButton.displayName = \"GlassButton\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/selection/SharePosterModal.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { copyImageToClipboard } from \"~/lib/clipboard\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\n\nimport { GlassButton } from \"./GlassButton\"\n\ntype SharePosterModalProps = {\n  selectedText: string\n  entryId: string\n}\n\ntype Mode = \"light\" | \"dark\"\n\nexport function SharePosterModal({ selectedText, entryId }: SharePosterModalProps) {\n  const { t } = useTranslation()\n  const { dismiss } = useCurrentModal()\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const [isCopying, setIsCopying] = useState(false)\n  const [authorAvatarImg, setAuthorAvatarImg] = useState<HTMLImageElement | null>(null)\n  const [mode, setMode] = useState<Mode>(\n    document.documentElement.classList.contains(\"dark\") ? \"dark\" : \"light\",\n  )\n\n  const entry = useEntry(entryId, (state) => ({\n    title: state.title,\n    feedId: state.feedId,\n    author: state.author,\n    authorAvatar: state.authorAvatar,\n    publishedAt: state.publishedAt,\n    url: state.url,\n  }))\n\n  const feed = useFeedById(entry?.feedId)\n\n  // Load author avatar image\n  useEffect(() => {\n    if (entry?.authorAvatar) {\n      loadImage(entry.authorAvatar).then(setAuthorAvatarImg)\n    } else {\n      setAuthorAvatarImg(null)\n    }\n  }, [entry?.authorAvatar])\n\n  const draw = useCallback(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n\n    const ctx = canvas.getContext(\"2d\", {\n      alpha: false, // Better performance for opaque backgrounds\n      desynchronized: false, // Better quality\n    })\n    if (!ctx) return\n\n    // High resolution for crisp text - use higher scale for better quality\n    // Scale 3 provides excellent quality for long text\n    const scale = 3\n    const baseWidth = 720\n    const width = baseWidth * scale\n\n    // --- Config ---\n    const baseConfig = {\n      bg: mode === \"dark\" ? \"#0f0f0f\" : \"#ffffff\",\n      bgGradient:\n        mode === \"dark\" ? [\"#1a1a1a\", \"#0f0f0f\", \"#0a0a0a\"] : [\"#fafafa\", \"#ffffff\", \"#f5f5f5\"],\n      text: mode === \"dark\" ? \"#f5f5f5\" : \"#1a1a1a\",\n      textSecondary: mode === \"dark\" ? \"#a3a3a3\" : \"#525252\",\n      accent: mode === \"dark\" ? \"#737373\" : \"#737373\",\n      quoteColor: mode === \"dark\" ? \"#404040\" : \"#e5e5e5\",\n      logoColor: mode === \"dark\" ? \"#ff5c00\" : \"#ff5c00\",\n      fontFamilyTitle: \"-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif\",\n      fontFamilyBody: \"-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif\",\n      fontFamilyMeta: \"-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif\",\n      sizeTitle: 36,\n      sizeBody: 26,\n      sizeMeta: 14,\n      lineHeight: 1.7,\n      titleLineHeight: 1.4,\n      maxBodyLines: 18, // Limit body text to 18 lines\n    }\n\n    const config = {\n      ...baseConfig,\n      fontTitle: `600 ${baseConfig.sizeTitle}px ${baseConfig.fontFamilyTitle}`,\n      fontBody: `400 ${baseConfig.sizeBody}px ${baseConfig.fontFamilyBody}`,\n      fontMeta: `500 ${baseConfig.sizeMeta}px ${baseConfig.fontFamilyMeta}`,\n    }\n\n    // --- Measure Height ---\n    const padding = 56\n    const contentWidth = width / scale - padding * 2\n\n    // Measure Body - with truncation\n    ctx.font = config.fontBody\n    const allBodyLines = wrapText(ctx, selectedText, contentWidth)\n    const bodyLines = allBodyLines.slice(0, config.maxBodyLines)\n    const bodyHeight = bodyLines.length * (baseConfig.sizeBody * config.lineHeight)\n\n    // Measure Title\n    let titleHeight = 0\n    if (entry?.title) {\n      ctx.font = config.fontTitle\n      const titleLines = wrapText(ctx, entry.title, contentWidth)\n      titleHeight = titleLines.length * (baseConfig.sizeTitle * config.titleLineHeight) + 24 // + margin\n    }\n\n    // Total Height Calculation\n    const headerHeight = 72\n    const authorHeight = entry?.author ? 48 : 0 // Increased for avatar\n    const footerHeight = 80 // Increased for logo\n    const spacing = 48\n    const quoteSpacing = 32\n\n    const totalContentHeight =\n      padding +\n      headerHeight +\n      quoteSpacing +\n      bodyHeight +\n      spacing +\n      titleHeight +\n      authorHeight +\n      footerHeight +\n      padding\n    const minHeight = 800\n    const finalHeight = Math.max(minHeight, totalContentHeight) * scale\n\n    // Resize canvas\n    canvas.width = width\n    canvas.height = finalHeight\n\n    // Enable high-quality rendering\n    ctx.imageSmoothingEnabled = true\n    ctx.imageSmoothingQuality = \"high\"\n\n    // Scale context for high DPI rendering\n    ctx.scale(scale, scale)\n\n    const w = width / scale\n    const h = finalHeight / scale\n\n    // --- Background with Gradient ---\n    const gradient = ctx.createLinearGradient(0, 0, 0, h)\n    const bgColors = config.bgGradient\n    gradient.addColorStop(0, bgColors[0] ?? config.bg)\n    gradient.addColorStop(0.5, bgColors[1] ?? config.bg)\n    gradient.addColorStop(1, bgColors[2] ?? config.bg)\n    ctx.fillStyle = gradient\n    ctx.fillRect(0, 0, w, h)\n\n    // Subtle texture overlay\n    ctx.save()\n    ctx.globalAlpha = 0.03\n    for (let i = 0; i < w; i += 4) {\n      for (let j = 0; j < h; j += 4) {\n        if ((i + j) % 8 === 0) {\n          ctx.fillStyle = mode === \"dark\" ? \"#ffffff\" : \"#000000\"\n          ctx.fillRect(i, j, 1, 1)\n        }\n      }\n    }\n    ctx.restore()\n\n    // --- Layout Drawing ---\n    let currentY = padding + 24\n\n    // 1. Header (Feed Info)\n    ctx.fillStyle = config.textSecondary\n    ctx.globalAlpha = 0.7\n    ctx.font = config.fontMeta\n    ctx.textAlign = \"left\"\n    ctx.textBaseline = \"top\"\n\n    const dateStr = entry?.publishedAt\n      ? new Date(entry.publishedAt).toLocaleDateString(undefined, {\n          year: \"numeric\",\n          month: \"short\",\n          day: \"numeric\",\n        })\n      : new Date().toLocaleDateString()\n\n    const headerText = `${feed?.title || \"Folo\"}  •  ${dateStr}`\n    ctx.fillText(headerText, padding, currentY)\n\n    currentY += headerHeight\n\n    // 2. Quote Body with improved styling\n    ctx.save()\n\n    // Decorative Quote Mark - larger and more refined\n    ctx.fillStyle = config.quoteColor\n    ctx.globalAlpha = 0.4\n    ctx.font = \"bold 64px Georgia, serif\"\n    ctx.textAlign = \"left\"\n    ctx.textBaseline = \"top\"\n    ctx.fillText(\"\\u201C\", padding - 36, currentY - 24)\n\n    ctx.restore()\n\n    // Body text with better spacing\n    ctx.fillStyle = config.text\n    ctx.globalAlpha = 1\n    ctx.font = config.fontBody\n    ctx.textAlign = \"left\"\n    ctx.textBaseline = \"top\"\n\n    bodyLines.forEach((line, index) => {\n      // Add slight indentation for continuation lines\n      const indent = index === 0 ? 0 : 24\n      ctx.fillText(line, padding + indent, currentY)\n      currentY += baseConfig.sizeBody * config.lineHeight\n    })\n\n    // Show truncation indicator if text was truncated\n    if (allBodyLines.length > config.maxBodyLines) {\n      currentY += 8\n      ctx.fillStyle = config.textSecondary\n      ctx.globalAlpha = 0.6\n      ctx.font = config.fontMeta\n      ctx.fillText(\"...\", padding, currentY)\n      ctx.globalAlpha = 1\n    }\n\n    currentY += spacing\n\n    // 3. Divider line\n    ctx.save()\n    ctx.strokeStyle = config.quoteColor\n    ctx.globalAlpha = 0.2\n    ctx.lineWidth = 1\n    ctx.beginPath()\n    ctx.moveTo(padding, currentY - spacing / 2)\n    ctx.lineTo(w - padding, currentY - spacing / 2)\n    ctx.stroke()\n    ctx.restore()\n\n    // 4. Entry Title & Author\n    if (entry?.title) {\n      currentY += 8\n      ctx.fillStyle = config.text\n      ctx.globalAlpha = 0.9\n      ctx.font = config.fontTitle\n      const titleLines = wrapText(ctx, entry.title, contentWidth)\n      titleLines.forEach((line) => {\n        ctx.fillText(line, padding, currentY)\n        currentY += baseConfig.sizeTitle * config.titleLineHeight\n      })\n    }\n\n    if (entry?.author) {\n      currentY += 16\n\n      // Draw author avatar if available\n      const avatarSize = 32\n      const avatarX = padding\n      const avatarY = currentY\n\n      if (authorAvatarImg) {\n        // Draw circular avatar\n        ctx.save()\n        ctx.beginPath()\n        ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2)\n        ctx.clip()\n        ctx.drawImage(authorAvatarImg, avatarX, avatarY, avatarSize, avatarSize)\n        ctx.restore()\n      }\n\n      // Draw author name\n      ctx.fillStyle = config.textSecondary\n      ctx.globalAlpha = 0.8\n      ctx.font = config.fontMeta\n      const authorTextX = authorAvatarImg ? padding + avatarSize + 12 : padding\n      ctx.fillText(`By ${entry.author}`, authorTextX, avatarY + avatarSize / 2 - 7)\n      ctx.globalAlpha = 1\n    }\n\n    // 5. Footer / Branding - Draw Folo logo and text\n    const footerY = h - padding - 24\n    const logoSize = 36\n    const gap = 16\n    const svgScale = logoSize / 24\n\n    // Calculate positions for [Logo] [Folo] aligned to right\n    // Total width = logoSize + gap + logoSize (assuming folo text is also 24x24 scaled)\n    const totalWidth = logoSize + gap + logoSize\n    const startX = w - padding - totalWidth\n\n    const logoX = startX\n    const foloX = startX + logoSize + gap\n    const drawY = footerY - logoSize / 2\n\n    ctx.save()\n\n    // Draw Logo\n    ctx.translate(logoX, drawY)\n    ctx.scale(svgScale, svgScale)\n\n    // Logo Background\n    ctx.fillStyle = config.logoColor\n    const logoBgPath = new Path2D(\n      \"M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z\",\n    )\n    ctx.fill(logoBgPath)\n\n    // Logo F\n    ctx.fillStyle = \"#ffffff\"\n    const logoFPath = new Path2D(\n      \"M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z\",\n    )\n    ctx.fill(logoFPath)\n\n    ctx.restore()\n\n    // Draw Folo Text\n    ctx.save()\n    ctx.translate(foloX, drawY)\n    ctx.scale(svgScale, svgScale)\n\n    ctx.fillStyle = config.textSecondary\n    ctx.globalAlpha = 0.6\n    const foloPath = new Path2D(\n      \"M.899 16.997c-.567 0-.899-.358-.899-.994v-7.77c0-.637.36-.996 1.01-.996h4.34c.595 0 .927.29.927.788 0 .497-.332.774-.926.774H1.797v2.336H5.06c.595 0 .927.263.927.76 0 .512-.332.775-.927.775H1.797v3.332c0 .636-.318.996-.898.996m9.035.125c-2.101 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.13 0 3.567 1.534 3.567 3.705 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185m5.266 1.367c-.553 0-.857-.359-.857-.967V7.845c0-.608.304-.968.857-.968s.857.36.857.968v8.185c0 .608-.29.967-.857.967m5.234.125c-2.102 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.129 0 3.566 1.534 3.566 3.704 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185\",\n    )\n    ctx.fill(foloPath)\n\n    ctx.restore()\n  }, [entry, feed, mode, selectedText, authorAvatarImg])\n\n  useEffect(() => {\n    draw()\n  }, [draw])\n\n  const handleCopy = useCallback(async () => {\n    if (!canvasRef.current || isCopying) return\n\n    setIsCopying(true)\n    try {\n      await copyImageToClipboard(canvasRef.current)\n      toast.success(t(\"entry_content.selection_toolbar.poster_copied\"))\n      dismiss()\n    } catch (error) {\n      console.error(\"Failed to copy image:\", error)\n      toast.error(t(\"entry_content.selection_toolbar.poster_copy_failed\"))\n    } finally {\n      setIsCopying(false)\n    }\n  }, [isCopying, t, dismiss])\n\n  const handleShareToX = useCallback(() => {\n    if (!entry) return\n    const text = selectedText.length > 200 ? `${selectedText.slice(0, 200)}...` : selectedText\n    const shareUrl = UrlBuilder.shareEntry(entryId)\n    const intentUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(shareUrl)}`\n    window.open(intentUrl, \"_blank\")\n  }, [entry, selectedText, entryId])\n\n  return (\n    <div className=\"container center size-full\" onClick={(e) => e.stopPropagation()}>\n      <div className=\"relative flex flex-col items-center justify-center gap-6\">\n        {/* Preview Card */}\n        <m.div\n          layout\n          className=\"relative overflow-hidden rounded-2xl shadow-2xl ring-1 ring-black/5 dark:ring-white/10\"\n          initial={{ opacity: 0, scale: 0.9, y: 20 }}\n          animate={{ opacity: 1, scale: 1, y: 0 }}\n          exit={{ opacity: 0, scale: 0.9, y: 20 }}\n          transition={Spring.presets.smooth}\n        >\n          <canvas ref={canvasRef} className=\"size-auto max-h-[65vh] max-w-[90vw] object-contain\" />\n        </m.div>\n\n        {/* Floating Toolbar - Glassmorphic Design */}\n        <m.div\n          className={cn(\n            \"relative flex items-center gap-3 rounded-full border p-2 backdrop-blur-2xl\",\n            \"text-text\",\n          )}\n          style={{\n            backgroundImage:\n              \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.95), rgba(var(--color-background) / 0.9))\",\n            borderWidth: \"1px\",\n            borderStyle: \"solid\",\n            borderColor: \"hsl(var(--fo-a) / 0.2)\",\n            boxShadow:\n              \"0 8px 32px hsl(var(--fo-a) / 0.08), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n          }}\n          initial={{ opacity: 0, y: 20 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 20 }}\n          transition={{ ...Spring.presets.smooth, delay: 0.1 }}\n        >\n          {/* Inner glow layer */}\n          <div\n            className=\"pointer-events-none absolute inset-0 rounded-full\"\n            style={{\n              background:\n                \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))\",\n            }}\n          />\n\n          {/* Content */}\n          <div className=\"relative z-10 flex items-center gap-3\">\n            {/* Mode Toggle - Glassmorphic Button */}\n            <m.button\n              type=\"button\"\n              onClick={() => setMode(mode === \"light\" ? \"dark\" : \"light\")}\n              className={cn(\n                \"relative flex size-8 items-center justify-center rounded-full\",\n                \"text-text-secondary transition-all duration-300\",\n                \"hover:bg-fill/20 hover:text-text\",\n              )}\n              whileTap={{ scale: 0.95 }}\n              title=\"Toggle Appearance\"\n            >\n              <span\n                className={\n                  mode === \"light\"\n                    ? \"i-mingcute-sun-line text-base\"\n                    : \"i-mingcute-moon-line text-base\"\n                }\n              />\n            </m.button>\n\n            {/* Share to X */}\n            <m.button\n              type=\"button\"\n              onClick={handleShareToX}\n              className={cn(\n                \"relative flex size-8 items-center justify-center rounded-full\",\n                \"text-text-secondary transition-all duration-300\",\n                \"hover:bg-fill/20 hover:text-text\",\n              )}\n              whileTap={{ scale: 0.95 }}\n              title=\"Share to X\"\n            >\n              <span className=\"i-mgc-social-x-cute-li text-base\" />\n            </m.button>\n\n            {/* Divider */}\n            <div className=\"h-4 w-px bg-accent/20\" />\n\n            {/* Actions */}\n            <div className=\"flex items-center gap-2 pl-1\">\n              <GlassButton variant=\"secondary\" onClick={() => dismiss()}>\n                {t(\"words.close\", { ns: \"common\" })}\n              </GlassButton>\n              <GlassButton onClick={handleCopy} isLoading={isCopying}>\n                {isCopying ? (\n                  <span className=\"i-mingcute-loading-line relative z-10 animate-spin\" />\n                ) : (\n                  <span className=\"i-mingcute-copy-line relative z-10\" />\n                )}\n                <span className=\"relative z-10\">\n                  {isCopying\n                    ? t(\"entry_content.selection_toolbar.copying\")\n                    : t(\"entry_content.selection_toolbar.copy_image\")}\n                </span>\n              </GlassButton>\n            </div>\n          </div>\n        </m.div>\n      </div>\n    </div>\n  )\n}\n\n// Helper function to load image from URL\nfunction loadImage(url: string): Promise<HTMLImageElement | null> {\n  return new Promise((resolve) => {\n    const img = new Image()\n    img.crossOrigin = \"anonymous\"\n    img.onload = () => resolve(img)\n    img.onerror = () => resolve(null)\n    img.src = url\n  })\n}\n\nfunction wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {\n  const lines: string[] = []\n\n  // Check if text contains Chinese/Japanese/Korean characters\n  const hasCJK = /[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/.test(text)\n\n  if (hasCJK) {\n    // For CJK text, split by character and build lines\n    let currentLine = \"\"\n\n    for (const char of text) {\n      const testLine = currentLine + char\n      const metrics = ctx.measureText(testLine)\n\n      if (metrics.width > maxWidth && currentLine.length > 0) {\n        lines.push(currentLine)\n        currentLine = char\n      } else {\n        currentLine = testLine\n      }\n    }\n\n    if (currentLine) {\n      lines.push(currentLine)\n    }\n  } else {\n    // For non-CJK text, split by words\n    const words = text.split(/\\s+/)\n    let currentLine = words[0] || \"\"\n\n    for (let i = 1; i < words.length; i++) {\n      const word = words[i]\n      if (!word) continue\n      const testLine = currentLine ? `${currentLine} ${word}` : word\n      const metrics = ctx.measureText(testLine)\n\n      if (metrics.width > maxWidth && currentLine.length > 0) {\n        lines.push(currentLine)\n        currentLine = word\n      } else {\n        currentLine = testLine\n      }\n    }\n\n    if (currentLine) {\n      lines.push(currentLine)\n    }\n  }\n\n  return lines.length > 0 ? lines : [text]\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/components/selection/TextSelectionToolbar.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { cn } from \"@follow/utils\"\nimport { m } from \"motion/react\"\nimport type { CSSProperties, MouseEventHandler } from \"react\"\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\nimport type { TextSelectionEvent } from \"~/lib/simple-text-selection\"\n\nimport { SharePosterModal } from \"./SharePosterModal\"\n\nconst styles = {\n  toolbar: {\n    backgroundImage:\n      \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n    boxShadow:\n      \"0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.06), 0 4px 16px hsl(var(--fo-a) / 0.08), 0 2px 8px hsl(var(--fo-a) / 0.06), 0 1px 3px rgba(0, 0, 0, 0.04)\",\n  } as CSSProperties,\n  innerGlow: {\n    background:\n      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.02), transparent, hsl(var(--fo-a) / 0.02))\",\n  } as CSSProperties,\n}\n\ntype TextSelectionToolbarProps = {\n  selection: TextSelectionEvent | null\n  onRequestClose: () => void\n  onAskAI?: (selection: TextSelectionEvent) => void\n  entryId?: string\n}\n\nconst DEFAULT_DIMENSIONS = {\n  width: 220,\n  height: 48,\n}\n\nconst VIEWPORT_PADDING = 12\n\nexport function TextSelectionToolbar({\n  selection,\n  onRequestClose,\n  onAskAI,\n  entryId,\n}: TextSelectionToolbarProps) {\n  const { t } = useTranslation()\n  const { present } = useModalStack()\n  const toolbarRef = useRef<HTMLDivElement | null>(null)\n  const [toolbarSize, setToolbarSize] = useState(DEFAULT_DIMENSIONS)\n  const [copied, setCopied] = useState(false)\n  const [viewport, setViewport] = useState(() => getViewport())\n\n  useEffect(() => {\n    const handleResize = () => setViewport(getViewport())\n    window.addEventListener(\"resize\", handleResize)\n    return () => {\n      window.removeEventListener(\"resize\", handleResize)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!selection) return\n\n    const handleScroll = () => onRequestClose()\n    window.addEventListener(\"scroll\", handleScroll, true)\n    return () => {\n      window.removeEventListener(\"scroll\", handleScroll, true)\n    }\n  }, [selection, onRequestClose])\n\n  useLayoutEffect(() => {\n    if (!selection || !toolbarRef.current) return\n    const rect = toolbarRef.current.getBoundingClientRect()\n    setToolbarSize({\n      width: rect.width || DEFAULT_DIMENSIONS.width,\n      height: rect.height || DEFAULT_DIMENSIONS.height,\n    })\n  }, [selection, copied])\n\n  useEffect(() => {\n    if (!copied) return\n    const timer = setTimeout(() => setCopied(false), 1600)\n    return () => clearTimeout(timer)\n  }, [copied])\n\n  const position = useMemo(() => {\n    if (!selection) return null\n    const { rect } = selection\n    const toolbarHeight = toolbarSize.height || DEFAULT_DIMENSIONS.height\n    const toolbarWidth = toolbarSize.width || DEFAULT_DIMENSIONS.width\n    const viewportWidth = viewport.width || toolbarWidth\n\n    let top = rect.top - toolbarHeight - VIEWPORT_PADDING\n    if (top < VIEWPORT_PADDING) {\n      top = rect.bottom + VIEWPORT_PADDING\n    }\n\n    let left = rect.left + rect.width / 2 - toolbarWidth / 2\n    const maxLeft = Math.max(VIEWPORT_PADDING, viewportWidth - toolbarWidth - VIEWPORT_PADDING)\n    left = clamp(left, VIEWPORT_PADDING, maxLeft)\n\n    return { top, left }\n  }, [selection, toolbarSize, viewport])\n\n  const handleCopy = useCallback(async () => {\n    if (!selection) return\n    await copyToClipboard(selection.selectedText)\n    setCopied(true)\n  }, [selection])\n\n  const handleShare = useCallback(() => {\n    if (!selection || !entryId) return\n    present({\n      CustomModalComponent: PlainModal,\n      title: t(\"entry_content.selection_toolbar.share_poster\"),\n      id: \"share-poster\",\n      content: () => <SharePosterModal selectedText={selection.selectedText} entryId={entryId} />,\n      clickOutsideToDismiss: true,\n    })\n    onRequestClose()\n  }, [selection, entryId, present, onRequestClose, t])\n\n  const handleMouseDown: MouseEventHandler<HTMLDivElement> = (event) => {\n    event.preventDefault()\n  }\n\n  if (!selection || !position) return null\n\n  return (\n    <RootPortal>\n      <m.div\n        ref={toolbarRef}\n        style={{\n          top: position.top,\n          left: position.left,\n          ...styles.toolbar,\n        }}\n        className=\"pointer-events-auto fixed z-[70] rounded-xl border border-border/50 bg-material-ultra-thick p-px backdrop-blur-background\"\n        onMouseDown={handleMouseDown}\n        layout=\"position\"\n        initial={{ opacity: 0, y: 4, scale: 0.95 }}\n        animate={{ opacity: 1, y: 0, scale: 1 }}\n        transition={Spring.presets.smooth}\n      >\n        {/* Inner glow layer */}\n        <div className=\"pointer-events-none absolute inset-0 rounded-xl\" style={styles.innerGlow} />\n        <div className=\"relative flex items-center gap-1 text-[0.85rem] font-medium text-text\">\n          <ToolbarButton\n            iconClassName={copied ? \"i-mgc-check-cute-re\" : \"i-mgc-copy-cute-re\"}\n            label={\n              copied\n                ? t(\"entry_content.selection_toolbar.copied\")\n                : t(\"entry_content.selection_toolbar.copy\")\n            }\n            onClick={handleCopy}\n            active={copied}\n          />\n          {entryId ? (\n            <ToolbarButton\n              iconClassName=\"i-mgc-share-forward-cute-re\"\n              label={t(\"entry_content.selection_toolbar.share\")}\n              onClick={handleShare}\n            />\n          ) : null}\n          {onAskAI ? (\n            <ToolbarButton\n              iconClassName=\"i-mingcute-sparkles-2-line\"\n              label={t(\"entry_content.selection_toolbar.ask_ai\")}\n              onClick={() => onAskAI(selection)}\n            />\n          ) : null}\n        </div>\n      </m.div>\n    </RootPortal>\n  )\n}\n\ntype ToolbarButtonProps = {\n  iconClassName: string\n  label: string\n  onClick?: () => void\n  active?: boolean\n}\n\nfunction ToolbarButton({ iconClassName, label, onClick, active }: ToolbarButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-all duration-200\",\n        active\n          ? \"bg-fill/80 text-text shadow-sm\"\n          : \"text-text-secondary hover:bg-fill/60 hover:text-text active:scale-95\",\n      )}\n      aria-label={label}\n    >\n      <i className={cn(\"text-base\", iconClassName)} />\n      <span>{label}</span>\n    </button>\n  )\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)\n\nfunction getViewport() {\n  if (typeof window === \"undefined\") {\n    return { width: 0, height: 0 }\n  }\n\n  return { width: window.innerWidth, height: window.innerHeight }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/constants/navigation-hints.ts",
    "content": "/**\n * Constants for entry navigation hints behavior\n */\nexport const NAVIGATION_HINTS_CONSTANTS = {\n  /** Default scroll threshold to trigger scroll hint (px) */\n  DEFAULT_SCROLL_THRESHOLD: 100,\n\n  /** Delay before showing first entry hint (ms) */\n  FIRST_HINT_DELAY: 500,\n\n  /** Duration to show hints before auto-hiding (ms) */\n  HINT_DISPLAY_DURATION: 3000,\n\n  /** Distance from bottom to trigger bottom hint (px) */\n  BOTTOM_THRESHOLD: 50,\n\n  /** Distance from bottom to hide bottom hint when scrolling up (px) */\n  BOTTOM_HIDE_THRESHOLD: 100,\n\n  /** Throttle interval for scroll handler (ms) */\n  SCROLL_THROTTLE_INTERVAL: 100,\n} as const\n\n/**\n * Text constants for navigation hints\n */\nexport const NAVIGATION_HINTS_TEXT = {\n  SCROLL_UP_EXIT: \"Scroll up or click left-top back button to exit\",\n  ESC_EXIT: \"Press ESC or click left-top back button to exit\",\n} as const\n\n/**\n * Icon constants for navigation hints\n */\nexport const NAVIGATION_HINTS_ICONS = {\n  ARROW_UP: \"i-mgc-up-cute-re\",\n  ARROW_LEFT_UP: \"i-mingcute-arrow-left-up-line\",\n  ARROW_TO_DOWN: \"i-mingcute-arrow-to-down-line\",\n  CLOSE: \"i-mgc-close-cute-re\",\n} as const\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/hooks/useEntryNavigationHints.ts",
    "content": "import { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { throttle } from \"es-toolkit\"\nimport { startTransition, useEffect, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { NAVIGATION_HINTS_CONSTANTS } from \"../constants/navigation-hints\"\n\ninterface UseEntryNavigationHintsOptions {\n  /** Whether hints are enabled */\n  enabled: boolean\n  /** Entry ID to track changes */\n  entryId?: string\n  /** Scroll threshold to show hint */\n  scrollThreshold?: number\n}\n\ninterface UseEntryNavigationHintsReturn {\n  /** Show hint for first entry */\n  showFirstEntryHint: boolean\n  /** Show hint when scrolled past threshold */\n  showScrollHint: boolean\n  /** Show hint when at bottom */\n  showBottomHint: boolean\n}\n\n/**\n * Custom hook for managing entry navigation hints\n * Shows contextual hints based on scroll position and entry state\n */\nexport const useEntryNavigationHints = ({\n  enabled,\n  entryId,\n  scrollThreshold = NAVIGATION_HINTS_CONSTANTS.DEFAULT_SCROLL_THRESHOLD,\n}: UseEntryNavigationHintsOptions): UseEntryNavigationHintsReturn => {\n  const $scrollElement = useScrollViewElement()\n\n  // State for different hint types\n  const [showFirstEntryHint, setShowFirstEntryHint] = useState(false)\n  const [showScrollHint, setShowScrollHint] = useState(false)\n  const [showBottomHint, setShowBottomHint] = useState(false)\n\n  // Refs to track state\n  const hasShownFirstHintRef = useRef(false)\n  const hasShownScrollHintRef = useRef(false)\n  const hasShownBottomHintRef = useRef(false)\n  const currentEntryIdRef = useRef<string>(void 0)\n  const firstHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)\n  const scrollHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)\n  const bottomHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)\n  const lastScrollTopRef = useRef(0)\n  const scrollDirectionRef = useRef<\"up\" | \"down\" | \"none\">(\"none\")\n\n  // Reset hints when entry changes\n  useEffect(() => {\n    if (entryId && entryId !== currentEntryIdRef.current) {\n      currentEntryIdRef.current = entryId\n      hasShownFirstHintRef.current = false\n      hasShownScrollHintRef.current = false\n      hasShownBottomHintRef.current = false\n      lastScrollTopRef.current = 0\n      scrollDirectionRef.current = \"none\"\n\n      // Clear existing timers\n      if (firstHintTimerRef.current) clearTimeout(firstHintTimerRef.current)\n      if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)\n      if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)\n\n      // Reset all hint states with low priority\n      startTransition(() => {\n        setShowFirstEntryHint(false)\n        setShowScrollHint(false)\n        setShowBottomHint(false)\n      })\n\n      if (enabled) {\n        // Show first entry hint after a brief delay\n        firstHintTimerRef.current = setTimeout(() => {\n          if (!hasShownFirstHintRef.current) {\n            startTransition(() => {\n              setShowFirstEntryHint(true)\n            })\n            hasShownFirstHintRef.current = true\n\n            // Hide after configured duration\n            firstHintTimerRef.current = setTimeout(() => {\n              startTransition(() => {\n                setShowFirstEntryHint(false)\n              })\n            }, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)\n          }\n        }, NAVIGATION_HINTS_CONSTANTS.FIRST_HINT_DELAY) // Small delay to allow content to load\n      }\n    }\n  }, [entryId, enabled])\n\n  // Scroll handler to manage hints based on scroll position\n  const handleScroll = useEventCallback(\n    throttle(() => {\n      if (!enabled || !$scrollElement) return\n\n      const { scrollTop } = $scrollElement\n      const { scrollHeight } = $scrollElement\n      const { clientHeight } = $scrollElement\n      const scrollBottom = scrollHeight - clientHeight - scrollTop\n\n      // Detect scroll direction\n      const lastScrollTop = lastScrollTopRef.current\n      if (scrollTop > lastScrollTop) {\n        scrollDirectionRef.current = \"down\"\n      } else if (scrollTop < lastScrollTop) {\n        scrollDirectionRef.current = \"up\"\n      }\n      lastScrollTopRef.current = scrollTop\n\n      // Check if scrolled past threshold and scrolling up\n      if (\n        scrollTop > scrollThreshold &&\n        !hasShownScrollHintRef.current &&\n        scrollDirectionRef.current === \"up\"\n      ) {\n        hasShownScrollHintRef.current = true\n        startTransition(() => {\n          setShowScrollHint(true)\n        })\n\n        // Clear previous timer\n        if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)\n\n        // Hide after configured duration\n        scrollHintTimerRef.current = setTimeout(() => {\n          startTransition(() => {\n            setShowScrollHint(false)\n          })\n        }, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)\n      }\n\n      // Check if at bottom (within configured threshold)\n      if (\n        scrollBottom <= NAVIGATION_HINTS_CONSTANTS.BOTTOM_THRESHOLD &&\n        !hasShownBottomHintRef.current\n      ) {\n        hasShownBottomHintRef.current = true\n        startTransition(() => {\n          setShowBottomHint(true)\n        })\n\n        // Clear previous timer\n        if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)\n\n        // Hide after configured duration\n        bottomHintTimerRef.current = setTimeout(() => {\n          startTransition(() => {\n            setShowBottomHint(false)\n          })\n          hasShownBottomHintRef.current = false\n        }, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)\n      }\n\n      // Hide bottom hint if user scrolls up from bottom\n      if (\n        scrollBottom > NAVIGATION_HINTS_CONSTANTS.BOTTOM_HIDE_THRESHOLD &&\n        hasShownBottomHintRef.current &&\n        scrollDirectionRef.current === \"up\"\n      ) {\n        // Clear timer if exists\n        if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)\n\n        startTransition(() => {\n          setShowBottomHint(false)\n        })\n        hasShownBottomHintRef.current = false\n      }\n    }, NAVIGATION_HINTS_CONSTANTS.SCROLL_THROTTLE_INTERVAL),\n  )\n\n  // Attach scroll listener\n  useEffect(() => {\n    if (!enabled || !$scrollElement) return\n\n    $scrollElement.addEventListener(\"scroll\", handleScroll, { passive: true })\n\n    return () => {\n      $scrollElement.removeEventListener(\"scroll\", handleScroll)\n    }\n  }, [enabled, $scrollElement, handleScroll])\n\n  // Cleanup timers on unmount\n  useEffect(() => {\n    return () => {\n      if (firstHintTimerRef.current) clearTimeout(firstHintTimerRef.current)\n      if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)\n      if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)\n    }\n  }, [])\n\n  return {\n    showFirstEntryHint,\n    showScrollHint,\n    showBottomHint,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx",
    "content": "import { isFreeRole } from \"@follow/constants\"\nimport { useEntry, usePrefetchEntryDetail } from \"@follow/store/entry/hooks\"\nimport { useEntryTranslation, usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { createElement, useCallback, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { useShowAITranslation } from \"~/atoms/ai-translation\"\nimport { useEntryIsInReadability, useEntryIsInReadabilitySuccess } from \"~/atoms/readability\"\nimport { useActionLanguage, useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { ImageGalleryContent } from \"./components/ImageGalleryContent\"\n\nexport const useGalleryModal = () => {\n  const { present } = useModalStack()\n  const { t } = useTranslation()\n  return useCallback(\n    (entryId?: string) => {\n      if (!entryId) {\n        // this should not happen unless there is a bug in the code\n        toast.error(\"Invalid feed id\")\n        return\n      }\n      tracker.entryContentHeaderImageGalleryClick({\n        feedId: entryId,\n      })\n      present({\n        title: t(\"entry_actions.image_gallery\"),\n        content: () => createElement(ImageGalleryContent, { entryId }),\n        max: true,\n        clickOutsideToDismiss: true,\n      })\n    },\n    [present, t],\n  )\n}\n\nexport const useEntryContent = (entryId: string) => {\n  const entry = useEntry(entryId, (state) => {\n    const { inboxHandle, content, readabilityContent } = state\n    return { inboxId: inboxHandle, content, readabilityContent }\n  })\n  const { error, data, isPending } = usePrefetchEntryDetail(entryId)\n\n  const isInReadabilityMode = useEntryIsInReadability(entryId)\n  const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)\n\n  const enableTranslation = useShowAITranslation()\n  const userRole = useUserRole()\n  const shouldPrefetchTranslation = enableTranslation && !isFreeRole(userRole)\n  const actionLanguage = useActionLanguage()\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const contentTranslated = useEntryTranslation({\n    entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n  usePrefetchEntryTranslation({\n    entryIds: [entryId],\n    enabled: shouldPrefetchTranslation,\n    language: actionLanguage,\n    withContent: true,\n    target: isReadabilitySuccess ? \"readabilityContent\" : \"content\",\n    mode: translationMode,\n  })\n\n  return useMemo(() => {\n    const entryContent = isInReadabilityMode\n      ? entry?.readabilityContent\n      : (entry?.content ?? data?.content)\n    const translatedContent = isInReadabilityMode\n      ? contentTranslated?.readabilityContent\n      : contentTranslated?.content\n    const content = translatedContent || entryContent\n    return {\n      content,\n      error,\n      isPending,\n    }\n  }, [\n    contentTranslated?.content,\n    contentTranslated?.readabilityContent,\n    data?.content,\n    entry?.content,\n    error,\n    isInReadabilityMode,\n    isPending,\n    entry?.readabilityContent,\n  ])\n}\n\nexport const useEntryMediaInfo = (entryId: string) => {\n  return useEntry(entryId, (entry) =>\n    Object.fromEntries(\n      entry?.media\n        ?.filter((m) => m.type === \"photo\")\n        .map((cur) => [\n          cur.url,\n          {\n            width: cur.width,\n            height: cur.height,\n          },\n        ]) ?? [],\n    ),\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/feed/feed-certification.tsx",
    "content": "import { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { useUserById, useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useReplaceImgUrlIfNeed } from \"~/lib/img-proxy\"\nimport { usePresentUserProfileModal } from \"~/modules/profile/hooks\"\n\nexport const FeedCertification = ({\n  feed,\n  className,\n}: {\n  feed: FeedModel | ListModel\n  className?: string\n}) => {\n  const me = useWhoami()\n\n  const { t } = useTranslation()\n  const { type } = feed\n\n  return (\n    feed.ownerUserId &&\n    (feed.ownerUserId === me?.id ? (\n      <Tooltip delayDuration={300}>\n        <TooltipTrigger asChild>\n          <i\n            className={cn(\"i-mgc-certificate-cute-fi ml-1.5 shrink-0 text-orange-500\", className)}\n          />\n        </TooltipTrigger>\n\n        <TooltipPortal>\n          <TooltipContent className=\"px-4 py-2\">\n            <div className=\"flex items-center text-base font-semibold\">\n              <i className=\"i-mgc-certificate-cute-fi mr-2 size-4 shrink-0 text-orange-500\" />\n              {type === \"feed\" ? t(\"feed_item.claimed_feed\") : t(\"feed_item.claimed_list\")}\n            </div>\n            <div>{t(\"feed_item.claimed_by_you\")}</div>\n          </TooltipContent>\n        </TooltipPortal>\n      </Tooltip>\n    ) : (\n      <Tooltip delayDuration={300}>\n        <TooltipTrigger asChild>\n          <i\n            className={cn(\"i-mgc-certificate-cute-fi ml-1.5 shrink-0 text-amber-500\", className)}\n          />\n        </TooltipTrigger>\n\n        <TooltipPortal>\n          <TooltipContent className=\"px-4 py-2\">\n            <div className=\"flex items-center text-base font-semibold\">\n              <i className=\"i-mgc-certificate-cute-fi mr-2 shrink-0 text-amber-500\" />\n              {type === \"feed\" ? t(\"feed_item.claimed_feed\") : t(\"feed_item.claimed_list\")}\n            </div>\n            <div className=\"mt-1 flex items-center gap-1.5\">\n              <span>{t(\"feed_item.claimed_by_owner\")}</span>\n              {feed.ownerUserId ? (\n                <FeedCertificateAvatar userId={feed.ownerUserId} />\n              ) : (\n                <span>{t(\"feed_item.claimed_by_unknown\")}</span>\n              )}\n            </div>\n          </TooltipContent>\n        </TooltipPortal>\n      </Tooltip>\n    ))\n  )\n}\n\nconst FeedCertificateAvatar = ({ userId }: { userId: string }) => {\n  const replaceImgUrlIfNeed = useReplaceImgUrlIfNeed()\n  const user = useUserById(userId)\n  const presentUserProfile = usePresentUserProfileModal(\"drawer\")\n  if (!user) return null\n  return (\n    <Avatar\n      className=\"inline-flex aspect-square size-5 rounded-full\"\n      onClick={(e) => {\n        e.stopPropagation()\n        presentUserProfile(userId)\n      }}\n    >\n      <AvatarImage src={replaceImgUrlIfNeed(user.image || undefined)} />\n      <AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>\n    </Avatar>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx",
    "content": "// import { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { PlatformIcon } from \"@follow/components/ui/platform-icon/index.jsx\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport { getBackgroundGradient } from \"@follow/utils/color\"\nimport { getImageProxyUrl } from \"@follow/utils/img-proxy\"\nimport { cn, getUrlIcon } from \"@follow/utils/utils\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\nimport { m } from \"motion/react\"\nimport type { ReactNode } from \"react\"\nimport { useMemo } from \"react\"\n\nimport { useCanUseImageProxy } from \"~/lib/img-proxy\"\n\nconst { Avatar, AvatarFallback, AvatarImage } = AvatarPrimitive\n\n// Size-responsive border radius utility function\nconst getBorderRadius = (size: number) => {\n  if (size <= 24) return \"rounded-sm\" // 2px for small avatars\n  if (size <= 32) return \"rounded-md\" // 6px for medium avatars\n  if (size <= 48) return \"rounded-lg\" // 8px for large avatars\n  return \"rounded-xl\" // 12px for extra large avatars\n}\n\ntype GetIconPropsProps = {\n  target?: IconTarget | null\n  entry?: FeedIconEntry | null\n  useMedia?: boolean\n  siteUrl?: string\n  fallbackUrl?: string\n  fallback?: boolean\n  size?: number\n  canUseProxy?: boolean\n}\nfunction getIconProps(props: GetIconPropsProps) {\n  const {\n    target,\n    entry,\n    useMedia,\n    siteUrl: propSiteUrl,\n    fallbackUrl,\n    fallback,\n    size = 20,\n    canUseProxy,\n  } = props\n  const image =\n    (useMedia ? entry?.firstPhotoUrl || entry?.authorAvatar : entry?.authorAvatar) || target?.image\n  const siteUrl = (target as FeedModel)?.siteUrl || fallbackUrl\n\n  if (propSiteUrl && !target) {\n    const [src] = getFeedIconSrc({\n      siteUrl: propSiteUrl,\n      canUseProxy,\n    })\n    return {\n      type: \"image\" as const,\n      src,\n      platformUrl: propSiteUrl,\n      fallbackSrc: \"\",\n    }\n  }\n  if (image) {\n    return {\n      type: \"image\" as const,\n      src: getImageProxyUrl({\n        url: image,\n        width: size * 2,\n        height: size * 2,\n        canUseProxy,\n      }),\n      platformUrl: image,\n      fallbackSrc: \"\",\n    }\n  }\n\n  if (siteUrl) {\n    const [src, fallbackSrc] = getFeedIconSrc({\n      siteUrl,\n      fallback,\n      proxy: {\n        width: size * 2,\n        height: size * 2,\n      },\n      canUseProxy,\n    })\n    return {\n      type: \"image\" as const,\n      src,\n      platformUrl: siteUrl,\n      fallbackSrc,\n    }\n  }\n  if (target?.type === \"inbox\") {\n    return {\n      type: \"inbox\" as const,\n    }\n  }\n\n  if (target?.title) {\n    return {\n      type: \"text\" as const,\n    }\n  }\n\n  return {\n    type: \"default\" as const,\n  }\n}\n\nconst getFeedIconSrc = ({\n  src,\n  siteUrl,\n  fallback,\n  proxy,\n  canUseProxy,\n}: {\n  src?: string\n  siteUrl?: string\n  fallback?: boolean\n  proxy?: { height: number; width: number }\n  canUseProxy?: boolean\n} = {}) => {\n  if (src) {\n    if (proxy) {\n      return [\n        getImageProxyUrl({\n          url: src,\n          width: proxy.width,\n          height: proxy.height,\n          canUseProxy,\n        }),\n        \"\",\n      ]\n    }\n\n    return [src, \"\"]\n  }\n  if (!siteUrl) return [\"\", \"\"]\n  const ret = getUrlIcon(siteUrl, fallback)\n\n  return [ret.src, ret.fallbackUrl]\n}\n\nconst FallbackableImage = function FallbackableImage({\n  ref,\n  fallbackUrl,\n  ...rest\n}: {\n  fallbackUrl: string\n} & React.ImgHTMLAttributes<HTMLImageElement> & {\n    ref?: React.Ref<HTMLImageElement | null>\n  }) {\n  return (\n    <img\n      onError={(e) => {\n        if (fallbackUrl && e.currentTarget.src !== fallbackUrl) {\n          e.currentTarget.src = fallbackUrl\n        } else {\n          rest.onError?.(e)\n          // Empty svg\n          e.currentTarget.src =\n            \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3C/svg%3E\"\n        }\n      }}\n      {...rest}\n      ref={ref}\n    />\n  )\n}\n\n// type FeedIconFeed = Pick<FeedModel, \"title\" | \"image\" | \"siteUrl\" | \"type\"> | ListModel\ntype IconTarget = {\n  title?: Nullable<string>\n  image?: Nullable<string>\n  siteUrl?: Nullable<string>\n  type: \"feed\" | \"list\" | \"inbox\"\n  entry?: FeedIconEntry | null\n  useMedia?: boolean\n  feed?: FeedModel | null\n  fallbackUrl?: string\n  fallback?: boolean\n  size?: number\n}\n\nexport type FeedIconEntry = { authorAvatar?: string | null; firstPhotoUrl?: string | null }\nconst fadeInVariant = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n}\n\nconst isIconLoadedSet = new Set<string>()\nexport function FeedIcon({\n  target,\n  entry,\n  fallbackUrl,\n  className,\n  size = 20,\n  fallback = true,\n  fallbackElement,\n  siteUrl,\n  useMedia,\n  disableFadeIn,\n  noMargin,\n}: {\n  target?: IconTarget | null\n  entry?: FeedIconEntry | null\n  fallbackUrl?: string\n  className?: string\n  size?: number\n  siteUrl?: string\n  /**\n   * Image loading error fallback to site icon\n   */\n  fallback?: boolean\n  fallbackElement?: ReactNode\n\n  useMedia?: boolean\n  disableFadeIn?: boolean\n  noMargin?: boolean\n}) {\n  const marginClassName = cn(noMargin ? \"\" : \"mr-2\", className)\n  const canUseProxy = useCanUseImageProxy()\n  const iconProps = getIconProps({\n    target,\n    entry,\n    useMedia,\n    siteUrl,\n    fallbackUrl,\n    fallback,\n    size,\n    canUseProxy,\n  })\n\n  const colors = useMemo(\n    () => getBackgroundGradient(target?.title || (target as FeedModel)?.url || siteUrl || \"\"),\n    [target?.title, (target as FeedModel)?.url, siteUrl],\n  )\n\n  const sizeStyle: React.CSSProperties = useMemo(\n    () => ({\n      width: size,\n      height: size,\n    }),\n    [size],\n  )\n  const colorfulStyle: React.CSSProperties = useMemo(() => {\n    const [, , , bgAccent, bgAccentLight, bgAccentUltraLight] = colors\n    return {\n      backgroundImage: `linear-gradient(to top, ${bgAccent} 0%, ${bgAccentLight} 99%, ${bgAccentUltraLight} 100%)`,\n\n      ...sizeStyle,\n    }\n  }, [colors, sizeStyle])\n\n  const textFallbackIcon = (\n    <span\n      style={colorfulStyle}\n      className={cn(\n        \"flex shrink-0 items-center justify-center rounded-sm\",\n        \"text-white\",\n        marginClassName,\n      )}\n    >\n      <span\n        style={{\n          fontSize: size / 2,\n        }}\n      >\n        {!!target?.title && target.title[0]}\n      </span>\n    </span>\n  )\n\n  let imageElement: ReactNode\n  let finalSrc = \"\"\n\n  switch (iconProps.type) {\n    case \"image\": {\n      finalSrc = iconProps.src!\n      const isIconLoaded = isIconLoadedSet.has(finalSrc)\n      isIconLoadedSet.add(finalSrc)\n      const { fallbackSrc } = iconProps\n\n      imageElement = (\n        <PlatformIcon url={iconProps.platformUrl!} style={sizeStyle} className={className}>\n          {fallbackSrc ? (\n            <FallbackableImage\n              className={marginClassName}\n              style={sizeStyle}\n              fallbackUrl={fallbackSrc}\n            />\n          ) : (\n            <m.img\n              className={marginClassName}\n              style={sizeStyle}\n              {...(disableFadeIn || isIconLoaded ? {} : fadeInVariant)}\n            />\n          )}\n        </PlatformIcon>\n      )\n      break\n    }\n    case \"inbox\": {\n      imageElement = (\n        <i className={cn(\"i-mgc-inbox-cute-fi shrink-0\", marginClassName)} style={sizeStyle} />\n      )\n      break\n    }\n    case \"text\": {\n      imageElement = textFallbackIcon\n      break\n    }\n    case \"default\": {\n      imageElement = (\n        <i className={cn(\"i-mgc-link-cute-re shrink-0\", marginClassName)} style={sizeStyle} />\n      )\n      break\n    }\n  }\n\n  if (!imageElement) {\n    return null\n  }\n\n  const fallbackIcon = fallbackElement || textFallbackIcon\n\n  if (finalSrc) {\n    return (\n      <Avatar className={cn(\"shrink-0 [&_*]:select-none\", marginClassName)} style={sizeStyle}>\n        <AvatarImage className={cn(\"object-cover\", getBorderRadius(size))} asChild src={finalSrc}>\n          {imageElement}\n        </AvatarImage>\n        <AvatarFallback delayMs={200} asChild>\n          {fallback ? fallbackIcon : <div />}\n        </AvatarFallback>\n      </Avatar>\n    )\n  }\n\n  return imageElement\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/feed/feed-summary.tsx",
    "content": "import { RSSHubLogo } from \"@follow/components/ui/platform-icon/icons.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport type { InboxModel } from \"@follow/store/inbox/types\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\n\nexport function FollowSummary({\n  feed,\n  docs,\n  className,\n  simple,\n}: {\n  feed: FeedModel | ListModel | InboxModel\n  docs?: string\n  className?: string\n  simple?: boolean\n}) {\n  let feedText: string | undefined\n  let isRSSHub = false\n\n  switch (feed.type) {\n    case \"list\": {\n      feedText = UrlBuilder.shareList(feed.id)\n      break\n    }\n    case \"inbox\": {\n      feedText = `${feed.id}${env.VITE_INBOXES_EMAIL}`\n      break\n    }\n    default: {\n      feedText = feed.url || docs\n      isRSSHub = Boolean(feedText?.startsWith(\"rsshub://\"))\n      break\n    }\n  }\n\n  return (\n    <div className={cn(\"flex select-text flex-col gap-2 text-sm\", className)}>\n      <div className=\"flex items-center\">\n        <FeedIcon\n          target={feed}\n          fallbackUrl={docs}\n          className=\"mask-squircle mask shrink-0 rounded-none\"\n          size={32}\n        />\n        <div className=\"min-w-0 flex-1 leading-tight\">\n          <div className=\"mb-0.5 flex items-center gap-1.5\">\n            <FeedTitle feed={feed} className=\"text-[15px] font-semibold\" />\n            {isRSSHub && <RSSHubIndicator />}\n          </div>\n          <EllipsisHorizontalTextWithTooltip className=\"truncate text-xs font-normal text-text-secondary duration-200\">\n            {feedText}\n          </EllipsisHorizontalTextWithTooltip>\n        </div>\n      </div>\n      {!simple && \"description\" in feed && feed.description && (\n        <EllipsisHorizontalTextWithTooltip className=\"truncate pl-10 text-body font-normal text-text/80\">\n          {feed.description}\n        </EllipsisHorizontalTextWithTooltip>\n      )}\n    </div>\n  )\n}\n\nconst RSSHubIndicator = () => {\n  return (\n    <Tooltip>\n      <TooltipTrigger>\n        <div className=\"inline-flex shrink-0 items-center gap-1 rounded bg-orange/10 px-1.5 py-0.5 text-xs text-orange\">\n          <RSSHubLogo className=\"size-2.5\" />\n          <span>RSSHub</span>\n        </div>\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent>This feed is powered by RSSHub.</TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/feed/feed-title.tsx",
    "content": "import { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport type { InboxModel } from \"@follow/store/inbox/types\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { FeedCertification } from \"~/modules/feed/feed-certification\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nexport const FeedTitle = ({\n  feed,\n  className,\n  titleClassName,\n  title,\n  style,\n}: {\n  feed?: FeedModel | ListModel | InboxModel | null\n  className?: string\n  titleClassName?: string\n  title?: string | null\n  style?: React.CSSProperties\n}) => {\n  const hideExtraBadge = useUISettingKey(\"hideExtraBadge\")\n\n  if (!feed) return null\n\n  return (\n    <div className={cn(\"flex select-none items-center truncate\", className)} style={style}>\n      <EllipsisHorizontalTextWithTooltip className={cn(\"truncate\", titleClassName)}>\n        {title || getPreferredTitle(feed)}\n      </EllipsisHorizontalTextWithTooltip>\n      {!hideExtraBadge && feed.type !== \"inbox\" && <FeedCertification feed={feed} />}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/feed/view-select-content.tsx",
    "content": "import { SelectContent, SelectItem } from \"@follow/components/ui/select/index.jsx\"\nimport { getViewList } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nexport const ViewSelectContent = () => {\n  const { t } = useTranslation()\n\n  return (\n    <SelectContent>\n      {getViewList().map((view, index) => (\n        <SelectItem key={view.name} value={`${index}`}>\n          <div className=\"flex items-center gap-2\">\n            <span className={cn(view.className, \"flex\")}>{view.icon}</span>\n            <span>{t(view.name, { ns: \"common\" })}</span>\n          </div>\n        </SelectItem>\n      ))}\n    </SelectContent>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/CustomIntegrationPreview.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FetchTemplate } from \"@follow/shared/settings/interface\"\nimport { cn } from \"@follow/utils\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { CustomIntegrationManager } from \"./custom-integration-manager\"\n\ninterface CustomIntegrationPreviewProps {\n  fetchTemplate: FetchTemplate\n  className?: string\n}\n\nexport const CustomIntegrationPreview = ({\n  fetchTemplate,\n  className,\n}: CustomIntegrationPreviewProps) => {\n  const { t } = useTranslation(\"settings\")\n  const [preview, setPreview] = useState<{\n    url: string\n    headers: Record<string, string>\n    body?: string\n    method: string\n  } | null>(null)\n  const [isOpen, setIsOpen] = useState(false)\n  const [isLoading, setIsLoading] = useState(false)\n\n  const generatePreview = async (fetchTemplate: FetchTemplate) => {\n    setIsLoading(true)\n    try {\n      const previewData = await CustomIntegrationManager.getTemplatePreview(fetchTemplate)\n\n      setPreview(previewData)\n    } catch (error) {\n      console.error(\"Failed to generate preview:\", error)\n      setPreview(null)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"sm\"\n        buttonClassName=\"w-full py-2\"\n        textClassName=\"flex w-full justify-between\"\n        onClick={() => {\n          setIsOpen(!isOpen)\n          if (!isOpen) {\n            generatePreview(fetchTemplate)\n          }\n        }}\n      >\n        <span className=\"flex items-center gap-2\">\n          <i className=\"i-mingcute-eye-line\" />\n          {t(\"integration.custom_integrations.preview.title\", \"Preview Request\")}\n        </span>\n        <i className={cn(\"i-mgc-right-cute-re transition-transform\", isOpen && \"rotate-90\")} />\n      </Button>\n\n      {isOpen && (\n        <div className=\"space-y-3\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center rounded-lg bg-material-medium p-4\">\n              <div className=\"flex items-center gap-2\">\n                <i className=\"i-mgc-loading-3-cute-re animate-spin\" />\n                <span className=\"text-sm text-text-tertiary\">Generating preview...</span>\n              </div>\n            </div>\n          ) : preview ? (\n            <div className=\"space-y-3 rounded-lg bg-material-medium p-4\">\n              {/* Method and URL */}\n              <div>\n                <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">Request</h4>\n                <div className=\"flex items-center gap-2 rounded bg-material-medium p-2 font-mono text-sm\">\n                  <span className=\"rounded bg-blue/10 px-2 py-1 text-xs font-bold text-blue\">\n                    {preview.method}\n                  </span>\n                  <span className=\"break-all text-text-secondary\">{preview.url}</span>\n                </div>\n              </div>\n\n              {/* Headers */}\n              {Object.keys(preview.headers).length > 0 && (\n                <div>\n                  <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">Headers</h4>\n                  <div className=\"space-y-1 rounded bg-material-medium p-2\">\n                    {Object.entries(preview.headers).map(([key, value]) => (\n                      <div key={key} className=\"flex font-mono text-sm\">\n                        <span className=\"min-w-0 flex-shrink-0 pr-2 text-text-secondary\">\n                          {key}:\n                        </span>\n                        <span className=\"min-w-0 flex-1 break-all text-text-tertiary\">{value}</span>\n                      </div>\n                    ))}\n                  </div>\n                </div>\n              )}\n\n              {/* Body */}\n              {preview.body && (\n                <div>\n                  <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">Request Body</h4>\n                  <div className=\"max-h-40 overflow-auto rounded bg-material-medium p-2\">\n                    <pre className=\"whitespace-pre-wrap font-mono text-sm text-text-secondary\">\n                      {preview.body}\n                    </pre>\n                  </div>\n                </div>\n              )}\n\n              {/* Placeholders Info */}\n              <div className=\"border-t border-border pt-3\">\n                <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">\n                  Available Placeholders\n                </h4>\n                <div className=\"grid grid-cols-2 gap-2 text-xs\">\n                  {CustomIntegrationManager.getAvailablePlaceholders().map((placeholder) => (\n                    <div key={placeholder.key} className=\"rounded bg-material-opaque p-2\">\n                      <code className=\"font-bold text-text\">{placeholder.key}</code>\n                      <div className=\"mt-1 text-text-tertiary\">{placeholder.description}</div>\n                    </div>\n                  ))}\n                </div>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex items-center justify-center rounded-lg bg-material-medium p-4\">\n              <span className=\"text-sm text-text-tertiary\">Failed to generate preview</span>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/CustomIntegrationValidator.tsx",
    "content": "import type { FetchTemplate } from \"@follow/shared/settings/interface\"\nimport { cn } from \"@follow/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { CustomIntegrationManager } from \"./custom-integration-manager\"\n\ninterface CustomIntegrationValidatorProps {\n  fetchTemplate: FetchTemplate\n  className?: string\n}\n\nexport const CustomIntegrationValidator = ({\n  fetchTemplate,\n  className,\n}: CustomIntegrationValidatorProps) => {\n  const { t } = useTranslation(\"settings\")\n\n  const validation = CustomIntegrationManager.validateFetchTemplate(fetchTemplate)\n\n  if (validation.valid) {\n    return (\n      <div className={cn(\"flex items-center gap-2 text-sm\", className)}>\n        <i className=\"i-mgc-check-circle-cute-re text-green\" />\n        <span className=\"text-green\">\n          {t(\"integration.custom_integrations.validation.valid\", \"Template is valid\")}\n        </span>\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <i className=\"i-mgc-close-circle-cute-re text-red\" />\n        <span className=\"text-red\">\n          {t(\"integration.custom_integrations.validation.invalid\", \"Template has errors\")}\n        </span>\n      </div>\n      <ul className=\"ml-6 space-y-1 text-sm text-red\">\n        {validation.errors.map((error, index) => (\n          <li key={index} className=\"list-disc\">\n            {error}\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/PlaceholderHelp.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { cn } from \"@follow/utils\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { CustomIntegrationManager } from \"./custom-integration-manager\"\n\ninterface PlaceholderHelpProps {\n  className?: string\n  onPlaceholderClick?: (placeholder: string) => void\n}\n\nexport const PlaceholderHelp = ({ className, onPlaceholderClick }: PlaceholderHelpProps) => {\n  const { t } = useTranslation(\"settings\")\n  const [isOpen, setIsOpen] = useState(false)\n\n  const placeholders = CustomIntegrationManager.getAvailablePlaceholders()\n\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"p-0 text-text-tertiary transition-colors hover:text-text\"\n      >\n        <span className=\"flex items-center gap-1 text-xs\">\n          <i className=\"i-mgc-question-cute-re\" />\n          {t(\"integration.custom_integrations.placeholders.help\", \"Available Placeholders\")}\n          <i className={cn(\"i-mgc-right-cute-re transition-transform\", isOpen && \"rotate-90\")} />\n        </span>\n      </button>\n\n      {isOpen && (\n        <div className=\"space-y-2\">\n          <div className=\"rounded-lg bg-fill-secondary p-3\">\n            <p className=\"mb-3 text-xs text-text-tertiary\">\n              {t(\n                \"integration.custom_integrations.placeholders.description\",\n                \"Click on any placeholder to copy it to your clipboard\",\n              )}\n            </p>\n\n            <div className=\"grid grid-cols-1 gap-2\">\n              {placeholders.map((placeholder) => (\n                <Tooltip key={placeholder.key}>\n                  <TooltipTrigger asChild>\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        if (onPlaceholderClick) {\n                          onPlaceholderClick(placeholder.key)\n                        } else {\n                          navigator.clipboard?.writeText(placeholder.key)\n                        }\n                      }}\n                      className=\"group flex items-start gap-2 rounded bg-fill p-2 text-left transition-colors hover:bg-fill-secondary\"\n                    >\n                      <code className=\"rounded bg-blue/10 px-1.5 py-0.5 font-mono text-xs text-blue transition-colors group-hover:bg-blue/20\">\n                        {placeholder.key}\n                      </code>\n                      <div className=\"min-w-0 flex-1\">\n                        <div className=\"text-xs font-medium text-text\">\n                          {placeholder.description}\n                        </div>\n                        {placeholder.example && (\n                          <div className=\"mt-1 text-xs text-text-tertiary\">\n                            Example: {placeholder.example}\n                          </div>\n                        )}\n                      </div>\n                      <i className=\"i-mgc-copy-cute-re text-text-tertiary opacity-0 transition-opacity group-hover:text-text group-hover:opacity-100\" />\n                    </button>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p>\n                      {t(\n                        \"integration.custom_integrations.placeholders.click_to_copy\",\n                        \"Click to copy\",\n                      )}\n                    </p>\n                  </TooltipContent>\n                </Tooltip>\n              ))}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/URLSchemePreview.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { URLSchemeTemplate } from \"@follow/shared/settings/interface\"\nimport { cn } from \"@follow/utils\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { CustomIntegrationManager } from \"./custom-integration-manager\"\n\ninterface URLSchemePreviewProps {\n  urlSchemeTemplate: URLSchemeTemplate\n  className?: string\n}\n\nexport const URLSchemePreview = ({ urlSchemeTemplate, className }: URLSchemePreviewProps) => {\n  const { t } = useTranslation(\"settings\")\n  const [preview, setPreview] = useState<string | null>(null)\n  const [isOpen, setIsOpen] = useState(false)\n  const [isLoading, setIsLoading] = useState(false)\n\n  const generatePreview = async (template: URLSchemeTemplate) => {\n    setIsLoading(true)\n    try {\n      const previewScheme = CustomIntegrationManager.getURLSchemePreview(template)\n      setPreview(previewScheme)\n    } catch (error) {\n      console.error(\"Failed to generate URL scheme preview:\", error)\n      setPreview(null)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"sm\"\n        buttonClassName=\"w-full py-2\"\n        textClassName=\"flex w-full justify-between\"\n        onClick={() => {\n          setIsOpen(!isOpen)\n          if (!isOpen) {\n            generatePreview(urlSchemeTemplate)\n          }\n        }}\n      >\n        <span className=\"flex items-center gap-2\">\n          <i className=\"i-mingcute-eye-line\" />\n          {t(\"integration.custom_integrations.preview.title\", \"Preview URL Scheme\")}\n        </span>\n        <i className={cn(\"i-mgc-right-cute-re transition-transform\", isOpen && \"rotate-90\")} />\n      </Button>\n\n      {isOpen && (\n        <div className=\"space-y-3\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center rounded-lg bg-material-medium p-4\">\n              <div className=\"flex items-center gap-2\">\n                <i className=\"i-mgc-loading-3-cute-re animate-spin\" />\n                <span className=\"text-sm text-text-tertiary\">Generating preview...</span>\n              </div>\n            </div>\n          ) : preview ? (\n            <div className=\"space-y-3 rounded-lg bg-material-medium p-4\">\n              {/* URL Scheme Preview */}\n              <div>\n                <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">\n                  Generated URL Scheme\n                </h4>\n                <div className=\"flex items-center gap-2 rounded bg-material-medium p-2 font-mono text-sm\">\n                  <span className=\"rounded bg-green/10 px-2 py-1 text-xs font-bold text-green\">\n                    SCHEME\n                  </span>\n                  <span className=\"break-all text-text-secondary\">{preview}</span>\n                </div>\n              </div>\n\n              {/* Protocol Info */}\n              <div>\n                <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">\n                  Protocol Information\n                </h4>\n                <div className=\"space-y-1 rounded bg-material-medium p-2\">\n                  <div className=\"flex font-mono text-sm\">\n                    <span className=\"min-w-0 flex-shrink-0 pr-2 text-text-secondary\">\n                      Protocol:\n                    </span>\n                    <span className=\"min-w-0 flex-1 break-all text-text-tertiary\">\n                      {preview.split(\"://\")[0]}://\n                    </span>\n                  </div>\n                  <div className=\"flex font-mono text-sm\">\n                    <span className=\"min-w-0 flex-shrink-0 pr-2 text-text-secondary\">Action:</span>\n                    <span className=\"min-w-0 flex-1 break-all text-text-tertiary\">\n                      {preview.includes(\"?\") ? \"Open with parameters\" : \"Direct open\"}\n                    </span>\n                  </div>\n                </div>\n              </div>\n\n              {/* Placeholders Info */}\n              <div className=\"border-t border-border pt-3\">\n                <h4 className=\"mb-2 text-sm font-medium text-text-secondary\">\n                  Available Placeholders for URL Schemes\n                </h4>\n                <div className=\"grid grid-cols-2 gap-2 text-xs\">\n                  {CustomIntegrationManager.getAvailablePlaceholders().map((placeholder) => (\n                    <div key={placeholder.key} className=\"rounded bg-material-opaque p-2\">\n                      <code className=\"font-bold text-text\">{placeholder.key}</code>\n                      <div className=\"mt-1 text-text-tertiary\">{placeholder.description}</div>\n                    </div>\n                  ))}\n                </div>\n              </div>\n\n              {/* Usage Note */}\n              <div className=\"rounded border border-blue/20 bg-blue/10 p-3\">\n                <div className=\"flex items-start gap-2\">\n                  <i className=\"i-mgc-information-cute-re mt-0.5 flex-shrink-0 text-blue\" />\n                  <div className=\"text-sm text-blue\">\n                    <div className=\"mb-1 font-medium\">URL Scheme Behavior</div>\n                    <div className=\"text-blue/80\">\n                      URL schemes will attempt to open the target application with the provided\n                      data. Make sure the application is installed and registered for the URL scheme\n                      on your system.\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex items-center justify-center rounded-lg bg-material-medium p-4\">\n              <span className=\"text-sm text-text-tertiary\">Failed to generate preview</span>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts",
    "content": "import type {\n  CustomIntegration,\n  FetchTemplate,\n  URLSchemeTemplate,\n} from \"@follow/shared/settings/interface\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { getSummary } from \"@follow/store/summary/getters\"\nimport { tracker } from \"@follow/tracker\"\nimport { toast } from \"sonner\"\n\nimport { getActionLanguage } from \"~/atoms/settings/general\"\nimport { getIntegrationSettings } from \"~/atoms/settings/integration\"\nimport { parseHtml } from \"~/lib/parse-html\"\n\nimport { getFetchAdapter } from \"./fetch-adapter\"\nimport { URLSchemeHandler } from \"./url-scheme-handler\"\n\n/**\n * Placeholder values that can be used in custom integration templates\n */\nexport interface PlaceholderContext {\n  title: string\n  url: string\n  contentHtml: string\n  contentMarkdown: string\n  summary: string\n  author: string\n  publishedAt: string\n  description: string\n  [key: string]: string\n}\n\n/**\n * Manager class for handling custom integrations\n */\nexport class CustomIntegrationManager {\n  /**\n   * Get entry content as markdown\n   */\n  private static async getEntryContentAsMarkdown(entry: EntryModel): Promise<string> {\n    const content = entry.content || \"\"\n    if (!content) return \"\"\n\n    try {\n      const [toMarkdown, toMdast, gfmTableToMarkdown] = await Promise.all([\n        import(\"mdast-util-to-markdown\").then((m) => m.toMarkdown),\n        import(\"hast-util-to-mdast\").then((m) => m.toMdast),\n        import(\"mdast-util-gfm-table\").then((m) => m.gfmTableToMarkdown),\n      ])\n      return toMarkdown(toMdast(parseHtml(content).hastTree), {\n        extensions: [gfmTableToMarkdown()],\n      })\n    } catch {\n      return content\n    }\n  }\n\n  /**\n   * Get description with optional summary\n   */\n  private static getDescription(entry: EntryModel): string {\n    const { saveSummaryAsDescription } = getIntegrationSettings()\n    const actionLanguage = getActionLanguage()\n\n    if (!saveSummaryAsDescription) {\n      return entry.description || \"\"\n    }\n    const summary = getSummary(entry.id, actionLanguage)\n    return summary?.readabilitySummary || summary?.summary || entry.description || \"\"\n  }\n\n  /**\n   * Build placeholder context from entry\n   */\n  static async buildPlaceholderContext(entry: EntryModel): Promise<PlaceholderContext> {\n    const markdownContent = await this.getEntryContentAsMarkdown(entry)\n\n    return {\n      title: entry.title || \"\",\n      url: entry.url || \"\",\n      contentHtml: entry.content || \"\",\n      contentMarkdown: markdownContent,\n      summary: this.getDescription(entry),\n      author: entry.author || \"\",\n      publishedAt: entry.publishedAt?.toISOString() || \"\",\n      description: entry.description || \"\",\n    }\n  }\n\n  /**\n   * Get all available placeholders with their descriptions\n   */\n  static getAvailablePlaceholders(): Array<{ key: string; description: string; example?: string }> {\n    return [\n      { key: \"[title]\", description: \"Entry title\", example: \"Example Article Title\" },\n      { key: \"[url]\", description: \"Entry URL\", example: \"https://example.com/article\" },\n      { key: \"[content_html]\", description: \"Entry content in HTML format\" },\n      { key: \"[content_markdown]\", description: \"Entry content in Markdown format\" },\n      { key: \"[summary]\", description: \"Entry summary or description\" },\n      { key: \"[author]\", description: \"Entry author\", example: \"John Doe\" },\n      {\n        key: \"[published_at]\",\n        description: \"Publication date in ISO format\",\n        example: \"2024-01-01T12:00:00.000Z\",\n      },\n      { key: \"[description]\", description: \"Entry description\" },\n    ]\n  }\n\n  /**\n   * Replace placeholders in a string with actual values\n   */\n  static replacePlaceholders(\n    template: string,\n    context: PlaceholderContext,\n    options: {\n      urlEncode?: boolean\n      htmlEscape?: boolean\n      jsonEscape?: boolean\n    } = {},\n  ): string {\n    let result = template\n\n    // Define placeholder mapping\n    const placeholders: Record<string, string> = {\n      \"[title]\": context.title,\n      \"[url]\": context.url,\n      \"[content_html]\": context.contentHtml,\n      \"[content_markdown]\": context.contentMarkdown,\n      \"[summary]\": context.summary,\n      \"[author]\": context.author,\n      \"[published_at]\": context.publishedAt,\n      \"[description]\": context.description,\n    }\n\n    // Replace each placeholder\n    Object.entries(placeholders).forEach(([placeholder, value]) => {\n      let processedValue = value\n\n      if (options.urlEncode) {\n        processedValue = encodeURIComponent(processedValue)\n      }\n\n      if (options.htmlEscape) {\n        processedValue = processedValue\n          .replaceAll(\"&\", \"&amp;\")\n          .replaceAll(\"<\", \"&lt;\")\n          .replaceAll(\">\", \"&gt;\")\n          .replaceAll('\"', \"&quot;\")\n          .replaceAll(\"'\", \"&#x27;\")\n      }\n\n      if (options.jsonEscape) {\n        // Learn more https://stackoverflow.com/questions/4253367/how-to-escape-a-json-string-containing-newline-characters-using-javascript\n        processedValue = JSON.stringify(processedValue).slice(1, -1)\n      }\n\n      result = result.replaceAll(placeholder, processedValue)\n    })\n\n    return result\n  }\n\n  /**\n   * Process a fetch template with placeholder replacement\n   */\n  static async processFetchTemplate(\n    fetchTemplate: FetchTemplate,\n    context: PlaceholderContext,\n  ): Promise<{\n    url: string\n    headers: Record<string, string>\n    body?: string\n    method: string\n  }> {\n    // Process URL with URL encoding for placeholder values\n    const processedUrl = this.replacePlaceholders(fetchTemplate.url, context, { urlEncode: true })\n\n    // Process headers without URL encoding\n    const processedHeaders: Record<string, string> = {}\n    Object.entries(fetchTemplate.headers).forEach(([key, value]) => {\n      const processedKey = this.replacePlaceholders(key, context)\n      const processedValue = this.replacePlaceholders(value, context)\n      // Field names are case-insensitive.\n      processedHeaders[processedKey.toLowerCase().trim()] = processedValue.trim()\n    })\n\n    // Process body without URL encoding\n    let processedBody: string | undefined\n    if (fetchTemplate.body) {\n      const jsonEscape = processedHeaders[\"content-type\"]?.toLowerCase() === \"application/json\"\n      processedBody = this.replacePlaceholders(fetchTemplate.body, context, { jsonEscape })\n    }\n\n    return {\n      url: processedUrl,\n      headers: processedHeaders,\n      body: processedBody,\n      method: fetchTemplate.method,\n    }\n  }\n\n  /**\n   * Execute a custom integration\n   */\n  static async executeIntegration(\n    integration: CustomIntegration,\n    entry: EntryModel,\n  ): Promise<{ success: boolean; error?: string }> {\n    if (!integration.enabled) {\n      return { success: false, error: `${integration.name} is disabled` }\n    }\n\n    try {\n      // Track integration usage\n      tracker.integration({\n        type: \"custom\",\n        event: \"save\",\n      })\n\n      // Build placeholder context\n      const context = await this.buildPlaceholderContext(entry)\n\n      if (integration.type === \"url-scheme\" && integration.urlSchemeTemplate) {\n        // Execute URL scheme\n        await URLSchemeHandler.getInstance().executeURLScheme(integration.urlSchemeTemplate, {\n          title: context.title,\n          url: context.url,\n          content_html: context.contentHtml,\n          content_markdown: context.contentMarkdown,\n          summary: context.summary,\n          author: context.author,\n          published_at: context.publishedAt,\n          description: context.description,\n        })\n        return { success: true }\n      } else if ((integration.type === \"http\" || !integration.type) && integration.fetchTemplate) {\n        // Execute HTTP request (existing logic)\n        const { url, headers, body, method } = await this.processFetchTemplate(\n          integration.fetchTemplate,\n          context,\n        )\n\n        // Prepare request options for fetch adapter\n        const finalHeaders = { ...headers }\n\n        // Set content-type if not already set and we have a body\n        if (\n          body &&\n          [\"POST\", \"PUT\", \"PATCH\"].includes(method) &&\n          !Object.keys(headers).some((key) => key.toLowerCase() === \"content-type\")\n        ) {\n          finalHeaders[\"Content-Type\"] = \"application/json\"\n        }\n\n        // Execute the HTTP request using fetch adapter\n        const response = await getFetchAdapter().fetch(url, {\n          method: method as \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\",\n          headers: finalHeaders,\n          body: body && [\"POST\", \"PUT\", \"PATCH\"].includes(method) ? body : undefined,\n        })\n\n        // Check if request was successful\n        if (!response.ok) {\n          throw new Error(`Request failed with status ${response.status}: ${response.statusText}`)\n        }\n\n        return { success: true }\n      } else {\n        // Handle backward compatibility for integrations without type field\n        if (integration.fetchTemplate) {\n          // Treat as HTTP integration\n          const { url, headers, body, method } = await this.processFetchTemplate(\n            integration.fetchTemplate,\n            context,\n          )\n\n          const finalHeaders = { ...headers }\n          if (\n            body &&\n            [\"POST\", \"PUT\", \"PATCH\"].includes(method) &&\n            !Object.keys(headers).some((key) => key.toLowerCase() === \"content-type\")\n          ) {\n            finalHeaders[\"Content-Type\"] = \"application/json\"\n          }\n\n          const response = await getFetchAdapter().fetch(url, {\n            method: method as \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\",\n            headers: finalHeaders,\n            body: body && [\"POST\", \"PUT\", \"PATCH\"].includes(method) ? body : undefined,\n          })\n\n          if (!response.ok) {\n            throw new Error(`Request failed with status ${response.status}: ${response.statusText}`)\n          }\n\n          return { success: true }\n        }\n      }\n\n      return { success: false, error: \"Invalid integration configuration\" }\n    } catch (error) {\n      const errorMessage = (error as Error)?.message || \"Unknown error\"\n      return { success: false, error: errorMessage }\n    }\n  }\n\n  /**\n   * Execute integration with toast notifications\n   */\n  static async executeWithToast(integration: CustomIntegration, entry: EntryModel): Promise<void> {\n    const result = await this.executeIntegration(integration, entry)\n\n    if (result.success) {\n      toast.success(`Saved to ${integration.name} successfully`, {\n        duration: 3000,\n      })\n    } else {\n      toast.error(`Failed to save to ${integration.name}: ${result.error}`, {\n        duration: 3000,\n      })\n    }\n  }\n\n  /**\n   * Validate a fetch template\n   */\n  static validateFetchTemplate(fetchTemplate: FetchTemplate): { valid: boolean; errors: string[] } {\n    const errors: string[] = []\n\n    if (!fetchTemplate.url?.trim()) {\n      errors.push(\"URL is required\")\n    }\n\n    if (!fetchTemplate.method) {\n      errors.push(\"HTTP method is required\")\n    }\n\n    if (\n      fetchTemplate.method &&\n      ![\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"].includes(fetchTemplate.method)\n    ) {\n      errors.push(\"Invalid HTTP method\")\n    }\n\n    try {\n      if (fetchTemplate.url) {\n        new URL(fetchTemplate.url.replaceAll(/\\[.*?\\]/g, \"https://example.com\"))\n      }\n    } catch {\n      errors.push(\"Invalid URL format\")\n    }\n\n    return {\n      valid: errors.length === 0,\n      errors,\n    }\n  }\n\n  /**\n   * Validate a URL scheme template\n   */\n  static validateURLSchemeTemplate(template: URLSchemeTemplate): {\n    valid: boolean\n    errors: string[]\n  } {\n    const errors: string[] = []\n\n    if (!template.scheme?.trim()) {\n      errors.push(\"URL scheme is required\")\n    }\n\n    if (template.scheme && !template.scheme.includes(\"://\")) {\n      errors.push(\"URL scheme must include protocol (e.g., 'app://')\")\n    }\n\n    try {\n      if (template.scheme) {\n        // Replace placeholders with sample values for validation\n        const testScheme = template.scheme.replaceAll(/\\[.*?\\]/g, \"test\")\n        new URL(testScheme)\n      }\n    } catch {\n      errors.push(\"Invalid URL scheme format\")\n    }\n\n    return {\n      valid: errors.length === 0,\n      errors,\n    }\n  }\n\n  /**\n   * Validate a custom integration\n   */\n  static validateCustomIntegration(integration: Partial<CustomIntegration>): {\n    valid: boolean\n    errors: string[]\n  } {\n    const errors: string[] = []\n\n    if (!integration.name?.trim()) {\n      errors.push(\"Integration name is required\")\n    }\n\n    // Type is optional for backward compatibility, default to \"http\"\n    const integrationType = integration.type || \"http\"\n\n    if (integrationType === \"http\") {\n      if (!integration.fetchTemplate) {\n        errors.push(\"HTTP template is required for HTTP integrations\")\n      } else {\n        const templateValidation = this.validateFetchTemplate(integration.fetchTemplate)\n        errors.push(...templateValidation.errors)\n      }\n    } else if (integrationType === \"url-scheme\") {\n      if (!integration.urlSchemeTemplate) {\n        errors.push(\"URL scheme template is required for URL scheme integrations\")\n      } else {\n        const templateValidation = this.validateURLSchemeTemplate(integration.urlSchemeTemplate)\n        errors.push(...templateValidation.errors)\n      }\n    } else {\n      errors.push(\"Invalid integration type. Must be 'http' or 'url-scheme'\")\n    }\n\n    return {\n      valid: errors.length === 0,\n      errors,\n    }\n  }\n\n  /**\n   * Get template preview with sample data\n   */\n  static async getTemplatePreview(\n    fetchTemplate: FetchTemplate,\n    sampleEntry?: Partial<EntryModel>,\n  ): Promise<{\n    url: string\n    headers: Record<string, string>\n    body?: string\n    method: string\n  }> {\n    // Create sample context\n    const sampleContext: PlaceholderContext = {\n      title: sampleEntry?.title || \"Sample Article Title\",\n      url: sampleEntry?.url || \"https://example.com/article\",\n      contentHtml: sampleEntry?.content || \"<p>This is sample HTML content</p>\",\n      contentMarkdown: \"# Sample Article\\n\\nThis is sample markdown content.\",\n      summary: \"This is a sample summary of the article content.\",\n      author: sampleEntry?.author || \"John Doe\",\n      publishedAt: sampleEntry?.publishedAt?.toISOString() || new Date().toISOString(),\n      description: sampleEntry?.description || \"Sample article description\",\n    }\n\n    return this.processFetchTemplate(fetchTemplate, sampleContext)\n  }\n\n  /**\n   * Get URL scheme preview with sample data\n   */\n  static getURLSchemePreview(\n    template: URLSchemeTemplate,\n    sampleEntry?: Partial<EntryModel>,\n  ): string {\n    // Create sample context\n    const sampleData = {\n      title: sampleEntry?.title || \"Sample Article Title\",\n      url: sampleEntry?.url || \"https://example.com/article\",\n      content_html: sampleEntry?.content || \"<p>This is sample HTML content</p>\",\n      content_markdown: \"# Sample Article\\n\\nThis is sample markdown content.\",\n      summary: \"This is a sample summary of the article content.\",\n      author: sampleEntry?.author || \"John Doe\",\n      published_at: sampleEntry?.publishedAt?.toISOString() || new Date().toISOString(),\n      description: sampleEntry?.description || \"Sample article description\",\n    }\n\n    let result = template.scheme\n\n    // Replace all placeholders with sample data\n    Object.entries(sampleData).forEach(([key, value]) => {\n      const placeholder = `[${key}]`\n      const encodedValue = encodeURIComponent(value || \"\")\n      result = result.replaceAll(placeholder, encodedValue)\n    })\n\n    return result\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/fetch-adapter.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport type { FetchError } from \"ofetch\"\nimport { ofetch } from \"ofetch\"\n\nimport { getIntegrationSettings } from \"~/atoms/settings/integration\"\nimport { ipcServices } from \"~/lib/client\"\n\n/**\n * HTTP request options for fetch adapters\n */\nexport interface FetchRequestOptions {\n  method: \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\"\n  headers?: Record<string, string>\n  body?: string\n  timeout?: number\n}\n\n/**\n * HTTP response from fetch adapters\n */\nexport interface FetchResponse {\n  ok: boolean\n  status: number\n  statusText: string\n  headers: Record<string, string>\n  data?: any\n  text?: string\n}\n\n/**\n * Abstract base class for HTTP fetch adapters\n */\nexport abstract class BaseFetchAdapter {\n  abstract fetch(url: string, options?: FetchRequestOptions): Promise<FetchResponse>\n}\n\n/**\n * Browser fetch adapter using native fetch or ofetch\n */\nexport class BrowserFetchAdapter extends BaseFetchAdapter {\n  async fetch(url: string, options?: FetchRequestOptions): Promise<FetchResponse> {\n    const finalOptions = options || { method: \"GET\" }\n    try {\n      const requestOptions: Parameters<typeof ofetch>[1] = {\n        method: finalOptions.method,\n        headers: finalOptions.headers || {},\n        timeout: finalOptions.timeout || 30000,\n      }\n\n      // Add body for methods that support it\n      if (finalOptions.body && [\"POST\", \"PUT\", \"PATCH\"].includes(finalOptions.method)) {\n        requestOptions.body = finalOptions.body\n      }\n\n      const response = await ofetch.raw(url, requestOptions)\n\n      // Convert Headers object to plain object\n      const headers: Record<string, string> = {}\n      response.headers.forEach((value, key) => {\n        headers[key] = value\n      })\n\n      return {\n        ok: response.ok,\n        status: response.status,\n        statusText: response.statusText,\n        headers,\n        data: response._data,\n        text: typeof response._data === \"string\" ? response._data : JSON.stringify(response._data),\n      }\n    } catch (error) {\n      const fetchError = error as FetchError\n      throw new Error(`Browser fetch failed: ${fetchError.message || \"Unknown error\"}`)\n    }\n  }\n}\n\n/**\n * Electron fetch adapter using IPC services\n */\nexport class ElectronFetchAdapter extends BaseFetchAdapter {\n  async fetch(url: string, options?: FetchRequestOptions): Promise<FetchResponse> {\n    const finalOptions = options || { method: \"GET\" }\n    try {\n      // Check if IPC services are available\n      if (!ipcServices?.integration?.customFetch) {\n        throw new Error(\"Electron IPC services not available\")\n      }\n\n      const response = await ipcServices.integration.customFetch({\n        url,\n        method: finalOptions.method,\n        headers: finalOptions.headers || {},\n        body: finalOptions.body,\n        timeout: finalOptions.timeout || 30000,\n      })\n\n      return {\n        ok: response.ok,\n        status: response.status,\n        statusText: response.statusText || \"OK\",\n        headers: response.headers || {},\n        data: response.data,\n        text: response.text,\n      }\n    } catch (error) {\n      throw new Error(`Electron fetch failed: ${(error as Error).message || \"Unknown error\"}`)\n    }\n  }\n}\n\n/**\n * Fetch adapter factory and configuration\n */\nexport class FetchAdapterManager {\n  private static instance: FetchAdapterManager\n  private adapter: BaseFetchAdapter\n  private preferElectron: boolean\n\n  private constructor() {\n    // Initialize preference based on settings\n    // Default to browser fetch if useBrowserFetch is true, electron otherwise\n    if (IN_ELECTRON) {\n      const settings = getIntegrationSettings()\n      this.preferElectron = !settings.useBrowserFetch\n    } else {\n      this.preferElectron = false // Always use browser fetch in non-Electron environment\n    }\n\n    this.adapter = this.createAdapter()\n  }\n\n  static getInstance(): FetchAdapterManager {\n    if (!FetchAdapterManager.instance) {\n      FetchAdapterManager.instance = new FetchAdapterManager()\n    }\n    return FetchAdapterManager.instance\n  }\n\n  /**\n   * @description Electron only\n   */\n  preferElectronFetch() {\n    this.preferElectron = true\n    this.adapter = this.createAdapter()\n  }\n  /**\n   * @description Electron only\n   */\n  preferClientFetch() {\n    this.preferElectron = false\n    this.adapter = this.createAdapter()\n  }\n\n  /**\n   * Create appropriate adapter based on environment and preferences\n   */\n  private createAdapter(): BaseFetchAdapter {\n    // If in Electron environment and Electron is preferred and available\n    if (IN_ELECTRON && this.preferElectron && ipcServices?.integration?.customFetch) {\n      return new ElectronFetchAdapter()\n    }\n\n    // Fallback to browser adapter\n    return new BrowserFetchAdapter()\n  }\n\n  /**\n   * Execute HTTP request using the current adapter\n   */\n  async fetch(url: string, options?: FetchRequestOptions): Promise<FetchResponse> {\n    return this.adapter.fetch(url, options)\n  }\n}\n\n/**\n * Convenience function to get the fetch adapter manager instance\n */\nexport const getFetchAdapter = () => FetchAdapterManager.getInstance()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/integration/url-scheme-handler.ts",
    "content": "import type { URLSchemeTemplate } from \"@follow/shared/settings/interface\"\nimport { toast } from \"sonner\"\n\nimport { ipcServices } from \"~/lib/client\"\n\nexport class URLSchemeHandler {\n  private static instance: URLSchemeHandler\n\n  static getInstance(): URLSchemeHandler {\n    if (!URLSchemeHandler.instance) {\n      URLSchemeHandler.instance = new URLSchemeHandler()\n    }\n    return URLSchemeHandler.instance\n  }\n\n  /**\n   * Replace placeholders in URL scheme with actual values\n   */\n  private replacePlaceholders(template: string, data: Record<string, string>): string {\n    let result = template\n\n    // Replace all placeholders like [title], [url], etc.\n    Object.entries(data).forEach(([key, value]) => {\n      const placeholder = `[${key}]`\n      const encodedValue = encodeURIComponent(value || \"\")\n      result = result.replaceAll(placeholder, encodedValue)\n    })\n\n    return result\n  }\n\n  /**\n   * Execute URL scheme with data placeholders\n   */\n  async executeURLScheme(\n    template: URLSchemeTemplate,\n    data: {\n      title?: string\n      url?: string\n      content_html?: string\n      content_markdown?: string\n      summary?: string\n      author?: string\n      published_at?: string\n      description?: string\n    },\n  ): Promise<void> {\n    try {\n      const finalScheme = this.replacePlaceholders(template.scheme, data)\n\n      // Validate URL scheme format\n      if (!finalScheme.includes(\"://\")) {\n        throw new Error(\"Invalid URL scheme format. Must include protocol (e.g., 'app://')\")\n      }\n\n      await this.openURLScheme(finalScheme)\n\n      // Since URL schemes don't return responses, we assume success\n      toast.success(\"URL scheme executed successfully\")\n    } catch (error) {\n      console.error(\"URL scheme execution failed:\", error)\n      toast.error(`URL scheme failed: ${error instanceof Error ? error.message : String(error)}`)\n    }\n  }\n\n  /**\n   * Platform-specific URL scheme opening\n   */\n  private async openURLScheme(scheme: string): Promise<void> {\n    if (window.electron && ipcServices) {\n      // Electron environment - use IPC service\n      await ipcServices.integration.openURLScheme(scheme)\n    } else {\n      // Browser environment - use window.open\n      // Note: This may be blocked by popup blockers for non-user-initiated actions\n      const opened = window.open(scheme, \"_blank\")\n      if (!opened) {\n        throw new Error(\"Failed to open URL scheme. This may be blocked by popup blockers.\")\n      }\n    }\n  }\n\n  /**\n   * Check if URL scheme is supported on current platform\n   */\n  canExecuteURLScheme(): boolean {\n    // URL schemes work in both Electron and browser contexts\n    // Browser support depends on registered protocol handlers\n    return true\n  }\n\n  /**\n   * Get common URL scheme examples for different app types\n   */\n  static getExamples(): { name: string; scheme: string; description: string }[] {\n    return [\n      {\n        name: \"Obsidian\",\n        scheme: \"obsidian://new?vault=MyVault&name=[title]&content=[content_markdown]\",\n        description: \"Create new note in Obsidian vault\",\n      },\n      {\n        name: \"Bear\",\n        scheme: \"bear://x-callback-url/create?title=[title]&text=[content_markdown]&tags=follow\",\n        description: \"Create new note in Bear with tags\",\n      },\n      {\n        name: \"Drafts\",\n        scheme: \"drafts://x-callback-url/create?text=[title]%0A%0A[content_markdown]\",\n        description: \"Create new draft with title and content\",\n      },\n      {\n        name: \"Things 3\",\n        scheme: \"things:///add?title=[title]&notes=[summary]&list=Reading\",\n        description: \"Add item to Things 3 reading list\",\n      },\n      {\n        name: \"Notion\",\n        scheme: \"notion://new?title=[title]&content=[content_markdown]\",\n        description: \"Create new Notion page\",\n      },\n      {\n        name: \"DEVONthink\",\n        scheme: \"x-devonthink://createText?title=[title]&text=[content_markdown]&destination=Inbox\",\n        description: \"Create new text document in DEVONthink\",\n      },\n    ]\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/modal/ConfirmDestroyModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nexport const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => void }) => {\n  const { t } = useTranslation()\n\n  return (\n    <div className=\"w-[540px]\">\n      <div className=\"mb-4 text-sm\">{t(\"sidebar.feed_actions.unfollow_feed_many_warning\")}</div>\n      <div className=\"flex justify-end\">\n        <Button data-testid=\"confirm-destroy\" buttonClassName=\"bg-red\" onClick={onConfirm}>\n          {t(\"words.confirm\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/modal/ShortcutModalContent.tsx",
    "content": "import { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { clsx } from \"@follow/utils/utils\"\nimport { m, useDragControls } from \"motion/react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { ShortcutsGuideline } from \"../command/shortcuts/SettingShortcuts\"\n\nexport const ShortcutModalContent = () => {\n  const { dismiss } = useCurrentModal()\n  const modalOverlay = useUISettingKey(\"modalOverlay\")\n  const dragControls = useDragControls()\n\n  const { t } = useTranslation(\"shortcuts\")\n  return (\n    <m.div\n      drag\n      dragListener={false}\n      dragControls={dragControls}\n      dragMomentum={false}\n      dragElastic={0}\n      exit={{\n        scale: 0.96,\n        opacity: 0,\n      }}\n      whileDrag={{\n        cursor: \"grabbing\",\n      }}\n      className={clsx(\n        \"center absolute inset-0 m-auto flex max-h-[80vh] w-[60ch] max-w-[90vw] flex-col rounded-xl border bg-background\",\n\n        !modalOverlay && \"shadow-modal\",\n      )}\n    >\n      <h2\n        onPointerDownCapture={dragControls.start.bind(dragControls)}\n        className=\"center w-full border-b p-3 font-medium\"\n      >\n        {t(\"shortcuts.guide.title\", { ns: \"app\" })}\n      </h2>\n      <MotionButtonBase onClick={dismiss} className=\"absolute right-3 top-2 p-2\">\n        <i className=\"i-mgc-close-cute-re\" />\n      </MotionButtonBase>\n      <ScrollArea.ScrollArea scrollbarClassName=\"w-2\" rootClassName=\"w-full h-full\">\n        <div className=\"w-full space-y-6 px-4 pb-5\">\n          <ShortcutsGuideline />\n        </div>\n      </ScrollArea.ScrollArea>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/modal/hooks/useConfirmUnsubscribeSubscriptionModal.tsx",
    "content": "import { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useDeleteSubscription } from \"~/hooks/biz/useSubscriptionActions\"\n\nimport { ConfirmDestroyModalContent } from \"../ConfirmDestroyModalContent\"\n\nexport const useConfirmUnsubscribeSubscriptionModal = () => {\n  const { present } = useModalStack()\n  const deleteSubscription = useDeleteSubscription({})\n  const { t } = useTranslation()\n  return useCallback(\n    (feedIds: string[], callback?: () => void) => {\n      present({\n        title: t(\"sidebar.feed_actions.unfollow_feed_many_confirm\"),\n        icon: <i className=\"i-mingcute-warning-fill text-red\" />,\n        content: ({ dismiss }) => (\n          <ConfirmDestroyModalContent\n            onConfirm={() => {\n              deleteSubscription.mutate({ feedIdList: feedIds })\n              callback?.()\n              dismiss()\n            }}\n          />\n        ),\n      })\n    },\n    [deleteSubscription, present, t],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/modal/hooks/useShortcutsModal.tsx",
    "content": "import { useCallback } from \"react\"\n\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { ShortcutModalContent } from \"../ShortcutModalContent\"\n\nexport const useShortcutsModal = () => {\n  const { present, dismiss, getModalStackById } = useModalStack()\n  const id = \"shortcuts\"\n\n  const showShortcutsModal = useCallback(() => {\n    present({\n      title: \"Shortcuts\",\n      id,\n      overlay: false,\n      content: () => <ShortcutModalContent />,\n      CustomModalComponent: PlainModal,\n      clickOutsideToDismiss: true,\n    })\n  }, [present])\n\n  return useCallback(() => {\n    const shortcutsModal = getModalStackById(id)\n    if (shortcutsModal && shortcutsModal.modal) {\n      dismiss(id)\n      return\n    }\n    showShortcutsModal()\n  }, [dismiss, getModalStackById, showShortcutsModal])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport type { LexicalRichEditorRef } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport {\n  convertLexicalToMarkdown,\n  getEditorStateJSONString,\n} from \"@follow/components/ui/lexical-rich-editor/utils.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { nextFrame } from \"@follow/utils\"\nimport { cn } from \"@follow/utils/utils\"\nimport { AnimatePresence } from \"framer-motion\"\nimport { useSetAtom } from \"jotai\"\nimport type { EditorState } from \"lexical\"\nimport { $getRoot, $getSelection, $isRangeSelection, createEditor } from \"lexical\"\nimport { nanoid } from \"nanoid\"\nimport type { RefObject } from \"react\"\nimport { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useI18n } from \"~/hooks/common\"\nimport { ChatInput } from \"~/modules/ai-chat/components/layouts/ChatInput\"\nimport { useAttachScrollBeyond } from \"~/modules/ai-chat/hooks/useAttachScrollBeyond\"\nimport { useAutoScroll } from \"~/modules/ai-chat/hooks/useAutoScroll\"\nimport {\n  useBlockActions,\n  useChatActions,\n  useChatError,\n  useChatStatus,\n  useCurrentChatId,\n  useHasMessages,\n  useMessages,\n} from \"~/modules/ai-chat/store/hooks\"\nimport type { AIChatContextBlock, BizUIMessage } from \"~/modules/ai-chat/store/types\"\n\nimport { Messages } from \"../ai-chat/components/layouts/Messages\"\nimport { RateLimitNotice } from \"../ai-chat/components/layouts/RateLimitNotice\"\nimport { AIChatWaitingIndicator } from \"../ai-chat/components/message/AIChatMessage\"\nimport { AIShortcutButton } from \"../ai-chat/components/ui/AIShortcutButton\"\nimport { LexicalAIEditorNodes } from \"../ai-chat/editor\"\nimport { computeRateLimitMessage } from \"../ai-chat/utils/rate-limit\"\nimport { stepAtom } from \"./store\"\n\nconst SUGGESTION_KEYS = [\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\",\n] as I18nKeys[]\n\nconst SUGGESTION_SAMPLE_SIZE = 5\n\ntype SuggestionKey = (typeof SUGGESTION_KEYS)[number]\n\nfunction pickSuggestionKeys(previous?: readonly SuggestionKey[]): SuggestionKey[] {\n  const shuffle = (input: readonly SuggestionKey[]) => {\n    const pool = [...input] as SuggestionKey[]\n    for (let i = pool.length - 1; i > 0; i--) {\n      const j = Math.floor(Math.random() * (i + 1))\n      ;[pool[i], pool[j]] = [pool[j]!, pool[i]!]\n    }\n    return pool\n  }\n\n  if (!previous || previous.length === 0) {\n    return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)\n  }\n\n  const previousSet = new Set(previous)\n  const available = SUGGESTION_KEYS.filter((key) => !previousSet.has(key))\n\n  if (available.length >= SUGGESTION_SAMPLE_SIZE) {\n    return shuffle(available).slice(0, SUGGESTION_SAMPLE_SIZE)\n  }\n\n  // When there aren't enough unique suggestions left, attempt to find a fully new batch.\n  const maxAttempts = 10\n  for (let attempt = 0; attempt < maxAttempts; attempt++) {\n    const candidate = shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)\n    if (!candidate.some((key) => previousSet.has(key))) {\n      return candidate\n    }\n  }\n\n  return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)\n}\n\nexport function AIChatPane() {\n  return (\n    <div className=\"flex h-full flex-col justify-between gap-8 overflow-hidden bg-background p-2 lg:col-span-6\">\n      <AIChatPaneImpl />\n    </div>\n  )\n}\n\nfunction AIChatPaneImpl() {\n  const t = useI18n()\n\n  const setStep = useSetAtom(stepAtom)\n\n  const hasMessages = useHasMessages()\n  const chatInputRef = useRef<LexicalRichEditorRef | null>(null)\n\n  const appendSuggestionToInput = (suggestion: string) => {\n    const ref = chatInputRef.current\n    const editor = ref?.getEditor()\n\n    if (!editor) {\n      return\n    }\n\n    editor.focus()\n    editor.update(() => {\n      const root = $getRoot()\n      const currentText = root.getTextContent()\n      const needsLeadingSpace = currentText.length > 0 && !currentText.endsWith(\" \")\n      const textToInsert = needsLeadingSpace ? ` ${suggestion}` : suggestion\n\n      root.selectEnd()\n      let selection = $getSelection()\n\n      if (!$isRangeSelection(selection)) {\n        root.selectEnd()\n        selection = $getSelection()\n      }\n\n      if ($isRangeSelection(selection)) {\n        selection.insertText(textToInsert)\n      }\n    })\n  }\n\n  return (\n    <div className=\"relative flex h-full flex-col\">\n      <header className=\"flex w-full items-start justify-between px-5 pb-5\">\n        <Logo className=\"size-12\" />\n\n        <Button variant=\"outline\" onClick={() => setStep(\"manual-import\")}>\n          {t.app(\"new_user_guide.actions.import_opml\")}\n        </Button>\n      </header>\n\n      <AnimatePresence mode=\"popLayout\">\n        {!hasMessages && <Welcome onSuggestionClick={appendSuggestionToInput} />}\n      </AnimatePresence>\n\n      <div className=\"flex-1 overflow-hidden\">\n        <AIChatInterface inputRef={chatInputRef} />\n      </div>\n    </div>\n  )\n}\n\ninterface WelcomeProps {\n  onSuggestionClick: (suggestion: string) => void\n}\n\nfunction Welcome({ onSuggestionClick }: WelcomeProps) {\n  const t = useI18n()\n  const isDark = useIsDark()\n  const [suggestionKeys, setSuggestionKeys] = useState<SuggestionKey[]>(() => pickSuggestionKeys())\n\n  const onClickSuggestion = useEventCallback((suggestion: string) => {\n    onSuggestionClick(suggestion)\n  })\n\n  const rerollSuggestions = useEventCallback(() => {\n    setSuggestionKeys((prev) => pickSuggestionKeys(prev))\n  })\n\n  return (\n    <div className=\"flex flex-col items-start gap-5 p-5\">\n      <div className=\"flex flex-col items-start gap-5\">\n        <div className=\"space-y-4\">\n          <p className=\"text-2xl leading-snug\">\n            {(t.app(\"new_user_guide.ai_chat.intro\") as string).split(\"\\n\").map((line) => (\n              <Fragment key={line}>\n                {line}\n                <br />\n              </Fragment>\n            ))}\n          </p>\n        </div>\n      </div>\n\n      <div className=\"space-y-3\">\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          <p className=\"text-xs font-medium uppercase text-text-secondary\">\n            {t.app(\"new_user_guide.ai_chat.you_can_say\")}\n          </p>\n          <Button variant=\"ghost\" size=\"sm\" onClick={rerollSuggestions}>\n            <i className=\"i-mgc-refresh-2-cute-re mr-2 text-sm\" aria-hidden />\n            {t.app(\"new_user_guide.ai_chat.reroll\")}\n          </Button>\n        </div>\n        <div className=\"flex flex-wrap gap-2\">\n          {suggestionKeys.map((suggestionKey, index) => {\n            const suggestionText = t.app(suggestionKey) as string\n            const gradient = gradientByIndex(index, isDark)\n            return (\n              <AIShortcutButton\n                key={suggestionKey}\n                onClick={() => onClickSuggestion(suggestionText)}\n                animationDelay={index * 0.05}\n                className=\"font-normal text-text\"\n                style={{ background: gradient }}\n              >\n                {suggestionText}\n              </AIShortcutButton>\n            )\n          })}\n        </div>\n      </div>\n\n      <FinishListener />\n    </div>\n  )\n}\n\n// if the chat response has `tool-onboardingGetTrendingFeedsTool`, set the step to pre-finish\nfunction FinishListener() {\n  const chatMessages = useMessages()\n  const setStep = useSetAtom(stepAtom)\n  useEffect(() => {\n    const hasCalledConfirmTool = chatMessages.some((msg) =>\n      msg.parts.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\"),\n    )\n    if (hasCalledConfirmTool) {\n      setStep(\"pre-finish\")\n    }\n  }, [chatMessages, setStep])\n\n  return null\n}\n\nconst SCROLL_BOTTOM_THRESHOLD = 100\n\ninterface AIChatInterfaceProps {\n  inputRef?: RefObject<LexicalRichEditorRef | null>\n}\n\nfunction AIChatInterface({ inputRef }: AIChatInterfaceProps) {\n  const hasMessages = useHasMessages()\n  const status = useChatStatus()\n  const chatActions = useChatActions()\n  const error = useChatError()\n  const t = useI18n()\n\n  useEffect(() => {\n    if (error) {\n      console.error(\"AIChat Error:\", error)\n    }\n  }, [error])\n\n  // on init, set the scene to onboarding\n  useEffect(() => {\n    chatActions.setScene(\"onboarding\")\n\n    return () => {\n      // reset the scene to general\n      chatActions.setScene(\"general\")\n    }\n  }, [chatActions])\n\n  const currentChatId = useCurrentChatId()\n\n  const [scrollAreaRef, setScrollAreaRef] = useState<HTMLDivElement | null>(null)\n  const [isAtBottom, setIsAtBottom] = useState(true)\n  const [messageContainerMinHeight, setMessageContainerMinHeight] = useState<number | undefined>()\n  const previousMinHeightRef = useRef<number>(0)\n  const messagesContentRef = useRef<HTMLDivElement | null>(null)\n\n  useEffect(() => {\n    setIsAtBottom(true)\n    setMessageContainerMinHeight(undefined)\n    previousMinHeightRef.current = 0\n  }, [currentChatId])\n\n  const { resetScrollState } = useAutoScroll(scrollAreaRef, status === \"streaming\")\n\n  const { handleScroll } = useAttachScrollBeyond()\n\n  useEffect(() => {\n    const scrollElement = scrollAreaRef\n\n    if (!scrollElement) return\n\n    const handleScroll = () => {\n      const { scrollTop, scrollHeight, clientHeight } = scrollElement\n      const distanceFromBottom = scrollHeight - scrollTop - clientHeight\n      const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD\n      setIsAtBottom(atBottom)\n    }\n\n    scrollElement.addEventListener(\"scroll\", handleScroll, { passive: true })\n\n    handleScroll()\n\n    return () => {\n      scrollElement.removeEventListener(\"scroll\", handleScroll)\n    }\n  }, [scrollAreaRef])\n\n  const blockActions = useBlockActions()\n\n  const scrollHeightBeforeSendingRef = useRef<number>(0)\n  const scrollContainerParentRef = useRef<HTMLDivElement | null>(null)\n  const handleScrollPositioning = useEventCallback(() => {\n    const $scrollContainerParent = scrollContainerParentRef.current\n    if (!scrollAreaRef || !$scrollContainerParent) return\n\n    const parentClientHeight = $scrollContainerParent.clientHeight\n    // Use actual content height captured before send (messages container height), not inflated by minHeight\n    const currentScrollHeight = scrollHeightBeforeSendingRef.current\n\n    // Calculate new minimum height based on actual content height\n    // Use previousMinHeightRef which tracks the real content height, not reserved space\n    const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight)\n    const newMinHeight = baseHeight + parentClientHeight - 250\n\n    setMessageContainerMinHeight(newMinHeight)\n\n    // Scroll to the end immediately to position user message at top\n    nextFrame(() => {\n      scrollAreaRef.scrollTo({\n        top: scrollAreaRef.scrollHeight,\n        behavior: \"instant\",\n      })\n    })\n  })\n\n  const staticEditor = useMemo(() => {\n    return createEditor({\n      nodes: LexicalAIEditorNodes,\n    })\n  }, [])\n\n  const handleSendMessage = useEventCallback((message: string | EditorState) => {\n    resetScrollState()\n\n    const blocks = [] as AIChatContextBlock[]\n\n    for (const block of blockActions.getBlocks()) {\n      if (block.type === \"fileAttachment\" && block.attachment.serverUrl) {\n        blocks.push({\n          ...block,\n          attachment: {\n            id: block.attachment.id,\n            name: block.attachment.name,\n            type: block.attachment.type,\n            size: block.attachment.size,\n            serverUrl: block.attachment.serverUrl,\n          },\n        })\n      } else {\n        blocks.push(block)\n      }\n    }\n\n    const parts: BizUIMessage[\"parts\"] = [\n      {\n        type: \"data-block\",\n        data: blocks,\n      },\n    ]\n\n    if (typeof message === \"string\") {\n      parts.push({\n        type: \"data-rich-text\",\n        data: {\n          state: getEditorStateJSONString(message),\n          text: message,\n        },\n      })\n    } else {\n      staticEditor.setEditorState(message)\n      parts.push({\n        type: \"data-rich-text\",\n        data: {\n          state: JSON.stringify(message.toJSON()),\n          text: convertLexicalToMarkdown(staticEditor),\n        },\n      })\n    }\n\n    // Capture actual content height (messages container), not including reserved minHeight\n    scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0\n    chatActions.sendMessage({\n      parts,\n      role: \"user\",\n      id: nanoid(),\n    })\n    tracker.aiChatMessageSent()\n\n    nextFrame(() => {\n      // Calculate and adjust scroll positioning immediately\n      handleScrollPositioning()\n    })\n  })\n\n  const [bottomPanelHeight, setBottomPanelHeight] = useState<number>(0)\n  const bottomPanelRef = useRef<HTMLDivElement | null>(null)\n\n  useLayoutEffect(() => {\n    if (!bottomPanelRef.current) {\n      return\n    }\n    setBottomPanelHeight(bottomPanelRef.current.offsetHeight)\n\n    const resizeObserver = new ResizeObserver(() => {\n      if (!bottomPanelRef.current) {\n        return\n      }\n      setBottomPanelHeight(bottomPanelRef.current.offsetHeight)\n    })\n    resizeObserver.observe(bottomPanelRef.current)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  useEffect(() => {\n    if (status === \"submitted\") {\n      resetScrollState()\n    }\n\n    // When AI response is complete, update the reference height but keep the container height unchanged\n    // This avoids CLS while ensuring next calculation is based on actual content\n    if (status === \"ready\" && scrollAreaRef && messagesContentRef.current) {\n      // Update the reference to actual content height for next calculation (use messages container)\n      previousMinHeightRef.current = messagesContentRef.current.scrollHeight\n      // Keep the current minHeight unchanged to avoid CLS\n    }\n  }, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef])\n\n  const shouldShowScrollToBottom = hasMessages && !isAtBottom\n\n  const rateLimitMessage = useMemo(() => computeRateLimitMessage(error, null), [error])\n\n  // Additional height for rate limit notice (~40px)\n  const rateLimitExtraHeight = rateLimitMessage ? 40 : 0\n\n  const messages = useMessages()\n  const setStep = useSetAtom(stepAtom)\n\n  const hasFeedsSelection = messages.some((msg) =>\n    msg.parts.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\" && p.output),\n  )\n\n  return (\n    <div className=\"flex h-full flex-1 flex-col\" ref={scrollContainerParentRef}>\n      <ScrollArea\n        onScroll={handleScroll}\n        flex\n        scrollbarClassName=\"mt-12\"\n        scrollbarProps={{\n          style: {\n            marginBottom: Math.max(160, bottomPanelHeight) + rateLimitExtraHeight,\n          },\n        }}\n        ref={setScrollAreaRef}\n        rootClassName=\"flex-1\"\n        viewportProps={{\n          style: {\n            paddingBottom: Math.max(128, bottomPanelHeight) + rateLimitExtraHeight,\n          },\n        }}\n        viewportClassName={\"pt-12\"}\n      >\n        <div\n          className=\"mx-auto w-full px-6 py-8\"\n          style={{\n            minHeight: messageContainerMinHeight ? `${messageContainerMinHeight}px` : undefined,\n          }}\n        >\n          <Messages contentRef={messagesContentRef as RefObject<HTMLDivElement>} />\n\n          {/* if the last message is from ai, show \"Next Step\" button */}\n          {messages.length > 0 &&\n            messages.at(-1)?.role === \"assistant\" &&\n            status === \"ready\" &&\n            hasFeedsSelection && (\n              <div>\n                <Button onClick={() => setStep(\"pre-finish\")}>\n                  {t.app(\"new_user_guide.actions.next\")}\n                </Button>\n              </div>\n            )}\n\n          {(status === \"submitted\" || status === \"streaming\") && <AIChatWaitingIndicator />}\n        </div>\n      </ScrollArea>\n\n      {shouldShowScrollToBottom && (\n        <div className={cn(\"absolute right-1/2 z-40 translate-x-1/2\", \"bottom-32\")}>\n          <button\n            type=\"button\"\n            onClick={() => resetScrollState()}\n            className={cn(\n              \"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2\",\n              \"border-border\",\n              \"hover:border-border/60 active:scale-[0.98]\",\n            )}\n          >\n            <i className=\"i-mingcute-arrow-down-line text-text/90\" />\n          </button>\n        </div>\n      )}\n\n      <div ref={bottomPanelRef} className={\"px-6\"}>\n        {rateLimitMessage && <RateLimitNotice message={rateLimitMessage} />}\n        <ChatInput\n          ref={inputRef}\n          onSend={handleSendMessage}\n          variant={!hasMessages ? \"minimal\" : \"default\"}\n        />\n      </div>\n    </div>\n  )\n}\n\n// Softer gradient colors based on ACCENT_COLOR_MAP\nconst GRADIENT_COLORS = [\n  {\n    light: { from: \"#FF6B35\", to: \"#FFB088\" },\n    dark: { from: \"#FF5C00\", to: \"#FF8B4D\" },\n  },\n  {\n    light: { from: \"#4CD7A5\", to: \"#8FE8C7\" },\n    dark: { from: \"#1FA97A\", to: \"#4DCFA0\" },\n  },\n  {\n    light: { from: \"#F7B500\", to: \"#FFD966\" },\n    dark: { from: \"#D99800\", to: \"#F7C84D\" },\n  },\n  {\n    light: { from: \"#B07BEF\", to: \"#D4B4F7\" },\n    dark: { from: \"#8A3DCC\", to: \"#B07BEF\" },\n  },\n  {\n    light: { from: \"#F266A8\", to: \"#F9A1CA\" },\n    dark: { from: \"#C63C82\", to: \"#E86BAA\" },\n  },\n]\n\nfunction gradientByIndex(index: number, isDark: boolean) {\n  const colors = GRADIENT_COLORS[index % GRADIENT_COLORS.length]!\n  const mode = isDark ? \"dark\" : \"light\"\n  return `linear-gradient(to right, ${colors[mode].from}, ${colors[mode].to})`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useSetAtom } from \"jotai\"\n\nimport { useI18n } from \"~/hooks/common\"\n\nimport { DiscoverImport } from \"../discover/DiscoverImport\"\nimport { stepAtom } from \"./store\"\n\nexport function DiscoverImportStep() {\n  const t = useI18n()\n  const setStep = useSetAtom(stepAtom)\n  return (\n    <div>\n      <DiscoverImport />\n\n      <div className=\"mt-4 flex items-center justify-end gap-2\">\n        <Button variant=\"ghost\" onClick={() => setStep(\"intro\")}>\n          {t.app(\"new_user_guide.actions.back\")}\n        </Button>\n\n        <Button onClick={() => setStep(\"finish\")}>{t.app(\"new_user_guide.actions.finish\")}</Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n} from \"@follow/components/ui/card/index.jsx\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/ScrollArea.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { decode } from \"@toon-format/toon\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { useAtom, useAtomValue, useSetAtom, useStore } from \"jotai\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useEffect, useMemo, useRef } from \"react\"\n\nimport { useI18n } from \"~/hooks/common\"\n\nimport { AISplineLoader } from \"../ai-chat/components/3d-models/AISplineLoader\"\nimport { useMessages } from \"../ai-chat/store/hooks\"\nimport { SearchResultContent } from \"../discover/DiscoverFeedCard\"\nimport { FeedIcon } from \"../feed/feed-icon\"\nimport type { FeedSelection } from \"./store\"\nimport { feedSelectionAtomsAtom, selectedFeedSelectionAtomsAtom } from \"./store\"\n\ntype FeedToSelect = Omit<FeedSelection, \"selected\">\n\nconst extractFeedsToSelect = (output: unknown): FeedToSelect[] => {\n  if (!output) {\n    return []\n  }\n\n  if (typeof output === \"string\") {\n    return decode(output) as unknown as FeedToSelect[]\n  }\n\n  if (Array.isArray(output)) {\n    return output as unknown as FeedToSelect[]\n  }\n\n  return []\n}\n\nexport function FeedsSelectionList() {\n  const chatMessages = useMessages()\n\n  const hasFeedsSelection = chatMessages.some((msg) =>\n    msg.parts.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\" && p.output),\n  )\n\n  return (\n    <div className=\"col-span-4 h-full overflow-hidden\">\n      <AnimatePresence mode=\"popLayout\">\n        {hasFeedsSelection ? <FeedSelectionOperationScreen /> : <FeedSelectionFirstScreen />}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nfunction FeedSelectionOperationScreen() {\n  const chatMessages = useMessages()\n\n  const feedsToSelect: FeedToSelect[] = useMemo(() => {\n    // find the last message that has the tool\n    const output = chatMessages\n      .findLast((m) => m.parts?.some((p) => p.type === \"tool-onboardingGetTrendingFeeds\"))\n      ?.parts?.findLast((p) => p.type === \"tool-onboardingGetTrendingFeeds\")?.output\n\n    return extractFeedsToSelect(output)\n  }, [chatMessages])\n\n  const store = useStore()\n  const atomList = useAtomValue(feedSelectionAtomsAtom)\n  const dispatch = useSetAtom(feedSelectionAtomsAtom)\n\n  const lastKeyRef = useRef<string | null>(null)\n\n  const outputKey = useMemo(() => {\n    const ids = Array.from(new Set(feedsToSelect.map((f) => String(f.id))))\n    ids.sort()\n    return ids.join(\"|\")\n  }, [feedsToSelect])\n\n  const existingIds = useMemo(\n    () => new Set(atomList.map((a) => String(store.get(a).id))),\n    [atomList, store],\n  )\n\n  useEffect(() => {\n    if (lastKeyRef.current === outputKey) return\n    lastKeyRef.current = outputKey\n\n    const seen = new Set(existingIds)\n\n    for (const feed of feedsToSelect) {\n      const id = String(feed.id)\n      if (seen.has(id)) continue\n      seen.add(id)\n\n      dispatch({\n        type: \"insert\",\n        value: { ...feed, selected: true },\n      })\n    }\n  }, [dispatch, feedsToSelect, existingIds, outputKey])\n\n  const selectedAtoms = useAtomValue(selectedFeedSelectionAtomsAtom)\n  const items = useMemo(\n    () => selectedAtoms.map((atom) => ({ atom, id: store.get(atom).id })),\n    [selectedAtoms, store],\n  )\n\n  return (\n    <ScrollArea flex rootClassName=\"h-full\" viewportClassName=\"px-3 flex min-h-0 grow\">\n      <div className=\"flex flex-col gap-5 py-5\">\n        <AnimatePresence mode=\"popLayout\">\n          {items.map(({ atom, id }) => (\n            <m.div\n              key={id}\n              layout\n              initial={{ opacity: 0, scale: 0.9 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.9 }}\n            >\n              <FeedSelectionItem feedAtom={atom} />\n            </m.div>\n          ))}\n        </AnimatePresence>\n      </div>\n    </ScrollArea>\n  )\n}\n\nfunction FeedSelectionItem({ feedAtom }: { feedAtom: PrimitiveAtom<FeedSelection> }) {\n  const [feed, setFeed] = useAtom(feedAtom)\n\n  const onRemove = () => {\n    setFeed((prev) => ({\n      ...prev,\n      selected: false,\n    }))\n  }\n\n  return (\n    <div className=\"relative mr-4\">\n      {/* remove button */}\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <i\n            onClick={onRemove}\n            className=\"i-mingcute-minus-circle-fill absolute right-0 top-0 z-10 size-5 -translate-y-1/2 translate-x-1/2 cursor-pointer text-text-secondary transition-colors hover:text-text\"\n          />\n        </TooltipTrigger>\n        <TooltipContent>Remove</TooltipContent>\n      </Tooltip>\n\n      <Card\n        data-feed-id={feed.id}\n        className={cn(\n          \"flex-shrink-0 select-text overflow-hidden border border-zinc-200/50 bg-white/80 backdrop-blur-xl transition-all duration-300 dark:border-zinc-800/50 dark:bg-neutral-800/50\",\n        )}\n      >\n        <CardHeader className=\"pb-2\">\n          <div className=\"flex items-center gap-1\">\n            <FeedIcon\n              size={32}\n              target={{ type: \"feed\", ...feed }}\n              siteUrl={feed.url}\n              fallbackUrl={feed.image ?? undefined}\n              fallback\n            />\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-sm font-semibold text-text\">{feed.title}</p>\n              <p className=\"text-xs text-text-secondary\">{feed.url}</p>\n            </div>\n          </div>\n        </CardHeader>\n\n        <CardContent>\n          <CardDescription className=\"text-sm text-text-secondary\">\n            {feed.description}\n          </CardDescription>\n\n          <div className=\"pointer-events-none mt-5 grid grid-cols-4 gap-2\">\n            {feed.entries?.map((entry) => (\n              <SearchResultContent key={entry.id} entry={entry as any} />\n            ))}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n\nfunction FeedSelectionFirstScreen() {\n  const t = useI18n()\n\n  return (\n    <m.div\n      className=\"relative mr-4 h-full overflow-hidden rounded-3xl border border-zinc-200/50 bg-material-thin p-8 backdrop-blur-xl dark:border-zinc-800/50\"\n      aria-hidden=\"true\"\n      initial={{ opacity: 0, y: 20 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, y: -20 }}\n      transition={{ duration: 0.5, ease: \"easeOut\" }}\n    >\n      {/* Grid background - consistent with app patterns */}\n      <div className=\"absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.03)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,black,transparent)] dark:bg-[linear-gradient(rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.05)_1px,transparent_1px)]\" />\n\n      {/* Content */}\n      <div className=\"relative z-10 flex h-full flex-col items-center justify-center text-center\">\n        {/* Icon - using app's existing icon library */}\n        <m.div\n          initial={{ scale: 0.8, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          transition={{ delay: 0.1, duration: 0.6, type: \"spring\" }}\n          className=\"mb-6\"\n        >\n          <div className=\"mx-auto mb-4 flex items-center justify-center\">\n            <AISplineLoader />\n          </div>\n        </m.div>\n\n        {/* Title - using app's gradient text pattern */}\n        <m.div\n          initial={{ y: 20, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          transition={{ delay: 0.3, duration: 0.6, ease: \"easeOut\" }}\n          className=\"mb-4\"\n        >\n          <h1 className=\"bg-gradient-to-r from-zinc-800 to-zinc-600 bg-clip-text text-4xl font-bold text-transparent dark:from-zinc-100 dark:to-zinc-300\">\n            {t.app(\"new_user_guide.intro.title\")}\n          </h1>\n        </m.div>\n\n        {/* Description text */}\n        <m.div\n          initial={{ y: 20, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          transition={{ delay: 0.5, duration: 0.6, ease: \"easeOut\" }}\n          className=\"mb-8 max-w-sm\"\n        >\n          <p className=\"text-lg leading-relaxed text-text-secondary\">\n            {t.app(\"new_user_guide.intro.description\")}\n          </p>\n        </m.div>\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/new-user-guide/pre-finish.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport Spline from \"@splinetool/react-spline\"\nimport { useAtomValue, useSetAtom } from \"jotai\"\nimport { useEffect, useMemo } from \"react\"\n\nimport { feedSelectionsAtom, stepAtom } from \"./store\"\n\nconst WAIT_DURATION_MS = 5000\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nexport function PreFinish() {\n  const feedSelections = useAtomValue(feedSelectionsAtom)\n  const setStep = useSetAtom(stepAtom)\n  const selectedFeeds = useMemo(\n    () => feedSelections.filter((feed) => feed.selected),\n    [feedSelections],\n  )\n\n  useEffect(() => {\n    let disposed = false\n\n    const subscribeSelectedFeeds = async () => {\n      for (const feed of selectedFeeds) {\n        if (disposed) break\n        const { url, id, title } = feed\n\n        try {\n          await subscriptionSyncService.subscribe({\n            url,\n            view: feed.analytics.view ?? FeedViewType.Articles,\n            category: null,\n            isPrivate: false,\n            hideFromTimeline: null,\n            title: title ?? null,\n            feedId: id,\n            listId: undefined,\n          })\n        } catch (error) {\n          if (!disposed) {\n            console.error(\"Failed to subscribe feed during onboarding\", { feedId: id, error })\n          }\n        }\n      }\n    }\n\n    const run = async () => {\n      const tasks: Promise<unknown>[] = [sleep(WAIT_DURATION_MS)]\n      if (selectedFeeds.length > 0) {\n        tasks.push(subscribeSelectedFeeds())\n      }\n      await Promise.allSettled(tasks)\n\n      if (!disposed) {\n        setStep(\"finish\")\n      }\n    }\n\n    run()\n\n    return () => {\n      disposed = true\n    }\n  }, [selectedFeeds, setStep])\n\n  return (\n    <div className=\"h-[100vh] w-screen\">\n      <Spline scene=\"https://prod.spline.design/07pKu5Ohpb-J2VPw/scene.splinecode\" />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/new-user-guide/store.ts",
    "content": "import type { MediaModel } from \"@follow/database/schemas/types\"\nimport type { FeedViewType } from \"@follow-app/client-sdk\"\nimport { atom } from \"jotai\"\nimport { splitAtom } from \"jotai/utils\"\n\nexport const stepAtom = atom<\n  \"intro\" | \"selecting-feeds\" | \"manual-import\" | \"pre-finish\" | \"finish\"\n>(\"intro\")\n\nexport type FeedSelection = {\n  description: string | null\n  id: string\n  image: string | null\n  title: string | null\n  url: string\n  selected?: boolean\n\n  entries: {\n    description: string | null\n    id: string\n    media: MediaModel[] | null\n    publishedAt: Date\n    title: string | null\n    url: string | null\n  }[]\n\n  analytics: {\n    view: FeedViewType | null\n  }\n}\n\nexport const feedSelectionsAtom = atom<FeedSelection[]>([])\n\nexport const feedSelectionAtomsAtom = splitAtom(feedSelectionsAtom)\n\nexport const selectedFeedSelectionAtomsAtom = atom((get) =>\n  get(feedSelectionAtomsAtom).filter((a) => get(a).selected),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/panel/cmdf.tsx",
    "content": "/**\n * @see https://github.com/toeverything/AFFiNE/blob/98e35384a6f71bf64c668b8f13afcaf28c9b8e97/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.tsx\n * @copyright AFFiNE, Folo\n */\nimport { Spring } from \"@follow/components/constants/spring.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.jsx\"\nimport { useInputComposition, useRefValue } from \"@follow/hooks\"\nimport { useSubscribeElectronEvent } from \"@follow/shared/event\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC } from \"react\"\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\"\nimport { useDebounceCallback, useEventCallback } from \"usehooks-ts\"\n\nimport { ipcServices } from \"~/lib/client\"\nimport { observeResize } from \"~/lib/observe-resize\"\n\nconst CmdFImpl: FC<{\n  onClose: () => void\n}> = ({ onClose }) => {\n  const [value, setValue] = useState(\"\")\n  const [activeMatchOrdinal, setActiveMatchOrdinal] = useState(0)\n  const [matches, setMatches] = useState(0)\n\n  const currentValue = useRefValue(value)\n  const inputRef = useRef<HTMLInputElement>(null)\n\n  const [scrollLeft, setScrollLeft] = useState(0)\n\n  useLayoutEffect(() => {\n    Promise.resolve(ipcServices?.app.readClipboard()).then((text) => {\n      if (!currentValue.current && text) {\n        setValue(text)\n      }\n    })\n\n    inputRef.current?.focus()\n    // Select all\n\n    nextFrame(() => inputRef.current?.setSelectionRange(0, currentValue.current.length))\n  }, [currentValue])\n\n  const [isSearching, setIsSearching] = useState(false)\n\n  const searchIdRef = useRef<number>(0)\n\n  const { isCompositionRef, ...inputProps } = useInputComposition<HTMLInputElement>({\n    onKeyDown: useEventCallback((e) => {\n      const $input = inputRef.current\n      if (!$input) return\n\n      if (e.key === \"Escape\") {\n        nativeSearchImpl(\"\")\n        onClose()\n        e.preventDefault()\n      }\n    }),\n    onCompositionEnd: useEventCallback(() => nativeSearch(value)),\n  })\n\n  const nativeSearchImpl = useEventCallback(\n    async (\n      text: string,\n\n      dir: \"forward\" | \"backward\" = \"forward\",\n    ) => {\n      if (isCompositionRef.current) return\n      const $input = inputRef.current\n      if (!$input) return\n      const { scrollLeft } = $input\n      setScrollLeft(scrollLeft)\n\n      setIsSearching(true)\n\n      const searchId = ++searchIdRef.current\n\n      let findNext = true\n      if (!text) {\n        Promise.resolve(ipcServices?.app.clearSearch()).finally(() => {\n          if (searchId === searchIdRef.current) {\n            setIsSearching(false)\n          }\n        })\n      } else {\n        Promise.resolve(\n          ipcServices?.app.search({\n            text,\n            options: {\n              findNext,\n              forward: dir === \"forward\",\n            },\n          }),\n        )\n          .then((result) => {\n            setMatches(result?.matches || 0)\n            setActiveMatchOrdinal(result?.activeMatchOrdinal || 0)\n          })\n          .finally(() => {\n            if (searchId === searchIdRef.current) {\n              setIsSearching(false)\n              findNext = false\n            }\n          })\n      }\n    },\n  )\n  const nativeSearch = useDebounceCallback(nativeSearchImpl, 500)\n  useLayoutEffect(() => {\n    inputRef.current?.focus()\n    setTimeout(() => {\n      inputRef.current?.focus()\n    })\n  }, [isSearching])\n  const handleScroll = useCallback(() => {\n    const $input = inputRef.current\n    if (!$input) return\n    const { scrollLeft } = $input\n\n    setScrollLeft(scrollLeft)\n  }, [])\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault()\n        nativeSearch(value)\n      }}\n      className=\"center shadow-perfect fixed right-8 top-12 size-9 w-64 gap-2 rounded-2xl border bg-zinc-50/90 pl-3 pr-2 backdrop-blur duration-200 focus-within:border-accent dark:bg-neutral-800/80\"\n    >\n      <div className=\"relative h-full grow\">\n        <input\n          {...inputProps}\n          ref={inputRef}\n          name=\"search\"\n          className=\"absolute inset-0 size-full appearance-none bg-transparent font-[system-ui] text-[15px] text-transparent caret-accent selection:text-transparent\"\n          style={{\n            visibility: isSearching ? \"hidden\" : \"visible\",\n          }}\n          type=\"text\"\n          value={value}\n          onScroll={handleScroll}\n          onChange={async (e) => {\n            e.preventDefault()\n            const search = e.target.value\n            setValue(search)\n            setIsSearching(false)\n            nativeSearch(search)\n          }}\n        />\n\n        <CanvasText\n          scrollLeft={scrollLeft}\n          className=\"pointer-events-none absolute inset-0 size-full text-transparent [&::placeholder]:text-text\"\n          text={value}\n        />\n      </div>\n      <div className=\"center gap-1 [&>*]:opacity-80\">\n        {!!value && matches > 0 && activeMatchOrdinal > 0 && (\n          <span>\n            {activeMatchOrdinal}/{matches}\n          </span>\n        )}\n        <button\n          type=\"button\"\n          className=\"center hover:opacity-90\"\n          onClick={() => {\n            nativeSearchImpl(value, \"backward\")\n          }}\n        >\n          <i className=\"i-mgc-back-2-cute-re\" />\n        </button>\n        <button\n          type=\"button\"\n          className=\"center hover:opacity-90\"\n          onClick={() => {\n            nativeSearchImpl(value, \"forward\")\n          }}\n        >\n          <i className=\"i-mgc-forward-2-cute-re\" />\n        </button>\n        <button\n          type=\"button\"\n          className=\"center hover:opacity-90\"\n          onClick={() => {\n            setValue(\"\")\n            nativeSearchImpl(\"\")\n            onClose()\n          }}\n        >\n          <i className=\"i-mgc-close-cute-re\" />\n        </button>\n      </div>\n    </form>\n  )\n}\n\nconst drawText = (canvas: HTMLCanvasElement, text: string, scrollLeft: number) => {\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) {\n    return\n  }\n\n  const dpr = window.devicePixelRatio || 1\n  canvas.width = canvas.getBoundingClientRect().width * dpr\n  canvas.height = canvas.getBoundingClientRect().height * dpr\n\n  const rootStyles = getComputedStyle(document.documentElement)\n\n  const textColor = `hsl(${rootStyles.getPropertyValue(\"--color-text\").trim()})`\n\n  ctx.scale(dpr, dpr)\n  ctx.clearRect(0, 0, canvas.width, canvas.height)\n  ctx.fillStyle = textColor\n  ctx.font = \"15px system-ui\"\n\n  const offsetX = -scrollLeft // Offset based on scrollLeft\n\n  ctx.fillText(text, offsetX, 23)\n\n  ctx.textAlign = \"left\"\n  ctx.textBaseline = \"middle\"\n}\n\nconst CanvasText = ({\n  text,\n  className,\n  scrollLeft,\n}: {\n  text: string\n  className: string\n  scrollLeft: number\n}) => {\n  const ref = useRef<HTMLCanvasElement>(null)\n\n  useEffect(() => {\n    const canvas = ref.current\n    if (!canvas) {\n      return\n    }\n    drawText(canvas, text, scrollLeft)\n    return observeResize(canvas, () => drawText(canvas, text, scrollLeft))\n  }, [scrollLeft, text])\n\n  return <canvas className={className} ref={ref} />\n}\n\nexport const CmdF = () => {\n  const [show, setShow] = useState(false)\n\n  useSubscribeElectronEvent(\"OpenSearch\", () => {\n    setShow(true)\n  })\n  return (\n    <RootPortal>\n      <AnimatePresence>\n        {show && (\n          <m.div\n            className=\"fixed top-0 w-full\"\n            initial={{ opacity: 0.8, y: -150 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -150 }}\n            transition={Spring.presets.smooth}\n          >\n            <CmdFImpl\n              onClose={() => {\n                setShow(false)\n              }}\n            />\n          </m.div>\n        )}\n      </AnimatePresence>\n    </RootPortal>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/panel/cmdk.module.css",
    "content": ".status-bar {\n  @apply relative z-10 h-px w-full shrink-0 scale-y-75 transform;\n\n  &.loading::before {\n    @apply absolute bottom-0 left-0 top-0 z-10 h-px w-full scale-y-75 transform;\n    @apply bg-repeat;\n\n    content: \"\";\n    background: linear-gradient(90deg, transparent, #bbb, transparent);\n    animation: move 2s steps(60) infinite;\n  }\n}\n\n.content-visually {\n  content-visibility: auto;\n  contain-intrinsic-size: auto 38px;\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/panel/cmdk.tsx",
    "content": "import { EmptyIcon } from \"@follow/components/icons/empty.jsx\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@follow/components/ui/select/index.jsx\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { useInputComposition } from \"@follow/hooks\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { getSubscriptionByFeedId } from \"@follow/store/subscription/getter\"\nimport { getUnreadById } from \"@follow/store/unread/getters\"\nimport { tracker } from \"@follow/tracker\"\nimport { clsx, cn } from \"@follow/utils/utils\"\nimport { Command } from \"cmdk\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { memo, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setAppSearchOpen, useAppSearchOpen } from \"~/atoms/app\"\nimport { ExPromise } from \"~/components/common/ExPromise\"\nimport { LoadMoreIndicator } from \"~/components/common/LoadMoreIndicator\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { ROUTE_ENTRY_PENDING } from \"~/constants\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useI18n } from \"~/hooks/common\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { searchActions, useSearchStore, useSearchType } from \"~/store/search\"\nimport { SearchType } from \"~/store/search/constants\"\nimport type { SearchInstance } from \"~/store/search/types\"\n\nimport styles from \"./cmdk.module.css\"\n\nconst SearchCmdKContext = React.createContext<Promise<SearchInstance> | null>(null)\nexport const SearchCmdK: React.FC = () => {\n  const { t } = useTranslation()\n  const open = useAppSearchOpen()\n\n  const [searchInstance, setSearchInstance] = React.useState(() =>\n    searchActions.createLocalDbSearch(),\n  )\n  React.useEffect(() => {\n    if (!open) return\n\n    tracker.searchOpen()\n\n    // Refresh data\n    setPage(0)\n    setSearchInstance(() => searchActions.createLocalDbSearch())\n  }, [open])\n\n  const entries = useSearchStore((s) => s.entries)\n  const feeds = useSearchStore((s) => s.feeds)\n\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const dialogRef = React.useRef<HTMLDivElement>(null)\n  const scrollViewRef = React.useRef<HTMLDivElement>(null)\n\n  const { getTopModalStack } = useModalStack()\n\n  React.useEffect(() => {\n    const $input = inputRef.current\n    if (open && $input) {\n      $input.focus()\n    }\n  }, [open])\n\n  const { onCompositionEnd, onCompositionStart, isCompositionRef } =\n    useInputComposition<HTMLInputElement>({})\n  const handleKeyDownToFocusInput: React.EventHandler<React.KeyboardEvent> = React.useCallback(\n    (e) => {\n      const $input = inputRef.current\n\n      if (e.key === \"Escape\" && !isCompositionRef.current && !getTopModalStack()) {\n        setAppSearchOpen(false)\n        return\n      }\n\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") return\n\n      if (!e.ctrlKey && !e.metaKey && !e.altKey) {\n        $input?.focus()\n      }\n    },\n    [getTopModalStack, isCompositionRef],\n  )\n  const [isPending, startTransition] = React.useTransition()\n  const handleSearch = React.useCallback(\n    async (value: string) => {\n      const { search } = await searchInstance\n      setPage(0)\n      startTransition(() => {\n        search(value)\n        const $scrollView = scrollViewRef.current\n        if ($scrollView) {\n          $scrollView.scrollTop = 0\n        }\n      })\n    },\n    [searchInstance],\n  )\n  // Performance optimization\n  const [page, setPage] = React.useState(0)\n  const pageSize = 16\n  const renderedEntries = useMemo(() => entries.slice(0, (page + 1) * pageSize), [entries, page])\n\n  const renderedFeeds = useMemo(() => {\n    const delta = entries.length - renderedEntries.length\n\n    if (delta > pageSize) return []\n\n    const entriesTotalPage = Math.ceil(entries.length / pageSize)\n    const right = entriesTotalPage === page + 1 ? delta : pageSize * page + 1 - entries.length\n\n    return feeds.slice(0, right)\n  }, [entries.length, feeds, page, renderedEntries.length])\n  const totalCount = entries.length + feeds.length\n  const renderedTotalCount = renderedEntries.length + renderedFeeds.length\n  const loadMore = React.useCallback(() => {\n    const totalPage = Math.ceil((entries.length + feeds.length) / pageSize)\n    setPage((p) => Math.min(p + 1, totalPage))\n  }, [entries.length, feeds.length])\n\n  const canLoadMore = totalCount > renderedTotalCount && renderedTotalCount > 0\n\n  return (\n    <SearchCmdKContext value={searchInstance}>\n      <Command.Dialog\n        ref={dialogRef}\n        shouldFilter={false}\n        open={open}\n        onKeyDown={handleKeyDownToFocusInput}\n        onOpenChange={setAppSearchOpen}\n        className={cn(\n          \"h-[600px] max-h-[80vh] w-[800px] max-w-[100vw] rounded-none md:h-screen md:max-h-[60vh] md:max-w-[80vw]\",\n          \"flex min-h-[50vh] flex-col bg-material-ultra-thick shadow-2xl backdrop-blur-background md:rounded-xl\",\n          \"border-0 border-border md:border\",\n          \"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\",\n        )}\n      >\n        <Command.Input\n          className=\"w-full shrink-0 border-b border-border bg-transparent p-4 px-5 text-lg leading-4\"\n          ref={inputRef}\n          placeholder={searchActions.getCurrentKeyword() || t(\"search.placeholder\")}\n          onValueChange={handleSearch}\n          onCompositionStart={onCompositionStart}\n          onCompositionEnd={onCompositionEnd}\n        />\n        <div className={cn(styles[\"status-bar\"], isPending && styles[\"loading\"])} />\n\n        <div className=\"flex flex-1 flex-col overflow-y-hidden\">\n          <ScrollArea.ScrollArea\n            ref={scrollViewRef}\n            viewportClassName=\"max-h-[50vh] [&>div]:!flex\"\n            rootClassName=\"flex-1 px-5\"\n            scrollbarClassName=\"mb-6\"\n          >\n            <Command.List className=\"flex w-full min-w-0 flex-col\">\n              <SearchPlaceholder />\n\n              {renderedEntries.length > 0 && (\n                <Command.Group\n                  heading={\n                    <SearchGroupHeading\n                      icon=\"i-mgc-paper-cute-fi size-4\"\n                      title={t(\"search.group.entries\")}\n                    />\n                  }\n                  className=\"flex w-full min-w-0 flex-col py-2\"\n                >\n                  {renderedEntries.map((entry) => {\n                    const feed = getFeedById(entry.feedId)\n                    return (\n                      <SearchItem\n                        key={`entry-${entry.item.id}-${entry.feedId}`}\n                        view={feed?.id ? getSubscriptionByFeedId(feed.id)?.view : undefined}\n                        title={entry.item.title!}\n                        feedId={entry.feedId}\n                        entryId={entry.item.id}\n                        id={entry.item.id}\n                        icon={feed?.type === \"feed\" ? feed?.siteUrl : undefined}\n                        subtitle={feed?.title}\n                      />\n                    )\n                  })}\n                </Command.Group>\n              )}\n              {renderedFeeds.length > 0 && (\n                <Command.Group\n                  heading={\n                    <SearchGroupHeading\n                      icon=\"i-mgc-rss-cute-fi size-4 text-accent\"\n                      title={t(\"search.group.feeds\")}\n                    />\n                  }\n                  className=\"py-2\"\n                >\n                  {renderedFeeds.map((feed) => (\n                    <SearchItem\n                      key={`feed-${feed.item.id}`}\n                      view={getSubscriptionByFeedId(feed.item.id!)?.view}\n                      title={feed.item.title!}\n                      feedId={feed.item.id!}\n                      entryId={ROUTE_ENTRY_PENDING}\n                      id={feed.item.id!}\n                      icon={feed.item.type === \"feed\" ? feed.item.siteUrl : undefined}\n                      subtitle={getUnreadById(feed.item.id)?.toString()}\n                    />\n                  ))}\n                </Command.Group>\n              )}\n              {canLoadMore && <LoadMoreIndicator className=\"center w-full\" onLoading={loadMore} />}\n            </Command.List>\n          </ScrollArea.ScrollArea>\n\n          <div className=\"relative flex items-center justify-between px-3 py-2\">\n            <SearchOptions />\n            <SearchResultCount count={totalCount} />\n          </div>\n        </div>\n      </Command.Dialog>\n    </SearchCmdKContext>\n  )\n}\n\ntype SearchListType = {\n  title: string\n  subtitle?: Nullable<string>\n  feedId?: string\n  entryId?: string\n  icon?: Nullable<string>\n  id: string\n  view?: FeedViewType\n}\n\nconst SearchItem = memo(function Item({\n  id,\n  title,\n  entryId,\n  feedId,\n\n  subtitle,\n  view,\n}: {} & SearchListType) {\n  const navigateEntry = useNavigateEntry()\n\n  const feed = getFeedById(feedId!)\n\n  return (\n    <Command.Item\n      className={clsx(\n        \"relative flex w-full justify-between px-1 text-[0.9rem]\",\n        `before:absolute before:inset-0 before:rounded-md before:content-[\"\"]`,\n        \"hover:before:bg-zinc-200/60 dark:hover:before:bg-zinc-800/80\",\n        \"data-[selected=true]:before:bg-zinc-200/60 data-[selected=true]:dark:before:bg-zinc-800/80\",\n        \"min-w-0 max-w-full\",\n        styles[\"content-visually\"],\n      )}\n      key={`${id}-${feedId}-${entryId}`}\n      value={`${id}-${feedId}-${entryId}`}\n      onSelect={() => {\n        navigateEntry({\n          feedId: feedId!,\n          entryId,\n          view,\n        })\n      }}\n    >\n      <div className=\"relative flex w-full items-center justify-between px-1 py-2\">\n        {feed && <FeedIcon className=\"mr-2 size-5 shrink-0 rounded\" target={feed} />}\n        <span className=\"block min-w-0 flex-1 shrink-0 truncate\">{title}</span>\n        <span className=\"block min-w-0 shrink-0 grow-0 text-xs font-medium text-zinc-800 opacity-60 dark:text-slate-200/80\">\n          {subtitle}\n        </span>\n      </div>\n    </Command.Item>\n  )\n})\n\nconst SearchGroupHeading: FC<{ icon: string; title: string }> = ({ icon, title }) => (\n  <div className=\"mb-2 flex items-center gap-2\">\n    <i className={icon} />\n    <span className=\"text-sm font-semibold\">{title}</span>\n  </div>\n)\n\nconst SearchResultCount: FC<{\n  count?: number\n}> = ({ count }) => {\n  const t = useI18n()\n  const searchInstance = React.use(SearchCmdKContext)\n  const hasKeyword = useSearchStore((s) => !!s.keyword)\n  const searchType = useSearchType()\n\n  const recordCountPromise = useMemo(async () => {\n    let count = 0\n    const counts = await searchInstance?.then((s) => s.counts)\n    if (!counts) return 0\n    if (searchType & SearchType.Entry) {\n      count += counts.entries || 0\n    }\n    if (searchType & SearchType.Feed) {\n      count += counts.feeds || 0\n    }\n    if (searchType & SearchType.Subscription) {\n      count += counts.subscriptions || 0\n    }\n    return count\n  }, [searchInstance, searchType])\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <small className=\"center shrink-0 gap-1 opacity-80\">\n          {hasKeyword ? (\n            <span>\n              {count} {t.common(\"words.result\", { count })}\n            </span>\n          ) : (\n            <ExPromise promise={recordCountPromise}>\n              {(count) => (\n                <>\n                  {count} {t.common(\"quantifier.piece\")}\n                  {t.common(\"words.local\")}\n                  {t.common(\"space\")}\n                  {t.common(\"words.record\", { count })}\n                </>\n              )}\n            </ExPromise>\n          )}{\" \"}\n          {t(\"search.result_count_local_mode\")}\n          <i className=\"i-mingcute-question-line\" />\n        </small>\n      </TooltipTrigger>\n      <TooltipContent>{t(\"search.tooltip.local_search\")}</TooltipContent>\n    </Tooltip>\n  )\n}\nconst SearchOptions: Component = memo(({ children }) => {\n  const { t } = useTranslation()\n  const searchType = useSearchType()\n\n  const searchInstance = React.use(SearchCmdKContext)\n\n  return (\n    <div className=\"flex items-center gap-2 text-sm text-text\">\n      <span className=\"shrink-0\">{t(\"search.options.search_type\")}</span>\n\n      <Select\n        onValueChange={async (value) => {\n          searchActions.setSearchType(+value as SearchType)\n\n          if (searchInstance) {\n            const { search } = await searchInstance\n            search(searchActions.getCurrentKeyword())\n          }\n        }}\n        value={`${searchType}`}\n      >\n        <SelectTrigger size=\"sm\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent position=\"item-aligned\">\n          <SelectItem\n            className=\"hover:bg-theme-item-hover\"\n            value={`${SearchType.All}`}\n            disabled={searchType === SearchType.All}\n          >\n            {t(\"search.options.all\")}\n          </SelectItem>\n          <SelectItem\n            className=\"hover:bg-theme-item-hover\"\n            value={`${SearchType.Entry}`}\n            disabled={searchType === SearchType.Entry}\n          >\n            {t(\"search.options.entry\")}\n          </SelectItem>\n          <SelectItem\n            className=\"hover:bg-theme-item-hover\"\n            value={`${SearchType.Feed}`}\n            disabled={searchType === SearchType.Feed}\n          >\n            {t(\"search.options.feed\")}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n\n      {children}\n    </div>\n  )\n})\n\nconst SearchPlaceholder = () => {\n  const { t } = useTranslation()\n  const hasKeyword = useSearchStore((s) => !!s.keyword)\n  return (\n    <Command.Empty className=\"center absolute inset-0\">\n      {hasKeyword ? (\n        <div className=\"flex flex-col items-center justify-center gap-2 opacity-80\">\n          <EmptyIcon />\n          {t(\"search.empty.no_results\")}\n        </div>\n      ) : (\n        <Logo className=\"size-12 opacity-80 grayscale\" />\n      )}\n    </Command.Empty>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx",
    "content": "import { Form, FormControl, FormField, FormItem } from \"@follow/components/ui/form/index.jsx\"\nimport { useRegisterGlobalContext } from \"@follow/shared/bridge\"\nimport { tracker } from \"@follow/tracker\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useEffect, useLayoutEffect } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { useEventCallback } from \"usehooks-ts\"\nimport { z } from \"zod\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { ipcServices } from \"~/lib/client\"\n\nimport { COMMAND_ID } from \"../command/commands/id\"\nimport { FeedForm } from \"../discover/FeedForm\"\n\nconst CmdNPanel = () => {\n  const { t } = useTranslation()\n  const form = useForm({\n    resolver: zodResolver(\n      z.object({\n        url: z.string().url(),\n      }),\n    ),\n\n    mode: \"all\",\n  })\n\n  useLayoutEffect(() => {\n    ipcServices?.app.readClipboard().then((clipboardText) => {\n      if (clipboardText) {\n        form.setValue(\"url\", clipboardText)\n        form.control._setValid()\n      }\n    })\n  }, [])\n\n  const { present, dismissAll } = useModalStack()\n\n  const handleSubmit = () => {\n    const { url } = form.getValues()\n\n    const defaultView = getRouteParams().view\n\n    tracker.quickAddFeed({\n      type: \"url\",\n      defaultView: Number(defaultView),\n    })\n\n    present({\n      title: t(\"feed_form.add_feed\"),\n      modalContentClassName: \"overflow-visible\",\n      content: () => <FeedForm url={url} onSuccess={dismissAll} />,\n    })\n  }\n\n  return (\n    <Form {...form}>\n      <m.form\n        exit={{ opacity: 0 }}\n        className={cn(\n          \"w-[700px] max-w-[100vw] rounded-none md:max-w-[80vw]\",\n          \"flex flex-col bg-zinc-50/85 shadow-2xl backdrop-blur-md dark:bg-neutral-900/80 md:rounded-full\",\n          \"border-0 border-zinc-200 dark:border-zinc-800 md:border\",\n          \"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pr-8\",\n          \"z-10\",\n        )}\n        onSubmit={form.handleSubmit(handleSubmit)}\n      >\n        <FormField\n          render={({ field }) => (\n            <FormItem>\n              <FormControl>\n                <input\n                  {...field}\n                  placeholder={t(\"quick_add.placeholder\")}\n                  className=\"w-full shrink-0 border-zinc-200 bg-transparent p-4 px-5 text-lg leading-4 dark:border-neutral-700\"\n                />\n              </FormControl>\n            </FormItem>\n          )}\n          control={form.control}\n          name=\"url\"\n        />\n\n        <button\n          disabled={form.formState.isSubmitting || !form.formState.isValid}\n          type=\"submit\"\n          className=\"center absolute inset-y-0 right-3 pl-2 text-accent duration-200 hover:text-accent/90 disabled:grayscale\"\n        >\n          <i className=\"i-mgc-arrow-right-circle-cute-fi size-6\" />\n        </button>\n      </m.form>\n    </Form>\n  )\n}\n\nexport const CmdNTrigger = () => {\n  const { t } = useTranslation()\n  const { present } = useModalStack()\n  const handler = useEventCallback(() => {\n    present({\n      title: t(\"quick_add.title\"),\n      content: CmdNPanel,\n      CustomModalComponent: PlainModal,\n      overlay: false,\n      id: \"quick-add\",\n      clickOutsideToDismiss: true,\n    })\n  })\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.global.quickAdd, handler)\n  }, [handler])\n\n  useRegisterGlobalContext(\"quickAdd\", handler)\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/plan/UpgradePlanModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { ModalActionsInternal } from \"~/components/ui/modal\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { useSettingModal } from \"../settings/modal/useSettingModal\"\n\nexport const UpgradePlanModalContent = ({\n  className,\n}: {\n  className?: string\n} & Partial<ModalActionsInternal>) => {\n  const { t } = useTranslation()\n  const settingModalPresent = useSettingModal()\n  const { dismiss } = useCurrentModal()\n\n  return (\n    <div\n      className={cn(\"flex w-[512px] max-w-full flex-col gap-2 overflow-hidden px-0.5\", className)}\n    >\n      <p>{t(\"activation.plan.description\")}</p>\n      <Button\n        buttonClassName=\"w-fit self-end\"\n        onClick={() => {\n          settingModalPresent(\"plan\")\n          dismiss()\n        }}\n      >\n        {t(\"activation.plan.upgrade\")}\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/plan/index.tsx",
    "content": "import { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { UpgradePlanModalContent } from \"./UpgradePlanModalContent\"\n\nexport const useUpgradePlanModal = () => {\n  const { present } = useModalStack()\n  const { t } = useTranslation()\n  return useCallback(\n    () =>\n      present({\n        title: t(\"activation.plan.title\"),\n        content: UpgradePlanModalContent,\n        id: \"upgrade-plan\",\n        autoFocus: false,\n      }),\n    [present, t],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/player/corner-player.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as Slider from \"@radix-ui/react-slider\"\nimport dayjs from \"dayjs\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport Marquee from \"react-fast-marquee\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  AudioPlayer,\n  getAudioPlayerAtomValue,\n  useAudioPlayerAtomSelector,\n  useAudioPlayerAtomValue,\n} from \"~/atoms/player\"\nimport { VolumeSlider } from \"~/components/ui/media/VolumeSlider\"\nimport type { NavigateEntryOptions } from \"~/hooks/biz/useNavigateEntry\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport type { FeedIconEntry } from \"~/modules/feed/feed-icon\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\n\nimport { COMMAND_ID } from \"../command/commands/id\"\n\nconst handleClickPlay = () => {\n  AudioPlayer.togglePlayAndPause()\n}\n\nconst setNowPlaying = (metadata: MediaMetadataInit) => {\n  if (\"mediaSession\" in navigator) {\n    navigator.mediaSession.metadata = new MediaMetadata(metadata)\n  }\n}\n\ninterface ControlButtonProps {\n  className?: string\n  hideControls?: boolean\n  rounded?: boolean\n}\n\nexport const CornerPlayer = ({ className, ...rest }: ControlButtonProps) => {\n  const show = useAudioPlayerAtomSelector((v) => v.show)\n  const entryId = useAudioPlayerAtomSelector((v) => v.entryId)\n  const entry = useEntry(entryId, (state) => ({ feedId: state.feedId }))\n  const feed = useFeedById(entry?.feedId)\n\n  return (\n    <AnimatePresence>\n      {show && entry && feed && (\n        <m.div\n          key=\"corner-player\"\n          className={cn(\"group relative z-10 !my-0 w-full pr-px\", className)}\n          initial={{ y: 50, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          exit={{ y: 50, opacity: 0 }}\n          transition={Spring.presets.snappy}\n          onClick={(e) => e.stopPropagation()}\n        >\n          <CornerPlayerImpl {...rest} />\n        </m.div>\n      )}\n    </AnimatePresence>\n  )\n}\n\nconst usePlayerTracker = () => {\n  const playerOpenAt = useState(Date.now)[0]\n  const show = useAudioPlayerAtomSelector((v) => v.show)\n\n  useEffect(() => {\n    const handler = () => {\n      const playerState = getAudioPlayerAtomValue()\n\n      tracker.playerOpenDuration({\n        duration: Date.now() - playerOpenAt,\n        status: playerState.status,\n        trigger: \"beforeunload\",\n      })\n    }\n\n    window.addEventListener(\"beforeunload\", handler)\n    return () => window.removeEventListener(\"beforeunload\", handler)\n  }, [playerOpenAt])\n\n  useEffect(() => {\n    if (!show) {\n      const playerState = getAudioPlayerAtomValue()\n      tracker.playerOpenDuration({\n        duration: Date.now() - playerOpenAt,\n        status: playerState.status,\n        trigger: \"manual\",\n      })\n    }\n  }, [playerOpenAt, show])\n}\nconst CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => {\n  const isMobile = useMobile()\n\n  const { t } = useTranslation()\n  const entryId = useAudioPlayerAtomSelector((v) => v.entryId)\n  const status = useAudioPlayerAtomSelector((v) => v.status)\n  const isMute = useAudioPlayerAtomSelector((v) => v.isMute)\n  const listId = useAudioPlayerAtomSelector((v) => v.listId)\n\n  const playerValue = { entryId, status, isMute }\n\n  const entry = useEntry(playerValue.entryId, (state) => {\n    const { feedId, inboxHandle } = state\n    const { authorAvatar, id, title } = state\n\n    const media = state.media || []\n    const firstMedia = media[0]\n    const firstPhoto = media.find((a) => a.type === \"photo\")\n    const firstPhotoUrl = firstPhoto?.url\n    const entryCoverImage = firstMedia?.preview_image_url || firstMedia?.url || firstPhotoUrl\n    const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar }\n\n    return {\n      authorAvatar,\n      feedId,\n      iconEntry,\n      id,\n      inboxId: inboxHandle,\n      title,\n      entryCoverImage,\n    }\n  })\n  const isInbox = !!entry?.inboxId\n  const feed = useFeedById(entry?.feedId)\n  const subscription = useSubscriptionByFeedId(entry?.feedId)\n  const list = useListById(listId)\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.global.toggleCornerPlay, () => {\n      handleClickPlay()\n    })\n  }, [])\n\n  useEffect(() => {\n    const coverImage = entry?.entryCoverImage || feed?.image\n\n    setNowPlaying({\n      title: entry?.title || undefined,\n      artist: feed?.title || undefined,\n      album: coverImage || undefined,\n      artwork: [\n        {\n          src: coverImage || \"\",\n        },\n      ],\n    })\n  }, [entry, feed])\n\n  useEffect(() => {\n    navigator.mediaSession.setActionHandler(\"play\", handleClickPlay)\n    navigator.mediaSession.setActionHandler(\"pause\", handleClickPlay)\n\n    return () => {\n      navigator.mediaSession.setActionHandler(\"play\", null)\n      navigator.mediaSession.setActionHandler(\"pause\", null)\n    }\n  }, [])\n\n  const navigateToEntry = useNavigateEntry()\n  usePlayerTracker()\n\n  const navigateOptions = useMemo<NavigateEntryOptions | null>(() => {\n    if (!entry) return null\n    const options: NavigateEntryOptions = {\n      entryId: entry.id,\n    }\n    if (isInbox) {\n      Object.assign(options, {\n        inboxId: entry?.feedId,\n        view: FeedViewType.Articles,\n      })\n    } else if (list) {\n      Object.assign(options, {\n        listId: list.id,\n        view: list.view,\n      })\n    } else if (feed) {\n      Object.assign(options, {\n        feedId: feed.id,\n        view: subscription?.view ?? FeedViewType.Audios,\n      })\n    } else {\n      return null\n    }\n    return options\n  }, [entry, feed, isInbox, list, subscription?.view])\n  const [pause, setPause] = useState(true)\n  if (!entry || !feed) return null\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"relative flex w-full border-y bg-white transition-all duration-200 ease-in-out dark:bg-neutral-800\",\n          rounded && \"overflow-hidden rounded-lg border\",\n        )}\n      >\n        {/* play cover */}\n        <div className=\"relative size-[3.625rem] shrink-0\">\n          <FeedIcon\n            target={feed}\n            entry={entry.iconEntry}\n            size={isMobile ? 65.25 : 58}\n            fallback={false}\n            noMargin\n            useMedia\n          />\n          <div\n            className={cn(\n              \"center absolute inset-0 w-full opacity-0 transition-all duration-200 ease-in-out\",\n              isMobile ? \"opacity-100\" : \"group-hover:opacity-100\",\n            )}\n          >\n            <button\n              type=\"button\"\n              className=\"center size-10 rounded-full bg-theme-background opacity-95 hover:bg-accent hover:text-white hover:opacity-100\"\n              onClick={handleClickPlay}\n            >\n              <i\n                className={cn(\"size-6\", {\n                  \"i-mgc-pause-cute-fi\": playerValue.status === \"playing\",\n                  \"i-mgc-loading-3-cute-re animate-spin\": playerValue.status === \"loading\",\n                  \"i-mgc-play-cute-fi\": playerValue.status === \"paused\",\n                })}\n              />\n            </button>\n          </div>\n        </div>\n\n        <div className=\"relative grow truncate px-2 py-1 text-center text-sm\">\n          <Marquee\n            play={playerValue.status === \"playing\" && pause}\n            className=\"mask-horizontal font-medium\"\n            speed={30}\n            gradient={false}\n            onCycleComplete={() => {\n              setPause(false)\n              setTimeout(() => {\n                setPause(true)\n              }, 1000)\n            }}\n          >\n            {`\\u00A0\\u00A0\\u00A0\\u00A0${entry.title}`}\n          </Marquee>\n          <div\n            className={cn(\n              \"mt-0.5 overflow-hidden truncate text-xs text-text-secondary\",\n              !isMobile && \"group-hover:opacity-0\",\n            )}\n          >\n            {feed.title}\n          </div>\n\n          {/* progress control */}\n          <PlayerProgress />\n        </div>\n      </div>\n\n      {/* advanced controls */}\n      {!hideControls && (\n        <div\n          className={cn(\n            \"absolute inset-x-0 top-0 z-[-1] flex justify-between border-t bg-theme-background p-1 opacity-0 transition-all duration-200 ease-in-out\",\n            isMobile\n              ? \"-translate-y-full opacity-100\"\n              : \"group-hover:-translate-y-full group-hover:opacity-100\",\n          )}\n        >\n          <div className=\"flex items-center\">\n            <ActionIcon\n              className=\"i-mgc-close-cute-re\"\n              onClick={() => AudioPlayer.close()}\n              label={t(\"player.close\")}\n            />\n            <ActionIcon\n              className=\"i-mgc-external-link-cute-re\"\n              onClick={() => {\n                if (navigateOptions) {\n                  navigateToEntry(navigateOptions)\n                }\n              }}\n              label={t(\"player.open_entry\")}\n            />\n            <ActionIcon\n              label={t(\"player.download\")}\n              onClick={() => {\n                window.open(AudioPlayer.get().src, \"_blank\")\n              }}\n            >\n              <i className=\"i-mgc-download-2-cute-re\" />\n            </ActionIcon>\n          </div>\n          {/* audio control */}\n          <div className=\"flex items-center\">\n            <ActionIcon label={<PlaybackRateSelector />} labelDelayDuration={0}>\n              <PlaybackRateButton />\n            </ActionIcon>\n            <ActionIcon\n              className={cn(\n                playerValue.isMute\n                  ? \"i-mgc-volume-off-cute-re text-red-500\"\n                  : \"i-mgc-volume-cute-re\",\n              )}\n              onClick={() => AudioPlayer.toggleMute()}\n              label={<CornerPlayerVolumeSlider />}\n              labelDelayDuration={0}\n            />\n            <ActionIcon\n              className=\"i-mgc-back-2-cute-re\"\n              onClick={() => AudioPlayer.back(10)}\n              label={t(\"player.back_10s\")}\n            />\n            <ActionIcon\n              className=\"i-mgc-forward-2-cute-re\"\n              onClick={() => AudioPlayer.forward(10)}\n              label={t(\"player.forward_10s\")}\n              tooltipAlign=\"end\"\n            />\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n\nconst ONE_HOUR_IN_SECONDS = 60 * 60\nexport const PlayerProgress = () => {\n  const isMobile = useMobile()\n  const playerValue = useAudioPlayerAtomValue()\n\n  const { currentTime = 0, duration = 0 } = playerValue\n  const [controlledCurrentTime, setControlledCurrentTime] = useState(currentTime)\n  const [isDraggingProgress, setIsDraggingProgress] = useState(false)\n\n  useEffect(() => {\n    if (isDraggingProgress) return\n    const playerState = getAudioPlayerAtomValue()\n    if (duration > 0 && currentTime >= duration && !playerState.isStream) {\n      AudioPlayer?.pause()\n      AudioPlayer?.seek(duration)\n      return\n    }\n    setControlledCurrentTime(currentTime)\n  }, [currentTime, isDraggingProgress, duration])\n\n  const getTimeIndicator = (time: number) => {\n    return dayjs()\n      .startOf(\"y\")\n      .second(time)\n      .format(time > ONE_HOUR_IN_SECONDS ? \"H:mm:ss\" : \"m:ss\")\n  }\n\n  const currentTimeIndicator = getTimeIndicator(controlledCurrentTime)\n  const remainingTimeIndicator = duration\n    ? getTimeIndicator(duration - controlledCurrentTime)\n    : null\n\n  return (\n    <div className=\"relative mt-2\">\n      <div\n        className={cn(\n          \"absolute bottom-2 flex w-full items-center justify-between text-theme-disabled opacity-0 duration-150 ease-in-out\",\n          isMobile ? \"opacity-100\" : \"group-hover:opacity-100\",\n        )}\n      >\n        <div className=\"text-xs\">{currentTimeIndicator}</div>\n        {!!remainingTimeIndicator && <div className=\"text-xs\">-{remainingTimeIndicator}</div>}\n      </div>\n\n      {/* slider */}\n      {!!duration && (\n        <Slider.Root\n          className=\"relative flex h-1 w-full items-center transition-all duration-200 ease-in-out\"\n          min={0}\n          max={duration}\n          step={1}\n          value={[controlledCurrentTime]}\n          onPointerDown={() => setIsDraggingProgress(true)}\n          onPointerUp={() => setIsDraggingProgress(false)}\n          onValueChange={(value) => setControlledCurrentTime(value[0]!)}\n          onValueCommit={(value) => AudioPlayer.seek(value[0]!)}\n        >\n          <Slider.Track className=\"relative h-1 w-full grow rounded bg-gray-200 duration-200 group-hover:bg-gray-300 dark:bg-neutral-700 group-hover:dark:bg-neutral-600\">\n            <Slider.Range className=\"absolute h-1 rounded bg-accent/80\" />\n          </Slider.Track>\n\n          {/* indicator */}\n          <Slider.Thumb\n            className=\"block h-2 w-[3px] rounded-[1px] bg-accent\"\n            aria-label=\"Progress\"\n          />\n        </Slider.Root>\n      )}\n    </div>\n  )\n}\n\nconst ActionIcon = ({\n  className,\n  onClick,\n  label,\n  labelDelayDuration = 700,\n  tooltipAlign,\n  children,\n}: {\n  className?: string\n  onClick?: () => void\n  label: React.ReactNode\n  labelDelayDuration?: number\n  tooltipAlign?: \"center\" | \"end\" | \"start\"\n  children?: React.ReactNode\n}) => (\n  <Tooltip delayDuration={labelDelayDuration}>\n    <TooltipTrigger\n      className=\"center size-6 rounded-md text-zinc-500 hover:bg-material-ultra-thick\"\n      onClick={onClick}\n      asChild\n    >\n      <button type=\"button\">{children || <i aria-hidden className={className} />}</button>\n    </TooltipTrigger>\n    <TooltipContent align={tooltipAlign}>{label}</TooltipContent>\n  </Tooltip>\n)\n\nconst CornerPlayerVolumeSlider = () => {\n  const volume = useAudioPlayerAtomSelector((v) => v.volume)\n\n  return <VolumeSlider volume={volume!} onVolumeChange={AudioPlayer.setVolume.bind(AudioPlayer)} />\n}\n\nconst PlaybackRateSelector = () => {\n  const playbackRate = useAudioPlayerAtomSelector((v) => v.playbackRate)\n\n  return (\n    <div className=\"flex flex-col items-center gap-0.5\">\n      {[0.5, 0.75, 1, 1.25, 1.5, 2].map((rate) => (\n        <button\n          key={rate}\n          type=\"button\"\n          className={cn(\n            \"center rounded-md p-1 font-mono hover:bg-theme-item-hover\",\n            playbackRate === rate && \"bg-theme-item-hover text-text\",\n            playbackRate !== rate && \"text-text-secondary\",\n          )}\n          onClick={() => AudioPlayer.setPlaybackRate(rate)}\n        >\n          {rate.toFixed(2)}x\n        </button>\n      ))}\n    </div>\n  )\n}\n\nconst PlaybackRateButton = () => {\n  const playbackRate = useAudioPlayerAtomSelector((v) => v.playbackRate)\n\n  const char = `${playbackRate || 1}`\n  return (\n    <span className={cn(char.length > 1 ? \"text-[9px]\" : \"text-xs\", \"block font-mono\")}>\n      {char}x\n    </span>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/player/entry-tts.ts",
    "content": "import { AudioPlayer, getAudioPlayerAtomValue, setAudioPlayerAtomValue } from \"~/atoms/player\"\nimport { followClient } from \"~/lib/api-client\"\nimport { toastFetchError } from \"~/lib/error-parser\"\n\nconst TTS_MIME_FALLBACK = \"audio/ogg; codecs=opus\"\nconst STREAM_PLACEHOLDER_SRC = \"about:blank\"\n\nlet activeTtsAbortController: AbortController | null = null\nlet activeTtsCleanup: (() => void) | null = null\n\ntype TtsAudioHandle = {\n  url: string\n  cleanup: () => void\n}\n\ntype AudioContextConstructor =\n  | (new (contextOptions?: AudioContextOptions | undefined) => AudioContext)\n  | null\n\nconst getAudioContextConstructor = (): AudioContextConstructor => {\n  if (typeof window === \"undefined\") {\n    return null\n  }\n\n  const ctor =\n    window.AudioContext ??\n    (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext\n\n  return ctor ?? null\n}\n\nconst getTtsMimeType = (response: Response) =>\n  response.headers.get(\"content-type\") ?? TTS_MIME_FALLBACK\n\nconst concatChunks = (chunks: Uint8Array[], totalLength: number) => {\n  const merged = new Uint8Array(totalLength)\n  let offset = 0\n\n  for (const chunk of chunks) {\n    merged.set(chunk, offset)\n    offset += chunk.length\n  }\n\n  return merged.buffer\n}\n\nconst createBufferedTtsHandle = async (\n  response: Response,\n  signal: AbortSignal,\n): Promise<TtsAudioHandle> => {\n  const buffer = await response.arrayBuffer()\n  if (signal.aborted) {\n    throw new DOMException(\"Aborted\", \"AbortError\")\n  }\n\n  const blob = new Blob([buffer], {\n    type: getTtsMimeType(response),\n  })\n\n  const objectUrl = URL.createObjectURL(blob)\n\n  return {\n    url: objectUrl,\n    cleanup: () => {\n      URL.revokeObjectURL(objectUrl)\n    },\n  }\n}\n\nconst createAudioContextStreamingHandle = (\n  response: Response,\n  entryId: string,\n  signal: AbortSignal,\n) => {\n  const AudioContextCtor = getAudioContextConstructor()\n  if (!AudioContextCtor || !response.body) {\n    return null\n  }\n\n  const audioContext = new AudioContextCtor()\n  const destination = audioContext.createMediaStreamDestination()\n  const audioElement = AudioPlayer.audio\n  const previousSrcObject = audioElement.srcObject\n  const reader = response.body.getReader()\n  const chunks: Uint8Array[] = []\n  let totalLength = 0\n  let decodedDuration = 0\n  let scheduledTime = audioContext.currentTime\n  let playbackStartTime = scheduledTime\n  let progressTimer: number | null = null\n  let closed = false\n  let decodePromise: Promise<void> | null = null\n  let pendingDecode = false\n\n  const updateProgress = () => {\n    if (closed) {\n      return\n    }\n\n    const played = Math.max(0, audioContext.currentTime - playbackStartTime)\n    const playerState = getAudioPlayerAtomValue()\n\n    if (playerState.entryId !== entryId) {\n      return\n    }\n\n    audioElement.currentTime = played\n    setAudioPlayerAtomValue({\n      ...playerState,\n      currentTime: played,\n    })\n  }\n\n  const cleanup = () => {\n    if (closed) {\n      return\n    }\n\n    closed = true\n    void reader.cancel().catch(() => {})\n    audioElement.removeEventListener(\"play\", onPlay)\n    audioElement.removeEventListener(\"pause\", onPause)\n\n    if (audioElement.srcObject === destination.stream) {\n      audioElement.srcObject = previousSrcObject ?? null\n      if (!previousSrcObject) {\n        audioElement.src = \"\"\n        audioElement.load()\n      }\n    }\n\n    if (progressTimer !== null) {\n      window.clearInterval(progressTimer)\n      progressTimer = null\n    }\n\n    const playerState = getAudioPlayerAtomValue()\n    if (playerState.entryId === entryId) {\n      setAudioPlayerAtomValue({\n        ...playerState,\n        isStream: false,\n      })\n    }\n\n    void audioContext.close().catch(() => {})\n  }\n\n  const onPlay = () => {\n    void audioContext.resume().catch(() => {})\n  }\n\n  const onPause = () => {\n    void audioContext.suspend().catch(() => {})\n  }\n\n  audioElement.addEventListener(\"play\", onPlay)\n  audioElement.addEventListener(\"pause\", onPause)\n  audioElement.srcObject = destination.stream\n  audioElement.src = \"\"\n  audioElement.currentTime = 0\n  audioElement.load()\n  void audioElement.play().catch(() => {})\n  void audioContext.resume().catch(() => {})\n\n  setAudioPlayerAtomValue({\n    ...getAudioPlayerAtomValue(),\n    isStream: true,\n  })\n\n  const scheduleFromDecodedBuffer = (buffer: AudioBuffer) => {\n    const totalDuration = buffer.duration\n    const newDuration = totalDuration - decodedDuration\n    if (newDuration <= 0) {\n      decodedDuration = Math.max(decodedDuration, totalDuration)\n      return\n    }\n\n    const { sampleRate } = buffer\n    const startSample = Math.floor(decodedDuration * sampleRate)\n    const endSample = Math.floor(totalDuration * sampleRate)\n    const frameCount = endSample - startSample\n    if (frameCount <= 0) {\n      return\n    }\n\n    const segmentBuffer = audioContext.createBuffer(buffer.numberOfChannels, frameCount, sampleRate)\n    for (let channel = 0; channel < buffer.numberOfChannels; channel++) {\n      const channelData = new Float32Array(frameCount)\n      buffer.copyFromChannel(channelData, channel, startSample)\n      segmentBuffer.copyToChannel(channelData, channel, 0)\n    }\n\n    const source = audioContext.createBufferSource()\n    source.buffer = segmentBuffer\n    source.connect(destination)\n    source.start(scheduledTime)\n\n    if (decodedDuration === 0) {\n      playbackStartTime = scheduledTime\n      if (progressTimer === null) {\n        progressTimer = window.setInterval(updateProgress, 250)\n      }\n    }\n\n    scheduledTime += frameCount / sampleRate\n    decodedDuration = totalDuration\n\n    const playerState = getAudioPlayerAtomValue()\n    if (playerState.entryId === entryId) {\n      setAudioPlayerAtomValue({\n        ...playerState,\n        duration: decodedDuration,\n        status: \"playing\",\n        isStream: true,\n      })\n    }\n  }\n\n  const decodeChunks = async () => {\n    if (signal.aborted || closed) {\n      return\n    }\n\n    const merged = concatChunks(chunks, totalLength)\n    let decoded: AudioBuffer\n    try {\n      decoded = await audioContext.decodeAudioData(merged.slice(0))\n    } catch {\n      return\n    }\n\n    scheduleFromDecodedBuffer(decoded)\n  }\n\n  const requestDecode = () => {\n    if (decodePromise) {\n      pendingDecode = true\n      return\n    }\n\n    decodePromise = decodeChunks()\n      .catch(() => {\n        // ignore decoding errors, will retry with more data\n      })\n      .finally(() => {\n        decodePromise = null\n        if (pendingDecode) {\n          pendingDecode = false\n          requestDecode()\n        }\n      })\n  }\n\n  const processStream = async () => {\n    try {\n      while (!closed) {\n        if (signal.aborted) {\n          break\n        }\n\n        const { done, value } = await reader.read()\n        if (done) {\n          break\n        }\n\n        if (value) {\n          chunks.push(value)\n          totalLength += value.length\n          requestDecode()\n        }\n      }\n\n      requestDecode()\n      if (decodePromise) {\n        await decodePromise\n      }\n\n      while (!closed && audioContext.currentTime < scheduledTime) {\n        await new Promise((resolve) => window.setTimeout(resolve, 200))\n      }\n\n      const playerState = getAudioPlayerAtomValue()\n      if (playerState.entryId === entryId) {\n        setAudioPlayerAtomValue({\n          ...playerState,\n          currentTime: decodedDuration,\n          status: \"paused\",\n        })\n      }\n    } finally {\n      cleanup()\n    }\n  }\n\n  void processStream()\n\n  return {\n    cleanup,\n  }\n}\n\nconst resetActiveTtsCleanup = () => {\n  const cleanup = activeTtsCleanup\n  activeTtsCleanup = null\n  cleanup?.()\n}\n\nconst stopActiveTts = () => {\n  activeTtsAbortController?.abort()\n  activeTtsAbortController = null\n  resetActiveTtsCleanup()\n}\n\nexport const playEntryTts = async (entryId: string, { toastTitle }: { toastTitle: string }) => {\n  stopActiveTts()\n\n  const abortController = new AbortController()\n  activeTtsAbortController = abortController\n\n  try {\n    const response = await followClient.api.ai.tts({ entryId }, { signal: abortController.signal })\n\n    let handled = false\n    if (response.body && getAudioContextConstructor()) {\n      AudioPlayer.mount({\n        type: \"audio\",\n        entryId,\n        src: STREAM_PLACEHOLDER_SRC,\n        currentTime: 0,\n      })\n\n      const streamingHandle = createAudioContextStreamingHandle(\n        response,\n        entryId,\n        abortController.signal,\n      )\n\n      if (streamingHandle) {\n        activeTtsCleanup = streamingHandle.cleanup\n        handled = true\n      }\n    }\n\n    if (!handled) {\n      const bufferedHandle = await createBufferedTtsHandle(response, abortController.signal)\n      AudioPlayer.mount({\n        type: \"audio\",\n        entryId,\n        src: bufferedHandle.url,\n        currentTime: 0,\n      })\n      activeTtsCleanup = bufferedHandle.cleanup\n    }\n  } catch (error) {\n    if (abortController.signal.aborted) {\n      return\n    }\n\n    toastFetchError(error as Error, { title: toastTitle })\n  } finally {\n    if (activeTtsAbortController === abortController) {\n      activeTtsAbortController = null\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/my-wallet-section/create-wallet.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Trans, useTranslation } from \"react-i18next\"\n\nimport { useCreateWalletMutation } from \"~/queries/wallet\"\n\nexport const CreateWallet = () => {\n  const mutation = useCreateWalletMutation()\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <div className=\"rounded-2xl border border-fill-secondary bg-material-ultra-thin p-6 shadow-sm\">\n      <div className=\"flex flex-col items-start gap-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"space-y-3\">\n          <div className=\"flex size-12 items-center justify-center rounded-2xl bg-fill-quaternary text-folo\">\n            <i className=\"i-mgc-power text-2xl\" />\n          </div>\n          <p className=\"max-w-2xl text-base text-text-secondary\">\n            <Trans\n              i18nKey=\"wallet.create.description\"\n              ns=\"settings\"\n              components={{\n                PowerIcon: <i className=\"i-mgc-power translate-y-[2px] text-folo\" />,\n                strong: <strong className=\"text-text\" />,\n              }}\n            />\n          </p>\n        </div>\n\n        <div className=\"shrink-0\">\n          <Button\n            variant=\"primary\"\n            isLoading={mutation.isPending}\n            onClick={() => mutation.mutate()}\n          >\n            {t(\"wallet.create.button\")}\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx",
    "content": "import { LoadingWithIcon } from \"@follow/components/ui/loading/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { SettingSectionTitle } from \"~/modules/settings/section\"\nimport { Balance } from \"~/modules/wallet/balance\"\nimport { useWallet } from \"~/queries/wallet\"\n\nimport { CreateWallet } from \"./create-wallet\"\nimport { WithdrawButton } from \"./withdraw\"\n\nexport const MyWalletSection = ({ className }: { className?: string }) => {\n  const { t } = useTranslation(\"settings\")\n  const wallet = useWallet()\n  const myWallet = wallet.data?.[0]\n\n  if (wallet.isPending) {\n    return (\n      <div className=\"center absolute inset-0 flex\">\n        <LoadingWithIcon\n          icon={<i className=\"i-mgc-power text-folo\" />}\n          size=\"large\"\n          className=\"-translate-y-full\"\n        />\n      </div>\n    )\n  }\n\n  if (!myWallet) {\n    return <CreateWallet />\n  }\n  return (\n    <div\n      className={cn(\n        \"rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm\",\n        className,\n      )}\n    >\n      <SettingSectionTitle title={t(\"wallet.balance.title\")} margin=\"compact\" />\n      <div className=\"flex items-start justify-between gap-4\">\n        <div>\n          <div className=\"flex items-center gap-1\">\n            <Balance className=\"text-xl font-bold text-folo\">\n              {BigInt(myWallet.powerToken || 0n)}\n            </Balance>\n          </div>\n          <Tooltip>\n            <TooltipTrigger className=\"mt-2 block\">\n              <div className=\"flex flex-row items-center gap-x-2 text-xs\">\n                <span className=\"flex items-center gap-1 text-left\">\n                  {t(\"wallet.balance.withdrawable\")} <i className=\"i-mgc-question-cute-re\" />\n                </span>\n                <Balance className=\"center text-[12px] font-medium\">\n                  {myWallet.cashablePowerToken}\n                </Balance>\n              </div>\n            </TooltipTrigger>\n            <TooltipPortal>\n              <TooltipContent align=\"start\" className=\"z-[999]\">\n                <p>{t(\"wallet.balance.withdrawableTooltip\")}</p>\n              </TooltipContent>\n            </TooltipPortal>\n          </Tooltip>\n        </div>\n        <div className=\"flex gap-2\">\n          <WithdrawButton />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { from, toNumber } from \"dnum\"\nimport { useEffect } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAuthQuery } from \"~/hooks/common/useBizQuery\"\nimport { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\nimport { useTOTPModalWrapper } from \"~/modules/profile/hooks\"\nimport { Balance } from \"~/modules/wallet/balance\"\nimport { useWallet, wallet as walletActions } from \"~/queries/wallet\"\n\nexport const WithdrawButton = () => {\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n\n  const onClick = () => {\n    present({\n      title: t(\"wallet.withdraw.modalTitle\"),\n      content: ({ dismiss }) => <WithdrawModalContent dismiss={dismiss} />,\n    })\n  }\n\n  return (\n    <Button variant=\"outline\" onClick={onClick}>\n      {t(\"wallet.withdraw.button\")}\n    </Button>\n  )\n}\n\nconst WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {\n  const { t } = useTranslation(\"settings\")\n  const wallet = useWallet()\n  const cashablePowerTokenBigInt = [BigInt(wallet.data?.[0]!.cashablePowerToken || 0n), 18] as const\n  const cashablePowerTokenNumber = toNumber(cashablePowerTokenBigInt)\n\n  const formSchema = z.object({\n    address: z.string().startsWith(\"0x\").length(42),\n    amount: z.number().positive().max(cashablePowerTokenNumber),\n    toRss3: z.boolean().optional(),\n  })\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n  })\n\n  const powerPrice = useAuthQuery(\n    defineQuery([\"power-price\"], async () => {\n      const res = await followClient.api.wallets.powerPrice()\n      return res.data\n    }),\n  )\n\n  const mutation = useMutation({\n    mutationFn: async ({\n      address,\n      amount,\n      toRss3,\n      TOTPCode,\n    }: {\n      address: string\n      amount: number\n      toRss3?: boolean\n      TOTPCode?: string\n    }) => {\n      const amountBigInt = from(amount, 18)[0]\n      await followClient.api.wallets.transactions.withdraw({\n        address,\n        amount: amountBigInt.toString(),\n        toRss3,\n        TOTPCode,\n      })\n    },\n  })\n  const present = useTOTPModalWrapper(mutation.mutateAsync, { force: true })\n\n  const onSubmit = (values: z.infer<typeof formSchema>) => {\n    present(values)\n  }\n\n  useEffect(() => {\n    if (mutation.isError) {\n      toast.error(t(\"wallet.withdraw.error\", { error: mutation.error?.message }))\n    }\n  }, [mutation.isError, t])\n\n  useEffect(() => {\n    if (mutation.isSuccess) {\n      toast.success(t(\"wallet.withdraw.success\"))\n      walletActions.get().invalidate()\n      walletActions.transactions.get({}).invalidate()\n      dismiss()\n    }\n  }, [mutation.isSuccess, t, dismiss])\n\n  return (\n    <>\n      <div className={cn(!cashablePowerTokenNumber && \"text-orange-700\", \"mb-4 text-sm\")}>\n        <Trans\n          i18nKey=\"wallet.withdraw.availableBalance\"\n          components={{\n            Balance: (\n              <Balance className=\"inline-block\" value={wallet.data?.[0]!.cashablePowerToken || \"0\"}>\n                {wallet.data?.[0]!.cashablePowerToken || \"0\"}\n              </Balance>\n            ),\n          }}\n          ns=\"settings\"\n        />\n      </div>\n      <Form {...form}>\n        <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4 lg:w-96\">\n          <FormField\n            control={form.control}\n            name=\"address\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t(\"wallet.withdraw.addressLabel\")}</FormLabel>\n                <FormControl>\n                  <Input autoFocus {...field} placeholder=\"0x...\" />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"amount\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t(\"wallet.withdraw.amountLabel\")}</FormLabel>\n                <FormControl>\n                  <Input\n                    {...field}\n                    type=\"number\"\n                    inputMode=\"numeric\"\n                    pattern=\"[0-9]*\"\n                    onChange={(value) => field.onChange(value.target.valueAsNumber)}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"toRss3\"\n            render={({ field }) => (\n              <FormItem>\n                <div className=\"flex items-center gap-2\">\n                  <FormLabel className=\"flex items-center gap-1\">\n                    {t(\"wallet.withdraw.toRss3Label\")}\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <i className=\"i-mgc-question-cute-re text-sm\" />\n                      </TooltipTrigger>\n                      <TooltipPortal>\n                        <TooltipContent>\n                          <span className=\"text-xs text-gray-500\">\n                            1 POWER = {powerPrice.data?.rss3 ?? \"-\"} RSS3\n                          </span>\n                        </TooltipContent>\n                      </TooltipPortal>\n                    </Tooltip>\n                  </FormLabel>\n                  <FormControl className=\"!mt-0\">\n                    <span className=\"inline-flex\">\n                      <Switch checked={field.value} onCheckedChange={field.onChange} />\n                    </span>\n                  </FormControl>\n                </div>\n                {field.value && (\n                  <span className=\"text-xs text-gray-500\">\n                    {t(\"wallet.withdraw.receiveRSS3\", {\n                      amount: ((form.watch(\"amount\") || 0) * (powerPrice.data?.rss3 ?? 0)).toFixed(\n                        4,\n                      ),\n                    })}\n                  </span>\n                )}\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <div className=\"center flex\">\n            <Button disabled={!form.formState.isValid} type=\"submit\" isLoading={mutation.isPending}>\n              {t(\"wallet.withdraw.submitButton\")}\n            </Button>\n          </div>\n        </form>\n      </Form>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx",
    "content": "import { LoadingCircle } from \"@follow/components/ui/loading/index.js\"\nimport { Tabs, TabsList, TabsTrigger } from \"@follow/components/ui/tabs/index.jsx\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { TransactionTypes } from \"@follow-app/client-sdk\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { getBlockchainExplorerUrl } from \"~/lib/utils\"\nimport { SettingSectionTitle } from \"~/modules/settings/section\"\nimport { useWallet, useWalletTransactions } from \"~/queries/wallet\"\n\nimport { TxTable } from \"./tx-table\"\n\nconst tabs = [\"all\", ...TransactionTypes] as const\n\nexport const TransactionsSection: Component = ({ className }) => {\n  const { t } = useTranslation(\"settings\")\n  const user = useWhoami()\n  const wallet = useWallet()\n  const myWallet = wallet.data?.[0]\n\n  const [type, setType] = useState(\"all\")\n\n  const transactions = useWalletTransactions({\n    fromOrToUserId: user?.id,\n    type: type === \"all\" ? undefined : (type as (typeof TransactionTypes)[number]),\n  })\n\n  if (!myWallet) return null\n\n  const hasTransactions = Boolean(transactions.data?.length)\n\n  return (\n    <div\n      className={cn(\n        \"relative flex min-w-0 grow flex-col rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm\",\n        className,\n      )}\n    >\n      <SettingSectionTitle title={t(\"wallet.transactions.title\")} />\n      <Tabs value={type} onValueChange={(val) => setType(val)}>\n        <TabsList className=\"relative border-b-transparent\">\n          {tabs.map((tab) => (\n            <TabsTrigger key={tab} value={tab} className=\"py-0\">\n              {t(`wallet.transactions.types.${tab}`)}\n            </TabsTrigger>\n          ))}\n        </TabsList>\n      </Tabs>\n      {hasTransactions ? <TxTable type={type} /> : null}\n      {hasTransactions && (\n        <a\n          className=\"my-2 w-full text-sm text-zinc-400 underline\"\n          href={`${getBlockchainExplorerUrl()}/address/${myWallet.address}`}\n          target=\"_blank\"\n        >\n          {t(\"wallet.transactions.more\")}\n        </a>\n      )}\n\n      {(transactions.isFetching || !hasTransactions) && (\n        <div className=\"my-4 flex w-full justify-center text-sm text-zinc-400\">\n          {transactions.isFetching ? (\n            <LoadingCircle size=\"medium\" />\n          ) : (\n            <div className=\"flex min-h-56 w-full flex-col items-center justify-center rounded-xl border border-dashed border-border bg-background/60 px-6 text-center\">\n              <i className=\"i-mgc-power mb-3 text-4xl text-text-quaternary\" />\n              <p className=\"text-sm font-medium text-text\">\n                {t(\"wallet.transactions.empty.title\")}\n              </p>\n              <p className=\"mt-1 max-w-sm text-sm text-text-secondary\">\n                {t(\"wallet.transactions.empty.description\")}\n              </p>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/transaction-section/index.ts",
    "content": "export { TransactionsSection } from \"./TransactionsSection\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/TxTable.tsx",
    "content": "import {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@follow/components/ui/table/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { TransactionType } from \"@follow-app/client-sdk\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\nimport { getBlockchainExplorerUrl } from \"~/lib/utils\"\nimport { useWalletTransactions } from \"~/queries/wallet\"\n\nimport type { TxTableProps } from \"./components\"\nimport { BalanceRenderer, TypeRenderer, UserRenderer } from \"./components\"\n\nexport const TxTable = ({ className, type }: ComponentType<TxTableProps>) => {\n  const { t } = useTranslation(\"settings\")\n  const user = useWhoami()\n  const transactions = useWalletTransactions({\n    fromOrToUserId: user?.id,\n    type: type === \"all\" ? undefined : (type as TransactionType),\n  })\n\n  return (\n    <div className={cn(\"w-fit min-w-0 grow overflow-x-auto\", className)}>\n      <Table className=\"w-full table-fixed\">\n        <TableHeader className=\"sticky top-0 bg-theme-background\">\n          <TableRow className=\"[&_*]:!pl-0 [&_*]:!font-semibold\">\n            <TableHead>{t(\"wallet.transactions.type\")}</TableHead>\n            <TableHead>{t(\"wallet.transactions.amount\")}</TableHead>\n            <TableHead>{t(\"wallet.transactions.from\")}</TableHead>\n            <TableHead>{t(\"wallet.transactions.to\")}</TableHead>\n            <TableHead>{t(\"wallet.transactions.date\")}</TableHead>\n            <TableHead>{t(\"wallet.transactions.tx\")}</TableHead>\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {transactions.data?.map((row) => (\n            <TableRow key={row.hash}>\n              <TableCell align=\"left\" size=\"sm\">\n                <TypeRenderer type={row.type} />\n              </TableCell>\n              <TableCell align=\"left\" size=\"sm\">\n                <BalanceRenderer\n                  sign={row.fromUserId === user?.id ? \"-\" : \"+\"}\n                  amount={row.powerToken}\n                  tax={row.tax}\n                />\n              </TableCell>\n              <TableCell align=\"left\" size=\"sm\">\n                <UserRenderer user={row.fromUser} />\n              </TableCell>\n              <TableCell align=\"left\" size=\"sm\">\n                <UserRenderer user={row.toUser} />\n              </TableCell>\n\n              <TableCell align=\"left\" size=\"sm\">\n                <EllipsisHorizontalTextWithTooltip>\n                  <RelativeTime date={row.createdAt} dateFormatTemplate=\"l\" />\n                </EllipsisHorizontalTextWithTooltip>\n              </TableCell>\n              <TableCell align=\"left\" size=\"sm\">\n                <Tooltip>\n                  <TooltipTrigger>\n                    <a\n                      target=\"_blank\"\n                      href={`${getBlockchainExplorerUrl()}/tx/${row.hash}`}\n                      className=\"underline\"\n                    >\n                      {row.hash.slice(0, 10)}...\n                    </a>\n                  </TooltipTrigger>\n                  <TooltipPortal>\n                    <TooltipContent>{row.hash}</TooltipContent>\n                  </TooltipPortal>\n                </Tooltip>\n              </TableCell>\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { usePresentUserProfileModal } from \"~/modules/profile/hooks\"\nimport { UserAvatar } from \"~/modules/user/UserAvatar\"\nimport { Balance } from \"~/modules/wallet/balance\"\nimport type { useWalletTransactions } from \"~/queries/wallet\"\n\nexport interface TxTableProps {\n  type: string\n}\nexport const TypeRenderer = ({\n  type,\n}: {\n  type: NonNullable<ReturnType<typeof useWalletTransactions>[\"data\"]>[number][\"type\"]\n}) => {\n  const { t } = useTranslation(\"settings\")\n  return <div className=\"uppercase\">{t(`wallet.transactions.types.${type}`)}</div>\n}\n\nexport const BalanceRenderer = ({\n  sign,\n  amount,\n  tax,\n}: {\n  sign: \"+\" | \"-\"\n  amount: NonNullable<ReturnType<typeof useWalletTransactions>[\"data\"]>[number][\"powerToken\"]\n  tax: NonNullable<ReturnType<typeof useWalletTransactions>[\"data\"]>[number][\"tax\"]\n}) => {\n  const hideTax = sign === \"-\" || tax === \"0\"\n  return (\n    <EllipsisHorizontalTextWithTooltip\n      className={cn(\"flex items-center tabular-nums\", {\n        \"text-green-500\": sign === \"+\",\n        \"text-red-500\": sign === \"-\",\n      })}\n    >\n      {sign}\n      <Balance>{amount}</Balance>\n      {!hideTax && (\n        <span className=\"text-xs text-gray-500\">\n          (-<Balance>{tax}</Balance>)\n        </span>\n      )}\n    </EllipsisHorizontalTextWithTooltip>\n  )\n}\n\nexport const UserRenderer = ({\n  user,\n  hideName,\n  iconClassName,\n}: {\n  user?: NonNullable<ReturnType<typeof useWalletTransactions>[\"data\"]>[number][\n    | \"fromUser\"\n    | \"toUser\"]\n  hideName?: boolean\n  iconClassName?: string\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const me = useWhoami()\n  const isMe = user?.id === me?.id\n\n  const name = isMe ? t(\"wallet.transactions.you\") : user?.name || APP_NAME\n\n  const presentUserModal = usePresentUserProfileModal(\"drawer\")\n  return (\n    <MotionButtonBase\n      onClick={() => {\n        if (user?.id) presentUserModal(user.id)\n      }}\n      className=\"flex w-full min-w-0 cursor-button items-center\"\n    >\n      {name === APP_NAME ? (\n        <Logo className={cn(\"aspect-square size-4\", iconClassName)} />\n      ) : (\n        <UserAvatar\n          userId={user?.id}\n          hideName\n          className=\"h-auto p-0\"\n          avatarClassName={cn(\"size-4\", iconClassName)}\n        />\n      )}\n\n      {!hideName && (\n        <div className=\"ml-1 w-0 grow truncate\">\n          <EllipsisHorizontalTextWithTooltip className=\"text-left\">\n            {isMe ? (\n              <span className=\"font-bold\">{t(\"wallet.transactions.you\")}</span>\n            ) : (\n              <span>{name}</span>\n            )}\n          </EllipsisHorizontalTextWithTooltip>\n        </div>\n      )}\n    </MotionButtonBase>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/index.ts",
    "content": "export { TxTable } from \"./TxTable\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/account-management.tsx",
    "content": "import { styledButtonVariant } from \"@follow/components/ui/button/variants.js\"\nimport { authProvidersConfig } from \"@follow/constants\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { linkSocial, unlinkAccount } from \"~/lib/auth\"\nimport { auth, useSocialAccounts } from \"~/queries/auth\"\nimport { useAuthProviders } from \"~/queries/users\"\n\nfunction AuthProviderButton({ provider }: { provider: string }) {\n  const { t } = useTranslation(\"settings\")\n  const { data: accounts } = useSocialAccounts()\n  const unlinkAccountMutation = useMutation({\n    mutationFn: async () => {\n      const account = accounts?.find((account) => account.provider === provider)\n      if (!account) throw new Error(\"Account not found\")\n      const res = await unlinkAccount({ providerId: provider, accountId: account.accountId })\n      if (res.error) throw new Error(res.error.message)\n    },\n    onSuccess: () => {\n      toast.success(t(\"profile.link_social.unlink.success\"))\n      auth.getAccounts().invalidate()\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n  })\n  const account = accounts?.find((account) => account.provider === provider)\n  if (!account && IN_ELECTRON) return null\n\n  return (\n    <button\n      type=\"button\"\n      key={provider}\n      className={styledButtonVariant({\n        variant: \"outline\",\n      })}\n      onClick={() => {\n        if (!account) {\n          linkSocial({\n            provider: provider as any,\n          })\n          return\n        }\n        unlinkAccountMutation.mutate()\n      }}\n      disabled={unlinkAccountMutation.isPending}\n    >\n      <div className=\"flex items-center gap-2\">\n        <i className={cn(\"text-xl\", authProvidersConfig[provider]?.iconClassName)} />\n        <span>\n          {account\n            ? account.profile?.email || account.profile?.name\n            : t(\"profile.link_social.link\")}\n        </span>\n        {account && <i className=\"i-mgc-delete-2-cute-re\" />}\n      </div>\n    </button>\n  )\n}\n\nexport function AccountManagement() {\n  const { t } = useTranslation(\"settings\")\n\n  const { data: providers } = useAuthProviders()\n\n  return (\n    <div className=\"space-y-2\">\n      <p className=\"text-sm font-semibold\">{t(\"profile.link_social.authentication\")}</p>\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {Object.keys(providers || {})\n          .filter((provider) => provider !== \"credential\")\n          .map((provider) => (\n            <AuthProviderButton key={provider} provider={provider} />\n          ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/email-management.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormMessage,\n} from \"@follow/components/ui/form/index.js\"\nimport { Input } from \"@follow/components/ui/input/Input.js\"\nimport { Label } from \"@follow/components/ui/label/index.js\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { userActions } from \"@follow/store/user/store\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { m } from \"motion/react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { AnimatedCommandButton } from \"~/components/ui/button/AnimatedCommandButton\"\nimport { CopyButton } from \"~/components/ui/button/CopyButton\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { changeEmail, sendVerificationEmail } from \"~/lib/auth\"\n\nconst formSchema = z.object({\n  email: z.string().email(),\n})\n\nexport function EmailManagement() {\n  const user = useWhoami()\n  const isMobile = useMobile()\n  const { t } = useTranslation(\"settings\")\n\n  const verifyEmailMutation = useMutation({\n    mutationFn: async () => {\n      if (!user?.email) return\n      return sendVerificationEmail({\n        email: user.email,\n      })\n    },\n    onSuccess: () => {\n      toast.success(t(\"profile.email.verification_sent\"))\n    },\n  })\n\n  const { present } = useModalStack()\n  return (\n    <>\n      <div className=\"mb-2 flex items-center space-x-1 pl-3\">\n        <Label className=\"text-sm\">{t(\"profile.email.label\")}</Label>\n        <span\n          className={cn(\n            \"rounded-full border px-1 text-[10px] font-semibold\",\n            user?.emailVerified ? \"border-green-500 text-green-500\" : \"border-red-500 text-red-500\",\n          )}\n        >\n          {user?.emailVerified ? t(\"profile.email.verified\") : t(\"profile.email.unverified\")}\n        </span>\n      </div>\n      <div className=\"flex items-center justify-between pl-3\">\n        <div className=\"group flex items-center gap-2 text-text-secondary\">\n          {user?.email}\n\n          <AnimatedCommandButton\n            icon={<m.i className=\"i-mgc-edit-cute-re size-4\" />}\n            className=\"size-5 p-1\"\n            variant=\"ghost\"\n            onClick={() => {\n              present({\n                title: t(\"profile.email.change\"),\n                content: EmailManagementForm,\n              })\n            }}\n          />\n          {user?.email && (\n            <CopyButton\n              value={user.email}\n              className={cn(\n                \"size-5 p-1 duration-300\",\n                !isMobile && \"opacity-0 group-hover:opacity-100\",\n              )}\n            />\n          )}\n        </div>\n\n        {!user?.emailVerified && (\n          <Button\n            variant=\"outline\"\n            type=\"button\"\n            isLoading={verifyEmailMutation.isPending}\n            onClick={() => {\n              verifyEmailMutation.mutate()\n            }}\n            buttonClassName=\"absolute right-20\"\n          >\n            {t(\"profile.email.send_verification\")}\n          </Button>\n        )}\n      </div>\n    </>\n  )\n}\nfunction EmailManagementForm() {\n  const { t } = useTranslation(\"settings\")\n  const user = useWhoami()\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      email: user?.email || \"\",\n    },\n  })\n\n  const updateEmailMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof formSchema>) => {\n      const res = await changeEmail({ newEmail: values.email })\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n    },\n    onSuccess: (_, variables) => {\n      if (user?.emailVerified) {\n        toast.success(t(\"profile.email.changed_verification_sent\"))\n      } else {\n        if (user) {\n          userActions.updateWhoami({\n            email: variables.email,\n          })\n        }\n        form.reset({ email: variables.email })\n        toast.success(t(\"profile.email.changed\"))\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n  })\n\n  return (\n    <Form {...form}>\n      <form\n        onSubmit={form.handleSubmit((values) => {\n          updateEmailMutation.mutate(values)\n        })}\n        className=\"w-[30ch] max-w-full space-y-4\"\n      >\n        <FormField\n          control={form.control}\n          name=\"email\"\n          render={({ field }) => (\n            <FormItem>\n              <FormControl>\n                <div className=\"flex items-center gap-x-2\">\n                  <Input autoFocus {...field} />\n                </div>\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <div className=\"space-x-2 text-right\">\n          <Button\n            type=\"submit\"\n            isLoading={updateEmailMutation.isPending}\n            disabled={updateEmailMutation.isPending || !form.formState.isDirty}\n          >\n            {t(\"profile.email.change\")}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/hooks.ts",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { usePrefetchUser, useWhoami } from \"@follow/store/user/hooks\"\nimport { capitalizeFirstLetter } from \"@follow/utils/utils\"\nimport { createElement, lazy, useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { parse } from \"tldts\"\n\nimport { useAsyncModal } from \"~/components/ui/modal/helper/useAsyncModal\"\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\nimport { getFetchErrorInfo } from \"~/lib/error-parser\"\n\nimport { TOTPForm, TwoFactorForm } from \"./two-factor\"\n\nconst LazyUserProfileModalContent = lazy(() =>\n  import(\"./user-profile-modal\").then((mod) => ({ default: mod.UserProfileModalContent })),\n)\n\nexport const useUserSubscriptionsQuery = (userId: string | undefined) => {\n  const subscriptions = useAuthQuery(\n    defineQuery([\"subscriptions\", \"group\", userId], async () => {\n      const res = await followClient.api.subscriptions.get({ userId })\n      const groupFolder = {} as Record<string, typeof res.data>\n\n      for (const subscription of res.data || []) {\n        if (!subscription.category && \"feeds\" in subscription) {\n          const { siteUrl } = subscription.feeds\n          if (!siteUrl) continue\n          const parsed = parse(siteUrl)\n          parsed.domain && (subscription.category = capitalizeFirstLetter(parsed.domain))\n        }\n        if (subscription.category) {\n          if (!groupFolder[subscription.category]) {\n            groupFolder[subscription.category] = []\n          }\n          groupFolder[subscription.category]!.push(subscription)\n        }\n      }\n\n      return groupFolder\n    }),\n    {\n      enabled: !!userId,\n    },\n  )\n  return subscriptions\n}\n\ntype Variant = \"drawer\" | \"dialog\"\nexport const usePresentUserProfileModal = (variant: Variant = \"dialog\") => {\n  const { present } = useModalStack()\n  const presentAsync = useAsyncModal()\n  return useCallback(\n    (userId: string | undefined, overrideVariant?: Variant) => {\n      if (!userId) return\n      const finalVariant = overrideVariant || variant\n\n      if (isMobile()) {\n        const useDataFetcher = () => {\n          const user = usePrefetchUser(userId)\n          const subscriptions = useUserSubscriptionsQuery(user?.data?.id)\n          return {\n            ...user,\n            isLoading: user.isLoading || subscriptions.isLoading,\n          }\n        }\n        type ResponseType = ReturnType<typeof useDataFetcher>[\"data\"]\n        return presentAsync<ResponseType>({\n          id: `user-profile-${userId}`,\n          title: (data: ResponseType) => `${data?.name}'s Profile`,\n\n          content: () => createElement(LazyUserProfileModalContent, { userId }),\n          useDataFetcher,\n          overlay: true,\n        })\n      }\n\n      present({\n        title: \"User Profile\",\n        id: `user-profile-${userId}`,\n        content: () =>\n          createElement(LazyUserProfileModalContent, {\n            userId,\n            variant: finalVariant,\n          }),\n        CustomModalComponent: PlainModal,\n        clickOutsideToDismiss: true,\n        modal: finalVariant === \"dialog\",\n        overlay: finalVariant === \"dialog\",\n        autoFocus: false,\n        modalContainerClassName:\n          finalVariant === \"drawer\"\n            ? tw`right-4 left-[auto] safe-inset-top-4 bottom-4`\n            : \"overflow-hidden\",\n      })\n    },\n    [present, presentAsync, variant],\n  )\n}\n\nexport function useTOTPModalWrapper<T extends { TOTPCode?: string }>(\n  callback: (input: T) => Promise<any>,\n  options?: { force?: boolean },\n) {\n  const { present } = useModalStack()\n  const { t } = useTranslation(\"settings\")\n  const user = useWhoami()\n  return useCallback(\n    async (input: T) => {\n      const presentTOTPModal = () => {\n        if (!user?.twoFactorEnabled) {\n          toast.error(t(\"profile.two_factor.enable_notice\"))\n          present({\n            title: t(\"profile.two_factor.enable\"),\n            content: TwoFactorForm,\n          })\n          return\n        }\n\n        present({\n          title: t(\"profile.totp_code.title\"),\n          content: ({ dismiss }) => {\n            return createElement(TOTPForm, {\n              async onSubmitMutationFn(values) {\n                await callback({\n                  ...input,\n                  TOTPCode: values.code,\n                })\n                dismiss()\n              },\n            })\n          },\n        })\n      }\n\n      if (options?.force) {\n        presentTOTPModal()\n        return\n      }\n\n      try {\n        await callback(input)\n      } catch (error) {\n        const { code } = getFetchErrorInfo(error as Error)\n        if (code === 4008) {\n          presentTOTPModal()\n        }\n      }\n    },\n    [callback, options?.force, present, t, user?.twoFactorEnabled],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/profile-setting-form.tsx",
    "content": "import { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input, TextArea } from \"@follow/components/ui/input/index.js\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { userActions } from \"@follow/store/user/store\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { AvatarUploadModal } from \"~/components/ui/crop/AvatarUploadModal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { updateUser } from \"~/lib/auth\"\nimport { uploadAvatarBlob } from \"~/lib/avatar-upload\"\nimport { toastFetchError } from \"~/lib/error-parser\"\n\nconst socialLinksSchema = z.object({\n  twitter: z.string().max(32).optional(),\n  github: z.string().max(32).optional(),\n  instagram: z.string().max(32).optional(),\n  facebook: z.string().max(32).optional(),\n  youtube: z.string().max(32).optional(),\n  discord: z.string().max(32).optional(),\n})\n\nconst formSchema = z.object({\n  handle: z.string().max(32).regex(/^\\w+$/).optional(),\n  name: z.string().min(3).max(50),\n  image: z.string().url().or(z.literal(\"\")).optional(),\n  bio: z.string().max(256).optional(),\n  website: z.string().url().max(64).optional().or(z.literal(\"\")),\n  socialLinks: socialLinksSchema.optional(),\n})\n\nconst socialIconClassNames = {\n  twitter: \"i-mgc-twitter-cute-fi\",\n  github: \"i-mgc-github-cute-fi\",\n  instagram: \"i-mingcute-ins-fill\",\n  facebook: \"i-mingcute-facebook-fill\",\n  youtube: \"i-mgc-youtube-cute-fi\",\n  discord: \"i-mingcute-discord-fill\",\n}\n\nconst formItemLabelClassName = tw`pl-3`\n// Extended user type to include the new fields\ntype ExtendedUser = ReturnType<typeof useWhoami> & {\n  bio?: string\n  website?: string\n  socialLinks?: {\n    twitter?: string\n    github?: string\n    instagram?: string\n    facebook?: string\n    youtube?: string\n    discord?: string\n  }\n}\n\nexport const ProfileSettingForm = ({\n  className,\n  buttonClassName,\n  hideAvatar,\n}: {\n  className?: string\n  buttonClassName?: string\n  hideAvatar?: boolean\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const user = useWhoami() as ExtendedUser\n  const { present } = useModalStack()\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      handle: user?.handle || undefined,\n      name: user?.name || \"\",\n      image: user?.image || \"\",\n      bio: user?.bio || \"\",\n      website: user?.website || \"\",\n      socialLinks: {\n        twitter: user?.socialLinks?.twitter || \"\",\n        github: user?.socialLinks?.github || \"\",\n        instagram: user?.socialLinks?.instagram || \"\",\n        facebook: user?.socialLinks?.facebook || \"\",\n        youtube: user?.socialLinks?.youtube || \"\",\n        discord: user?.socialLinks?.discord || \"\",\n      },\n    },\n  })\n\n  const updateMutation = useMutation({\n    mutationFn: (values: Partial<z.infer<typeof formSchema>>) =>\n      updateUser({\n        handle: values.handle,\n        image: values.image,\n        name: values.name,\n        bio: values.bio,\n        website: values.website,\n        socialLinks: values.socialLinks as any,\n      }),\n    onError: (error) => {\n      toastFetchError(error)\n    },\n    onSuccess: (_, variables) => {\n      if (user && variables) {\n        userActions.updateWhoami({ ...variables } as any)\n      }\n      toast(t(\"profile.updateSuccess\"), {\n        duration: 3000,\n      })\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    updateMutation.mutate(values)\n  }\n\n  const handleAvatarUpload = async (blob: Blob) => {\n    try {\n      const imageUrl = await uploadAvatarBlob(blob)\n      form.setValue(\"image\", imageUrl)\n      toast.success(t(\"profile.avatar.uploadSuccess\"))\n      updateMutation.mutate({ image: imageUrl })\n    } catch (error) {\n      console.error(\"Upload error:\", error)\n      toast.error(t(\"profile.avatar.uploadError\"))\n    }\n  }\n\n  const openAvatarUpload = () => {\n    present({\n      title: t(\"profile.avatar.uploadTitle\"),\n      content: ({ dismiss }) => (\n        <AvatarUploadModal\n          maxSizeKB={1024}\n          onConfirm={async (blob) => {\n            await handleAvatarUpload(blob)\n            dismiss()\n          }}\n          onCancel={dismiss}\n        />\n      ),\n    })\n  }\n\n  const socialLinkFields: (keyof z.infer<typeof socialLinksSchema>)[] = [\n    \"twitter\",\n    \"github\",\n    \"instagram\",\n    \"facebook\",\n    \"youtube\",\n    \"discord\",\n  ]\n\n  const socialCopyMap = {\n    twitter: t(\"profile.profile.social_links_twitter\"),\n    github: t(\"profile.profile.social_links_github\"),\n    instagram: t(\"profile.profile.social_links_instagram\"),\n    facebook: t(\"profile.profile.social_links_facebook\"),\n    youtube: t(\"profile.profile.social_links_youtube\"),\n    discord: t(\"profile.profile.social_links_discord\"),\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className={cn(\"mt-4 space-y-4\", className)}>\n        {!hideAvatar && (\n          <FormField\n            control={form.control}\n            name=\"image\"\n            render={({ field }) => (\n              <div className=\"absolute right-0 flex -translate-y-full gap-4\">\n                <FormItem className=\"w-full\">\n                  <FormControl>\n                    <div className=\"flex items-center gap-4\">\n                      <button\n                        type=\"button\"\n                        onClick={openAvatarUpload}\n                        className=\"group relative cursor-pointer transition-all duration-200 hover:opacity-80\"\n                      >\n                        <Avatar className=\"size-16\">\n                          <AvatarImage src={field.value} />\n                          <AvatarFallback>{user?.name?.[0] || \"\"}</AvatarFallback>\n                        </Avatar>\n                        <div className=\"absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity duration-200 group-hover:opacity-100\">\n                          <i className=\"i-mgc-pic-cute-fi text-xl text-white\" />\n                        </div>\n                      </button>\n                    </div>\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              </div>\n            )}\n          />\n        )}\n\n        <FormField\n          control={form.control}\n          name=\"handle\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel className={formItemLabelClassName}>{t(\"profile.handle.label\")}</FormLabel>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormDescription className={formItemLabelClassName}>\n                {t(\"profile.handle.description\")}\n              </FormDescription>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"name\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel className={formItemLabelClassName}>{t(\"profile.name.label\")}</FormLabel>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormDescription className={formItemLabelClassName}>\n                {t(\"profile.name.description\")}\n              </FormDescription>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        {hideAvatar && (\n          <FormField\n            control={form.control}\n            name=\"image\"\n            render={({ field }) => (\n              <div className=\"flex gap-4\">\n                <FormItem className=\"w-full\">\n                  <FormLabel className={formItemLabelClassName}>\n                    {t(\"profile.avatar.label\")}\n                  </FormLabel>\n                  <FormControl>\n                    <div className=\"flex items-center gap-4\">\n                      <Input {...field} />\n                      <button\n                        type=\"button\"\n                        onClick={openAvatarUpload}\n                        className=\"group relative cursor-pointer transition-all duration-200 hover:opacity-80\"\n                      >\n                        <Avatar className=\"size-8\">\n                          <AvatarImage src={field.value} />\n                          <AvatarFallback>{user?.name?.[0] || \"\"}</AvatarFallback>\n                        </Avatar>\n                        <div className=\"absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity duration-200 group-hover:opacity-100\">\n                          <i className=\"i-mgc-pic-cute-fi text-xl text-white\" />\n                        </div>\n                      </button>\n                    </div>\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              </div>\n            )}\n          />\n        )}\n\n        <FormField\n          control={form.control}\n          name=\"bio\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel className={formItemLabelClassName}>{t(\"profile.profile.bio\")}</FormLabel>\n              <FormControl>\n                <TextArea\n                  rounded=\"lg\"\n                  {...field}\n                  placeholder={t(\"profile.profile.bio_placeholder\")}\n                  className=\"min-h-[80px] resize-none p-3 text-sm placeholder:text-text-tertiary\"\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <FormField\n          control={form.control}\n          name=\"website\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel className={formItemLabelClassName}>\n                {t(\"profile.profile.website\")}\n              </FormLabel>\n              <FormControl>\n                <Input type=\"url\" {...field} placeholder=\"https://your-website.com\" />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <div>\n          <FormLabel className={cn(formItemLabelClassName, \"text-sm font-medium\")}>\n            {t(\"profile.profile.social_links\")}\n          </FormLabel>\n          <div className=\"mt-2 grid grid-cols-2 gap-2\">\n            {socialLinkFields.map((social) => (\n              <FormField\n                key={social}\n                control={form.control}\n                name={`socialLinks.${social}`}\n                render={({ field }) => (\n                  <FormItem>\n                    <FormControl>\n                      <label\n                        className={cn(\n                          \"h-9 ring-accent/20 duration-200 focus-within:border-accent/80 focus-within:outline-none focus-within:ring-2\",\n                          \"flex cursor-text items-center gap-2 rounded-lg border border-border bg-theme-background px-3 py-2 transition-colors hover:bg-accent/5 dark:bg-zinc-700/[0.15]\",\n                        )}\n                      >\n                        <i\n                          className={`${socialIconClassNames[social]} shrink-0 text-base text-text-secondary`}\n                        />\n                        <input\n                          {...field}\n                          placeholder={socialCopyMap[social]}\n                          className=\"flex-1 border-0 !bg-transparent p-0 text-sm placeholder:text-text-tertiary focus-visible:ring-0\"\n                        />\n                      </label>\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n            ))}\n          </div>\n        </div>\n\n        <div className={cn(\"text-right\", buttonClassName)}>\n          <Button type=\"submit\" isLoading={updateMutation.isPending}>\n            {t(\"profile.submit\")}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input, InputOTP, InputOTPGroup, InputOTPSlot } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.js\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { userActions } from \"@follow/store/user/store\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { m, useAnimation } from \"motion/react\"\nimport { useState } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport QRCode from \"react-qr-code\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useCurrentModal, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { twoFactor } from \"~/lib/auth\"\nimport { getFetchErrorInfo } from \"~/lib/error-parser\"\nimport { useHasPassword } from \"~/queries/auth\"\n\nimport { NoPasswordHint } from \"./update-password-form\"\n\nconst passwordSchema = z.string().min(8).max(128)\nconst totpCodeSchema = z.string().length(6).regex(/^\\d+$/)\n\nconst passwordFormSchema = z.object({\n  password: passwordSchema,\n})\ntype PasswordFormValues = z.infer<typeof passwordFormSchema>\n\nconst totpFormSchema = z.object({\n  code: totpCodeSchema,\n})\ntype TOTPFormValues = z.infer<typeof totpFormSchema>\n\ntype PasswordFormProps<V> = {\n  onSubmitMutationFn: (values: V) => Promise<void>\n  message?: {\n    placeholder?: string\n    label?: string\n  }\n  onSuccess?: () => void\n}\n\nconst shakeVariants = {\n  shake: {\n    x: [0, -2, 2, -2, 2, 0],\n    transition: {\n      duration: 0.5,\n    },\n  },\n  reset: {\n    x: 0,\n  },\n}\n\nexport function TOTPForm({\n  message,\n  onSubmitMutationFn,\n  onSuccess,\n}: PasswordFormProps<TOTPFormValues>) {\n  const { t } = useTranslation(\"settings\")\n  const controls = useAnimation()\n\n  const form = useForm<TOTPFormValues>({\n    resolver: zodResolver(totpFormSchema),\n    defaultValues: { code: \"\" },\n  })\n\n  const updateMutation = useMutation({\n    mutationFn: onSubmitMutationFn,\n    onError: (error) => {\n      const { code } = getFetchErrorInfo(error)\n      if (error.message === \"invalid two factor authentication\" || code === 4007) {\n        form.resetField(\"code\")\n        form.setError(\"code\", {\n          type: \"manual\",\n          message: t(\"profile.totp_code.invalid\"),\n        })\n        // Avoid calling setFocus right after reset as all input references will be removed by reset API.\n        setTimeout(() => {\n          form.setFocus(\"code\")\n        }, 10)\n        controls.start(\"shake\")\n      }\n    },\n    onSuccess,\n  })\n\n  function onSubmit(values: TOTPFormValues) {\n    updateMutation.mutate(values)\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"w-[35ch] max-w-full space-y-4\">\n        <FormField\n          control={form.control}\n          name={\"code\"}\n          render={({ field }) => (\n            <FormItem className=\"flex flex-col items-center\">\n              <FormLabel className=\"shrink-0\">\n                {message?.label ?? t(\"profile.totp_code.label\")}\n              </FormLabel>\n              <FormControl>\n                <m.div variants={shakeVariants} animate={controls} className=\"flex justify-center\">\n                  <InputOTP\n                    disabled={updateMutation.isPending}\n                    autoFocus\n                    className=\"!w-full\"\n                    maxLength={6}\n                    onComplete={() => form.handleSubmit(onSubmit)()}\n                    {...field}\n                  >\n                    <InputOTPGroup>\n                      <InputOTPSlot index={0} />\n                      <InputOTPSlot index={1} />\n                      <InputOTPSlot index={2} />\n                      <InputOTPSlot index={3} />\n                      <InputOTPSlot index={4} />\n                      <InputOTPSlot index={5} />\n                    </InputOTPGroup>\n                  </InputOTP>\n                </m.div>\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n      </form>\n    </Form>\n  )\n}\n\nexport function PasswordForm({\n  message,\n  onSubmitMutationFn,\n  onSuccess,\n}: PasswordFormProps<PasswordFormValues>) {\n  const { data: hasPassword, isLoading } = useHasPassword()\n  const { t } = useTranslation(\"settings\")\n\n  const form = useForm<PasswordFormValues>({\n    resolver: zodResolver(passwordFormSchema),\n    defaultValues: { password: \"\" },\n  })\n\n  const updateMutation = useMutation({\n    mutationFn: onSubmitMutationFn,\n    onError: (error) => {\n      toast.error(error.message)\n    },\n    onSuccess,\n  })\n\n  function onSubmit(values: PasswordFormValues) {\n    updateMutation.mutate(values)\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"w-[35ch] max-w-full space-y-4\">\n        <FormField\n          control={form.control}\n          name={\"password\"}\n          render={({ field }) => (\n            <FormItem className=\"flex flex-col\">\n              <FormLabel className=\"shrink-0\">\n                {message?.label ?? t(\"profile.current_password.label\")}\n              </FormLabel>\n              <FormControl>\n                <Input\n                  disabled={updateMutation.isPending}\n                  autoFocus\n                  type=\"password\"\n                  placeholder={message?.placeholder ?? t(\"profile.current_password.label\")}\n                  {...field}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        {!hasPassword && !isLoading && <NoPasswordHint i18nKey=\"profile.two_factor.no_password\" />}\n\n        <div className=\"text-right\">\n          <Button type=\"submit\" isLoading={updateMutation.isPending}>\n            {t(\"profile.submit\")}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n\nexport const TwoFactorForm = () => {\n  const { t } = useTranslation(\"settings\")\n  const modal = useCurrentModal()\n  const user = useWhoami()\n  const [totpURI, setTotpURI] = useState(\"\")\n  return totpURI ? (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center justify-center\">\n        <QRCode value={totpURI} className=\"dark:border dark:border-white\" />\n      </div>\n      <TOTPForm\n        message={{\n          label: t(\"profile.totp_code.init\"),\n        }}\n        onSubmitMutationFn={async (values) => {\n          const res = await twoFactor.verifyTotp({ code: values.code })\n          if (res.error) {\n            throw new Error(res.error.message)\n          }\n          toast.success(t(\"profile.two_factor.enabled\"))\n          modal.dismiss()\n          userActions.updateWhoami({ twoFactorEnabled: true })\n        }}\n      />\n    </div>\n  ) : (\n    <PasswordForm\n      onSubmitMutationFn={async (values) => {\n        const res = user?.twoFactorEnabled\n          ? await twoFactor.disable({ password: values.password })\n          : await twoFactor.enable({ password: values.password })\n        if (res.error) {\n          throw new Error(res.error.message)\n        }\n        if (\"totpURI\" in res.data) {\n          setTotpURI(res.data?.totpURI ?? \"\")\n        } else {\n          toast.success(t(\"profile.two_factor.disabled\"))\n          modal.dismiss()\n          userActions.updateWhoami({ twoFactorEnabled: false })\n        }\n      }}\n    />\n  )\n}\n\nexport function TwoFactor() {\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n  const user = useWhoami()\n  const actionTitle = user?.twoFactorEnabled\n    ? t(\"profile.two_factor.disable\")\n    : t(\"profile.two_factor.enable\")\n\n  const { data: hasPassword, isLoading } = useHasPassword()\n\n  return (\n    <div className=\"flex items-center justify-between\">\n      <Label>{t(\"profile.two_factor.label\")}</Label>\n      {isLoading ? null : hasPassword ? (\n        <Button\n          variant=\"outline\"\n          onClick={() => {\n            present({\n              title: actionTitle,\n              content: TwoFactorForm,\n            })\n          }}\n        >\n          {actionTitle}\n        </Button>\n      ) : (\n        <NoPasswordHint i18nKey=\"profile.two_factor.no_password\" />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/update-password-form.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.js\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { changePassword } from \"~/lib/auth\"\nimport { useHasPassword } from \"~/queries/auth\"\n\nconst passwordSchema = z.string().min(8).max(128)\n\nconst updatePasswordFormSchema = z\n  .object({\n    currentPassword: passwordSchema,\n    newPassword: passwordSchema,\n    confirmPassword: passwordSchema,\n  })\n  .refine((data) => data.newPassword === data.confirmPassword, {\n    message: \"Passwords don't match\",\n    path: [\"confirmPassword\"],\n  })\n\nconst UpdateExistingPasswordForm = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const { t: tApp } = useTranslation(\"app\")\n\n  const form = useForm<z.infer<typeof updatePasswordFormSchema>>({\n    resolver: zodResolver(updatePasswordFormSchema),\n    defaultValues: {\n      currentPassword: \"\",\n      newPassword: \"\",\n      confirmPassword: \"\",\n    },\n  })\n\n  const updateMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof updatePasswordFormSchema>) => {\n      const res = await changePassword({\n        currentPassword: values.currentPassword,\n        newPassword: values.newPassword,\n        revokeOtherSessions: true,\n      })\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n    onSuccess: (_) => {\n      toast(t(\"profile.update_password_success\"), {\n        duration: 3000,\n      })\n      form.reset()\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof updatePasswordFormSchema>) {\n    updateMutation.mutate(values)\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"w-[35ch] max-w-full space-y-4\">\n        <FormField\n          control={form.control}\n          name=\"currentPassword\"\n          render={({ field }) => (\n            <FormItem>\n              <FormControl>\n                <Input\n                  autoFocus\n                  type=\"password\"\n                  placeholder={t(\"profile.current_password.label\")}\n                  {...field}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"newPassword\"\n          render={({ field }) => (\n            <FormItem>\n              <FormControl>\n                <Input type=\"password\" placeholder={t(\"profile.new_password.label\")} {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"confirmPassword\"\n          render={({ field }) => (\n            <FormItem>\n              <FormControl>\n                <Input\n                  type=\"password\"\n                  placeholder={t(\"profile.confirm_password.label\")}\n                  {...field}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"flex items-center justify-between pl-2\">\n          <a\n            href={`${env.VITE_WEB_URL}/forget-password`}\n            className=\"text-sm duration-200 hover:text-accent hover:underline\"\n            target=\"_blank\"\n          >\n            {tApp(\"login.forget_password.note\")}\n          </a>\n          <Button type=\"submit\" isLoading={updateMutation.isPending}>\n            {t(\"profile.submit\")}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n\nexport const UpdatePasswordForm = () => {\n  const { data: hasPassword, isLoading } = useHasPassword()\n\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n\n  return (\n    <div className=\"flex items-center justify-between\">\n      <Label>{t(\"profile.password.label\")}</Label>\n      {isLoading ? null : hasPassword ? (\n        <Button\n          variant=\"outline\"\n          onClick={() =>\n            present({\n              title: t(\"profile.change_password.label\"),\n              content: UpdateExistingPasswordForm,\n            })\n          }\n        >\n          {t(\"profile.change_password.label\")}\n        </Button>\n      ) : (\n        <NoPasswordHint i18nKey=\"profile.no_password\" />\n      )}\n    </div>\n  )\n}\n\nexport const NoPasswordHint = ({ i18nKey }: { i18nKey: string }) => {\n  return (\n    <p className=\"text-sm text-text-secondary\">\n      <Trans\n        ns=\"settings\"\n        i18nKey={i18nKey as any}\n        components={{\n          Link: (\n            <a\n              href={`${env.VITE_WEB_URL}/forget-password`}\n              className=\"text-accent\"\n              target=\"_blank\"\n            />\n          ),\n        }}\n      />\n    </p>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/UserProfileModalContent.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { ActionButton, Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { usePrefetchUser, useUserById, useWhoami } from \"@follow/store/user/hooks\"\nimport { getAvatarUrl } from \"@follow/utils\"\nimport { nextFrame, stopPropagation } from \"@follow/utils/dom\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ListWithStats } from \"@follow-app/client-sdk\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useAtom } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\nimport { useAnimationControls } from \"motion/react\"\nimport type { FC } from \"react\"\nimport { Fragment, memo, useEffect, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { followClient } from \"~/lib/api-client\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\n\nimport { getSocialLink, socialCopyMap, socialIconClassNames } from \"./constants\"\nimport type { SubscriptionModalContentProps } from \"./shared\"\nimport { SubscriptionItems } from \"./shared\"\n\ntype ItemVariant = \"loose\" | \"compact\"\nconst itemVariantAtom = atomWithStorage(\n  getStorageNS(\"item-variant\"),\n  \"loose\" as ItemVariant,\n  undefined,\n  {\n    getOnInit: true,\n  },\n)\n\nconst pickUserData = <\n  T extends {\n    image?: Nullable<string>\n    name?: Nullable<string>\n    handle?: Nullable<string>\n    id: string\n    bio?: Nullable<string>\n    website?: Nullable<string>\n    socialLinks?: Nullable<Record<string, string>>\n  },\n>(\n  user: T,\n) => {\n  return {\n    image: user.image,\n    name: user.name,\n    handle: user.handle,\n    id: user.id,\n    bio: user.bio,\n    website: user.website,\n    socialLinks: user.socialLinks,\n  }\n}\n\nconst ListCard = memo(({ list }: { list: ListWithStats }) => {\n  return (\n    <div className=\"group/card relative overflow-hidden rounded-lg border border-fill bg-material-ultra-thin transition-all duration-200 hover:border-fill-secondary\">\n      <a\n        className=\"block h-full cursor-pointer\"\n        href={UrlBuilder.shareList(list.id)}\n        target=\"_blank\"\n      >\n        {/* Main Content */}\n        <div className=\"p-4\">\n          <div className=\"flex items-start gap-3\">\n            <div className=\"shrink-0\">\n              <FeedIcon\n                fallback\n                target={list as any}\n                className=\"size-10 rounded-lg border border-fill-secondary\"\n                noMargin\n              />\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              <h3 className=\"font-medium text-text transition-colors duration-200 group-hover/card:text-accent\">\n                {list.title}\n              </h3>\n              {list.description && (\n                <p className=\"mt-1 line-clamp-2 text-sm text-text-secondary\">{list.description}</p>\n              )}\n            </div>\n            {/* Hover indicator */}\n            <div className=\"opacity-0 transition-opacity duration-200 group-hover/card:opacity-100\">\n              <i className=\"i-mingcute-arrow-right-line size-4 text-text-tertiary\" />\n            </div>\n          </div>\n        </div>\n\n        {/* Stats Footer */}\n        {typeof list.subscriptionCount === \"number\" && (\n          <div className=\"border-t border-fill-secondary px-4 py-2.5\">\n            <div className=\"flex items-center justify-between text-xs\">\n              <div className=\"flex items-center gap-1.5 text-text-secondary\">\n                <i className=\"i-mingcute-group-2-line size-3\" />\n                <span>\n                  {list.subscriptionCount}\n                  <span className=\"ml-1 hidden sm:inline\">\n                    {list.subscriptionCount > 1 ? \"subscribers\" : \"subscriber\"}\n                  </span>\n                </span>\n              </div>\n            </div>\n          </div>\n        )}\n      </a>\n    </div>\n  )\n})\nListCard.displayName = \"ListCard\"\n\nexport const UserProfileModalContent: FC<SubscriptionModalContentProps> = ({ userId, variant }) => {\n  const { t } = useTranslation()\n  const user = usePrefetchUser(userId)\n  const storeUser = useUserById(userId)\n\n  const userInfo = user.data ? pickUserData(user.data) : storeUser ? pickUserData(storeUser) : null\n\n  const modal = useCurrentModal()\n  const controller = useAnimationControls()\n  useEffect(() => {\n    nextFrame(() => controller.start(\"enter\"))\n  }, [controller])\n\n  const winHeight = useMemo(() => window.innerHeight, [])\n\n  const modalVariant = useMemo(() => {\n    switch (variant) {\n      case \"drawer\": {\n        return {\n          enter: {\n            x: 0,\n            opacity: 1,\n          },\n          initial: {\n            x: 700,\n            opacity: 0.9,\n          },\n          exit: {\n            x: 750,\n            opacity: 0,\n          },\n        }\n      }\n\n      case \"dialog\": {\n        return {\n          enter: {\n            y: 0,\n            opacity: 1,\n          },\n          initial: {\n            y: \"100%\",\n            opacity: 0.9,\n          },\n          exit: {\n            y: winHeight,\n          },\n        }\n      }\n    }\n  }, [variant, winHeight])\n\n  return (\n    <div\n      className={variant === \"drawer\" ? \"h-full\" : \"container center h-full\"}\n      onPointerDown={variant === \"dialog\" ? modal.dismiss : undefined}\n      onClick={stopPropagation}\n    >\n      <m.div\n        onPointerDown={stopPropagation}\n        tabIndex={-1}\n        initial=\"initial\"\n        animate={controller}\n        variants={modalVariant}\n        transition={Spring.presets.snappy}\n        exit=\"exit\"\n        layout=\"size\"\n        className={cn(\n          \"relative flex flex-col overflow-hidden rounded-xl border bg-theme-background\",\n          variant === \"drawer\"\n            ? \"shadow-drawer-to-left h-full w-[60ch] max-w-full\"\n            : \"h-[80vh] w-[800px] max-w-full shadow lg:max-h-[calc(100vh-10rem)]\",\n        )}\n      >\n        <div className=\"absolute right-2 top-2 z-10 flex items-center gap-2 text-[20px] opacity-80\">\n          <ActionButton\n            tooltip={t(\"user_profile.share\")}\n            onClick={() => {\n              if (!user.data) return\n              window.open(UrlBuilder.profile(user.data.handle ?? user.data.id))\n            }}\n          >\n            <i className=\"i-mgc-share-forward-cute-re\" />\n          </ActionButton>\n          <ActionButton tooltip={t(\"user_profile.close\")} onClick={modal.dismiss}>\n            <i className=\"i-mgc-close-cute-re\" />\n          </ActionButton>\n        </div>\n\n        {userInfo && <Content userInfo={userInfo} />}\n\n        {!userInfo && <LoadingCircle size=\"large\" className=\"center h-full\" />}\n      </m.div>\n    </div>\n  )\n}\n\ntype PickedUser = ReturnType<typeof pickUserData>\n\nconst UserInfo = ({ userInfo }: { userInfo: PickedUser }) => {\n  const whoami = useWhoami()\n  const follow = useFollow()\n\n  // It's a string value when it's from the store\n  if (typeof userInfo.socialLinks === \"string\") {\n    userInfo.socialLinks = JSON.parse(userInfo.socialLinks)\n  }\n\n  return (\n    <div className=\"relative flex flex-col bg-material-medium p-8 pb-4\">\n      <div className=\"flex flex-col space-y-4\">\n        <div className=\"flex items-center gap-4\">\n          <div className=\"relative\">\n            <Avatar className=\"size-14 shrink-0 shadow-lg ring-4 ring-white/10\">\n              <AvatarImage src={getAvatarUrl(userInfo)} className=\"bg-material-ultra-thick\" />\n              <AvatarFallback className=\"bg-gradient-to-br from-blue to-purple text-2xl font-bold uppercase text-white\">\n                {userInfo.name?.slice(0, 2)}\n              </AvatarFallback>\n            </Avatar>\n\n            {whoami?.id !== userInfo.id && (\n              <Button\n                buttonClassName=\"absolute -bottom-1 -right-1 size-6 rounded-full\"\n                onClick={() => {\n                  follow({\n                    url: `rsshub://follow/profile/${userInfo.id}`,\n                    isList: false,\n                  })\n                }}\n                size=\"sm\"\n              >\n                <i className=\"i-mgc-add-cute-re size-4\" />\n              </Button>\n            )}\n          </div>\n          <div className=\"flex-1\">\n            <h1 className=\"max-w-[200px] truncate text-xl font-bold tracking-tight text-text\">\n              {userInfo.name}\n            </h1>\n            <p\n              className={cn(\n                \"text-sm font-medium text-text-secondary\",\n                userInfo.handle ? \"visible\" : \"hidden select-none\",\n              )}\n            >\n              @{userInfo.handle}\n            </p>\n          </div>\n        </div>\n        {userInfo.bio && (\n          <p className=\"text-sm leading-relaxed text-text-secondary\">{userInfo.bio}</p>\n        )}\n\n        <div className=\"flex flex-wrap gap-2\">\n          {userInfo.website && (\n            <a\n              href={userInfo.website}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"mr-auto inline-flex items-center gap-2 text-text-secondary transition-colors hover:text-accent\"\n            >\n              <i className=\"i-mgc-link-cute-re\" />\n              <span className=\"text-base leading-relaxed\">\n                {userInfo.website.replace(/^https?:\\/\\//, \"\")}\n              </span>\n            </a>\n          )}\n          {userInfo.socialLinks &&\n            Object.values(userInfo.socialLinks).filter(Boolean).length > 0 && (\n              <>\n                {Object.entries(userInfo.socialLinks).map(([platform, id]) => {\n                  if (!id || !(platform in socialIconClassNames) || typeof id !== \"string\")\n                    return null\n\n                  return (\n                    <Tooltip key={platform}>\n                      <TooltipTrigger asChild>\n                        <a\n                          href={getSocialLink(platform as keyof typeof socialIconClassNames, id)}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"group flex size-10 items-center justify-center rounded-lg transition-colors hover:bg-theme-item-hover\"\n                        >\n                          <i\n                            className={cn(\n                              socialIconClassNames[platform as keyof typeof socialIconClassNames],\n                              \"text-base text-text-secondary\",\n                            )}\n                          />\n                        </a>\n                      </TooltipTrigger>\n                      <TooltipContent className=\"text-xs font-medium\">\n                        {socialCopyMap[platform as keyof typeof socialCopyMap]}\n                      </TooltipContent>\n                    </Tooltip>\n                  )\n                })}\n              </>\n            )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nconst Subscriptions = ({ userId }: { userId: string }) => {\n  const { t } = useTranslation()\n  const [itemStyle, setItemStyle] = useAtom(itemVariantAtom)\n\n  return (\n    <>\n      <div className=\"-mb-4 flex items-center justify-between\">\n        <h2 className=\"text-lg font-semibold text-text\">{t(\"user_profile.subscriptions\")}</h2>\n        <ActionButton\n          tooltip={t(\"user_profile.toggle_item_style\")}\n          onClick={() => {\n            setItemStyle((current) => (current === \"loose\" ? \"compact\" : \"loose\"))\n          }}\n        >\n          <i\n            className={cn(\n              itemStyle === \"loose\" ? \"i-mgc-list-check-3-cute-re\" : \"i-mgc-list-check-cute-re\",\n            )}\n          />\n        </ActionButton>\n      </div>\n\n      <SubscriptionItems userId={userId} itemStyle={itemStyle} />\n    </>\n  )\n}\n\nconst useUserListsQuery = (userId: string) => {\n  return useQuery({\n    queryKey: [\"lists\", userId],\n    queryFn: async () => {\n      const res = await followClient.api.lists.list({ userId })\n      return res.data\n    },\n  })\n}\n\nconst Lists = ({ lists }: { lists: ListWithStats[] }) => {\n  const { t } = useTranslation()\n  if (!lists || lists.length === 0) return null\n  return (\n    <div>\n      <div className=\"mb-4 flex items-center justify-between\">\n        <h2 className=\"text-lg font-semibold text-text\">{t(\"user_profile.created_lists\")}</h2>\n      </div>\n      <div className=\"grid grid-cols-1 gap-4 @[500px]:grid-cols-2 @[700px]:grid-cols-3\">\n        {lists.map((list) => (\n          <ListCard key={list.id} list={list} />\n        ))}\n      </div>\n    </div>\n  )\n}\n\nconst Content = ({ userInfo }: { userInfo: PickedUser }) => {\n  const lists = useUserListsQuery(userInfo.id)\n  return (\n    <Fragment>\n      <UserInfo userInfo={userInfo} />\n      <ScrollArea.ScrollArea\n        rootClassName=\"grow max-w-full w-full bg-material-ultra-thin/30 \"\n        viewportClassName=\"[&>div]:!flex [&>div]:flex-col\"\n      >\n        {!lists.isLoading && (\n          <div className=\"flex flex-col space-y-4 px-8 py-6 @container\">\n            {/* Lists Section */}\n            {lists.data && lists.data.length > 0 && <Lists lists={lists.data} />}\n            {/* Subscriptions Section */}\n            <Subscriptions userId={userInfo.id} />\n          </div>\n        )}\n      </ScrollArea.ScrollArea>\n    </Fragment>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/constants.ts",
    "content": "// Social platform icons mapping\nexport const socialIconClassNames = {\n  twitter: \"i-mgc-twitter-cute-fi duration-200 group-hover:text-[#1DA1F2]\",\n  github: \"i-mgc-github-cute-fi duration-200 group-hover:text-[#181717] group-hover:dark:invert\",\n  instagram: \"i-mingcute-ins-fill duration-200 group-hover:text-[#C13584]\",\n  facebook: \"i-mingcute-facebook-fill duration-200 group-hover:text-[#1877F2]\",\n  youtube: \"i-mgc-youtube-cute-fi duration-200 group-hover:text-[#FF0000]\",\n  discord: \"i-mingcute-discord-fill duration-200 group-hover:text-[#5865F2]\",\n}\n\nexport const socialCopyMap = {\n  twitter: \"Twitter\",\n  github: \"GitHub\",\n  instagram: \"Instagram\",\n  facebook: \"Facebook\",\n  youtube: \"YouTube\",\n  discord: \"Discord\",\n}\n\nexport const getSocialLink = (platform: keyof typeof socialIconClassNames, id: string) => {\n  switch (platform) {\n    case \"twitter\": {\n      return `https://twitter.com/${id}`\n    }\n    case \"github\": {\n      return `https://github.com/${id}`\n    }\n    case \"instagram\": {\n      return `https://instagram.com/${id}`\n    }\n    case \"facebook\": {\n      return `https://facebook.com/${id}`\n    }\n    case \"youtube\": {\n      return `https://youtube.com/${id}`\n    }\n    case \"discord\": {\n      return `https://discord.com/${id}`\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/index.ts",
    "content": "export * from \"./UserProfileModalContent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/shared.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { FollowIcon } from \"@follow/components/icons/follow.jsx\"\nimport { AutoResizeHeight } from \"@follow/components/ui/auto-resize-height/index.jsx\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingWithIcon } from \"@follow/components/ui/loading/index.jsx\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { useIsSubscribed } from \"@follow/store/subscription/hooks\"\nimport { usePrefetchUser, useUserById } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type {\n  InboxSubscriptionResponse,\n  ListSubscriptionResponse,\n  SubscriptionWithFeed,\n} from \"@follow-app/client-sdk\"\nimport { AnimatePresence } from \"motion/react\"\nimport type { FC } from \"react\"\nimport { memo, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { m } from \"~/components/common/Motion\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { useI18n } from \"~/hooks/common\"\nimport { useReplaceImgUrlIfNeed } from \"~/lib/img-proxy\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { useUserSubscriptionsQuery } from \"~/modules/profile/hooks\"\n\ntype ItemVariant = \"loose\" | \"compact\"\n\nexport interface SubscriptionModalContentProps {\n  userId: string\n\n  variant?: \"drawer\" | \"dialog\"\n}\nexport const SubscriptionItems = ({\n  userId,\n  itemStyle,\n}: {\n  userId: string\n  itemStyle: ItemVariant\n}) => {\n  const replaceImgUrlIfNeed = useReplaceImgUrlIfNeed()\n  const profile = usePrefetchUser(userId)\n  const userInfo = useUserById(userId)\n  const subscriptions = useUserSubscriptionsQuery(userId)\n  const renderUserData = profile.data || userInfo\n\n  return subscriptions.isLoading ? (\n    <LoadingWithIcon\n      size=\"large\"\n      icon={\n        <Avatar className=\"aspect-square size-4\">\n          <AvatarImage src={replaceImgUrlIfNeed(renderUserData?.image || undefined)} />\n          <AvatarFallback>{renderUserData?.name?.slice(0, 2)}</AvatarFallback>\n        </Avatar>\n      }\n      className=\"center h-48 w-full max-w-full\"\n    />\n  ) : (\n    subscriptions.data && (\n      <div className=\"flex w-full\">\n        <div className=\"relative flex w-0 grow flex-col\">\n          {Object.keys(subscriptions.data).map((category) => (\n            <SubscriptionGroup\n              key={category}\n              category={category}\n              subscriptions={subscriptions.data?.[category]!}\n              itemStyle={itemStyle}\n            />\n          ))}\n        </div>\n      </div>\n    )\n  )\n}\n\nexport const SubscriptionGroup: FC<{\n  category: string\n  subscriptions: (SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse)[]\n  itemStyle: ItemVariant\n}> = memo(({ category, subscriptions, itemStyle }) => {\n  const [isOpened, setIsOpened] = useState(true)\n  return (\n    <div>\n      <button\n        onClick={() => setIsOpened(!isOpened)}\n        className=\"mb-2 mt-8 flex w-full items-center justify-between font-bold\"\n        type=\"button\"\n      >\n        <h3 className=\"min-w-0 pr-1\">\n          <EllipsisHorizontalTextWithTooltip className=\"min-w-0 truncate\">\n            {category}\n          </EllipsisHorizontalTextWithTooltip>\n        </h3>\n\n        <div className=\"inline-flex shrink-0 items-center opacity-50\">\n          <i\n            className={cn(\"i-mingcute-down-line size-5 duration-200\", isOpened ? \"rotate-180\" : \"\")}\n          />\n        </div>\n      </button>\n      <AutoResizeHeight>\n        <AnimatePresence mode=\"popLayout\">\n          {isOpened && (\n            <div>\n              {subscriptions.map((subscription) => (\n                <SubscriptionItem\n                  variant={itemStyle}\n                  key={subscription.feedId}\n                  subscription={subscription}\n                />\n              ))}\n            </div>\n          )}\n        </AnimatePresence>\n      </AutoResizeHeight>\n    </div>\n  )\n})\n\nconst SubscriptionItem: FC<{\n  subscription: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse\n\n  variant: ItemVariant\n}> = ({ subscription, variant }) => {\n  const t = useI18n()\n  const isFollowed = useIsSubscribed(subscription.feedId)\n  const follow = useFollow()\n  const isLoose = variant === \"loose\"\n  const handleFollow = useEventCallback((e: React.MouseEvent) => {\n    if (!(\"feeds\" in subscription)) return\n    e.stopPropagation()\n    e.preventDefault()\n\n    const defaultView = subscription.view\n\n    follow({\n      id: subscription.feedId,\n      url: subscription.feeds.url,\n      isList: false,\n      defaultValues: {\n        view: defaultView.toString(),\n      },\n    })\n  })\n\n  const isMobile = useMobile()\n  if (!(\"feeds\" in subscription)) return null\n\n  return (\n    <m.div\n      exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.2 } }}\n      className={cn(\"group relative pl-1\", isLoose ? \"border-b py-5 last:border-b-0\" : \"py-2\")}\n      data-feed-id={subscription.feedId}\n    >\n      <a\n        className=\"flex flex-1 cursor-menu items-center\"\n        href={subscription.feeds.siteUrl!}\n        target=\"_blank\"\n        onClick={isMobile ? handleFollow : undefined}\n      >\n        <FeedIcon target={subscription.feeds as any} size={22} className=\"mr-3\" />\n        <div\n          className={cn(\n            \"w-0 flex-1 grow\",\n            \"group-hover:grow-[0.85]\",\n            !isLoose && \"flex items-center\",\n          )}\n        >\n          <div className=\"truncate font-medium leading-tight\">{subscription.feeds?.title}</div>\n          {isLoose && (\n            <div className=\"mt-1 line-clamp-1 text-xs text-zinc-500\">\n              {subscription.feeds?.description}\n            </div>\n          )}\n        </div>\n        {!isMobile && (\n          <div className=\"absolute right-0 opacity-0 transition-opacity group-hover:opacity-100\">\n            <Button onClick={handleFollow}>\n              {isFollowed ? (\n                t(\"user_profile.edit\")\n              ) : (\n                <>\n                  <FollowIcon className=\"mr-1 size-3\" />\n                  {t(\"feed_form.follow\")}\n                </>\n              )}\n            </Button>\n          </div>\n        )}\n      </a>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/profile/user-profile-modal.constants.ts",
    "content": "// Social platform icons mapping\nexport const socialIconClassNames = {\n  twitter: \"i-mgc-twitter-cute-fi duration-200 group-hover:text-[#1DA1F2]\",\n  github: \"i-mgc-github-cute-fi duration-200 group-hover:text-[#181717] group-hover:dark:invert\",\n  instagram: \"i-mingcute-ins-fill duration-200 group-hover:text-[#C13584]\",\n  facebook: \"i-mingcute-facebook-fill duration-200 group-hover:text-[#1877F2]\",\n  youtube: \"i-mgc-youtube-cute-fi duration-200 group-hover:text-[#FF0000]\",\n  discord: \"i-mingcute-discord-fill duration-200 group-hover:text-[#5865F2]\",\n}\n\nexport const socialCopyMap = {\n  twitter: \"Twitter\",\n  github: \"GitHub\",\n  instagram: \"Instagram\",\n  facebook: \"Facebook\",\n  youtube: \"YouTube\",\n  discord: \"Discord\",\n}\n\nexport const getSocialLink = (platform: keyof typeof socialIconClassNames, id: string) => {\n  switch (platform) {\n    case \"twitter\": {\n      return `https://twitter.com/${id}`\n    }\n    case \"github\": {\n      return `https://github.com/${id}`\n    }\n    case \"instagram\": {\n      return `https://instagram.com/${id}`\n    }\n    case \"facebook\": {\n      return `https://facebook.com/${id}`\n    }\n    case \"youtube\": {\n      return `https://youtube.com/${id}`\n    }\n    case \"discord\": {\n      return `https://discord.com/${id}`\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/renderer/components/TimeStamp.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { formatTimeToSeconds, timeStringToSeconds } from \"@follow/utils/utils\"\nimport { use } from \"react\"\n\nimport { AudioPlayer } from \"~/atoms/player\"\n\nimport { EntryInfoContext } from \"../context\"\n\nexport const TimeStamp = (props: { time: string }) => {\n  const { entryId } = use(EntryInfoContext)\n  const entry = useEntry(entryId, (state) => {\n    const attachments = state.attachments || []\n    const firstAttachment = attachments[0]\n    const { duration_in_seconds, url: firstAttachmentUrl } = firstAttachment || {}\n    const seconds = duration_in_seconds ? (formatTimeToSeconds(duration_in_seconds) ?? 0) : 0\n\n    return {\n      firstAttachmentUrl,\n      seconds,\n    }\n  })\n  const src = entry?.firstAttachmentUrl\n  const mediaDuration = entry?.seconds\n\n  if (!src) return <span>{props.time}</span>\n\n  const seekTo = timeStringToSeconds(props.time)\n  if (typeof seekTo !== \"number\") return <span>{props.time}</span>\n\n  return (\n    <span\n      className=\"cursor-pointer tabular-nums text-accent\"\n      onClick={() => {\n        AudioPlayer.mount({\n          type: \"audio\",\n          entryId,\n          src,\n          currentTime: 0,\n        })\n        nextFrame(() => AudioPlayer.seek(seekTo))\n      }}\n    >\n      {!!mediaDuration && (\n        <CircleProgress\n          className=\"mr-1 inline translate-y-px scale-95 align-text-top\"\n          percent={(seekTo / mediaDuration) * 100}\n          size={16}\n          strokeWidth={2}\n        />\n      )}\n      {props.time}\n    </span>\n  )\n}\n\ninterface CircleProgressProps {\n  percent: number\n  size?: number\n  strokeWidth?: number\n  strokeColor?: string\n  backgroundColor?: string\n  className?: string\n}\n\nconst CircleProgress: React.FC<CircleProgressProps> = ({\n  percent,\n  size = 100,\n  strokeWidth = 8,\n  strokeColor = \"hsl(var(--fo-a))\",\n  backgroundColor = \"hsl(var(--fo-inactive))\",\n  className,\n}) => {\n  const normalizedPercent = Math.min(100, Math.max(0, percent))\n  const radius = (size - strokeWidth) / 2\n  const circumference = radius * 2 * Math.PI\n  const offset = circumference - (normalizedPercent / 100) * circumference\n\n  return (\n    <svg width={size} height={size} className={className}>\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        fill=\"none\"\n        stroke={backgroundColor}\n        strokeWidth={strokeWidth}\n      />\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        fill=\"none\"\n        stroke={strokeColor}\n        strokeWidth={strokeWidth}\n        strokeDasharray={circumference}\n        strokeDashoffset={offset}\n        transform={`rotate(-90 ${size / 2} ${size / 2})`}\n        style={{ transition: \"stroke-dashoffset 0.3s\" }}\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/renderer/context.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { createContext as createReactContext } from \"react\"\nimport { createContext } from \"use-context-selector\"\n\nexport interface EntryContentContext {\n  entryId: string\n  feedId: string\n\n  audioSrc?: string\n\n  view: FeedViewType\n}\nconst defaultContextValue: EntryContentContext = {\n  entryId: \"\",\n  feedId: \"\",\n  view: FeedViewType.Articles,\n}\nexport const EntryContentContext = createContext<EntryContentContext>(defaultContextValue)\n\nexport const EntryContentProvider: Component<EntryContentContext> = ({ children, ...value }) => (\n  // eslint-disable-next-line @eslint-react/no-context-provider\n  <EntryContentContext.Provider value={value}>{children}</EntryContentContext.Provider>\n)\n\nexport interface EntryInfoContext {\n  feedId: string\n  entryId: string\n}\nconst defaultInfoContextValue: EntryInfoContext = {\n  feedId: \"\",\n  entryId: \"\",\n}\nexport const EntryInfoContext = createReactContext<EntryInfoContext>(defaultInfoContextValue)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/renderer/html.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { useMemo } from \"react\"\nimport type { JSX } from \"react/jsx-runtime\"\n\nimport {\n  MarkdownImageRecordContext,\n  MarkdownRenderActionContext,\n} from \"~/components/ui/markdown/context\"\nimport type { HTMLProps } from \"~/components/ui/markdown/HTML\"\nimport { HTML } from \"~/components/ui/markdown/HTML\"\nimport type { MarkdownImage, MarkdownRenderActions } from \"~/components/ui/markdown/types\"\n\nimport { TimeStamp } from \"./components/TimeStamp\"\nimport { EntryInfoContext } from \"./context\"\nimport type { EntryContentRendererProps } from \"./types\"\n\nexport function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements = \"div\">({\n  view,\n  feedId,\n  entryId,\n  children,\n  ...props\n}: EntryContentRendererProps & HTMLProps<AS>) {\n  const entry = useEntry(entryId, (state) => {\n    const images =\n      state.media?.reduce(\n        (acc, media) => {\n          if (media.height && media.width) {\n            acc[media.url] = media\n          }\n          return acc\n        },\n        {} as Record<string, MarkdownImage>,\n      ) ?? {}\n\n    const { url } = state\n\n    return {\n      images,\n      url,\n    }\n  })\n\n  const images: Record<string, MarkdownImage> = useMemo(() => entry?.images ?? {}, [entry])\n  const actions: MarkdownRenderActions = useMemo(() => {\n    return {\n      isAudio() {\n        return view === FeedViewType.Audios\n      },\n      transformUrl(url) {\n        if (!url || url.startsWith(\"http\")) return url\n\n        const feed = getFeedById(feedId)\n        if (url.startsWith(\"/\") && feed?.siteUrl) return safeUrl(url, feed.siteUrl)\n\n        if (url?.startsWith(\".\") && entry?.url) return safeUrl(url, entry?.url)\n\n        return url\n      },\n      ensureAndRenderTimeStamp,\n    }\n  }, [entry, feedId, view])\n  return (\n    // eslint-disable-next-line @eslint-react/no-context-provider\n    <MarkdownImageRecordContext.Provider value={images}>\n      <MarkdownRenderActionContext value={actions}>\n        <EntryInfoContext value={useMemo(() => ({ feedId, entryId }), [feedId, entryId])}>\n          {/*  @ts-expect-error */}\n          <HTML {...props}>{children}</HTML>\n        </EntryInfoContext>\n      </MarkdownRenderActionContext>\n    </MarkdownImageRecordContext.Provider>\n  )\n}\n\nconst safeUrl = (url: string, baseUrl: string) => {\n  try {\n    return new URL(url, baseUrl).href\n  } catch {\n    return url\n  }\n}\n\nconst ensureAndRenderTimeStamp = (children: string) => {\n  const firstPart = children.replace(\" \", \" \").split(\" \")[0]\n  // 00:00 , 00:00:00\n  if (!firstPart) {\n    return\n  }\n  const isTime = isValidTimeString(firstPart.trim())\n  if (isTime) {\n    return (\n      <>\n        <TimeStamp time={firstPart} />\n        <span>{children.slice(firstPart.length)}</span>\n      </>\n    )\n  }\n  return false\n}\nfunction isValidTimeString(time: string): boolean {\n  const timeRegex = /^\\d{1,2}:[0-5]\\d(?::[0-5]\\d)?$/\n  return timeRegex.test(time)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport type { ComponentProps } from \"react\"\nimport { useMemo } from \"react\"\n\nimport {\n  MarkdownImageRecordContext,\n  MarkdownRenderActionContext,\n} from \"~/components/ui/markdown/context\"\nimport { Markdown } from \"~/components/ui/markdown/Markdown\"\nimport type { MarkdownImage, MarkdownRenderActions } from \"~/components/ui/markdown/types\"\n\nimport { TimeStamp } from \"./components/TimeStamp\"\nimport { EntryInfoContext } from \"./context\"\nimport type { EntryContentRendererProps } from \"./types\"\n\ntype MarkdownProps = Omit<ComponentProps<typeof Markdown>, \"children\">\n\nexport function EntryContentMarkdownRenderer({\n  view,\n  feedId,\n  entryId,\n  children,\n  ...props\n}: EntryContentRendererProps & MarkdownProps) {\n  const entry = useEntry(entryId, (state) => {\n    const images =\n      state.media?.reduce(\n        (acc, media) => {\n          if (media.height && media.width) {\n            acc[media.url] = media\n          }\n          return acc\n        },\n        {} as Record<string, MarkdownImage>,\n      ) ?? {}\n\n    const { url } = state\n\n    return {\n      images,\n      url,\n    }\n  })\n\n  const images: Record<string, MarkdownImage> = useMemo(() => entry?.images ?? {}, [entry])\n  const actions: MarkdownRenderActions = useMemo(() => {\n    return {\n      isAudio() {\n        return view === FeedViewType.Audios\n      },\n      transformUrl(url) {\n        if (!url || url.startsWith(\"http\")) return url\n\n        const feed = getFeedById(feedId)\n        if (url.startsWith(\"/\") && feed?.siteUrl) return safeUrl(url, feed.siteUrl)\n\n        if (url?.startsWith(\".\") && entry?.url) return safeUrl(url, entry?.url)\n\n        return url\n      },\n      ensureAndRenderTimeStamp,\n    }\n  }, [entry, feedId, view])\n\n  return (\n    // eslint-disable-next-line @eslint-react/no-context-provider\n    <MarkdownImageRecordContext.Provider value={images}>\n      <MarkdownRenderActionContext value={actions}>\n        <EntryInfoContext value={useMemo(() => ({ feedId, entryId }), [feedId, entryId])}>\n          <Markdown {...props}>{children ?? \"\"}</Markdown>\n        </EntryInfoContext>\n      </MarkdownRenderActionContext>\n    </MarkdownImageRecordContext.Provider>\n  )\n}\n\nconst safeUrl = (url: string, baseUrl: string) => {\n  try {\n    return new URL(url, baseUrl).href\n  } catch {\n    return url\n  }\n}\n\nconst ensureAndRenderTimeStamp = (children: string) => {\n  const firstPart = children.replace(\" \", \" \").split(\" \")[0]\n  // 00:00 , 00:00:00\n  if (!firstPart) {\n    return\n  }\n  const isTime = isValidTimeString(firstPart.trim())\n  if (isTime) {\n    return (\n      <>\n        <TimeStamp time={firstPart} />\n        <span>{children.slice(firstPart.length)}</span>\n      </>\n    )\n  }\n  return false\n}\nfunction isValidTimeString(time: string): boolean {\n  const timeRegex = /^\\d{1,2}:[0-5]\\d(?::[0-5]\\d)?$/\n  return timeRegex.test(time)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/renderer/types.ts",
    "content": "import type { FeedViewType } from \"@follow-app/client-sdk\"\n\nexport type EntryContentRendererProps = {\n  view: FeedViewType\n  feedId: string\n  entryId: string\n  children: Nullable<string>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/review-prompt/ReviewPromptModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nexport const ReviewPromptModalContent = ({\n  dismiss,\n  onNegative,\n  onPositive,\n}: {\n  dismiss: () => void\n  onNegative: () => void\n  onPositive: () => void\n}) => {\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <div className=\"flex min-w-80 max-w-md flex-col gap-4\">\n      <p className=\"text-sm leading-relaxed text-text-secondary\">{t(\"reviewPrompt.description\")}</p>\n\n      <div className=\"flex items-center justify-end gap-3\">\n        <Button\n          variant=\"outline\"\n          onClick={() => {\n            onNegative()\n            dismiss()\n          }}\n        >\n          {t(\"reviewPrompt.notReally\")}\n        </Button>\n\n        <Button\n          onClick={() => {\n            onPositive()\n            dismiss()\n          }}\n        >\n          {t(\"reviewPrompt.loveIt\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/review-prompt/debug.ts",
    "content": "let triggerReviewPromptDebug: (() => void) | null = null\nlet resetReviewPromptDebug: (() => void) | null = null\n\nexport const setDesktopReviewPromptDebugAction = (callback: (() => void) | null) => {\n  triggerReviewPromptDebug = callback\n}\n\nexport const openDesktopReviewPromptDebug = () => {\n  triggerReviewPromptDebug?.()\n}\n\nexport const setDesktopReviewPromptResetAction = (callback: (() => void) | null) => {\n  resetReviewPromptDebug = callback\n}\n\nexport const resetDesktopReviewPromptDebug = () => {\n  resetReviewPromptDebug?.()\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/review-prompt/provider.tsx",
    "content": "import { sheetStackAtom } from \"@follow/components/ui/sheet/context.js\"\nimport { UserRole } from \"@follow/constants\"\nimport {\n  getReviewPromptEligibility,\n  recordReviewPromptActiveDay,\n  recordReviewPromptEntryOpen,\n  recordReviewPromptPaidConversion,\n  recordReviewPromptSubscriptionAdded,\n  syncReviewPromptSubscriptionCount,\n} from \"@follow/shared/review-prompt\"\nimport { useAllFeedSubscription, useAllListSubscription } from \"@follow/store/subscription/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { tracker, TrackerMapper, trackManager } from \"@follow/tracker\"\nimport { useAtomValue } from \"jotai\"\nimport { useEffect, useMemo, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useLocation } from \"react-router\"\n\nimport { useIsInMASReview } from \"~/atoms/server-configs\"\nimport { useHasModal, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { DebugRegistry } from \"../debug/registry\"\nimport { setDesktopReviewPromptDebugAction, setDesktopReviewPromptResetAction } from \"./debug\"\nimport { ReviewPromptModalContent } from \"./ReviewPromptModalContent\"\nimport { useDesktopReviewPromptState } from \"./use-review-prompt-state\"\nimport {\n  clearDesktopReviewPromptState,\n  getDesktopReviewDebugTarget,\n  openDesktopFeedbackEmail,\n  openDesktopStoreReview,\n  persistDesktopReviewOutcome,\n  readDesktopReviewPromptState,\n  REVIEW_PROMPT_QUIET_WINDOW_MS,\n} from \"./utils\"\n\nconst isPaidRole = (role: ReturnType<typeof useUserRole>) =>\n  role === UserRole.Pro || role === UserRole.Plus\n\nexport const ReviewPromptProvider = () => {\n  const { t } = useTranslation(\"settings\")\n  const location = useLocation()\n  const { present } = useModalStack()\n  const hasModal = useHasModal()\n  const sheetStack = useAtomValue(sheetStackAtom)\n  const feedSubscriptions = useAllFeedSubscription()\n  const listSubscriptions = useAllListSubscription()\n  const role = useUserRole()\n  const isInMASReview = useIsInMASReview()\n\n  const {\n    distribution,\n    getLatestReviewState,\n    platform,\n    rateTarget,\n    reviewState,\n    storageKey,\n    updateReviewState,\n    userId,\n  } = useDesktopReviewPromptState()\n\n  const hasAttemptedInSessionRef = useRef(false)\n  const lastActionRef = useRef<\"positive\" | \"negative\" | null>(null)\n  const roleRef = useRef(role)\n  const subscriptionCountRef = useRef(feedSubscriptions.length + listSubscriptions.length)\n\n  subscriptionCountRef.current = feedSubscriptions.length + listSubscriptions.length\n\n  useEffect(() => {\n    hasAttemptedInSessionRef.current = false\n  }, [storageKey])\n\n  useEffect(() => {\n    if (!userId) {\n      return\n    }\n\n    const recordActiveDay = () => {\n      updateReviewState((state) => recordReviewPromptActiveDay(state, new Date()))\n    }\n\n    recordActiveDay()\n\n    const onVisibilityChange = () => {\n      if (!document.hidden) {\n        recordActiveDay()\n      }\n    }\n\n    window.addEventListener(\"focus\", recordActiveDay)\n    document.addEventListener(\"visibilitychange\", onVisibilityChange)\n\n    return () => {\n      window.removeEventListener(\"focus\", recordActiveDay)\n      document.removeEventListener(\"visibilitychange\", onVisibilityChange)\n    }\n  }, [updateReviewState, userId])\n\n  useEffect(() => {\n    if (!userId) {\n      roleRef.current = role\n      return\n    }\n\n    if (isPaidRole(role) && !isPaidRole(roleRef.current)) {\n      updateReviewState((state) => recordReviewPromptPaidConversion(state, new Date()))\n    }\n\n    roleRef.current = role\n  }, [role, updateReviewState, userId])\n\n  useEffect(() => {\n    if (!userId) {\n      return\n    }\n\n    updateReviewState((state) =>\n      syncReviewPromptSubscriptionCount(state, subscriptionCountRef.current),\n    )\n  }, [feedSubscriptions.length, listSubscriptions.length, updateReviewState, userId])\n\n  useEffect(() => {\n    if (!userId) {\n      return\n    }\n\n    return trackManager.setTrackFn((code) => {\n      switch (code) {\n        case TrackerMapper.NavigateEntry: {\n          updateReviewState((state) => recordReviewPromptEntryOpen(state))\n          break\n        }\n        case TrackerMapper.Subscribe: {\n          updateReviewState((state) =>\n            recordReviewPromptSubscriptionAdded(state, subscriptionCountRef.current),\n          )\n          break\n        }\n      }\n\n      return Promise.resolve()\n    })\n  }, [updateReviewState, userId])\n\n  const isRouteBlocked = useMemo(\n    () => location.pathname.startsWith(\"/settings/plan\"),\n    [location.pathname],\n  )\n  const isInQuietWindow = !hasModal && sheetStack.length === 0 && !isRouteBlocked\n  const isPlatformSupported = distribution !== \"unsupported\" && !isInMASReview\n  const presentReviewPrompt = useMemo(\n    () =>\n      ({\n        score,\n        source,\n        target = rateTarget,\n      }: {\n        score?: number\n        source: \"auto\" | \"manual\"\n        target?: ReturnType<typeof getDesktopReviewDebugTarget>\n      }) => {\n        if (!target) {\n          return\n        }\n\n        lastActionRef.current = null\n        tracker.reviewPromptShown({ distribution, platform, score, source })\n\n        present({\n          canClose: true,\n          clickOutsideToDismiss: true,\n          id: \"review-prompt-modal\",\n          onClose: () => {\n            if (lastActionRef.current) {\n              lastActionRef.current = null\n              return\n            }\n\n            persistDesktopReviewOutcome({\n              appVersion: APP_VERSION,\n              distribution,\n              outcome: \"dismissed\",\n              platform,\n              source,\n              state: readDesktopReviewPromptState(storageKey),\n              storageKey,\n            })\n          },\n          title: t(\"reviewPrompt.title\"),\n          content: ({ dismiss }) => (\n            <ReviewPromptModalContent\n              dismiss={dismiss}\n              onNegative={() => {\n                lastActionRef.current = \"negative\"\n                persistDesktopReviewOutcome({\n                  appVersion: APP_VERSION,\n                  distribution,\n                  outcome: \"negative_feedback\",\n                  platform,\n                  source,\n                  state: readDesktopReviewPromptState(storageKey),\n                  storageKey,\n                })\n                void openDesktopFeedbackEmail({ distribution, userId })\n              }}\n              onPositive={() => {\n                lastActionRef.current = \"positive\"\n                persistDesktopReviewOutcome({\n                  appVersion: APP_VERSION,\n                  distribution,\n                  outcome: \"positive_store_redirect\",\n                  platform,\n                  score,\n                  source,\n                  state: readDesktopReviewPromptState(storageKey),\n                  storageKey,\n                })\n                void openDesktopStoreReview(target)\n              }}\n            />\n          ),\n        })\n      },\n    [distribution, platform, present, rateTarget, storageKey, t, userId],\n  )\n  const eligibility = useMemo(\n    () =>\n      getReviewPromptEligibility({\n        appVersion: APP_VERSION,\n        isLoggedIn: !!userId,\n        isInQuietWindow,\n        isPaidUser: isPaidRole(role),\n        isPlatformSupported,\n        now: new Date(),\n        state: reviewState,\n      }),\n    [isInQuietWindow, isPlatformSupported, reviewState, role, userId],\n  )\n\n  useEffect(() => {\n    if (!storageKey) {\n      setDesktopReviewPromptDebugAction(null)\n      setDesktopReviewPromptResetAction(null)\n      return\n    }\n\n    setDesktopReviewPromptDebugAction(() => {\n      presentReviewPrompt({\n        source: \"manual\",\n        score: undefined,\n        target: getDesktopReviewDebugTarget(),\n      })\n    })\n    setDesktopReviewPromptResetAction(() => {\n      clearDesktopReviewPromptState(storageKey)\n      hasAttemptedInSessionRef.current = false\n      updateReviewState(() => readDesktopReviewPromptState(storageKey))\n    })\n\n    return () => {\n      setDesktopReviewPromptDebugAction(null)\n      setDesktopReviewPromptResetAction(null)\n    }\n  }, [presentReviewPrompt, storageKey, updateReviewState])\n\n  useEffect(() => {\n    const removeTrigger = DebugRegistry.add(\"Review Prompt\", () => {\n      presentReviewPrompt({\n        source: \"manual\",\n        score: undefined,\n        target: getDesktopReviewDebugTarget(),\n      })\n    })\n    const removeReset = DebugRegistry.add(\"Reset Review Prompt State\", () => {\n      if (!storageKey) {\n        return\n      }\n      clearDesktopReviewPromptState(storageKey)\n      hasAttemptedInSessionRef.current = false\n      updateReviewState(() => readDesktopReviewPromptState(storageKey))\n    })\n\n    return () => {\n      removeTrigger()\n      removeReset()\n    }\n  }, [presentReviewPrompt, storageKey, updateReviewState])\n\n  useEffect(() => {\n    if (!userId || hasAttemptedInSessionRef.current || !eligibility.allowed || !rateTarget) {\n      return\n    }\n\n    const timeoutId = window.setTimeout(() => {\n      if (hasAttemptedInSessionRef.current) {\n        return\n      }\n\n      const latestState = readDesktopReviewPromptState(storageKey)\n      const latestEligibility = getReviewPromptEligibility({\n        appVersion: APP_VERSION,\n        isLoggedIn: !!userId,\n        isInQuietWindow: !hasModal && sheetStack.length === 0 && !isRouteBlocked,\n        isPaidUser: isPaidRole(roleRef.current),\n        isPlatformSupported: distribution !== \"unsupported\" && !isInMASReview,\n        now: new Date(),\n        state: latestState,\n      })\n\n      if (!latestEligibility.allowed) {\n        return\n      }\n\n      hasAttemptedInSessionRef.current = true\n      lastActionRef.current = null\n\n      tracker.reviewPromptEligible({\n        distribution,\n        platform,\n        score: latestEligibility.score,\n        source: \"auto\",\n      })\n      presentReviewPrompt({ source: \"auto\", score: latestEligibility.score })\n    }, REVIEW_PROMPT_QUIET_WINDOW_MS)\n\n    return () => {\n      window.clearTimeout(timeoutId)\n    }\n  }, [\n    distribution,\n    eligibility.allowed,\n    getLatestReviewState,\n    hasModal,\n    isInMASReview,\n    isRouteBlocked,\n    platform,\n    presentReviewPrompt,\n    rateTarget,\n    sheetStack.length,\n    storageKey,\n    userId,\n  ])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/review-prompt/use-review-prompt-state.ts",
    "content": "import type { ReviewPromptState } from \"@follow/shared/review-prompt\"\nimport { normalizeReviewPromptState } from \"@follow/shared/review-prompt\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\n\nimport type { DesktopReviewDistribution } from \"./utils\"\nimport {\n  getDesktopReviewDistribution,\n  getDesktopReviewPlatform,\n  getDesktopReviewRateTarget,\n  getDesktopReviewStorageKey,\n  readDesktopReviewPromptState,\n  writeDesktopReviewPromptState,\n} from \"./utils\"\n\nexport const useDesktopReviewPromptState = () => {\n  const user = useWhoami()\n  const distribution = getDesktopReviewDistribution()\n  const platform = getDesktopReviewPlatform()\n  const rateTarget = getDesktopReviewRateTarget()\n\n  const storageKey = useMemo(() => {\n    if (!user?.id) {\n      return null\n    }\n\n    return getDesktopReviewStorageKey(user.id, distribution)\n  }, [distribution, user?.id])\n\n  const [reviewState, setReviewState] = useState(() => readDesktopReviewPromptState(storageKey))\n\n  useEffect(() => {\n    setReviewState(readDesktopReviewPromptState(storageKey))\n  }, [storageKey])\n\n  const getLatestReviewState = useCallback(\n    () => readDesktopReviewPromptState(storageKey),\n    [storageKey],\n  )\n\n  const updateReviewState = useCallback(\n    (updater: (state: ReviewPromptState) => ReviewPromptState) => {\n      const nextState = normalizeReviewPromptState(updater(getLatestReviewState()))\n      writeDesktopReviewPromptState(storageKey, nextState)\n      setReviewState(nextState)\n      return nextState\n    },\n    [getLatestReviewState, storageKey],\n  )\n\n  return {\n    distribution: distribution as DesktopReviewDistribution,\n    getLatestReviewState,\n    platform,\n    rateTarget,\n    reviewState,\n    storageKey,\n    updateReviewState,\n    userId: user?.id ?? null,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/review-prompt/utils.ts",
    "content": "import type { ReviewPromptOutcome, ReviewPromptState } from \"@follow/shared/review-prompt\"\nimport {\n  createReviewPromptState,\n  normalizeReviewPromptState,\n  recordReviewPromptOutcome,\n} from \"@follow/shared/review-prompt\"\nimport { tracker } from \"@follow/tracker\"\nimport { getStorageNS } from \"@follow/utils/ns\"\n\nimport { ipcServices } from \"~/lib/client\"\n\nexport const REVIEW_PROMPT_QUIET_WINDOW_MS = 5000\n\nexport type DesktopReviewDistribution = \"mas\" | \"microsoft_store\" | \"unsupported\"\nexport type DesktopReviewRateTarget = \"mas\" | \"microsoft_store\" | null\n\nconst APPLE_REVIEW_URL =\n  \"https://apps.apple.com/us/app/folo-follow-everything/id6739802604?action=write-review\"\nconst MICROSOFT_PRODUCT_ID = \"9NVFZPV0V0HT\"\nconst MICROSOFT_REVIEW_URI = `ms-windows-store://review/?ProductId=${MICROSOFT_PRODUCT_ID}`\nconst MICROSOFT_REVIEW_URL = \"https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct\"\nconst SUPPORT_EMAIL = \"support@folo.is\"\nconst REVIEW_PROMPT_STORAGE_PREFIX = getStorageNS(\"review-prompt\")\n\nexport const getDesktopReviewPlatform = () =>\n  window.platform === \"win32\" ? \"windows\" : window.platform === \"darwin\" ? \"macos\" : \"desktop\"\n\nexport const getDesktopReviewDistribution = (): DesktopReviewDistribution => {\n  if (typeof process !== \"undefined\" && process.mas) {\n    return \"mas\"\n  }\n\n  if (window.api?.isWindowsStore) {\n    return \"microsoft_store\"\n  }\n\n  return \"unsupported\"\n}\n\nexport const getDesktopReviewRateTarget = (): DesktopReviewRateTarget => {\n  if (window.platform === \"darwin\") {\n    return \"mas\"\n  }\n\n  if (window.platform === \"win32\") {\n    return \"microsoft_store\"\n  }\n\n  return null\n}\n\nexport const getDesktopReviewDebugTarget = (): DesktopReviewRateTarget => {\n  const defaultTarget = getDesktopReviewRateTarget()\n  if (defaultTarget) {\n    return defaultTarget\n  }\n\n  return /Windows/i.test(window.navigator.userAgent) ? \"microsoft_store\" : \"mas\"\n}\n\nexport const getDesktopReviewStorageKey = (\n  userId: string,\n  distribution: DesktopReviewDistribution,\n) => `${REVIEW_PROMPT_STORAGE_PREFIX}:${distribution}:${userId}`\n\nconst openExternal = async (url: string) => {\n  if (ipcServices?.app.openExternal) {\n    await ipcServices.app.openExternal(url)\n    return\n  }\n\n  window.open(url, \"_blank\", \"noopener,noreferrer\")\n}\n\nexport const openDesktopStoreReview = async (target: DesktopReviewRateTarget) => {\n  switch (target) {\n    case \"mas\": {\n      await openExternal(APPLE_REVIEW_URL)\n      return\n    }\n    case \"microsoft_store\": {\n      try {\n        await openExternal(MICROSOFT_REVIEW_URI)\n      } catch {\n        await openExternal(MICROSOFT_REVIEW_URL)\n      }\n      return\n    }\n    default: {\n      return\n    }\n  }\n}\n\nexport const openDesktopFeedbackEmail = async ({\n  distribution,\n  userId,\n}: {\n  distribution: DesktopReviewDistribution\n  userId: string | null\n}) => {\n  const subject = \"Folo feedback\"\n  const body = [\n    \"Hi Folo team,\",\n    \"\",\n    \"Here is my feedback:\",\n    \"\",\n    `Platform: ${getDesktopReviewPlatform()}`,\n    `Distribution: ${distribution}`,\n    `Version: ${APP_VERSION}`,\n    `User ID: ${userId ?? \"anonymous\"}`,\n  ].join(\"\\n\")\n\n  await openExternal(\n    `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`,\n  )\n}\n\nexport const readDesktopReviewPromptState = (storageKey: string | null): ReviewPromptState => {\n  if (!storageKey) {\n    return createReviewPromptState()\n  }\n\n  try {\n    const raw = window.localStorage.getItem(storageKey)\n    if (!raw) {\n      return createReviewPromptState()\n    }\n    return normalizeReviewPromptState(JSON.parse(raw) as Partial<ReviewPromptState>)\n  } catch {\n    return createReviewPromptState()\n  }\n}\n\nexport const writeDesktopReviewPromptState = (\n  storageKey: string | null,\n  state: ReviewPromptState,\n) => {\n  if (!storageKey) {\n    return\n  }\n\n  window.localStorage.setItem(storageKey, JSON.stringify(state))\n}\n\nexport const clearDesktopReviewPromptState = (storageKey: string | null) => {\n  if (!storageKey) {\n    return\n  }\n\n  window.localStorage.removeItem(storageKey)\n}\n\nexport const trackDesktopReviewOutcome = ({\n  distribution,\n  outcome,\n  platform,\n  score,\n  source,\n}: {\n  distribution: DesktopReviewDistribution\n  outcome: ReviewPromptOutcome\n  platform: string\n  score?: number\n  source: \"auto\" | \"manual\"\n}) => {\n  switch (outcome) {\n    case \"dismissed\": {\n      tracker.reviewPromptDismissed({ distribution, platform, source })\n      return\n    }\n    case \"negative_feedback\": {\n      tracker.reviewPromptNegative({ distribution, platform, source })\n      tracker.reviewPromptFeedbackOpened({ distribution, platform, source })\n      return\n    }\n    case \"positive_store_redirect\": {\n      tracker.reviewPromptPositive({ distribution, platform, source })\n      tracker.reviewPromptStoreOpened({ distribution, platform, source })\n      return\n    }\n    case \"native_request\": {\n      tracker.reviewPromptNativeRequested({ distribution, platform, score, source })\n    }\n  }\n}\n\nexport const persistDesktopReviewOutcome = ({\n  appVersion,\n  distribution,\n  outcome,\n  platform,\n  score,\n  source,\n  state,\n  storageKey,\n}: {\n  appVersion: string\n  distribution: DesktopReviewDistribution\n  outcome: ReviewPromptOutcome\n  platform: string\n  score?: number\n  source: \"auto\" | \"manual\"\n  state: ReviewPromptState\n  storageKey: string | null\n}) => {\n  const nextState = recordReviewPromptOutcome(state, outcome, new Date(), appVersion)\n  writeDesktopReviewPromptState(storageKey, nextState)\n  trackDesktopReviewOutcome({ distribution, outcome, platform, score, source })\n  return nextState\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/rsshub/add-modal-content.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/Input.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport type { RSSHubListItem } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useEffect } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { z } from \"zod\"\n\nimport { ShikiHighLighter } from \"~/components/ui/code-highlighter\"\nimport { useShikiDefaultTheme } from \"~/components/ui/code-highlighter/shiki/hooks\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { Queries } from \"~/queries\"\nimport { useAddRSSHubMutation } from \"~/queries/rsshub\"\n\nexport function AddModalContent({\n  dismiss,\n  instance,\n}: {\n  dismiss: () => void\n  instance?: RSSHubListItem\n}) {\n  const { t } = useTranslation(\"settings\")\n  const addRSSHubMutation = useAddRSSHubMutation()\n  const shikiTheme = useShikiDefaultTheme()\n  const me = whoami()\n  const details = useAuthQuery(Queries.rsshub.get({ id: instance?.id || \"\" }), {\n    enabled: !!instance?.id,\n  })\n\n  const formSchema = z.object({\n    baseUrl: z.string().url(),\n    accessKey: z.string().optional(),\n  })\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n  })\n\n  const onSubmit = (data: z.infer<typeof formSchema>) => {\n    addRSSHubMutation.mutate({ ...data, id: instance?.id })\n  }\n\n  useEffect(() => {\n    if (addRSSHubMutation.isSuccess) {\n      dismiss()\n    }\n  }, [addRSSHubMutation.isSuccess])\n\n  useEffect(() => {\n    if (details.data?.instance.baseUrl) {\n      form.reset({\n        baseUrl: details.data.instance.baseUrl,\n        accessKey: details.data.instance.accessKey || undefined,\n      })\n    }\n  }, [details.data])\n\n  const codes = [\n    `FOLLOW_OWNER_USER_ID=${me?.handle || me?.id}      # User id or handle of your follow account`,\n    `FOLLOW_DESCRIPTION=${instance?.description || `${me?.name}'s instance`} # The description of your instance`,\n    `FOLLOW_PRICE=${instance?.price || 100}                 # The monthly price of your instance, set to 0 means free.`,\n    `FOLLOW_USER_LIMIT=${instance?.userLimit || 1000}           # The user limit of your instance, set it to 0 or 1 can make your instance private, leaving it empty means no restriction`,\n  ]\n\n  return (\n    <div className=\"max-w-[550px] space-y-4 lg:min-w-[550px]\">\n      <div className=\"text-sm\">{t(\"rsshub.addModal.description\")}</div>\n\n      <ShikiHighLighter\n        transparent\n        theme={shikiTheme}\n        className=\"group mt-3 cursor-auto select-text whitespace-pre break-words rounded-lg bg-zinc-100 text-sm dark:bg-neutral-800 [&_pre]:whitespace-pre [&_pre]:break-words\"\n        code={codes.join(\"\\n\")}\n        language=\"dotenv\"\n      />\n      {details.isLoading ? (\n        <div className=\"center mt-12 flex w-full flex-col gap-8\">\n          <LoadingCircle size=\"large\" />\n        </div>\n      ) : (\n        <Form {...form}>\n          <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n            <FormField\n              control={form.control}\n              name=\"baseUrl\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t(\"rsshub.addModal.base_url_label\")}</FormLabel>\n                  <FormControl>\n                    <Input placeholder=\"https://\" {...field} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <FormField\n              control={form.control}\n              name=\"accessKey\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t(\"rsshub.addModal.access_key_label\")}</FormLabel>\n                  <FormControl>\n                    <Input {...field} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <div className=\"flex items-center justify-end\">\n              <Button type=\"submit\" isLoading={addRSSHubMutation.isPending}>\n                {t(\"rsshub.addModal.add\")}\n              </Button>\n            </div>\n          </form>\n        </Form>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/rsshub/delete-modal-content.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { followClient } from \"~/lib/api-client\"\nimport { Queries } from \"~/queries\"\n\nexport const ConfirmDeleteModalContent = ({ id, dismiss }: { dismiss: () => void; id: string }) => {\n  const { t } = useTranslation(\"settings\")\n  const deleteMutation = useMutation({\n    mutationFn: () => {\n      return followClient.api.rsshub.delete({ id })\n    },\n    onSuccess: () => {\n      Queries.rsshub.list().invalidate()\n      toast.success(t(\"rsshub.table.delete.success\"))\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n  })\n\n  return (\n    <div className=\"w-[540px]\">\n      <div className=\"mb-4\">\n        <i className=\"i-mingcute-warning-fill -mb-1 mr-1 size-5 text-red-500\" />\n        {t(\"rsshub.table.delete.confirm\")}\n      </div>\n      <div className=\"flex justify-end\">\n        <Button\n          buttonClassName=\"bg-red-600\"\n          onClick={() => {\n            deleteMutation.mutate()\n            dismiss()\n          }}\n        >\n          {t(\"rsshub.table.delete.label\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/rsshub/set-modal-content.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Card, CardContent } from \"@follow/components/ui/card/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/Input.js\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport type { RSSHubListItem } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useEffect } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { z } from \"zod\"\n\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { UserAvatar } from \"~/modules/user/UserAvatar\"\nimport { Queries } from \"~/queries\"\nimport { useSetRSSHubMutation } from \"~/queries/rsshub\"\n\nimport { useTOTPModalWrapper } from \"../profile/hooks\"\n\nexport function SetModalContent({\n  dismiss,\n  instance,\n}: {\n  dismiss: () => void\n  instance: RSSHubListItem\n}) {\n  const { t } = useTranslation(\"settings\")\n  const setRSSHubMutation = useSetRSSHubMutation()\n  const preset = useTOTPModalWrapper(setRSSHubMutation.mutateAsync)\n  const details = useAuthQuery(Queries.rsshub.get({ id: instance.id }))\n  const hasPurchase = !!details.data?.purchase\n  const price = instance.ownerUserId === whoami()?.id ? 0 : instance.price\n\n  const formSchema = z.object({\n    months: z.coerce\n      .number()\n      .min(hasPurchase ? 0 : 1)\n      .max(12),\n  })\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      months: hasPurchase ? 0 : 1,\n    },\n  })\n\n  const months = form.watch(\"months\")\n\n  const onSubmit = (data: z.infer<typeof formSchema>) => {\n    preset({ id: instance.id, durationInMonths: data.months })\n  }\n\n  useEffect(() => {\n    if (setRSSHubMutation.isSuccess) {\n      dismiss()\n    }\n  }, [setRSSHubMutation.isSuccess])\n\n  return (\n    <div className=\"max-w-[550px] space-y-4 lg:min-w-[550px]\">\n      <Card>\n        <CardContent className=\"max-w-2xl space-y-2 p-6\">\n          <div className=\"mb-3 text-lg font-medium\">{t(\"rsshub.useModal.about\")}</div>\n          <table className=\"w-full\">\n            <tbody className=\"divide-y-8 divide-transparent\">\n              <tr>\n                <td className=\"w-24 text-sm text-text-secondary\">{t(\"rsshub.table.owner\")}</td>\n                <td>\n                  <UserAvatar\n                    userId={instance.ownerUserId}\n                    className=\"h-auto justify-start p-0\"\n                    avatarClassName=\"size-6\"\n                  />\n                </td>\n              </tr>\n              <tr>\n                <td className=\"text-sm text-text-secondary\">{t(\"rsshub.table.description\")}</td>\n                <td className=\"line-clamp-2\">{instance.description}</td>\n              </tr>\n              <tr>\n                <td className=\"text-sm text-text-secondary\">{t(\"rsshub.table.price\")}</td>\n                <td className=\"flex items-center gap-1\">\n                  {instance.price} <i className=\"i-mgc-power text-folo\" />\n                </td>\n              </tr>\n              <tr>\n                <td className=\"text-sm text-text-secondary\">{t(\"rsshub.table.userCount\")}</td>\n                <td>{instance.userCount}</td>\n              </tr>\n              <tr>\n                <td className=\"text-sm text-text-secondary\">{t(\"rsshub.table.userLimit\")}</td>\n                <td>{instance.userLimit || t(\"rsshub.table.unlimited\")}</td>\n              </tr>\n            </tbody>\n          </table>\n        </CardContent>\n      </Card>\n      {details.data?.purchase && (\n        <div>\n          <div className=\"text-sm text-text-secondary\">\n            {t(\"rsshub.useModal.purchase_expires_at\")}\n          </div>\n          <div className=\"line-clamp-2\">\n            {new Date(details.data.purchase.expiresAt).toLocaleString()}\n          </div>\n        </div>\n      )}\n      <Form {...form}>\n        <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n          {price > 0 && (\n            <FormField\n              control={form.control}\n              name=\"months\"\n              render={({ field }) => (\n                <FormItem className=\"flex flex-row items-center gap-4\">\n                  <FormLabel>{t(\"rsshub.useModal.months_label\")}</FormLabel>\n                  <FormControl className=\"!mt-0\">\n                    <div className=\"flex items-center gap-10\">\n                      <div className=\"space-x-2\">\n                        <Input\n                          className=\"w-24\"\n                          type=\"number\"\n                          inputMode=\"numeric\"\n                          pattern=\"[0-9]*\"\n                          max={12}\n                          min={hasPurchase ? 0 : 1}\n                          {...field}\n                        />\n                        <span className=\"text-sm text-text-secondary\">\n                          {t(\"rsshub.useModal.month\")}\n                        </span>\n                      </div>\n                    </div>\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n          )}\n          <div className=\"flex items-center justify-end\">\n            <Button type=\"submit\" isLoading={setRSSHubMutation.isPending}>\n              {price ? (\n                <Trans\n                  ns=\"settings\"\n                  i18nKey={\"rsshub.useModal.useWith\"}\n                  components={{ Power: <i className=\"i-mgc-power ml-1 text-white\" /> }}\n                  values={{ amount: price * months }}\n                />\n              ) : (\n                t(\"rsshub.table.use\")\n              )}\n            </Button>\n          </div>\n        </form>\n      </Form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/constants.ts",
    "content": "export const SETTING_MODAL_ID = \"setting-modal\"\n\nexport const GUEST_ALLOWED_SETTING_TABS = [\"general\", \"appearance\", \"about\", \"shortcuts\"] as const\n\nconst GUEST_ALLOWED_SETTING_TABS_SET = new Set<string>(GUEST_ALLOWED_SETTING_TABS)\n\nexport const isGuestAccessibleSettingTab = (tab?: string | null) => {\n  if (!tab) return false\n  return GUEST_ALLOWED_SETTING_TABS_SET.has(tab)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/context.tsx",
    "content": "import { createContext } from \"react\"\n\nexport const IsInSettingIndependentWindowContext = createContext<boolean>(false)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/control.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.jsx\"\nimport { Input, TextArea } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.jsx\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ChangeEventHandler, ReactNode } from \"react\"\nimport { useCallback, useId, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { titleCase } from \"title-case\"\n\nimport { useIsPaymentEnabled } from \"~/atoms/server-configs\"\n\nimport { SettingPaidLevels } from \"./helper/setting-builder\"\nimport { useSetSettingTab } from \"./modal/context\"\n\nexport const PaidBadge: Component<{\n  paidLevel: SettingPaidLevels\n}> = ({ paidLevel }) => {\n  const { t } = useTranslation(\"settings\")\n  const setTab = useSetSettingTab()\n  const isPaymentEnabled = useIsPaymentEnabled()\n\n  const handleClick = useCallback(\n    (e) => {\n      e.preventDefault()\n      setTab(\"plan\")\n    },\n    [setTab],\n  )\n\n  if (!isPaymentEnabled) {\n    return null\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <i className=\"i-mgc-power block text-accent\" onClick={handleClick} />\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent>\n          {paidLevel === SettingPaidLevels.FreeLimited && t(\"control.paid_badge.free_limited\")}\n          {paidLevel === SettingPaidLevels.Basic && t(\"control.paid_badge.basic_or_higher\")}\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n\nexport const SettingCheckbox: Component<{\n  label: string\n  checked: boolean\n  onCheckedChange: (checked: boolean) => void\n}> = ({ checked, label, onCheckedChange }) => {\n  const id = useId()\n  return (\n    <div className=\"mb-2 flex items-center gap-4\">\n      <Checkbox\n        id={id}\n        checked={checked}\n        onCheckedChange={onCheckedChange}\n        className=\"cursor-auto\"\n      />\n      <Label htmlFor={id}>{titleCase(label)}</Label>\n    </div>\n  )\n}\n\nexport const SettingSwitch: Component<{\n  label: string\n  checked: boolean\n  onCheckedChange: (checked: boolean) => void\n  disabled?: boolean\n  paidLevel?: SettingPaidLevels\n}> = ({ checked, label, onCheckedChange, className, disabled, paidLevel }) => {\n  const id = useId()\n  const handleCheckedChange = (checked: boolean) => {\n    onCheckedChange(checked)\n  }\n  return (\n    <div className={cn(\"mb-3 flex items-center justify-between gap-4\", className)}>\n      <Label htmlFor={id} className=\"flex items-center gap-1\">\n        <span>{titleCase(label)}</span>\n        {!!paidLevel && <PaidBadge paidLevel={paidLevel} />}\n      </Label>\n      <Switch id={id} checked={checked} onCheckedChange={handleCheckedChange} disabled={disabled} />\n    </div>\n  )\n}\n\nexport const SettingInput: Component<{\n  label: string\n  value: string\n  onChange: ChangeEventHandler<HTMLInputElement>\n  type: string\n  vertical?: boolean\n  labelClassName?: string\n}> = ({ value, label, onChange, labelClassName, className, type, vertical }) => {\n  const id = useId()\n\n  return (\n    <div\n      className={cn(\n        \"mb-1 flex\",\n        vertical ? \"mb-2 flex-col gap-3\" : \"flex-row items-center justify-between gap-12\",\n        className,\n      )}\n    >\n      <Label\n        className={cn(\"shrink-0 text-sm font-medium leading-none\", labelClassName)}\n        htmlFor={id}\n      >\n        {titleCase(label)}\n      </Label>\n      <Input type={type} id={id} value={value} onChange={onChange} className=\"text-xs\" />\n    </div>\n  )\n}\n\nexport const SettingTextArea: Component<{\n  label: string\n  value: string\n  onChange: ChangeEventHandler<HTMLTextAreaElement>\n  vertical?: boolean\n  labelClassName?: string\n}> = ({ value, label, onChange, labelClassName, className, vertical }) => {\n  const id = useId()\n\n  return (\n    <div\n      className={cn(\n        \"mb-1 flex\",\n        vertical ? \"mb-2 flex-col gap-3\" : \"flex-row items-center justify-between gap-12\",\n        className,\n      )}\n    >\n      <Label\n        className={cn(\"shrink-0 text-sm font-medium leading-none\", labelClassName)}\n        htmlFor={id}\n      >\n        {titleCase(label)}\n      </Label>\n      <TextArea id={id} value={value} onChange={onChange} className=\"text-xs\" />\n    </div>\n  )\n}\n\nexport const SettingTabbedSegment: Component<{\n  label: ReactNode\n  value: string\n  onValueChanged?: (value: string) => void\n  values: { value: string; label: string; icon?: ReactNode }[]\n  description?: string\n}> = ({ label, className, value, values, onValueChanged, description }) => {\n  const [currentValue, setCurrentValue] = useState(value)\n\n  return (\n    <>\n      <div className={cn(\"mb-3 flex items-center justify-between gap-4\", className)}>\n        <label className=\"text-sm font-medium leading-none\">\n          {typeof label === \"string\" ? titleCase(label) : label}\n        </label>\n\n        <SegmentGroup\n          className=\"h-8\"\n          value={currentValue}\n          onValueChanged={(v) => {\n            setCurrentValue(v)\n            onValueChanged?.(v)\n          }}\n        >\n          {values.map((v) => (\n            <SegmentItem\n              key={v.value}\n              value={v.value}\n              label={\n                <div className=\"flex items-center gap-1\">\n                  {v.icon}\n                  <span>{v.label}</span>\n                </div>\n              }\n            />\n          ))}\n        </SegmentGroup>\n      </div>\n      {description && <SettingDescription className=\"-mt-3\">{description}</SettingDescription>}\n    </>\n  )\n}\n\nexport const SettingDescription: Component = ({ children, className }) => (\n  <small className={cn(\"block w-4/5 text-body leading-snug text-text-secondary\", className)}>\n    {children}\n  </small>\n)\n\nexport const SettingActionItem = ({\n  label,\n  action,\n  buttonText,\n}: {\n  label: ReactNode\n  action: () => void\n  buttonText: string\n}) => (\n  <div className={cn(\"relative mb-2 mt-4 flex items-center justify-between gap-4\")}>\n    <div className=\"text-sm font-medium\">\n      {typeof label === \"string\" ? titleCase(label) : label}\n    </div>\n    <Button variant=\"outline\" size=\"sm\" onClick={action}>\n      {buttonText}\n    </Button>\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/helper/EnhancedIndicator.tsx",
    "content": "import { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { IconTransition } from \"~/components/ux/transition/icon\"\n\nexport const EnhancedSettingsIndicator = () => {\n  const enhancedSettings = useGeneralSettingKey(\"enhancedSettings\")\n  const { t } = useTranslation(\"settings\")\n\n  if (!enhancedSettings) return null\n  return (\n    <Tooltip>\n      <TooltipTrigger>\n        <IconTransition animatedKey={enhancedSettings ? \"done\" : \"init\"} preset=\"fade\">\n          {enhancedSettings ? (\n            <i className=\"i-mgc-rocket-cute-fi size-4 text-accent\" />\n          ) : (\n            <i className=\"i-mgc-rocket-cute-re size-4 opacity-50\" />\n          )}\n        </IconTransition>\n      </TooltipTrigger>\n      <RootPortal>\n        <TooltipContent className=\"max-w-[40ch]\">\n          {enhancedSettings ? (\n            <p>{t(\"general.enhanced.enabled.tip\")}</p>\n          ) : (\n            <p>{t(\"general.enhanced.disabled.tip\")}</p>\n          )}\n        </TooltipContent>\n      </RootPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/helper/SyncIndicator.tsx",
    "content": "import { PhCloudCheck } from \"@follow/components/icons/PhCloudCheck.jsx\"\nimport { PhCloudWarning } from \"@follow/components/icons/PhCloudWarning.jsx\"\nimport { PhCloudX } from \"@follow/components/icons/PhCloudX.jsx\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport { useIsOnline } from \"@follow/hooks\"\nimport { useEffect, useMemo, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { settings } from \"~/queries/settings\"\n\nimport { useSettingContextSelector } from \"../modal/hooks\"\nimport { settingSyncQueue } from \"./sync-queue\"\n\nexport const SettingSyncIndicator = () => {\n  const { t } = useTranslation()\n  const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {})\n  const canSync = useSettingContextSelector((s) => s.canSync)\n\n  const isOnline = useIsOnline()\n  const onceRef = useRef(false)\n  useEffect(() => {\n    if (!isLoading && remoteSettings && !onceRef.current) {\n      const hasSetting = JSON.stringify(remoteSettings.settings) !== \"{}\"\n      onceRef.current = true\n      if (hasSetting) {\n        return\n      }\n      // Replace local to remote\n      settingSyncQueue.replaceRemote()\n    }\n  }, [remoteSettings, isLoading])\n\n  const metaInfo: {\n    icon: React.FC<React.SVGProps<SVGSVGElement>>\n    text: string\n  } = useMemo(() => {\n    switch (true) {\n      case !isOnline: {\n        return {\n          icon: PhCloudX,\n          text: t(\"sync_indicator.offline\"),\n        }\n      }\n      case !canSync: {\n        return {\n          icon: PhCloudWarning,\n          text: t(\"sync_indicator.disabled\"),\n        }\n      }\n      default: {\n        return {\n          icon: PhCloudCheck,\n          text: t(\"sync_indicator.synced\"),\n        }\n      }\n    }\n  }, [isOnline, canSync, t])\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <div className=\"size-5\">\n          <metaInfo.icon className=\"size-4\" />\n        </div>\n      </TooltipTrigger>\n      <RootPortal>\n        <TooltipContent>\n          <div className=\"text-center text-xs\">{metaInfo.text}</div>\n        </TooltipContent>\n      </RootPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts",
    "content": "import type { SettingNamespace } from \"@follow/shared/settings/constants\"\nimport { getSettingPaidLevel } from \"@follow/shared/settings/constants\"\nimport type { JSX } from \"react/jsx-runtime\"\n\nimport type { SettingItem } from \"./setting-builder\"\nimport { createSettingBuilder } from \"./setting-builder\"\n\nexport const createDefineSettingItem =\n  <T>(\n    settingNamespace: SettingNamespace,\n    _getSetting: () => T,\n    setSetting: (key: any, value: Partial<T>) => void,\n  ) =>\n  <K extends keyof T>(\n    key: K,\n    options: {\n      label: string\n      description?: string | JSX.Element\n      onChange?: (value: T[K]) => void\n      onAfterChange?: (value: T[K]) => void\n      hide?: boolean\n    } & Omit<SettingItem<any>, \"onChange\" | \"description\" | \"label\" | \"hide\" | \"key\" | \"paidLevel\">,\n  ): any => {\n    const { label, description, onChange, hide, onAfterChange, ...rest } = options\n    const paidLevel = getSettingPaidLevel(settingNamespace, String(key))\n\n    if (hide) return null\n    return {\n      key,\n      label,\n      description,\n      onChange: (value: any) => {\n        try {\n          if (onChange) return onChange(value as any)\n          setSetting(key, value as any)\n        } finally {\n          onAfterChange?.(value as any)\n        }\n      },\n      disabled: hide,\n      paidLevel,\n      ...rest,\n    } as SettingItem<any>\n  }\n\nexport const createSetting = <T extends object>(\n  settingNamespace: SettingNamespace,\n  useSetting: () => T,\n  setSetting: (key: any, value: Partial<T>) => void,\n) => {\n  const SettingBuilder = createSettingBuilder(useSetting)\n  const defineSettingItem = createDefineSettingItem(settingNamespace, useSetting, setSetting)\n  return {\n    SettingBuilder,\n    defineSettingItem,\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx",
    "content": "/* eslint-disable @eslint-react/no-array-index-key */\nimport { UserRole } from \"@follow/constants\"\nimport { SettingPaidLevels } from \"@follow/shared/settings/constants\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { FC, ReactNode } from \"react\"\nimport * as React from \"react\"\nimport { isValidElement } from \"react\"\n\nimport { SettingActionItem, SettingDescription, SettingInput, SettingSwitch } from \"../control\"\nimport { SettingItemGroup, SettingSectionTitle } from \"../section\"\n\nexport { SettingPaidLevels } from \"@follow/shared/settings/constants\"\n\ntype SharedSettingItem = {\n  disabled?: boolean\n}\n\nexport type SettingItem<T, K extends keyof T = keyof T> = {\n  key: K\n  label: string\n  description?: string\n  onChange: (value: T[K]) => void\n  onChangeGuard?: (value: T[K]) => \"handled\" | void\n  type?: \"password\"\n\n  vertical?: boolean\n\n  componentProps?: {\n    labelClassName?: string\n    className?: string\n    [key: string]: any\n  }\n  paidLevel?: SettingPaidLevels\n} & SharedSettingItem\n\ntype SectionSettingItem = {\n  type: \"title\"\n  value?: string\n  id?: string\n} & SharedSettingItem\n\ntype ActionSettingItem = {\n  label: string\n  action: () => void\n  description?: string\n  buttonText: string\n} & SharedSettingItem\ntype CustomSettingItem = ReactNode | FC\n\nexport const createSettingBuilder =\n  <T extends object>(useSetting: () => T) =>\n  <K extends keyof T>(props: {\n    settings: (\n      | SettingItem<T, K>\n      | SectionSettingItem\n      | CustomSettingItem\n      | ActionSettingItem\n      | boolean\n    )[]\n  }) => {\n    const { settings } = props\n    const settingObject = useSetting()\n    const role = useUserRole()\n\n    const filteredSettings = settings.filter((i) => !!i)\n    return filteredSettings.map((setting, index) => {\n      if (isValidElement(setting)) return setting\n      if (typeof setting === \"function\") {\n        return React.createElement(setting, { key: index })\n      }\n      const assertSetting = setting as SettingItem<T> | SectionSettingItem | ActionSettingItem\n\n      if (!assertSetting) return null\n      // if (assertSetting.disabled) return null\n\n      const nextItem = filteredSettings[index + 1]\n      // If has no next item or next item is also a title, then it is an empty section\n      const isEmptySection =\n        !nextItem ||\n        (typeof nextItem === \"object\" && \"type\" in nextItem && nextItem.type === \"title\")\n\n      const isValidTitle =\n        \"type\" in assertSetting &&\n        assertSetting.type === \"title\" &&\n        assertSetting.value &&\n        !isEmptySection\n\n      if (isValidTitle) {\n        return (\n          <SettingSectionTitle\n            key={index}\n            title={assertSetting.value}\n            sectionId={assertSetting.id}\n          />\n        )\n      }\n      if (\"type\" in assertSetting && assertSetting.type === \"title\") {\n        return null\n      }\n      const disabledForRole =\n        role === UserRole.Free &&\n        \"paidLevel\" in assertSetting &&\n        assertSetting.paidLevel !== undefined &&\n        assertSetting.paidLevel !== SettingPaidLevels.Free &&\n        assertSetting.paidLevel !== SettingPaidLevels.FreeLimited\n\n      let ControlElement: React.ReactNode\n\n      if (\"key\" in assertSetting) {\n        switch (typeof settingObject[assertSetting.key]) {\n          case \"boolean\": {\n            ControlElement = (\n              <SettingSwitch\n                className=\"mt-4\"\n                checked={settingObject[assertSetting.key] as boolean}\n                onCheckedChange={(checked) => {\n                  if (assertSetting.onChangeGuard) {\n                    const handled = assertSetting.onChangeGuard(checked as T[keyof T])\n                    if (handled === \"handled\") {\n                      return\n                    }\n                  }\n                  assertSetting.onChange(checked as T[keyof T])\n                }}\n                label={assertSetting.label}\n                disabled={assertSetting.disabled || disabledForRole}\n                paidLevel={assertSetting.paidLevel}\n              />\n            )\n            break\n          }\n          case \"string\": {\n            ControlElement = (\n              <SettingInput\n                vertical={assertSetting.vertical}\n                labelClassName={assertSetting.componentProps?.labelClassName}\n                type={assertSetting.type || \"text\"}\n                className=\"mt-4\"\n                value={settingObject[assertSetting.key] as string}\n                onChange={(event) => assertSetting.onChange(event.target.value as T[keyof T])}\n                label={assertSetting.label}\n              />\n            )\n            break\n          }\n          default: {\n            return null\n          }\n        }\n      } else if (\"action\" in assertSetting) {\n        ControlElement = <SettingActionItem {...assertSetting} key={index} />\n      }\n\n      return (\n        <SettingItemGroup key={index}>\n          {ControlElement}\n          {!!assertSetting.description && (\n            <SettingDescription>{assertSetting.description}</SettingDescription>\n          )}\n        </SettingItemGroup>\n      )\n    })\n  }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts",
    "content": "import type { AISettings, GeneralSettings, UISettings } from \"@follow/shared/settings/interface\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { tracker } from \"@follow/tracker\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { isEmptyObject, sleep } from \"@follow/utils/utils\"\nimport type { SettingsTab } from \"@follow-app/client-sdk\"\nimport { FollowAPIError } from \"@follow-app/client-sdk\"\nimport type { PrimitiveAtom } from \"jotai\"\n\nimport { __aiSettingAtom, aiServerSyncWhiteListKeys, getAISettings } from \"~/atoms/settings/ai\"\nimport {\n  __generalSettingAtom,\n  generalServerSyncWhiteListKeys,\n  getGeneralSettings,\n} from \"~/atoms/settings/general\"\nimport { __uiSettingAtom, getUISettings, uiServerSyncWhiteListKeys } from \"~/atoms/settings/ui\"\nimport { followClient } from \"~/lib/api-client\"\nimport { jotaiStore } from \"~/lib/jotai\"\nimport { settings } from \"~/queries/settings\"\n\ntype SettingMapping = {\n  appearance: UISettings\n  general: GeneralSettings\n  ai: AISettings\n}\n\nconst pickSyncPayload = <T extends object>(payload: T, keys: readonly (keyof T | string)[]) => {\n  const nextPayload = {} as Partial<T>\n  const record = payload as Record<string, unknown>\n\n  for (const key of keys) {\n    if (Object.prototype.hasOwnProperty.call(record, key)) {\n      nextPayload[key as keyof T] = record[key as string] as T[keyof T]\n    }\n  }\n\n  return nextPayload\n}\n\nconst localSettingGetterMap = {\n  appearance: () => getUISettings(),\n  general: () => getGeneralSettings(),\n  ai: () => getAISettings(),\n}\n\nconst createInternalSetter =\n  <T>(atom: PrimitiveAtom<T>) =>\n  (payload: Partial<T>) => {\n    const current = jotaiStore.get(atom)\n    jotaiStore.set(atom, { ...current, ...payload })\n  }\n\nconst localSettingSetterMap = {\n  appearance: createInternalSetter(__uiSettingAtom),\n  general: createInternalSetter(__generalSettingAtom),\n  ai: createInternalSetter(__aiSettingAtom),\n}\n\nconst settingWhiteListMap = {\n  appearance: uiServerSyncWhiteListKeys,\n  general: generalServerSyncWhiteListKeys,\n  ai: aiServerSyncWhiteListKeys,\n}\n\nconst bizSettingKeyToTabMapping = {\n  ui: \"appearance\",\n  general: \"general\",\n  ai: \"ai\",\n}\n\nconst isUnauthorizedError = (error: unknown) => {\n  if (error instanceof FollowAPIError) {\n    return error.status === 401\n  }\n\n  if (error && typeof error === \"object\" && \"status\" in error) {\n    return Number((error as { status?: unknown }).status) === 401\n  }\n\n  return false\n}\n\nexport type SettingSyncTab = keyof SettingMapping\nexport interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {\n  tab: T\n  payload: Partial<SettingMapping[T]>\n  date: number\n}\n\ninterface PersistedSettingSyncQueue {\n  ownerUserId: string | null\n  queue: SettingSyncQueueItem[]\n}\n\nclass SettingSyncQueue {\n  queue: SettingSyncQueueItem[] = []\n  private ownerUserId: string | null = null\n\n  private getCurrentUserId() {\n    return whoami()?.id ?? null\n  }\n\n  private bindQueueOwner(currentUserId: string) {\n    if (this.ownerUserId === null) {\n      this.ownerUserId = currentUserId\n      return\n    }\n\n    if (this.ownerUserId !== currentUserId) {\n      this.ownerUserId = currentUserId\n      this.queue = []\n    }\n  }\n\n  private reportSyncError(stage: \"flush\" | \"syncLocal\", error: unknown) {\n    void tracker.manager.captureException(error, {\n      module: \"setting_sync\",\n      stage,\n    })\n  }\n\n  private disposers: (() => void)[] = []\n  async init() {\n    this.teardown()\n\n    this.load()\n\n    const d1 = EventBus.subscribe(\"SETTING_CHANGE_EVENT\", (data) => {\n      const currentUserId = this.getCurrentUserId()\n      if (!currentUserId) return\n\n      this.bindQueueOwner(currentUserId)\n\n      const tab = bizSettingKeyToTabMapping[data.key]\n      if (!tab) return\n\n      const nextPayload = pickSyncPayload(data.payload, settingWhiteListMap[tab])\n      if (isEmptyObject(nextPayload)) return\n      this.enqueue(tab, nextPayload)\n    })\n    const onlineHandler = () => (this.chain = this.chain.finally(() => this.flush()))\n\n    window.addEventListener(\"online\", onlineHandler)\n    const d2 = () => window.removeEventListener(\"online\", onlineHandler)\n\n    const unloadHandler = () => this.persist()\n\n    window.addEventListener(\"beforeunload\", unloadHandler)\n    const d3 = () => window.removeEventListener(\"beforeunload\", unloadHandler)\n\n    this.disposers.push(d1, d2, d3)\n  }\n\n  teardown() {\n    for (const disposer of this.disposers) {\n      disposer()\n    }\n    this.queue = []\n    this.ownerUserId = null\n  }\n\n  private readonly storageKey = getStorageNS(\"setting_sync_queue\")\n  private persist() {\n    if (this.queue.length === 0) {\n      localStorage.removeItem(this.storageKey)\n      return\n    }\n\n    const payload: PersistedSettingSyncQueue = {\n      ownerUserId: this.ownerUserId,\n      queue: this.queue,\n    }\n    localStorage.setItem(this.storageKey, JSON.stringify(payload))\n  }\n\n  private load() {\n    const queue = localStorage.getItem(this.storageKey)\n    localStorage.removeItem(this.storageKey)\n    if (!queue) {\n      return\n    }\n\n    const currentUserId = this.getCurrentUserId()\n\n    try {\n      const parsed = JSON.parse(queue) as unknown\n      if (Array.isArray(parsed)) {\n        // Backward compatibility: legacy versions persisted the queue array directly.\n        this.queue = parsed\n        this.ownerUserId = currentUserId\n      } else if (!parsed || typeof parsed !== \"object\") {\n        this.queue = []\n        this.ownerUserId = null\n        return\n      } else {\n        const payload = parsed as Partial<PersistedSettingSyncQueue>\n        this.queue = Array.isArray(payload.queue) ? payload.queue : []\n        if (typeof payload.ownerUserId === \"string\" || payload.ownerUserId === null) {\n          this.ownerUserId = payload.ownerUserId\n        } else {\n          // Backward compatibility for payloads without owner information.\n          this.ownerUserId = currentUserId\n        }\n      }\n    } catch {\n      /* empty */\n    }\n\n    if (!currentUserId) {\n      return\n    }\n\n    this.bindQueueOwner(currentUserId)\n  }\n\n  private chain = Promise.resolve()\n\n  private threshold = 1000\n  private flushScheduled = false\n\n  async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) {\n      return\n    }\n\n    this.bindQueueOwner(currentUserId)\n\n    const now = Date.now()\n    if (isEmptyObject(payload)) {\n      return\n    }\n    this.queue.push({\n      tab,\n      payload,\n      date: now,\n    })\n\n    if (this.flushScheduled) {\n      return\n    }\n\n    this.flushScheduled = true\n    this.chain = this.chain\n      .finally(() => sleep(this.threshold))\n      .finally(async () => {\n        try {\n          await this.flush()\n        } finally {\n          this.flushScheduled = false\n        }\n      })\n  }\n\n  private async flush() {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) {\n      return\n    }\n\n    this.bindQueueOwner(currentUserId)\n\n    if (navigator.onLine === false) {\n      return\n    }\n\n    const groupedTab = {} as Record<SettingSyncTab, any>\n\n    const referenceMap = {} as Record<SettingSyncTab, Set<SettingSyncQueueItem>>\n    for (const item of this.queue) {\n      if (!groupedTab[item.tab]) {\n        groupedTab[item.tab] = {}\n      }\n\n      referenceMap[item.tab] ||= new Set()\n      referenceMap[item.tab].add(item)\n\n      groupedTab[item.tab] = {\n        ...groupedTab[item.tab],\n        ...item.payload,\n      }\n    }\n\n    const promises = [] as Promise<any>[]\n    for (const tab in groupedTab) {\n      const json = pickSyncPayload(groupedTab[tab], settingWhiteListMap[tab])\n\n      if (isEmptyObject(json)) {\n        continue\n      }\n\n      const promise = followClient.api.settings\n        .update({\n          tab: tab as SettingsTab,\n          ...json,\n        })\n        .then(() => {\n          // remove from queue\n          for (const item of referenceMap[tab]) {\n            const index = this.queue.indexOf(item)\n            if (index !== -1) {\n              this.queue.splice(index, 1)\n            }\n          }\n        })\n      // TODO rollback or retry\n      promises.push(promise)\n    }\n\n    try {\n      await Promise.all(promises)\n    } catch (error) {\n      if (isUnauthorizedError(error)) {\n        this.queue = []\n        this.ownerUserId = currentUserId\n        return\n      }\n\n      this.reportSyncError(\"flush\", error)\n    }\n  }\n\n  replaceRemote(tab?: SettingSyncTab) {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) {\n      return this.chain\n    }\n\n    this.bindQueueOwner(currentUserId)\n\n    if (!tab) {\n      const promises = [] as Promise<any>[]\n      for (const tab in localSettingGetterMap) {\n        const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])\n\n        const promise = followClient.api.settings.update({\n          tab: tab as SettingsTab,\n          ...payload,\n        })\n\n        promises.push(promise)\n      }\n\n      this.chain = this.chain.finally(() => Promise.all(promises))\n      return this.chain\n    } else {\n      const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])\n\n      this.chain = this.chain.finally(() =>\n        followClient.api.settings.update({\n          tab: tab as SettingsTab,\n          ...payload,\n        }),\n      )\n\n      return this.chain\n    }\n  }\n\n  async syncLocal() {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) return\n\n    this.bindQueueOwner(currentUserId)\n\n    const remoteSettings = await settings\n      .get()\n      .prefetch()\n      .catch((error) => {\n        if (isUnauthorizedError(error)) {\n          this.queue = []\n          this.ownerUserId = currentUserId\n          return null\n        }\n\n        this.reportSyncError(\"syncLocal\", error)\n        return null\n      })\n\n    if (!remoteSettings) return\n\n    if (isEmptyObject(remoteSettings.settings)) return\n\n    for (const tab in remoteSettings.settings) {\n      const remoteSettingPayload = remoteSettings.settings[tab]\n      const updated = remoteSettings.updated[tab]\n\n      if (!updated) {\n        continue\n      }\n\n      const remoteUpdatedDate = new Date(updated).getTime()\n\n      const localSettings = localSettingGetterMap[tab]()\n      const localSettingsUpdated = localSettings.updated\n\n      if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) {\n        // Use remote and update local\n        const nextPayload = pickSyncPayload(remoteSettingPayload, settingWhiteListMap[tab])\n\n        if (isEmptyObject(nextPayload)) {\n          continue\n        }\n\n        const setter = localSettingSetterMap[tab]\n\n        nextPayload.updated = remoteUpdatedDate\n\n        setter(nextPayload)\n      }\n    }\n  }\n}\n\nexport const settingSyncQueue = new SettingSyncQueue()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/helper/withSettingEnable.tsx",
    "content": "import type { FC } from \"react\"\nimport { createElement } from \"react\"\nimport type { JSX } from \"react/jsx-runtime\"\n\ntype WithSelect<T> = T & {\n  select: (_s: any) => any\n}\n\nexport const withSettingEnabled =\n  <SE,>(useSettings: WithSelect<() => SE>, condition: (_setting: SE) => boolean) =>\n  <P extends JSX.IntrinsicAttributes>(\n    IfComponent: FC<P> | keyof JSX.IntrinsicElements,\n    ElseComponent: FC<P> | keyof JSX.IntrinsicElements,\n  ) =>\n  ({ ref, ...props }: P & { ref?: React.Ref<any | null> }) => {\n    const res = useSettings.select(condition)\n    return res\n      ? // @ts-expect-error\n        createElement(IfComponent, { ...props, ref })\n      : // @ts-expect-error\n        createElement(ElseComponent, { ...props, ref })\n  }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/hooks/use-setting-ctx.ts",
    "content": "import { useUserRole } from \"@follow/store/user/hooks\"\nimport { useMemo } from \"react\"\n\nimport { useDebugFeatureValue } from \"~/atoms/debug-feature\"\nimport { useIsInMASReview, useServerConfigs } from \"~/atoms/server-configs\"\n\nimport { getMemoizedSettings } from \"../settings-glob\"\nimport type { SettingPageContext } from \"../utils\"\n\nexport const useSettingPageContext = (): SettingPageContext => {\n  const role = useUserRole()\n  const isInMASReview = useIsInMASReview()\n  return useMemo(() => ({ role, isInMASReview }), [role, isInMASReview])\n}\n\nexport const useAvailableSettings = () => {\n  const ctx = useSettingPageContext()\n  const serverConfigs = useServerConfigs()\n  const debugFeatureValue = useDebugFeatureValue()\n  return useMemo(\n    () => getMemoizedSettings().filter((t) => !t.loader.hideIf?.(ctx, serverConfigs)),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [ctx, serverConfigs, debugFeatureValue],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/hooks/useWrapEnhancedSettingItem.ts",
    "content": "import { defaultSettings } from \"@follow/shared/settings/defaults\"\nimport { useCallback } from \"react\"\n\nimport { enhancedGeneralSettingKeys, useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { enhancedUISettingKeys } from \"~/atoms/settings/ui\"\n\nexport enum WrapEnhancedSettingTab {\n  General,\n  Appearance,\n}\n\nconst enhancedSettingMapper: Record<WrapEnhancedSettingTab, Set<keyof any>> = {\n  [WrapEnhancedSettingTab.General]: enhancedGeneralSettingKeys,\n  [WrapEnhancedSettingTab.Appearance]: enhancedUISettingKeys,\n}\nconst defaultSettingMapper: Record<WrapEnhancedSettingTab, Record<keyof any, any>> = {\n  [WrapEnhancedSettingTab.General]: defaultSettings.general,\n  [WrapEnhancedSettingTab.Appearance]: defaultSettings.ui,\n}\nexport const useWrapEnhancedSettingItem = <T extends (key: any, options: any) => any>(\n  fn: T,\n  tab: WrapEnhancedSettingTab,\n): T => {\n  const enableEnhancedSettings = useGeneralSettingKey(\"enhancedSettings\")\n  return useCallback(\n    (key: string, options: any) => {\n      const enhancedKeys = enhancedSettingMapper[tab]\n      const defaults = defaultSettingMapper[tab]\n\n      if (!enhancedKeys || !defaults) {\n        return fn(key, options)\n      }\n\n      if (enhancedKeys.has(key) && !enableEnhancedSettings) {\n        return null\n      }\n\n      return fn(key, options)\n    },\n    [enableEnhancedSettings, fn, tab],\n  ) as any as T\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx",
    "content": "import { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { clsx, cn } from \"@follow/utils\"\nimport { repository } from \"@pkg\"\nimport type { FC } from \"react\"\nimport {\n  Suspense,\n  useCallback,\n  useDeferredValue,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useState,\n} from \"react\"\nimport { Trans } from \"react-i18next\"\nimport { useLoaderData } from \"react-router\"\n\nimport { ModalClose } from \"~/components/ui/modal/stacked/components\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\n\nimport { isGuestAccessibleSettingTab } from \"../constants\"\nimport { useAvailableSettings } from \"../hooks/use-setting-ctx\"\nimport { SettingSectionHighlightIdContext } from \"../section\"\nimport { getSettingPages } from \"../settings-glob\"\nimport type { SettingPageConfig } from \"../utils\"\nimport { SettingTabProvider, useSetSettingTab, useSettingTab } from \"./context\"\nimport { SettingModalLayout } from \"./layout\"\n\nexport const SettingModalContent: FC<{\n  initialTab?: string\n  initialSection?: string\n}> = ({ initialTab, initialSection }) => {\n  const pages = getSettingPages()\n\n  const availableSettings = useAvailableSettings()\n\n  const fallbackTab = availableSettings[0]?.path\n  const availablePaths = useMemo(\n    () => new Set(availableSettings.map((setting) => setting.path)),\n    [availableSettings],\n  )\n  const canUseInitialTab =\n    typeof initialTab === \"string\" && initialTab in pages && availablePaths.has(initialTab)\n  const resolvedInitialTab = canUseInitialTab ? initialTab : fallbackTab\n\n  if (!resolvedInitialTab) {\n    return null\n  }\n\n  return (\n    <SettingTabProvider initialTab={resolvedInitialTab}>\n      <SettingModalLayout>\n        <Content initialSection={initialSection} />\n      </SettingModalLayout>\n    </SettingTabProvider>\n  )\n}\n\nconst Content: FC<{\n  initialSection?: string | null\n}> = ({ initialSection }) => {\n  const availableSettings = useAvailableSettings()\n  const tab = useSettingTab()\n  const setTab = useSetSettingTab()\n  const isLoggedIn = useIsLoggedIn()\n  const { ensureLogin } = useRequireLogin()\n\n  useEffect(() => {\n    if (availableSettings.length === 0) return\n    if (!tab || !availableSettings.some((setting) => setting.path === tab)) {\n      setTab(availableSettings[0]!.path)\n    }\n  }, [availableSettings, setTab, tab])\n\n  useEffect(() => {\n    if (!isLoggedIn && tab && !isGuestAccessibleSettingTab(tab)) {\n      ensureLogin()\n    }\n  }, [ensureLogin, isLoggedIn, tab])\n\n  const activeSetting = useMemo(() => {\n    if (availableSettings.length === 0) return\n    if (tab) {\n      const matched = availableSettings.find((setting) => setting.path === tab)\n      if (matched) {\n        return matched\n      }\n    }\n    return availableSettings[0]\n  }, [availableSettings, tab])\n\n  const tabAccessible =\n    !!activeSetting && (isLoggedIn || isGuestAccessibleSettingTab(activeSetting.path))\n\n  const key = useDeferredValue(activeSetting?.path ?? \"\")\n  const pages = getSettingPages()\n  const page = key ? pages[key] : undefined\n  const Component = page?.Component\n  const loader = page?.loader\n\n  const [scrollerAtTop, setScrollerAtTop] = useState(true)\n  const [scroller, setScroller] = useState<HTMLDivElement | null>(null)\n\n  useLayoutEffect(() => {\n    if (scroller) {\n      scroller.scrollTop = 0\n    }\n  }, [key, scroller])\n\n  useEffect(() => {\n    if (!scroller) return\n    const handler = () => {\n      setScrollerAtTop(scroller.scrollTop < 20)\n    }\n    scroller.addEventListener(\"scroll\", handler)\n    return () => {\n      scroller.removeEventListener(\"scroll\", handler)\n    }\n  }, [scroller])\n\n  const scrollToSection = useCallback(\n    (sectionId: string) => {\n      if (!sectionId) return false\n      if (!scroller) return false\n      const element = scroller.querySelector(`[data-setting-section=\"${sectionId}\"]`) as HTMLElement\n      if (!element) return false\n\n      const elementTop = element.offsetTop\n      const scrollerTop = scroller.scrollTop\n      const delta = elementTop - scrollerTop - 20\n\n      scroller.scrollTo({\n        top: delta,\n        behavior: \"smooth\",\n      })\n\n      return true\n    },\n    [scroller],\n  )\n\n  useEffect(() => {\n    if (initialSection) {\n      scrollToSection(initialSection)\n    }\n  }, [initialSection, scrollToSection])\n\n  const config = (useLoaderData() || loader || {}) as SettingPageConfig\n  if (!Component || !activeSetting) return null\n\n  if (!tabAccessible) {\n    return (\n      <>\n        <SettingsTitle\n          loader={loader}\n          className={clsx(\n            \"relative mb-0 border-b border-transparent px-8 transition-colors duration-200\",\n            !scrollerAtTop ? \"border-border\" : \"\",\n          )}\n        />\n        <ModalClose />\n        <button\n          type=\"button\"\n          className=\"flex flex-1 items-center justify-center px-12 text-center text-text-secondary\"\n          onClick={ensureLogin}\n        >\n          Please log in to access this setting.\n        </button>\n      </>\n    )\n  }\n\n  return (\n    <Suspense>\n      <SettingsTitle\n        loader={loader}\n        className={clsx(\n          \"relative mb-0 border-b border-transparent px-8 transition-colors duration-200\",\n          !scrollerAtTop ? \"border-border\" : \"\",\n        )}\n      />\n      <ModalClose />\n      <ScrollArea.ScrollArea\n        mask={false}\n        ref={setScroller}\n        rootClassName=\"h-full grow flex-1 shrink-0 overflow-auto\"\n        viewportClassName={cn(\n          \"px-1 min-h-full [&>div]:min-h-full [&>div]:relative pl-8 pr-7\",\n          config.viewportClassName,\n        )}\n      >\n        <SettingSectionHighlightIdContext value={initialSection!}>\n          <Component />\n        </SettingSectionHighlightIdContext>\n\n        <div className=\"h-16\" />\n        {activeSetting.path === \"about\" && (\n          <p className=\"absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80\">\n            <Trans\n              ns=\"settings\"\n              i18nKey=\"common.give_star\"\n              components={{\n                Link: (\n                  <a\n                    href={`${repository.url}`}\n                    className=\"font-semibold text-accent\"\n                    target=\"_blank\"\n                  />\n                ),\n                HeartIcon: <i className=\"i-mgc-heart-cute-fi\" />,\n              }}\n            />\n          </p>\n        )}\n      </ScrollArea.ScrollArea>\n    </Suspense>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/modal/context.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { atom } from \"jotai\"\nimport { createContext, use, useMemo } from \"react\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nconst SettingTabContext = createContext<ReturnType<typeof createAtomHooks<string>>>(null!)\nexport const SettingTabProvider = ({\n  children,\n  initialTab,\n}: {\n  children: React.ReactNode\n  initialTab?: string\n}) => {\n  const ctxValue = useMemo(() => {\n    return createAtomHooks(atom(initialTab ?? \"\"))\n  }, [])\n  return <SettingTabContext value={ctxValue}>{children}</SettingTabContext>\n}\n\nconst useSettingTabContext = () => {\n  const ctx = use(SettingTabContext)\n  if (!ctx) {\n    throw new Error(\"SettingTabContext not found\")\n  }\n  return ctx\n}\nexport const useSettingTab = () => {\n  return useSettingTabContext()[2]()\n}\n\nexport const useSetSettingTab = () => {\n  return useSettingTabContext()[3]()\n}\n\nexport const SettingModalContentPortalableContext = createContext<PrimitiveAtom<HTMLElement>>(\n  atom(null as any),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/modal/hooks.ts",
    "content": "import type { ExtractAtomValue, PrimitiveAtom } from \"jotai\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { createContext, useContextSelector } from \"use-context-selector\"\n\ninterface Ctx {\n  canSync: PrimitiveAtom<boolean>\n}\nexport const defaultCtx: Ctx = {\n  canSync: atom(true),\n}\nexport const SettingContext = createContext<Ctx>(defaultCtx)\n\nexport const useSettingContextSelector = <T>(selector: (state: Ctx) => T) => {\n  // @ts-expect-error\n  return useAtomValue(useContextSelector(SettingContext, selector)) as ExtractAtomValue<T>\n}\n\nexport const useSetSettingCanSync = () => {\n  const canSync = useContextSelector(SettingContext, (s) => s.canSync)\n  return useSetAtom(canSync)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Folo } from \"@follow/components/icons/folo.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { LetsIconsResizeDownRightLight } from \"@follow/components/icons/resize.jsx\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { preventDefault } from \"@follow/utils/dom\"\nimport { cn, getOS } from \"@follow/utils/utils\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport type { BoundingBox } from \"motion/react\"\nimport { Resizable } from \"re-resizable\"\nimport type { PropsWithChildren } from \"react\"\nimport { memo, Suspense, use, useCallback, useMemo, useRef } from \"react\"\nimport { createPortal } from \"react-dom\"\n\nimport { useUISettingSelector } from \"~/atoms/settings/ui\"\nimport { m } from \"~/components/common/Motion\"\nimport { resizableOnly } from \"~/components/ui/modal\"\nimport { useModalResizeAndDrag } from \"~/components/ui/modal/stacked/internal/use-drag\"\nimport { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from \"~/constants\"\nimport { useRequireLogin } from \"~/hooks/common/useRequireLogin\"\nimport { useUpgradePlanModal } from \"~/modules/plan\"\n\nimport { isGuestAccessibleSettingTab, SETTING_MODAL_ID } from \"../constants\"\nimport { EnhancedSettingsIndicator } from \"../helper/EnhancedIndicator\"\nimport { SettingSyncIndicator } from \"../helper/SyncIndicator\"\nimport { useAvailableSettings, useSettingPageContext } from \"../hooks/use-setting-ctx\"\nimport { SettingsSidebarTitle } from \"../title\"\nimport type { SettingPageConfig } from \"../utils\"\nimport { DisableWhy } from \"../utils\"\nimport { SettingModalContentPortalableContext, useSetSettingTab, useSettingTab } from \"./context\"\nimport { defaultCtx, SettingContext } from \"./hooks\"\n\nexport function SettingModalLayout(props: PropsWithChildren) {\n  const { children } = props\n\n  const elementRef = useRef<HTMLDivElement>(null)\n  const edgeElementRef = useRef<HTMLDivElement>(null)\n  const {\n    handleDrag,\n    handleResizeStart,\n    handleResizeStop,\n    preferDragDir,\n    isResizeable,\n    resizeableStyle,\n\n    dragController,\n  } = useModalResizeAndDrag(elementRef, {\n    resizeable: true,\n    draggable: true,\n  })\n\n  const { draggable, overlay } = useUISettingSelector((state) => ({\n    draggable: state.modalDraggable,\n    overlay: state.modalOverlay,\n  }))\n\n  const measureDragConstraints = useRef((constraints: BoundingBox) => {\n    if (getOS() === \"Windows\") {\n      return {\n        ...constraints,\n        top: constraints.top + ElECTRON_CUSTOM_TITLEBAR_HEIGHT,\n      }\n    }\n    return constraints\n  }).current\n\n  const portalableCtxValue = useMemo(() => {\n    return atom(null as any)\n  }, [])\n\n  return (\n    <div\n      id={SETTING_MODAL_ID}\n      className={cn(\"h-full\", !isResizeable && \"center\")}\n      ref={edgeElementRef}\n    >\n      <m.div\n        exit={{\n          opacity: 0,\n          scale: 0.96,\n        }}\n        transition={Spring.presets.smooth}\n        className={cn(\n          \"relative flex overflow-hidden rounded-xl rounded-br-none border border-border\",\n          !overlay && \"shadow-modal\",\n        )}\n        style={resizeableStyle}\n        onContextMenu={preventDefault}\n        drag={draggable && (preferDragDir || draggable)}\n        dragControls={dragController}\n        dragListener={false}\n        dragMomentum={false}\n        dragElastic={false}\n        dragConstraints={edgeElementRef}\n        onMeasureDragConstraints={measureDragConstraints}\n        whileDrag={{\n          cursor: \"grabbing\",\n        }}\n      >\n        {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n        <SettingContext.Provider value={defaultCtx}>\n          <Resizable\n            onResizeStart={handleResizeStart}\n            onResizeStop={handleResizeStop}\n            enable={resizableOnly(\"bottomRight\")}\n            defaultSize={{\n              width: 950,\n              height: 800,\n            }}\n            maxHeight=\"90vh\"\n            minHeight={400}\n            minWidth={700}\n            maxWidth=\"95vw\"\n            className=\"flex !select-none flex-col\"\n          >\n            {draggable && (\n              <div className=\"absolute inset-x-0 top-0 z-[1] h-8\" onPointerDown={handleDrag} />\n            )}\n            <div className=\"flex h-0 flex-1\" ref={elementRef}>\n              <div className=\"flex min-h-0 min-w-44 max-w-[20ch] flex-col rounded-l-xl border-r border-r-border bg-sidebar px-2 py-6 backdrop-blur-background\">\n                <div className=\"mb-4 flex h-8 items-center gap-2 px-2 font-bold\">\n                  <Logo className=\"mr-1 size-6\" />\n\n                  <Folo className=\"size-8\" />\n                </div>\n                <nav className=\"flex grow flex-col\">\n                  <SidebarItems />\n                </nav>\n\n                <div className=\"relative -mb-6 flex h-8 shrink-0 items-center justify-end gap-2\">\n                  <EnhancedSettingsIndicator />\n                  <SettingSyncIndicator />\n                </div>\n              </div>\n              <div className=\"relative flex h-full min-w-0 flex-1 flex-col bg-background pt-1\">\n                <SettingModalContentPortalableContext value={portalableCtxValue}>\n                  <Suspense>{children}</Suspense>\n                  <SettingModalContentPortalable />\n                </SettingModalContentPortalableContext>\n              </div>\n            </div>\n\n            <LetsIconsResizeDownRightLight className=\"pointer-events-none absolute bottom-0 right-0 size-6 translate-x-px translate-y-px text-border\" />\n          </Resizable>\n        </SettingContext.Provider>\n      </m.div>\n    </div>\n  )\n}\n\nconst SettingModalContentPortalable = () => {\n  const setElement = useSetAtom(use(SettingModalContentPortalableContext))\n  return <div ref={setElement as any} />\n}\n\nconst SettingItemButtonImpl = (props: {\n  setTab: (tab: string) => void\n  item: SettingPageConfig\n  path: string\n  isActive: boolean\n  onChange?: (tab: string) => void\n  guestLocked?: boolean\n}) => {\n  const { setTab, item, path, onChange, isActive, guestLocked = false } = props\n  const { disableIf } = item\n\n  const ctx = useSettingPageContext()\n  const { ensureLogin } = useRequireLogin()\n\n  const [disabledByConfig, whyFromConfig = DisableWhy.Noop] = disableIf?.(ctx) || [\n    false,\n    DisableWhy.Noop,\n  ]\n  const disabled = guestLocked || disabledByConfig\n  const why = disabledByConfig ? whyFromConfig : DisableWhy.Noop\n  const presentActivationModal = useUpgradePlanModal()\n\n  return (\n    <button\n      data-testid={`settings-tab-${path}`}\n      className={cn(\n        \"my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text\",\n        isActive && \"!bg-theme-item-active !text-text\",\n        !IN_ELECTRON && \"duration-200 hover:bg-theme-item-hover\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30\",\n        disabled && \"opacity-50\",\n        disabledByConfig && \"cursor-not-allowed\",\n      )}\n      type=\"button\"\n      onClick={useCallback(() => {\n        if (guestLocked) {\n          ensureLogin()\n          return\n        }\n        if (disabled) {\n          switch (why) {\n            case DisableWhy.NotActivation: {\n              presentActivationModal()\n              return\n            }\n            case DisableWhy.Noop: {\n              break\n            }\n          }\n        }\n        setTab(path)\n        onChange?.(path)\n      }, [disabled, ensureLogin, guestLocked, onChange, path, presentActivationModal, setTab, why])}\n    >\n      <SettingsSidebarTitle path={path} className=\"text-[0.94rem] font-medium\" />\n    </button>\n  )\n}\n\nconst SettingItemButton = memo(SettingItemButtonImpl)\n\nexport const SidebarItems = memo((props: { onChange?: (tab: string) => void }) => {\n  const { onChange } = props\n  const setTab = useSetSettingTab()\n  const tab = useSettingTab()\n  const availableSettings = useAvailableSettings()\n  const isLoggedIn = useIsLoggedIn()\n\n  return availableSettings.map((t) => {\n    const isActive = tab === t.path\n    const guestLocked = !isLoggedIn && !isGuestAccessibleSettingTab(t.path)\n    return (\n      <SettingItemButton\n        key={t.path}\n        isActive={isActive}\n        setTab={setTab}\n        item={t}\n        path={t.path}\n        onChange={onChange}\n        guestLocked={guestLocked}\n      />\n    )\n  })\n})\n\nexport const SettingModalContentPortal: Component = ({ children }) => {\n  const element = useAtomValue(use(SettingModalContentPortalableContext))\n  return createPortal(children, element)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/modal/use-setting-modal-hack.ts",
    "content": "// HACK: Use expose the navigate function in the window object, avoid to import `router` circular issue.\nimport type { SettingModalOptions } from \"./useSettingModal\"\n\nconst showSettings = (args?: SettingModalOptions) => window.router.showSettings.call(null, args)\n\nexport const useSettingModal = () => showSettings\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/modal/useSettingModal.ts",
    "content": "import { createElement, useCallback } from \"react\"\n\nimport { PlainModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { SettingModalContent } from \"./SettingModalContent\"\n\nexport type SettingModalOptions =\n  | string\n  | {\n      tab?: string\n      section?: string\n    }\n\nconst normalizeOptions = (options?: SettingModalOptions) => {\n  if (!options) return {}\n  if (typeof options === \"string\") {\n    return { tab: options }\n  }\n  return options\n}\n\nexport const useSettingModal = () => {\n  const { present } = useModalStack()\n\n  return useCallback(\n    (options?: SettingModalOptions) => {\n      const { tab, section } = normalizeOptions(options)\n\n      return present({\n        title: \"Setting\",\n        id: \"setting\",\n        content: () =>\n          createElement(SettingModalContent, {\n            initialTab: tab,\n            initialSection: section,\n          }),\n        CustomModalComponent: PlainModal,\n        modalContainerClassName: \"overflow-hidden\",\n      })\n    },\n    [present],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/section.tsx",
    "content": "/* eslint-disable @eslint-react/no-children-to-array */\n/* eslint-disable @eslint-react/no-children-map */\n\nimport { cn } from \"@follow/utils/utils\"\nimport type { FC, PropsWithChildren, ReactNode } from \"react\"\nimport { cloneElement, createContext, use, useEffect, useRef } from \"react\"\nimport * as React from \"react\"\nimport { titleCase } from \"title-case\"\n\nimport { SettingActionItem, SettingDescription, SettingSwitch } from \"./control\"\n\nexport const SettingSectionHighlightIdContext = createContext<string | null>(null)\n\nexport const SettingSectionTitle: FC<{\n  title: string | ReactNode\n  className?: string\n  margin?: \"compact\" | \"normal\"\n  sectionId?: string\n}> = ({ title, margin, className, sectionId }) => {\n  const highlightedSectionId = use(SettingSectionHighlightIdContext)\n  const elementRef = useRef<HTMLDivElement | null>(null)\n\n  const isHighlighted = !!sectionId && highlightedSectionId === sectionId && !!elementRef.current\n\n  useEffect(() => {\n    if (!isHighlighted) {\n      return\n    }\n\n    let rollingAnimation: Animation | null = null\n\n    const timer = setTimeout(() => {\n      const highlightedElement = elementRef.current?.querySelector(\n        \"[data-highlighted-element]\",\n      ) as HTMLElement\n      if (!highlightedElement) {\n        clearTimeout(timer)\n        return\n      }\n      const keyframeEffect = new KeyframeEffect(\n        highlightedElement,\n        [\n          {\n            backgroundColor: \"color-mix(in srgb, hsl(var(--fo-a)) 33%, hsl(var(--background)) 67%)\",\n          },\n          { backgroundColor: \"transparent\" },\n        ],\n        {\n          duration: 1000,\n          easing: \"ease-in-out\",\n        },\n      )\n      rollingAnimation = new Animation(keyframeEffect, document.timeline)\n      rollingAnimation.play()\n    }, 500)\n    return () => {\n      rollingAnimation?.cancel()\n      clearTimeout(timer)\n    }\n  }, [isHighlighted])\n  return (\n    <div\n      ref={elementRef}\n      data-setting-section={sectionId}\n      data-highlighted={isHighlighted ? \"true\" : undefined}\n      className={cn(\n        \"relative shrink-0 text-headline font-bold text-text/60 first:mt-0\",\n        margin === \"compact\" ? \"mb-2 mt-8\" : \"mb-4 mt-10\",\n        className,\n      )}\n    >\n      {isHighlighted && <div className=\"absolute -inset-4 rounded-lg\" data-highlighted-element />}\n      {typeof title === \"string\" ? titleCase(title) : title}\n    </div>\n  )\n}\n\nexport const SettingItemGroup: FC<PropsWithChildren> = ({ children }) => {\n  const childrenArray = React.Children.toArray(children)\n  return React.Children.map(children, (child, index) => {\n    if (typeof child !== \"object\") return child\n\n    if (child === null) return child\n\n    const compType = (child as React.ReactElement).type\n    if (compType === SettingDescription) {\n      const prevIndex = index - 1\n      const prevChild = childrenArray[prevIndex]\n      const prevType = getChildType(prevChild)\n\n      switch (prevType) {\n        case SettingSwitch: {\n          return cloneElement(child as React.ReactElement, {\n            // @ts-expect-error\n            className: \"!-mt-2\",\n          })\n        }\n        case SettingActionItem: {\n          return cloneElement(child as React.ReactElement, {\n            // @ts-expect-error\n            className: \"!-mt-2\",\n          })\n        }\n        default: {\n          return child\n        }\n      }\n    }\n\n    return child\n  })\n}\n\nconst getChildType = (child: ReactNode) => {\n  if (typeof child !== \"object\") return null\n\n  if (child === null) return null\n\n  return (child as React.ReactElement).type\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/sections/fonts.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport type { ResponsiveSelectItem } from \"@follow/components/ui/select/responsive.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport * as React from \"react\"\nimport { useCallback, useEffect, useMemo, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setUISetting, useUISettingSelector } from \"~/atoms/settings/ui\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { ipcServices } from \"~/lib/client\"\n\nimport { SettingDescription } from \"../control\"\nimport { SettingItemGroup } from \"../section\"\n\nconst FALLBACK_FONT = \"Default (UI Font)\"\nconst DEFAULT_FONT = \"system-ui\"\nconst CUSTOM_FONT = \"Custom\"\nconst useFontDataElectron = () => {\n  const { t } = useTranslation(\"settings\")\n  const { data } = useQuery({\n    queryFn: () => ipcServices?.setting.getSystemFonts(),\n    queryKey: [\"systemFonts\"],\n  })\n\n  return (\n    [\n      { label: t(\"appearance.content_font.default\"), value: \"inherit\" },\n      { label: t(\"appearance.font.system\"), value: DEFAULT_FONT },\n    ] as { label: string; value: string }[]\n  ).concat(\n    (data || []).map((font) => ({\n      label: font,\n      value: font,\n    })),\n  )\n}\n\nconst useFontDataWeb = () => {\n  const { t } = useTranslation(\"settings\")\n  return [\n    { label: t(\"appearance.content_font.default\"), value: \"inherit\" },\n    { label: t(\"appearance.font.system\"), value: DEFAULT_FONT },\n    ...[\n      // English\n      \"SN Pro\",\n      \"SF Pro\",\n      \"Segoe UI\",\n      \"Helvetica\",\n      \"Arial\",\n      // Chinese\n      \"PingFang SC\",\n      \"PingFang TC\",\n      \"PingFang HK\",\n\n      \"Microsoft YaHei\",\n      \"Microsoft JhengHei\",\n      // Japanese\n      \"Yu Gothic\",\n      \"Hiragino Sans\",\n    ].map((font) => ({\n      label: font,\n\n      value: font,\n    })),\n    {\n      label: t(\"appearance.font.custom\"),\n      value: CUSTOM_FONT,\n    },\n  ]\n}\n\nconst useFontData = IN_ELECTRON ? useFontDataElectron : useFontDataWeb\nexport const ContentFontSelector = () => {\n  const { t } = useTranslation(\"settings\")\n  const data = useFontData()\n  const readerFontFamily = useUISettingSelector((state) => state.readerFontFamily || DEFAULT_FONT)\n  const setCustom = usePresentCustomFontDialog(\"readerFontFamily\")\n\n  const isCustomFont = useMemo(\n    () =>\n      readerFontFamily !== \"inherit\" &&\n      data.find((d) => d.value === readerFontFamily) === undefined,\n    [data, readerFontFamily],\n  )\n\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">{t(\"appearance.content_font.label\")}</span>\n        <ResponsiveSelect\n          defaultValue={FALLBACK_FONT}\n          value={readerFontFamily}\n          onValueChange={(value) => {\n            if (value === CUSTOM_FONT) {\n              setCustom()\n              return\n            }\n\n            setUISetting(\"readerFontFamily\", value)\n          }}\n          size=\"sm\"\n          triggerClassName=\"w-48\"\n          renderItem={({ label, value }) => {\n            return <span style={{ fontFamily: value }}>{label}</span>\n          }}\n          items={[\n            isCustomFont && { label: readerFontFamily, value: readerFontFamily },\n            ...data,\n          ].filter((i) => typeof i === \"object\")}\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.content_font.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nexport const UIFontSelector = () => {\n  const { t } = useTranslation(\"settings\")\n  // filter out the fallback font\n  const data = useFontData()\n    .slice(1)\n    .filter((d) => d.value !== DEFAULT_FONT)\n  const uiFont = useUISettingSelector((state) => state.uiFontFamily)\n  const setCustom = usePresentCustomFontDialog(\"uiFontFamily\")\n  const isCustomFont = useMemo(\n    () => uiFont !== DEFAULT_FONT && data.find((d) => d.value === uiFont) === undefined,\n    [data, uiFont],\n  )\n\n  const renderItemOrValue = useCallback(\n    (item: ResponsiveSelectItem) => {\n      if (item.value === DEFAULT_FONT) {\n        return <span>{t(\"appearance.global_font.default\")}</span>\n      }\n      return (\n        <span\n          style={{\n            fontFamily: item.value,\n          }}\n        >\n          {item.value}\n        </span>\n      )\n    },\n    [t],\n  )\n\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">{t(\"appearance.ui_font.label\")}</span>\n        <ResponsiveSelect\n          defaultValue={FALLBACK_FONT}\n          value={uiFont}\n          onValueChange={(value) => {\n            if (value === CUSTOM_FONT) {\n              setCustom()\n              return\n            }\n\n            setUISetting(\"uiFontFamily\", value)\n          }}\n          size=\"sm\"\n          triggerClassName=\"w-48\"\n          renderValue={renderItemOrValue}\n          renderItem={renderItemOrValue}\n          items={[\n            isCustomFont && { label: uiFont, value: uiFont },\n            { label: DEFAULT_FONT, value: DEFAULT_FONT },\n            ...data,\n          ].filter((i) => typeof i === \"object\")}\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.ui_font.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst usePresentCustomFontDialog = (setKey: \"uiFontFamily\" | \"readerFontFamily\") => {\n  const HISTORY_KEY = getStorageNS(\"customFonts\")\n  const { present } = useModalStack()\n  const { t } = useTranslation(\"settings\")\n\n  return useCallback(() => {\n    present({\n      title: t(\"appearance.custom_font\"),\n      clickOutsideToDismiss: true,\n      content: function Content({ dismiss, setClickOutSideToDismiss }) {\n        const inputRef = useRef<HTMLInputElement>(null)\n\n        useEffect(() => {\n          nextFrame(() => inputRef.current?.focus())\n        }, [inputRef])\n\n        const save: React.FormEventHandler = (e) => {\n          e.preventDefault()\n          const value = inputRef.current?.value\n          if (value) {\n            setUISetting(setKey, value)\n            localStorage.setItem(HISTORY_KEY, value)\n            dismiss()\n          }\n        }\n        return (\n          <form className=\"flex flex-col gap-2\" onSubmit={save}>\n            <Input\n              defaultValue={localStorage.getItem(HISTORY_KEY) || \"\"}\n              ref={inputRef}\n              onChange={() => {\n                setClickOutSideToDismiss(false)\n              }}\n            />\n\n            <div className=\"flex justify-end\">\n              <Button type=\"submit\">{t(\"appearance.save\")}</Button>\n            </div>\n          </form>\n        )\n      },\n    })\n  }, [HISTORY_KEY, present, setKey, t])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/settings-glob.ts",
    "content": "import { memoize } from \"es-toolkit/compat\"\nimport type { JSX } from \"react/jsx-runtime\"\n\nimport type { SettingPageConfig } from \"./utils\"\n\nfunction getSettings() {\n  const map = import.meta.glob(\"../../pages/settings/\\\\(settings\\\\)/*\", { eager: true })\n\n  const settings = [] as {\n    name: I18nKeysForSettings\n    icon: string | React.ReactNode\n    path: string\n    Component: () => JSX.Element\n    priority: number\n    loader: SettingPageConfig\n  }[]\n  for (const path in map) {\n    const prefix = \"(settings)\"\n    const postfix = \".tsx\"\n    const lastItem = path.split(\"/\").pop()\n\n    if (!lastItem) continue\n    let p = lastItem.slice(0, -postfix.length)\n\n    if (p.includes(prefix)) {\n      p = p.replace(prefix, \"\")\n    }\n\n    if (p === \"index\" || p === \"layout\") continue\n\n    const Module = map[path] as {\n      Component: () => JSX.Element\n      loader: SettingPageConfig\n    }\n\n    if (!Module.loader) continue\n    settings.push({\n      ...Module.loader,\n      Component: Module.Component,\n      loader: Module.loader,\n      path: p,\n    })\n  }\n\n  return settings.sort((a, b) => a.priority - b.priority)\n}\n\nexport const getMemoizedSettings = memoize(getSettings)\n\nexport const getSettingPages = memoize(() => {\n  const pages = {}\n  const settings = getMemoizedSettings()\n  for (const setting of settings) {\n    const filename = setting.path\n\n    pages[filename] = {\n      Component: setting.Component,\n      loader: setting.loader,\n    }\n  }\n  return pages\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/about.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Divider } from \"@follow/components/ui/divider/Divider.js\"\nimport { SocialMediaLinks } from \"@follow/constants\"\nimport { IN_ELECTRON, MODE, ModeEnum } from \"@follow/shared/constants\"\nimport { getCurrentEnvironment } from \"@follow/utils/environment\"\nimport { cn } from \"@follow/utils/utils\"\nimport PKG, { repository } from \"@pkg\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { PlainWithAnimationModal } from \"~/components/ui/modal/stacked/custom-modal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { ipcServices } from \"~/lib/client\"\nimport { getNewIssueUrl } from \"~/lib/issues\"\nimport { EnvironmentDebugModalContent } from \"~/modules/app/EnvironmentIndicator\"\nimport { AppTipModalContent } from \"~/modules/app-tip\"\nimport { useDesktopReviewPromptState } from \"~/modules/review-prompt/use-review-prompt-state\"\nimport {\n  openDesktopFeedbackEmail,\n  openDesktopStoreReview,\n  persistDesktopReviewOutcome,\n  readDesktopReviewPromptState,\n} from \"~/modules/review-prompt/utils\"\n\nexport const SettingAbout = () => {\n  const { t } = useTranslation(\"settings\")\n  const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)\n  const { present } = useModalStack()\n  const currentEnvironment = getCurrentEnvironment().join(\"\\n\")\n  const { distribution, platform, rateTarget, storageKey, userId } = useDesktopReviewPromptState()\n  const { data: appVersion } = useQuery({\n    queryKey: [\"appVersion\"],\n    queryFn: () => ipcServices?.app.getAppVersion(),\n  })\n\n  const rendererVersion = PKG.version\n  const rendererClickCountRef = useRef(0)\n  const rendererClickResetTimerRef = useRef<number | null>(null)\n  const lastRendererClickTimestampRef = useRef(0)\n\n  useEffect(() => {\n    return () => {\n      if (rendererClickResetTimerRef.current) {\n        window.clearTimeout(rendererClickResetTimerRef.current)\n      }\n    }\n  }, [])\n\n  const handleCheckForUpdates = async () => {\n    if (isCheckingUpdate) return\n\n    setIsCheckingUpdate(true)\n    const toastId = toast.loading(t(\"about.checkingForUpdates\"))\n\n    try {\n      const result = await ipcServices?.app.checkForUpdates()\n\n      if (result?.error) {\n        toast.error(t(\"about.updateCheckFailed\"), { id: toastId })\n      } else if (result?.hasUpdate) {\n        toast.success(t(\"about.updateAvailable\"), { id: toastId })\n      } else {\n        toast.info(t(\"about.noUpdateAvailable\"), { id: toastId })\n      }\n    } catch {\n      toast.error(t(\"about.updateCheckFailed\"), { id: toastId })\n    } finally {\n      setIsCheckingUpdate(false)\n    }\n  }\n\n  const handleRendererVersionClick = () => {\n    if (!rendererVersion) return\n\n    const now = Date.now()\n    if (now - lastRendererClickTimestampRef.current > 800) {\n      rendererClickCountRef.current = 0\n    }\n\n    rendererClickCountRef.current += 1\n    lastRendererClickTimestampRef.current = now\n\n    if (rendererClickResetTimerRef.current) {\n      window.clearTimeout(rendererClickResetTimerRef.current)\n    }\n\n    rendererClickResetTimerRef.current = window.setTimeout(() => {\n      rendererClickCountRef.current = 0\n      rendererClickResetTimerRef.current = null\n    }, 800)\n\n    if (rendererClickCountRef.current >= 10) {\n      rendererClickCountRef.current = 0\n      if (rendererClickResetTimerRef.current) {\n        window.clearTimeout(rendererClickResetTimerRef.current)\n        rendererClickResetTimerRef.current = null\n      }\n\n      present({\n        title: \"Debug Actions\",\n        content: EnvironmentDebugModalContent,\n      })\n    }\n  }\n\n  const handleOpenLegal = (type: \"privacy\" | \"tos\") => {\n    const path = type === \"privacy\" ? \"privacy-policy\" : \"terms-of-service\"\n    window.open(`https://folo.is/${path}`, \"_blank\")\n  }\n\n  const handleOpenAiOnboarding = () => {\n    present({\n      title: \"App Tip\",\n      content: () => <AppTipModalContent />,\n      CustomModalComponent: PlainWithAnimationModal,\n      modalContainerClassName: \"flex items-center justify-center\",\n      modalClassName: \"w-full max-w-5xl\",\n      canClose: true,\n      clickOutsideToDismiss: false,\n      overlay: false,\n    })\n  }\n\n  const handleRateFolo = async () => {\n    if (!rateTarget) {\n      return\n    }\n\n    persistDesktopReviewOutcome({\n      appVersion: APP_VERSION,\n      distribution,\n      outcome: \"positive_store_redirect\",\n      platform,\n      source: \"manual\",\n      state: readDesktopReviewPromptState(storageKey),\n      storageKey,\n    })\n    await openDesktopStoreReview(rateTarget)\n  }\n\n  const handleSendFeedback = async () => {\n    persistDesktopReviewOutcome({\n      appVersion: APP_VERSION,\n      distribution,\n      outcome: \"negative_feedback\",\n      platform,\n      source: \"manual\",\n      state: readDesktopReviewPromptState(storageKey),\n      storageKey,\n    })\n    await openDesktopFeedbackEmail({ distribution, userId })\n  }\n\n  return (\n    <div className=\"mx-auto mt-6 max-w-3xl space-y-8\">\n      {/* Header Section */}\n      <div className=\"px-2 text-center\">\n        <div className=\"mb-6 flex justify-center\">\n          <Logo className=\"size-20\" />\n        </div>\n        <h1 className=\"-mt-6 flex justify-center\">\n          <Folo className=\"size-16\" />\n        </h1>\n        {MODE !== ModeEnum.production && (\n          <span className=\"block -translate-y-2 text-sm font-normal text-text-tertiary\">\n            {MODE}\n          </span>\n        )}\n        <p className=\"mt-2 text-sm text-text-secondary\">\n          {t(\"about.licenseInfo\", { appName: APP_NAME, currentYear: new Date().getFullYear() })}\n        </p>\n\n        <div className=\"mt-6 flex flex-wrap items-center justify-center gap-2\">\n          {appVersion && (\n            <span className=\"inline-flex items-center rounded-full bg-fill-secondary px-3 py-1 text-xs font-medium text-text-secondary\">\n              <span className=\"mr-1.5 text-text-tertiary\">App</span>\n              {appVersion}\n            </span>\n          )}\n          {rendererVersion && (\n            <button\n              type=\"button\"\n              onClick={handleRendererVersionClick}\n              className=\"inline-flex items-center rounded-full bg-fill-secondary px-3 py-1 text-xs font-medium text-text-secondary transition-colors hover:bg-fill-tertiary\"\n            >\n              <span className=\"mr-1.5 text-text-tertiary\">Renderer</span>\n              {rendererVersion}\n            </button>\n          )}\n          <button\n            type=\"button\"\n            onClick={() => {\n              navigator.clipboard.writeText(\n                rendererVersion\n                  ? `${currentEnvironment}\\n**Renderer**: ${rendererVersion}`\n                  : currentEnvironment,\n              )\n              toast.success(t(\"about.environmentCopied\"))\n            }}\n            className=\"inline-flex items-center rounded-full px-3 py-1 text-xs text-text-tertiary transition-colors hover:bg-fill-secondary hover:text-text-secondary\"\n          >\n            <i className=\"i-mgc-copy-cute-re mr-1.5\" />\n            {t(\"about.copyEnvironment\")}\n          </button>\n        </div>\n      </div>\n\n      {/* Quick Actions */}\n      <div className=\"-mx-3 space-y-1 px-2\">\n        {IN_ELECTRON && (\n          <button\n            type=\"button\"\n            onClick={handleCheckForUpdates}\n            disabled={isCheckingUpdate}\n            className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n          >\n            <div className=\"flex items-center gap-3\">\n              <div>\n                <div className=\"text-sm font-medium\">{t(\"about.checkForUpdates\")}</div>\n                <div className=\"text-xs text-text-tertiary\">{t(\"about.updateDescription\")}</div>\n              </div>\n            </div>\n            {isCheckingUpdate ? (\n              <i className=\"i-mgc-loading-3-cute-re animate-spin text-base\" />\n            ) : (\n              <i className=\"i-mgc-arrow-right-up-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n            )}\n          </button>\n        )}\n        <button\n          type=\"button\"\n          onClick={() => window.open(`${repository.url}/releases`, \"_blank\")}\n          className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n        >\n          <div>\n            <div className=\"text-sm font-medium\">{t(\"about.changelog\")}</div>\n            <div className=\"text-xs text-text-tertiary\">{t(\"about.changelogDescription\")}</div>\n          </div>\n          <i className=\"i-mgc-external-link-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={handleOpenAiOnboarding}\n          className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n        >\n          <div>\n            <div className=\"text-sm font-medium\">{t(\"about.appTip\")}</div>\n            <div className=\"text-xs text-text-tertiary\">{t(\"about.appTipDescription\")}</div>\n          </div>\n          <i className=\"i-mingcute-sparkles-2-line text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n        </button>\n        {rateTarget && (\n          <button\n            type=\"button\"\n            onClick={() => {\n              void handleRateFolo()\n            }}\n            className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n          >\n            <div>\n              <div className=\"text-sm font-medium\">{t(\"about.rateFolo\")}</div>\n              <div className=\"text-xs text-text-tertiary\">{t(\"about.rateFoloDescription\")}</div>\n            </div>\n            <i className=\"i-mgc-star-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n          </button>\n        )}\n        <button\n          type=\"button\"\n          onClick={() => {\n            void handleSendFeedback()\n          }}\n          className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n        >\n          <div>\n            <div className=\"text-sm font-medium\">{t(\"about.sendFeedback\")}</div>\n            <div className=\"text-xs text-text-tertiary\">{t(\"about.sendFeedbackDescription\")}</div>\n          </div>\n          <i className=\"i-mgc-mail-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n        </button>\n      </div>\n\n      {/* Legal Section */}\n      <div className=\"-mx-3 !mt-4 space-y-1 px-2\">\n        <Divider />\n        <button\n          type=\"button\"\n          onClick={() => handleOpenLegal(\"tos\")}\n          className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n        >\n          <span className=\"text-sm\">{t(\"about.termsOfService\")}</span>\n          <i className=\"i-mgc-external-link-cute-re text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => handleOpenLegal(\"privacy\")}\n          className=\"group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm\"\n        >\n          <span className=\"text-sm\">{t(\"about.privacyPolicy\")}</span>\n          <i className=\"i-mgc-external-link-cute-re text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent\" />\n        </button>\n      </div>\n\n      {/* Resources Section */}\n      <div className=\"px-2\">\n        <h2 className=\"mb-4 text-sm font-semibold text-text-secondary\">{t(\"about.resources\")}</h2>\n        <div className=\"space-y-4\">\n          <p className=\"text-sm leading-relaxed text-text-secondary\">\n            <Trans\n              ns=\"settings\"\n              i18nKey=\"about.feedbackInfo\"\n              values={{ appName: APP_NAME, commitSha: GIT_COMMIT_SHA.slice(0, 7).toUpperCase() }}\n              components={{\n                OpenIssueLink: (\n                  <a\n                    className=\"text-accent hover:underline\"\n                    href={getNewIssueUrl({ template: \"feature_request.yml\" })}\n                    target=\"_blank\"\n                  >\n                    open an issue\n                  </a>\n                ),\n              }}\n            />\n          </p>\n          <p className=\"text-sm leading-relaxed text-text-secondary\">\n            {t(\"about.projectLicense\", { appName: APP_NAME })}\n          </p>\n          <p className=\"text-sm leading-relaxed text-text-secondary\">\n            <Trans\n              ns=\"settings\"\n              i18nKey=\"about.iconLibrary\"\n              components={{\n                IconLibraryLink: (\n                  <a\n                    className=\"text-accent hover:underline\"\n                    href=\"https://mgc.mingcute.com/\"\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                  >\n                    MingCute Icons\n                  </a>\n                ),\n              }}\n            />\n          </p>\n        </div>\n      </div>\n\n      {/* Social Links */}\n      <div className=\"px-2\">\n        <h2 className=\"mb-4 text-sm font-semibold text-text-secondary\">{t(\"about.socialMedia\")}</h2>\n        <div className=\"flex flex-wrap gap-6\">\n          {SocialMediaLinks.map((link) => (\n            <a\n              href={link.url}\n              key={link.url}\n              className=\"flex items-center gap-2 text-sm transition-colors hover:text-accent\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              <i className={cn(link.iconClassName, \"text-base\")} />\n              <span>{link.label}</span>\n            </a>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/PanelStyleSection.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport { AIChatPanelStyle, setAIChatPanelStyle, useAIChatPanelStyle } from \"~/atoms/settings/ai\"\n\nimport { SettingTabbedSegment } from \"../../control\"\n\nexport const PanelStyleSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const panelStyle = useAIChatPanelStyle()\n\n  return (\n    <SettingTabbedSegment\n      key=\"panel-style\"\n      label={t(\"settings.panel_style.label\")}\n      description={t(\"settings.panel_style.description\")}\n      value={panelStyle}\n      values={[\n        {\n          value: AIChatPanelStyle.Fixed,\n          label: t(\"settings.panel_style.fixed\"),\n          icon: <i className=\"i-mingcute-rectangle-vertical-line\" />,\n        },\n        {\n          value: AIChatPanelStyle.Floating,\n          label: t(\"settings.panel_style.floating\"),\n          icon: <i className=\"i-mingcute-layout-right-line\" />,\n        },\n      ]}\n      onValueChanged={(value) => {\n        setAIChatPanelStyle(value as AIChatPanelStyle)\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/PersonalizePromptSection.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { LexicalRichEditorTextArea } from \"@follow/components/ui/lexical-rich-editor/index.js\"\nimport { AnimatePresence } from \"motion/react\"\nimport { useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { setAISetting, useAISettingValue } from \"~/atoms/settings/ai\"\nimport { m } from \"~/components/common/Motion\"\nimport { MentionPlugin } from \"~/modules/ai-chat/editor\"\n\nimport { SettingDescription } from \"../../control\"\nimport { SettingModalContentPortal } from \"../../modal/layout\"\n\nexport const PersonalizePromptSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const aiSettings = useAISettingValue()\n  const promptRef = useRef(\"\")\n  const [isSaving, setIsSaving] = useState(false)\n  const [currentLength, setCurrentLength] = useState(0)\n  const [hasChanges, setHasChanges] = useState(false)\n\n  const MAX_CHARACTERS = 500\n  const isOverLimit = currentLength > MAX_CHARACTERS\n\n  const handleEditorChange = (value: string, textLength: number) => {\n    promptRef.current = value\n    setCurrentLength(textLength)\n    setHasChanges(value !== aiSettings.personalizePrompt)\n  }\n\n  const handleSave = async () => {\n    if (isOverLimit) {\n      toast.error(`Prompt must be ${MAX_CHARACTERS} characters or less`)\n      return\n    }\n\n    if (!promptRef.current) return\n\n    setIsSaving(true)\n    try {\n      setAISetting(\"personalizePrompt\", promptRef.current)\n      toast.success(t(\"personalize.saved\"))\n      setHasChanges(false)\n    } finally {\n      setIsSaving(false)\n    }\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <div className=\"relative -ml-3\">\n          <LexicalRichEditorTextArea\n            initialValue={aiSettings.personalizePrompt}\n            onValueChange={handleEditorChange}\n            plugins={[MentionPlugin]}\n            namespace=\"PersonalizePromptEditor\"\n            placeholder={t(\"personalize.prompt.placeholder\")}\n            className={`min-h-[80px] resize-none text-sm ${\n              isOverLimit ? \"border-red focus:border-red\" : \"\"\n            }`}\n          />\n          <div\n            className={`absolute bottom-2 right-2 text-xs ${\n              isOverLimit\n                ? \"text-red\"\n                : currentLength > MAX_CHARACTERS * 0.8\n                  ? \"text-yellow\"\n                  : \"text-text-tertiary\"\n            }`}\n          >\n            {currentLength}/{MAX_CHARACTERS}\n          </div>\n        </div>\n        <SettingDescription>\n          {t(\"personalize.prompt.help\")}\n          {isOverLimit && (\n            <span className=\"mt-1 block text-red\">\n              Prompt exceeds {MAX_CHARACTERS} character limit\n            </span>\n          )}\n        </SettingDescription>\n      </div>\n\n      <AnimatePresence>\n        {hasChanges && (\n          <SettingModalContentPortal>\n            <m.div\n              initial={{ opacity: 0, y: 20 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 20 }}\n              transition={Spring.presets.snappy}\n              className=\"absolute inset-x-0 bottom-3 z-10 flex justify-center px-3\"\n            >\n              <div\n                className=\"relative overflow-hidden rounded-full backdrop-blur-2xl\"\n                style={{\n                  backgroundImage:\n                    \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n                  borderWidth: \"1px\",\n                  borderStyle: \"solid\",\n                  borderColor: \"hsl(var(--fo-a) / 0.2)\",\n                  boxShadow:\n                    \"0 8px 32px hsl(var(--fo-a) / 0.08), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n                }}\n              >\n                {/* Inner glow layer */}\n                <div\n                  className=\"absolute inset-0 rounded-2xl\"\n                  style={{\n                    background:\n                      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))\",\n                  }}\n                />\n\n                {/* Content */}\n                <div className=\"relative flex w-fit max-w-full items-center justify-between gap-3 px-5 py-3\">\n                  <span className=\"whitespace-nowrap text-xs text-text-secondary sm:text-sm\">\n                    Unsaved changes\n                  </span>\n                  <Button\n                    buttonClassName=\"bg-accent rounded-full\"\n                    size=\"sm\"\n                    onClick={handleSave}\n                    disabled={isSaving || isOverLimit}\n                  >\n                    {isSaving ? \"Saving...\" : \"Save\"}\n                  </Button>\n                </div>\n              </div>\n            </m.div>\n          </SettingModalContentPortal>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/TimelinePromptSection.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { TextArea } from \"@follow/components/ui/input/index.js\"\nimport { AnimatePresence } from \"motion/react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { setAISetting, useAISettingValue } from \"~/atoms/settings/ai\"\nimport { m } from \"~/components/common/Motion\"\n\nimport { SettingDescription } from \"../../control\"\nimport { SettingModalContentPortal } from \"../../modal/layout\"\n\nexport const TimelinePromptSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const aiSettings = useAISettingValue()\n  const promptRef = useRef(\"\")\n  const [isSaving, setIsSaving] = useState(false)\n  const [currentLength, setCurrentLength] = useState(0)\n  const [hasChanges, setHasChanges] = useState(false)\n  const [promptValue, setPromptValue] = useState(aiSettings.aiTimelinePrompt)\n\n  const MAX_CHARACTERS = 500\n  const isOverLimit = currentLength > MAX_CHARACTERS\n\n  useEffect(() => {\n    promptRef.current = aiSettings.aiTimelinePrompt\n    setPromptValue(aiSettings.aiTimelinePrompt)\n    setCurrentLength(aiSettings.aiTimelinePrompt.length)\n    setHasChanges(false)\n  }, [aiSettings.aiTimelinePrompt])\n\n  const handleEditorChange = (value: string) => {\n    promptRef.current = value\n    setPromptValue(value)\n    setCurrentLength(value.length)\n    setHasChanges(value !== aiSettings.aiTimelinePrompt)\n  }\n\n  const handleSave = async () => {\n    if (isOverLimit) {\n      toast.error(`Prompt must be ${MAX_CHARACTERS} characters or less`)\n      return\n    }\n\n    if (!promptRef.current) return\n\n    setIsSaving(true)\n    try {\n      setAISetting(\"aiTimelinePrompt\", promptRef.current)\n      toast.success(t(\"timeline_prompt.saved\"))\n      setHasChanges(false)\n    } finally {\n      setIsSaving(false)\n    }\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <div className=\"relative -ml-3\">\n          <TextArea\n            value={promptValue}\n            onChange={(event) => handleEditorChange(event.target.value)}\n            placeholder={t(\"timeline_prompt.prompt.placeholder\")}\n            className={`min-h-[80px] resize-none text-sm ${\n              isOverLimit ? \"border-red focus:border-red\" : \"\"\n            }`}\n          />\n          <div\n            className={`absolute bottom-2 right-2 text-xs ${\n              isOverLimit\n                ? \"text-red\"\n                : currentLength > MAX_CHARACTERS * 0.8\n                  ? \"text-yellow\"\n                  : \"text-text-tertiary\"\n            }`}\n          >\n            {currentLength}/{MAX_CHARACTERS}\n          </div>\n        </div>\n        <SettingDescription>\n          {t(\"timeline_prompt.prompt.help\")}\n          {isOverLimit && (\n            <span className=\"mt-1 block text-red\">\n              Prompt exceeds {MAX_CHARACTERS} character limit\n            </span>\n          )}\n        </SettingDescription>\n      </div>\n\n      <AnimatePresence>\n        {hasChanges && (\n          <SettingModalContentPortal>\n            <m.div\n              initial={{ opacity: 0, y: 20 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 20 }}\n              transition={Spring.presets.snappy}\n              className=\"absolute inset-x-0 bottom-3 z-10 flex justify-center px-3\"\n            >\n              <div\n                className=\"relative overflow-hidden rounded-full backdrop-blur-2xl\"\n                style={{\n                  backgroundImage:\n                    \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n                  borderWidth: \"1px\",\n                  borderStyle: \"solid\",\n                  borderColor: \"hsl(var(--fo-a) / 0.2)\",\n                  boxShadow:\n                    \"0 8px 32px hsl(var(--fo-a) / 0.08), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n                }}\n              >\n                <div\n                  className=\"absolute inset-0 rounded-2xl\"\n                  style={{\n                    background:\n                      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))\",\n                  }}\n                />\n\n                <div className=\"relative flex w-fit max-w-full items-center justify-between gap-3 px-5 py-3\">\n                  <span className=\"whitespace-nowrap text-xs text-text-secondary sm:text-sm\">\n                    Unsaved changes\n                  </span>\n                  <Button\n                    buttonClassName=\"bg-accent rounded-full\"\n                    size=\"sm\"\n                    onClick={handleSave}\n                    disabled={isSaving || isOverLimit}\n                  >\n                    {isSaving ? \"Saving...\" : \"Save\"}\n                  </Button>\n                </div>\n              </div>\n            </m.div>\n          </SettingModalContentPortal>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/byok/ByokProviderItem.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.jsx\"\nimport type { UserByokProviderConfig } from \"@follow/shared/settings/interface\"\nimport { useTranslation } from \"react-i18next\"\n\ninterface ByokProviderItemProps {\n  provider: UserByokProviderConfig\n  onDelete: () => void\n  onEdit: () => void\n}\n\nconst PROVIDER_LABELS: Record<string, string> = {\n  openai: \"OpenAI\",\n  google: \"Google\",\n  \"vercel-ai-gateway\": \"Vercel AI Gateway\",\n  openrouter: \"OpenRouter\",\n}\n\nexport const ByokProviderItem = ({ provider, onEdit, onDelete }: ByokProviderItemProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  const providerLabel = PROVIDER_LABELS[provider.provider] || provider.provider\n\n  return (\n    <div className=\"group -ml-3 rounded-lg border border-border p-3 transition-colors hover:bg-material-medium\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex-1\">\n          <div className=\"flex items-center gap-2\">\n            <h4 className=\"text-sm font-medium text-text\">{providerLabel}</h4>\n            <span className=\"inline-flex rounded-full bg-accent/10 px-2 py-1 text-xs text-accent\">\n              {t(\"byok.providers.configured\")}\n            </span>\n          </div>\n        </div>\n\n        <div className=\"ml-4 flex items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100\">\n          <Tooltip delayDuration={300}>\n            <TooltipTrigger asChild>\n              <Button variant=\"ghost\" size=\"sm\" onClick={onEdit}>\n                <i className=\"i-mgc-edit-cute-re size-4\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{t(\"byok.providers.edit\")}</TooltipContent>\n          </Tooltip>\n          <Tooltip delayDuration={300}>\n            <TooltipTrigger asChild>\n              <Button variant=\"ghost\" size=\"sm\" onClick={onDelete}>\n                <i className=\"i-mgc-delete-2-cute-re size-4\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{t(\"byok.providers.delete\")}</TooltipContent>\n          </Tooltip>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/byok/ByokProviderModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@follow/components/ui/select/index.js\"\nimport type { ByokProviderName, UserByokProviderConfig } from \"@follow/shared/settings/interface\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { PROVIDER_OPTIONS } from \"./constants\"\n\ninterface ByokProviderModalContentProps {\n  provider: UserByokProviderConfig | null\n  configuredProviders?: ByokProviderName[]\n  onSave: (provider: UserByokProviderConfig) => void\n  onCancel: () => void\n}\n\nconst EMPTY_CONFIGURED_PROVIDERS: ByokProviderName[] = []\n\nexport const ByokProviderModalContent = ({\n  provider,\n  configuredProviders = EMPTY_CONFIGURED_PROVIDERS,\n  onSave,\n  onCancel,\n}: ByokProviderModalContentProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  // Filter out already configured providers, but keep the current one if editing\n  const availableProviders = PROVIDER_OPTIONS.filter(\n    (option) => !configuredProviders.includes(option.value) || option.value === provider?.provider,\n  )\n\n  // Get the first available provider or fallback to the current one\n  const defaultProvider = availableProviders[0]?.value ?? provider?.provider ?? \"openai\"\n\n  const [formData, setFormData] = useState<UserByokProviderConfig>({\n    provider: provider?.provider ?? defaultProvider,\n    baseURL: provider?.baseURL ?? null,\n    apiKey: provider?.apiKey ?? null,\n    headers: provider?.headers ?? {},\n  })\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault()\n    if (!formData.provider) {\n      return\n    }\n    onSave(formData)\n  }\n\n  return (\n    <form onSubmit={handleSubmit} className=\"min-w-[40ch] space-y-4\">\n      <div className=\"space-y-2\">\n        <Label htmlFor=\"provider\">{t(\"byok.providers.form.provider\")}</Label>\n        <Select\n          value={formData.provider}\n          disabled={availableProviders.length === 0}\n          onValueChange={(value) =>\n            setFormData({ ...formData, provider: value as ByokProviderName })\n          }\n        >\n          <SelectTrigger id=\"provider\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {availableProviders.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                {option.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      <div className=\"space-y-2\">\n        <Label htmlFor=\"baseURL\">{t(\"byok.providers.form.base_url\")}</Label>\n        <Input\n          id=\"baseURL\"\n          type=\"url\"\n          placeholder={t(\"byok.providers.form.base_url_placeholder\")}\n          value={formData.baseURL ?? \"\"}\n          onChange={(e) =>\n            setFormData({\n              ...formData,\n              baseURL: e.target.value || null,\n            })\n          }\n        />\n        <p className=\"text-xs text-text-secondary\">{t(\"byok.providers.form.base_url_help\")}</p>\n      </div>\n\n      <div className=\"space-y-2\">\n        <Label htmlFor=\"apiKey\">{t(\"byok.providers.form.api_key\")}</Label>\n        <Input\n          id=\"apiKey\"\n          type=\"password\"\n          placeholder={t(\"byok.providers.form.api_key_placeholder\")}\n          value={formData.apiKey ?? \"\"}\n          onChange={(e) =>\n            setFormData({\n              ...formData,\n              apiKey: e.target.value || null,\n            })\n          }\n        />\n        <p className=\"text-xs text-text-secondary\">{t(\"byok.providers.form.api_key_help\")}</p>\n      </div>\n\n      <div className=\"flex justify-end gap-2\">\n        <Button type=\"button\" variant=\"ghost\" onClick={onCancel}>\n          {t(\"words.cancel\", { ns: \"common\" })}\n        </Button>\n        <Button type=\"submit\">{t(\"words.save\", { ns: \"common\" })}</Button>\n      </div>\n    </form>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/byok/ByokSection.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport type { UserByokProviderConfig } from \"@follow/shared/settings/interface\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { getAISettings, setAISetting, useAISettingValue } from \"~/atoms/settings/ai\"\nimport { useDialog, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { ByokProviderItem } from \"./ByokProviderItem\"\nimport { ByokProviderModalContent } from \"./ByokProviderModalContent\"\nimport { PROVIDER_OPTIONS } from \"./constants\"\n\nexport const ByokSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const aiSettings = useAISettingValue()\n  const byok = aiSettings.byok ?? { enabled: false, providers: [] }\n  const { present } = useModalStack()\n  const { ask } = useDialog()\n\n  const handleToggleEnabled = (enabled: boolean) => {\n    setAISetting(\"byok\", {\n      ...byok,\n      enabled,\n    })\n  }\n\n  const handleAddProvider = () => {\n    const currentByok = getAISettings().byok ?? { enabled: false, providers: [] }\n    const configuredProviders = currentByok.providers.map((p) => p.provider)\n\n    present({\n      title: t(\"byok.providers.add_title\"),\n      content: ({ dismiss }: { dismiss: () => void }) => (\n        <ByokProviderModalContent\n          provider={null}\n          configuredProviders={configuredProviders}\n          onSave={(provider) => {\n            const updatedByok = getAISettings().byok ?? { enabled: false, providers: [] }\n            setAISetting(\"byok\", {\n              ...updatedByok,\n              providers: [...updatedByok.providers, provider],\n            })\n            toast.success(t(\"byok.providers.added\"))\n            dismiss()\n          }}\n          onCancel={dismiss}\n        />\n      ),\n    })\n  }\n\n  const handleEditProvider = (index: number, provider: UserByokProviderConfig) => {\n    const currentByok = getAISettings().byok ?? { enabled: false, providers: [] }\n    // Exclude the current provider being edited from configured list\n    const configuredProviders = currentByok.providers\n      .filter((_, i) => i !== index)\n      .map((p) => p.provider)\n\n    present({\n      title: t(\"byok.providers.edit_title\"),\n      content: ({ dismiss }: { dismiss: () => void }) => (\n        <ByokProviderModalContent\n          provider={provider}\n          configuredProviders={configuredProviders}\n          onSave={(updatedProvider) => {\n            const updatedByok = getAISettings().byok ?? { enabled: false, providers: [] }\n            const updatedProviders = [...updatedByok.providers]\n            updatedProviders[index] = updatedProvider\n            setAISetting(\"byok\", {\n              ...updatedByok,\n              providers: updatedProviders,\n            })\n            toast.success(t(\"byok.providers.updated\"))\n            dismiss()\n          }}\n          onCancel={dismiss}\n        />\n      ),\n    })\n  }\n\n  const handleDeleteProvider = async (index: number) => {\n    const confirmed = await ask({\n      title: t(\"byok.providers.delete_title\"),\n      message: t(\"byok.providers.delete_message\"),\n      confirmText: t(\"words.delete\", { ns: \"common\" }),\n      cancelText: t(\"words.cancel\", { ns: \"common\" }),\n      variant: \"danger\",\n    })\n\n    if (confirmed) {\n      const currentByok = getAISettings().byok ?? { enabled: false, providers: [] }\n      const updatedProviders = currentByok.providers.filter((_, i) => i !== index)\n      setAISetting(\"byok\", {\n        ...currentByok,\n        providers: updatedProviders,\n      })\n      toast.success(t(\"byok.providers.deleted\"))\n    }\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"space-y-1\">\n            <Label className=\"text-sm font-medium text-text\">{t(\"byok.enabled\")}</Label>\n            <div className=\"text-xs text-text-secondary\">{t(\"byok.description\")}</div>\n          </div>\n          <Switch checked={byok.enabled} onCheckedChange={handleToggleEnabled} />\n        </div>\n      </div>\n\n      {byok.enabled && (\n        <>\n          <div className=\"flex items-center justify-between\">\n            <Label className=\"text-sm font-medium text-text\">{t(\"byok.providers.title\")}</Label>\n            {byok.providers.length < PROVIDER_OPTIONS.length && (\n              <Button variant=\"outline\" size=\"sm\" onClick={handleAddProvider}>\n                <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n                {t(\"byok.providers.add\")}\n              </Button>\n            )}\n          </div>\n\n          {byok.providers.length === 0 && (\n            <div className=\"py-8 text-center\">\n              <div className=\"mx-auto mb-3 flex size-12 items-center justify-center rounded-full bg-fill-secondary\">\n                <i className=\"i-mgc-key-2-cute-re size-6 text-text\" />\n              </div>\n              <h4 className=\"mb-1 text-sm font-medium text-text\">\n                {t(\"byok.providers.empty.title\")}\n              </h4>\n              <p className=\"text-xs text-text-secondary\">{t(\"byok.providers.empty.description\")}</p>\n            </div>\n          )}\n\n          <div className=\"!mt-2 space-y-4\">\n            {byok.providers.map((provider, index) => (\n              <ByokProviderItem\n                key={index}\n                provider={provider}\n                onDelete={() => handleDeleteProvider(index)}\n                onEdit={() => handleEditProvider(index, provider)}\n              />\n            ))}\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/byok/constants.ts",
    "content": "import type { ByokProviderName } from \"@follow/shared/settings/interface\"\n\nexport const PROVIDER_OPTIONS: { value: ByokProviderName; label: string }[] = [\n  { value: \"openai\", label: \"OpenAI\" },\n  { value: \"google\", label: \"Google\" },\n  { value: \"vercel-ai-gateway\", label: \"Vercel AI Gateway\" },\n  { value: \"openrouter\", label: \"OpenRouter\" },\n]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/byok/index.ts",
    "content": "export { ByokSection } from \"./ByokSection\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/index.ts",
    "content": "export { PanelStyleSection } from \"./PanelStyleSection\"\nexport { PersonalizePromptSection } from \"./PersonalizePromptSection\"\nexport { AIShortcutsSection } from \"./shortcuts\"\nexport { UsageAnalysisSection } from \"./usage\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/mcp/MCPPresetCard.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\n\nimport type { MCPPreset } from \"./types\"\n\ninterface MCPPresetCardProps {\n  preset: MCPPreset\n  onSelect: (preset: MCPPreset) => void\n}\n\nexport const MCPPresetCard = ({ preset, onSelect }: MCPPresetCardProps) => {\n  return (\n    <div className=\"group rounded-lg border border-fill-secondary bg-material-medium p-4 transition-all hover:border-accent hover:bg-fill-quaternary hover:shadow-md\">\n      <div className=\"flex flex-col items-center space-y-3 text-center\">\n        {/* Icon */}\n        <div className=\"flex size-12 items-center justify-center\">\n          <i className={`${preset.icon} size-8 text-text`} />\n        </div>\n\n        {/* Service Name */}\n        <h3 className=\"text-sm font-medium text-text\">{preset.displayName}</h3>\n\n        {/* Description */}\n        <p className=\"text-xs leading-relaxed text-text-secondary\">{preset.description}</p>\n\n        {/* Features */}\n        <div className=\"w-full space-y-1\">\n          {preset.features.map((feature) => (\n            <div key={feature} className=\"flex items-center text-left text-xs text-text\">\n              <span className=\"mr-2 text-accent\">•</span>\n              <span>{feature}</span>\n            </div>\n          ))}\n        </div>\n\n        {/* Action Button */}\n        <Button\n          size=\"sm\"\n          buttonClassName=\"w-full bg-accent text-white hover:bg-accent/90\"\n          onClick={() => onSelect(preset)}\n        >\n          Quick Setup\n        </Button>\n\n        {/* Auth Required Indicator */}\n        {preset.authRequired && (\n          <div className=\"flex items-center text-xs text-text-secondary\">\n            <i className=\"i-mgc-user-setting-cute-re mr-1 size-3\" />\n            <span>Authentication required</span>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/mcp/MCPPresetSelectionModal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\n\nimport { MCPPresetCard } from \"./MCPPresetCard\"\nimport type { MCPPreset } from \"./types\"\nimport { MCP_PRESETS } from \"./types\"\n\ninterface MCPPresetSelectionModalProps {\n  onPresetSelected: (preset: MCPPreset) => void\n  onManualConfig: () => void\n}\n\nexport const MCPPresetSelectionModal = ({\n  onPresetSelected,\n  onManualConfig,\n}: MCPPresetSelectionModalProps) => {\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"space-y-4\">\n        <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n          {MCP_PRESETS.map((preset) => (\n            <MCPPresetCard key={preset.id} preset={preset} onSelect={onPresetSelected} />\n          ))}\n\n          {/* Custom/Manual Configuration Card */}\n          <div className=\"group rounded-lg border border-fill-secondary bg-material-medium p-4 transition-all hover:border-accent hover:bg-fill-quaternary hover:shadow-md\">\n            <div className=\"flex flex-col items-center space-y-3 text-center\">\n              <div className=\"flex size-12 items-center justify-center\">\n                <i className=\"i-mgc-settings-7-cute-re size-8 text-text\" />\n              </div>\n\n              <h3 className=\"text-sm font-medium text-text\">Custom</h3>\n\n              <p className=\"text-xs leading-relaxed text-text-secondary\">\n                Manual configuration for other MCP services\n              </p>\n\n              <div className=\"w-full space-y-1\">\n                <div className=\"flex items-center text-left text-xs text-text\">\n                  <span className=\"mr-2 text-accent\">•</span>\n                  <span>Custom URL & settings</span>\n                </div>\n                <div className=\"flex items-center text-left text-xs text-text\">\n                  <span className=\"mr-2 text-accent\">•</span>\n                  <span>Advanced configuration</span>\n                </div>\n                <div className=\"flex items-center text-left text-xs text-text\">\n                  <span className=\"mr-2 text-accent\">•</span>\n                  <span>Full control</span>\n                </div>\n              </div>\n\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                buttonClassName=\"w-full border-accent text-accent hover:bg-accent hover:text-white\"\n                onClick={onManualConfig}\n              >\n                Configure\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Future Services Hint */}\n      <div className=\"rounded-lg bg-fill-secondary/50 p-4\">\n        <div className=\"flex items-start space-x-3\">\n          <i className=\"i-mgc-information-cute-re mt-0.5 size-4 text-text-secondary\" />\n          <div className=\"space-y-1\">\n            <p className=\"text-xs font-medium text-text\">More services coming soon</p>\n            <p className=\"text-xs text-text-secondary\">\n              You can use the custom configuration option for any MCP-compatible service.\n            </p>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/mcp/MCPServiceItem.tsx",
    "content": "import type { MCPService } from \"@follow/shared/settings/interface\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { ActionButton } from \"~/modules/ai-task/components/ai-item-actions\"\nimport { ItemActions } from \"~/modules/ai-task/components/ai-item-actions\"\n\ninterface MCPServiceItemProps {\n  service: MCPService\n  onDelete: (id: string) => void\n  onRefresh: (connectionId: string) => void\n  onEdit: (service: MCPService) => void\n  onToggleEnabled: (id: string, enabled: boolean) => void\n  isDeleting?: boolean\n  isRefreshing?: boolean\n}\n\nexport const MCPServiceItem = ({\n  service,\n  onDelete,\n  onRefresh,\n  onEdit,\n  onToggleEnabled,\n  isDeleting = false,\n  isRefreshing = false,\n}: MCPServiceItemProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  const getConnectionStatusColor = (isConnected: boolean) => {\n    return isConnected ? \"bg-green/10 text-green\" : \"bg-gray/10 text-text-tertiary\"\n  }\n\n  const getConnectionStatusText = (isConnected: boolean) => {\n    return isConnected\n      ? t(\"integration.mcp.service.connected\")\n      : t(\"integration.mcp.service.disconnected\")\n  }\n\n  const formatDate = (dateString: string | null) => {\n    if (!dateString) return \"Never\"\n    return new Date(dateString).toLocaleDateString()\n  }\n\n  const actions: ActionButton[] = [\n    {\n      icon: \"i-mgc-edit-cute-re\",\n      onClick: () => onEdit(service),\n      title: \"Edit connection\",\n    },\n    {\n      icon: \"i-mgc-refresh-2-cute-re\",\n      onClick: () => onRefresh(service.id),\n      title: \"Refresh tools\",\n      disabled: isRefreshing,\n      loading: isRefreshing,\n    },\n    {\n      icon: \"i-mgc-delete-2-cute-re\",\n      onClick: () => onDelete(service.id),\n      title: \"Delete service\",\n      disabled: isDeleting,\n      loading: isDeleting,\n    },\n  ]\n\n  return (\n    <div className=\"group -ml-3 rounded-lg border border-border p-3 transition-colors hover:bg-material-medium\">\n      <div className=\"flex items-start justify-between\">\n        <div className=\"flex-1 space-y-2\">\n          <div className=\"flex items-center gap-2\">\n            <h4 className=\"text-sm font-medium text-text\">{service.name}</h4>\n            <div\n              className={`rounded-full px-2 py-1 text-xs ${getConnectionStatusColor(service.isConnected)}`}\n            >\n              {getConnectionStatusText(service.isConnected)}\n            </div>\n            <div className=\"rounded-full bg-blue/10 px-2 py-1 text-xs text-blue\">\n              {service.transportType}\n            </div>\n          </div>\n          <div className=\"space-y-1\">\n            {service.url && (\n              <p className=\"text-xs text-text-secondary\">\n                <span className=\"text-text-tertiary\">URL:</span> {service.url}\n              </p>\n            )}\n\n            <p className=\"text-xs text-text-secondary\">\n              <span className=\"text-text-tertiary\">Tools:</span> {service.toolCount}\n              <span className=\"ml-4 text-text-tertiary\">Created:</span>{\" \"}\n              {formatDate(service.createdAt)}\n              <span className=\"ml-4 text-text-tertiary\">Last Used:</span>{\" \"}\n              {formatDate(service.lastUsed)}\n            </p>\n            {service.lastError && (\n              <p className=\"text-xs text-red\">\n                <span className=\"text-text-tertiary\">Error:</span> {service.lastError}\n              </p>\n            )}\n          </div>\n        </div>\n\n        <ItemActions\n          actions={actions}\n          enabled={service.enabled}\n          onToggle={(enabled) => onToggleEnabled(service.id, enabled)}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/mcp/MCPServiceModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { KeyValueEditor } from \"@follow/components/ui/key-value-editor/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@follow/components/ui/select/index.js\"\nimport type { MCPService } from \"@follow/shared/settings/interface\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\ninterface MCPServiceModalContentProps {\n  service?: MCPService | null\n  initialValues?: {\n    name: string\n    transportType: \"streamable-http\" | \"sse\"\n    url: string\n  }\n  onSave: (service: {\n    name: string\n    transportType: \"streamable-http\" | \"sse\"\n    url: string\n    headers?: Record<string, string>\n  }) => void\n  onCancel: () => void\n}\n\nexport const MCPServiceModalContent = ({\n  service,\n  initialValues,\n  onSave,\n  onCancel,\n}: MCPServiceModalContentProps) => {\n  const { t } = useTranslation(\"ai\")\n  const [name, setName] = useState(service?.name || initialValues?.name || \"\")\n  const [url, setUrl] = useState(service?.url || initialValues?.url || \"\")\n  const [transportType, setTransportType] = useState<\"streamable-http\" | \"sse\">(\n    service?.transportType || initialValues?.transportType || \"streamable-http\",\n  )\n  const [headers, setHeaders] = useState<Record<string, string>>(service?.headers || {})\n\n  const handleSave = () => {\n    if (!name.trim()) {\n      toast.error(t(\"integration.mcp.service.validation.name_required\"))\n      return\n    }\n\n    if (!url.trim()) {\n      toast.error(t(\"integration.mcp.service.validation.baseUrl_required\"))\n      return\n    }\n\n    // Basic URL validation\n    try {\n      new URL(url.trim())\n    } catch {\n      toast.error(t(\"integration.mcp.service.validation.invalid_url\"))\n      return\n    }\n\n    onSave({\n      name: name.trim(),\n      transportType,\n      url: url.trim(),\n      headers: Object.keys(headers).length > 0 ? headers : undefined,\n    })\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-4\">\n        <div className=\"grid grid-cols-1 gap-4\">\n          <div className=\"space-y-2\">\n            <Label className=\"text-xs text-text\">{t(\"integration.mcp.service.name\")}</Label>\n            <Input\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder={t(\"integration.mcp.service.name_placeholder\")}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label className=\"text-xs text-text\">Transport Type</Label>\n            <Select\n              value={transportType}\n              onValueChange={(value) => setTransportType(value as \"streamable-http\" | \"sse\")}\n            >\n              <SelectTrigger>\n                <SelectValue placeholder=\"Select transport type\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"streamable-http\">Streamable HTTP</SelectItem>\n                <SelectItem value=\"sse\">Server-Sent Events</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label className=\"text-xs text-text\">URL</Label>\n            <Input\n              value={url}\n              onChange={(e) => setUrl(e.target.value)}\n              placeholder=\"https://example.com/mcp\"\n            />\n          </div>\n\n          <div className=\"min-w-[500px] space-y-2\">\n            <Label className=\"text-xs text-text\">Headers (Optional)</Label>\n            <KeyValueEditor\n              value={headers}\n              onChange={setHeaders}\n              keyPlaceholder=\"Header name\"\n              valuePlaceholder=\"Header value\"\n              addButtonText=\"Add Header\"\n              minRows={0}\n            />\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <div />\n        <div className=\"flex gap-2\">\n          <Button variant=\"outline\" size=\"sm\" onClick={onCancel}>\n            Cancel\n          </Button>\n          <Button size=\"sm\" onClick={handleSave}>\n            Save\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/mcp/MCPServicesSection.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport type { WithOptimistic } from \"@follow/hooks\"\nimport { createOptimisticConfig, useOptimisticMutation } from \"@follow/hooks\"\nimport type { MCPService } from \"@follow/shared/settings/interface\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { useEventListener } from \"usehooks-ts\"\n\nimport { setMCPEnabled, useMCPEnabled } from \"~/atoms/settings/ai\"\nimport { useDialog, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport {\n  createMCPConnection,\n  deleteMCPConnection,\n  fetchMCPConnections,\n  mcpQueryKeys,\n  refreshMCPTools,\n  updateMCPConnection,\n} from \"~/queries/mcp\"\n\nimport { MCPPresetSelectionModal } from \"./MCPPresetSelectionModal\"\nimport { MCPServiceItem } from \"./MCPServiceItem\"\nimport { MCPServiceModalContent } from \"./MCPServiceModalContent\"\n\n// Use the generic optimistic wrapper type\ntype OptimisticMCPService = WithOptimistic<MCPService>\n\nexport const MCPServicesSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const mcpEnabled = useMCPEnabled()\n  const queryClient = useQueryClient()\n  const dialog = useDialog()\n\n  const shouldWindowFocusRefetchRef = React.useRef(false)\n\n  // Reusable OAuth authorization handler using dialog\n  const handleOAuthAuthorization = async (authorizationUrl: string, _connectionId?: string) => {\n    const confirmed = await dialog.ask({\n      title: t(\"integration.mcp.service.auth_required\"),\n      message: t(\"integration.mcp.service.auth_message\"),\n      confirmText: t(\"integration.mcp.service.open_auth\"),\n      cancelText: t(\"words.cancel\", { ns: \"common\" }),\n      variant: \"ask\",\n    })\n\n    if (confirmed) {\n      const popup = window.open(\n        authorizationUrl,\n        \"_blank\",\n        \"width=600,height=700,scrollbars=yes,resizable=yes,popup=yes\",\n      )\n\n      if (!popup) {\n        toast.error(t(\"integration.mcp.service.popup_blocked\"))\n      } else {\n        shouldWindowFocusRefetchRef.current = true\n      }\n    }\n  }\n\n  useEventListener(\"focus\", () => {\n    if (shouldWindowFocusRefetchRef.current) {\n      shouldWindowFocusRefetchRef.current = false\n      refetch()\n    }\n  })\n\n  // Query for MCP connections\n  const {\n    data: mcpServices = [],\n    isLoading,\n    error,\n    refetch,\n  } = useQuery({\n    queryKey: mcpQueryKeys.connections(),\n    queryFn: fetchMCPConnections,\n    enabled: mcpEnabled,\n    refetchInterval: 30_000,\n    retry: 2,\n  })\n\n  // Optimistic mutation for creating MCP connection\n  const createConnectionMutation = useOptimisticMutation(\n    createOptimisticConfig.custom<OptimisticMCPService, Parameters<typeof createMCPConnection>[0]>({\n      mutationFn: createMCPConnection,\n      queryKey: mcpQueryKeys.connections(),\n      optimisticUpdater: (variables, previousData) => {\n        const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`\n        const optimisticService: OptimisticMCPService = {\n          id: tempId,\n          name: variables.name || \"New Service\",\n          transportType: variables.transportType,\n          url: variables.url,\n          headers: variables.headers,\n          isConnected: false,\n          enabled: true,\n          toolCount: 0,\n          resourceCount: 0,\n          promptCount: 0,\n          createdAt: new Date().toISOString(),\n          lastUsed: null,\n        }\n\n        return {\n          newData: [optimisticService, ...previousData],\n          rollbackData: previousData,\n          tempId,\n        }\n      },\n      successUpdater: (result: any, _variables, previousData, context: any) => {\n        // Handle the API response structure\n        return previousData.map((service) => {\n          if (service.id === context?.tempId) {\n            // Merge the response data with optimistic data\n            return {\n              ...service,\n              id: result.connectionId || result.id || service.id,\n              isConnected: true,\n              status: \"connected\" as const,\n              isOptimistic: false,\n            }\n          }\n          return service\n        })\n      },\n      onSuccess: async (result: any) => {\n        if (result.authorizationUrl) {\n          await handleOAuthAuthorization(result.authorizationUrl, result.connectionId)\n        } else {\n          toast.success(t(\"integration.mcp.service.added\"))\n          refreshToolsMutation.mutate([result.connectionId])\n        }\n      },\n      errorConfig: {\n        showToast: true,\n        customMessage: t(\"integration.mcp.service.discovery_failed\"),\n        retryable: false,\n      },\n    }),\n  )\n\n  // Optimistic mutation for updating MCP connection\n  const updateConnectionMutation = useOptimisticMutation(\n    createOptimisticConfig.custom<\n      OptimisticMCPService,\n      {\n        connectionId: string\n        updateData: Parameters<typeof updateMCPConnection>[1]\n      }\n    >({\n      mutationFn: (({ connectionId, updateData }) =>\n        updateMCPConnection(connectionId, updateData)) as any,\n      queryKey: mcpQueryKeys.connections(),\n      optimisticUpdater: (variables, previousData) => {\n        const newData = previousData.map((service) =>\n          service.id === variables.connectionId\n            ? {\n                ...service,\n                ...variables.updateData,\n                status: \"updating\" as const,\n                isOptimistic: true,\n                updatedAt: new Date().toISOString(),\n              }\n            : service,\n        )\n\n        return {\n          newData,\n          rollbackData: previousData,\n          targetId: variables.connectionId,\n        }\n      },\n      successUpdater: (result: any, variables, previousData) => {\n        return previousData.map((service) =>\n          service.id === variables.connectionId\n            ? {\n                ...service,\n                ...result,\n                id: variables.connectionId, // Keep the original ID\n                status: \"connected\" as const,\n                isOptimistic: false,\n              }\n            : service,\n        )\n      },\n      onSuccess: async (result: any) => {\n        if (result.authorizationUrl) {\n          await handleOAuthAuthorization(result.authorizationUrl, result.connectionId)\n        } else {\n          toast.success(t(\"integration.mcp.service.updated\"))\n          refreshToolsMutation.mutate([result.connectionId])\n        }\n      },\n      errorConfig: {\n        showToast: true,\n        customMessage: \"Failed to update MCP connection\",\n        retryable: false,\n      },\n    }),\n  )\n\n  // Optimistic mutation for toggling connection enabled status\n  const toggleConnectionMutation = useOptimisticMutation(\n    createOptimisticConfig.forToggle<\n      OptimisticMCPService,\n      { connectionId: string; enabled: boolean },\n      any // API response type\n    >({\n      mutationFn: ({ connectionId, enabled }) => updateMCPConnection(connectionId, { enabled }),\n      queryKey: mcpQueryKeys.connections(),\n      getId: (variables) => variables.connectionId,\n      getToggleData: (variables) => ({ enabled: variables.enabled }),\n      errorMessage: \"Failed to toggle MCP connection\",\n      retryable: true,\n    }),\n  )\n\n  // Optimistic mutation for deleting MCP connection\n  const deleteConnectionMutation = useOptimisticMutation(\n    createOptimisticConfig.forDelete<OptimisticMCPService, string, void>({\n      mutationFn: deleteMCPConnection,\n      queryKey: mcpQueryKeys.connections(),\n      getId: (connectionId) => connectionId,\n      onSuccess: () => {\n        toast.success(t(\"integration.mcp.service.deleted\"))\n      },\n      errorMessage: \"Failed to delete MCP connection\",\n      retryable: false,\n    }),\n  )\n\n  // Mutation for refreshing MCP tools\n  const refreshToolsMutation = useMutation({\n    mutationFn: (connectionIds?: string[]) => refreshMCPTools(connectionIds),\n    onSuccess: () => {\n      // Invalidate both connections (for updated counts) and tools queries\n      queryClient.invalidateQueries({ queryKey: mcpQueryKeys.connections() })\n      queryClient.invalidateQueries({ queryKey: mcpQueryKeys.all })\n      toast.success(\"MCP tools refreshed successfully\")\n    },\n    onError: (error) => {\n      toast.error(\"Failed to refresh MCP tools\")\n      console.error(\"Failed to refresh MCP tools:\", error)\n    },\n  })\n\n  const { present } = useModalStack()\n  const handleAddService = () => {\n    present({\n      title: \"Add MCP Service\",\n      content: ({ dismiss }: { dismiss: () => void }) => (\n        <MCPPresetSelectionModal\n          onPresetSelected={(preset) => {\n            if (!preset.quickSetup) {\n              // Show form with preset values pre-filled\n              present({\n                title: `Setup ${preset.displayName}`,\n                content: ({ dismiss: dismissForm }) => (\n                  <MCPServiceModalContent\n                    service={null}\n                    initialValues={preset.configTemplate}\n                    onSave={(service) => {\n                      createConnectionMutation.mutate(service)\n                      dismissForm()\n                    }}\n                    onCancel={dismissForm}\n                  />\n                ),\n              })\n            } else {\n              // Direct submission for services\n              createConnectionMutation.mutate(preset.configTemplate)\n            }\n            dismiss()\n          }}\n          onManualConfig={() => {\n            // Show manual configuration form\n            present({\n              title: \"Add MCP Service\",\n              content: ({ dismiss: dismissForm }) => (\n                <MCPServiceModalContent\n                  service={null}\n                  onSave={(service) => {\n                    createConnectionMutation.mutate(service)\n                    dismissForm()\n                  }}\n                  onCancel={dismissForm}\n                />\n              ),\n            })\n            dismiss()\n          }}\n        />\n      ),\n    })\n  }\n\n  const handleEditService = (service: MCPService) => {\n    present({\n      title: \"Edit MCP Service\",\n      content: ({ dismiss }: { dismiss: () => void }) => (\n        <MCPServiceModalContent\n          service={service}\n          onSave={(updatedService) => {\n            updateConnectionMutation.mutate({\n              connectionId: service.id,\n              updateData: updatedService,\n            })\n            dismiss()\n          }}\n          onCancel={dismiss}\n        />\n      ),\n    })\n  }\n\n  const { ask } = useDialog()\n\n  const handleDeleteService = async (id: string) => {\n    const confirmed = await ask({\n      title: t(\"integration.mcp.service.delete_title\"),\n      message: t(\"integration.mcp.service.delete_message\"),\n      confirmText: t(\"words.delete\", { ns: \"common\" }),\n      cancelText: t(\"words.cancel\", { ns: \"common\" }),\n      variant: \"danger\",\n    })\n\n    if (confirmed) {\n      deleteConnectionMutation.mutate(id)\n    }\n  }\n\n  const handleRefreshTools = (connectionId?: string) => {\n    refreshToolsMutation.mutate(connectionId ? [connectionId] : undefined)\n  }\n\n  const handleToggleEnabled = (id: string, enabled: boolean) => {\n    toggleConnectionMutation.mutate({ connectionId: id, enabled })\n  }\n\n  // Show error message if query failed\n  React.useEffect(() => {\n    if (error) {\n      toast.error(\"Failed to load MCP connections\")\n      console.error(\"Failed to load MCP connections:\", error)\n    }\n  }, [error])\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"space-y-1\">\n            <Label className=\"text-sm font-medium text-text\">{t(\"integration.mcp.enabled\")}</Label>\n            <div className=\"text-xs text-text-secondary\">{t(\"integration.mcp.description\")}</div>\n          </div>\n          <Switch checked={mcpEnabled} onCheckedChange={setMCPEnabled} />\n        </div>\n      </div>\n\n      {mcpEnabled && (\n        <>\n          <div className=\"flex items-center justify-between\">\n            <Label className=\"text-sm font-medium text-text\">\n              {t(\"integration.mcp.services.title\")}\n            </Label>\n            <div className=\"flex gap-2\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => refetch()}\n                disabled={isLoading}\n                title=\"Refresh connections\"\n              >\n                {isLoading ? (\n                  <i className=\"i-mgc-loading-3-cute-re size-4 animate-spin\" />\n                ) : (\n                  <i className=\"i-mgc-refresh-2-cute-re size-4\" />\n                )}\n              </Button>\n              <Button variant=\"outline\" size=\"sm\" onClick={handleAddService}>\n                <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n                {t(\"integration.mcp.services.add\")}\n              </Button>\n            </div>\n          </div>\n\n          {mcpServices.length === 0 && !isLoading && (\n            <div className=\"py-8 text-center\">\n              <div className=\"mx-auto mb-3 flex size-12 items-center justify-center rounded-full bg-fill-secondary\">\n                <i className=\"i-mgc-plugin-2-cute-re size-6 text-text\" />\n              </div>\n              <h4 className=\"mb-1 text-sm font-medium text-text\">\n                {t(\"integration.mcp.services.empty.title\")}\n              </h4>\n              <p className=\"text-xs text-text-secondary\">\n                {t(\"integration.mcp.services.empty.description\")}\n              </p>\n            </div>\n          )}\n\n          <div className=\"!mt-2 space-y-4\">\n            {mcpServices.map((service) => (\n              <MCPServiceItem\n                key={service.id}\n                service={service}\n                onDelete={handleDeleteService}\n                onRefresh={handleRefreshTools}\n                onEdit={handleEditService}\n                onToggleEnabled={handleToggleEnabled}\n                isDeleting={\n                  deleteConnectionMutation.isPending &&\n                  deleteConnectionMutation.variables === service.id\n                }\n                isRefreshing={\n                  refreshToolsMutation.isPending &&\n                  refreshToolsMutation.variables?.[0] === service.id\n                }\n              />\n            ))}\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/mcp/types.ts",
    "content": "export interface MCPPreset {\n  id: string\n  name: string\n  displayName: string\n  icon: string // simple-icons class name\n  description: string\n  features: string[]\n\n  quickSetup: boolean\n  authRequired: boolean\n  configTemplate: {\n    name: string\n    transportType: \"streamable-http\" | \"sse\"\n    url: string\n  }\n}\n\nexport const MCP_PRESETS: MCPPreset[] = [\n  {\n    id: \"notion\",\n    name: \"notion\",\n    displayName: \"Notion\",\n    icon: \"i-simple-icons-notion\",\n    description: \"Connect your Notion workspace\",\n    features: [\"Read & search pages\", \"Create new content\", \"Update existing pages\"],\n\n    quickSetup: true,\n    authRequired: true,\n    configTemplate: {\n      name: \"Notion\",\n      transportType: \"streamable-http\",\n      url: \"https://mcp.notion.com/mcp\",\n    },\n  },\n  {\n    id: \"linear\",\n    name: \"linear\",\n    displayName: \"Linear\",\n    icon: \"i-simple-icons-linear\",\n    description: \"Connect your Linear workspace\",\n    features: [\"Read & search issues\", \"Create new issues\", \"Update existing issues\"],\n\n    quickSetup: true,\n    authRequired: true,\n    configTemplate: {\n      name: \"Linear\",\n      transportType: \"streamable-http\",\n      url: \"https://mcp.linear.app/mcp\",\n    },\n  },\n\n  {\n    id: \"github\",\n    name: \"github\",\n    displayName: \"GitHub\",\n    icon: \"i-simple-icons-github\",\n    description: \"Connect your GitHub repository\",\n    features: [\"Read & search issues\", \"Create new issues\", \"Update existing issues\"],\n\n    quickSetup: false,\n    authRequired: true,\n    configTemplate: {\n      name: \"GitHub\",\n      transportType: \"streamable-http\",\n      url: \"https://api.githubcopilot.com/mcp\",\n    },\n  },\n\n  {\n    id: \"fabric\",\n    name: \"fabric\",\n    displayName: \"Fabric\",\n    icon: tw`i-simple-icons-modelcontextprotocol`,\n    description: \"Connect your Fabric AI workspace\",\n    features: [\"Read & search workspaces\", \"Create new notes\", \"Update existing notes\"],\n\n    quickSetup: true,\n    authRequired: true,\n    configTemplate: {\n      name: \"Fabric AI\",\n      transportType: \"streamable-http\",\n      url: \"https://mcp.api.fabric.so/mcp\",\n    },\n  },\n]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/shortcuts/AIShortcutsSection.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { setAISetting, useAISettingValue } from \"~/atoms/settings/ai\"\n\nimport { useCreateAIShortcutModal, useEditAIShortcutModal } from \"./hooks\"\nimport { ShortcutItem } from \"./ShortcutItem\"\n\nexport const AIShortcutsSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const { shortcuts } = useAISettingValue()\n\n  const handleAddShortcut = useCreateAIShortcutModal()\n  const handleEditShortcut = useEditAIShortcutModal()\n\n  const handleDeleteShortcut = (id: string) => {\n    setAISetting(\n      \"shortcuts\",\n      shortcuts.filter((s) => s.id !== id),\n    )\n    toast.success(t(\"shortcuts.deleted\"))\n  }\n\n  const handleToggleShortcut = (id: string, enabled: boolean) => {\n    setAISetting(\n      \"shortcuts\",\n      shortcuts.map((s) => (s.id === id ? { ...s, enabled } : s)),\n    )\n  }\n\n  return (\n    <div className=\"relative -mt-4 space-y-4\">\n      <div className=\"flex items-center justify-between\">\n        <p className=\"text-xs text-text-secondary\">{t(\"shortcuts.empty.description\")}</p>\n        <Button variant=\"outline\" size=\"sm\" onClick={handleAddShortcut}>\n          {t(\"shortcuts.add\")}\n        </Button>\n      </div>\n      {shortcuts.length === 0 && (\n        <div className=\"py-8 text-center\">\n          <div className=\"mx-auto mb-3 flex size-12 items-center justify-center rounded-full bg-fill-secondary\">\n            <i className=\"i-mgc-magic-2-cute-re size-6 text-text\" />\n          </div>\n          <h4 className=\"mb-1 text-sm font-medium text-text\">{t(\"shortcuts.empty.title\")}</h4>\n        </div>\n      )}\n\n      {shortcuts.map((shortcut) => (\n        <ShortcutItem\n          key={shortcut.id}\n          shortcut={shortcut}\n          onDelete={handleDeleteShortcut}\n          onToggle={handleToggleShortcut}\n          onEdit={handleEditShortcut}\n        />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/shortcuts/ShortcutItem.tsx",
    "content": "import { KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from \"@follow/shared/settings/defaults\"\nimport type { AIShortcut } from \"@follow/shared/settings/interface\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { ActionButton } from \"~/modules/ai-task/components/ai-item-actions\"\nimport { ItemActions } from \"~/modules/ai-task/components/ai-item-actions\"\n\ninterface ShortcutItemProps {\n  shortcut: AIShortcut\n  onDelete: (id: string) => void\n  onToggle: (id: string, enabled: boolean) => void\n  onEdit: (shortcut: AIShortcut) => void\n}\n\nexport const ShortcutItem = ({ shortcut, onDelete, onToggle, onEdit }: ShortcutItemProps) => {\n  const { t } = useTranslation(\"ai\")\n  const isProtected =\n    shortcut.defaultPrompt || shortcut.id === DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID\n  const actions: ActionButton[] = [\n    {\n      icon: \"i-mgc-edit-cute-re\",\n      onClick: () => onEdit(shortcut),\n      title: \"Edit shortcut\",\n    },\n  ]\n\n  if (!isProtected) {\n    actions.push({\n      icon: \"i-mgc-delete-2-cute-re\",\n      onClick: () => onDelete(shortcut.id),\n      title: \"Delete shortcut\",\n    })\n  }\n\n  return (\n    <div className=\"group -ml-3 rounded-lg border border-border p-3 transition-colors hover:bg-material-medium\">\n      <div className=\"flex items-start justify-between\">\n        <div className=\"flex-1 space-y-2\">\n          <div className=\"flex items-center gap-2\">\n            <i className={shortcut.icon || \"i-mgc-hotkey-cute-re\"} />\n            <h4 className=\"text-sm font-medium text-text\">{shortcut.name}</h4>\n            {shortcut.hotkey && (\n              <KbdCombined kbdProps={{ wrapButton: false }} joint={false}>\n                {shortcut.hotkey}\n              </KbdCombined>\n            )}\n          </div>\n          <div className=\"flex flex-wrap gap-1.5\">\n            {shortcut.displayTargets?.map((target) => (\n              <span\n                key={target}\n                className=\"inline-flex items-center rounded-full bg-material-thin px-2 py-0.5 text-[11px] font-medium tracking-wide text-text-tertiary\"\n              >\n                {t(`shortcuts.targets.${target}`)}\n              </span>\n            ))}\n          </div>\n        </div>\n\n        <ItemActions\n          actions={actions}\n          enabled={shortcut.enabled}\n          onToggle={(enabled) => onToggle(shortcut.id, enabled)}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/shortcuts/ShortcutModalContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.jsx\"\nimport { Input, TextArea } from \"@follow/components/ui/input/index.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport {\n  Popover,\n  PopoverClose,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@follow/components/ui/popover/index.js\"\nimport { Switch } from \"@follow/components/ui/switch/index.jsx\"\nimport type { AIShortcut, AIShortcutTarget } from \"@follow/shared/settings/interface\"\nimport { DEFAULT_SHORTCUT_TARGETS } from \"@follow/shared/settings/interface\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { isServerShortcut } from \"~/atoms/settings/ai\"\n\ninterface ShortcutModalContentProps {\n  shortcut?: AIShortcut | null\n  onSave: (shortcut: Omit<AIShortcut, \"id\">) => void\n  onCancel: () => void\n}\n\nexport const ShortcutModalContent = ({ shortcut, onSave, onCancel }: ShortcutModalContentProps) => {\n  const { t } = useTranslation(\"ai\")\n  const [name, setName] = useState(shortcut?.name || \"\")\n  const [prompt, setPrompt] = useState(shortcut?.prompt || \"\")\n  const [enabled, setEnabled] = useState(shortcut?.enabled ?? true)\n  const [icon, setIcon] = useState<string>(shortcut?.icon || \"i-mgc-hotkey-cute-re\")\n  const initialTargets = useMemo<AIShortcutTarget[]>(() => {\n    if (shortcut?.displayTargets && shortcut.displayTargets.length > 0) {\n      return [...shortcut.displayTargets]\n    }\n    return [...DEFAULT_SHORTCUT_TARGETS]\n  }, [shortcut?.displayTargets])\n  const [displayTargets, setDisplayTargets] = useState<AIShortcutTarget[]>(initialTargets)\n  const PRESET_ICONS = useMemo(\n    () => [\n      \"i-mgc-hotkey-cute-re\",\n      \"i-mgc-magic-2-cute-re\",\n      \"i-mgc-thought-cute-fi\",\n      \"i-mgc-rocket-cute-re\",\n      \"i-mgc-quill-pen-cute-re\",\n      \"i-mgc-search-3-cute-re\",\n      \"i-mgc-brain-cute-re\",\n      \"i-mgc-list-check-3-cute-re\",\n      \"i-mgc-translate-2-cute-re\",\n      \"i-mgc-send-plane-cute-re\",\n      \"i-mgc-hammer-cute-re\",\n      \"i-mgc-settings-1-cute-re\",\n      \"i-mgc-test-tube-cute-re\",\n      \"i-mgc-star-cute-re\",\n      \"i-mgc-bookmark-cute-re\",\n      \"i-mgc-book-6-cute-re\",\n      \"i-mgc-plugin-2-cute-re\",\n      \"i-mgc-grid-2-cute-re\",\n      \"i-mgc-palette-cute-re\",\n      \"i-mgc-fire-cute-re\",\n      \"i-mgc-gift-cute-re\",\n      \"i-mgc-trophy-cute-re\",\n      \"i-mgc-tool-cute-re\",\n      \"i-mgc-link-cute-re\",\n      \"i-mgc-attachment-cute-re\",\n      \"i-mgc-external-link-cute-re\",\n      \"i-mgc-copy-2-cute-re\",\n    ],\n    [],\n  )\n  const isServer = shortcut && isServerShortcut(shortcut)\n\n  useEffect(() => {\n    setDisplayTargets(initialTargets)\n  }, [initialTargets])\n\n  const handleTargetChange = (target: AIShortcutTarget, checked: boolean) => {\n    setDisplayTargets((prev) => {\n      if (checked) {\n        if (prev.includes(target)) {\n          return prev\n        }\n        return [...prev, target]\n      }\n      return prev.filter((item) => item !== target)\n    })\n  }\n\n  const handleSave = () => {\n    const trimmedName = name.trim()\n    const trimmedPrompt = prompt.trim()\n    const effectivePrompt = trimmedPrompt || shortcut?.defaultPrompt\n\n    if (!trimmedName) {\n      toast.error(t(\"shortcuts.validation.name_required\"))\n      return\n    }\n\n    if (!effectivePrompt) {\n      toast.error(t(\"shortcuts.validation.prompt_required\"))\n      return\n    }\n    if (displayTargets.length === 0) {\n      toast.error(t(\"shortcuts.validation.targets_required\"))\n      return\n    }\n\n    onSave({\n      name: trimmedName,\n      prompt: trimmedPrompt,\n      defaultPrompt: shortcut?.defaultPrompt,\n      enabled,\n      icon,\n      displayTargets,\n    })\n  }\n\n  return (\n    <div className=\"w-[400px] space-y-4\">\n      <div className=\"grid grid-cols-6 gap-4\">\n        <div className=\"col-span-6 space-y-2\">\n          <Label className=\"text-xs text-text\">{t(\"shortcuts.name\")}</Label>\n          <Input\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            placeholder={t(\"shortcuts.name_placeholder\")}\n          />\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between space-y-2\">\n        <Label className=\"text-xs text-text\">{t(\"shortcuts.icon\")}</Label>\n\n        <Popover>\n          <PopoverTrigger asChild>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              buttonClassName=\"size-8 flex items-center justify-center\"\n              title={t(\"shortcuts.icon\")}\n            >\n              <i className={icon || \"i-mgc-hotkey-cute-re\"} />\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent align=\"end\" className=\"shadow-context-menu w-[280px] p-2\">\n            <div className=\"grid grid-cols-7 gap-1.5\">\n              {PRESET_ICONS.map((klass) => (\n                <PopoverClose asChild key={klass}>\n                  <button\n                    type=\"button\"\n                    className=\"inline-flex size-8 items-center justify-center rounded-md border border-border bg-material-thin text-text hover:bg-fill hover:text-text-vibrant\"\n                    onClick={() => setIcon(klass)}\n                    title={klass}\n                  >\n                    <i className={klass} />\n                  </button>\n                </PopoverClose>\n              ))}\n            </div>\n          </PopoverContent>\n        </Popover>\n      </div>\n\n      {isServer ? (\n        <div className=\"space-y-2\">\n          <Label className=\"text-xs text-text\">{t(\"shortcuts.default_prompt.label\")}</Label>\n          <div className=\"select-text whitespace-pre-wrap rounded-md border border-border bg-material-thin px-3 py-2 text-xs leading-relaxed text-text\">\n            {shortcut?.defaultPrompt}\n          </div>\n        </div>\n      ) : null}\n\n      <div className=\"space-y-1.5\">\n        <Label className=\"text-xs text-text\">\n          {t(isServer ? \"shortcuts.custom_prompt.title\" : \"shortcuts.prompt\")}\n        </Label>\n        <TextArea\n          value={prompt}\n          onChange={(e) => setPrompt(e.target.value)}\n          placeholder={t(\n            isServer ? \"shortcuts.custom_prompt_placeholder\" : \"shortcuts.prompt_placeholder\",\n          )}\n          className=\"min-h-[120px] resize-none py-2 text-sm\"\n        />\n        {isServer && (\n          <p className=\"text-xs text-text-tertiary\">{t(\"shortcuts.custom_prompt.help\")}</p>\n        )}\n      </div>\n\n      <div className=\"space-y-2\">\n        <Label className=\"text-xs text-text\">{t(\"shortcuts.targets.label\")}</Label>\n        <div className=\"flex flex-wrap gap-3 text-xs text-text-secondary\">\n          <label className=\"flex items-center gap-2\">\n            <Checkbox\n              size=\"sm\"\n              checked={displayTargets.includes(\"list\")}\n              onCheckedChange={(value) => handleTargetChange(\"list\", Boolean(value))}\n            />\n            <span>{t(\"shortcuts.targets.list\")}</span>\n          </label>\n          <label className=\"flex items-center gap-2\">\n            <Checkbox\n              size=\"sm\"\n              checked={displayTargets.includes(\"entry\")}\n              onCheckedChange={(value) => handleTargetChange(\"entry\", Boolean(value))}\n            />\n            <span>{t(\"shortcuts.targets.entry\")}</span>\n          </label>\n        </div>\n        <p className=\"text-xs text-text-tertiary\">{t(\"shortcuts.targets.help\")}</p>\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <Switch size=\"sm\" checked={enabled} onCheckedChange={setEnabled} />\n          <Label className=\"text-xs text-text\">{t(\"shortcuts.enabled\")}</Label>\n        </div>\n\n        <div className=\"flex gap-2\">\n          <Button variant=\"outline\" size=\"sm\" onClick={onCancel}>\n            Cancel\n          </Button>\n          <Button size=\"sm\" onClick={handleSave}>\n            Save\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/shortcuts/hooks.tsx",
    "content": "import type { AIShortcut } from \"@follow/shared/settings/interface\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { getAISettings, setAISetting } from \"~/atoms/settings/ai\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { ShortcutModalContent } from \"./ShortcutModalContent\"\n\nexport const useCreateAIShortcutModal = () => {\n  const { present } = useModalStack()\n  const { t } = useTranslation(\"ai\")\n  return useCallback(() => {\n    present({\n      title: \"Add AI Shortcut\",\n      content: ({ dismiss }: { dismiss: () => void }) => (\n        <ShortcutModalContent\n          shortcut={null}\n          onSave={(shortcut) => {\n            const newShortcut: AIShortcut = {\n              ...shortcut,\n              id: Date.now().toString(),\n            }\n            const { shortcuts } = getAISettings()\n            setAISetting(\"shortcuts\", [...shortcuts, newShortcut])\n            toast.success(t(\"shortcuts.added\"))\n            dismiss()\n          }}\n          onCancel={dismiss}\n        />\n      ),\n    })\n  }, [present, t])\n}\n\nexport const useEditAIShortcutModal = () => {\n  const { present } = useModalStack()\n  const { t } = useTranslation(\"ai\")\n  return useCallback(\n    (shortcut: AIShortcut) => {\n      present({\n        title: \"Edit AI Shortcut\",\n        content: ({ dismiss }: { dismiss: () => void }) => (\n          <ShortcutModalContent\n            shortcut={shortcut}\n            onSave={(updatedShortcut) => {\n              const { shortcuts } = getAISettings()\n              setAISetting(\n                \"shortcuts\",\n                shortcuts.map((s) =>\n                  s.id === shortcut.id ? { ...updatedShortcut, id: shortcut.id } : s,\n                ),\n              )\n              toast.success(t(\"shortcuts.updated\"))\n              dismiss()\n            }}\n            onCancel={dismiss}\n          />\n        ),\n      })\n    },\n    [present, t],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/shortcuts/index.ts",
    "content": "export { AIShortcutsSection } from \"./AIShortcutsSection\"\nexport { ShortcutItem } from \"./ShortcutItem\"\nexport { ShortcutModalContent } from \"./ShortcutModalContent\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/tasks/TaskSchedulingSection.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { AITaskList, AITaskModal, useCanCreateNewAITask } from \"~/modules/ai-task\"\n\nexport const TaskSchedulingSection = () => {\n  const { present } = useModalStack()\n  const canCreateNewTask = useCanCreateNewAITask()\n  const { t } = useTranslation(\"ai\")\n\n  const handleCreateTask = useCallback(() => {\n    present({\n      title: t(\"tasks.modal.new_title\"),\n      content: () => <AITaskModal />,\n    })\n  }, [present, t])\n\n  return (\n    <div className=\"-mt-4 space-y-4\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"space-y-1\">\n          <div className=\"text-xs text-text-secondary\">\n            {t(\"tasks.manage.desc\")}\n            {!canCreateNewTask && (\n              <span className=\"text-red\"> {t(\"tasks.manage.limit_reached\")}</span>\n            )}\n          </div>\n        </div>\n\n        <Button\n          disabled={!canCreateNewTask}\n          size={\"sm\"}\n          variant={\"outline\"}\n          onClick={handleCreateTask}\n          buttonClassName=\"!-translate-y-7\"\n        >\n          <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n          {t(\"tasks.actions.new_task\")}\n        </Button>\n      </div>\n\n      <AITaskList />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/tasks/index.ts",
    "content": "export { TaskSchedulingSection } from \"./TaskSchedulingSection\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/UsageAnalysisSection.tsx",
    "content": "import { Card, CardContent } from \"@follow/components/ui/card/index.jsx\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAIConfiguration } from \"~/modules/ai-chat/hooks/useAIConfiguration\"\n\nimport { DetailedUsageModal, UsageProgressRing, UsageWarningBanner } from \"./components\"\nimport { formatTokenCountString } from \"./utils\"\n\nexport const UsageAnalysisSection = () => {\n  const { t } = useTranslation(\"ai\")\n  const { data: config, isLoading } = useAIConfiguration()\n\n  const { present } = useModalStack()\n  if (isLoading) {\n    return <div className=\"h-36 animate-pulse rounded-lg bg-fill-secondary\" />\n  }\n  if (!config) return null\n\n  const { usage, rateLimit } = config\n  const usagePercentage = usage.total === 0 ? 0 : (usage.used / usage.total) * 100\n\n  return (\n    <div className=\"-ml-3 space-y-4\">\n      {rateLimit?.warningLevel && rateLimit.warningLevel !== \"safe\" ? (\n        <UsageWarningBanner\n          level={rateLimit.warningLevel}\n          projectedLimitTime={rateLimit.projectedLimitTime ?? null}\n          usageRate={rateLimit.usageRate}\n        />\n      ) : null}\n\n      <Card>\n        <CardContent className=\"relative p-4\">\n          <div className=\"flex items-center gap-4\">\n            <UsageProgressRing percentage={usagePercentage} size=\"md\" />\n\n            <div className=\"flex-1 space-y-2\">\n              <div className=\"flex items-baseline gap-2\">\n                <span className=\"text-lg font-semibold text-text\">\n                  {formatTokenCountString(usage.total - usage.used)}\n                </span>\n                <span className=\"text-sm text-text-secondary\">\n                  {t(\"usage_analysis.tokens_remaining\")}\n                </span>\n              </div>\n\n              <div className=\"text-xs text-text-tertiary\">\n                {formatTokenCountString(usage.used)} / {formatTokenCountString(usage.total)} used\n              </div>\n            </div>\n          </div>\n          <button\n            type=\"button\"\n            onClick={() =>\n              present({\n                id: \"detailed-usage-modal\",\n                content: DetailedUsageModal,\n                title: t(\"usage_analysis.detailed_title\"),\n                modalContentClassName: \"-mx-6 -mb-4\",\n              })\n            }\n            className=\"absolute right-4 top-4 flex items-center gap-1 text-sm text-text-secondary duration-200 hover:text-text\"\n          >\n            {t(\"usage_analysis.view_details\")}\n            <i className=\"i-mingcute-right-line\" />\n          </button>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/DetailedUsageModal.tsx",
    "content": "import {\n  Tabs,\n  TabsList,\n  TabsScrollAreaContent,\n  TabsTrigger,\n} from \"@follow/components/ui/tabs/index.js\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { followApi } from \"~/lib/api-client\"\nimport { useAIConfiguration } from \"~/modules/ai-chat/hooks/useAIConfiguration\"\n\nimport { formatTokenCount } from \"../utils\"\nimport { EfficiencyTab } from \"./EfficiencyTab\"\nimport { HistoryTab } from \"./HistoryTab\"\nimport { OverviewTab } from \"./OverviewTab\"\nimport { PatternsTab } from \"./PatternsTab\"\nimport { UsageProgressRing } from \"./UsageProgressRing\"\nimport { UsageWarningBanner } from \"./UsageWarningBanner\"\n\nconst useAIAnalysisData = () => {\n  return useQuery({\n    queryKey: [\"ai-token-usage\", \"usage-history\"],\n    queryFn: () => {\n      return followApi.aiAnalytics.get()\n    },\n  })\n}\n\nexport const DetailedUsageModal = () => {\n  const { t } = useTranslation(\"ai\")\n  const { data: config, isLoading: loadingConfig } = useAIConfiguration()\n\n  const { data: analysis, isLoading: _loadingUsageHistory } = useAIAnalysisData()\n  if (loadingConfig) {\n    return (\n      <div className=\"flex h-96 items-center justify-center\">\n        <div className=\"i-mgc-loading-3-cute-re size-6 animate-spin\" />\n      </div>\n    )\n  }\n\n  if (!config) {\n    return (\n      <div className=\"flex h-96 items-center justify-center\">\n        <div className=\"text-sm text-text-secondary\">{t(\"usage_analysis.no_data\")}</div>\n      </div>\n    )\n  }\n\n  const { usage, rateLimit } = config\n  const usagePercentage = usage.total === 0 ? 0 : (usage.used / usage.total) * 100\n\n  // Build derived datasets for inline charts\n  const daily = analysis?.patterns?.daily ?? []\n  const byOperation = analysis?.patterns?.byOperation ?? []\n  const byModel = analysis?.patterns?.byModel ?? []\n\n  const dailyTotals = daily.map((d: any) => Number(d.totalTokens) || 0)\n\n  const peakDay = daily.reduce(\n    (acc: any, cur: any) => (cur.totalTokens > (acc?.totalTokens ?? -1) ? cur : acc),\n    null,\n  )\n\n  // Peak-hour distribution from provided peakHour field on each day\n  const hourBuckets = Array.from({ length: 24 }, () => 0)\n  daily.forEach((d: any) => {\n    if (d?.peakHour != null) {\n      const h = Number(d.peakHour)\n      if (Number.isFinite(h) && h >= 0 && h < 24) {\n        hourBuckets[h]! += 1\n      }\n    }\n  })\n  const maxHourCount = Math.max(1, ...hourBuckets)\n\n  const formattedUsageTokens = formatTokenCount(usage.used)\n  const formattedTotalTokens = formatTokenCount(usage.total)\n\n  return (\n    <div className=\"flex max-h-[80vh] min-h-[640px] w-[500px] max-w-full flex-col space-y-6 overflow-hidden\">\n      <div className=\"space-y-6 px-4\">\n        <p className=\"text-sm text-text-secondary\">{t(\"usage_analysis.detailed_description\")}</p>\n\n        {rateLimit?.warningLevel && rateLimit.warningLevel !== \"safe\" && (\n          <UsageWarningBanner\n            level={rateLimit.warningLevel}\n            projectedLimitTime={rateLimit.projectedLimitTime ?? null}\n            usageRate={rateLimit.usageRate}\n            detailed={true}\n          />\n        )}\n\n        {/* Unified Usage Overview Card */}\n        <div className=\"overflow-hidden rounded-xl border border-border bg-fill-secondary/30 backdrop-blur-sm\">\n          <div className=\"flex items-center gap-4 p-4\">\n            {/* Progress Ring Section */}\n            <div className=\"shrink-0\">\n              <UsageProgressRing percentage={usagePercentage} size={80} />\n            </div>\n\n            {/* Metrics Section */}\n            <div className=\"ml-6 flex flex-1 gap-6\">\n              <Metric\n                label={t(\"usage_analysis.tokens_used\")}\n                value={formattedUsageTokens.value}\n                unit={formattedUsageTokens.unit}\n              />\n              <div className=\"h-px bg-border/50\" />\n              <Metric\n                label={t(\"usage_analysis.total_credits\")}\n                value={formattedTotalTokens.value}\n                unit={formattedTotalTokens.unit}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n      <Tabs defaultValue=\"overview\" className=\"relative flex min-h-0 grow flex-col space-y-4\">\n        <TabsList className=\"grid w-full grid-cols-4 px-4\">\n          <TabsTrigger value=\"overview\">{t(\"analytics.tabs.overview\")}</TabsTrigger>\n          <TabsTrigger value=\"patterns\">{t(\"analytics.tabs.patterns\")}</TabsTrigger>\n          <TabsTrigger value=\"efficiency\">{t(\"analytics.tabs.efficiency\")}</TabsTrigger>\n          <TabsTrigger value=\"history\">{t(\"analytics.tabs.history\")}</TabsTrigger>\n        </TabsList>\n\n        <TabsScrollAreaContent className=\"h-0 grow\" viewportClassName=\"pb-4\" value=\"overview\">\n          <OverviewTab dailyTotals={dailyTotals} peakDay={peakDay} />\n        </TabsScrollAreaContent>\n\n        <TabsScrollAreaContent className=\"h-0 grow\" viewportClassName=\"pb-4\" value=\"patterns\">\n          <PatternsTab\n            hourBuckets={hourBuckets}\n            maxHourCount={maxHourCount}\n            byOperation={byOperation}\n          />\n        </TabsScrollAreaContent>\n\n        <TabsScrollAreaContent className=\"h-0 grow\" viewportClassName=\"pb-4\" value=\"efficiency\">\n          <EfficiencyTab byModel={byModel} />\n        </TabsScrollAreaContent>\n\n        <TabsScrollAreaContent className=\"h-0 grow\" viewportClassName=\"pb-4\" value=\"history\">\n          <HistoryTab analysis={analysis!} />\n        </TabsScrollAreaContent>\n      </Tabs>\n    </div>\n  )\n}\n\n// ------- UI bits: small utility components -------\n\nfunction Metric({ label, value, unit }: { label: string; value: string; unit?: string }) {\n  return (\n    <div>\n      <div className=\"text-[11px] uppercase tracking-wide text-text-secondary\">{label}</div>\n      <div className=\"text-lg font-semibold text-text\">\n        {value}\n        {unit ? <span className=\"ml-1 text-sm text-text-tertiary\">{unit}</span> : null}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/EfficiencyTab.tsx",
    "content": "import { Card, CardContent, CardHeader, CardTitle } from \"@follow/components/ui/card/index.jsx\"\nimport type { ModelPattern } from \"@follow-app/client-sdk\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { formatTokenCount, formatTokenCountString } from \"../utils\"\nimport { BarList } from \"./charts\"\n\ninterface EfficiencyTabProps {\n  byModel: ModelPattern[]\n}\n\nexport const EfficiencyTab = ({ byModel }: EfficiencyTabProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  return (\n    <Card className=\"mx-4\">\n      <CardHeader>\n        <CardTitle className=\"text-base text-text\">\n          {t(\"analytics.efficiency_analysis\", { defaultValue: \"Efficiency analysis\" })}\n        </CardTitle>\n      </CardHeader>\n      <CardContent>\n        {byModel?.length > 0 ? (\n          <BarList\n            data={byModel.map((m) => {\n              const formatted = formatTokenCount(m.totalTokens ?? 0)\n              return {\n                label: m.model ?? \"unknown\",\n                value: m.avgEfficiency || 0,\n                right: `${formatted.value}${formatted.unit}`,\n              }\n            })}\n            format={(v) => formatTokenCountString(v)}\n          />\n        ) : (\n          <div className=\"py-8 text-center text-sm text-text-tertiary\">\n            {t(\"analytics.no_data\")}\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/HistoryTab.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { AnalyticsData } from \"@follow-app/client-sdk\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { RelativeTime } from \"~/components/ui/datetime\"\n\ninterface HistoryTabProps {\n  analysis: AnalyticsData | null\n}\n\nexport const HistoryTab = ({ analysis }: HistoryTabProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  if (!analysis || analysis.usageHistory.length === 0) {\n    return (\n      <div className=\"text-center text-text-secondary\">\n        <p className=\"text-sm\">{t(\"analytics.no_history\")}</p>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"space-y-3 px-4\">\n      <div className=\"sticky top-0 z-10 rounded-lg bg-material-opaque px-4 py-3\">\n        <div className=\"grid grid-cols-[2fr_1fr_1fr] gap-4 text-xs font-medium text-text-secondary\">\n          <div className=\"flex items-center gap-2\">\n            {t(\"analytics.event\", { defaultValue: \"Event\" })}\n          </div>\n          <div className=\"ml-5 flex items-center justify-start gap-2\">\n            {t(\"analytics.tokens\", { defaultValue: \"Tokens\" })}\n          </div>\n          <div className=\"flex items-center justify-end gap-2\">\n            {t(\"analytics.time\", { defaultValue: \"Time\" })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        {analysis.usageHistory.slice(0, 20).map((item, index) => (\n          <div className=\"grid grid-cols-[2fr_1fr_1fr] items-center gap-4\" key={index}>\n            <div className=\"min-w-0 space-y-1\">\n              <div className=\"flex items-center gap-2\">\n                <span className=\"truncate text-sm font-medium text-text\">\n                  {item.operationType\n                    ? t(\"analytics.history_operation\", {\n                        operation: t(`analytics.operation_types.${item.operationType}`, {\n                          defaultValue: item.operationType,\n                        }),\n                      })\n                    : t(\"analytics.history_usage\")}\n                </span>\n              </div>\n            </div>\n\n            <div className=\"flex justify-start\">\n              <span\n                className={cn(\n                  \"inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium tabular-nums\",\n                  item.changes > 0 ? \"bg-orange/10 text-orange\" : \"bg-green/10 text-green\",\n                )}\n              >\n                <span>{item.changes > 0 ? \"+\" : \"\"}</span>\n                {item.changes.toLocaleString()}\n              </span>\n            </div>\n\n            <div className=\"flex justify-end\">\n              <span className=\"text-xs text-text-tertiary\">\n                <RelativeTime date={item.createdAt} />\n              </span>\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/OverviewTab.tsx",
    "content": "import { Card, CardContent, CardHeader, CardTitle } from \"@follow/components/ui/card/index.jsx\"\nimport type { DailyPattern } from \"@follow-app/client-sdk\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { formatTokenCountString } from \"../utils\"\nimport { Sparkline } from \"./charts\"\n\ninterface OverviewTabProps {\n  dailyTotals: number[]\n  peakDay: DailyPattern | null\n}\n\nexport const OverviewTab = ({ dailyTotals, peakDay }: OverviewTabProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  return (\n    <div className=\"space-y-4 px-4\">\n      {dailyTotals.length > 0 ? (\n        <Card>\n          <CardHeader>\n            <CardTitle className=\"text-base text-text\">\n              {t(\"analytics.usage_trends\", { defaultValue: \"Usage trends\" })}\n            </CardTitle>\n          </CardHeader>\n          <CardContent>\n            <div className=\"h-44 w-full\">\n              <Sparkline data={dailyTotals} area color=\"#60a5fa\" />\n            </div>\n            <div className=\"mt-2 flex items-center justify-between text-xs text-text-tertiary\">\n              <span>\n                {t(\"analytics.points\", { defaultValue: \"Points\" })}: {dailyTotals.length}\n              </span>\n              {peakDay?.date ? (\n                <span>\n                  <span>{t(\"analytics.peak\", { defaultValue: \"Peak\" })}: </span>\n                  <span>{formatTokenCountString(peakDay.totalTokens)}</span>\n                  <span>{\" · \"}</span>\n                  <span>{new Date(peakDay.date).toLocaleDateString()} </span>\n                  <span>\n                    {peakDay.peakHour != null\n                      ? `@ ${String(peakDay.peakHour).padStart(2, \"0\")}:00`\n                      : \"\"}\n                  </span>\n                </span>\n              ) : null}\n            </div>\n          </CardContent>\n        </Card>\n      ) : (\n        <Card>\n          <CardContent className=\"flex h-32 items-center justify-center\">\n            <div className=\"text-center text-sm text-text-secondary\">{t(\"analytics.no_data\")}</div>\n          </CardContent>\n        </Card>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/PatternsTab.tsx",
    "content": "import { Card, CardContent, CardHeader, CardTitle } from \"@follow/components/ui/card/index.jsx\"\nimport type { UsagePattern } from \"@follow-app/client-sdk\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { formatTokenCountString } from \"../utils\"\nimport { BarList, TinyBars } from \"./charts\"\n\ninterface PatternsTabProps {\n  hourBuckets: number[]\n  maxHourCount: number\n  byOperation: UsagePattern[]\n}\n\nexport const PatternsTab = ({ hourBuckets, maxHourCount, byOperation }: PatternsTabProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  return (\n    <div className=\"mx-4 grid grid-cols-1 gap-4 @md:grid-cols-2\">\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"text-base text-text\">\n            {t(\"analytics.peak_hours\", { defaultValue: \"Peak hours\" })}\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          <div className=\"mb-3 h-24 w-full\">\n            <TinyBars\n              data={hourBuckets.map((v, h) => ({ label: String(h), value: v }))}\n              max={maxHourCount}\n              highlightThreshold={(v) => v >= Math.max(1, Math.round(maxHourCount * 0.6))}\n            />\n          </div>\n          <div className=\"grid grid-cols-6 gap-1 text-[10px] text-text-tertiary\">\n            <span>00</span>\n            <span>04</span>\n            <span>08</span>\n            <span>12</span>\n            <span>16</span>\n            <span>20</span>\n          </div>\n        </CardContent>\n      </Card>\n\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"text-base text-text\">\n            {t(\"analytics.by_operation\", { defaultValue: \"By operation\" })}\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          {byOperation?.length > 0 ? (\n            <BarList\n              data={byOperation.map((o) => ({\n                label: o.operationType ?? \"unknown\",\n                value: o.percentage || 0,\n\n                right: formatTokenCountString(o.totalTokens ?? 0),\n              }))}\n              suffix=\"%\"\n            />\n          ) : (\n            <div className=\"py-8 text-center text-sm text-text-tertiary\">\n              {t(\"analytics.no_data\")}\n            </div>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/UsageProgressRing.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\ninterface UsageProgressRingProps {\n  percentage: number\n  size?: \"sm\" | \"md\" | \"lg\" | number\n  className?: string\n}\n\nconst sizeMap = {\n  sm: 56,\n  md: 72,\n  lg: 96,\n} as const\n\nexport const UsageProgressRing = ({\n  percentage,\n  size = \"md\",\n  className,\n}: UsageProgressRingProps) => {\n  const normalized = Math.max(0, Math.min(100, Number.isFinite(percentage) ? percentage : 0))\n  const dimension = typeof size === \"number\" ? size : sizeMap[size]\n  const strokeWidth = size === \"sm\" ? 8 : size === \"md\" ? 12 : 14\n  const radius = (dimension - strokeWidth) / 2\n  const circumference = 2 * Math.PI * radius\n  const offset = circumference - (normalized / 100) * circumference\n\n  // Gradient colors by percentage\n  const gradientId = `gradient-${Math.random().toString(36).slice(2, 9)}`\n  const gradientColors =\n    normalized >= 90\n      ? { start: \"#ef4444\", end: \"#dc2626\" } // red\n      : normalized >= 70\n        ? { start: \"#f59e0b\", end: \"#d97706\" } // amber\n        : { start: \"#22c55e\", end: \"#16a34a\" } // green\n\n  return (\n    <div\n      className={cn(\"relative inline-block\", className)}\n      style={{ width: dimension, height: dimension }}\n    >\n      <svg width={dimension} height={dimension} className=\"block -scale-100\">\n        <defs>\n          <linearGradient id={gradientId} x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n            <stop offset=\"0%\" stopColor={gradientColors.start} />\n            <stop offset=\"100%\" stopColor={gradientColors.end} />\n          </linearGradient>\n        </defs>\n        <circle\n          className=\"stroke-fill-secondary\"\n          fill=\"transparent\"\n          strokeWidth={strokeWidth}\n          r={radius}\n          cx={dimension / 2}\n          cy={dimension / 2}\n        />\n        <circle\n          stroke={`url(#${gradientId})`}\n          fill=\"transparent\"\n          strokeWidth={strokeWidth}\n          strokeLinecap=\"round\"\n          strokeDasharray={circumference}\n          strokeDashoffset={offset}\n          r={radius}\n          cx={dimension / 2}\n          cy={dimension / 2}\n          style={{\n            transition: \"stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1)\",\n            opacity: 0.95,\n          }}\n        />\n      </svg>\n      <div className=\"absolute inset-0 grid place-items-center text-sm font-semibold text-text\">\n        {Math.round(normalized)}%\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/UsageWarningBanner.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { useTranslation } from \"react-i18next\"\n\ntype WarningLevel = \"safe\" | \"moderate\" | \"high\" | \"critical\" | (string & {})\n\nexport interface UsageWarningBannerProps {\n  level: WarningLevel\n  projectedLimitTime?: number | null\n  usageRate?: number\n  detailed?: boolean\n  className?: string\n}\n\nexport const UsageWarningBanner = ({\n  level,\n  projectedLimitTime,\n  usageRate,\n  detailed,\n  className,\n}: UsageWarningBannerProps) => {\n  const { t } = useTranslation(\"ai\")\n\n  if (!level || level === \"safe\") return null\n\n  const stylesByLevel: Record<string, string> = {\n    moderate: \"bg-amber-50 border-amber-200 text-amber-800 border\",\n    high: \"bg-orange-50 border-orange-200 text-orange-800 border\",\n    critical: \"bg-red-50 border-red-200 text-red-800\",\n  }\n\n  const iconByLevel: Record<string, string> = {\n    moderate: \"i-mingcute-alert-line text-amber-500\",\n    high: \"i-mingcute-alert-line text-orange-500\",\n    critical: \"i-mingcute-alert-line text-red-500\",\n  }\n\n  const resetEta = projectedLimitTime ? formatEta(projectedLimitTime) : null\n\n  return (\n    <div\n      className={cn(\n        \"flex items-start gap-3 rounded-md border p-3\",\n        stylesByLevel[level] || stylesByLevel.moderate,\n        className,\n      )}\n    >\n      <i className={cn(iconByLevel[level] || iconByLevel.moderate, \"size-5 shrink-0\")} />\n      <div className=\"text-sm\">\n        <div className=\"font-medium\">\n          {t(\"usage_analysis.warning.title\", \"High AI usage detected\")}\n        </div>\n        <div className=\"text-xs opacity-90\">\n          {resetEta\n            ? t(\"usage_analysis.warning.projected\", {\n                defaultValue: \"Projected limit in {{eta}}\",\n                eta: resetEta,\n              })\n            : t(\n                \"usage_analysis.warning.general\",\n                \"Consider reducing usage to avoid hitting limits\",\n              )}\n          {detailed && usageRate ? (\n            <>\n              {\" \"}\n              ·{\" \"}\n              {t(\"usage_analysis.warning.rate\", {\n                defaultValue: \"Rate: {{rate}} tok/min\",\n                rate: Math.round(usageRate),\n              })}\n            </>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction formatEta(ts: number) {\n  const diff = ts - Date.now()\n  if (diff <= 0) return \"now\"\n  const minutes = Math.round(diff / 60000)\n  if (minutes < 1) return \"<1m\"\n  if (minutes < 60) return `${minutes}m`\n  const hours = Math.floor(minutes / 60)\n  const rem = minutes % 60\n  return rem ? `${hours}h ${rem}m` : `${hours}h`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/charts/BarList.tsx",
    "content": "import type { BarListItem } from \"../../types\"\n\ninterface BarListProps {\n  data: BarListItem[]\n  suffix?: string\n  format?: (v: number) => string\n}\n\nexport const BarList = ({ data, suffix, format }: BarListProps) => {\n  const max = Math.max(1, ...data.map((d) => d.value))\n\n  return (\n    <div className=\"space-y-3\">\n      {data.map((d) => {\n        const pct = (d.value / max) * 100\n        const left = format ? format(d.value) : `${Math.round(d.value)}${suffix ?? \"\"}`\n        return (\n          <div key={d.label} className=\"space-y-1\">\n            <div className=\"flex items-center justify-between text-sm text-text\">\n              <span className=\"truncate\" title={d.label}>\n                {d.label}\n              </span>\n              <span className=\"text-xs text-text-tertiary\">{d.right ?? left}</span>\n            </div>\n            <div className=\"relative h-2.5 overflow-hidden rounded bg-fill-secondary\">\n              <div\n                className=\"absolute inset-y-0 left-0 rounded bg-accent/70\"\n                style={{ width: `${pct}%` }}\n              />\n            </div>\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/charts/Sparkline.tsx",
    "content": "interface SparklineProps {\n  data: number[]\n  color?: string\n  area?: boolean\n}\n\nexport const Sparkline = ({ data, color = \"#22c55e\", area }: SparklineProps) => {\n  const w = 800\n  const h = 160\n  const pad = 8\n  const max = Math.max(1, ...data)\n  const min = Math.min(0, ...data)\n  const range = Math.max(1, max - min)\n  const step = data.length > 1 ? (w - pad * 2) / (data.length - 1) : 0\n  const points = data.map((v, i) => {\n    const x = pad + i * step\n    const y = pad + (1 - (v - min) / range) * (h - pad * 2)\n    return `${x},${y}`\n  })\n  const path = `M ${points.join(\" L \")}`\n  const areaPath = `M ${points[0]} L ${points.slice(1).join(\" L \")} L ${w - pad},${h - pad} L ${pad},${h - pad} Z`\n\n  return (\n    <svg viewBox={`0 0 ${w} ${h}`} className=\"size-full\">\n      {area ? (\n        <>\n          <defs>\n            <linearGradient id=\"spark-grad\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0%\" stopColor={color} stopOpacity=\"0.25\" />\n              <stop offset=\"100%\" stopColor={color} stopOpacity=\"0\" />\n            </linearGradient>\n          </defs>\n          <path d={areaPath} fill=\"url(#spark-grad)\" />\n        </>\n      ) : null}\n      <path d={path} fill=\"none\" stroke={color} strokeWidth={2} strokeLinecap=\"round\" />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/charts/TinyBars.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nimport type { ChartDataPoint } from \"../../types\"\n\ninterface TinyBarsProps {\n  data: ChartDataPoint[]\n  max: number\n  highlightThreshold?: (value: number) => boolean\n}\n\nexport const TinyBars = ({ data, max, highlightThreshold }: TinyBarsProps) => {\n  return (\n    <div className=\"flex size-full items-end gap-[3px]\">\n      {data.map((d) => {\n        const h = (d.value / Math.max(1, max)) * 100\n        const highlight = highlightThreshold ? highlightThreshold(d.value) : false\n        return (\n          <div\n            key={d.label}\n            className={cn(\n              \"relative w-full rounded-sm bg-fill-secondary transition-colors hover:bg-accent/20\",\n              highlight && \"bg-accent/40\",\n            )}\n            style={{ height: `${Math.max(4, h)}%` }}\n            title={`${d.label}: ${d.value}`}\n          />\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/charts/index.ts",
    "content": "export { BarList } from \"./BarList\"\nexport { Sparkline } from \"./Sparkline\"\nexport { TinyBars } from \"./TinyBars\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/components/index.ts",
    "content": "export * from \"./DetailedUsageModal\"\nexport * from \"./UsageProgressRing\"\nexport * from \"./UsageWarningBanner\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/index.ts",
    "content": "export * from \"./components\"\nexport * from \"./UsageAnalysisSection\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/types.ts",
    "content": "// Chart Data Types\nexport interface ChartDataPoint {\n  label: string\n  value: number\n}\n\nexport interface BarListItem {\n  label: string\n  value: number\n  right?: string\n}\n\n// Component Props Types\nexport interface TokenCount {\n  value: string\n  unit: string\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai/usage/utils.ts",
    "content": "import type { TokenCount } from \"./types\"\n\nexport const formatTokenCount = (count: number): TokenCount => {\n  if (!Number.isFinite(count)) return { value: \"0\", unit: \"\" }\n  if (count >= 1_000_000) return { value: (count / 1_000_000).toFixed(1), unit: \"M\" }\n  if (count >= 1_000) return { value: (count / 1_000).toFixed(1), unit: \"K\" }\n  return { value: String(Math.round(count)), unit: \"\" }\n}\n\nexport const formatTimeRemaining = (ms: number): string => {\n  if (!Number.isFinite(ms) || ms <= 0) return \"0m\"\n  const minutes = Math.round(ms / 60000)\n  if (minutes < 60) return `${minutes}m`\n  const hours = Math.floor(minutes / 60)\n  const rem = minutes % 60\n  return rem ? `${hours}h ${rem}m` : `${hours}h`\n}\n\nexport const formatTokenCountString = (count: number): string => {\n  const formatted = formatTokenCount(count)\n  return `${formatted.value}${formatted.unit}`\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/ai.tsx",
    "content": "import { Label } from \"@follow/components/ui/label/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setAISetting, useAISettingValue } from \"~/atoms/settings/ai\"\n\nimport { createDefineSettingItem } from \"../helper/builder\"\nimport { createSettingBuilder } from \"../helper/setting-builder\"\nimport { ByokSection } from \"./ai/byok\"\nimport { MCPServicesSection } from \"./ai/mcp/MCPServicesSection\"\nimport { PanelStyleSection } from \"./ai/PanelStyleSection\"\nimport { PersonalizePromptSection } from \"./ai/PersonalizePromptSection\"\nimport { AIShortcutsSection } from \"./ai/shortcuts/AIShortcutsSection\"\nimport { TaskSchedulingSection } from \"./ai/tasks\"\nimport { UsageAnalysisSection } from \"./ai/usage\"\n\nconst SettingBuilder = createSettingBuilder(useAISettingValue)\nconst defineSettingItem = createDefineSettingItem(\"ai\", useAISettingValue, setAISetting)\n\nexport const AI_SETTING_SECTION_IDS = {\n  shortcuts: \"settings-ai-shortcuts\",\n  tasks: \"settings-ai-tasks\",\n  timelinePrompt: \"settings-ai-timeline-prompt\",\n} as const\n\nexport const SettingAI = () => {\n  const { t } = useTranslation(\"ai\")\n\n  return (\n    <div className=\"mt-4\">\n      <SettingBuilder\n        settings={[\n          {\n            type: \"title\",\n            value: t(\"features.title\"),\n          },\n\n          PanelStyleSection,\n          defineSettingItem(\"showSplineButton\", {\n            label: t(\"settings.showSplineButton.label\"),\n            description: t(\"settings.showSplineButton.description\"),\n          }),\n          defineSettingItem(\"autoScrollWhenStreaming\", {\n            label: t(\"settings.autoScrollWhenStreaming.label\"),\n            description: t(\"settings.autoScrollWhenStreaming.description\"),\n          }),\n\n          {\n            type: \"title\",\n            value: t(\"personalize.title\"),\n          },\n\n          PersonalizePromptSection,\n\n          {\n            type: \"title\",\n            value: t(\"shortcuts.title\"),\n            id: AI_SETTING_SECTION_IDS.shortcuts,\n          },\n          AIShortcutsSection,\n\n          {\n            type: \"title\",\n            value: t(\"tasks.section.title\"),\n            id: AI_SETTING_SECTION_IDS.tasks,\n          },\n          TaskSchedulingSection,\n\n          {\n            type: \"title\",\n            value: t(\"integration.title\"),\n          },\n          MCPServicesSection,\n\n          {\n            type: \"title\",\n            value: t(\"byok.title\"),\n          },\n          ByokSection,\n\n          {\n            type: \"title\",\n            value: t(\"usage_analysis.title\"),\n          },\n          UsageAnalysisSection,\n          AISecurityDisclosureSection,\n        ]}\n      />\n    </div>\n  )\n}\n\nconst AISecurityDisclosureSection = () => {\n  const { t } = useTranslation(\"ai\")\n\n  return (\n    <div className=\"mt-6 border-t border-fill-secondary pt-4\">\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center gap-2\">\n          <i className=\"i-mgc-safety-certificate-cute-re size-4 text-green\" />\n          <Label className=\"text-sm font-medium text-text\">{t(\"integration.security.title\")}</Label>\n        </div>\n        <p className=\"text-xs leading-relaxed text-text-secondary\">\n          {t(\"integration.security.description\")}\n        </p>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/appearance.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.js\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@follow/components/ui/popover/index.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { useIsDark, useThemeAtomValue } from \"@follow/hooks\"\nimport { ELECTRON_BUILD, IN_ELECTRON } from \"@follow/shared/constants\"\nimport { getAccentColorValue } from \"@follow/shared/settings/constants\"\nimport type { AccentColor } from \"@follow/shared/settings/interface\"\nimport { capitalizeFirstLetter, getOS } from \"@follow/utils/utils\"\nimport dayjs from \"dayjs\"\nimport { throttle } from \"es-toolkit/compat\"\nimport { useForceUpdate } from \"motion/react\"\nimport {\n  lazy,\n  startTransition,\n  Suspense,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { bundledThemesInfo } from \"shiki/themes\"\n\nimport {\n  getUISettings,\n  setUISetting,\n  useUISettingKey,\n  useUISettingSelector,\n  useUISettingValue,\n} from \"~/atoms/settings/ui\"\nimport { useCurrentModal, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useSetTheme } from \"~/hooks/common\"\nimport { useShowCustomizeToolbarModal } from \"~/modules/customize-toolbar/modal\"\n\nimport { useShowTimelineTabsSettingsModal } from \"../../subscription-column/TimelineTabsSettingsModal\"\nimport { SETTING_MODAL_ID } from \"../constants\"\nimport { SettingActionItem, SettingDescription, SettingTabbedSegment } from \"../control\"\nimport { createDefineSettingItem } from \"../helper/builder\"\nimport { createSettingBuilder } from \"../helper/setting-builder\"\nimport {\n  useWrapEnhancedSettingItem,\n  WrapEnhancedSettingTab,\n} from \"../hooks/useWrapEnhancedSettingItem\"\nimport { SettingItemGroup } from \"../section\"\nimport { ContentFontSelector, UIFontSelector } from \"../sections/fonts\"\n\nconst SettingBuilder = createSettingBuilder(useUISettingValue)\nconst _defineItem = createDefineSettingItem(\"ui\", useUISettingValue, setUISetting)\n\nexport const SettingAppearance = () => {\n  const { t } = useTranslation(\"settings\")\n  const isMobile = useMobile()\n  const defineItem = useWrapEnhancedSettingItem(_defineItem, WrapEnhancedSettingTab.Appearance)\n  return (\n    <div className=\"mt-4\">\n      <SettingBuilder\n        settings={[\n          {\n            type: \"title\",\n            value: t(\"appearance.common.title\"),\n          },\n\n          // Top Level - Most Common\n          AppThemeSegment,\n          AccentColorSelector,\n          GlobalFontSize,\n          UIFontSelector,\n          ContentLineHeight,\n\n          {\n            type: \"title\",\n            value: t(\"appearance.subscription_list.title\"),\n          },\n\n          defineItem(\"sidebarShowUnreadCount\", {\n            label: t(\"appearance.unread_count.sidebar.title\"),\n            description: t(\"appearance.unread_count.sidebar.description\"),\n          }),\n          defineItem(\"hideExtraBadge\", {\n            label: t(\"appearance.hide_extra_badge.label\"),\n            description: t(\"appearance.hide_extra_badge.description\"),\n            hide: isMobile,\n          }),\n          {\n            type: \"title\",\n            value: t(\"appearance.reading_view.title\"),\n          },\n\n          {\n            type: \"title\",\n            value: t(\"appearance.interface_window.title\"),\n          },\n\n          defineItem(\"opaqueSidebar\", {\n            label: t(\"appearance.opaque_sidebars.label\"),\n            description: t(\"appearance.opaque_sidebars.description\"),\n            hide: !window.api?.canWindowBlur || isMobile,\n          }),\n\n          defineItem(\"modalOverlay\", {\n            label: t(\"appearance.modal_overlay.label\"),\n            description: t(\"appearance.modal_overlay.description\"),\n            hide: isMobile,\n          }),\n\n          defineItem(\"reduceMotion\", {\n            label: t(\"appearance.reduce_motion.label\"),\n            description: t(\"appearance.reduce_motion.description\"),\n          }),\n\n          defineItem(\"usePointerCursor\", {\n            label: t(\"appearance.use_pointer_cursor.label\"),\n            description: t(\"appearance.use_pointer_cursor.description\"),\n            hide: isMobile,\n          }),\n\n          {\n            type: \"title\",\n            value: t(\"appearance.system_integration.title\"),\n          },\n\n          defineItem(\"showDockBadge\", {\n            label: t(\"appearance.unread_count.badge.label\"),\n            description: t(\"appearance.unread_count.badge.description\"),\n            hide: !IN_ELECTRON || ![\"macOS\", \"Linux\"].includes(getOS()) || isMobile,\n          }),\n\n          {\n            type: \"title\",\n            value: t(\"appearance.typography.title\"),\n          },\n\n          ContentFontSelector,\n\n          {\n            type: \"title\",\n            value: t(\"appearance.content_display.title\"),\n          },\n\n          ThumbnailRatio,\n          DateFormat,\n\n          defineItem(\"hideRecentReader\", {\n            label: t(\"appearance.hide_recent_reader.label\"),\n            description: t(\"appearance.hide_recent_reader.description\"),\n          }),\n          defineItem(\"readerRenderInlineStyle\", {\n            label: t(\"appearance.reader_render_inline_style.label\"),\n            description: t(\"appearance.reader_render_inline_style.description\"),\n          }),\n\n          {\n            type: \"title\",\n            value: t(\"appearance.code_highlighting.title\"),\n          },\n\n          ShikiTheme,\n\n          defineItem(\"guessCodeLanguage\", {\n            label: t(\"appearance.guess_code_language.label\"),\n            hide: !ELECTRON_BUILD,\n            description: t(\"appearance.guess_code_language.description\"),\n          }),\n\n          {\n            type: \"title\",\n            value: t(\"appearance.customization.title\"),\n          },\n\n          CustomCSS,\n          CustomizeToolbar,\n          CustomizeSubscriptionTabs,\n        ]}\n      />\n    </div>\n  )\n}\n\nconst ShikiTheme = () => {\n  const { t } = useTranslation(\"settings\")\n  const isMobile = useMobile()\n  const isDark = useIsDark()\n  const codeHighlightThemeLight = useUISettingKey(\"codeHighlightThemeLight\")\n  const codeHighlightThemeDark = useUISettingKey(\"codeHighlightThemeDark\")\n\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">\n          {t(\"appearance.code_highlight_theme.label\")}\n        </span>\n\n        <ResponsiveSelect\n          items={bundledThemesInfo\n            .filter((theme) => theme.type === (isDark ? \"dark\" : \"light\"))\n            .map((theme) => ({ value: theme.id, label: theme.displayName }))}\n          value={isDark ? codeHighlightThemeDark : codeHighlightThemeLight}\n          onValueChange={(value) => {\n            if (isDark) {\n              setUISetting(\"codeHighlightThemeDark\", value)\n            } else {\n              setUISetting(\"codeHighlightThemeLight\", value)\n            }\n          }}\n          triggerClassName=\"w-48\"\n          renderItem={(item) =>\n            isMobile ? (\n              capitalizeFirstLetter(item.label)\n            ) : (\n              <span className=\"capitalize\">{item.label}</span>\n            )\n          }\n          size=\"sm\"\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.code_highlight_theme.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst textSizeMap = {\n  smaller: 15,\n  default: 16,\n  medium: 18,\n  large: 20,\n}\n\nexport const TextSize = () => {\n  const { t } = useTranslation(\"settings\")\n  const uiTextSize = useUISettingSelector((state) => state.uiTextSize)\n\n  return (\n    <div className=\"mb-3 flex items-center justify-between\">\n      <span className=\"shrink-0 text-sm font-medium\">{t(\"appearance.text_size.label\")}</span>\n      <ResponsiveSelect\n        defaultValue={textSizeMap.default.toString()}\n        value={uiTextSize.toString() || textSizeMap.default.toString()}\n        onValueChange={(value) => {\n          setUISetting(\"uiTextSize\", Number.parseInt(value) || textSizeMap.default)\n        }}\n        size=\"sm\"\n        triggerClassName=\"w-48 capitalize\"\n        items={Object.entries(textSizeMap).map(([size, value]) => ({\n          label: t(`appearance.text_size.${size as keyof typeof textSizeMap}`),\n          value: value.toString(),\n        }))}\n      />\n    </div>\n  )\n}\n\n// Global Font Size component that combines UI and content font size\nconst GlobalFontSize = () => {\n  const { t } = useTranslation(\"settings\")\n  const uiTextSize = useUISettingSelector((state) => state.uiTextSize)\n\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">\n          {t(\"appearance.global_font_size.label\")}\n        </span>\n\n        <ResponsiveSelect\n          defaultValue={textSizeMap.default.toString()}\n          value={uiTextSize.toString() || textSizeMap.default.toString()}\n          onValueChange={(value) => {\n            const size = Number.parseInt(value) || textSizeMap.default\n            setUISetting(\"uiTextSize\", size)\n            // Also update content font size to keep them in sync\n            setUISetting(\"contentFontSize\", size)\n          }}\n          size=\"sm\"\n          triggerClassName=\"w-48 capitalize\"\n          items={Object.entries(textSizeMap).map(([size, value]) => ({\n            label: t(`appearance.text_size.${size as keyof typeof textSizeMap}`),\n            value: value.toString(),\n          }))}\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.global_font_size.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nexport const AppThemeSegment = () => {\n  const { t } = useTranslation(\"settings\")\n  const theme = useThemeAtomValue()\n  const setTheme = useSetTheme()\n\n  return (\n    <SettingTabbedSegment\n      key=\"theme\"\n      label={t(\"appearance.theme.label\")}\n      description={t(\"appearance.theme.description\")}\n      value={theme}\n      values={[\n        {\n          value: \"system\",\n          label: t(\"appearance.theme.system\"),\n          icon: <i className=\"i-mingcute-monitor-line\" />,\n        },\n        {\n          value: \"light\",\n          label: t(\"appearance.theme.light\"),\n          icon: <i className=\"i-mingcute-sun-line\" />,\n        },\n        {\n          value: \"dark\",\n          label: t(\"appearance.theme.dark\"),\n          icon: <i className=\"i-mingcute-moon-line\" />,\n        },\n      ]}\n      onValueChanged={(value) => {\n        setTheme(value as \"light\" | \"dark\" | \"system\")\n      }}\n    />\n  )\n}\n\nconst ThumbnailRatio = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const thumbnailRatio = useUISettingKey(\"thumbnailRatio\")\n\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">\n          {t(\"appearance.thumbnail_ratio.title\")}\n        </span>\n\n        <ResponsiveSelect\n          items={[\n            { value: \"square\", label: t(\"appearance.thumbnail_ratio.square\") },\n            { value: \"original\", label: t(\"appearance.thumbnail_ratio.original\") },\n          ]}\n          value={thumbnailRatio}\n          onValueChange={(value) => {\n            setUISetting(\"thumbnailRatio\", value as \"square\" | \"original\")\n          }}\n          renderValue={(item) =>\n            t(`appearance.thumbnail_ratio.${item.value as \"square\" | \"original\"}`)\n          }\n          triggerClassName=\"w-48 lg:translate-y-2 -inset-8translate-y-1\"\n          size=\"sm\"\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.thumbnail_ratio.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst CustomCSS = () => {\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n  return (\n    <>\n      <SettingActionItem\n        label={t(\"appearance.custom_css.label\")}\n        action={() => {\n          present({\n            title: t(\"appearance.custom_css.label\"),\n            content: CustomCSSModal,\n            clickOutsideToDismiss: false,\n            overlay: false,\n            resizeable: true,\n            resizeDefaultSize: {\n              width: 700,\n              height: window.innerHeight - 200,\n            },\n          })\n        }}\n        buttonText={t(\"appearance.custom_css.button\")}\n      />\n      <SettingDescription className=\"-mt-2\">\n        {t(\"appearance.custom_css.description\")}\n      </SettingDescription>\n    </>\n  )\n}\nconst LazyCSSEditor = lazy(() =>\n  import(\"../../editor/css-editor\").then((m) => ({ default: m.CSSEditor })),\n)\n\nconst CustomCSSModal = () => {\n  const initialCSS = useRef(getUISettings().customCSS)\n  const { t } = useTranslation(\"common\")\n  const { dismiss } = useCurrentModal()\n  useEffect(() => {\n    return () => {\n      setUISetting(\"customCSS\", initialCSS.current)\n    }\n  }, [])\n  useEffect(() => {\n    const modal = document.querySelector(`#${SETTING_MODAL_ID}`) as HTMLDivElement\n    if (!modal) return\n    const prevOverlay = getUISettings().modalOverlay\n    setUISetting(\"modalOverlay\", false)\n\n    modal.style.display = \"none\"\n    return () => {\n      setUISetting(\"modalOverlay\", prevOverlay)\n\n      modal.style.display = \"\"\n    }\n  }, [])\n  const [forceUpdate, key] = useForceUpdate()\n  return (\n    <form\n      className=\"relative flex h-full max-w-full flex-col\"\n      onSubmit={(e) => {\n        e.preventDefault()\n        if (initialCSS.current !== getUISettings().customCSS) {\n          initialCSS.current = getUISettings().customCSS\n        }\n        dismiss()\n      }}\n    >\n      <Suspense\n        fallback={\n          <div className=\"center flex grow lg:h-0\">\n            <LoadingCircle size=\"large\" />\n          </div>\n        }\n      >\n        <LazyCSSEditor\n          defaultValue={initialCSS.current}\n          key={key}\n          className=\"h-[70vh] grow rounded-lg border p-3 font-mono lg:h-0\"\n          onChange={(value) => {\n            setUISetting(\"customCSS\", value)\n          }}\n        />\n      </Suspense>\n\n      <div className=\"mt-2 flex shrink-0 justify-end gap-2\">\n        <Button\n          variant=\"outline\"\n          onClick={(e) => {\n            e.preventDefault()\n\n            setUISetting(\"customCSS\", initialCSS.current)\n\n            forceUpdate()\n          }}\n        >\n          {t(\"words.reset\")}\n        </Button>\n        <Button type=\"submit\">{t(\"words.save\")}</Button>\n      </div>\n    </form>\n  )\n}\n\nconst ContentLineHeight = () => {\n  const { t } = useTranslation(\"settings\")\n  const contentLineHeight = useUISettingKey(\"contentLineHeight\")\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">\n          {t(\"appearance.content_line_height.label\")}\n        </span>\n\n        <ResponsiveSelect\n          items={[\n            { value: \"1.25\", label: t(\"appearance.content_line_height.tight\") },\n            { value: \"1.375\", label: t(\"appearance.content_line_height.snug\") },\n            { value: \"1.5\", label: t(\"appearance.content_line_height.normal\") },\n            { value: \"1.75\", label: t(\"appearance.content_line_height.relaxed\") },\n            { value: \"2\", label: t(\"appearance.content_line_height.loose\") },\n          ]}\n          value={contentLineHeight.toString()}\n          onValueChange={(value) => {\n            setUISetting(\"contentLineHeight\", Number.parseFloat(value))\n          }}\n          triggerClassName=\"w-48\"\n          size=\"sm\"\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.content_line_height.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst DateFormat = () => {\n  const { t } = useTranslation(\"settings\")\n  const { t: commonT } = useTranslation(\"common\")\n  const dateFormat = useUISettingKey(\"dateFormat\")\n  const [date] = useState(() => new Date())\n\n  const generateItem = (format: string) => ({\n    value: format,\n    label: dayjs(date).format(format),\n  })\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">{t(\"appearance.date_format.label\")}</span>\n\n        <ResponsiveSelect\n          items={[\n            { value: \"default\", label: commonT(\"words.default\") },\n            generateItem(\"MM/DD/YY HH:mm\"),\n            generateItem(\"DD/MM/YYYY HH:mm\"),\n\n            generateItem(\"L\"),\n            generateItem(\"LTS\"),\n            generateItem(\"LT\"),\n            generateItem(\"LLLL\"),\n            generateItem(\"LL\"),\n            generateItem(\"LLL\"),\n          ]}\n          value={dateFormat}\n          onValueChange={(value) => {\n            setUISetting(\"dateFormat\", value)\n          }}\n          triggerClassName=\"w-48\"\n          size=\"sm\"\n        />\n      </div>\n      <SettingDescription>{t(\"appearance.date_format.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\n/**\n * @description customize the toolbar actions\n */\nconst CustomizeToolbar = () => {\n  const { t } = useTranslation(\"settings\")\n  const showModal = useShowCustomizeToolbarModal()\n\n  return (\n    <SettingItemGroup>\n      <SettingActionItem\n        label={t(\"appearance.customize_toolbar.label\")}\n        action={async () => {\n          showModal()\n        }}\n        buttonText={t(\"appearance.words.customize\")}\n      />\n      <SettingDescription className=\"-mt-3\">\n        {t(\"appearance.customize_toolbar.description\")}\n      </SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst CustomizeSubscriptionTabs = () => {\n  const { t } = useTranslation(\"settings\")\n  const showTabsModal = useShowTimelineTabsSettingsModal()\n  return (\n    <SettingItemGroup>\n      <SettingActionItem\n        label={t(\"appearance.customize_sub_tabs.label\")}\n        action={() => showTabsModal()}\n        buttonText={t(\"appearance.words.customize\")}\n      />\n      <SettingDescription className=\"-mt-3\">\n        {t(\"appearance.customize_sub_tabs.description\")}\n      </SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst ACCENT_COLORS: AccentColor[] = [\n  \"orange\",\n  \"blue\",\n  \"green\",\n  \"purple\",\n  \"pink\",\n  \"red\",\n  \"yellow\",\n  \"gray\",\n]\n\nconst CustomColorPicker = ({\n  value,\n  onChange,\n}: {\n  value: string\n  onChange: (color: string) => void\n}) => {\n  const barRef = useRef<HTMLDivElement>(null)\n  const indicatorRef = useRef<HTMLDivElement>(null)\n  const barRectRef = useRef<DOMRect | null>(null)\n  const rafIdRef = useRef<number | null>(null)\n  const [isDragging, setIsDragging] = useState(false)\n\n  // Predefined color stops for smooth gradient\n  const colorStops = useMemo(\n    () => [\n      { pos: 0, color: \"#3B82F6\" },\n      { pos: 0.2, color: \"#8B5CF6\" },\n      { pos: 0.35, color: \"#EC4899\" },\n      { pos: 0.5, color: \"#EF4444\" },\n      { pos: 0.65, color: \"#F59E0B\" },\n      { pos: 0.8, color: \"#FBBF24\" },\n      { pos: 1, color: \"#10B981\" },\n    ],\n    [],\n  )\n\n  // Interpolate color based on position\n  const getColorAtPosition = useCallback(\n    (position: number) => {\n      const pos = Math.max(0, Math.min(1, position))\n\n      // Find the two color stops to interpolate between\n      let lowerStop = colorStops[0]\n      let upperStop = colorStops.at(-1)\n\n      if (!lowerStop || !upperStop) return \"#3B82F6\"\n\n      for (let i = 0; i < colorStops.length - 1; i++) {\n        const currentStop = colorStops[i]\n        const nextStop = colorStops[i + 1]\n        if (currentStop && nextStop && pos >= currentStop.pos && pos <= nextStop.pos) {\n          lowerStop = currentStop\n          upperStop = nextStop\n          break\n        }\n      }\n\n      // Calculate interpolation factor\n      const range = upperStop.pos - lowerStop.pos\n      const factor = range === 0 ? 0 : (pos - lowerStop.pos) / range\n\n      // Parse hex colors\n      const parseHex = (hex: string) => ({\n        r: Number.parseInt(hex.slice(1, 3), 16),\n        g: Number.parseInt(hex.slice(3, 5), 16),\n        b: Number.parseInt(hex.slice(5, 7), 16),\n      })\n\n      const color1 = parseHex(lowerStop.color)\n      const color2 = parseHex(upperStop.color)\n\n      // Interpolate RGB values\n      const r = Math.round(color1.r + (color2.r - color1.r) * factor)\n      const g = Math.round(color1.g + (color2.g - color1.g) * factor)\n      const b = Math.round(color1.b + (color2.b - color1.b) * factor)\n\n      return `#${[r, g, b].map((v) => v.toString(16).padStart(2, \"0\")).join(\"\")}`\n    },\n    [colorStops],\n  )\n\n  const updateFromClientX = useCallback(\n    (clientX: number) => {\n      const rect = barRectRef.current ?? barRef.current?.getBoundingClientRect()\n      if (!rect) return\n\n      const x = Math.max(0, Math.min(rect.width, clientX - rect.left))\n      const position = x / rect.width\n\n      if (indicatorRef.current) {\n        indicatorRef.current.style.left = `${position * 100}%`\n      }\n\n      if (rafIdRef.current == null) {\n        rafIdRef.current = requestAnimationFrame(() => {\n          rafIdRef.current = null\n          const color = getColorAtPosition(position)\n          startTransition(() => {\n            onChange(color)\n          })\n        })\n      }\n    },\n    [getColorAtPosition, onChange],\n  )\n\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent) => {\n      if (!barRef.current) return\n      setIsDragging(true)\n      barRectRef.current = barRef.current.getBoundingClientRect()\n      ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)\n      updateFromClientX(e.clientX)\n    },\n    [updateFromClientX],\n  )\n\n  const handlePointerMove = useCallback(\n    (e: React.PointerEvent) => {\n      if (!isDragging) return\n      updateFromClientX(e.clientX)\n    },\n    [isDragging, updateFromClientX],\n  )\n\n  const endPointer = useCallback((e?: React.PointerEvent) => {\n    if (e) {\n      try {\n        ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)\n      } catch {\n        /* ignore release error */\n      }\n    }\n    setIsDragging(false)\n    barRectRef.current = null\n  }, [])\n\n  useEffect(() => {\n    return () => {\n      if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current)\n      rafIdRef.current = null\n    }\n  }, [])\n\n  // Calculate position indicator based on current color\n  const getIndicatorPosition = useCallback(() => {\n    if (!value.startsWith(\"#\")) return 50\n\n    // Simple approximation - find closest color stop\n    const parseHex = (hex: string) => ({\n      r: Number.parseInt(hex.slice(1, 3), 16),\n      g: Number.parseInt(hex.slice(3, 5), 16),\n      b: Number.parseInt(hex.slice(5, 7), 16),\n    })\n\n    const currentColor = parseHex(value)\n    let closestPos = 0\n    let minDistance = Number.POSITIVE_INFINITY\n\n    for (let i = 0; i <= 100; i++) {\n      const pos = i / 100\n      const testColor = parseHex(getColorAtPosition(pos))\n      const distance =\n        Math.abs(testColor.r - currentColor.r) +\n        Math.abs(testColor.g - currentColor.g) +\n        Math.abs(testColor.b - currentColor.b)\n\n      if (distance < minDistance) {\n        minDistance = distance\n        closestPos = pos\n      }\n    }\n\n    return closestPos * 100\n  }, [value, getColorAtPosition])\n\n  const gradientStyle = useMemo(\n    () => ({\n      background: `linear-gradient(to right, ${colorStops\n        .map((stop) => `${stop.color} ${stop.pos * 100}%`)\n        .join(\", \")})`,\n      touchAction: \"none\" as const,\n    }),\n    [colorStops],\n  )\n\n  return (\n    <div className=\"w-[280px]\">\n      {/* Color gradient bar */}\n      <div className=\"relative\">\n        <div\n          ref={barRef}\n          className=\"h-6 w-full cursor-pointer\"\n          style={gradientStyle}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={endPointer}\n          onPointerCancel={endPointer}\n        />\n        {/* Selection indicator */}\n        <div\n          ref={indicatorRef}\n          className=\"pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-white/20 shadow-lg backdrop-blur-sm\"\n          style={{\n            left: `${getIndicatorPosition()}%`,\n            willChange: \"left\",\n          }}\n        />\n      </div>\n    </div>\n  )\n}\n\nconst AccentColorSelector = () => {\n  const { t } = useTranslation(\"settings\")\n  const accentColor = useUISettingKey(\"accentColor\")\n  const isDark = useIsDark()\n  const [customColor, setCustomColor] = useState(\"#5CA9F2\")\n  const [isPopoverOpen, setIsPopoverOpen] = useState(false)\n\n  const isCustomColor = !ACCENT_COLORS.includes(accentColor as any)\n\n  const handleCustomColorChange = useMemo(\n    () =>\n      throttle((color: string) => {\n        setCustomColor(color)\n        setUISetting(\"accentColor\", color)\n      }, 120),\n    [],\n  )\n  return (\n    <SettingItemGroup>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"shrink-0 text-sm font-medium\">{t(\"appearance.accent_color.label\")}</span>\n        <div className=\"flex items-center gap-3 pr-1\">\n          {ACCENT_COLORS.map((color) => {\n            const isSelected = accentColor === color\n            const colorValue = getAccentColorValue(color)\n            const bgColor = colorValue ? colorValue[isDark ? \"dark\" : \"light\"] : \"#5CA9F2\"\n\n            return (\n              <button\n                key={color}\n                type=\"button\"\n                className=\"group relative flex size-7 items-center justify-center rounded-full transition-all duration-200 hover:scale-110\"\n                onClick={() => setUISetting(\"accentColor\", color)}\n                style={{\n                  backgroundColor: bgColor,\n                }}\n              >\n                {/* Selection ring */}\n                {isSelected && (\n                  <div\n                    className=\"absolute inset-0 rounded-full ring-2 ring-offset-2 ring-offset-white dark:ring-offset-neutral-900\"\n                    style={\n                      {\n                        \"--tw-ring-color\": bgColor,\n                      } as React.CSSProperties\n                    }\n                  />\n                )}\n\n                {/* Checkmark for selected color */}\n                {isSelected && (\n                  <i className=\"i-mgc-check-cute-re text-sm text-white drop-shadow-sm\" />\n                )}\n\n                {/* Hover effect */}\n                <div className=\"absolute inset-0 rounded-full bg-white opacity-0 transition-opacity duration-200 group-hover:opacity-20\" />\n              </button>\n            )\n          })}\n\n          {/* Custom color button with popover */}\n          <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>\n            <PopoverTrigger asChild>\n              <button\n                type=\"button\"\n                className=\"group relative flex size-7 items-center justify-center rounded-full transition-all duration-200 hover:scale-110\"\n                style={{\n                  background: isCustomColor\n                    ? (getAccentColorValue(accentColor)?.[isDark ? \"dark\" : \"light\"] ?? \"#5CA9F2\")\n                    : \"linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #00f2fe 100%)\",\n                }}\n              >\n                {/* Selection ring for custom color */}\n                {isCustomColor && (\n                  <div\n                    className=\"absolute inset-0 rounded-full ring-2 ring-offset-2 ring-offset-white dark:ring-offset-neutral-900\"\n                    style={\n                      {\n                        \"--tw-ring-color\":\n                          getAccentColorValue(accentColor)?.[isDark ? \"dark\" : \"light\"] ??\n                          \"#5CA9F2\",\n                      } as React.CSSProperties\n                    }\n                  />\n                )}\n\n                {/* Icon */}\n                {isCustomColor ? (\n                  <i className=\"i-mgc-check-cute-re text-sm text-white drop-shadow-sm\" />\n                ) : (\n                  <i className=\"i-mgc-add-cute-re text-sm text-white drop-shadow-sm\" />\n                )}\n\n                {/* Hover effect */}\n                <div className=\"absolute inset-0 rounded-full bg-white opacity-0 transition-opacity duration-200 group-hover:opacity-20\" />\n              </button>\n            </PopoverTrigger>\n            <PopoverContent align=\"end\" className=\"w-auto p-0\">\n              <CustomColorPicker\n                value={isCustomColor ? accentColor : customColor}\n                onChange={handleCustomColorChange}\n              />\n            </PopoverContent>\n          </Popover>\n        </div>\n      </div>\n      <SettingDescription>{t(\"appearance.accent_color.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useCallback, useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { ipcServices } from \"~/lib/client\"\n\nimport { SettingSectionTitle } from \"../section\"\n\nexport const SettingCli = () => {\n  interface CliInstallStatus {\n    connected: boolean\n    configPath: string\n    hasDesktopSession: boolean\n    installCommand: string\n    loginCommand: string\n    npxAvailable: boolean\n    packageName: string\n  }\n  const { t } = useTranslation(\"settings\")\n  const [status, setStatus] = useState<CliInstallStatus | null>(null)\n  const [loading, setLoading] = useState(false)\n\n  const refreshStatus = useCallback(async () => {\n    const result = await ipcServices?.cli.getInstallStatus()\n    if (result) {\n      setStatus(result)\n    }\n  }, [])\n\n  useEffect(() => {\n    refreshStatus()\n  }, [refreshStatus])\n\n  const handleInstall = useCallback(async () => {\n    setLoading(true)\n    try {\n      const result = await ipcServices?.cli.installCli()\n      if (result?.success) {\n        toast.success(t(\"cli.install_success\"))\n      } else {\n        toast.error(result?.error || t(\"cli.install_failed\"))\n      }\n    } catch {\n      toast.error(t(\"cli.install_failed\"))\n    }\n    await refreshStatus()\n    setLoading(false)\n  }, [t, refreshStatus])\n\n  const handleUninstall = useCallback(async () => {\n    setLoading(true)\n    try {\n      const result = await ipcServices?.cli.uninstallCli()\n      if (result?.success) {\n        toast.success(t(\"cli.uninstall_success\"))\n      } else {\n        toast.error(result?.error || t(\"cli.uninstall_failed\"))\n      }\n    } catch {\n      toast.error(t(\"cli.uninstall_failed\"))\n    }\n    await refreshStatus()\n    setLoading(false)\n  }, [t, refreshStatus])\n\n  if (!IN_ELECTRON) return null\n\n  return (\n    <div className=\"mt-4 space-y-6\">\n      <SettingSectionTitle title={t(\"cli.title\")} />\n\n      <div className=\"space-y-4\">\n        <p className=\"text-sm text-text-secondary\">{t(\"cli.description\")}</p>\n\n        {status && (\n          <div className=\"space-y-3\">\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <span className=\"text-sm font-medium\">Status:</span>\n              {status.connected ? (\n                <span className=\"inline-flex items-center gap-1 rounded-full bg-green/10 px-2 py-0.5 text-xs text-green\">\n                  <i className=\"i-mingcute-check-line\" />\n                  {t(\"cli.installed\")}\n                </span>\n              ) : (\n                <span className=\"inline-flex items-center gap-1 rounded-full bg-zinc-500/10 px-2 py-0.5 text-xs text-zinc-500\">\n                  {t(\"cli.not_installed\")}\n                </span>\n              )}\n\n              <span\n                className={\n                  status.npxAvailable\n                    ? \"inline-flex items-center gap-1 rounded-full bg-blue/10 px-2 py-0.5 text-xs text-blue\"\n                    : \"inline-flex items-center gap-1 rounded-full bg-orange/10 px-2 py-0.5 text-xs text-orange\"\n                }\n              >\n                {status.npxAvailable ? t(\"cli.runtime_ready\") : t(\"cli.runtime_missing\")}\n              </span>\n            </div>\n\n            <div className=\"grid gap-3\">\n              <div className=\"rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3\">\n                <div className=\"mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary\">\n                  {t(\"cli.package\")}\n                </div>\n                <code className=\"text-sm font-medium\">{status.packageName}</code>\n              </div>\n\n              <div className=\"rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3\">\n                <div className=\"mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary\">\n                  {t(\"cli.global_install\")}\n                </div>\n                <code className=\"block break-all text-sm\">{status.installCommand}</code>\n              </div>\n\n              <div className=\"rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3\">\n                <div className=\"mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary\">\n                  {t(\"cli.desktop_sync\")}\n                </div>\n                <code className=\"block break-all text-sm\">{status.loginCommand}</code>\n              </div>\n            </div>\n\n            <div className=\"flex items-center gap-2\">\n              <div className=\"flex items-center gap-2\">\n                <span className=\"text-sm font-medium\">{t(\"cli.path\")}:</span>\n                <code className=\"rounded bg-fill-quaternary px-2 py-0.5 text-xs\">\n                  {status.configPath}\n                </code>\n              </div>\n            </div>\n\n            {!status.npxAvailable && (\n              <p className=\"text-sm text-orange-500\">{t(\"cli.not_available\")}</p>\n            )}\n\n            {!status.hasDesktopSession && (\n              <p className=\"text-sm text-text-secondary\">{t(\"cli.require_login\")}</p>\n            )}\n\n            <div className=\"flex gap-2\">\n              <Button\n                onClick={handleInstall}\n                disabled={loading || !status.npxAvailable || !status.hasDesktopSession}\n                isLoading={loading}\n              >\n                {t(\"cli.install\")}\n              </Button>\n\n              {status.connected && (\n                <Button\n                  variant=\"outline\"\n                  onClick={handleUninstall}\n                  disabled={loading}\n                  isLoading={loading}\n                >\n                  {t(\"cli.uninstall\")}\n                </Button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/data-control.tsx",
    "content": "import { CarbonInfinitySymbol } from \"@follow/components/icons/infinify.jsx\"\nimport { Button, MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.js\"\nimport { Input } from \"@follow/components/ui/input/Input.js\"\nimport { Label } from \"@follow/components/ui/label/index.jsx\"\nimport { RadioGroup, RadioGroupItem } from \"@follow/components/ui/radio-group/motion.js\"\nimport { Slider } from \"@follow/components/ui/slider/index.js\"\nimport { exportDB } from \"@follow/database/db\"\nimport { ELECTRON_BUILD } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { setGeneralSetting, useGeneralSettingValue } from \"~/atoms/settings/general\"\nimport { useDialog, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { ipcServices } from \"~/lib/client\"\nimport { queryClient } from \"~/lib/query-client\"\nimport { clearLocalPersistStoreData } from \"~/store/utils/clear\"\n\nimport { SettingActionItem, SettingDescription } from \"../control\"\nimport { createSetting } from \"../helper/builder\"\nimport { SettingItemGroup } from \"../section\"\n\nconst { SettingBuilder } = createSetting(\"general\", useGeneralSettingValue, setGeneralSetting)\n\nexport const SettingDataControl = () => {\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n  const { ask } = useDialog()\n\n  return (\n    <div className=\"mt-4\">\n      {/* Top Level - Most Important */}\n      <SettingBuilder\n        settings={[\n          {\n            type: \"title\",\n            value: t(\"general.data\"),\n          },\n\n          {\n            type: \"title\",\n            value: t(\"general.export_data.title\"),\n          },\n\n          {\n            label: t(\"general.export.label\"),\n            description: t(\"general.export.description\"),\n            buttonText: t(\"general.export.button\"),\n            action: () => {\n              present({\n                title: t(\"general.export.label\"),\n                clickOutsideToDismiss: true,\n                content: () => <ExportFeedsForm />,\n              })\n            },\n          },\n          {\n            label: t(\"general.export_database.label\"),\n            description: t(\"general.export_database.description\"),\n            buttonText: t(\"general.export_database.button\"),\n            action: () => {\n              exportDB()\n            },\n          },\n\n          {\n            type: \"title\",\n            value: t(\"general.maintenance.title\"),\n          },\n          ELECTRON_BUILD ? CleanElectronCache : CleanCacheStorage,\n          ELECTRON_BUILD && AppCacheLimit,\n          {\n            label: t(\"general.rebuild_database.label\"),\n            action: () => {\n              ask({\n                title: t(\"general.rebuild_database.title\"),\n                variant: \"danger\",\n                message: `${t(\"general.rebuild_database.warning.line1\")}\\n${t(\"general.rebuild_database.warning.line2\")}`,\n                confirmText: t(\"ok\", { ns: \"common\" }),\n                onConfirm: async () => {\n                  await clearLocalPersistStoreData()\n                  window.location.reload()\n                },\n              })\n            },\n            description: t(\"general.rebuild_database.description\"),\n            buttonText: t(\"general.rebuild_database.button\"),\n          },\n          ELECTRON_BUILD && {\n            label: t(\"general.log_file.label\"),\n            description: t(\"general.log_file.description\"),\n            buttonText: t(\"general.log_file.button\"),\n            action: () => {\n              ipcServices?.app.revealLogFile?.()\n            },\n          },\n        ]}\n      />\n    </div>\n  )\n}\n\nconst exportFeedFormSchema = z.object({\n  rsshubUrl: z.string().url().optional(),\n  folderMode: z.enum([\"view\", \"category\"]),\n})\n\nconst ExportFeedsForm = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const form = useForm<z.infer<typeof exportFeedFormSchema>>({\n    resolver: zodResolver(exportFeedFormSchema),\n    defaultValues: {\n      folderMode: \"view\",\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof exportFeedFormSchema>) {\n    const link = document.createElement(\"a\")\n    const exportUrl = new URL(`${env.VITE_API_URL}/subscriptions/export`)\n    exportUrl.searchParams.append(\"folderMode\", values.folderMode)\n    if (values.rsshubUrl) {\n      exportUrl.searchParams.append(\"RSSHubURL\", values.rsshubUrl)\n    }\n    link.href = exportUrl.toString()\n    link.download = \"follow.opml\"\n    link.click()\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4 text-sm\">\n        <FormField\n          control={form.control}\n          name=\"rsshubUrl\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t(\"general.export.rsshub_url.label\")}</FormLabel>\n              <FormControl>\n                <Input type=\"url\" placeholder=\"https://rsshub.app\" {...field} />\n              </FormControl>\n              <FormDescription>{t(\"general.export.rsshub_url.description\")}</FormDescription>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"folderMode\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t(\"general.export.folder_mode.label\")}</FormLabel>\n              <FormControl>\n                <RadioGroup\n                  value={field.value}\n                  onValueChange={(value) => {\n                    field.onChange(value)\n                  }}\n                >\n                  <div className=\"flex gap-4\">\n                    <RadioGroupItem\n                      label={t(\"general.export.folder_mode.option.view\")}\n                      value=\"view\"\n                    />\n                    <RadioGroupItem\n                      label={t(\"general.export.folder_mode.option.category\")}\n                      value=\"category\"\n                    />\n                  </div>\n                </RadioGroup>\n              </FormControl>\n              <FormDescription>{t(\"general.export.folder_mode.description\")}</FormDescription>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"flex justify-end\">\n          <Button type=\"submit\">{t(\"ok\", { ns: \"common\" })}</Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n\n/**\n * @description clean web app service worker cache\n */\nconst CleanCacheStorage = () => {\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <SettingItemGroup>\n      <SettingActionItem\n        label={\n          <span className=\"flex items-center gap-1\">{t(\"data_control.clean_cache.button\")}</span>\n        }\n        action={async () => {\n          const keys = await caches.keys()\n          return Promise.all(\n            keys.map((key) => {\n              if (key.startsWith(\"workbox-precache-\")) return null\n              return caches.delete(key)\n            }),\n          ).then(() => {\n            toast.success(t(\"data_control.clean_cache.success\"))\n          })\n        }}\n        buttonText={t(\"data_control.clean_cache.button\")}\n      />\n      <SettingDescription>{t(\"data_control.clean_cache.description_web\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst CleanElectronCache = () => {\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <SettingItemGroup>\n      <SettingActionItem\n        label={\n          <span className=\"flex items-center gap-1\">\n            {t(\"data_control.clean_cache.button\")}\n            <MotionButtonBase\n              onClick={() => {\n                ipcServices?.app.openCacheFolder?.()\n              }}\n              className=\"center flex\"\n            >\n              <i className=\"i-mgc-folder-open-cute-re\" />\n            </MotionButtonBase>\n          </span>\n        }\n        action={async () => {\n          await ipcServices?.app.clearCache?.()\n          queryClient.setQueryData([\"app\", \"cache\", \"size\"], 0)\n        }}\n        buttonText={t(\"data_control.clean_cache.button\")}\n      />\n      <SettingDescription>{t(\"data_control.clean_cache.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\nconst AppCacheLimit = () => {\n  const { t } = useTranslation(\"settings\")\n  const { data: cacheSize, isLoading: isLoadingCacheSize } = useQuery({\n    queryKey: [\"app\", \"cache\", \"size\"],\n    queryFn: async () => {\n      const byteSize = (await ipcServices?.app.getCacheSize?.()) ?? 0\n      return Math.round(byteSize / 1024 / 1024)\n    },\n    refetchOnMount: \"always\",\n  })\n  const {\n    data: cacheLimit,\n    isLoading: isLoadingCacheLimit,\n    refetch: refetchCacheLimit,\n  } = useQuery({\n    queryKey: [\"app\", \"cache\", \"limit\"],\n    queryFn: async () => {\n      const size = (await ipcServices?.app.getCacheLimit?.()) ?? 0\n      return size\n    },\n  })\n\n  const onChange = (value: number[]) => {\n    ipcServices?.app.limitCacheSize?.(value[0]!)\n    refetchCacheLimit()\n  }\n\n  if (isLoadingCacheSize || isLoadingCacheLimit) return null\n\n  const InfinitySymbol = <CarbonInfinitySymbol />\n  return (\n    <SettingItemGroup>\n      <div className={\"mb-3 mt-4 flex items-center justify-between gap-4\"}>\n        <Label className=\"center flex\">\n          {t(\"data_control.app_cache_limit.label\")}\n\n          <span className=\"center ml-2 flex shrink-0 gap-1 text-xs opacity-60\">\n            <span>({cacheSize}M</span> /{\" \"}\n            <span className=\"center flex shrink-0\">\n              {cacheLimit ? `${cacheLimit}M` : InfinitySymbol})\n            </span>\n          </span>\n        </Label>\n\n        <div className=\"relative flex w-1/5 flex-col gap-1\">\n          <Slider\n            min={0}\n            max={500}\n            step={100}\n            defaultValue={[cacheLimit ?? 0]}\n            onValueCommit={onChange}\n          />\n          <div className=\"absolute bottom-[-1.5em] text-base opacity-50\">{InfinitySymbol}</div>\n          <div className=\"absolute bottom-[-1.5em] right-0 text-xs opacity-50\">500M</div>\n        </div>\n      </div>\n      <SettingDescription>{t(\"data_control.app_cache_limit.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/feeds.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { RSSHubLogo } from \"@follow/components/ui/platform-icon/icons.js\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@follow/components/ui/table/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { getView, getViewList } from \"@follow/constants\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { useFeedById, usePrefetchFeedAnalytics } from \"@follow/store/feed/hooks\"\nimport { getSubscriptionByFeedId } from \"@follow/store/subscription/getter\"\nimport {\n  useAllFeedSubscriptionIds,\n  useSubscriptionByFeedId,\n} from \"@follow/store/subscription/hooks\"\nimport { clsx, formatNumber, sortByAlphabet } from \"@follow/utils/utils\"\nimport { useVirtualizer } from \"@tanstack/react-virtual\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC } from \"react\"\nimport { memo, useCallback, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useIsInMASReview } from \"~/atoms/server-configs\"\nimport { RelativeDay } from \"~/components/ui/datetime\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useDialog } from \"~/components/ui/modal/stacked/hooks\"\nimport { useBatchUpdateSubscription } from \"~/hooks/biz/useSubscriptionActions\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { useConfirmUnsubscribeSubscriptionModal } from \"~/modules/modal/hooks/useConfirmUnsubscribeSubscriptionModal\"\nimport { SettingModalContentPortal } from \"~/modules/settings/modal/layout\"\nimport { Balance } from \"~/modules/wallet/balance\"\nimport { Queries } from \"~/queries\"\n\ntype SortField = \"name\" | \"view\" | \"date\" | \"subscriptionCount\" | \"updatesPerWeek\"\ntype SortDirection = \"asc\" | \"desc\"\ntype FeedFilter = \"all\" | \"rsshub\"\n\nexport const SettingFeeds = () => {\n  const inMas = useIsInMASReview()\n  return (\n    <div className=\"space-y-4 pb-8\">\n      <SubscriptionFeedsSection />\n      {!inMas && <FeedClaimedSection />}\n    </div>\n  )\n}\n\nconst GRID_COLS_CLASSNAME = \"grid-cols-[30px_auto_100px_150px_60px_60px]\"\n\nconst SubscriptionFeedsSection = () => {\n  const { t } = useTranslation(\"settings\")\n  const allFeeds = useAllFeedSubscriptionIds()\n  const [selectedFeeds, setSelectedFeeds] = useState<Set<string>>(() => new Set())\n  const [sortField, setSortField] = useState<SortField>(\"name\")\n  const [sortDirection, setSortDirection] = useState<SortDirection>(\"asc\")\n  const [filter, setFilter] = useState<FeedFilter>(\"all\")\n\n  // Calculate RSSHub feeds count\n  const rsshubFeedsCount = useMemo(() => {\n    return allFeeds.filter((feedId) => {\n      const feed = getFeedById(feedId)\n      return Boolean(feed?.url?.startsWith(\"rsshub://\"))\n    }).length\n  }, [allFeeds])\n\n  // Filter feeds based on selected filter\n  const filteredFeeds = useMemo(() => {\n    if (filter === \"all\") {\n      return allFeeds\n    }\n    return allFeeds.filter((feedId) => {\n      const feed = getFeedById(feedId)\n      return Boolean(feed?.url?.startsWith(\"rsshub://\"))\n    })\n  }, [allFeeds, filter])\n\n  // Clean up selectedFeeds when filter changes\n  const filteredFeedsSet = useMemo(() => new Set(filteredFeeds), [filteredFeeds])\n\n  // Clean selected feeds that are not in current filter\n  useEffect(() => {\n    setSelectedFeeds((prev) => {\n      const cleaned = new Set<string>()\n      prev.forEach((feedId) => {\n        if (filteredFeedsSet.has(feedId)) {\n          cleaned.add(feedId)\n        }\n      })\n      return cleaned\n    })\n  }, [filteredFeedsSet])\n\n  const handleSort = useCallback(\n    (field: SortField) => {\n      if (sortField === field) {\n        setSortDirection(sortDirection === \"asc\" ? \"desc\" : \"asc\")\n      } else {\n        setSortField(field)\n        setSortDirection(\"asc\")\n      }\n    },\n    [sortField, sortDirection],\n  )\n\n  const handleSelectAll = useCallback(\n    (checked: boolean) => {\n      if (checked) {\n        setSelectedFeeds(new Set(filteredFeeds))\n      } else {\n        setSelectedFeeds(new Set())\n      }\n    },\n    [filteredFeeds],\n  )\n\n  const handleSelectFeed = useCallback((feedId: string, checked: boolean) => {\n    setSelectedFeeds((prev) => {\n      const newSet = new Set(prev)\n      if (checked) {\n        newSet.add(feedId)\n      } else {\n        newSet.delete(feedId)\n      }\n      return newSet\n    })\n  }, [])\n\n  const isAllSelected = filteredFeeds.length > 0 && selectedFeeds.size === filteredFeeds.length\n\n  const presentDeleteSubscription = useConfirmUnsubscribeSubscriptionModal()\n  const handleBatchUnsubscribe = useCallback(() => {\n    const feedIds = Array.from(selectedFeeds)\n    presentDeleteSubscription(feedIds, () => setSelectedFeeds(new Set()))\n  }, [presentDeleteSubscription, selectedFeeds, setSelectedFeeds])\n\n  return (\n    <section className=\"relative mt-4\">\n      <div className=\"mb-2 flex items-center justify-between gap-4\">\n        <h2 className=\"text-lg font-semibold\">{t(\"feeds.subscription\")}</h2>\n        {allFeeds.length > 0 && (\n          <ResponsiveSelect\n            size=\"sm\"\n            triggerClassName=\"w-36\"\n            value={filter}\n            onValueChange={(value) => setFilter(value as FeedFilter)}\n            items={[\n              {\n                label: t(\"feeds.filter.all\", { count: allFeeds.length }),\n                value: \"all\",\n              },\n              {\n                label: t(\"feeds.filter.rsshub\", { count: rsshubFeedsCount }),\n                value: \"rsshub\",\n              },\n            ]}\n          />\n        )}\n      </div>\n\n      {filteredFeeds.length > 0 && (\n        <div className=\"mt-6 space-y-0.5\">\n          {/* Header - Sticky */}\n          <div\n            className={clsx(\n              \"sticky top-0 z-20 grid h-7 gap-3 border-b border-border bg-background/80 px-1 pb-1.5 text-xs font-medium text-text-secondary backdrop-blur-sm\",\n              GRID_COLS_CLASSNAME,\n            )}\n          >\n            <div className=\"flex items-center justify-center\">\n              <Checkbox size=\"sm\" checked={isAllSelected} onCheckedChange={handleSelectAll} />\n            </div>\n            <button\n              type=\"button\"\n              className=\"text-left transition-colors hover:text-text\"\n              onClick={() => handleSort(\"name\")}\n            >\n              {t(\"feeds.tableHeaders.name\")}\n              {sortField === \"name\" && (\n                <span className=\"ml-1\">{sortDirection === \"asc\" ? \"↑\" : \"↓\"}</span>\n              )}\n            </button>\n            <button\n              type=\"button\"\n              className=\"ml-4 text-left transition-colors hover:text-text\"\n              onClick={() => handleSort(\"view\")}\n            >\n              {t(\"feeds.tableHeaders.view\")}\n              {sortField === \"view\" && (\n                <span className=\"ml-1\">{sortDirection === \"asc\" ? \"↑\" : \"↓\"}</span>\n              )}\n            </button>\n            <button\n              className=\"text-center transition-colors hover:text-text\"\n              onClick={() => handleSort(\"date\")}\n              type=\"button\"\n            >\n              {t(\"feeds.tableHeaders.date\")}\n              {sortField === \"date\" && (\n                <span className=\"ml-1\">{sortDirection === \"asc\" ? \"↑\" : \"↓\"}</span>\n              )}\n            </button>\n            <button\n              className=\"text-nowrap text-center transition-colors hover:text-text\"\n              onClick={() => handleSort(\"subscriptionCount\")}\n              type=\"button\"\n            >\n              {t(\"feeds.tableHeaders.followers\")}\n              {sortField === \"subscriptionCount\" && (\n                <span className=\"ml-1\">{sortDirection === \"asc\" ? \"↑\" : \"↓\"}</span>\n              )}\n            </button>\n            <button\n              className=\"text-nowrap text-center transition-colors hover:text-text\"\n              onClick={() => handleSort(\"updatesPerWeek\")}\n              type=\"button\"\n            >\n              {t(\"feeds.tableHeaders.updatesPerWeek\")}\n              {sortField === \"updatesPerWeek\" && (\n                <span className=\"ml-1\">{sortDirection === \"asc\" ? \"↑\" : \"↓\"}</span>\n              )}\n            </button>\n          </div>\n\n          {/* Feed List */}\n          <div className=\"relative\">\n            <SortedFeedsList\n              feeds={filteredFeeds}\n              sortField={sortField}\n              sortDirection={sortDirection}\n              selectedFeeds={selectedFeeds}\n              onSelect={handleSelectFeed}\n            />\n          </div>\n        </div>\n      )}\n\n      {/* Sticky Action Bar at bottom when scrolled */}\n      <AnimatePresence>\n        {selectedFeeds.size > 0 && (\n          <SettingModalContentPortal>\n            <m.div\n              initial={{ opacity: 0, y: 20 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 20 }}\n              transition={Spring.presets.smooth}\n              className=\"absolute inset-x-0 bottom-3 z-10 flex justify-center px-3\"\n            >\n              <div\n                className=\"relative overflow-hidden rounded-2xl backdrop-blur-2xl\"\n                style={{\n                  backgroundImage:\n                    \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n                  borderWidth: \"1px\",\n                  borderStyle: \"solid\",\n                  borderColor: \"hsl(var(--fo-a) / 0.2)\",\n                  boxShadow:\n                    \"0 8px 32px hsl(var(--fo-a) / 0.08), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n                }}\n              >\n                {/* Inner glow layer */}\n                <div\n                  className=\"absolute inset-0 rounded-2xl\"\n                  style={{\n                    background:\n                      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))\",\n                  }}\n                />\n\n                {/* Content */}\n                <div className=\"relative flex items-center justify-between gap-4 px-5 py-3\">\n                  <span className=\"text-sm text-text-secondary\">\n                    {t(\"feeds.tableSelected.item\", { count: selectedFeeds.size })}\n                  </span>\n\n                  <div className=\"flex items-center gap-3\">\n                    <button\n                      className=\"cursor-button text-xs text-accent transition-colors hover:text-accent/80\"\n                      type=\"button\"\n                      onClick={() => setSelectedFeeds(new Set())}\n                    >\n                      {t(\"feeds.tableSelected.clear\")}\n                    </button>\n\n                    <div\n                      className=\"h-4 w-px\"\n                      style={{\n                        background:\n                          \"linear-gradient(to bottom, transparent, hsl(var(--fo-a) / 0.2), transparent)\",\n                      }}\n                    />\n\n                    <DropdownMenu>\n                      <DropdownMenuTrigger asChild>\n                        <MotionButtonBase\n                          className=\"text-xs text-accent transition-colors hover:text-accent/80\"\n                          type=\"button\"\n                        >\n                          {t(\"feeds.tableSelected.moveToView.action\")}\n                        </MotionButtonBase>\n                      </DropdownMenuTrigger>\n                      <DropdownMenuContent side=\"top\">\n                        <ViewSelector selectedFeeds={selectedFeeds} />\n                      </DropdownMenuContent>\n                    </DropdownMenu>\n\n                    <MotionButtonBase\n                      data-testid=\"feeds-batch-unsubscribe\"\n                      className=\"text-xs text-red transition-colors hover:text-red/80\"\n                      type=\"button\"\n                      onClick={handleBatchUnsubscribe}\n                    >\n                      {t(\"feeds.tableSelected.unsubscribe\")}\n                    </MotionButtonBase>\n                  </div>\n                </div>\n              </div>\n            </m.div>\n          </SettingModalContentPortal>\n        )}\n      </AnimatePresence>\n    </section>\n  )\n}\n\nconst SortedFeedsList: FC<{\n  feeds: string[]\n  sortField: SortField\n  sortDirection: SortDirection\n  selectedFeeds: Set<string>\n  onSelect: (feedId: string, checked: boolean) => void\n}> = ({ feeds, sortField, sortDirection, selectedFeeds, onSelect }) => {\n  const scrollContainerElement = useScrollViewElement()\n\n  const sortedFeedIds = useMemo(() => {\n    switch (sortField) {\n      case \"date\": {\n        return feeds.sort((a, b) => {\n          const aSubscription = getSubscriptionByFeedId(a)\n          const bSubscription = getSubscriptionByFeedId(b)\n          if (!aSubscription || !bSubscription) return 0\n          if (!aSubscription.createdAt || !bSubscription.createdAt) return 0\n          const aDate = new Date(aSubscription.createdAt)\n          const bDate = new Date(bSubscription.createdAt)\n          return sortDirection === \"asc\"\n            ? aDate.getTime() - bDate.getTime()\n            : bDate.getTime() - aDate.getTime()\n        })\n      }\n      case \"view\": {\n        return feeds.sort((a, b) => {\n          const aSubscription = getSubscriptionByFeedId(a)\n          const bSubscription = getSubscriptionByFeedId(b)\n          if (!aSubscription || !bSubscription) return 0\n          return sortDirection === \"asc\"\n            ? aSubscription.view - bSubscription.view\n            : bSubscription.view - aSubscription.view\n        })\n      }\n      case \"name\": {\n        return feeds.sort((a, b) => {\n          const aSubscription = getSubscriptionByFeedId(a)\n          const bSubscription = getSubscriptionByFeedId(b)\n          if (!aSubscription || !bSubscription) return 0\n          const aFeed = getFeedById(a)\n          const bFeed = getFeedById(b)\n          if (!aFeed || !bFeed) return 0\n          const aCompareTitle = aSubscription.title || aFeed.title || \"\"\n          const bCompareTitle = bSubscription.title || bFeed.title || \"\"\n          return sortDirection === \"asc\"\n            ? sortByAlphabet(aCompareTitle, bCompareTitle)\n            : sortByAlphabet(bCompareTitle, aCompareTitle)\n        })\n      }\n      case \"updatesPerWeek\": {\n        return feeds.sort((a, b) => {\n          const aSubscription = getSubscriptionByFeedId(a)\n          const bSubscription = getSubscriptionByFeedId(b)\n          if (!aSubscription || !bSubscription) return 0\n          const aFeed = getFeedById(a)\n          const bFeed = getFeedById(b)\n          if (!aFeed || !bFeed) return 0\n          return sortDirection === \"asc\"\n            ? (aFeed.updatesPerWeek || 0) - (bFeed.updatesPerWeek || 0)\n            : (bFeed.updatesPerWeek || 0) - (aFeed.updatesPerWeek || 0)\n        })\n      }\n      case \"subscriptionCount\": {\n        return feeds.sort((a, b) => {\n          const aSubscription = getSubscriptionByFeedId(a)\n          const bSubscription = getSubscriptionByFeedId(b)\n          if (!aSubscription || !bSubscription) return 0\n          const aFeed = getFeedById(a)\n          const bFeed = getFeedById(b)\n          if (!aFeed || !bFeed) return 0\n          return sortDirection === \"asc\"\n            ? (aFeed.subscriptionCount || 0) - (bFeed.subscriptionCount || 0)\n            : (bFeed.subscriptionCount || 0) - (aFeed.subscriptionCount || 0)\n        })\n      }\n    }\n  }, [feeds, sortDirection, sortField])\n\n  const rowVirtualizer = useVirtualizer({\n    count: sortedFeedIds.length,\n    getScrollElement: () => scrollContainerElement,\n    estimateSize: () => 38, // Estimated height of each feed item (h-9 = 36px + 2px gap)\n    overscan: 5,\n  })\n\n  // Track visible feeds for analytics prefetching\n  const virtualItems = rowVirtualizer.getVirtualItems()\n  const visibleFeedIds = useMemo(() => {\n    const feedIds: string[] = []\n    virtualItems.forEach((item) => {\n      const feedId = sortedFeedIds[item.index]\n      if (feedId) {\n        feedIds.push(feedId)\n      }\n    })\n    return feedIds\n  }, [virtualItems, sortedFeedIds])\n\n  usePrefetchFeedAnalytics(visibleFeedIds)\n\n  return (\n    <div\n      className=\"space-y-0.5\"\n      style={{\n        height: `${rowVirtualizer.getTotalSize()}px`,\n        width: \"100%\",\n        position: \"relative\",\n      }}\n    >\n      {virtualItems.map((virtualRow) => {\n        const feedId = sortedFeedIds[virtualRow.index]\n        if (!feedId) return null\n\n        return (\n          <div\n            key={virtualRow.key}\n            data-index={virtualRow.index}\n            ref={rowVirtualizer.measureElement}\n            style={{\n              position: \"absolute\",\n              top: 0,\n              left: 0,\n              width: \"100%\",\n              transform: `translateY(${virtualRow.start}px)`,\n            }}\n          >\n            <FeedListItem id={feedId} selected={selectedFeeds.has(feedId)} onSelect={onSelect} />\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n\nconst ViewSelector: FC<{ selectedFeeds: Set<string> }> = ({ selectedFeeds }) => {\n  const { t } = useTranslation(\"settings\")\n  const { t: tCommon } = useTranslation(\"common\")\n  const { mutate: batchUpdateSubscription } = useBatchUpdateSubscription()\n  const { ask } = useDialog()\n  return getViewList().map((view) => {\n    return (\n      <DropdownMenuItem\n        key={view.view}\n        icon={view.icon}\n        onClick={() => {\n          ask({\n            title: t(\"feeds.tableSelected.moveToView.confirmTitle\"),\n            message: t(\"feeds.tableSelected.moveToView.confirm\", { view: tCommon(view.name) }),\n            onConfirm: () => {\n              batchUpdateSubscription({\n                feedIdList: Array.from(selectedFeeds),\n                view: view.view,\n              })\n            },\n          })\n        }}\n      >\n        {tCommon(view.name)}\n      </DropdownMenuItem>\n    )\n  })\n}\n\nconst FeedListItem = memo(\n  ({\n    id,\n    selected,\n    onSelect,\n  }: {\n    id: string\n    selected: boolean\n    onSelect: (feedId: string, checked: boolean) => void\n  }) => {\n    const subscription = useSubscriptionByFeedId(id)\n    const feed = useFeedById(id)\n    const isCustomizeName = subscription?.title && feed?.title !== subscription?.title\n    const { t: tCommon } = useTranslation(\"common\")\n    const isRSSHub = Boolean(feed?.url?.startsWith(\"rsshub://\"))\n\n    if (!subscription) return null\n\n    return (\n      <div\n        data-id={id}\n        data-testid={`settings-feed-row-${id}`}\n        role=\"button\"\n        tabIndex={-1}\n        className={clsx(\n          \"group relative grid h-9 w-full items-center gap-3 rounded-md px-1.5 transition-all\",\n          \"content-visibility-auto contain-intrinsic-size-[auto_2.25rem]\",\n          GRID_COLS_CLASSNAME,\n          \"hover:bg-material-medium\",\n\n          selected && \"bg-material-thick\",\n        )}\n        onClick={() => onSelect(id, !selected)}\n      >\n        <div className=\"flex items-center justify-center\">\n          <Checkbox\n            size=\"sm\"\n            checked={selected}\n            onCheckedChange={(checked) => onSelect(id, !!checked)}\n          />\n        </div>\n        <div className=\"flex min-w-0 items-center gap-1.5\">\n          <FeedIcon target={feed} size={14} />\n          <div className=\"flex min-w-0 flex-1 flex-col\">\n            <div className=\"flex items-center gap-1\">\n              {feed?.errorAt ? (\n                <Tooltip>\n                  <TooltipTrigger>\n                    <EllipsisHorizontalTextWithTooltip className=\"text-sm font-medium leading-tight text-red\">\n                      {subscription.title || feed?.title}\n                    </EllipsisHorizontalTextWithTooltip>\n                  </TooltipTrigger>\n                  <TooltipPortal>\n                    <TooltipContent>\n                      {feed?.errorMessage || \"Feed has encountered an error\"}\n                    </TooltipContent>\n                  </TooltipPortal>\n                </Tooltip>\n              ) : (\n                <EllipsisHorizontalTextWithTooltip className=\"text-sm font-medium leading-tight text-text\">\n                  {subscription.title || feed?.title}\n                </EllipsisHorizontalTextWithTooltip>\n              )}\n              {isRSSHub && (\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <div className=\"inline-flex shrink-0 items-center gap-0.5 rounded bg-orange/20 px-1 py-0.5 text-[9px] font-semibold text-orange shadow-sm ring-1 ring-orange/30\">\n                      <RSSHubLogo className=\"size-2.5\" />\n                      <span>RSSHub</span>\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipPortal>\n                    <TooltipContent>This feed is powered by RSSHub</TooltipContent>\n                  </TooltipPortal>\n                </Tooltip>\n              )}\n            </div>\n            {isCustomizeName && (\n              <EllipsisHorizontalTextWithTooltip className=\"text-left text-xs leading-tight text-text-secondary\">\n                {feed?.title}\n              </EllipsisHorizontalTextWithTooltip>\n            )}\n          </div>\n        </div>\n\n        <div className=\"flex items-center gap-0.5 text-xs text-text opacity-80\">\n          {getView(subscription.view)!.icon}\n          <span className=\"leading-tight\">{tCommon(getView(subscription.view)!.name)}</span>\n        </div>\n        {!!subscription.createdAt && (\n          <div className=\"whitespace-nowrap pr-1 text-center text-xs\">\n            <RelativeDay date={new Date(subscription.createdAt)} />\n          </div>\n        )}\n        <div className=\"text-center text-xs\">\n          {feed?.subscriptionCount ? (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <div className=\"flex items-center justify-center gap-0.5 text-text-secondary\">\n                  <i className=\"i-mgc-user-3-cute-re text-[10px]\" />\n                  <span className=\"text-[11px] tabular-nums\">\n                    {formatNumber(feed.subscriptionCount)}\n                  </span>\n                </div>\n              </TooltipTrigger>\n              <TooltipPortal>\n                <TooltipContent>Subscription Count</TooltipContent>\n              </TooltipPortal>\n            </Tooltip>\n          ) : (\n            <div className=\"text-[11px] text-text-secondary\">--</div>\n          )}\n        </div>\n        <div className=\"text-center text-xs\">\n          {feed?.updatesPerWeek ? (\n            <div className=\"flex justify-center gap-0.5\">\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <div className=\"flex items-center justify-center gap-0.5 text-text-secondary\">\n                    <i className=\"i-mgc-safety-certificate-cute-re text-[10px]\" />\n                    <span className=\"text-[11px] tabular-nums\">\n                      {Math.round(feed.updatesPerWeek)}\n                      {\"/w\"}\n                    </span>\n                  </div>\n                </TooltipTrigger>\n                <TooltipPortal>\n                  <TooltipContent>Updates Per Week</TooltipContent>\n                </TooltipPortal>\n              </Tooltip>\n              {feed.latestEntryPublishedAt && (\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <div className=\"text-[10px] text-text-secondary\">\n                      <i className=\"i-mgc-calendar-time-add-cute-re\" />\n                      <RelativeDay date={new Date(feed.latestEntryPublishedAt)} />\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipContent>Latest Entry Published</TooltipContent>\n                </Tooltip>\n              )}\n            </div>\n          ) : (\n            <div className=\"text-[11px] text-text-secondary\">--</div>\n          )}\n        </div>\n      </div>\n    )\n  },\n)\n\nconst FeedClaimedSection = () => {\n  const { t } = useTranslation(\"settings\")\n  const claimedList = useAuthQuery(Queries.feed.claimedList())\n\n  const numberFormatter = useMemo(() => new Intl.NumberFormat(\"en-US\", {}), [])\n\n  return (\n    <section className=\"mt-4\">\n      <div>\n        <h2 className=\"mb-2 text-lg font-semibold\">{t(\"feeds.claim\")}</h2>\n      </div>\n      <div className=\"mb-4 space-y-2 text-sm\">\n        <p>{t(\"feeds.claimTips\")}</p>\n      </div>\n      <Divider className=\"mb-6 mt-8\" />\n      <div className=\"flex flex-1 flex-col\">\n        {claimedList.isLoading ? (\n          <LoadingCircle size=\"large\" className=\"center h-36\" />\n        ) : !claimedList.data?.length ? (\n          <div className=\"mt-36 w-full text-center text-sm text-text-secondary\">\n            <p>{t(\"feeds.noFeeds\")}</p>\n          </div>\n        ) : null}\n        {claimedList.data?.length ? (\n          <ScrollArea.ScrollArea viewportClassName=\"max-h-[380px]\">\n            <Table className=\"mt-4\">\n              <TableHeader className=\"border-b\">\n                <TableRow className=\"[&_*]:!font-semibold\">\n                  <TableHead className=\"w-16 text-center\" size=\"sm\">\n                    {t(\"feeds.tableHeaders.name\")}\n                  </TableHead>\n                  <TableHead className=\"text-center\" size=\"sm\">\n                    {t(\"feeds.tableHeaders.subscriptionCount\")}\n                  </TableHead>\n                  <TableHead className=\"text-center\" size=\"sm\">\n                    {t(\"feeds.tableHeaders.tipAmount\")}\n                  </TableHead>\n                </TableRow>\n              </TableHeader>\n              <TableBody className=\"border-t-[12px] border-transparent\">\n                {claimedList.data?.map((row) => (\n                  <TableRow key={row.feed.id} className=\"h-8\">\n                    <TableCell size=\"sm\" width={200}>\n                      <a\n                        target=\"_blank\"\n                        href={UrlBuilder.shareFeed(row.feed.id)}\n                        className=\"flex items-center\"\n                      >\n                        <FeedIcon fallback target={row.feed} size={16} />\n                        <EllipsisHorizontalTextWithTooltip className=\"inline-block max-w-[200px] truncate\">\n                          {row.feed.title}\n                        </EllipsisHorizontalTextWithTooltip>\n                      </a>\n                    </TableCell>\n                    <TableCell align=\"center\" className=\"tabular-nums\" size=\"sm\">\n                      {numberFormatter.format(row.subscriptionCount)}\n                    </TableCell>\n                    <TableCell align=\"center\" size=\"sm\">\n                      <Balance>{BigInt(row.tipAmount || 0n)}</Balance>\n                    </TableCell>\n                  </TableRow>\n                ))}\n              </TableBody>\n            </Table>\n          </ScrollArea.ScrollArea>\n        ) : null}\n      </div>\n    </section>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/general.tsx",
    "content": "import { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { UserRole } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { ACTION_LANGUAGE_MAP } from \"@follow/shared\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport dayjs from \"dayjs\"\nimport { useAtom } from \"jotai\"\nimport { useEffect } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { currentSupportedLanguages } from \"~/@types/constants\"\nimport { defaultResources } from \"~/@types/default-resource\"\nimport { langLoadingLockMapAtom } from \"~/atoms/lang\"\nimport { useIsPaymentEnabled } from \"~/atoms/server-configs\"\nimport {\n  DEFAULT_ACTION_LANGUAGE,\n  setGeneralSetting,\n  useGeneralSettingKey,\n  useGeneralSettingSelector,\n  useGeneralSettingValue,\n} from \"~/atoms/settings/general\"\nimport { useDialog } from \"~/components/ui/modal/stacked/hooks\"\nimport { useProxyValue, useSetProxy } from \"~/hooks/biz/useProxySetting\"\nimport { useMinimizeToTrayValue, useSetMinimizeToTray } from \"~/hooks/biz/useTraySetting\"\nimport { fallbackLanguage } from \"~/i18n\"\nimport { ipcServices } from \"~/lib/client\"\nimport { setTranslationCache } from \"~/modules/entry-content/atoms\"\n\nimport { PaidBadge, SettingDescription, SettingInput, SettingSwitch } from \"../control\"\nimport { createSetting } from \"../helper/builder\"\nimport { SettingPaidLevels } from \"../helper/setting-builder\"\nimport {\n  useWrapEnhancedSettingItem,\n  WrapEnhancedSettingTab,\n} from \"../hooks/useWrapEnhancedSettingItem\"\nimport { SettingItemGroup } from \"../section\"\n\nconst { defineSettingItem: _defineSettingItem, SettingBuilder } = createSetting(\n  \"general\",\n  useGeneralSettingValue,\n  setGeneralSetting,\n)\n\nconst saveLoginSetting = (checked: boolean) => {\n  ipcServices?.setting.setLoginItemSettings({\n    openAtLogin: checked,\n    openAsHidden: true,\n    args: [\"--startup\"],\n  })\n  setGeneralSetting(\"appLaunchOnStartup\", checked)\n}\n\nexport const SettingGeneral = () => {\n  const { t } = useTranslation(\"settings\")\n  useEffect(() => {\n    ipcServices?.setting.getLoginItemSettings().then((settings) => {\n      if (settings) {\n        setGeneralSetting(\"appLaunchOnStartup\", settings.openAtLogin)\n      }\n    })\n  }, [])\n\n  const defineSettingItem = useWrapEnhancedSettingItem(\n    _defineSettingItem,\n    WrapEnhancedSettingTab.General,\n  )\n\n  const { ask } = useDialog()\n  const reRenderKey = useGeneralSettingKey(\"enhancedSettings\")\n\n  return (\n    <div className=\"mt-4\">\n      <SettingBuilder\n        key={reRenderKey.toString()}\n        settings={[\n          {\n            type: \"title\",\n            value: t(\"general.app\"),\n          },\n\n          defineSettingItem(\"appLaunchOnStartup\", {\n            label: t(\"general.launch_at_login\"),\n            hide: !ipcServices,\n            onChange(value) {\n              saveLoginSetting(value)\n            },\n          }),\n          IN_ELECTRON && MinimizeToTraySetting,\n          LanguageSelector,\n\n          {\n            type: \"title\",\n            value: t(\"general.action.title\"),\n          },\n          defineSettingItem(\"summary\", {\n            label: t(\"general.action.summary.label\"),\n            description: t(\"general.action.summary.description\"),\n          }),\n          defineSettingItem(\"translation\", {\n            label: t(\"general.action.translation.label\"),\n            description: t(\"general.action.translation.description\"),\n          }),\n          TranslationModeSelector,\n          ActionLanguageSelector,\n\n          {\n            type: \"title\",\n            value: t(\"general.subscription\"),\n          },\n          defineSettingItem(\"autoGroup\", {\n            label: t(\"general.auto_group.label\"),\n            description: t(\"general.auto_group.description\"),\n          }),\n          defineSettingItem(\"hideAllReadSubscriptions\", {\n            label: t(\"general.hide_all_read_subscriptions.label\"),\n            description: t(\"general.hide_all_read_subscriptions.description\"),\n          }),\n          defineSettingItem(\"hidePrivateSubscriptionsInTimeline\", {\n            label: t(\"general.hide_private_subscriptions_in_timeline.label\"),\n            description: t(\"general.hide_private_subscriptions_in_timeline.description\"),\n          }),\n\n          {\n            type: \"title\",\n            value: t(\"general.timeline\"),\n          },\n          defineSettingItem(\"unreadOnly\", {\n            label: t(\"general.show_unread_on_launch.label\"),\n            description: t(\"general.show_unread_on_launch.description\"),\n          }),\n          defineSettingItem(\"groupByDate\", {\n            label: t(\"general.group_by_date.label\"),\n            description: t(\"general.group_by_date.description\"),\n          }),\n          defineSettingItem(\"autoExpandLongSocialMedia\", {\n            label: t(\"general.auto_expand_long_social_media.label\"),\n            description: t(\"general.auto_expand_long_social_media.description\"),\n          }),\n          defineSettingItem(\"dimRead\", {\n            label: t(\"general.dim_read.label\"),\n            description: t(\"general.dim_read.description\"),\n          }),\n\n          { type: \"title\", value: t(\"general.mark_as_read.title\") },\n\n          defineSettingItem(\"scrollMarkUnread\", {\n            label: t(\"general.mark_as_read.scroll.label\"),\n            description: t(\"general.mark_as_read.scroll.description\"),\n          }),\n\n          defineSettingItem(\"hoverMarkUnread\", {\n            label: t(\"general.mark_as_read.hover.label\"),\n            description: t(\"general.mark_as_read.hover.description\"),\n          }),\n          defineSettingItem(\"renderMarkUnread\", {\n            label: t(\"general.mark_as_read.render.label\"),\n            description: t(\"general.mark_as_read.render.description\"),\n          }),\n\n          { type: \"title\", value: \"TTS\" },\n\n          IN_ELECTRON && VoiceSelector,\n\n          { type: \"title\", value: t(\"general.network\") },\n          IN_ELECTRON && NettingSetting,\n\n          { type: \"title\", value: t(\"general.advanced\") },\n\n          defineSettingItem(\"enhancedSettings\", {\n            label: t(\"general.enhanced.label\"),\n            description: t(\"general.enhanced.description\"),\n            onChangeGuard(value) {\n              if (value) {\n                ask({\n                  variant: \"danger\",\n                  title: t(\"general.enhanced.enable.modal.title\"),\n                  message: t(\"general.enhanced.enable.modal.description\"),\n                  confirmText: t(\"general.enhanced.enable.modal.confirm\"),\n                  cancelText: t(\"general.enhanced.enable.modal.cancel\"),\n                  onConfirm: () => {\n                    setGeneralSetting(\"enhancedSettings\", value)\n                  },\n                })\n                return \"handled\"\n              }\n            },\n          }),\n        ]}\n      />\n    </div>\n  )\n}\n\nconst VoiceSelector = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const { data } = useQuery({\n    queryFn: () => ipcServices?.reader.getVoices(),\n    queryKey: [\"voices\"],\n    meta: {\n      persist: true,\n    },\n  })\n\n  const voice = useGeneralSettingKey(\"voice\")\n\n  return (\n    <div className=\"-mt-1 mb-3 flex items-center justify-between\">\n      <span className=\"shrink-0 text-sm font-medium\">{t(\"general.voices\")}</span>\n      <ResponsiveSelect\n        size=\"sm\"\n        triggerClassName=\"w-48\"\n        defaultValue={voice}\n        value={voice}\n        onValueChange={(value) => {\n          setGeneralSetting(\"voice\", value)\n        }}\n        items={\n          data?.map((item) => ({\n            label: item.FriendlyName,\n            value: item.ShortName,\n          })) ?? []\n        }\n      />\n    </div>\n  )\n}\n\nexport const LanguageSelector = ({\n  containerClassName,\n  contentClassName,\n\n  showDescription = true,\n}: {\n  containerClassName?: string\n  contentClassName?: string\n  showDescription?: boolean\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const language = useGeneralSettingSelector((state) => state.language)\n\n  const finalRenderLanguage = currentSupportedLanguages.includes(language)\n    ? language\n    : fallbackLanguage\n\n  const [loadingLanguageLockMap] = useAtom(langLoadingLockMapAtom)\n\n  return (\n    <div className={cn(\"mb-3 mt-4 flex w-full items-center\", containerClassName)}>\n      <div className=\"flex grow flex-col gap-1\">\n        <span className=\"shrink-0 text-sm font-medium\">{t(\"general.language.title\")}</span>\n        {showDescription && (\n          <SettingDescription>{t(\"general.language.description\")}</SettingDescription>\n        )}\n      </div>\n\n      <ResponsiveSelect\n        size=\"sm\"\n        triggerClassName=\"w-48\"\n        triggerTestId=\"settings-language-select\"\n        contentClassName={contentClassName}\n        defaultValue={finalRenderLanguage}\n        value={finalRenderLanguage}\n        disabled={loadingLanguageLockMap[finalRenderLanguage]}\n        onValueChange={(value) => {\n          setGeneralSetting(\"language\", value as string)\n          dayjs.locale(value)\n        }}\n        renderValue={useTypeScriptHappyCallback((item) => {\n          return <span>{defaultResources[item.value].lang.name}</span>\n        }, [])}\n        renderItem={useTypeScriptHappyCallback((item) => {\n          const lang = item.value\n          const percent = I18N_COMPLETENESS_MAP[lang]\n\n          const originalLanguageName = defaultResources[lang].lang.name\n\n          return (\n            <span className=\"group\" key={lang}>\n              <span>\n                {originalLanguageName}\n                {typeof percent === \"number\" ? (percent >= 100 ? null : ` (${percent}%)`) : null}\n              </span>\n            </span>\n          )\n        }, [])}\n        items={currentSupportedLanguages.map((lang) => ({\n          label: `langs.${lang}`,\n          value: lang,\n        }))}\n      />\n    </div>\n  )\n}\n\nconst TranslationModeSelector = () => {\n  const { t } = useTranslation(\"settings\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const role = useUserRole()\n  const isPaymentEnabled = useIsPaymentEnabled()\n  const disabledForRole = role === UserRole.Free && isPaymentEnabled\n\n  return (\n    <>\n      <div className=\"mt-4 flex items-center justify-between\">\n        <span className=\"flex shrink-0 items-center gap-1 text-sm font-medium\">\n          <span>{t(\"general.translation_mode.label\")}</span>\n          <PaidBadge paidLevel={SettingPaidLevels.Basic} />\n        </span>\n        <ResponsiveSelect\n          size=\"sm\"\n          triggerClassName=\"w-48\"\n          defaultValue={translationMode}\n          value={translationMode}\n          onValueChange={(value) => {\n            setGeneralSetting(\"translationMode\", value as \"bilingual\" | \"translation-only\")\n          }}\n          items={[\n            { label: t(\"general.translation_mode.bilingual\"), value: \"bilingual\" },\n            { label: t(\"general.translation_mode.translation-only\"), value: \"translation-only\" },\n          ]}\n          disabled={disabledForRole}\n        />\n      </div>\n      <SettingDescription>{t(\"general.translation_mode.description\")}</SettingDescription>\n    </>\n  )\n}\n\nconst ActionLanguageSelector = () => {\n  const { t } = useTranslation(\"settings\")\n  const actionLanguage = useGeneralSettingKey(\"actionLanguage\")\n\n  return (\n    <div className=\"mb-3 mt-4 flex w-full gap-1\">\n      <div className=\"flex grow flex-col gap-1\">\n        <span className=\"shrink-0 text-sm font-medium\">{t(\"general.action_language.label\")}</span>\n        <SettingDescription>{t(\"general.action_language.description\")}</SettingDescription>\n      </div>\n\n      <ResponsiveSelect\n        size=\"sm\"\n        triggerClassName=\"w-48\"\n        defaultValue={actionLanguage}\n        value={actionLanguage}\n        onValueChange={(value) => {\n          setGeneralSetting(\"actionLanguage\", value)\n          setTranslationCache({})\n        }}\n        items={[\n          { label: t(\"general.action_language.default\"), value: DEFAULT_ACTION_LANGUAGE },\n          ...Object.values(ACTION_LANGUAGE_MAP).map((item) => ({\n            label: defaultResources[item.value].lang.name,\n            value: item.value,\n          })),\n        ]}\n      />\n    </div>\n  )\n}\n\nconst NettingSetting = () => {\n  const { t } = useTranslation(\"settings\")\n  const proxyConfig = useProxyValue()\n  const setProxyConfig = useSetProxy()\n\n  return (\n    <SettingItemGroup>\n      <SettingInput\n        type=\"text\"\n        label={t(\"general.proxy.label\")}\n        labelClassName=\"w-[150px]\"\n        value={proxyConfig}\n        onChange={(event) => setProxyConfig(event.target.value.trim())}\n      />\n      <SettingDescription>{t(\"general.proxy.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n\nconst MinimizeToTraySetting = () => {\n  const { t } = useTranslation(\"settings\")\n  const minimizeToTray = useMinimizeToTrayValue()\n  const setMinimizeToTray = useSetMinimizeToTray()\n  return (\n    <SettingItemGroup>\n      <SettingSwitch\n        checked={minimizeToTray}\n        className=\"mt-4\"\n        onCheckedChange={setMinimizeToTray}\n        label={t(\"general.minimize_to_tray.label\")}\n      />\n      <SettingDescription>{t(\"general.minimize_to_tray.description\")}</SettingDescription>\n    </SettingItemGroup>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationModal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input, TextArea } from \"@follow/components/ui/input/index.js\"\nimport { KeyValueEditor } from \"@follow/components/ui/key-value-editor/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport type {\n  CustomIntegration,\n  FetchTemplate,\n  URLSchemeTemplate,\n} from \"@follow/shared/settings/interface\"\nimport { nextFrame } from \"@follow/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { memo, useCallback, useEffect, useMemo } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { CustomIntegrationPreview } from \"~/modules/integration/CustomIntegrationPreview\"\nimport { PlaceholderHelp } from \"~/modules/integration/PlaceholderHelp\"\nimport { URLSchemePreview } from \"~/modules/integration/URLSchemePreview\"\n\nconst httpTemplateSchema = z.object({\n  method: z.enum([\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"]),\n  url: z.string().url(\"URL is required\"),\n  headers: z.record(z.string()),\n  body: z.string().optional(),\n})\n\nconst urlSchemeTemplateSchema = z.object({\n  scheme: z.string().url(\"URL scheme is required\"),\n})\n\nconst createFormSchema = () =>\n  z\n    .object({\n      name: z.string().min(1, \"Name is required\"),\n      icon: z.string().min(1, \"Icon is required\"),\n      type: z.enum([\"http\", \"url-scheme\"]),\n      fetchTemplate: z.any().optional(),\n      urlSchemeTemplate: z.any().optional(),\n      enabled: z.boolean(),\n    })\n    .superRefine((data, ctx) => {\n      try {\n        if (data.type === \"http\") {\n          httpTemplateSchema.parse(data.fetchTemplate)\n        } else if (data.type === \"url-scheme\") {\n          urlSchemeTemplateSchema.parse(data.urlSchemeTemplate)\n        }\n      } catch (error) {\n        if (error instanceof z.ZodError) {\n          error.errors.forEach((zodError) => {\n            ctx.addIssue({\n              code: z.ZodIssueCode.custom,\n              message: zodError.message,\n              path:\n                data.type === \"http\"\n                  ? [\"fetchTemplate\", ...zodError.path]\n                  : [\"urlSchemeTemplate\", ...zodError.path],\n            })\n          })\n        }\n      }\n    })\n\ntype FormData = z.infer<ReturnType<typeof createFormSchema>>\n\ninterface CustomIntegrationModalProps {\n  integration?: CustomIntegration\n  onSave: (integration: Omit<CustomIntegration, \"id\"> & { id?: string }) => void\n}\n\n// Constants\nconst HTTP_METHODS = [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"] as const\nconst INTEGRATION_TYPES = [\n  { value: \"http\", icon: \"i-mgc-world-2-cute-re\" },\n  { value: \"url-scheme\", icon: \"i-mgc-link-cute-re\" },\n] as const\n\nconst ICON_OPTIONS = [\n  { value: \"i-mgc-bookmark-cute-re\", key: \"bookmark\" },\n  { value: \"i-mgc-pic-cute-re\", key: \"picture\" },\n  { value: \"i-mgc-share-forward-cute-re\", key: \"share\" },\n  { value: \"i-mgc-external-link-cute-re\", key: \"external_link\" },\n  { value: \"i-mgc-save-cute-re\", key: \"save\" },\n  { value: \"i-mgc-documents-cute-re\", key: \"document\" },\n  { value: \"i-mgc-link-cute-re\", key: \"link\" },\n  { value: \"i-mgc-star-cute-re\", key: \"star\" },\n  { value: \"i-mgc-download-2-cute-re\", key: \"download\" },\n  { value: \"i-mgc-send-plane-cute-re\", key: \"send\" },\n] as const\n\n// Helper functions\nconst getDefaultFetchTemplate = (): FetchTemplate => ({\n  method: \"GET\",\n  url: \"\",\n  headers: {},\n  body: \"\",\n})\n\nconst getDefaultURLSchemeTemplate = (): URLSchemeTemplate => ({\n  scheme: \"\",\n})\n\n// Memoized icon selector component\nconst IconSelector = memo(\n  ({\n    value,\n    onChange,\n    icons,\n  }: {\n    value: string\n    onChange: (value: string) => void\n    icons: Array<{ value: string; label: string; icon: string }>\n  }) => (\n    <ResponsiveSelect\n      value={value}\n      onValueChange={onChange}\n      items={icons.map((icon) => ({\n        label: icon.label,\n        value: icon.value,\n      }))}\n      renderItem={(item) => (\n        <div className=\"flex items-center gap-2\">\n          <i className={item.value} />\n          {item.label}\n        </div>\n      )}\n    />\n  ),\n)\n\n// Memoized type selector component\nconst TypeSelector = memo(\n  ({\n    value,\n    onChange,\n    items,\n    getTypeIcon,\n  }: {\n    value: string\n    onChange: (value: string) => void\n    items: Array<{ label: string; value: string }>\n    getTypeIcon: (type: string) => string\n  }) => (\n    <ResponsiveSelect\n      value={value}\n      onValueChange={onChange}\n      items={items}\n      renderItem={(item) => (\n        <div className=\"flex items-center gap-2\">\n          <i className={getTypeIcon(item.value)} />\n          {item.label}\n        </div>\n      )}\n    />\n  ),\n)\n\n// Memoized method selector component\nconst MethodSelector = memo(\n  ({\n    value,\n    onChange,\n    items,\n  }: {\n    value: string\n    onChange: (value: string) => void\n    items: Array<{ label: string; value: string }>\n  }) => <ResponsiveSelect value={value} onValueChange={onChange} items={items} />,\n)\n\n// Memoized input field component\nconst MemoizedInput = memo(Input)\nconst MemoizedTextArea = memo(TextArea)\nconst MemoizedKeyValueEditor = memo(KeyValueEditor)\n\nexport const CustomIntegrationModalContent = ({\n  integration,\n  onSave,\n}: CustomIntegrationModalProps) => {\n  const { dismiss } = useCurrentModal()\n  const { t } = useTranslation(\"settings\")\n\n  const getCommonIcons = useCallback(\n    () =>\n      ICON_OPTIONS.map((icon) => ({\n        value: icon.value,\n        label: t(`integration.custom_integrations.icons.${icon.key}`),\n        icon: icon.value,\n      })),\n    [t],\n  )\n\n  // Memoized values\n  const commonIcons = useMemo(\n    (): Array<{ value: string; label: string; icon: string }> => getCommonIcons(),\n    [getCommonIcons],\n  )\n\n  const defaultValues = useMemo((): FormData => {\n    const integrationType = (integration?.type as \"http\" | \"url-scheme\") || \"http\"\n\n    return {\n      name: integration?.name || \"\",\n      icon: integration?.icon || commonIcons[0]?.value || ICON_OPTIONS[0].value,\n      type: integrationType,\n\n      fetchTemplate:\n        integrationType === \"http\"\n          ? integration?.fetchTemplate || getDefaultFetchTemplate()\n          : getDefaultFetchTemplate(),\n\n      urlSchemeTemplate:\n        integrationType === \"url-scheme\"\n          ? integration?.urlSchemeTemplate || getDefaultURLSchemeTemplate()\n          : getDefaultURLSchemeTemplate(),\n      enabled: integration?.enabled ?? true,\n    }\n  }, [integration, commonIcons])\n\n  const formSchema = useMemo(() => createFormSchema(), [])\n\n  const form = useForm<FormData>({\n    resolver: zodResolver(formSchema),\n    defaultValues,\n  })\n\n  const onSubmit = useCallback(\n    (values: FormData) => {\n      try {\n        // Clean up data based on type - only include relevant template\n        const cleanedValues = {\n          id: integration?.id,\n          name: values.name,\n          icon: values.icon,\n          type: values.type,\n          enabled: values.enabled,\n          ...(values.type === \"http\"\n            ? { fetchTemplate: values.fetchTemplate }\n            : { urlSchemeTemplate: values.urlSchemeTemplate }),\n        }\n\n        onSave(cleanedValues)\n        toast.success(\n          integration\n            ? t(\"integration.custom_integrations.edit.success\")\n            : t(\"integration.custom_integrations.create.success\"),\n        )\n        dismiss()\n      } catch {\n        toast.error(\n          integration\n            ? t(\"integration.custom_integrations.edit.error\")\n            : t(\"integration.custom_integrations.create.error\"),\n        )\n      }\n    },\n    [onSave, integration, t, dismiss],\n  )\n\n  // Only watch essential fields for conditional rendering to minimize re-renders\n  const selectedType = form.watch(\"type\")\n  const selectedMethod = form.watch(\"fetchTemplate.method\") // Only watch method for body field display\n\n  // Computed values with minimal dependencies\n  const showBodyField = useMemo(\n    () => selectedMethod && [\"POST\", \"PUT\", \"PATCH\"].includes(selectedMethod),\n    [selectedMethod],\n  )\n\n  const shouldShowHTTPPreview = useMemo(() => selectedType === \"http\", [selectedType])\n\n  const shouldShowURLSchemePreview = useMemo(() => selectedType === \"url-scheme\", [selectedType])\n\n  // Get templates only when needed for preview\n  const getWatchedFetchTemplate = useCallback(() => {\n    if (!shouldShowHTTPPreview) return null\n    const template = form.getValues(\"fetchTemplate\")\n    return template?.url ? template : null\n  }, [form, shouldShowHTTPPreview])\n\n  const getWatchedURLSchemeTemplate = useCallback(() => {\n    if (!shouldShowURLSchemePreview) return null\n    const template = form.getValues(\"urlSchemeTemplate\")\n    return template?.scheme ? template : null\n  }, [form, shouldShowURLSchemePreview])\n\n  // Event handlers\n  const handleTypeChange = useCallback(\n    (onChange: (value: string) => void) => (value: string) => {\n      onChange(value)\n\n      // Clear templates for the type we're switching away from\n      if (value === \"http\") {\n        form.setValue(\"urlSchemeTemplate\", getDefaultURLSchemeTemplate())\n\n        const currentFetchTemplate = form.getValues(\"fetchTemplate\")\n        if (!currentFetchTemplate?.url && !currentFetchTemplate?.method) {\n          form.setValue(\"fetchTemplate\", getDefaultFetchTemplate())\n        }\n      } else if (value === \"url-scheme\") {\n        form.setValue(\"fetchTemplate\", getDefaultFetchTemplate())\n\n        const currentUrlSchemeTemplate = form.getValues(\"urlSchemeTemplate\")\n        if (!currentUrlSchemeTemplate?.scheme) {\n          form.setValue(\"urlSchemeTemplate\", getDefaultURLSchemeTemplate())\n        }\n      }\n\n      // Clear any existing validation errors\n      form.clearErrors()\n    },\n    [form],\n  )\n\n  const handleMethodChange = useCallback(\n    (onChange: (value: string) => void) => (value: string) => {\n      onChange(value)\n\n      const currentHeaders: Record<string, string> = form.getValues(\"fetchTemplate.headers\") || {}\n\n      if (value !== \"GET\") {\n        // Add default Content-Type header for non-GET methods\n        const hasContentType = Object.keys(currentHeaders).some(\n          (key) => key.toLowerCase() === \"content-type\",\n        )\n\n        if (!hasContentType) {\n          form.setValue(\"fetchTemplate.headers\", {\n            ...currentHeaders,\n            \"Content-Type\": \"application/json\",\n          })\n        }\n      } else {\n        // Remove Content-Type: application/json header for GET method\n        const filteredHeaders: Record<string, string> = {}\n        Object.entries(currentHeaders).forEach(([key, value]) => {\n          if (key.toLowerCase() !== \"content-type\" || value.toLowerCase() !== \"application/json\") {\n            filteredHeaders[key] = value\n          }\n        })\n        form.setValue(\"fetchTemplate.headers\", filteredHeaders)\n      }\n    },\n    [form],\n  )\n\n  // Memoized items\n  const integrationTypeItems = useMemo(\n    () => [\n      {\n        label: t(\"integration.custom_integrations.form.type.http\"),\n        value: \"http\" as const,\n      },\n      {\n        label: t(\"integration.custom_integrations.form.type.url_scheme\"),\n        value: \"url-scheme\" as const,\n      },\n    ],\n    [t],\n  )\n\n  const httpMethodItems = useMemo(\n    () =>\n      HTTP_METHODS.map((method) => ({\n        label: method,\n        value: method,\n      })),\n    [],\n  )\n\n  // Helper functions\n  const getTypeIcon = (type: string) => {\n    const typeConfig = INTEGRATION_TYPES.find((t) => t.value === type)\n    return typeConfig?.icon || \"i-mgc-world-2-cute-re\"\n  }\n\n  useEffect(() => {\n    nextFrame(() => {\n      form.setFocus(\"name\")\n    })\n  }, [form])\n\n  return (\n    <div className=\"flex max-h-[80vh] w-[500px] flex-col\">\n      {/* Scrollable Content */}\n      <div className=\"relative -mx-4 flex h-0 flex-1\">\n        <ScrollArea.ScrollArea flex rootClassName=\"flex-1\" viewportClassName=\"px-4\">\n          <div className=\"shrink-0 space-y-2 pb-4\">\n            <p className=\"text-sm text-text-secondary\">\n              {t(\"integration.custom_integrations.modal.description\")}\n            </p>\n            <PlaceholderHelp />\n          </div>\n\n          <div className=\"pr-3\">\n            <Form {...form}>\n              <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n                <FormField\n                  name=\"name\"\n                  render={({ field }) => (\n                    <FormItem>\n                      <FormLabel className=\"pl-2.5\">\n                        {t(\"integration.custom_integrations.form.name.label\")}\n                      </FormLabel>\n                      <FormControl>\n                        <MemoizedInput\n                          placeholder={t(\"integration.custom_integrations.form.name.placeholder\")}\n                          {...field}\n                          autoFocus\n                        />\n                      </FormControl>\n                      <FormMessage className=\"pl-2.5\" />\n                    </FormItem>\n                  )}\n                />\n\n                <FormField\n                  name=\"icon\"\n                  render={({ field }) => (\n                    <FormItem>\n                      <FormLabel className=\"pl-2.5\">\n                        {t(\"integration.custom_integrations.form.icon.label\")}\n                      </FormLabel>\n                      <FormControl>\n                        <IconSelector\n                          value={field.value}\n                          onChange={field.onChange}\n                          icons={commonIcons}\n                        />\n                      </FormControl>\n                      <FormDescription className=\"pl-2.5\">\n                        {t(\"integration.custom_integrations.form.icon.description\")}\n                      </FormDescription>\n                      <FormMessage className=\"pl-2.5\" />\n                    </FormItem>\n                  )}\n                />\n\n                {/* Integration Type */}\n                <FormField\n                  name=\"type\"\n                  render={({ field }) => (\n                    <FormItem>\n                      <FormLabel className=\"pl-2.5\">\n                        {t(\"integration.custom_integrations.form.type.label\")}\n                      </FormLabel>\n                      <FormControl>\n                        <TypeSelector\n                          value={field.value}\n                          onChange={handleTypeChange(field.onChange)}\n                          items={integrationTypeItems}\n                          getTypeIcon={getTypeIcon}\n                        />\n                      </FormControl>\n                      <FormDescription className=\"pl-2.5\">\n                        {t(\"integration.custom_integrations.form.type.description\")}\n                      </FormDescription>\n                      <FormMessage className=\"pl-2.5\" />\n                    </FormItem>\n                  )}\n                />\n\n                {/* HTTP Fields */}\n                {selectedType === \"http\" && (\n                  <>\n                    {/* HTTP Method */}\n                    <FormField\n                      name=\"fetchTemplate.method\"\n                      render={({ field }) => (\n                        <FormItem>\n                          <FormLabel className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.method.label\")}\n                          </FormLabel>\n                          <FormControl>\n                            <MethodSelector\n                              value={field.value}\n                              onChange={handleMethodChange(field.onChange)}\n                              items={httpMethodItems}\n                            />\n                          </FormControl>\n                          <FormDescription className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.method.description\")}\n                          </FormDescription>\n                          <FormMessage className=\"pl-2.5\" />\n                        </FormItem>\n                      )}\n                    />\n\n                    {/* URL */}\n                    <FormField\n                      name=\"fetchTemplate.url\"\n                      render={({ field }) => (\n                        <FormItem>\n                          <FormLabel className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.url.label\")}\n                          </FormLabel>\n                          <FormControl>\n                            <MemoizedInput\n                              placeholder={t(\n                                \"integration.custom_integrations.form.url.placeholder\",\n                              )}\n                              {...field}\n                            />\n                          </FormControl>\n                          <FormDescription className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.url.description\")}\n                          </FormDescription>\n                          <FormMessage className=\"pl-2.5\" />\n                        </FormItem>\n                      )}\n                    />\n\n                    {/* Headers */}\n                    <FormField\n                      name=\"fetchTemplate.headers\"\n                      render={({ field }) => (\n                        <FormItem>\n                          <FormLabel className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.headers.label\")}\n                          </FormLabel>\n                          <FormControl>\n                            <MemoizedKeyValueEditor\n                              value={field.value}\n                              onChange={field.onChange}\n                              keyPlaceholder={t(\n                                \"integration.custom_integrations.form.headers.key_placeholder\",\n                              )}\n                              valuePlaceholder={t(\n                                \"integration.custom_integrations.form.headers.value_placeholder\",\n                              )}\n                              addButtonText={t(\"integration.custom_integrations.form.headers.add\")}\n                            />\n                          </FormControl>\n                          <FormDescription className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.headers.description\")}\n                          </FormDescription>\n                          <FormMessage className=\"pl-2.5\" />\n                        </FormItem>\n                      )}\n                    />\n\n                    {/* Request Body (conditional) */}\n                    {showBodyField && (\n                      <FormField\n                        name=\"fetchTemplate.body\"\n                        render={({ field }) => (\n                          <FormItem>\n                            <FormLabel className=\"pl-2.5\">\n                              {t(\"integration.custom_integrations.form.body.label\")}\n                            </FormLabel>\n                            <FormControl>\n                              <MemoizedTextArea\n                                placeholder={t(\n                                  \"integration.custom_integrations.form.body.placeholder\",\n                                )}\n                                className=\"resize-none p-2.5 font-mono text-sm\"\n                                rows={4}\n                                {...field}\n                              />\n                            </FormControl>\n                            <FormDescription className=\"pl-2.5\">\n                              {t(\"integration.custom_integrations.form.body.description\")}\n                            </FormDescription>\n                            <FormMessage className=\"pl-2.5\" />\n                          </FormItem>\n                        )}\n                      />\n                    )}\n                  </>\n                )}\n\n                {/* URL Scheme Fields */}\n                {selectedType === \"url-scheme\" && (\n                  <>\n                    {/* URL Scheme */}\n                    <FormField\n                      name=\"urlSchemeTemplate.scheme\"\n                      render={({ field }) => (\n                        <FormItem>\n                          <FormLabel className=\"pl-2.5\">\n                            {t(\"integration.custom_integrations.form.scheme.label\", \"URL Scheme\")}\n                          </FormLabel>\n                          <FormControl>\n                            <MemoizedInput\n                              placeholder={t(\n                                \"integration.custom_integrations.form.scheme.placeholder\",\n                                \"e.g., obsidian://new?vault=MyVault&name=[title]&content=[content_markdown]\",\n                              )}\n                              {...field}\n                              className=\"font-mono text-sm\"\n                            />\n                          </FormControl>\n                          <FormDescription className=\"pl-2.5\">\n                            {t(\n                              \"integration.custom_integrations.form.scheme.description\",\n                              \"Enter the URL scheme for the external application. Use placeholders like [title], [url], [content_markdown], etc.\",\n                            )}\n                          </FormDescription>\n                          <FormMessage className=\"pl-2.5\" />\n                        </FormItem>\n                      )}\n                    />\n                  </>\n                )}\n\n                {/* Template Preview */}\n                {shouldShowHTTPPreview &&\n                  (() => {\n                    const template = getWatchedFetchTemplate()\n                    return template ? (\n                      <CustomIntegrationPreview\n                        key=\"http-preview\"\n                        fetchTemplate={template}\n                        className=\"border-t pt-4\"\n                      />\n                    ) : null\n                  })()}\n                {shouldShowURLSchemePreview &&\n                  (() => {\n                    const template = getWatchedURLSchemeTemplate()\n                    return template ? (\n                      <URLSchemePreview\n                        key=\"url-scheme-preview\"\n                        urlSchemeTemplate={template}\n                        className=\"border-t pt-4\"\n                      />\n                    ) : null\n                  })()}\n              </form>\n            </Form>\n          </div>\n        </ScrollArea.ScrollArea>\n      </div>\n\n      {/* Static Footer */}\n      <div className=\"mt-4 flex shrink-0 justify-end gap-2 border-t border-fill-secondary pt-4\">\n        <Button variant=\"outline\" type=\"button\" onClick={dismiss}>\n          {t(\"words.cancel\", { ns: \"common\" })}\n        </Button>\n        <Button\n          type=\"button\"\n          disabled={form.formState.isSubmitting}\n          onClick={form.handleSubmit(onSubmit)}\n        >\n          {integration ? t(\"words.save\", { ns: \"common\" }) : t(\"words.create\", { ns: \"common\" })}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationSection.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { CustomIntegration } from \"@follow/shared/settings/interface\"\nimport { nanoid } from \"nanoid\"\nimport { memo, useCallback, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { setIntegrationSetting, useIntegrationSettingValue } from \"~/atoms/settings/integration\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { createSetting } from \"../../helper/builder\"\nimport { SettingSectionTitle } from \"../../section\"\nimport { CustomIntegrationModalContent } from \"./CustomIntegrationModal\"\n\nconst { defineSettingItem, SettingBuilder } = createSetting(\n  \"integration\",\n  useIntegrationSettingValue,\n  setIntegrationSetting,\n)\n\ninterface CustomIntegrationSectionProps {\n  searchQuery: string\n}\n\nexport const CustomIntegrationSection = memo(({ searchQuery }: CustomIntegrationSectionProps) => {\n  const settings = useIntegrationSettingValue()\n  const { present } = useModalStack()\n\n  const { t } = useTranslation(\"settings\")\n\n  // Check if custom integrations match search\n  const customIntegrationsMatchesSearch = useMemo(() => {\n    if (!searchQuery) return true\n\n    const query = searchQuery.toLowerCase()\n\n    // Check section title and related translations\n    const sectionTitle = t(\"integration.custom_integrations.title\") as string\n    const enableLabel = t(\"integration.custom_integrations.enable.label\") as string\n    const enableDescription = t(\"integration.custom_integrations.enable.description\") as string\n\n    // Check if query matches section-related content\n    if (\n      sectionTitle.includes(query) ||\n      enableLabel.includes(query) ||\n      enableDescription.includes(query) ||\n      \"custom\".includes(query) ||\n      \"action\".includes(query) ||\n      \"actions\".includes(query) ||\n      \"integration\".includes(query) ||\n      \"url\".includes(query) ||\n      \"template\".includes(query) ||\n      \"share\".includes(query) ||\n      \"sharing\".includes(query)\n    ) {\n      return true\n    }\n\n    // Check existing custom integrations\n    const customIntegrations = settings.customIntegration || []\n    return customIntegrations.some(\n      (integration) =>\n        integration.name.toLowerCase().includes(query) ||\n        integration.fetchTemplate?.url?.toLowerCase().includes(query) ||\n        integration.fetchTemplate?.method?.toLowerCase().includes(query) ||\n        Object.keys(integration.fetchTemplate?.headers || {}).some(\n          (key) =>\n            key.toLowerCase().includes(query) ||\n            integration.fetchTemplate?.headers?.[key]?.toLowerCase().includes(query) ||\n            false,\n        ) ||\n        (integration.fetchTemplate?.body &&\n          integration.fetchTemplate.body.toLowerCase().includes(query)) ||\n        (integration.type === \"url-scheme\" &&\n          integration.urlSchemeTemplate?.scheme?.toLowerCase().includes(query)),\n    )\n  }, [searchQuery, t, settings.customIntegration])\n\n  const handleCreateCustomIntegration = useCallback(() => {\n    present({\n      title: t(\"integration.custom_integrations.create.title\"),\n      content: () => (\n        <CustomIntegrationModalContent\n          onSave={(integrationData) => {\n            const newIntegration: CustomIntegration = {\n              ...integrationData,\n              id: nanoid(),\n            }\n\n            const currentIntegrations = settings.customIntegration || []\n            setIntegrationSetting(\"customIntegration\", [...currentIntegrations, newIntegration])\n          }}\n        />\n      ),\n    })\n  }, [present, settings.customIntegration, t])\n\n  const handleEditCustomIntegration = useCallback(\n    (integration: CustomIntegration) => {\n      present({\n        title: t(\"integration.custom_integrations.edit.title\"),\n        content: () => (\n          <CustomIntegrationModalContent\n            integration={integration}\n            onSave={(integrationData) => {\n              const currentIntegrations = settings.customIntegration || []\n              const updatedIntegrations = currentIntegrations.map((i) =>\n                i.id === integration.id ? { ...i, ...integrationData } : i,\n              )\n              setIntegrationSetting(\"customIntegration\", updatedIntegrations)\n            }}\n          />\n        ),\n      })\n    },\n    [present, settings.customIntegration, t],\n  )\n\n  const handleDeleteCustomIntegration = useCallback(\n    (integrationId: string) => {\n      const currentIntegrations = settings.customIntegration || []\n      const updatedIntegrations = currentIntegrations.filter((i) => i.id !== integrationId)\n      setIntegrationSetting(\"customIntegration\", updatedIntegrations)\n      toast.success(t(\"integration.custom_integrations.delete.success\"))\n    },\n    [settings.customIntegration, t],\n  )\n\n  const handleToggleCustomIntegration = useCallback(\n    (integrationId: string, enabled: boolean) => {\n      const currentIntegrations = settings.customIntegration || []\n      const updatedIntegrations = currentIntegrations.map((i) =>\n        i.id === integrationId ? { ...i, enabled } : i,\n      )\n      setIntegrationSetting(\"customIntegration\", updatedIntegrations)\n    },\n    [settings.customIntegration],\n  )\n\n  if (settings.enableCustomIntegration && !customIntegrationsMatchesSearch) {\n    return (\n      <div className=\"text-center\">\n        <i className=\"i-mgc-webhook-cute-re mb-3 text-2xl text-text-tertiary\" />\n        <p className=\"mb-2 text-sm font-medium text-text-tertiary\">No custom integration found</p>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"relative\">\n      <div className=\"flex items-center justify-between\">\n        <SettingSectionTitle title={t(\"integration.custom_integrations.title\")} />\n      </div>\n\n      <div className=\"space-y-6\">\n        <SettingBuilder\n          settings={[\n            defineSettingItem(\"enableCustomIntegration\", {\n              label: t(\"integration.custom_integrations.enable.label\"),\n              description: t(\"integration.custom_integrations.enable.description\"),\n            }),\n          ]}\n        />\n\n        {settings.enableCustomIntegration && (\n          <CustomIntegrationsSection\n            integrations={settings.customIntegration || []}\n            onCreateIntegration={handleCreateCustomIntegration}\n            onEditIntegration={handleEditCustomIntegration}\n            onDeleteIntegration={handleDeleteCustomIntegration}\n            onToggleIntegration={handleToggleCustomIntegration}\n          />\n        )}\n      </div>\n    </div>\n  )\n})\n\ninterface CustomIntegrationsSectionProps {\n  integrations: CustomIntegration[]\n  onCreateIntegration: () => void\n  onEditIntegration: (integration: CustomIntegration) => void\n  onDeleteIntegration: (integrationId: string) => void\n  onToggleIntegration: (integrationId: string, enabled: boolean) => void\n}\n\nconst CustomIntegrationsSection = ({\n  integrations,\n  onCreateIntegration,\n  onEditIntegration,\n  onDeleteIntegration,\n  onToggleIntegration,\n}: CustomIntegrationsSectionProps) => {\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center justify-between\">\n        <span className=\"text-sm font-medium leading-none\">\n          {t(\"integration.custom_integrations.list.title\")}\n        </span>\n        <Button\n          size=\"sm\"\n          variant=\"outline\"\n          onClick={onCreateIntegration}\n          buttonClassName=\"flex items-center\"\n        >\n          <i className=\"i-mgc-add-cute-re mr-2\" />\n          {t(\"integration.custom_integrations.add.button\")}\n        </Button>\n      </div>\n\n      {integrations.length === 0 ? (\n        <div className=\"flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-fill-secondary py-12\">\n          <i className=\"i-mgc-webhook-cute-re mb-3 text-2xl text-text-tertiary\" />\n          <p className=\"mb-2 text-sm font-medium text-text-tertiary\">\n            {t(\"integration.custom_integrations.list.empty.title\")}\n          </p>\n          <p className=\"mb-4 max-w-xs text-center text-xs text-text-quaternary\">\n            {t(\"integration.custom_integrations.list.empty.description\")}\n          </p>\n          <Button size=\"sm\" onClick={onCreateIntegration} buttonClassName=\"flex items-center gap-2\">\n            <i className=\"i-mgc-add-cute-re\" />\n            {t(\"integration.custom_integrations.list.empty.button\")}\n          </Button>\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          {integrations.map((integration) => (\n            <div\n              key={integration.id}\n              className=\"flex items-center gap-4 border-b border-fill-secondary pb-4 last:border-b-0 last:pb-0\"\n            >\n              <span className=\"inline-flex items-center justify-center text-text-secondary\">\n                <i className={integration.icon} />\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"mb-1 flex items-center gap-2\">\n                  <span className=\"font-medium text-text\">{integration.name}</span>\n                  {integration.enabled ? (\n                    <span className=\"inline-flex items-center gap-1 rounded-full bg-green/10 px-2 py-0.5 text-xs text-green\">\n                      <i className=\"i-mingcute-power-line\" />\n                      <span>{t(\"integration.status.enabled\")}</span>\n                    </span>\n                  ) : (\n                    <span className=\"inline-flex items-center gap-1 rounded-full bg-gray/10 px-2 py-0.5 text-xs text-gray\">\n                      <i className=\"i-mgc-pause-cute-re\" />\n                      <span>{t(\"integration.custom_integrations.status.disabled\")}</span>\n                    </span>\n                  )}\n                </div>\n                <p className=\"truncate text-xs text-text-tertiary\">\n                  <span className=\"mr-2 rounded bg-fill px-1.5 py-0.5 font-mono text-xs text-text-secondary\">\n                    {integration.type === \"url-scheme\"\n                      ? \"URL\"\n                      : integration.fetchTemplate?.method || \"GET\"}\n                  </span>\n                  {integration.type === \"url-scheme\"\n                    ? integration.urlSchemeTemplate?.scheme\n                    : integration.fetchTemplate?.url}\n                </p>\n              </div>\n              <div className=\"flex items-center gap-1\">\n                <Button\n                  size=\"sm\"\n                  variant=\"ghost\"\n                  onClick={() => onToggleIntegration(integration.id, !integration.enabled)}\n                  buttonClassName=\"size-8 p-0\"\n                  aria-label={\n                    integration.enabled\n                      ? t(\"integration.custom_integrations.actions.disable\")\n                      : t(\"integration.custom_integrations.actions.enable\")\n                  }\n                >\n                  <i\n                    className={integration.enabled ? \"i-mgc-pause-cute-re\" : \"i-mgc-play-cute-re\"}\n                  />\n                </Button>\n                <Button\n                  size=\"sm\"\n                  variant=\"ghost\"\n                  onClick={() => onEditIntegration(integration)}\n                  buttonClassName=\"size-8 p-0\"\n                  aria-label={t(\"integration.custom_integrations.actions.edit\")}\n                >\n                  <i className=\"i-mgc-edit-cute-re\" />\n                </Button>\n                <Button\n                  size=\"sm\"\n                  variant=\"ghost\"\n                  onClick={() => onDeleteIntegration(integration.id)}\n                  buttonClassName=\"size-8 p-0\"\n                  aria-label={t(\"integration.custom_integrations.actions.delete\")}\n                >\n                  <i className=\"i-mgc-delete-2-cute-re\" />\n                </Button>\n              </div>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { CollapseCss, CollapseCssGroup } from \"@follow/components/ui/collapse/CollapseCss.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport { InputV2 } from \"@follow/components/ui/input/index.js\"\nimport {\n  SimpleIconsCubox,\n  SimpleIconsEagle,\n  SimpleIconsInstapaper,\n  SimpleIconsObsidian,\n  SimpleIconsOutline,\n  SimpleIconsReadeck,\n  SimpleIconsReadwise,\n  SimpleIconsZotero,\n} from \"@follow/components/ui/platform-icon/icons.js\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport {\n  getIntegrationSettings,\n  setIntegrationSetting,\n  useIntegrationSettingValue,\n} from \"~/atoms/settings/integration\"\nimport { downloadJsonFile, selectJsonFile } from \"~/lib/export\"\nimport { getFetchAdapter } from \"~/modules/integration/fetch-adapter\"\n\nimport { createSetting } from \"../../helper/builder\"\nimport { useSetSettingCanSync } from \"../../modal/hooks\"\nimport { SettingSectionTitle } from \"../../section\"\nimport { CustomIntegrationSection } from \"./CustomIntegrationSection\"\n\nconst { defineSettingItem, SettingBuilder } = createSetting(\n  \"integration\",\n  useIntegrationSettingValue,\n  setIntegrationSetting,\n)\nexport const SettingIntegration = () => {\n  const { t } = useTranslation(\"settings\")\n  const setSync = useSetSettingCanSync()\n  const [searchQuery, setSearchQuery] = useState(\"\")\n  const settings = useIntegrationSettingValue()\n\n  useEffect(() => {\n    setSync(false)\n    return () => {\n      setSync(true)\n    }\n  }, [setSync])\n\n  const integrationCategories = useMemo(() => {\n    const knowledgeManagement = {\n      id: \"knowledge\",\n      title: t(\"integration.categories.knowledge_management\"),\n      icon: <i className=\"i-mingcute-document-line\" />,\n      integrations: [\n        {\n          key: \"cubox\",\n          title: t(\"integration.cubox.title\"),\n          icon: <SimpleIconsCubox />,\n          enabled: settings.enableCubox,\n          configured: Boolean(settings.cuboxToken),\n          settings: [\n            defineSettingItem(\"enableCubox\", {\n              label: t(\"integration.cubox.enable.label\"),\n              description: t(\"integration.cubox.enable.description\"),\n            }),\n            defineSettingItem(\"cuboxToken\", {\n              label: t(\"integration.cubox.token.label\"),\n              vertical: true,\n              type: \"password\",\n              description: (\n                <>\n                  {t(\"integration.cubox.token.description\")}{\" \"}\n                  <a\n                    target=\"_blank\"\n                    className=\"underline\"\n                    rel=\"noreferrer noopener\"\n                    href=\"https://cubox.pro/my/settings/extensions\"\n                  >\n                    https://cubox.pro/my/settings/extensions\n                  </a>\n                </>\n              ),\n            }),\n            defineSettingItem(\"enableCuboxAutoMemo\", {\n              label: t(\"integration.cubox.autoMemo.label\"),\n              description: t(\"integration.cubox.autoMemo.description\"),\n            }),\n          ],\n        },\n        {\n          key: \"obsidian\",\n          title: t(\"integration.obsidian.title\"),\n          icon: <SimpleIconsObsidian />,\n          enabled: settings.enableObsidian,\n          configured: Boolean(settings.obsidianVaultPath),\n          settings: [\n            defineSettingItem(\"enableObsidian\", {\n              label: t(\"integration.obsidian.enable.label\"),\n              description: t(\"integration.obsidian.enable.description\"),\n            }),\n            defineSettingItem(\"obsidianVaultPath\", {\n              label: t(\"integration.obsidian.vaultPath.label\"),\n              vertical: true,\n              description: t(\"integration.obsidian.vaultPath.description\"),\n            }),\n          ],\n        },\n        {\n          key: \"outline\",\n          title: t(\"integration.outline.title\"),\n          icon: <SimpleIconsOutline />,\n          enabled: settings.enableOutline,\n          configured: Boolean(settings.outlineEndpoint && settings.outlineToken),\n          settings: [\n            defineSettingItem(\"enableOutline\", {\n              label: t(\"integration.outline.enable.label\"),\n              description: t(\"integration.outline.enable.description\"),\n            }),\n            defineSettingItem(\"outlineEndpoint\", {\n              label: t(\"integration.outline.endpoint.label\"),\n              vertical: true,\n              description: t(\"integration.outline.endpoint.description\"),\n            }),\n            defineSettingItem(\"outlineToken\", {\n              label: t(\"integration.outline.token.label\"),\n              vertical: true,\n              type: \"password\",\n              description: t(\"integration.outline.token.description\"),\n            }),\n            defineSettingItem(\"outlineCollection\", {\n              label: t(\"integration.outline.collection.label\"),\n              vertical: true,\n              description: t(\"integration.outline.collection.description\"),\n            }),\n          ],\n        },\n        {\n          key: \"readwise\",\n          title: t(\"integration.readwise.title\"),\n          icon: <SimpleIconsReadwise />,\n          enabled: settings.enableReadwise,\n          configured: Boolean(settings.readwiseToken),\n          settings: [\n            defineSettingItem(\"enableReadwise\", {\n              label: t(\"integration.readwise.enable.label\"),\n              description: t(\"integration.readwise.enable.description\"),\n            }),\n            defineSettingItem(\"readwiseToken\", {\n              label: t(\"integration.readwise.token.label\"),\n              vertical: true,\n              type: \"password\",\n              description: (\n                <>\n                  {t(\"integration.readwise.token.description\")}{\" \"}\n                  <a\n                    target=\"_blank\"\n                    className=\"underline\"\n                    rel=\"noreferrer noopener\"\n                    href=\"https://readwise.io/access_token\"\n                  >\n                    readwise.io/access_token\n                  </a>\n                  .\n                </>\n              ),\n            }),\n          ],\n        },\n        {\n          key: \"zotero\",\n          title: t(\"integration.zotero.title\"),\n          icon: <SimpleIconsZotero />,\n          enabled: settings.enableZotero,\n          configured: Boolean(settings.zoteroUserID && settings.zoteroToken),\n          settings: [\n            defineSettingItem(\"enableZotero\", {\n              label: t(\"integration.zotero.enable.label\"),\n              description: t(\"integration.zotero.enable.description\"),\n            }),\n            defineSettingItem(\"zoteroUserID\", {\n              label: t(\"integration.zotero.userID.label\"),\n              description: (\n                <>\n                  {t(\"integration.zotero.userID.description\")}{\" \"}\n                  <a\n                    target=\"_blank\"\n                    className=\"underline\"\n                    rel=\"noreferrer noopener\"\n                    href=\"https://www.zotero.org/settings/keys\"\n                  >\n                    https://www.zotero.org/settings/keys\n                  </a>\n                </>\n              ),\n              vertical: true,\n              type: \"password\",\n            }),\n            defineSettingItem(\"zoteroToken\", {\n              label: t(\"integration.zotero.token.label\"),\n              description: (\n                <>\n                  {t(\"integration.zotero.token.description\")}{\" \"}\n                  <a\n                    target=\"_blank\"\n                    className=\"underline\"\n                    rel=\"noreferrer noopener\"\n                    href=\"https://www.zotero.org/settings/keys/new\"\n                  >\n                    https://www.zotero.org/settings/keys/new\n                  </a>\n                </>\n              ),\n              vertical: true,\n              type: \"password\",\n            }),\n          ],\n        },\n      ],\n    }\n\n    const readingServices = {\n      id: \"reading\",\n      title: t(\"integration.categories.reading_services\"),\n      icon: <i className=\"i-mingcute-book-line\" />,\n      integrations: [\n        {\n          key: \"instapaper\",\n          title: t(\"integration.instapaper.title\"),\n          icon: <SimpleIconsInstapaper />,\n          enabled: settings.enableInstapaper,\n          configured: Boolean(settings.instapaperUsername && settings.instapaperPassword),\n          settings: [\n            defineSettingItem(\"enableInstapaper\", {\n              label: t(\"integration.instapaper.enable.label\"),\n              description: t(\"integration.instapaper.enable.description\"),\n            }),\n            defineSettingItem(\"instapaperUsername\", {\n              label: t(\"integration.instapaper.username.label\"),\n              vertical: true,\n            }),\n            defineSettingItem(\"instapaperPassword\", {\n              label: t(\"integration.instapaper.password.label\"),\n              vertical: true,\n              type: \"password\",\n            }),\n          ],\n        },\n        {\n          key: \"readeck\",\n          title: t(\"integration.readeck.title\"),\n          icon: <SimpleIconsReadeck />,\n          enabled: settings.enableReadeck,\n          configured: Boolean(settings.readeckEndpoint && settings.readeckToken),\n          settings: [\n            defineSettingItem(\"enableReadeck\", {\n              label: t(\"integration.readeck.enable.label\"),\n              description: t(\"integration.readeck.enable.description\"),\n            }),\n            defineSettingItem(\"readeckEndpoint\", {\n              label: t(\"integration.readeck.endpoint.label\"),\n              vertical: true,\n              description: t(\"integration.readeck.endpoint.description\"),\n            }),\n            defineSettingItem(\"readeckToken\", {\n              label: t(\"integration.readeck.token.label\"),\n              vertical: true,\n              type: \"password\",\n              description: t(\"integration.readeck.token.description\"),\n            }),\n          ],\n        },\n      ],\n    }\n\n    const mediaTools = {\n      id: \"media\",\n      title: t(\"integration.categories.media_tools\"),\n      icon: <i className=\"i-mingcute-pic-line\" />,\n      integrations: [\n        {\n          key: \"eagle\",\n          title: t(\"integration.eagle.title\"),\n          icon: <SimpleIconsEagle />,\n          enabled: settings.enableEagle,\n          configured: settings.enableEagle,\n          settings: [\n            defineSettingItem(\"enableEagle\", {\n              label: t(\"integration.eagle.enable.label\"),\n              description: t(\"integration.eagle.enable.description\"),\n            }),\n          ],\n        },\n      ],\n    }\n\n    const downloadTools = {\n      id: \"download\",\n      title: t(\"integration.categories.download_tools\"),\n      icon: <i className=\"i-mingcute-download-line\" />,\n      integrations: [\n        {\n          key: \"qbittorrent\",\n          title: t(\"integration.qbittorrent.title\"),\n          icon: <i className=\"i-simple-icons-qbittorrent\" />,\n          enabled: settings.enableQBittorrent,\n          configured: Boolean(settings.qbittorrentHost && settings.qbittorrentUsername),\n          settings: [\n            defineSettingItem(\"enableQBittorrent\", {\n              label: t(\"integration.qbittorrent.enable.label\"),\n              description: t(\"integration.qbittorrent.enable.description\"),\n            }),\n            defineSettingItem(\"qbittorrentHost\", {\n              label: t(\"integration.qbittorrent.host.label\"),\n              vertical: true,\n              description: t(\"integration.qbittorrent.host.description\"),\n            }),\n            defineSettingItem(\"qbittorrentUsername\", {\n              label: t(\"integration.qbittorrent.username.label\"),\n              vertical: true,\n            }),\n            defineSettingItem(\"qbittorrentPassword\", {\n              label: t(\"integration.qbittorrent.password.label\"),\n              vertical: true,\n              type: \"password\",\n            }),\n          ],\n        },\n      ],\n    }\n\n    return [knowledgeManagement, readingServices, mediaTools, downloadTools]\n  }, [t, settings])\n\n  const filteredIntegrations = useMemo(() => {\n    const allIntegrations = integrationCategories.flatMap((category) =>\n      category.integrations.map((integration) => ({\n        ...integration,\n        categoryTitle: category.title,\n        categoryIcon: category.icon,\n      })),\n    )\n\n    if (!searchQuery) return allIntegrations\n\n    return allIntegrations.filter((integration) => {\n      const matchesSearch = searchQuery\n        ? (integration.title as string).toLowerCase().includes(searchQuery.toLowerCase()) ||\n          integration.key.toString().toLowerCase().includes(searchQuery.toLowerCase())\n        : true\n      return matchesSearch\n    })\n  }, [integrationCategories, searchQuery])\n\n  const shouldDefaultOpen = useCallback((integration: (typeof filteredIntegrations)[0]) => {\n    return integration.configured\n  }, [])\n\n  return (\n    <div className=\"mt-4 space-y-8\">\n      {/* Search Bar */}\n      <div className=\"max-w-md\">\n        <InputV2\n          icon={<i className=\"i-mgc-search-cute-re\" />}\n          canClear\n          placeholder={t(\"integration.search.placeholder\")}\n          value={searchQuery}\n          onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n            setSearchQuery(e.target.value)\n          }, [])}\n          className=\"h-9\"\n          aria-label=\"Search integrations\"\n        />\n      </div>\n\n      {/* General Section */}\n      <div>\n        <SettingSectionTitle title={t(\"integration.general\")} />\n        <SettingBuilder\n          settings={[\n            defineSettingItem(\"saveSummaryAsDescription\", {\n              label: t(\"integration.save_ai_summary_as_description.label\"),\n            }),\n            // Only show browser fetch setting in Electron environment\n            ...(IN_ELECTRON\n              ? [\n                  defineSettingItem(\"useBrowserFetch\", {\n                    label: t(\"integration.use_browser_fetch.label\"),\n                    description: t(\"integration.use_browser_fetch.description\"),\n                    onAfterChange: (value) => {\n                      if (value) {\n                        getFetchAdapter().preferClientFetch()\n                      } else {\n                        getFetchAdapter().preferElectronFetch()\n                      }\n                    },\n                  }),\n                ]\n              : []),\n          ]}\n        />\n      </div>\n\n      <Divider />\n\n      {/* Built-in Integration Section */}\n      {filteredIntegrations.length > 0 ? (\n        <>\n          <div className=\"space-y-6\">\n            <div className=\"flex items-center justify-between\">\n              <SettingSectionTitle title={t(\"integration.builtin.title\")} />\n              <span className=\"flex items-center gap-1 text-sm text-text-tertiary\">\n                <span className=\"size-2 rounded-full bg-green\" />\n                {filteredIntegrations.filter((i) => i.configured).length}/\n                {filteredIntegrations.length} configured\n              </span>\n            </div>\n\n            <CollapseCssGroup>\n              <div className=\"space-y-4\">\n                {filteredIntegrations.map((integration) => (\n                  <CollapseCss\n                    key={integration.key}\n                    collapseId={integration.key}\n                    title={\n                      <div className=\"flex items-center gap-3\">\n                        <span className=\"inline-flex items-center justify-center text-text-secondary\">\n                          {integration.icon}\n                        </span>\n                        <div className=\"flex flex-col items-start\">\n                          <span className=\"font-medium\">{integration.title as string}</span>\n                          <span className=\"text-xs text-text-tertiary\">\n                            {integration.categoryTitle as string}\n                          </span>\n                        </div>\n                        <div className=\"ml-auto flex items-center gap-2\">\n                          {integration.configured && (\n                            <span className=\"inline-flex items-center gap-1 rounded-full bg-green/10 px-2 py-0.5 text-xs text-green\">\n                              <i className=\"i-mingcute-check-line\" />\n                              {t(\"integration.status.configured\")}\n                            </span>\n                          )}\n                          {integration.enabled && (\n                            <span className=\"inline-flex items-center gap-1 rounded-full bg-blue/10 px-2 py-0.5 text-xs text-blue\">\n                              <i className=\"i-mingcute-power-line\" />\n                              {t(\"integration.status.enabled\")}\n                            </span>\n                          )}\n                        </div>\n                      </div>\n                    }\n                    defaultOpen={shouldDefaultOpen(integration)}\n                    className=\"mt-4 rounded-lg border border-border bg-background px-4 py-2 shadow-sm\"\n                    contentClassName=\"px-4\"\n                  >\n                    <div className=\"pb-4\">\n                      <SettingBuilder settings={integration.settings} />\n                    </div>\n                  </CollapseCss>\n                ))}\n              </div>\n            </CollapseCssGroup>\n          </div>\n        </>\n      ) : (\n        <div className=\"text-center\">\n          <i className=\"i-mingcute-document-line mb-3 text-2xl text-text-tertiary\" />\n          <p className=\"mb-2 text-sm font-medium text-text-tertiary\">\n            No built-in integration found\n          </p>\n        </div>\n      )}\n\n      <Divider />\n\n      {/* Custom Integration Section */}\n      <CustomIntegrationSection searchQuery={searchQuery} />\n\n      <BottomTip />\n    </div>\n  )\n}\n\nconst BottomTip = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const handleExport = useCallback(() => {\n    try {\n      const settings = getIntegrationSettings()\n      const jsonData = JSON.stringify(settings, null, 2)\n      const filename = `follow-integration-settings-${new Date().toISOString().split(\"T\")[0]}.json`\n      downloadJsonFile(jsonData, filename)\n      toast.success(t(\"integration.export.success\"))\n    } catch (error) {\n      console.error(\"Failed to export integration settings:\", error)\n      toast.error(t(\"integration.export.error\"))\n    }\n  }, [t])\n\n  const handleImport = useCallback(async () => {\n    try {\n      const jsonData = await selectJsonFile()\n      const settings = JSON.parse(jsonData)\n\n      // Validate the imported settings structure\n      if (typeof settings !== \"object\" || settings === null) {\n        throw new Error(\"Invalid settings format\")\n      }\n\n      // Get current settings to use as a base for validation\n      const currentSettings = getIntegrationSettings()\n\n      // Only apply settings that exist in the current schema\n      let importCount = 0\n      Object.entries(settings).forEach(([key, value]) => {\n        if (key in currentSettings) {\n          setIntegrationSetting(key as any, value)\n          importCount++\n        }\n      })\n\n      if (importCount === 0) {\n        throw new Error(\"No valid settings found in the imported file\")\n      }\n\n      toast.success(t(\"integration.import.success\"))\n    } catch (error) {\n      if (error instanceof Error && error.message === \"No file selected\") {\n        // User cancelled file selection, don't show error\n        return\n      }\n      console.error(\"Failed to import integration settings:\", error)\n      if (error instanceof SyntaxError) {\n        toast.error(t(\"integration.import.invalid\"))\n      } else {\n        toast.error(t(\"integration.import.error\"))\n      }\n    }\n  }, [t])\n\n  return (\n    <div className=\"mt-6 space-y-4\">\n      <Divider />\n      <div className=\"flex flex-col gap-3\">\n        <p className=\"text-text-tertiary\">\n          <small>{t(\"integration.tip\")}</small>\n        </p>\n        <div className=\"flex gap-2\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleExport}\n            buttonClassName=\"flex items-center gap-2\"\n          >\n            <i className=\"i-mgc-download-2-cute-re mr-2 size-4\" />\n            {t(\"integration.export.button\")}\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleImport}\n            buttonClassName=\"flex items-center gap-2\"\n          >\n            <i className=\"i-mgc-file-upload-cute-re mr-2 size-4\" />\n            {t(\"integration.import.button\")}\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/lists/hooks.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useBatchUpdateSubscription } from \"~/hooks/biz/useSubscriptionActions\"\n\nimport { CategoryCreationModalContent } from \"./modals\"\n\nexport const useCategoryCreationModal = () => {\n  const { t } = useTranslation()\n  const { present } = useModalStack()\n  const { mutate: addFeedsToCategoryMutation } = useBatchUpdateSubscription()\n  return useCallback(\n    (view: FeedViewType, feedIds: string[]) =>\n      present({\n        title: t(\"sidebar.feed_column.context_menu.title\"),\n        content: () => (\n          <CategoryCreationModalContent\n            onSubmit={(category: string) => {\n              addFeedsToCategoryMutation({\n                feedIdList: feedIds,\n                category,\n                view: view!,\n              })\n            }}\n          />\n        ),\n      }),\n    [addFeedsToCategoryMutation, present, t],\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx",
    "content": "import { Avatar, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { ActionButton, Button } from \"@follow/components/ui/button/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@follow/components/ui/table/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { getView } from \"@follow/constants\"\nimport { useOwnedLists, usePrefetchLists } from \"@follow/store/list/hooks\"\nimport { listSyncServices } from \"@follow/store/list/store\"\nimport { cn, formatNumber } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useMemo } from \"react\"\nimport { toast } from \"sonner\"\n\nimport { useCurrentModal, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useI18n } from \"~/hooks/common\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\n\nimport { ListCreationModalContent, ListFeedsModalContent } from \"./modals\"\n\nconst ConfirmDestroyModalContent = ({ listId }: { listId: string }) => {\n  const t = useI18n()\n  const currentModal = useCurrentModal()\n\n  const deleteFeedList = useMutation({\n    mutationFn: (payload: { listId: string }) => listSyncServices.deleteList(payload.listId),\n    onSuccess: () => {\n      toast.success(t.settings(\"lists.delete.success\"))\n    },\n    onError() {\n      toast.error(t.settings(\"lists.delete.error\"))\n    },\n    onMutate() {\n      currentModal?.dismiss()\n    },\n  })\n\n  return (\n    <div className=\"w-[540px]\">\n      <div className=\"mb-4\">\n        <i className=\"i-mingcute-warning-fill -mb-1 mr-1 size-5 text-red-500\" />\n        {t.settings(\"lists.delete.warning\")}\n      </div>\n      <div className=\"flex justify-end\">\n        <Button buttonClassName=\"bg-red-600\" onClick={() => deleteFeedList.mutate({ listId })}>\n          {t(\"words.confirm\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nexport const SettingLists = () => {\n  const t = useI18n()\n  const { isLoading } = usePrefetchLists()\n  const ownedLists = useOwnedLists()\n  const listDataMap = useMemo(() => {\n    if (!ownedLists) return {}\n    return ownedLists?.reduce(\n      (acc, curr) => {\n        acc[curr.id] = {\n          id: curr.id,\n          subscriptionCount: curr.subscriptionCount,\n        }\n        return acc\n      },\n      {} as Record<\n        string,\n        {\n          id: string\n          subscriptionCount: number | null | undefined\n        }\n      >,\n    )\n  }, [ownedLists])\n\n  const { present } = useModalStack()\n\n  return (\n    <section className=\"mt-4\">\n      <div className=\"mb-4 space-y-2 text-sm\">\n        <p>{t.settings(\"lists.info\")}</p>\n      </div>\n      <Button\n        onClick={() => {\n          present({\n            title: t.settings(\"lists.create\"),\n            content: () => <ListCreationModalContent />,\n          })\n        }}\n      >\n        <i className=\"i-mgc-add-cute-re mr-1 text-base\" />\n        {t.settings(\"lists.create\")}\n      </Button>\n      <Divider className=\"mb-6 mt-8\" />\n      <div className=\"flex flex-1 flex-col\">\n        {isLoading && ownedLists.length === 0 && (\n          <LoadingCircle size=\"large\" className=\"center absolute inset-0\" />\n        )}\n\n        {isLoading && ownedLists.length > 0 && (\n          <LoadingCircle size=\"small\" className=\"center absolute right-0\" />\n        )}\n        {!!ownedLists && (\n          <ScrollArea.ScrollArea viewportClassName=\"max-h-[380px]\">\n            {ownedLists.length > 0 ? (\n              <div className=\"overflow-auto\">\n                <Table className=\"mt-4\">\n                  <TableHeader className=\"border-b\">\n                    <TableRow className=\"[&_*]:!font-semibold\">\n                      <TableHead size=\"sm\">{t.settings(\"lists.title\")}</TableHead>\n                      <TableHead size=\"sm\">{t.settings(\"lists.view\")}</TableHead>\n                      <TableHead size=\"sm\">{t.settings(\"lists.subscriptions\")}</TableHead>\n                      <TableHead size=\"sm\" className=\"center\">\n                        {t.common(\"words.actions\")}\n                      </TableHead>\n                    </TableRow>\n                  </TableHeader>\n                  <TableBody className=\"border-t-[12px] border-transparent [&_td]:!px-3\">\n                    {ownedLists.map((row) => (\n                      <TableRow key={row.title} className=\"h-8\">\n                        <TableCell size=\"sm\">\n                          <a\n                            target=\"_blank\"\n                            href={UrlBuilder.shareList(row.id)}\n                            className=\"inline-flex items-center gap-2 font-semibold\"\n                          >\n                            {row.image && (\n                              <Avatar className=\"size-6\">\n                                <AvatarImage src={row.image} />\n                              </Avatar>\n                            )}\n                            <span className=\"inline-block max-w-[200px] truncate\">{row.title}</span>\n                          </a>\n                        </TableCell>\n                        <TableCell size=\"sm\">\n                          <Tooltip>\n                            <TooltipTrigger asChild>\n                              <span\n                                className={cn(\n                                  \"inline-flex items-center\",\n                                  getView(row.view)?.className,\n                                )}\n                              >\n                                {getView(row.view)?.icon}\n                              </span>\n                            </TooltipTrigger>\n                            <TooltipPortal>\n                              <TooltipContent>\n                                {t(getView(row.view)!.name, {\n                                  ns: \"common\",\n                                })}\n                              </TooltipContent>\n                            </TooltipPortal>\n                          </Tooltip>\n                        </TableCell>\n                        <TableCell size=\"sm\" className=\"tabular-nums\">\n                          {formatNumber(listDataMap[row.id]?.subscriptionCount || 0)}\n                        </TableCell>\n                        <TableCell size=\"sm\" className=\"center\">\n                          <Tooltip>\n                            <TooltipTrigger asChild>\n                              <Button\n                                variant=\"ghost\"\n                                onClick={() => {\n                                  present({\n                                    title: t.settings(\"lists.feeds.manage\"),\n                                    content: () => <ListFeedsModalContent id={row.id} />,\n                                  })\n                                }}\n                              >\n                                <i className=\"i-mgc-inbox-cute-re\" />\n                              </Button>\n                            </TooltipTrigger>\n                            <TooltipPortal>\n                              <TooltipContent>{t.common(\"words.manage\")}</TooltipContent>\n                            </TooltipPortal>\n                          </Tooltip>\n                          <Tooltip>\n                            <TooltipTrigger asChild>\n                              <Button\n                                variant=\"ghost\"\n                                onClick={() => {\n                                  present({\n                                    title: t.settings(\"lists.edit.label\"),\n                                    content: () => <ListCreationModalContent id={row.id} />,\n                                  })\n                                }}\n                              >\n                                <i className=\"i-mgc-edit-cute-re\" />\n                              </Button>\n                            </TooltipTrigger>\n                            <TooltipPortal>\n                              <TooltipContent>{t.common(\"words.edit\")}</TooltipContent>\n                            </TooltipPortal>\n                          </Tooltip>\n                          <Tooltip>\n                            <TooltipTrigger asChild>\n                              <ActionButton\n                                size=\"sm\"\n                                onClick={() =>\n                                  present({\n                                    title: t.settings(\"lists.delete.confirm\"),\n                                    content: () => <ConfirmDestroyModalContent listId={row.id} />,\n                                  })\n                                }\n                              >\n                                <i className=\"i-mgc-delete-2-cute-re\" />\n                              </ActionButton>\n                            </TooltipTrigger>\n                            <TooltipPortal>\n                              <TooltipContent>{t.common(\"words.delete\")}</TooltipContent>\n                            </TooltipPortal>\n                          </Tooltip>\n                        </TableCell>\n                      </TableRow>\n                    ))}\n                  </TableBody>\n                </Table>\n              </div>\n            ) : (\n              <div className=\"mt-36 w-full text-center text-sm text-zinc-400\">\n                <p>{t.settings(\"lists.noLists\")}</p>\n              </div>\n            )}\n          </ScrollArea.ScrollArea>\n        )}\n      </div>\n    </section>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/lists/modals.tsx",
    "content": "import { Avatar, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@follow/components/ui/table/index.jsx\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport { listSyncServices } from \"@follow/store/list/store\"\nimport { useAllFeedSubscription } from \"@follow/store/subscription/hooks\"\nimport { isBizId } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useMemo, useRef, useState } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nimport type { Suggestion } from \"~/components/ui/auto-completion\"\nimport { Autocomplete } from \"~/components/ui/auto-completion\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAddFeedToFeedList, useRemoveFeedFromFeedList } from \"~/hooks/biz/useFeedActions\"\nimport { createErrorToaster } from \"~/lib/error-parser\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { FeedCertification } from \"~/modules/feed/feed-certification\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { ViewSelectorRadioGroup } from \"~/modules/shared/ViewSelectorRadioGroup\"\n\nconst formSchema = z.object({\n  view: z.string(),\n  title: z.string().min(1),\n  description: z.string().optional(),\n  image: z.string().optional(),\n})\n\nexport const ListCreationModalContent = ({ id }: { id?: string }) => {\n  const { dismiss } = useCurrentModal()\n  const { t } = useTranslation([\"settings\", \"common\"])\n\n  const list = useListById(id)\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      view: list?.view.toString() || FeedViewType.Articles.toString(),\n      title: list?.title || \"\",\n      description: list?.description || \"\",\n      image: list?.image || \"\",\n    },\n  })\n\n  const createMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof formSchema>) => {\n      if (id) {\n        await listSyncServices.updateList({\n          listId: id,\n          list: {\n            ...values,\n            view: Number.parseInt(values.view),\n          },\n        })\n      } else {\n        await listSyncServices.createList({\n          list: {\n            ...values,\n            view: Number.parseInt(values.view),\n          },\n        })\n      }\n    },\n    onSuccess: (_) => {\n      const isCreate = !id\n      toast.success(t(isCreate ? \"lists.created.success\" : \"lists.edit.success\"))\n\n      dismiss()\n    },\n    onError: createErrorToaster(id ? t(\"lists.edit.error\") : t(\"lists.created.error\")),\n  })\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    createMutation.mutate(values)\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4 lg:w-[450px]\">\n        <FormField\n          control={form.control}\n          name=\"title\"\n          render={({ field }) => (\n            <FormItem>\n              <div>\n                <FormLabel>\n                  {t(\"lists.title\")}\n                  <sup className=\"ml-1 align-sub text-red-500\">*</sup>\n                </FormLabel>\n              </div>\n              <FormControl>\n                <Input autoFocus {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"description\"\n          render={({ field }) => (\n            <FormItem>\n              <div>\n                <FormLabel>{t(\"lists.description\")}</FormLabel>\n              </div>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"image\"\n          render={({ field }) => (\n            <div className=\"flex items-center gap-4\">\n              <FormItem className=\"w-full\">\n                <FormLabel>{t(\"lists.image\")}</FormLabel>\n                <FormControl>\n                  <div className=\"flex items-center gap-4\">\n                    <Input {...field} />\n                    {field.value && (\n                      <Avatar className=\"size-9\">\n                        <AvatarImage src={field.value} />\n                      </Avatar>\n                    )}\n                  </div>\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            </div>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"view\"\n          render={() => (\n            <FormItem>\n              <FormLabel>{t(\"lists.view\")}</FormLabel>\n\n              <ViewSelectorRadioGroup {...form.register(\"view\")} />\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"flex justify-end\">\n          <Button type=\"submit\" isLoading={createMutation.isPending}>\n            {id ? t(\"common:words.update\") : t(\"common:words.create\")}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n\nexport const ListFeedsModalContent = ({ id }: { id: string }) => {\n  const list = useListById(id)\n  const { t } = useTranslation(\"settings\")\n\n  const [feedSearchFor, setFeedSearchFor] = useState(\"\")\n  const selectedFeedIdRef = useRef<string | null>(undefined)\n  const addMutation = useAddFeedToFeedList({\n    onSuccess: () => {\n      setFeedSearchFor(\"\")\n      selectedFeedIdRef.current = null\n    },\n  })\n\n  const allFeeds = useAllFeedSubscription()\n  const autocompleteSuggestions: Suggestion[] = useMemo(() => {\n    return allFeeds\n      .filter((feed) => !feed.feedId || !list?.feedIds?.includes(feed.feedId))\n      .map((feed) => {\n        const title = getFeedById(feed.feedId)?.title\n        return {\n          name: title || \"\",\n          value: feed.feedId || \"\",\n        }\n      })\n  }, [allFeeds, list?.feedIds])\n\n  if (!list) return null\n  return (\n    <>\n      <div className=\"flex items-center gap-2\">\n        <Autocomplete\n          maxHeight={window.innerHeight < 600 ? 120 : 240}\n          autoFocus\n          value={feedSearchFor}\n          searchKeys={[\"name\"]}\n          onSuggestionSelected={(e) => {\n            selectedFeedIdRef.current = e?.value\n            setFeedSearchFor(e?.name || \"\")\n          }}\n          onChange={(e) => {\n            setFeedSearchFor(e.target.value)\n          }}\n          suggestions={autocompleteSuggestions}\n        />\n        <Button\n          textClassName=\"whitespace-nowrap\"\n          onClick={() => {\n            if (isBizId(feedSearchFor)) {\n              addMutation.mutate({ feedId: feedSearchFor, listId: id })\n              return\n            }\n            if (selectedFeedIdRef.current) {\n              addMutation.mutate({ feedId: selectedFeedIdRef.current, listId: id })\n            }\n          }}\n          isLoading={addMutation.isPending}\n        >\n          {t(\"lists.feeds.add.label\")}\n        </Button>\n      </div>\n      <Divider className=\"mt-8\" />\n      <ScrollArea.ScrollArea viewportClassName=\"max-h-[380px] w-[450px]\">\n        <Table className=\"mt-4\">\n          <TableHeader className=\"border-b\">\n            <TableRow className=\"[&_*]:!font-semibold\">\n              <TableHead size=\"sm\" className=\"pl-8\">\n                {t(\"lists.feeds.title\")}\n              </TableHead>\n              <TableHead className=\"w-20 text-center\" size=\"sm\">\n                {t(\"lists.feeds.owner\")}\n              </TableHead>\n              <TableHead className=\"w-20 text-center\" size=\"sm\">\n                {t(\"lists.feeds.actions\")}\n              </TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody className=\"border-t-[12px] border-transparent\">\n            {list.feedIds?.map((feedId) => (\n              <RowRender feedId={feedId} key={feedId} listId={id} />\n            ))}\n          </TableBody>\n        </Table>\n      </ScrollArea.ScrollArea>\n    </>\n  )\n}\n\nconst RowRender = ({ feedId, listId }: { feedId: string; listId: string }) => {\n  const feed = useFeedById(feedId)\n\n  const removeMutation = useRemoveFeedFromFeedList()\n  if (!feed) return null\n  return (\n    <TableRow key={feed.title} className=\"h-8\">\n      <TableCell size=\"sm\">\n        <a\n          target=\"_blank\"\n          href={UrlBuilder.shareFeed(feed.id)}\n          className=\"flex items-center gap-2 font-semibold\"\n        >\n          {feed.siteUrl && <FeedIcon noMargin siteUrl={feed.siteUrl} />}\n          <span className=\"inline-block max-w-[200px] truncate\">{feed.title}</span>\n        </a>\n      </TableCell>\n      <TableCell align=\"center\" size=\"sm\">\n        <div className=\"center\">\n          <FeedCertification className=\"ml-0\" feed={feed} />\n        </div>\n      </TableCell>\n      <TableCell align=\"center\" size=\"sm\">\n        <Button variant=\"ghost\" onClick={() => removeMutation.mutate({ feedId: feed.id, listId })}>\n          <i className=\"i-mgc-delete-2-cute-re\" />\n        </Button>\n      </TableCell>\n    </TableRow>\n  )\n}\nconst categoryFormSchema = z.object({\n  categoryName: z.string().min(1),\n})\nexport const CategoryCreationModalContent = ({\n  onSubmit,\n}: {\n  onSubmit: (category: string) => void\n}) => {\n  const { dismiss } = useCurrentModal()\n  const { t } = useTranslation()\n\n  const form = useForm<z.infer<typeof categoryFormSchema>>({\n    resolver: zodResolver(categoryFormSchema),\n    defaultValues: {\n      categoryName: \"\",\n    },\n  })\n\n  const handleSubmit = form.handleSubmit(({ categoryName }) => {\n    onSubmit(categoryName)\n    dismiss()\n  })\n\n  return (\n    <Form {...form}>\n      <form onSubmit={handleSubmit} className=\"space-y-4 lg:w-[450px]\">\n        <FormField\n          control={form.control}\n          name=\"categoryName\"\n          render={({ field }) => (\n            <FormItem>\n              <div>\n                <FormLabel>\n                  {t(\"sidebar.feed_column.context_menu.new_category_modal.category_name\")}\n                  <sup className=\"ml-1 align-sub text-red-500\">*</sup>\n                </FormLabel>\n              </div>\n              <FormControl>\n                <Input autoFocus {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"flex justify-end\">\n          <Button type=\"submit\">\n            {t(\"sidebar.feed_column.context_menu.new_category_modal.create\")}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { useEffect } from \"react\"\nimport { Trans } from \"react-i18next\"\nimport { Link } from \"react-router\"\nimport { toast } from \"sonner\"\n\nimport { setAppMessagingToken, useAppMessagingToken } from \"~/atoms/app\"\nimport { useCurrentModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { useI18n } from \"~/hooks/common\"\nimport { ipcServices } from \"~/lib/client\"\nimport { useMessaging, useTestMessaging } from \"~/queries/messaging\"\n\nexport const SettingNotifications = () => {\n  const t = useI18n()\n  const { isLoading, data } = useMessaging()\n  const { dismiss } = useCurrentModal()\n\n  const token = useAppMessagingToken()\n\n  const testMessaging = useTestMessaging()\n\n  useEffect(() => {\n    ipcServices?.setting.getMessagingToken().then((credentials) => {\n      setAppMessagingToken(credentials || null)\n    })\n  }, [])\n\n  return (\n    <section className=\"mt-4 space-y-6\">\n      {/* Info Section */}\n\n      <p className=\"text-sm leading-relaxed text-text-secondary\">\n        <Trans\n          ns=\"settings\"\n          i18nKey=\"notifications.info\"\n          components={{\n            ActionsLink: (\n              <Link\n                className=\"font-medium text-accent underline-offset-2 hover:text-accent/80 hover:underline\"\n                to=\"/action\"\n                onClick={() => {\n                  dismiss()\n                }}\n              />\n            ),\n          }}\n        />\n      </p>\n\n      {/* Channels Section */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"text-sm font-semibold text-text\">{t.settings(\"notifications.channel\")}</h3>\n          <span className=\"text-xs text-text-tertiary\">\n            <span>{data?.data?.length || 0}</span>{\" \"}\n            <span>{t.common(\"words.items\", { count: data?.data?.length || 0 })}</span>\n          </span>\n        </div>\n\n        <div className=\"relative min-h-[200px]\">\n          {isLoading && (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <LoadingCircle size=\"large\" />\n            </div>\n          )}\n\n          {!isLoading && (!data?.data || data.data.length === 0) ? (\n            <div className=\"flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-material-medium py-12\">\n              <i className=\"i-mgc-notification-cute-re mb-3 text-4xl text-text-quaternary\" />\n              <p className=\"text-sm font-medium text-text\">\n                {t.settings(\"notifications.empty.title\")}\n              </p>\n              <p className=\"mt-1 max-w-sm px-6 text-center text-sm text-text-secondary\">\n                {t.settings(\"notifications.empty.description\")}\n              </p>\n            </div>\n          ) : (\n            <ScrollArea.ScrollArea viewportClassName=\"max-h-[400px]\">\n              <div className=\"space-y-2\">\n                {data?.data?.map((row) => (\n                  <div\n                    key={row.channel}\n                    className=\"group relative flex items-center gap-4 rounded-lg border border-border bg-background p-4 transition-all hover:border-border hover:bg-fill-secondary/30\"\n                  >\n                    {/* Channel Info */}\n                    <div className=\"flex-1 space-y-1\">\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"font-medium text-text\">{row.channel}</span>\n                        {row.token === token && (\n                          <span className=\"inline-flex items-center gap-1 rounded-full bg-accent/10 px-2 py-0.5 text-xs font-medium text-accent\">\n                            <i className=\"i-mgc-check-cute-re text-[10px]\" />\n                            {t.settings(\"notifications.current\")}\n                          </span>\n                        )}\n                      </div>\n                      <div className=\"flex items-center gap-2\">\n                        <code className=\"rounded bg-fill px-2 py-0.5 font-mono text-xs text-text-secondary\">\n                          {row.token.slice(0, 8)}...{row.token.slice(-8)}\n                        </code>\n                      </div>\n                    </div>\n\n                    {/* Actions */}\n                    <div className=\"flex items-center gap-2\">\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"sm\"\n                            buttonClassName=\"size-8 p-0\"\n                            onClick={() =>\n                              testMessaging.mutate(\n                                { channel: row.channel },\n                                {\n                                  onSuccess: () => {\n                                    toast.success(t.settings(\"notifications.test_success\"))\n                                  },\n                                },\n                              )\n                            }\n                          >\n                            <i className=\"i-mgc-finger-press-cute-re text-base\" />\n                          </Button>\n                        </TooltipTrigger>\n                        <TooltipPortal>\n                          <TooltipContent>{t.settings(\"notifications.test\")}</TooltipContent>\n                        </TooltipPortal>\n                      </Tooltip>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </ScrollArea.ScrollArea>\n          )}\n        </div>\n      </div>\n    </section>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.jsx\"\nimport { UserRole } from \"@follow/constants\"\nimport { DEEPLINK_SCHEME, IN_ELECTRON } from \"@follow/shared\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { useUserRole, useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport NumberFlow from \"@number-flow/react\"\nimport { useMutation, useQuery } from \"@tanstack/react-query\"\nimport type { TFunction } from \"i18next\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { PaymentFeature, PaymentPlan } from \"~/atoms/server-configs\"\nimport { useIsPaymentEnabled, useServerConfigs } from \"~/atoms/server-configs\"\nimport { followClient } from \"~/lib/api-client\"\nimport { subscription } from \"~/lib/auth\"\n\nconst APPLE_SUBSCRIPTION_MANAGEMENT_URL = \"https://apps.apple.com/account/subscriptions\"\n\ntype ActiveSubscription = {\n  source: \"stripe\" | \"apple\" | null\n  plan: string | null\n  status: string | null\n  productId: string | null\n  periodEnd: string | null\n  trialEnd: string | null\n  canManage: boolean\n}\n\nconst AI_MODEL_SELECTION_VALUE_LABELS = {\n  none: {\n    translationKey: \"plan.featureValues.AI_MODEL_SELECTION.none\",\n    fallback: \"—\",\n  },\n  curated: {\n    translationKey: \"plan.featureValues.AI_MODEL_SELECTION.curated\",\n    fallback: \"Curated best-value models\",\n  },\n  high_performance: {\n    translationKey: \"plan.featureValues.AI_MODEL_SELECTION.high_performance\",\n    fallback: \"All high-performance models\",\n  },\n} as const\n\nconst formatFeatureValue = (\n  key: keyof PaymentFeature,\n  value: PaymentFeature[keyof PaymentFeature] | null | undefined,\n  t?: TFunction<\"settings\">,\n): string => {\n  if (value == null || value === undefined) {\n    return \"—\"\n  }\n\n  if (key === \"AI_MODEL_SELECTION\" && typeof value === \"string\") {\n    const selectionValue =\n      AI_MODEL_SELECTION_VALUE_LABELS[value as keyof typeof AI_MODEL_SELECTION_VALUE_LABELS]\n    if (selectionValue) {\n      return t?.(selectionValue.translationKey) ?? selectionValue.fallback\n    }\n  }\n\n  if (typeof value === \"boolean\") {\n    return value ? \"✓\" : \"—\"\n  }\n\n  if (value === Number.MAX_SAFE_INTEGER) {\n    return \"Unlimited\"\n  }\n\n  if (typeof value === \"number\") {\n    return new Intl.NumberFormat(\"en\", {\n      notation: \"compact\",\n      compactDisplay: \"short\",\n      maximumFractionDigits: 1,\n    }).format(value)\n  }\n\n  if (Array.isArray(value)) {\n    return value.length > 0 ? value.join(\" \") : \"—\"\n  }\n\n  return value\n}\n\nconst useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => {\n  return useMutation({\n    mutationFn: async () => {\n      if (!plan) {\n        return\n      }\n\n      const res = await subscription.upgrade({\n        plan,\n        annual,\n        successUrl: IN_ELECTRON ? `${DEEPLINK_SCHEME}refresh` : env.VITE_WEB_URL,\n        cancelUrl: env.VITE_WEB_URL,\n        disableRedirect: IN_ELECTRON,\n      })\n      if (IN_ELECTRON && res.data?.url) {\n        window.open(res.data.url, \"_blank\")\n      }\n    },\n  })\n}\n\nconst useActiveSubscription = () => {\n  const userId = useWhoami()?.id\n  return useQuery({\n    queryKey: [\"billingSubscription\"],\n    queryFn: async () => {\n      const response = await followClient.request<{ code: number; data: ActiveSubscription }>(\n        \"/billing/subscription\",\n      )\n      return response.data\n    },\n    enabled: !!userId,\n  })\n}\n\nconst useBillingPortal = () => {\n  return useMutation({\n    mutationFn: async () => {\n      const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href\n      const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        credentials: \"include\",\n        body: JSON.stringify({ returnUrl }),\n      })\n      const data = await res.json()\n      if (data.code === 0 && data.data?.url) {\n        window.open(data.data.url, \"_blank\")\n      }\n    },\n  })\n}\n\nexport function SettingPlan() {\n  const isPaymentEnabled = useIsPaymentEnabled()\n  const role = useUserRole()\n  const [billingPeriod, setBillingPeriod] = useState<\"monthly\" | \"yearly\">(\"yearly\")\n\n  const serverConfig = useServerConfigs()\n  const plans = serverConfig?.PAYMENT_PLAN_LIST || []\n  const currentPlan = plans.find((plan) => plan.role === role)\n  const currentTier = currentPlan?.tier || 0\n\n  // Calculate average savings percentage across all paid plans\n  const averageSavings = Math.round(\n    plans\n      .filter((plan) => plan.priceInDollars > 0 && plan.priceInDollarsAnnual > 0)\n      .reduce((acc, plan) => {\n        const monthlyTotal = plan.priceInDollars * 12\n        const yearlyTotal = plan.priceInDollarsAnnual\n        const savings = ((monthlyTotal - yearlyTotal) / monthlyTotal) * 100\n        return acc + savings\n      }, 0) / plans.filter((plan) => plan.priceInDollars > 0).length,\n  )\n  if (!isPaymentEnabled) {\n    return null\n  }\n\n  return (\n    <section className=\"mt-4 space-y-8\">\n      {/* Billing Period Toggle */}\n      <div className=\"flex justify-center\">\n        <SegmentGroup\n          value={billingPeriod}\n          onValueChanged={(value) => setBillingPeriod(value as \"monthly\" | \"yearly\")}\n        >\n          <SegmentItem value=\"monthly\" label=\"Monthly\" />\n          <SegmentItem\n            value=\"yearly\"\n            label={\n              <span className=\"flex items-center gap-2\">\n                <span>Yearly</span>\n                {averageSavings > 0 && (\n                  <span className=\"text-xs font-semibold text-green\">Save {averageSavings}%</span>\n                )}\n              </span>\n            }\n          />\n        </SegmentGroup>\n      </div>\n\n      {/* Plans Grid */}\n      <div className=\"@container\">\n        <div className=\"grid grid-cols-1 gap-4 @md:grid-cols-3\">\n          {plans\n            .filter((plan) => plan.priceInDollars > 0)\n            .map((plan) => (\n              <PlanCard\n                key={plan.name}\n                plan={plan}\n                billingPeriod={billingPeriod}\n                isCurrentPlan={role === plan.role}\n                currentTier={currentTier}\n              />\n            ))}\n        </div>\n      </div>\n\n      {/* Comparison Table */}\n      <PlanComparisonTable plans={plans} />\n    </section>\n  )\n}\n\n// Reusable PlanCard Component\ninterface PlanCardProps {\n  plan: PaymentPlan\n  billingPeriod: \"monthly\" | \"yearly\"\n  isCurrentPlan: boolean\n  currentTier: number\n}\n\nconst PlanCard = ({ plan, billingPeriod, isCurrentPlan, currentTier }: PlanCardProps) => {\n  const { t } = useTranslation(\"settings\")\n  const getPlanActionType = ():\n    | \"current\"\n    | \"upgrade\"\n    | \"coming-soon\"\n    | \"in-trial\"\n    | \"switch\"\n    | \"new\"\n    | null => {\n    if (plan.isComingSoon) return \"coming-soon\"\n    switch (true) {\n      case isCurrentPlan: {\n        return \"current\"\n      }\n      case plan.tier > currentTier && !!plan.planID && currentTier !== 0: {\n        return \"upgrade\"\n      }\n      case plan.tier > currentTier && !!plan.planID && currentTier === 0: {\n        return \"new\"\n      }\n      // case plan.tier < currentTier && !!plan.planID: {\n      //   return \"switch\"\n      // }\n      default: {\n        return null\n      }\n    }\n  }\n\n  const actionType = getPlanActionType()\n  const upgradePlanMutation = useUpgradePlan({\n    plan: plan.planID,\n    annual: billingPeriod === \"yearly\",\n  })\n\n  // Calculate price and period based on billing period\n  const regularPrice =\n    billingPeriod === \"yearly\" ? plan.priceInDollarsAnnual / 12 : plan.priceInDollars\n  const discountPrice =\n    billingPeriod === \"yearly\"\n      ? (plan.priceInDollarsInDiscountAnnual || 0) / 12\n      : plan.priceInDollarsInDiscount\n  const period = plan.role === UserRole.Free ? \"\" : \"month\"\n\n  // Calculate discount percentage from prices\n  const hasDiscount =\n    discountPrice &&\n    discountPrice > 0 &&\n    discountPrice < regularPrice &&\n    discountPrice !== regularPrice\n\n  // Use discount price if available, otherwise use regular price\n  const finalPrice = hasDiscount ? discountPrice : regularPrice\n  const regularPriceForStrike = hasDiscount && regularPrice > 0 ? regularPrice : undefined\n\n  // Get plan description from i18n\n  const planDescriptionKey = `plan.descriptions.${plan.role}` as const\n  const planDescription = t(planDescriptionKey, { defaultValue: \"\" })\n\n  return (\n    <div\n      className={cn(\n        \"group relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-200\",\n        actionType === \"upgrade\"\n          ? \"border-accent/40 bg-background shadow-sm hover:border-accent/60 hover:shadow-md\"\n          : \"border-fill-tertiary bg-background hover:border-fill-secondary\",\n        plan.isComingSoon && \"opacity-75\",\n        isCurrentPlan && \"border-blue/30\",\n      )}\n    >\n      <PlanBadges isPopular={plan.isPopular || false} />\n\n      <div className=\"flex h-full flex-col justify-between gap-4 p-4 @md:p-5\">\n        <PlanHeader\n          title={plan.name}\n          price={finalPrice}\n          regularPrice={regularPriceForStrike}\n          period={period}\n          description={planDescription}\n          discountDescription={plan.discountDescription}\n        />\n\n        <PlanAction\n          actionType={actionType}\n          upgradeButtonText={plan.upgradeButtonText}\n          isLoading={upgradePlanMutation.isPending}\n          onSelect={\n            !plan.isComingSoon && !isCurrentPlan\n              ? () => {\n                  upgradePlanMutation.mutate()\n                }\n              : undefined\n          }\n        />\n      </div>\n\n      {/* Subtle bottom accent line */}\n      {actionType === \"upgrade\" && (\n        <div className=\"absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent\" />\n      )}\n    </div>\n  )\n}\n\n// Plan card sub-components\nconst PlanBadges = ({ isPopular }: { isPopular: boolean }) => (\n  <>\n    {isPopular && (\n      <div className=\"absolute -top-px right-4 z-10\">\n        <div className=\"rounded-b-lg bg-gradient-to-r from-accent to-accent/90 px-2.5 py-1 text-caption font-medium text-white shadow-sm\">\n          Most Popular\n        </div>\n      </div>\n    )}\n  </>\n)\n\nconst PlanHeader = ({\n  title,\n  price,\n  regularPrice,\n  period,\n  description,\n  discountDescription,\n}: {\n  title: string\n  price: number\n  regularPrice?: number\n  period: string\n  description?: string\n  discountDescription?: string\n}) => (\n  <div className=\"space-y-2.5\">\n    <h3 className=\"text-lg font-semibold\">{title}</h3>\n    <div className=\"space-y-1.5\">\n      <div className=\"flex items-baseline gap-1.5\">\n        <NumberFlow\n          className=\"text-2xl font-bold\"\n          value={price}\n          locales=\"en-US\"\n          format={{ style: \"currency\", currency: \"USD\", trailingZeroDisplay: \"stripIfInteger\" }}\n        />\n        {period && <span className=\"text-xs text-text-secondary @md:text-sm\">/{period}</span>}\n\n        {typeof regularPrice === \"number\" && regularPrice > 0 && (\n          <span className=\"relative inline-block text-sm text-text-tertiary after:absolute after:inset-x-0 after:top-1/2 after:h-px after:w-full after:translate-y-1/2 after:bg-text-tertiary after:content-['']\">\n            <NumberFlow\n              value={regularPrice}\n              locales=\"en-US\"\n              format={{ style: \"currency\", currency: \"USD\", trailingZeroDisplay: \"stripIfInteger\" }}\n            />\n          </span>\n        )}\n      </div>\n      {typeof regularPrice === \"number\" && regularPrice > 0 && !!discountDescription && (\n        <span className=\"inline-flex items-center gap-1 rounded-md bg-green/10 px-1.5 py-0.5 text-xs font-medium text-green\">\n          {discountDescription}\n        </span>\n      )}\n    </div>\n    {description && <p className=\"text-xs leading-relaxed text-text-secondary\">{description}</p>}\n  </div>\n)\n\nconst PlanAction = ({\n  actionType,\n  upgradeButtonText,\n  onSelect,\n  isLoading,\n}: {\n  actionType: \"current\" | \"upgrade\" | \"coming-soon\" | \"in-trial\" | \"switch\" | \"new\" | null\n  upgradeButtonText?: string\n  onSelect?: () => void\n  isLoading?: boolean\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const { data: activeSubscription } = useActiveSubscription()\n  const billingPortalMutation = useBillingPortal()\n  const canManageSubscription = !!activeSubscription?.canManage\n  const isAppleSubscription = activeSubscription?.source === \"apple\"\n\n  // Determine subscription status info\n  const isCanceled = activeSubscription?.status === \"canceled\"\n  const periodEnd = activeSubscription?.trialEnd ?? activeSubscription?.periodEnd\n  const effectivePeriodEnd = periodEnd ? new Date(periodEnd) : null\n\n  const getButtonConfig = () => {\n    switch (actionType) {\n      case \"coming-soon\": {\n        return {\n          text: \"Coming Soon\",\n          icon: \"i-mgc-time-cute-re\",\n          variant: \"outline\" as const,\n          disabled: true,\n        }\n      }\n      case \"current\": {\n        return {\n          text: canManageSubscription ? t(\"plan.manage_subscription\") : t(\"plan.current_plan\"),\n          icon: undefined,\n          variant: \"outline\" as const,\n          className: !canManageSubscription ? \"text-text-secondary\" : undefined,\n          disabled: !canManageSubscription,\n        }\n      }\n      case \"in-trial\": {\n        return {\n          text: \"In Trial\",\n          icon: \"i-mgc-stopwatch-cute-re\",\n          variant: \"outline\" as const,\n          disabled: false,\n        }\n      }\n      case \"new\": {\n        return {\n          text: upgradeButtonText || \"Upgrade\",\n          icon: \"i-mgc-arrow-up-cute-re\",\n          className:\n            \"bg-gradient-to-r from-accent to-accent/90 text-white hover:from-accent/95 hover:to-accent/85\",\n          disabled: false,\n        }\n      }\n      case \"upgrade\": {\n        return {\n          text: \"Upgrade\",\n          icon: \"i-mgc-arrow-up-cute-re\",\n          className:\n            \"bg-gradient-to-r from-accent to-accent/90 text-white hover:from-accent/95 hover:to-accent/85\",\n          disabled: false,\n        }\n      }\n      case \"switch\": {\n        return {\n          text: \"Switch Plan\",\n          icon: \"i-mgc-transfer-cute-re\",\n          className:\n            \"bg-gradient-to-r from-accent to-accent/90 text-white font-semibold hover:from-accent/95 hover:to-accent/85\",\n          disabled: false,\n        }\n      }\n      case null: {\n        return null\n      }\n    }\n  }\n\n  const buttonConfig = getButtonConfig()\n\n  if (!buttonConfig) {\n    return null\n  }\n\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      {/* Subscription status info for current plan */}\n      {actionType === \"current\" && canManageSubscription && (\n        <div className=\"flex items-center justify-center gap-1.5 text-xs text-text-secondary\">\n          <span\n            className={cn(\n              \"inline-block size-1.5 rounded-full\",\n              isCanceled\n                ? \"bg-yellow\"\n                : activeSubscription?.status === \"trialing\"\n                  ? \"bg-blue\"\n                  : \"bg-green\",\n            )}\n          />\n          <span>\n            {isCanceled\n              ? t(\"plan.canceled_expires\", {\n                  date: effectivePeriodEnd?.toLocaleDateString(undefined, {\n                    year: \"numeric\",\n                    month: \"short\",\n                    day: \"numeric\",\n                  }),\n                })\n              : activeSubscription?.status === \"trialing\"\n                ? t(\"plan.trial_ends\", {\n                    date: effectivePeriodEnd?.toLocaleDateString(undefined, {\n                      year: \"numeric\",\n                      month: \"short\",\n                      day: \"numeric\",\n                    }),\n                  })\n                : t(\"plan.renews\", {\n                    date: effectivePeriodEnd?.toLocaleDateString(undefined, {\n                      year: \"numeric\",\n                      month: \"short\",\n                      day: \"numeric\",\n                    }),\n                  })}\n          </span>\n        </div>\n      )}\n      <Button\n        variant={buttonConfig.variant}\n        buttonClassName={cn(\n          \"w-full h-9 text-sm font-medium transition-all duration-200\",\n          buttonConfig.className,\n        )}\n        disabled={buttonConfig.disabled}\n        onClick={\n          actionType === \"current\" && canManageSubscription\n            ? () => {\n                if (isAppleSubscription) {\n                  window.open(APPLE_SUBSCRIPTION_MANAGEMENT_URL, \"_blank\")\n                  return\n                }\n\n                billingPortalMutation.mutate()\n              }\n            : onSelect\n        }\n        isLoading={isLoading || billingPortalMutation.isPending}\n      >\n        <span className=\"flex items-center justify-center gap-1.5\">\n          {buttonConfig.icon && <i className={cn(buttonConfig.icon, \"text-sm\")} />}\n          <span>{buttonConfig.text}</span>\n        </span>\n      </Button>\n    </div>\n  )\n}\n\nconst PlanComparisonTable = ({ plans }: { plans: PaymentPlan[] }) => {\n  const { t } = useTranslation(\"settings\")\n\n  // Get all unique feature keys\n  const allFeatureKeys = Array.from(\n    new Set(plans.flatMap((plan) => Object.keys(plan.limit))),\n  ) as (keyof PaymentFeature)[]\n\n  // Filter out features that are all false/0/null\n  const visibleFeatureKeys = allFeatureKeys.filter((key) =>\n    plans.some((plan) => {\n      const value = plan.limit[key]\n      if (value == null) return false\n      if (typeof value === \"boolean\" && !value) return false\n      if (typeof value === \"number\" && value === 0) return false\n      return true\n    }),\n  )\n\n  return (\n    <div className=\"overflow-hidden rounded-xl border border-fill-tertiary bg-background\">\n      <div className=\"overflow-x-auto\">\n        <table className=\"w-full\">\n          <thead>\n            <tr className=\"border-b border-fill-tertiary bg-fill-secondary/50\">\n              <th className=\"sticky left-0 z-10 w-44 bg-fill-secondary/50 px-4 py-3 text-left text-sm font-semibold\">\n                Features\n              </th>\n              {plans.map((plan) => (\n                <th key={plan.name} className=\"px-4 py-3 text-center text-sm font-semibold\">\n                  {plan.name}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {visibleFeatureKeys.map((featureKey, index) => (\n              <tr\n                key={featureKey}\n                className={cn(\n                  \"border-b border-fill-tertiary transition-colors hover:bg-fill-secondary/30\",\n                  index % 2 === 0 ? \"bg-background\" : \"bg-fill-secondary/20\",\n                )}\n              >\n                <td className=\"sticky left-0 z-10 bg-inherit px-4 py-3 text-sm font-medium\">\n                  {t(`plan.features.${featureKey}`, { defaultValue: featureKey })}\n                </td>\n                {plans.map((plan) => {\n                  const value = plan.limit[featureKey]\n                  const formattedValue = formatFeatureValue(featureKey, value, t)\n\n                  return (\n                    <td\n                      key={`${plan.name}-${featureKey}`}\n                      className=\"px-4 py-3 text-center text-sm\"\n                    >\n                      <span\n                        className={cn(\n                          \"font-medium\",\n                          formattedValue === \"—\" && \"text-text-tertiary\",\n                          formattedValue === \"✓\" && \"text-green\",\n                          (formattedValue === \"Unlimited\" ||\n                            formattedValue.startsWith(\"×\") ||\n                            formattedValue.startsWith(\"All\")) &&\n                            \"text-accent\",\n                          formattedValue.length > 10 && \"text-xs\",\n                        )}\n                      >\n                        {formattedValue}\n                      </span>\n                    </td>\n                  )\n                })}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/tabs/shortcut.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.jsx\"\nimport { KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { cn } from \"@follow/utils\"\nimport { memo, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { KeyRecorder } from \"~/components/ui/keyboard-recorder\"\nimport { useCommand } from \"~/modules/command/hooks/use-command\"\nimport type { AllowCustomizeCommandId } from \"~/modules/command/hooks/use-command-binding\"\nimport {\n  allowCustomizeCommands,\n  defaultCommandShortcuts,\n  useCommandShortcutItems,\n  useCommandShortcuts,\n  useIsShortcutConflict,\n  useSetCustomCommandShortcut,\n} from \"~/modules/command/hooks/use-command-binding\"\nimport type { CommandCategory, FollowCommandId } from \"~/modules/command/types\"\n\nexport const ShortcutSetting = () => {\n  const { t } = useTranslation(\"shortcuts\")\n  const commandShortcuts = useCommandShortcutItems()\n  const currentShortcuts = useCommandShortcuts()\n  const setCustomCommandShortcut = useSetCustomCommandShortcut()\n\n  // Check if any shortcuts have been customized\n  const hasCustomizedShortcuts = useMemo(() => {\n    return Object.entries(currentShortcuts).some(([commandId, shortcut]) => {\n      return (\n        allowCustomizeCommands.has(commandId as AllowCustomizeCommandId) &&\n        shortcut !== defaultCommandShortcuts[commandId as keyof typeof defaultCommandShortcuts]\n      )\n    })\n  }, [currentShortcuts])\n\n  const resetDefaults = () => {\n    Object.entries(defaultCommandShortcuts).forEach(([commandId, shortcut]) => {\n      if (allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)) {\n        setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)\n      }\n    })\n  }\n\n  return (\n    <div>\n      <p className=\"mb-6 mt-4 space-y-2 text-sm\">{t(\"settings.shortcuts.description\")}</p>\n\n      {Object.entries(commandShortcuts).map(([type, commands]) => (\n        <section key={type} className=\"mb-8\">\n          <div className=\"mb-4 border-b border-border pb-2 text-base font-medium text-text\">\n            {t(type as CommandCategory)}\n          </div>\n          <div className=\"space-y-4\">\n            {commands.map((commandId) => (\n              <EditableCommandShortcutItem key={commandId} commandId={commandId} />\n            ))}\n          </div>\n        </section>\n      ))}\n\n      <div className=\"mb-4 flex min-h-6 items-center justify-end\">\n        {hasCustomizedShortcuts && (\n          <Button variant={\"outline\"} onClick={resetDefaults}>\n            Reset Defaults\n          </Button>\n        )}\n      </div>\n    </div>\n  )\n}\n\nconst EditableCommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {\n  const { t } = useTranslation(\"shortcuts\")\n  const command = useCommand(commandId)\n  const commandShortcuts = useCommandShortcuts()\n  const [isEditing, setIsEditing] = useState(false)\n\n  const setCustomCommandShortcut = useSetCustomCommandShortcut()\n  const allowCustomize = allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)\n\n  if (!command) return null\n\n  const isUserCustomize = commandShortcuts[commandId] !== defaultCommandShortcuts[commandId]\n\n  return (\n    <div\n      className={\n        \"relative box-content grid h-8 grid-cols-[auto_200px] items-center justify-between gap-x-8 py-1.5\"\n      }\n    >\n      <div className=\"flex min-w-0 flex-col gap-1\">\n        <div className=\"flex items-center gap-2 text-sm text-text\">\n          {command.label.title}\n          {isUserCustomize && (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <div className=\"inline-flex items-center rounded-full bg-accent/10 px-2 py-0.5 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/20\">\n                  <div className=\"mr-1 size-2 rounded-full bg-accent\" />\n                  {t(\"settings.shortcuts.custom\")}\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>{t(\"settings.shortcuts.custom_content\")}</TooltipContent>\n            </Tooltip>\n          )}\n        </div>\n        {!!command.label.description && (\n          <small className=\"text-xs text-text-secondary\">{command.label.description}</small>\n        )}\n      </div>\n      <ShortcutInputWrapper\n        commandId={commandId}\n        shortcut={commandShortcuts[commandId]}\n        isEditing={isEditing}\n        isUserCustomize={isUserCustomize}\n        allowCustomize={allowCustomize}\n        onEditingChange={setIsEditing}\n        onShortcutChange={(shortcut) => {\n          setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)\n          setIsEditing(false)\n        }}\n      />\n    </div>\n  )\n})\n\ninterface ShortcutInputWrapperProps {\n  commandId: FollowCommandId\n  shortcut: string\n  isEditing: boolean\n  isUserCustomize: boolean\n  allowCustomize: boolean\n  onEditingChange: (editing: boolean) => void\n  onShortcutChange: (shortcut: string | null) => void\n}\n\nconst ShortcutInputWrapper = memo(\n  ({\n    commandId,\n    shortcut,\n    isEditing,\n    isUserCustomize,\n    allowCustomize,\n    onEditingChange,\n    onShortcutChange,\n  }: ShortcutInputWrapperProps) => {\n    const { t } = useTranslation(\"shortcuts\")\n    const conflictResult = useIsShortcutConflict(shortcut, commandId as AllowCustomizeCommandId)\n\n    const hasConflict = allowCustomize && conflictResult.hasConflict\n    const conflictingCommandId = allowCustomize ? conflictResult.conflictingCommandId : null\n\n    const conflictCommand = useCommand(conflictingCommandId as FollowCommandId)\n\n    const getBorderColor = () => {\n      if (hasConflict) {\n        return \"border-red/70 hover:!border-red\"\n      }\n      if (isEditing) {\n        return \"border-border bg-material-ultra-thick\"\n      }\n      if (allowCustomize) {\n        return \"border-border/50 bg-material-ultra-thin data-[customized=true]:bg-accent/10 data-[customized=true]:border-accent/50\"\n      }\n      return \"border-transparent\"\n    }\n\n    const getBackgroundColor = () => {\n      if (hasConflict && !isEditing) {\n        return \"bg-red/5\"\n      }\n      if (isEditing) {\n        return \"bg-material-ultra-thick\"\n      }\n      return \"\"\n    }\n\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <button\n            type=\"button\"\n            data-customized={isUserCustomize}\n            className={cn(\n              \"relative flex h-full cursor-text justify-end rounded-md border px-1 duration-200\",\n              allowCustomize && \"hover:!border-border hover:!bg-material-medium\",\n              getBorderColor(),\n              getBackgroundColor(),\n              !allowCustomize && \"pointer-events-none\",\n            )}\n            onClick={() => {\n              if (allowCustomize) {\n                onEditingChange(!isEditing)\n              }\n            }}\n          >\n            {isEditing ? (\n              <KeyRecorder\n                onBlur={() => {\n                  onEditingChange(false)\n                }}\n                onChange={(keys) => {\n                  onShortcutChange(Array.isArray(keys) ? keys.join(\"+\") : null)\n                }}\n              />\n            ) : (\n              <KbdCombined kbdProps={{ wrapButton: false }} joint={false}>\n                {shortcut}\n              </KbdCombined>\n            )}\n          </button>\n        </TooltipTrigger>\n        {hasConflict && (\n          <RootPortal>\n            <TooltipContent className=\"max-w-xs p-2\">\n              <div className=\"space-y-1\">\n                <div className=\"font-medium text-red-400\">{t(\"settings.shortcuts.conflict\")}</div>\n                <div className=\"leading-6\">\n                  <span className=\"text-xs text-text-secondary\">\n                    {t(\"settings.shortcuts.conflict_command\")}\n                  </span>\n                  <p className=\"text-sm font-medium\">{conflictCommand?.label.title}</p>\n                </div>\n              </div>\n            </TooltipContent>\n          </RootPortal>\n        )}\n      </Tooltip>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/title.tsx",
    "content": "import { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { use } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useLoaderData } from \"react-router\"\n\nimport { IsInSettingIndependentWindowContext } from \"./context\"\nimport { getMemoizedSettings } from \"./settings-glob\"\nimport type { SettingPageConfig } from \"./utils\"\n\nexport const SettingsSidebarTitle = ({ path, className }: { path: string; className?: string }) => {\n  const { t } = useTranslation(\"settings\")\n  const tab = getMemoizedSettings().find((t) => t.path === path)\n\n  if (!tab) {\n    return null\n  }\n\n  return (\n    <div className={cn(\"flex min-w-0 items-center gap-2 text-[0.94rem] font-medium\", className)}>\n      {typeof tab.icon === \"string\" ? (\n        <i className={`${tab.icon} shrink-0 text-[19px] text-accent`} />\n      ) : (\n        <Slot className=\"shrink-0 text-[19px] text-accent\">{tab.icon}</Slot>\n      )}\n      <EllipsisHorizontalTextWithTooltip>{t(tab.name as any)}</EllipsisHorizontalTextWithTooltip>\n    </div>\n  )\n}\n\nexport const SettingsTitle = ({\n  className,\n  loader,\n}: {\n  className?: string\n  loader?: () => SettingPageConfig\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const {\n    icon: iconName,\n    name,\n    title,\n    headerIcon,\n  } = (useLoaderData() || loader || {}) as SettingPageConfig\n\n  const usedIcon = headerIcon || iconName\n  const usedTitle = title || name\n  const isInSettingIndependentWindow = use(IsInSettingIndependentWindowContext)\n  if (!usedTitle) {\n    return null\n  }\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-2 pb-2 pt-6 text-xl font-bold\",\n        \"sticky top-0 mb-4\",\n        isInSettingIndependentWindow ? \"z-[99] bg-background\" : \"\",\n        className,\n      )}\n    >\n      {typeof usedIcon === \"string\" ? <i className={usedIcon} /> : usedIcon}\n      <span>{t(usedTitle)}</span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/settings/utils.ts",
    "content": "import type { UserRole } from \"@follow/constants\"\nimport type { ExtractResponseData, GetStatusConfigsResponse } from \"@follow-app/client-sdk\"\n\nexport interface SettingPageContext {\n  role: Nullable<UserRole>\n  isInMASReview: boolean\n}\n\nexport enum DisableWhy {\n  Noop = \"noop\",\n  NotActivation = \"not_activation\",\n}\n\nexport interface SettingPageConfig {\n  icon: string | React.ReactNode\n  name: I18nKeysForSettings\n  title?: I18nKeysForSettings\n  priority: number\n  headerIcon?: string | React.ReactNode\n  hideIf?: (\n    ctx: SettingPageContext,\n    serverConfigs?: ExtractResponseData<GetStatusConfigsResponse> | null,\n  ) => boolean\n  disableIf?: (\n    ctx: SettingPageContext,\n    serverConfigs?: ExtractResponseData<GetStatusConfigsResponse> | null,\n  ) => [boolean, DisableWhy]\n  viewportClassName?: string\n}\nexport const defineSettingPageData = (config: SettingPageConfig) => ({\n  ...config,\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/shared/ViewSelectorRadioGroup.tsx",
    "content": "import { Card, CardContent, CardHeader } from \"@follow/components/ui/card/index.jsx\"\nimport { FeedViewType, getView } from \"@follow/constants\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ParsedEntry } from \"@follow-app/client-sdk\"\nimport { cloneElement, useMemo } from \"react\"\n\nimport { parseView } from \"~/hooks/biz/useRouteParams\"\nimport { useTimelineList } from \"~/hooks/biz/useTimelineList\"\nimport { useI18n } from \"~/hooks/common\"\n\nimport { EntryItemSkeleton } from \"../entry-column/EntryItemSkeleton\"\nimport { EntryItemStateless } from \"../entry-column/item-stateless\"\n\nexport const ViewSelectorRadioGroup = ({\n  ref,\n  entries,\n  feed,\n  view,\n  className,\n  ...rest\n}: {\n  entries?: ParsedEntry[]\n  feed?: FeedModel\n  view?: number\n} & React.InputHTMLAttributes<HTMLInputElement> & {\n    ref?: React.Ref<HTMLInputElement | null>\n  }) => {\n  const t = useI18n()\n\n  const timelineViewIds = useTimelineList({ withAll: false, visible: true })\n  const configuredViews = useMemo(() => {\n    return timelineViewIds\n      .map((timelineId) => parseView(timelineId))\n      .filter((viewType): viewType is FeedViewType => viewType !== undefined)\n      .map((viewType) => getView(viewType))\n      .filter((view) => view.switchable)\n  }, [timelineViewIds])\n\n  const showPreview = feed && entries && entries.length > 0\n  const showLoading = !!feed && !showPreview\n\n  return (\n    <Card className={rest.disabled ? \"pointer-events-none\" : void 0}>\n      <CardHeader className={cn(\"flex flex-row justify-around space-y-0 px-2 py-3\", className)}>\n        {configuredViews.map((view) => (\n          <div key={view.name}>\n            <input\n              className=\"peer hidden\"\n              type=\"radio\"\n              id={view.name}\n              value={view.view}\n              ref={ref}\n              {...rest}\n            />\n            <label\n              htmlFor={view.name}\n              className={cn(\n                \"hover:text-text\",\n                view.peerClassName,\n                \"center flex h-10 flex-col text-xs leading-none opacity-80 duration-200\",\n                \"text-text-secondary\",\n                \"peer-checked:opacity-100\",\n                \"whitespace-nowrap\",\n              )}\n            >\n              {cloneElement(view.icon, {\n                className: `text-lg ${view.icon?.props?.className ?? \"\"}`,\n              })}\n              <span className=\"mt-1 hidden text-xs lg:inline\">\n                {t(view.name, { ns: \"common\" })}\n              </span>\n            </label>\n          </div>\n        ))}\n      </CardHeader>\n      {showPreview && (\n        <CardContent\n          className={\n            getView(view || FeedViewType.Articles)?.gridMode\n              ? \"relative grid w-full grid-cols-3 flex-col gap-2 pb-4\"\n              : \"relative flex w-full flex-col gap-2 pb-0\"\n          }\n        >\n          {entries.slice(0, 3).map((entry) => (\n            <EntryItemStateless entry={entry} feed={feed} view={view} key={entry.guid} />\n          ))}\n        </CardContent>\n      )}\n      {showLoading && <EntryItemSkeleton view={view ?? FeedViewType.Articles} count={2} />}\n    </Card>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { createErrorToaster } from \"~/lib/error-parser\"\n\nimport { useCurrentModal } from \"../../components/ui/modal/stacked/hooks\"\n\nexport function CategoryRemoveDialogContent({\n  category,\n  view,\n}: {\n  category: string\n  view: FeedViewType\n}) {\n  const { t } = useTranslation()\n  const deleteMutation = useMutation({\n    mutationFn: () => subscriptionSyncService.deleteCategory({ category, view }),\n    onError: createErrorToaster(t(\"sidebar.category_remove_dialog.error\")),\n    onSuccess: () => {\n      toast.success(t(\"sidebar.category_remove_dialog.success\"))\n    },\n  })\n\n  const { dismiss } = useCurrentModal()\n\n  return (\n    <div className=\"flex w-[45ch] max-w-full flex-col gap-4\">\n      <Trans i18nKey=\"sidebar.category_remove_dialog.description\">\n        <p>\n          This operation will delete your category, but the feeds it contains will be retained and\n          grouped by website.\n        </p>\n      </Trans>\n\n      <div className=\"flex items-center justify-end gap-3\">\n        <Button variant=\"outline\" onClick={dismiss}>\n          {t(\"sidebar.category_remove_dialog.cancel\")}\n        </Button>\n        <Button\n          isLoading={deleteMutation.isPending}\n          onClick={() => deleteMutation.mutateAsync().then(() => dismiss())}\n        >\n          {t(\"sidebar.category_remove_dialog.continue\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/CategoryUnsubscribeDialogContent.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { getCategoryFeedIds } from \"@follow/store/subscription/getter\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { createErrorToaster } from \"~/lib/error-parser\"\n\nimport { useCurrentModal } from \"../../components/ui/modal/stacked/hooks\"\n\nexport function CategoryUnsubscribeDialogContent({\n  category,\n  view,\n}: {\n  category: string\n  view: FeedViewType\n}) {\n  const { t } = useTranslation()\n  const feedIds = useMemo(() => getCategoryFeedIds(category, view), [category, view])\n  const count = feedIds.length\n\n  const unsubscribeMutation = useMutation({\n    mutationFn: async (ids: string[]) => {\n      if (ids.length === 0) return\n      await subscriptionSyncService.unsubscribe(ids)\n    },\n    onError: createErrorToaster(t(\"sidebar.category_unsubscribe_dialog.error\")),\n    onSuccess: () => {\n      toast.success(t(\"sidebar.category_unsubscribe_dialog.success\", { count, category }))\n    },\n  })\n\n  const { dismiss } = useCurrentModal()\n\n  return (\n    <div className=\"flex w-[45ch] max-w-full flex-col gap-4\">\n      <p className=\"text-text\">\n        {t(\"sidebar.category_unsubscribe_dialog.description\", {\n          category,\n          count,\n        })}\n      </p>\n\n      <div className=\"flex items-center justify-end gap-3\">\n        <Button variant=\"outline\" onClick={dismiss}>\n          {t(\"sidebar.category_unsubscribe_dialog.cancel\")}\n        </Button>\n        <Button\n          isLoading={unsubscribeMutation.isPending}\n          disabled={count === 0}\n          onClick={() =>\n            unsubscribeMutation.mutateAsync(feedIds).then(() => {\n              dismiss()\n            })\n          }\n        >\n          {t(\"sidebar.category_unsubscribe_dialog.confirm\", { count })}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx",
    "content": "import { useDroppable } from \"@dnd-kit/core\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { getViewList } from \"@follow/constants\"\nimport { useRefValue } from \"@follow/hooks\"\nimport { useOwnedListByView } from \"@follow/store/list/hooks\"\nimport {\n  useSubscriptionByFeedId,\n  useSubscriptionCategoryExist,\n} from \"@follow/store/subscription/hooks\"\nimport { subscriptionActions, subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { getDefaultCategory } from \"@follow/store/subscription/utils\"\nimport { useSortedIdsByUnread, useUnreadByIds } from \"@follow/store/unread/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport type { MenuItemInput } from \"~/atoms/context-menu\"\nimport { MenuItemSeparator, MenuItemText, useShowContextMenu } from \"~/atoms/context-menu\"\nimport { useGeneralSettingSelector, useHideAllReadSubscriptions } from \"~/atoms/settings/general\"\nimport { ROUTE_FEED_IN_FOLDER } from \"~/constants\"\nimport { useAddFeedToFeedList } from \"~/hooks/biz/useFeedActions\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams, useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\n\nimport { useModalStack } from \"../../components/ui/modal/stacked/hooks\"\nimport { ListCreationModalContent } from \"../settings/tabs/lists/modals\"\nimport { CategoryRemoveDialogContent } from \"./CategoryRemoveDialogContent\"\nimport { CategoryUnsubscribeDialogContent } from \"./CategoryUnsubscribeDialogContent\"\nimport { RenameCategoryForm } from \"./RenameCategoryForm\"\nimport { SortedFeedItems } from \"./SortedFeedItems\"\nimport { feedColumnStyles } from \"./styles\"\nimport { UnreadNumber } from \"./UnreadNumber\"\n\ntype FeedId = string\ninterface FeedCategoryProps {\n  data: FeedId[]\n  view: FeedViewType\n  categoryOpenStateData: Record<string, boolean>\n}\n\nfunction FeedCategoryImpl({\n  data: ids,\n  view: viewOnRoute,\n  categoryOpenStateData,\n}: FeedCategoryProps) {\n  const { t } = useTranslation()\n\n  const sortByUnreadFeedList = useSortedIdsByUnread(ids)\n\n  const navigate = useNavigateEntry()\n\n  const subscription = useSubscriptionByFeedId(ids[0]!)!\n\n  const { view } = subscription\n  const autoGroup = useGeneralSettingSelector((state) => state.autoGroup)\n  const folderName =\n    subscription?.category || (autoGroup ? getDefaultCategory(subscription) : subscription.feedId)\n\n  const isCategory = sortByUnreadFeedList.length > 1 || !!subscription?.category\n\n  const open = useMemo(() => {\n    if (!isCategory) return true\n    if (folderName && typeof categoryOpenStateData[folderName] === \"boolean\") {\n      return categoryOpenStateData[folderName]\n    }\n    return false\n  }, [categoryOpenStateData, folderName, isCategory])\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (viewOnRoute !== undefined && folderName) {\n        subscriptionActions.changeCategoryOpenState(viewOnRoute, folderName, next)\n      }\n    },\n    [folderName, viewOnRoute],\n  )\n\n  const shouldOpen = useRouteParamsSelector(\n    (s) => typeof s.feedId === \"string\" && ids.includes(s.feedId),\n  )\n\n  const scroller = useScrollViewElement()\n  const scrollerRef = useRefValue(scroller)\n  useEffect(() => {\n    if (shouldOpen) {\n      setOpen(true)\n\n      const $items = itemsRef.current\n\n      if (!$items) return\n      const $target = $items.querySelector(\n        `[data-feed-id=\"${getRouteParams().feedId}\"]`,\n      ) as HTMLElement\n      if (!$target) return\n\n      const $scroller = scrollerRef.current\n      if (!$scroller) return\n\n      const scrollTop = $target.offsetTop - $scroller.clientHeight / 2\n      $scroller.scrollTo({\n        top: scrollTop,\n        behavior: \"smooth\",\n      })\n    }\n  }, [scrollerRef, setOpen, shouldOpen])\n\n  const itemsRef = useRef<HTMLDivElement>(null)\n\n  const isMobile = useMobile()\n  const toggleCategoryOpenState = useEventCallback(\n    (e: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {\n      e.stopPropagation()\n      if (!isCategoryEditing && !isMobile) {\n        setCategoryActive()\n      }\n      if (view !== undefined && folderName) {\n        subscriptionActions.toggleCategoryOpenState(viewOnRoute, folderName)\n      }\n    },\n  )\n\n  const handleCollapseButtonClick = useEventCallback((e: React.MouseEvent<HTMLButtonElement>) => {\n    e.stopPropagation()\n    if (view !== undefined && folderName) {\n      subscriptionActions.toggleCategoryOpenState(viewOnRoute, folderName)\n    }\n  })\n\n  const setCategoryActive = () => {\n    if (view !== undefined) {\n      navigate({\n        entryId: null,\n        folderName,\n        view: viewOnRoute,\n      })\n    }\n  }\n\n  const unread = useUnreadByIds(ids)\n\n  const isActive = useRouteParamsSelector(\n    (routerParams) => routerParams.feedId === `${ROUTE_FEED_IN_FOLDER}${folderName}`,\n  )\n  const { present } = useModalStack()\n\n  const { mutateAsync: changeCategoryView, isPending: isChangePending } = useMutation({\n    mutationKey: [\"changeCategoryView\", folderName, view],\n    mutationFn: async (nextView: FeedViewType) => {\n      if (!folderName) return\n      if (typeof view !== \"number\") return\n      return subscriptionSyncService.changeCategoryView({\n        category: folderName,\n        currentView: view,\n        newView: nextView,\n      })\n    },\n  })\n\n  const [isCategoryEditing, setIsCategoryEditing] = useState(false)\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)\n  const isCategoryIsWaiting = isChangePending\n\n  const addMutation = useAddFeedToFeedList()\n\n  const listList = useOwnedListByView(view!)\n  const showContextMenu = useShowContextMenu()\n\n  const subscriptionCategoryExist = useSubscriptionCategoryExist(folderName)\n  const isAutoGroupedCategory = !!folderName && !subscriptionCategoryExist\n\n  const { isOver, setNodeRef } = useDroppable({\n    id: `category-${folderName}`,\n    disabled: isAutoGroupedCategory,\n    data: {\n      category: folderName,\n      view,\n    },\n  })\n\n  const contextMenuProps = useContextMenu({\n    onContextMenu: async (e) => {\n      setIsContextMenuOpen(true)\n      await showContextMenu(\n        [\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.mark_as_read\"),\n            click: () => {\n              unreadSyncService.markFeedAsRead(ids)\n            },\n            requiresLogin: true,\n          }),\n          new MenuItemSeparator(),\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.add_feeds_to_list\"),\n            requiresLogin: true,\n            submenu: listList\n              ?.map(\n                (list) =>\n                  new MenuItemText({\n                    label: list.title || \"\",\n                    click() {\n                      return addMutation.mutate({\n                        feedIds: ids,\n                        listId: list.id,\n                      })\n                    },\n                    requiresLogin: true,\n                  }) as MenuItemInput,\n              )\n              .concat(listList?.length > 0 ? [new MenuItemSeparator()] : [])\n              .concat([\n                new MenuItemText({\n                  label: t(\"sidebar.feed_actions.create_list\"),\n                  click: () => {\n                    present({\n                      title: t(\"sidebar.feed_actions.create_list\"),\n                      content: () => <ListCreationModalContent />,\n                    })\n                  },\n                  requiresLogin: true,\n                }),\n              ]),\n          }),\n          MenuItemSeparator.default,\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.change_to_other_view\"),\n            submenu: getViewList()\n              .filter((v) => v.view !== view && v.switchable)\n              .map(\n                (v) =>\n                  new MenuItemText({\n                    label: t(v.name, { ns: \"common\" }),\n                    shortcut: (v.view + 1).toString(),\n                    icon: v.icon,\n                    click() {\n                      return changeCategoryView(v.view)\n                    },\n                    requiresLogin: true,\n                  }),\n              ),\n            requiresLogin: true,\n          }),\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.rename_category\"),\n            click: () => {\n              setIsCategoryEditing(true)\n            },\n            requiresLogin: true,\n          }),\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.ungroup_category\"),\n            hide: !folderName || isAutoGroupedCategory,\n            click: () => {\n              present({\n                title: t(\"sidebar.feed_column.context_menu.ungroup_category_confirmation\", {\n                  folderName,\n                }),\n                content: () => <CategoryRemoveDialogContent category={folderName!} view={view} />,\n              })\n            },\n            requiresLogin: true,\n          }),\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.unsubscribe_category\"),\n            hide: !folderName || isAutoGroupedCategory,\n            click: () => {\n              present({\n                title: t(\"sidebar.category_unsubscribe_dialog.title\", {\n                  folderName,\n                }),\n                content: () => (\n                  <CategoryUnsubscribeDialogContent category={folderName!} view={view} />\n                ),\n              })\n            },\n            requiresLogin: true,\n          }),\n        ],\n        e,\n      )\n\n      setIsContextMenuOpen(false)\n    },\n  })\n  return (\n    <div tabIndex={-1} onClick={stopPropagation}>\n      {!!isCategory && (\n        <div\n          ref={setNodeRef}\n          data-active={isActive || isContextMenuOpen}\n          className={cn(\n            isOver && \"border-folo bg-folo/60\",\n\n            \"my-px px-2.5\",\n            feedColumnStyles.item,\n          )}\n          data-sub={`feed-category-${folderName}`}\n          onClick={(e) => {\n            e.stopPropagation()\n            if (!isCategoryEditing) {\n              setCategoryActive()\n            }\n          }}\n          {...contextMenuProps}\n        >\n          <div\n            className={cn(\"flex w-full min-w-0 items-center\")}\n            onDoubleClick={toggleCategoryOpenState}\n          >\n            <button\n              data-type=\"collapse\"\n              type=\"button\"\n              onClick={handleCollapseButtonClick}\n              data-state={open ? \"open\" : \"close\"}\n              className={cn(\n                \"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90\",\n              )}\n              tabIndex={-1}\n            >\n              {isCategoryIsWaiting ? (\n                <LoadingCircle size=\"small\" className=\"mr-2 size-[16px]\" />\n              ) : isCategoryEditing ? (\n                <MotionButtonBase\n                  onClick={() => {\n                    setIsCategoryEditing(false)\n                  }}\n                  className=\"center -ml-1 flex size-5 shrink-0 rounded-lg hover:bg-material-ultra-thick\"\n                >\n                  <i className=\"i-mgc-close-cute-re text-red\" />\n                </MotionButtonBase>\n              ) : (\n                <div className=\"center mr-2 size-[16px]\">\n                  <i className=\"i-mgc-right-cute-fi transition-transform\" />\n                </div>\n              )}\n            </button>\n            {isCategoryEditing ? (\n              <RenameCategoryForm\n                currentCategory={folderName!}\n                view={view}\n                onFinished={() => setIsCategoryEditing(false)}\n              />\n            ) : (\n              <Fragment>\n                <span className=\"grow truncate\">{folderName}</span>\n                <UnreadNumber unread={unread} className=\"ml-2\" />\n              </Fragment>\n            )}\n          </div>\n        </div>\n      )}\n      <AnimatePresence initial={false}>\n        {open && (\n          <m.div\n            ref={itemsRef}\n            className=\"space-y-px\"\n            initial={\n              !!isCategory && {\n                height: 0,\n                opacity: 0.01,\n              }\n            }\n            animate={{\n              height: \"auto\",\n              opacity: 1,\n            }}\n            exit={{\n              height: 0,\n              opacity: 0.01,\n            }}\n          >\n            <SortedFeedItems\n              ids={ids}\n              showCollapse={isCategory as boolean}\n              view={view as FeedViewType}\n            />\n          </m.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nfunction FilterReadFeedCategory(props: FeedCategoryProps) {\n  const unread = useUnreadByIds(props.data)\n  if (!unread) return null\n  return <FeedCategoryImpl {...props} />\n}\n\nexport const FeedCategoryAutoHideUnread = memo(function FeedCategoryAutoHideUnread(\n  props: FeedCategoryProps,\n) {\n  const hideAllReadSubscriptions = useHideAllReadSubscriptions()\n  if (hideAllReadSubscriptions) {\n    return <FilterReadFeedCategory {...props} />\n  }\n  return <FeedCategoryImpl {...props} />\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { OouiUserAnonymous } from \"@follow/components/icons/OouiUserAnonymous.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { FeedViewType, getViewList } from \"@follow/constants\"\nimport { isOnboardingFeedUrl } from \"@follow/store/constants/onboarding\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { useUnreadById, useUnreadByListId } from \"@follow/store/unread/hooks\"\nimport { cn, isKeyForMultiSelectPressed } from \"@follow/utils/utils\"\nimport { createElement, memo, use, useCallback, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { MenuItemSeparator, MenuItemText, useShowContextMenu } from \"~/atoms/context-menu\"\nimport { useHideAllReadSubscriptions } from \"~/atoms/settings/general\"\nimport { ErrorTooltip } from \"~/components/common/ErrorTooltip\"\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { useContextMenuActionShortCutTrigger } from \"~/hooks/biz/useContextMenuActionShortCutTrigger\"\nimport { useFeedActions, useInboxActions, useListActions } from \"~/hooks/biz/useFeedActions\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useBatchUpdateSubscription } from \"~/hooks/biz/useSubscriptionActions\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\nimport { getNewIssueUrl } from \"~/lib/issues\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { FeedTitle } from \"~/modules/feed/feed-title\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { useSelectedFeedIdsState } from \"./atom\"\nimport { DraggableContext } from \"./context\"\nimport { feedColumnStyles } from \"./styles\"\nimport { UnreadNumber } from \"./UnreadNumber\"\n\ninterface FeedItemProps {\n  feedId: string\n  view?: number\n  className?: string\n  isPreview?: boolean\n}\n\nconst DraggableItemWrapper: Component<\n  {\n    className?: string\n    isInMultipleSelection: boolean\n  } & React.HTMLAttributes<HTMLDivElement>\n> = ({ children, isInMultipleSelection, ...props }) => {\n  const draggableContext = use(DraggableContext)\n\n  return (\n    <div\n      {...draggableContext?.attributes}\n      {...draggableContext?.listeners}\n      style={isInMultipleSelection ? draggableContext?.style : undefined}\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\nconst FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) => {\n  const { t } = useTranslation()\n  const subscription = useSubscriptionByFeedId(feedId)\n  const navigate = useNavigateEntry()\n\n  // Use current route view for navigation to stay in current view (e.g., All view)\n  const currentRouteView = useRouteParamsSelector((s) => s.view)\n  const navigationView = currentRouteView === FeedViewType.All ? currentRouteView : view\n  const feed = useFeedById(feedId, (feed) => {\n    return {\n      type: feed.type,\n      id: feed.id,\n      title: feed.title,\n      errorAt: feed.errorAt,\n      errorMessage: feed.errorMessage,\n      url: feed.url,\n      image: feed.image,\n      siteUrl: feed.siteUrl,\n      ownerUserId: feed.ownerUserId,\n    }\n  })\n\n  const [selectedFeedIds, setSelectedFeedIds] = useSelectedFeedIdsState()\n\n  const isMobile = useMobile()\n  const isInMultipleSelection = !isMobile && selectedFeedIds.includes(feedId)\n  const isMultiSelectingButNotSelected =\n    !isMobile && selectedFeedIds.length > 0 && !isInMultipleSelection\n\n  const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(\n    (e) => {\n      if (isKeyForMultiSelectPressed(e.nativeEvent)) {\n        return\n      } else {\n        setSelectedFeedIds([feedId])\n      }\n\n      e.stopPropagation()\n      if (navigationView === undefined) return\n      navigate({\n        feedId,\n        entryId: null,\n        view: navigationView,\n      })\n    },\n    [feedId, navigate, setSelectedFeedIds, navigationView],\n  )\n\n  const feedUnread = useUnreadById(feedId)\n\n  const isActive = useRouteParamsSelector((routerParams) => routerParams.feedId === feedId)\n\n  const items = useFeedActions({\n    feedIds: selectedFeedIds,\n    feedId,\n    view,\n  })\n\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList)\n\n  const whenTrigger = when && isActive\n  useContextMenuActionShortCutTrigger(items, whenTrigger)\n\n  const { mutate: changeFeedView } = useBatchUpdateSubscription()\n\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)\n  const showContextMenu = useShowContextMenu()\n  const contextMenuProps = useContextMenu({\n    onContextMenu: useEventCallback(async (e) => {\n      const nextItems = items.concat()\n\n      if (!feed) return\n\n      const isFeed = feed.type === \"feed\" || !feed.type\n      if (isFeed && feed.errorAt && feed.errorMessage) {\n        nextItems.push(\n          MenuItemSeparator.default,\n          new MenuItemText({\n            label: \"Feedback\",\n            click: () => {\n              window.open(\n                getNewIssueUrl({\n                  body:\n                    `### Error\\n\\nError Message: ${feed.errorMessage}\\n\\n### Info\\n\\n` +\n                    `\\`\\`\\`json\\n${JSON.stringify(feed, null, 2)}\\n\\`\\`\\``,\n                  label: \"bug\",\n                  title: `Feed Error: ${feed.title}, ${feed.errorMessage}`,\n                  target: \"discussion\",\n                  category: \"feed-expired\",\n                }),\n              )\n            },\n          }),\n        )\n      }\n\n      // Add \"Switch to Another View\" menu item\n      if (subscription && typeof subscription.view === \"number\") {\n        nextItems.push(\n          MenuItemSeparator.default,\n          new MenuItemText({\n            label: t(\"sidebar.feed_column.context_menu.change_to_other_view\"),\n            submenu: getViewList()\n              .filter((v) => v.view !== subscription.view && v.switchable)\n              .map(\n                (v) =>\n                  new MenuItemText({\n                    label: t(v.name, { ns: \"common\" }),\n                    shortcut: (v.view + 1).toString(),\n                    icon: v.icon,\n                    click() {\n                      return changeFeedView({\n                        feedIdList: [feedId],\n                        view: v.view,\n                      })\n                    },\n                    requiresLogin: true,\n                  }),\n              ),\n            requiresLogin: true,\n          }),\n        )\n      }\n\n      setIsContextMenuOpen(true)\n      await showContextMenu(nextItems, e)\n      setIsContextMenuOpen(false)\n    }),\n  })\n  const follow = useFollow()\n  const handleDoubleClick = useEventCallback(() => {\n    window.open(UrlBuilder.shareFeed(feedId, view), \"_blank\")\n  })\n\n  if (!feed) return null\n\n  const isFeed = feed.type === \"feed\" || !feed.type\n  const isOnboardingFeed = isOnboardingFeedUrl(feed.url)\n\n  return (\n    <DraggableItemWrapper\n      isInMultipleSelection={isInMultipleSelection}\n      data-feed-id={feedId}\n      data-sub={`feed-${feedId}`}\n      data-active={\n        isMultiSelectingButNotSelected\n          ? false\n          : isActive || isContextMenuOpen || isInMultipleSelection\n      }\n      className={cn(\n        feedColumnStyles.item,\n        isFeed ? \"py-0.5\" : \"py-1.5\",\n        \"justify-between py-0.5\",\n\n        className,\n      )}\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      {...contextMenuProps}\n    >\n      <div\n        className={cn(\n          \"flex min-w-0 items-center\",\n          isFeed && feed.errorAt && !isOnboardingFeed && \"text-red\",\n        )}\n      >\n        <FeedIcon fallback target={feed} size={16} />\n        <FeedTitle feed={feed} />\n        {isFeed && !isOnboardingFeed && (\n          <ErrorTooltip errorAt={feed.errorAt} errorMessage={feed.errorMessage}>\n            <i className=\"i-mingcute-close-circle-fill ml-1 shrink-0 text-base\" />\n          </ErrorTooltip>\n        )}\n        {subscription?.isPrivate && !isOnboardingFeed && (\n          <Tooltip delayDuration={300}>\n            <TooltipTrigger>\n              <OouiUserAnonymous className=\"ml-1 shrink-0 text-base\" />\n            </TooltipTrigger>\n            <TooltipPortal>\n              <TooltipContent>{t(\"feed_item.not_publicly_visible\")}</TooltipContent>\n            </TooltipPortal>\n          </Tooltip>\n        )}\n      </div>\n      {isOnboardingFeed && (\n        <Tooltip delayDuration={300}>\n          <TooltipTrigger>\n            <i className=\"i-mingcute-sparkles-line shrink-0 text-base text-text-tertiary\" />\n          </TooltipTrigger>\n          <TooltipPortal>\n            <TooltipContent>{t(\"feed_item.onboarding_feed\")}</TooltipContent>\n          </TooltipPortal>\n        </Tooltip>\n      )}\n      {!isOnboardingFeed && (\n        <>\n          {isPreview ? (\n            <Button\n              size=\"sm\"\n              variant=\"ghost\"\n              buttonClassName=\"!p-1 mr-0.5\"\n              onClick={() => {\n                follow({\n                  isList: false,\n                  id: feedId,\n                  url: feed.url,\n                })\n              }}\n            >\n              <i className=\"i-mgc-add-cute-re text-base text-accent\" />\n            </Button>\n          ) : (\n            <UnreadNumber unread={feedUnread} className=\"ml-2\" />\n          )}\n        </>\n      )}\n    </DraggableItemWrapper>\n  )\n}\n\nconst FilterReadFeedItem: Component<FeedItemProps> = (props) => {\n  const feedUnread = useUnreadById(props.feedId)\n\n  if (!feedUnread) return null\n  return createElement(FeedItemImpl, props)\n}\n\nexport const FeedItem = memo(FeedItemImpl)\n\nexport const FeedItemAutoHideUnread: Component<FeedItemProps> = memo((props) => {\n  const hideAllReadSubscriptions = useHideAllReadSubscriptions()\n  if (hideAllReadSubscriptions) return createElement(FilterReadFeedItem, props)\n  return createElement(FeedItemImpl, props)\n})\n\ninterface ListItemProps {\n  listId: string\n  view: FeedViewType\n  iconSize?: number\n  isPreview?: boolean\n}\n\nconst ListItemImpl: Component<ListItemProps> = ({\n  view,\n  listId,\n  className,\n  iconSize = 22,\n  isPreview,\n}) => {\n  const list = useListById(listId)\n\n  const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId)\n  const items = useListActions({ listId, view })\n\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList)\n  useContextMenuActionShortCutTrigger(items, when && isActive)\n\n  const listUnread = useUnreadByListId(listId)\n\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)\n  const subscription = useSubscriptionByFeedId(listId)!\n  const navigate = useNavigateEntry()\n\n  // Use current route view for navigation to stay in current view (e.g., All view)\n  const currentRouteView = useRouteParamsSelector((s) => s.view)\n  const navigationView = currentRouteView ?? view\n\n  const handleNavigate = useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      e.stopPropagation()\n\n      navigate({\n        listId,\n        entryId: null,\n        view: navigationView,\n      })\n    },\n    [listId, navigate, navigationView],\n  )\n  const showContextMenu = useShowContextMenu()\n  const { t } = useTranslation()\n\n  const contextMenuProps = useContextMenu({\n    onContextMenu: useEventCallback(async (e) => {\n      setIsContextMenuOpen(true)\n      await showContextMenu(items, e)\n      setIsContextMenuOpen(false)\n    }),\n  })\n  const follow = useFollow()\n  const handleDoubleClick = useEventCallback(() => {\n    window.open(UrlBuilder.shareList(listId, view), \"_blank\")\n  })\n\n  if (!list) return null\n  return (\n    <div\n      data-list-id={listId}\n      data-sub={`list-${listId}`}\n      data-active={isActive || isContextMenuOpen}\n      className={cn(feedColumnStyles.item, \"py-1 pl-2.5\", className)}\n      onClick={handleNavigate}\n      onDoubleClick={handleDoubleClick}\n      {...contextMenuProps}\n    >\n      <div className=\"flex min-w-0 flex-1 items-center\">\n        <FeedIcon fallback target={list} size={iconSize} className=\"mask-squircle mask\" />\n        <EllipsisHorizontalTextWithTooltip className=\"truncate\">\n          {getPreferredTitle(list)}\n        </EllipsisHorizontalTextWithTooltip>\n\n        {subscription?.isPrivate && (\n          <Tooltip delayDuration={300}>\n            <TooltipTrigger>\n              <OouiUserAnonymous className=\"ml-1 shrink-0 text-base\" />\n            </TooltipTrigger>\n            <TooltipPortal>\n              <TooltipContent>{t(\"feed_item.not_publicly_visible\")}</TooltipContent>\n            </TooltipPortal>\n          </Tooltip>\n        )}\n      </div>\n      {isPreview ? (\n        <Button\n          size=\"sm\"\n          variant=\"ghost\"\n          buttonClassName=\"!p-1 mr-0.5\"\n          onClick={() => {\n            follow({\n              isList: true,\n              id: listId,\n            })\n          }}\n        >\n          <i className=\"i-mgc-add-cute-re text-base text-accent\" />\n        </Button>\n      ) : (\n        <UnreadNumber unread={listUnread} className=\"ml-2\" />\n      )}\n    </div>\n  )\n}\n\nexport const ListItem = memo(ListItemImpl)\n\nconst FilterReadListItem: Component<ListItemProps> = (props) => {\n  const listUnread = useUnreadByListId(props.listId)\n\n  if (!listUnread) return null\n  return createElement(ListItem, props)\n}\n\nexport const ListItemAutoHideUnread: Component<ListItemProps> = memo((props) => {\n  const hideAllReadSubscriptions = useHideAllReadSubscriptions()\n\n  if (hideAllReadSubscriptions) return createElement(FilterReadListItem, props)\n  return createElement(ListItemImpl, props)\n})\n\ninterface InboxItemProps {\n  inboxId: string\n  view: FeedViewType\n  iconSize?: number\n}\nconst InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, iconSize = 16 }) => {\n  const inbox = useInboxById(inboxId)\n\n  const isActive = useRouteParamsSelector((routerParams) => routerParams.inboxId === inboxId)\n  const { items } = useInboxActions({ inboxId })\n\n  const when = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList)\n  useContextMenuActionShortCutTrigger(items, when && isActive)\n\n  const inboxUnread = useUnreadById(inboxId)\n\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)\n  const navigate = useNavigateEntry()\n\n  // Use current route view for navigation to stay in current view (e.g., All view)\n  const currentRouteView = useRouteParamsSelector((s) => s.view)\n  const navigationView = currentRouteView ?? view\n\n  const handleNavigate = useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      e.stopPropagation()\n\n      navigate({\n        inboxId,\n        entryId: null,\n        view: navigationView,\n      })\n    },\n    [inboxId, navigate, navigationView],\n  )\n  const showContextMenu = useShowContextMenu()\n\n  const contextMenuProps = useContextMenu({\n    onContextMenu: async (e) => {\n      setIsContextMenuOpen(true)\n      await showContextMenu(items, e)\n      setIsContextMenuOpen(false)\n    },\n  })\n  if (!inbox) return null\n  return (\n    <div\n      data-active={isActive || isContextMenuOpen}\n      data-sub={`inbox-${inboxId}`}\n      data-inbox-id={inboxId}\n      className={cn(\n        \"flex w-full cursor-menu items-center justify-between rounded-md pr-2.5 text-base font-medium leading-loose lg:text-sm\",\n        feedColumnStyles.item,\n        \"py-0.5 pl-2.5\",\n        className,\n      )}\n      onClick={handleNavigate}\n      {...contextMenuProps}\n    >\n      <div className={\"flex min-w-0 items-center\"}>\n        <FeedIcon fallback target={inbox} size={iconSize} />\n        <EllipsisHorizontalTextWithTooltip className=\"truncate\">\n          {getPreferredTitle(inbox)}\n        </EllipsisHorizontalTextWithTooltip>\n      </div>\n      <UnreadNumber unread={inboxUnread} className=\"ml-2\" />\n    </div>\n  )\n}\n\nexport const InboxItem = memo(InboxItemImpl)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/RenameCategoryForm.tsx",
    "content": "import { MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { ShrinkingFocusBorder } from \"@follow/components/ui/shrinking-focus-border/index.js\"\nimport type { FeedViewType } from \"@follow/constants\"\nimport { useInputComposition } from \"@follow/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { useOnClickOutside } from \"usehooks-ts\"\n\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { createErrorToaster } from \"~/lib/error-parser\"\n\nexport const RenameCategoryForm = ({\n  currentCategory,\n  view,\n  onFinished,\n}: {\n  currentCategory: string\n  view: FeedViewType\n  onFinished: () => void\n}) => {\n  const navigate = useNavigateEntry()\n  const { t } = useTranslation()\n  const renameMutation = useMutation({\n    mutationFn: async ({\n      lastCategory,\n      newCategory,\n    }: {\n      lastCategory: string\n      newCategory: string\n    }) => subscriptionSyncService.renameCategory({ lastCategory, newCategory, view }),\n    onMutate({ lastCategory, newCategory }) {\n      const routeParams = getRouteParams()\n\n      if (routeParams.folderName === lastCategory) {\n        navigate({\n          folderName: newCategory,\n        })\n      }\n\n      onFinished()\n    },\n    onError: createErrorToaster(t(\"sidebar.feed_column.context_menu.rename_category_error\")),\n    onSuccess: () => {\n      toast.success(t(\"sidebar.feed_column.context_menu.rename_category_success\"))\n    },\n  })\n  const formRef = useRef<HTMLFormElement | null>(null)\n  const [isFocused, setIsFocused] = useState(false)\n\n  useOnClickOutside(\n    formRef as React.RefObject<HTMLElement>,\n    () => {\n      onFinished()\n    },\n    \"mousedown\",\n  )\n  const inputRef = useRef<HTMLInputElement>(null)\n  useEffect(() => {\n    nextFrame(() => {\n      inputRef.current?.focus()\n      setIsFocused(true)\n    })\n  }, [])\n  const compositionInputProps = useInputComposition({\n    onKeyDown: (e) => {\n      if (e.key === \"Escape\") {\n        onFinished()\n      }\n    },\n  })\n  return (\n    <div className=\"relative ml-3 flex h-8 w-full items-center\">\n      <ShrinkingFocusBorder isVisible={isFocused} containerRef={inputRef} persistBorder />\n      <form\n        ref={formRef}\n        className=\"flex w-full items-center\"\n        onSubmit={(e) => {\n          e.preventDefault()\n\n          return renameMutation.mutateAsync({\n            lastCategory: currentCategory!,\n            newCategory: e.currentTarget.category.value,\n          })\n        }}\n      >\n        <input\n          {...compositionInputProps}\n          ref={inputRef}\n          name=\"category\"\n          autoFocus\n          defaultValue={currentCategory}\n          className=\"w-full appearance-none bg-transparent px-2 py-1 caret-accent\"\n          onFocus={() => setIsFocused(true)}\n          onBlur={() => setIsFocused(false)}\n        />\n        <MotionButtonBase\n          type=\"submit\"\n          className=\"center -mr-1 flex size-5 shrink-0 rounded-lg text-green hover:bg-material-ultra-thick\"\n        >\n          <i className=\"i-mgc-check-filled size-3\" />\n        </MotionButtonBase>\n      </form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/SimpleDiscoverModal.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { SegmentGroup, SegmentItem } from \"@follow/components/ui/segment/index.js\"\nimport type { DiscoveryItem } from \"@follow-app/client-sdk\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { atom, useAtomValue, useStore } from \"jotai\"\nimport type { ChangeEvent } from \"react\"\nimport { useCallback, useState } from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { Link } from \"react-router\"\nimport { z } from \"zod\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { followClient } from \"~/lib/api-client\"\n\nimport { DiscoverFeedCard } from \"../discover/DiscoverFeedCard\"\nimport { FeedForm } from \"../discover/FeedForm\"\n\nconst formSchema = z.object({\n  keyword: z.string().min(1),\n  type: z.enum([\"search\", \"rss\", \"rsshub\"]),\n})\n\nconst typeConfig = {\n  search: {\n    label: \"discover.any_url_or_keyword\",\n    placeholder: \"Enter keywords or URL...\",\n    prefix: [] as string[],\n    default: undefined,\n  },\n  rss: {\n    label: \"discover.rss_url\",\n    placeholder: \"https://example.com/feed.xml\",\n    prefix: [\"https://\", \"http://\"],\n    default: \"https://\",\n  },\n  rsshub: {\n    label: \"discover.rss_hub_route\",\n    placeholder: \"rsshub://github/issue/follow/follow\",\n    prefix: [\"rsshub://\"],\n    default: \"rsshub://\",\n  },\n} as const\n\nexport function SimpleDiscoverModal({ dismiss }: { dismiss: () => void }) {\n  const { t } = useTranslation()\n  const { present } = useModalStack()\n  const jotaiStore = useStore()\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      keyword: \"\",\n      type: \"search\",\n    },\n  })\n\n  const watchedType = form.watch(\"type\")\n  const currentConfig = typeConfig[watchedType]\n\n  const discoverSearchDataAtom = useState(() => atom<DiscoveryItem[]>())[0]\n  const discoverSearchData = useAtomValue(discoverSearchDataAtom)\n\n  const mutation = useMutation({\n    mutationFn: async ({ keyword, type }: { keyword: string; type: string }) => {\n      // For RSS/RSSHub, show feed form modal\n      if (type === \"rss\" || type === \"rsshub\") {\n        present({\n          title: t(\"feed_form.add_feed\"),\n          content: ({ dismiss: dismissFeedForm }) => (\n            <FeedForm\n              url={keyword}\n              onSuccess={() => {\n                dismissFeedForm()\n                dismiss()\n              }}\n            />\n          ),\n        })\n        return []\n      }\n\n      const { data } = await followClient.api.discover.discover({\n        keyword: keyword.trim(),\n        target: \"feeds\",\n      })\n\n      jotaiStore.set(discoverSearchDataAtom, data)\n      return data\n    },\n  })\n\n  const handleKeywordChange = useCallback(\n    (event: ChangeEvent<HTMLInputElement>) => {\n      const trimmedKeyword = event.target.value.trimStart()\n      const { prefix } = currentConfig\n\n      if (!prefix || prefix.length === 0) {\n        form.setValue(\"keyword\", trimmedKeyword, { shouldValidate: true })\n        return\n      }\n\n      const isValidPrefix = prefix.find((p) => trimmedKeyword.startsWith(p))\n      if (!isValidPrefix) {\n        form.setValue(\"keyword\", prefix[0]!)\n        return\n      }\n\n      if (trimmedKeyword.startsWith(`${isValidPrefix}${isValidPrefix}`)) {\n        form.setValue(\"keyword\", trimmedKeyword.slice(isValidPrefix.length))\n        return\n      }\n\n      form.setValue(\"keyword\", trimmedKeyword)\n    },\n    [form, currentConfig],\n  )\n\n  const handleTypeChange = useCallback(\n    (value: string) => {\n      form.setValue(\"type\", value as any)\n      const newConfig = typeConfig[value as keyof typeof typeConfig]\n      if (newConfig.default) {\n        form.setValue(\"keyword\", newConfig.default)\n      } else {\n        form.setValue(\"keyword\", \"\")\n      }\n    },\n    [form],\n  )\n\n  function onSubmit(values: z.infer<typeof formSchema>) {\n    mutation.mutate(values)\n  }\n\n  return (\n    <div className=\"flex min-h-[400px] w-[600px] flex-col\">\n      <div className=\"mb-6\">\n        <p className=\"text-sm text-text-secondary\">\n          {t(\"discover.find_feeds_description\", \"Find and add new feeds to your collection\")}\n        </p>\n      </div>\n\n      <Form {...form}>\n        <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n          {/* Type Selector */}\n          <FormField\n            control={form.control}\n            name=\"type\"\n            render={({ field }) => (\n              <FormItem className=\"relative\">\n                <FormControl>\n                  <SegmentGroup value={field.value} onValueChanged={handleTypeChange}>\n                    <SegmentItem value=\"search\" label={t(\"words.search\")} />\n                    <SegmentItem value=\"rss\" label={t(\"words.rss\")} />\n                    <SegmentItem value=\"rsshub\" label={t(\"words.rsshub\")} />\n                  </SegmentGroup>\n                </FormControl>\n                <div className=\"absolute bottom-0 right-0 flex flex-col flex-wrap items-end gap-1 text-sm text-text-secondary\">\n                  <div>\n                    Or go to{\" \"}\n                    <Link className=\"text-accent underline\" to=\"/discover\" onClick={dismiss}>\n                      Discover\n                    </Link>\n                    <i className=\"i-mgc-arrow-right-up-cute-re\" />\n                  </div>\n\n                  <p>to find more interesting contents.</p>\n                </div>\n              </FormItem>\n            )}\n          />\n\n          {/* Input Field */}\n          <FormField\n            control={form.control}\n            name=\"keyword\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t(currentConfig.label)}</FormLabel>\n                <FormControl>\n                  <Input\n                    placeholder={currentConfig.placeholder}\n                    {...field}\n                    onChange={handleKeywordChange}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n\n          {/* Action Buttons */}\n          <div className=\"flex items-center justify-end gap-3\">\n            <Button type=\"button\" variant=\"outline\" onClick={dismiss}>\n              {t(\"words.cancel\", { ns: \"common\" })}\n            </Button>\n            <Button type=\"submit\" disabled={mutation.isPending}>\n              {mutation.isPending ? t(\"words.searching\", \"Searching...\") : t(\"words.search\")}\n            </Button>\n          </div>\n        </form>\n      </Form>\n\n      {/* Search Results */}\n      {discoverSearchData && discoverSearchData.length > 0 && (\n        <div className=\"mt-6 flex-1\">\n          <div className=\"mb-4 border-b border-border pb-2\">\n            <h3 className=\"font-medium text-text\">\n              {t(\"discover.search_results\", \"Search Results\")} ({discoverSearchData.length})\n            </h3>\n          </div>\n          <div className=\"max-h-[300px] space-y-3 overflow-y-auto\">\n            {discoverSearchData.map((item, index) => (\n              <DiscoverFeedCard key={index} item={item} />\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Empty State */}\n      {mutation.isSuccess && discoverSearchData && discoverSearchData.length === 0 && (\n        <div className=\"mt-6 flex flex-1 items-center justify-center\">\n          <div className=\"text-center text-text-secondary\">\n            <i className=\"i-mgc-search-3-cute-re mb-2 text-2xl\" />\n            <p>{t(\"discover.no_results\", \"No feeds found for your search.\")}</p>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/SortedFeedItems.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { isOnboardingFeedUrl } from \"@follow/store/constants/onboarding\"\nimport { useFeedStore } from \"@follow/store/feed/store\"\nimport { useSortedIdsByUnread } from \"@follow/store/unread/hooks\"\nimport { sortByAlphabet } from \"@follow/utils/utils\"\nimport { Fragment, memo, useCallback } from \"react\"\n\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { useFeedListSortSelector } from \"./atom\"\nimport { FeedItemAutoHideUnread } from \"./FeedItem\"\n\ntype SortListProps = {\n  ids: string[]\n  view: FeedViewType\n  showCollapse: boolean\n}\n\nexport const SortedFeedItems = memo((props: SortListProps) => {\n  const by = useFeedListSortSelector((s) => s.by)\n  switch (by) {\n    case \"count\": {\n      return <SortByUnreadList {...props} />\n    }\n    case \"alphabetical\": {\n      return <SortByAlphabeticalList {...props} />\n    }\n\n    default: {\n      return <SortByUnreadList {...props} />\n    }\n  }\n})\n\nconst SortByAlphabeticalList = (props: SortListProps) => {\n  const { ids, showCollapse, view } = props\n  const isDesc = useFeedListSortSelector((s) => s.order === \"desc\")\n  const sortedFeedList = useFeedStore(\n    useCallback(\n      (state) => {\n        // Separate onboarding feeds and regular feeds\n        const onboardingFeeds: string[] = []\n        const regularFeeds: string[] = []\n\n        for (const id of ids) {\n          const feed = state.feeds[id]\n          if (feed && isOnboardingFeedUrl(feed.url)) {\n            onboardingFeeds.push(id)\n          } else {\n            regularFeeds.push(id)\n          }\n        }\n\n        // Sort each group\n        const sortFeeds = (feedIds: string[]) => {\n          const sorted = feedIds.sort((a, b) => {\n            const feedTitleA = getPreferredTitle(state.feeds[a]) || \"\"\n            const feedTitleB = getPreferredTitle(state.feeds[b]) || \"\"\n            return sortByAlphabet(feedTitleA, feedTitleB)\n          })\n          return isDesc ? sorted : sorted.reverse()\n        }\n\n        // Return onboarding feeds first, then regular feeds\n        return [...sortFeeds(onboardingFeeds), ...sortFeeds(regularFeeds)]\n      },\n      [ids, isDesc],\n    ),\n  )\n  return (\n    <Fragment>\n      {sortedFeedList.map((feedId) => (\n        <FeedItemAutoHideUnread\n          key={feedId}\n          feedId={feedId}\n          view={view}\n          className={showCollapse ? \"pl-6\" : \"pl-2.5\"}\n        />\n      ))}\n    </Fragment>\n  )\n}\n\nconst SortByUnreadList = ({ ids, showCollapse, view }: SortListProps) => {\n  const isDesc = useFeedListSortSelector((s) => s.order === \"desc\")\n  const sortByUnreadFeedList = useSortedIdsByUnread(ids, isDesc)\n\n  // Separate onboarding feeds and regular feeds, then merge with onboarding first\n  const sortedList = useFeedStore(\n    useCallback(\n      (state) => {\n        const onboardingFeeds: string[] = []\n        const regularFeeds: string[] = []\n\n        for (const id of sortByUnreadFeedList) {\n          const feed = state.feeds[id]\n          if (feed && isOnboardingFeedUrl(feed.url)) {\n            onboardingFeeds.push(id)\n          } else {\n            regularFeeds.push(id)\n          }\n        }\n\n        return [...onboardingFeeds, ...regularFeeds]\n      },\n      [sortByUnreadFeedList],\n    ),\n  )\n\n  return (\n    <Fragment>\n      {sortedList.map((feedId) => (\n        <FeedItemAutoHideUnread\n          key={feedId}\n          feedId={feedId}\n          view={view}\n          className={showCollapse ? \"pl-6\" : \"pl-2.5\"}\n        />\n      ))}\n    </Fragment>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { memo, useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useNavigate } from \"react-router\"\nimport { toast } from \"sonner\"\n\nimport { setTimelineColumnShow, useSubscriptionColumnShow } from \"~/atoms/sidebar\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useBackHome } from \"~/hooks/biz/useNavigateEntry\"\nimport { useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useI18n } from \"~/hooks/common\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\nimport { copyToClipboard } from \"~/lib/clipboard\"\nimport { ProfileButton } from \"~/modules/user/ProfileButton\"\n\nexport const SubscriptionColumnHeader = memo(() => {\n  const timelineId = useRouteParamsSelector((s) => s.timelineId)\n  const navigateBackHome = useBackHome(timelineId)\n  const navigate = useNavigate()\n  const normalStyle = !window.electron || window.electron.process.platform !== \"darwin\"\n  const { t } = useTranslation()\n  return (\n    <div\n      className={cn(\n        \"ml-5 mr-3 flex items-center\",\n\n        normalStyle ? \"ml-4 justify-between\" : \"justify-end\",\n      )}\n    >\n      {normalStyle && (\n        <LogoContextMenu>\n          <div\n            className=\"relative flex items-center gap-1 text-lg font-semibold\"\n            onClick={(e) => {\n              e.stopPropagation()\n              navigateBackHome()\n            }}\n          >\n            <Logo className=\"mr-1 size-6\" />\n            <Folo className=\"size-8\" />\n          </div>\n        </LogoContextMenu>\n      )}\n      <div className=\"relative flex items-center gap-2\" onClick={stopPropagation}>\n        <ActionButton\n          data-testid=\"subscription-discover-trigger\"\n          shortcut=\"$mod+T\"\n          tooltip={t(\"words.discover\")}\n          onClick={() => navigate(\"/discover\")}\n        >\n          <i className=\"i-mgc-add-cute-re size-5 text-text-secondary\" />\n        </ActionButton>\n\n        <ProfileButton method=\"modal\" animatedAvatar />\n        <LayoutActionButton />\n      </div>\n    </div>\n  )\n})\n\nconst LayoutActionButton = () => {\n  const feedColumnShow = useSubscriptionColumnShow()\n\n  const [animation, setAnimation] = useState({ width: !feedColumnShow ? \"auto\" : 0 })\n  useEffect(() => {\n    setAnimation({ width: !feedColumnShow ? \"auto\" : 0 })\n  }, [feedColumnShow])\n\n  const t = useI18n()\n\n  if (feedColumnShow) return null\n\n  return (\n    <m.div initial={animation} animate={animation} className=\"overflow-hidden\">\n      <ActionButton\n        tooltip={t(\"app.toggle_sidebar\")}\n        icon={\n          <i\n            className={cn(\n              !feedColumnShow\n                ? \"i-mgc-layout-leftbar-open-cute-re\"\n                : \"i-mgc-layout-leftbar-close-cute-re\",\n              \"text-text-secondary\",\n            )}\n          />\n        }\n        onClick={() => {\n          setTimelineColumnShow(!feedColumnShow)\n        }}\n      />\n    </m.div>\n  )\n}\n\nconst LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {\n  const [open, setOpen] = useState(false)\n  const logoRef = useRef<SVGSVGElement>(null)\n  const t = useI18n()\n  const contextMenuProps = useContextMenu({\n    onContextMenu: () => {\n      setOpen(true)\n    },\n  })\n\n  const logoTextRef = useRef<SVGSVGElement>(null)\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen}>\n      <DropdownMenuTrigger asChild {...contextMenuProps}>\n        {children}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent>\n        <DropdownMenuItem\n          onClick={() => {\n            copyToClipboard(logoRef.current?.outerHTML || \"\")\n            setOpen(false)\n            toast.success(t.common(\"app.copied_to_clipboard\"))\n          }}\n        >\n          <Logo ref={logoRef} className=\"hidden\" />\n          <span>{t(\"app.copy_logo_svg\")}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => {\n            copyToClipboard(logoTextRef.current?.outerHTML || \"\")\n            setOpen(false)\n            toast.success(t.common(\"app.copied_to_clipboard\"))\n          }}\n        >\n          <Folo ref={logoTextRef} className=\"hidden\" />\n          <span>{t(\"app.copy_logo_text_svg\")}</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx",
    "content": "import { useDroppable } from \"@dnd-kit/core\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { FeedViewType, getView } from \"@follow/constants\"\nimport { useUnreadByView } from \"@follow/store/unread/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FC } from \"react\"\nimport { startTransition, useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { MenuItemText, useShowContextMenu } from \"~/atoms/context-menu\"\nimport { setUISetting, useUISettingKey } from \"~/atoms/settings/ui\"\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { parseView, useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useTimelineList } from \"~/hooks/biz/useTimelineList\"\nimport { useContextMenu } from \"~/hooks/common/useContextMenu\"\n\nimport { resetSelectedFeedIds } from \"./atom\"\nimport { useShowTimelineTabsSettingsModal } from \"./TimelineTabsSettingsModal\"\n\nconst getTimelineTabTestId = (name: string) =>\n  `timeline-tab-${name.split(\".\").pop()?.replaceAll(\"_\", \"-\")}`\n\nexport function SubscriptionTabButton({\n  timelineId,\n  shortcut,\n}: {\n  timelineId: string\n  shortcut: string\n}) {\n  const activeTimelineId = useRouteParamsSelector((s) => s.timelineId)\n\n  const isActive = activeTimelineId === timelineId\n  const navigate = useNavigateEntry()\n  const navigateToTimeline = useCallback(\n    (nextTimelineId: string) => {\n      navigate({\n        timelineId: nextTimelineId,\n        feedId: null,\n        entryId: null,\n      })\n      resetSelectedFeedIds()\n    },\n    [navigate],\n  )\n  const setActive = useCallback(() => {\n    navigateToTimeline(timelineId)\n  }, [navigateToTimeline, timelineId])\n\n  const view = parseView(timelineId)\n\n  if (view === FeedViewType.All) {\n    return (\n      <ViewAllSwitchButton\n        timelineId={timelineId}\n        isActive={isActive}\n        setActive={setActive}\n        shortcut={shortcut}\n        navigateToTimeline={navigateToTimeline}\n      />\n    )\n  } else if (typeof view === \"number\") {\n    return (\n      <ViewSwitchButton\n        view={view}\n        timelineId={timelineId}\n        isActive={isActive}\n        setActive={setActive}\n        shortcut={shortcut}\n        navigateToTimeline={navigateToTimeline}\n      />\n    )\n  }\n}\n\nconst useSubscriptionTabContextMenu = ({\n  timelineId,\n  isActive,\n  navigateToTimeline,\n}: {\n  timelineId: string\n  isActive: boolean\n  navigateToTimeline: (timelineId: string) => void\n}) => {\n  const { t } = useTranslation()\n  const showContextMenu = useShowContextMenu()\n  const showTimelineTabsSettingsModal = useShowTimelineTabsSettingsModal()\n  const visibleTimelineList = useTimelineList({ withAll: true, visible: true })\n  const hiddenTimelineList = useTimelineList({ withAll: true, hidden: true })\n\n  const canHide = visibleTimelineList.filter((id) => id !== timelineId).length > 0\n\n  const handleHide = useCallback(() => {\n    if (!canHide) return\n\n    const nextVisible = visibleTimelineList.filter((id) => id !== timelineId)\n    const nextHidden = hiddenTimelineList.filter((id) => id !== timelineId).concat(timelineId)\n    setUISetting(\"timelineTabs\", {\n      visible: nextVisible,\n      hidden: nextHidden,\n    })\n\n    if (isActive) {\n      const currentIndex = visibleTimelineList.indexOf(timelineId)\n      const fallbackTimelineId =\n        nextVisible[currentIndex] ?? nextVisible[currentIndex - 1] ?? nextVisible[0]\n\n      if (fallbackTimelineId) {\n        navigateToTimeline(fallbackTimelineId)\n      }\n    }\n  }, [canHide, hiddenTimelineList, isActive, navigateToTimeline, timelineId, visibleTimelineList])\n\n  const contextMenuProps = useContextMenu({\n    onContextMenu: async (event) => {\n      event.preventDefault()\n      event.stopPropagation()\n      await showContextMenu(\n        [\n          new MenuItemText({\n            label: t(\"sidebar.timeline_tabs.hide_tab\"),\n            click: handleHide,\n            disabled: !canHide,\n            requiresLogin: true,\n          }),\n          new MenuItemText({\n            label: t(\"sidebar.timeline_tabs.customize\"),\n            click: showTimelineTabsSettingsModal,\n            requiresLogin: true,\n          }),\n        ],\n        event,\n      )\n    },\n  })\n\n  return contextMenuProps\n}\n\nconst ViewAllSwitchButton: FC<{\n  timelineId: string\n  isActive: boolean\n  setActive: () => void\n  shortcut: string\n  navigateToTimeline: (timelineId: string) => void\n}> = ({ timelineId, isActive, setActive, shortcut, navigateToTimeline }) => {\n  const unreadByView = useUnreadByView(FeedViewType.All)\n  const { t } = useTranslation()\n  const showSidebarUnreadCount = useUISettingKey(\"sidebarShowUnreadCount\")\n  const item = getView(FeedViewType.All)\n  const contextMenuProps = useSubscriptionTabContextMenu({\n    timelineId,\n    isActive,\n    navigateToTimeline,\n  })\n\n  return (\n    <ActionButton\n      data-testid={getTimelineTabTestId(item.name)}\n      aria-pressed={isActive}\n      shortcutScope={FocusablePresets.isNotFloatingLayerScope}\n      key={item.name}\n      tooltip={t(item.name, { ns: \"common\" })}\n      shortcut={shortcut}\n      className={cn(\n        isActive && item.className,\n        \"flex h-11 w-8 shrink-0 grow flex-col items-center gap-1 text-[1.375rem]\",\n        ELECTRON ? \"hover:!bg-theme-item-hover\" : \"\",\n      )}\n      {...contextMenuProps}\n      onClick={(e) => {\n        startTransition(() => {\n          setActive()\n        })\n        e.stopPropagation()\n      }}\n    >\n      {item.icon}\n      {showSidebarUnreadCount ? (\n        <div className=\"text-[0.625rem] font-medium leading-none\">\n          {unreadByView > 99 ? <span className=\"-mr-0.5\">99+</span> : unreadByView}\n        </div>\n      ) : (\n        <i\n          className={cn(\n            \"i-mgc-round-cute-fi text-[0.25rem]\",\n            unreadByView ? (isActive ? \"opacity-100\" : \"opacity-60\") : \"opacity-0\",\n          )}\n        />\n      )}\n    </ActionButton>\n  )\n}\n\nconst ViewSwitchButton: FC<{\n  view: FeedViewType\n  timelineId: string\n  isActive: boolean\n  setActive: () => void\n  shortcut: string\n  navigateToTimeline: (timelineId: string) => void\n}> = ({ view, timelineId, isActive, setActive, shortcut, navigateToTimeline }) => {\n  const unreadByView = useUnreadByView(view)\n  const { t } = useTranslation()\n  const showSidebarUnreadCount = useUISettingKey(\"sidebarShowUnreadCount\")\n  const item = getView(view)\n\n  const { isOver, setNodeRef } = useDroppable({\n    id: `view-${item.name}`,\n    data: {\n      view: item.view,\n    },\n  })\n  const contextMenuProps = useSubscriptionTabContextMenu({\n    timelineId,\n    isActive,\n    navigateToTimeline,\n  })\n\n  return (\n    <ActionButton\n      data-testid={getTimelineTabTestId(item.name)}\n      aria-pressed={isActive}\n      shortcutScope={FocusablePresets.isNotFloatingLayerScope}\n      ref={setNodeRef}\n      key={item.name}\n      tooltip={t(item.name, { ns: \"common\" })}\n      shortcut={shortcut}\n      className={cn(\n        isActive && item.className,\n        \"flex h-11 w-8 shrink-0 grow flex-col items-center gap-1 text-[1.375rem]\",\n        ELECTRON ? \"hover:!bg-theme-item-hover\" : \"\",\n        isOver && \"border-orange-400 bg-orange-400/60\",\n      )}\n      {...contextMenuProps}\n      onClick={(e) => {\n        startTransition(() => {\n          setActive()\n        })\n        e.stopPropagation()\n      }}\n    >\n      {item.icon}\n      {showSidebarUnreadCount ? (\n        <div className=\"text-[0.625rem] font-medium leading-none\">\n          {unreadByView > 99 ? <span className=\"-mr-0.5\">99+</span> : unreadByView}\n        </div>\n      ) : (\n        <i\n          className={cn(\n            \"i-mgc-round-cute-fi text-[0.25rem]\",\n            unreadByView ? (isActive ? \"opacity-100\" : \"opacity-60\") : \"opacity-0\",\n          )}\n        />\n      )}\n    </ActionButton>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx",
    "content": "import type { DragOverEvent, UniqueIdentifier } from \"@dnd-kit/core\"\nimport {\n  closestCenter,\n  DndContext,\n  KeyboardSensor,\n  PointerSensor,\n  useDroppable,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\"\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\"\nimport { CSS } from \"@dnd-kit/utilities\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { getView } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { CSSProperties, ReactNode } from \"react\"\nimport { useCallback, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setUISetting } from \"~/atoms/settings/ui\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { parseView } from \"~/hooks/biz/useRouteParams\"\nimport { useTimelineList } from \"~/hooks/biz/useTimelineList\"\n\nfunction ContainerDroppable({\n  id,\n  children,\n  emptyLabel,\n  hasItems,\n}: {\n  id: \"visible\" | \"hidden\"\n  children: ReactNode\n  emptyLabel: string\n  hasItems: boolean\n}) {\n  const { setNodeRef, isOver } = useDroppable({ id, data: { container: id } })\n  return (\n    <div\n      ref={setNodeRef}\n      className={cn(\n        \"flex min-h-[120px] w-full flex-col items-stretch justify-center rounded-xl border border-border bg-material-ultra-thin p-3 shadow-sm transition-colors\",\n        isOver && \"border-accent/50 bg-accent/5 ring-2 ring-accent/20\",\n      )}\n    >\n      {hasItems ? (\n        children\n      ) : (\n        <p className=\"px-3 py-6 text-center text-sm text-text-tertiary\">{emptyLabel}</p>\n      )}\n    </div>\n  )\n}\n\nconst areArraysEqual = (a: string[], b: string[]) =>\n  a.length === b.length && a.every((value, index) => value === b[index])\n\nfunction getViewMeta(timelineId: string) {\n  const id = parseView(timelineId)\n  if (typeof id !== \"number\") return { name: timelineId, icon: null }\n  const item = getView(id)\n  return { name: item?.name ?? String(id), icon: item?.icon ?? null }\n}\n\nfunction TabItem({ id }: { id: UniqueIdentifier }) {\n  const meta = getViewMeta(String(id))\n  const { t } = useTranslation()\n  return (\n    <div className=\"flex w-full items-center gap-2 rounded-lg border border-transparent bg-background/60 p-2.5 hover:bg-material-opaque\">\n      <div className=\"flex size-6 items-center justify-center text-lg\">{meta.icon}</div>\n      <div className=\"text-callout text-text-secondary\">\n        {t(meta.name as any, { ns: \"common\" })}\n      </div>\n    </div>\n  )\n}\n\nfunction SortableTabItem({ id }: { id: UniqueIdentifier }) {\n  const { t } = useTranslation(\"app\")\n  const meta = getViewMeta(String(id))\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n    id,\n  })\n  const style = useMemo(() => {\n    return {\n      transform: CSS.Transform.toString(transform),\n      transition,\n      zIndex: isDragging ? 999 : undefined,\n    } as CSSProperties\n  }, [transform, transition, isDragging])\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      className={cn(\n        isDragging ? \"cursor-grabbing\" : \"cursor-grab\",\n        \"rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30\",\n      )}\n      aria-label={`${t(\"sidebar.timeline_tabs.drag_tab\")}: ${t(meta.name as any, { ns: \"common\" })}`}\n      {...attributes}\n      {...listeners}\n    >\n      <TabItem id={id} />\n    </div>\n  )\n}\n\nfunction useResolvedTimelineTabs() {\n  const timelineList = useTimelineList({ visible: true })\n  const timelineListHidden = useTimelineList({ hidden: true })\n\n  return { visible: timelineList, hidden: timelineListHidden }\n}\n\nconst TimelineTabsSettings = () => {\n  const { t } = useTranslation([\"app\", \"common\", \"settings\"])\n  const { visible, hidden } = useResolvedTimelineTabs()\n\n  const commitTimelineTabs = useCallback(\n    (nextVisible: string[], nextHidden: string[]) => {\n      if (areArraysEqual(nextVisible, visible) && areArraysEqual(nextHidden, hidden)) return\n      setUISetting(\"timelineTabs\", { visible: nextVisible, hidden: nextHidden })\n    },\n    [hidden, visible],\n  )\n\n  const sensors = useSensors(\n    useSensor(PointerSensor),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  )\n\n  const handleDragOver = useCallback(\n    (event: DragOverEvent) => {\n      const { active, over } = event\n      if (!over) return\n      const activeId = String(active.id)\n      const overId = String(over.id)\n\n      const current = (key: \"visible\" | \"hidden\") => (key === \"visible\" ? visible : hidden)\n      const isActiveInVisible = visible.includes(activeId)\n\n      // Determine hovered list\n      const overContainer = (over.data?.current as any)?.container as\n        | \"visible\"\n        | \"hidden\"\n        | undefined\n      const overKey: \"visible\" | \"hidden\" =\n        overContainer || (visible.includes(overId) ? \"visible\" : \"hidden\")\n\n      const isCross = (isActiveInVisible ? \"visible\" : \"hidden\") !== overKey\n\n      if (isCross) {\n        const sourceKey = isActiveInVisible ? \"visible\" : \"hidden\"\n        const targetKey = overKey\n        const sourceList = current(sourceKey)\n        const targetList = current(targetKey)\n\n        // Normal cross-container insert\n        const newIndexOfOver = targetList.indexOf(overId)\n        const insertIndex = newIndexOfOver !== -1 ? newIndexOfOver : targetList.length\n        const nextSource = sourceList.filter((i) => i !== activeId)\n        const nextTarget = [\n          ...targetList.slice(0, insertIndex),\n          activeId,\n          ...targetList.slice(insertIndex),\n        ]\n        const nextVisible =\n          sourceKey === \"visible\" ? nextSource : targetKey === \"visible\" ? nextTarget : visible\n        const nextHidden =\n          sourceKey === \"hidden\" ? nextSource : targetKey === \"hidden\" ? nextTarget : hidden\n        commitTimelineTabs(nextVisible, nextHidden)\n        return\n      }\n\n      // Reorder within list\n      const listKey = isActiveInVisible ? \"visible\" : \"hidden\"\n      const items = current(listKey)\n      const oldIndex = items.indexOf(activeId)\n      const newIndex = items.indexOf(overId)\n      if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return\n      const reordered = arrayMove(items, oldIndex, newIndex)\n      const nextVisible = listKey === \"visible\" ? reordered : visible\n      const nextHidden = listKey === \"hidden\" ? reordered : hidden\n      commitTimelineTabs(nextVisible, nextHidden)\n    },\n    [commitTimelineTabs, hidden, visible],\n  )\n\n  return (\n    <div\n      className=\"mx-auto w-[600px] max-w-full space-y-4 overflow-hidden pt-2\"\n      onPointerDown={(e) => e.stopPropagation()}\n    >\n      <div className=\"space-y-1 px-1\">\n        <p className=\"text-sm text-text-secondary\">\n          {t(\"appearance.customize_sub_tabs.description\", { ns: \"settings\" })}\n        </p>\n        <p className=\"text-xs text-text-tertiary\">{t(\"sidebar.timeline_tabs.instructions\")}</p>\n      </div>\n      <DndContext\n        sensors={sensors}\n        collisionDetection={closestCenter}\n        onDragOver={handleDragOver}\n        onDragEnd={handleDragOver}\n      >\n        <div className=\"space-y-4\">\n          <div>\n            <h3 className=\"mb-2 text-subheadline font-medium text-text\">\n              {t(\"sidebar.timeline_tabs.visible\")}\n            </h3>\n            <ContainerDroppable\n              id=\"visible\"\n              emptyLabel={t(\"sidebar.timeline_tabs.empty_visible\")}\n              hasItems={visible.length > 0}\n            >\n              <SortableContext items={visible} strategy={verticalListSortingStrategy}>\n                {visible.map((id) => (\n                  <SortableTabItem key={id} id={id} />\n                ))}\n              </SortableContext>\n            </ContainerDroppable>\n          </div>\n\n          <div>\n            <h3 className=\"mb-2 text-subheadline font-medium text-text\">\n              {t(\"sidebar.timeline_tabs.hidden\")}\n            </h3>\n            <ContainerDroppable\n              id=\"hidden\"\n              emptyLabel={t(\"sidebar.timeline_tabs.empty_hidden\")}\n              hasItems={hidden.length > 0}\n            >\n              <SortableContext items={hidden} strategy={verticalListSortingStrategy}>\n                {hidden.map((id) => (\n                  <SortableTabItem key={id} id={id} />\n                ))}\n              </SortableContext>\n            </ContainerDroppable>\n          </div>\n        </div>\n      </DndContext>\n\n      <div className=\"flex justify-end\">\n        <Button\n          variant=\"outline\"\n          disabled={visible.length === 0 && hidden.length === 0}\n          onClick={() => {\n            setUISetting(\"timelineTabs\", {\n              visible: [],\n              hidden: [],\n            })\n          }}\n        >\n          {t(\"sidebar.timeline_tabs.reset\")}\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nexport const useShowTimelineTabsSettingsModal = () => {\n  const { present } = useModalStack()\n  const { t } = useTranslation(\"settings\")\n  return useCallback(() => {\n    present({\n      id: \"timeline-tabs-settings\",\n      title: t(\"appearance.customize_sub_tabs.label\"),\n      content: () => <TimelineTabsSettings />,\n      overlay: true,\n      clickOutsideToDismiss: true,\n    })\n  }, [present, t])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/UnreadNumber.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\n\nexport const UnreadNumber = ({ unread, className }: { unread?: number; className?: string }) => {\n  const showUnreadCount = useUISettingKey(\"sidebarShowUnreadCount\")\n\n  if (!unread) return null\n  return (\n    <div className={cn(\"center text-[0.65rem] tabular-nums text-text-tertiary\", className)}>\n      {!showUnreadCount ? <i className=\"i-mgc-round-cute-fi text-[0.3rem]\" /> : unread}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/atom.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { atom } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\n\nimport { createAtomHooks } from \"~/lib/jotai\"\n\nexport type FeedListSortBy = \"count\" | \"alphabetical\"\nexport type FeedListSortOrder = \"asc\" | \"desc\"\nconst [, , useFeedListSort, , getFeedListSort, setFeedListSort, useFeedListSortSelector] =\n  createAtomHooks(\n    atomWithStorage(\n      getStorageNS(\"feedListSort\"),\n      {\n        by: \"count\" as FeedListSortBy,\n        order: \"desc\" as FeedListSortOrder,\n      },\n      undefined,\n      { getOnInit: true },\n    ),\n  )\n\nexport { getFeedListSort, useFeedListSort, useFeedListSortSelector }\n\nexport const setFeedListSortBy = (by: FeedListSortBy) => {\n  setFeedListSort({\n    ...getFeedListSort(),\n    by,\n  })\n}\n\nexport const setFeedListSortOrder = (order: FeedListSortOrder) => {\n  setFeedListSort({\n    ...getFeedListSort(),\n    order,\n  })\n}\n\nconst SELECT_NOTHING = []\nexport const [, useSelectedFeedIdsState, , , getSelectedFeedIds, setSelectedFeedIds, ,] =\n  createAtomHooks(atom<string[]>(SELECT_NOTHING))\nexport const resetSelectedFeedIds = () => {\n  setSelectedFeedIds(SELECT_NOTHING)\n}\n\nexport const [, , useFeedAreaScrollProgressValue, , , setFeedAreaScrollProgressValue] =\n  createAtomHooks(atom(0))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/context.ts",
    "content": "import type { DraggableAttributes, DraggableSyntheticListeners } from \"@dnd-kit/core\"\nimport type { CSSProperties } from \"react\"\nimport { createContext } from \"react\"\n\nexport const DraggableContext = createContext<{\n  attributes: DraggableAttributes\n  listeners: DraggableSyntheticListeners\n  style?: CSSProperties | undefined\n} | null>(null)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/hook.ts",
    "content": "import { useDndContext } from \"@dnd-kit/core\"\n\nimport { useFeedAreaScrollProgressValue } from \"./atom\"\n\nexport function useShouldFreeUpSpace() {\n  const dndContext = useDndContext()\n  const isDragging = !!dndContext.active\n  const scrollProgress = useFeedAreaScrollProgressValue()\n  return isDragging && scrollProgress === 0\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { Spring } from \"@follow/components/constants/spring.js\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { ELECTRON_BUILD } from \"@follow/shared/constants\"\nimport { useFeedsByIds } from \"@follow/store/feed/hooks\"\nimport { useAllFeedSubscription, usePrefetchSubscription } from \"@follow/store/subscription/hooks\"\nimport { usePrefetchUnread } from \"@follow/store/unread/hooks\"\nimport { useUserSubscriptionLimit } from \"@follow/store/user/hooks\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { clamp, cn } from \"@follow/utils/utils\"\nimport { useWheel } from \"@use-gesture/react\"\nimport { Lethargy } from \"lethargy\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { Trans } from \"react-i18next\"\n\nimport { useRootContainerElement } from \"~/atoms/dom\"\nimport { useIsInMASReview } from \"~/atoms/server-configs\"\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { setTimelineColumnShow, useSubscriptionColumnShow } from \"~/atoms/sidebar\"\nimport { Focusable } from \"~/components/common/Focusable\"\nimport { HotkeyScope } from \"~/constants\"\nimport { useBackHome } from \"~/hooks/biz/useNavigateEntry\"\nimport { useReduceMotion } from \"~/hooks/biz/useReduceMotion\"\nimport { parseView, useRouteParamsSelector } from \"~/hooks/biz/useRouteParams\"\nimport { useTimelineList } from \"~/hooks/biz/useTimelineList\"\nimport { useSettingModal } from \"~/modules/settings/modal/useSettingModal\"\n\nimport { WindowUnderBlur } from \"../../components/ui/background\"\nimport { COMMAND_ID } from \"../command/commands/id\"\nimport { useCommandBinding } from \"../command/hooks/use-command-binding\"\nimport { getSelectedFeedIds, resetSelectedFeedIds, setSelectedFeedIds } from \"./atom\"\nimport { useShouldFreeUpSpace } from \"./hook\"\nimport { SubscriptionListGuard } from \"./subscription-list/SubscriptionListGuard\"\nimport { SubscriptionColumnHeader } from \"./SubscriptionColumnHeader\"\nimport { SubscriptionTabButton } from \"./SubscriptionTabButton\"\n\nconst lethargy = new Lethargy()\n\nexport function SubscriptionColumn({\n  children,\n  className,\n}: PropsWithChildren<{ className?: string }>) {\n  const { isLoading: isSubscriptionLoading } = usePrefetchSubscription()\n  usePrefetchUnread()\n\n  const carouselRef = useRef<HTMLDivElement>(null)\n  const timelineList = useTimelineList({\n    withAll: true,\n    visible: true,\n  })\n\n  const routeParams = useRouteParamsSelector((s) => ({\n    timelineId: s.timelineId,\n    view: s.view,\n    listId: s.listId,\n  }))\n\n  const [timelineId, setMemoizedTimelineId] = useState(routeParams.timelineId ?? timelineList[0])\n\n  useEffect(() => {\n    if (routeParams.timelineId) setMemoizedTimelineId(routeParams.timelineId)\n  }, [routeParams.timelineId])\n\n  const navigateBackHome = useBackHome(timelineId)\n  const setActive = useCallback(\n    (args: string | ((prev: string | undefined, index: number) => string)) => {\n      let nextActive\n      if (typeof args === \"function\") {\n        const index = timelineId ? timelineList.indexOf(timelineId) : 0\n        nextActive = args(timelineId, index)\n      } else {\n        nextActive = args\n      }\n\n      navigateBackHome(nextActive)\n      resetSelectedFeedIds()\n    },\n    [navigateBackHome, timelineId, timelineList],\n  )\n\n  useWheel(\n    ({ event, last, memo: wait = false, direction: [dx], delta: [dex] }) => {\n      if (!last) {\n        const s = lethargy.check(event)\n        if (s) {\n          if (!wait && Math.abs(dex) > 20) {\n            setActive((_, i) => timelineList[clamp(i + dx, 0, timelineList.length - 1)]!)\n            return true\n          } else {\n            return\n          }\n        } else {\n          return false\n        }\n      } else {\n        return false\n      }\n    },\n    { target: carouselRef },\n  )\n\n  const shouldFreeUpSpace = useShouldFreeUpSpace()\n  const feedColumnShow = useSubscriptionColumnShow()\n  const rootContainerElement = useRootContainerElement()\n\n  const focusableContainerRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    if (!focusableContainerRef.current) return\n    focusableContainerRef.current.focus()\n  }, [])\n\n  return (\n    <WindowUnderBlur\n      as={Focusable}\n      scope={HotkeyScope.SubscriptionList}\n      data-hide-in-print\n      className={cn(\n        !feedColumnShow && ELECTRON_BUILD && \"bg-material-opaque\",\n        \"relative flex h-full flex-col pt-2.5\",\n        className,\n      )}\n      ref={focusableContainerRef}\n      onClick={useCallback(async () => {\n        if (document.hasFocus()) {\n          navigateBackHome()\n        }\n      }, [navigateBackHome])}\n    >\n      <CommandsHandler setActive={setActive} timelineList={timelineList} />\n      <SubscriptionColumnHeader />\n      {!feedColumnShow && (\n        <RootPortal to={rootContainerElement}>\n          <ActionButton\n            tooltip={\"Toggle Feed Column\"}\n            className=\"center absolute left-0 top-2.5 z-0 hidden -translate-x-2 text-zinc-500 macos:flex macos:left-macos-traffic-light-2\"\n            onClick={() => setTimelineColumnShow(true)}\n          >\n            <i className=\"i-mgc-layout-leftbar-open-cute-re\" />\n          </ActionButton>\n        </RootPortal>\n      )}\n\n      <div className=\"relative mb-2 mt-3\">\n        <TabsRow />\n      </div>\n      <SubscriptionLimitNotice />\n      <div\n        className={cn(\"relative mt-1 flex size-full\", !shouldFreeUpSpace && \"overflow-hidden\")}\n        ref={carouselRef}\n        onPointerDown={useTypeScriptHappyCallback((e) => {\n          if (!(e.target instanceof HTMLElement) || !e.target.closest(\"[data-feed-id]\")) {\n            const nextSelectedFeedIds = getSelectedFeedIds()\n            if (nextSelectedFeedIds.length === 0) {\n              setSelectedFeedIds(nextSelectedFeedIds)\n            } else {\n              resetSelectedFeedIds()\n            }\n          }\n        }, [])}\n      >\n        <SwipeWrapper active={timelineId!}>\n          {timelineList.map((timelineId) => (\n            <section key={timelineId} className=\"h-full w-feed-col shrink-0 snap-center\">\n              <SubscriptionListGuard\n                key={timelineId}\n                view={parseView(timelineId) ?? FeedViewType.Articles}\n                isSubscriptionLoading={isSubscriptionLoading}\n              />\n            </section>\n          ))}\n        </SwipeWrapper>\n      </div>\n\n      {children}\n    </WindowUnderBlur>\n  )\n}\n\nconst SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo(\n  ({ children, active }) => {\n    const reduceMotion = useReduceMotion()\n    const timelineList = useTimelineList({ withAll: true, visible: true })\n    const viewIndex = timelineList.indexOf(active)\n\n    const feedColumnWidth = useUISettingKey(\"feedColWidth\")\n\n    const orderIndex = timelineList.indexOf(active)\n\n    const prevOrderIndexRef = useRef(-1)\n    const [isReady, setIsReady] = useState(false)\n\n    const [direction, setDirection] = useState<\"left\" | \"right\">(\"right\")\n    const [currentAnimtedActive, setCurrentAnimatedActive] = useState(viewIndex)\n\n    useLayoutEffect(() => {\n      const prevOrderIndex = prevOrderIndexRef.current\n      if (prevOrderIndex !== orderIndex) {\n        if (prevOrderIndex < orderIndex) setDirection(\"right\")\n        else setDirection(\"left\")\n      }\n      // eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout\n      setTimeout(() => {\n        setCurrentAnimatedActive(viewIndex)\n      }, 0)\n      if (prevOrderIndexRef.current !== -1) {\n        setIsReady(true)\n      }\n      prevOrderIndexRef.current = orderIndex\n    }, [orderIndex, viewIndex])\n\n    if (reduceMotion) {\n      return <div>{children[currentAnimtedActive]}</div>\n    }\n\n    return (\n      <AnimatePresence mode=\"popLayout\">\n        <m.div\n          className=\"grow\"\n          key={currentAnimtedActive}\n          initial={\n            isReady ? { x: direction === \"right\" ? feedColumnWidth : -feedColumnWidth } : true\n          }\n          animate={{ x: 0 }}\n          exit={{ x: direction === \"right\" ? -feedColumnWidth : feedColumnWidth }}\n          transition={Spring.presets.snappy}\n        >\n          {children[currentAnimtedActive]}\n        </m.div>\n      </AnimatePresence>\n    )\n  },\n)\n\nconst TabsRow: FC = () => {\n  const timelineList = useTimelineList({ withAll: true, visible: true })\n\n  return (\n    <div className=\"flex h-11 items-center px-1 text-xl text-text-secondary\">\n      {timelineList.map((timelineId, index) => (\n        <SubscriptionTabButton key={timelineId} timelineId={timelineId} shortcut={`${index + 1}`} />\n      ))}\n    </div>\n  )\n}\n\nconst SubscriptionLimitNotice: FC = () => {\n  const feedSubscriptions = useAllFeedSubscription()\n  const { feedLimit, rsshubLimit } = useUserSubscriptionLimit()\n  const openSettings = useSettingModal()\n  const isInMASReview = useIsInMASReview()\n\n  const feedIds = useMemo(\n    () =>\n      feedSubscriptions\n        .map((subscription) => subscription?.feedId)\n        .filter((feedId): feedId is string => typeof feedId === \"string\" && feedId.length > 0),\n    [feedSubscriptions],\n  )\n\n  const feeds = useFeedsByIds(feedIds)\n\n  const feedCount = feedSubscriptions.length\n  const rsshubCount = useMemo(() => {\n    if (!feeds || feeds.length === 0) return 0\n    return feeds.reduce((count, feed) => {\n      if (!feed?.url) return count\n      return feed.url.startsWith(\"rsshub://\") ? count + 1 : count\n    }, 0)\n  }, [feeds])\n\n  const exceededFeed = typeof feedLimit === \"number\" && feedCount > feedLimit\n  const exceededRSSHub = typeof rsshubLimit === \"number\" && rsshubCount > rsshubLimit\n  if (!exceededFeed && !exceededRSSHub) {\n    return null\n  }\n  if (isInMASReview) {\n    return null\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => openSettings(\"plan\")}\n      className=\"my-1 flex items-start gap-2 border-red/30 bg-red/10 px-1.5 py-2 text-left text-xs leading-snug text-red transition-colors hover:border-red hover:bg-red/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-red/40\"\n    >\n      <span className=\"ml-1 text-lg\">😢</span>\n      <p>\n        <Trans\n          i18nKey=\"subscription_limit_warning\"\n          values={{\n            feedCount,\n            rsshubCount,\n            feedLimit,\n            rsshubLimit,\n          }}\n          components={{\n            b: <b key=\"b\" />,\n            br: <br key=\"br\" />,\n          }}\n        />\n      </p>\n    </button>\n  )\n}\n\nconst CommandsHandler = ({\n  setActive,\n  timelineList,\n}: {\n  setActive: (args: string | ((prev: string | undefined, index: number) => string)) => void\n  timelineList: string[]\n}) => {\n  const when = useGlobalFocusableScopeSelector(\n    // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback\n    useCallback(\n      (activeScope) =>\n        activeScope.or(HotkeyScope.SubscriptionList, HotkeyScope.Timeline) ||\n        activeScope.size === 0,\n      [],\n    ),\n  )\n\n  useCommandBinding({\n    commandId: COMMAND_ID.subscription.switchTabToNext,\n    when,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.subscription.switchTabToPrevious,\n    when,\n  })\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.subscription.switchTabToNext, () => {\n      setActive((_, i) => timelineList[(i + 1) % timelineList.length]!)\n    })\n  }, [setActive, timelineList])\n\n  useEffect(() => {\n    return EventBus.subscribe(COMMAND_ID.subscription.switchTabToPrevious, () => {\n      setActive((_, i) => timelineList[(i - 1 + timelineList.length) % timelineList.length]!)\n    })\n  }, [setActive, timelineList])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByAlphabeticalList.tsx",
    "content": "import { isOnboardingFeedUrl } from \"@follow/store/constants/onboarding\"\nimport { useFeedStore } from \"@follow/store/feed/store\"\nimport { useSubscriptionStore } from \"@follow/store/subscription/store\"\nimport { getInboxHandleOrFeedIdFromFeedId } from \"@follow/store/unread/utils\"\nimport { sortByAlphabet } from \"@follow/utils/utils\"\nimport { Fragment, useCallback } from \"react\"\n\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nimport { useFeedListSortSelector } from \"../atom\"\nimport { FeedCategoryAutoHideUnread } from \"../FeedCategory\"\nimport { InboxItem, ListItemAutoHideUnread } from \"../FeedItem\"\nimport type { FeedListProps, ListListProps } from \"./types\"\n\nexport const SortByAlphabeticalFeedList = ({\n  view,\n  data,\n  categoryOpenStateData,\n}: FeedListProps) => {\n  const feedId2CategoryMap = useSubscriptionStore(\n    useCallback(\n      (state) => {\n        const map = {} as Record<string, string>\n        for (const categoryName in data) {\n          const feedId = data[categoryName]![0]\n          if (!feedId) {\n            continue\n          }\n          const subscription = state.data[feedId]\n          if (!subscription) {\n            continue\n          }\n          if (subscription.category) {\n            map[feedId] = subscription.category\n          }\n        }\n        return map\n      },\n      [data],\n    ),\n  )\n  const categoryName2RealDisplayNameMap = useFeedStore(\n    useCallback(\n      (state) => {\n        const map = {} as Record<string, string>\n        for (const categoryName in data) {\n          const feedId = data[categoryName]![0]\n\n          if (!feedId) {\n            continue\n          }\n          const feed = state.feeds[feedId]\n          if (!feed) {\n            continue\n          }\n          const hascategoryNameNotDefault = !!feedId2CategoryMap[feedId]\n          const isSingle = data[categoryName]!.length === 1\n          if (!isSingle || hascategoryNameNotDefault) {\n            map[categoryName] = categoryName\n          } else {\n            map[categoryName] = getPreferredTitle(feed)!\n          }\n        }\n        return map\n      },\n      [data, feedId2CategoryMap],\n    ),\n  )\n\n  const isDesc = useFeedListSortSelector((s) => s.order === \"desc\")\n\n  // Separate categories with onboarding feeds and regular categories\n  const sortedByAlphabetical = useFeedStore(\n    useCallback(\n      (state) => {\n        const onboardingCategories: string[] = []\n        const regularCategories: string[] = []\n\n        // First, separate categories\n        for (const category of Object.keys(data)) {\n          const ids = data[category]!\n          const hasOnboardingFeed = ids.some((id) => {\n            const feed = state.feeds[id]\n            return feed && isOnboardingFeedUrl(feed.url)\n          })\n\n          if (hasOnboardingFeed) {\n            onboardingCategories.push(category)\n          } else {\n            regularCategories.push(category)\n          }\n        }\n\n        // Sort each group alphabetically\n        const sortCategories = (categories: string[]) => {\n          const sorted = categories.sort((a, b) => {\n            const nameA = categoryName2RealDisplayNameMap[a]\n            const nameB = categoryName2RealDisplayNameMap[b]\n            if (typeof nameA !== \"string\" || typeof nameB !== \"string\") {\n              return 0\n            }\n            return sortByAlphabet(nameA, nameB)\n          })\n          return isDesc ? sorted : sorted.reverse()\n        }\n\n        // Return onboarding categories first, then regular categories\n        return [...sortCategories(onboardingCategories), ...sortCategories(regularCategories)]\n      },\n      [data, categoryName2RealDisplayNameMap, isDesc],\n    ),\n  )\n\n  return (\n    <Fragment>\n      {sortedByAlphabetical.map((category) => (\n        <FeedCategoryAutoHideUnread\n          key={category}\n          data={data[category]!}\n          view={view}\n          categoryOpenStateData={categoryOpenStateData}\n        />\n      ))}\n    </Fragment>\n  )\n}\n\nexport const SortByAlphabeticalListList = ({ view, data }: ListListProps) => {\n  return (\n    <div>\n      {data.map((listId) => (\n        <ListItemAutoHideUnread key={listId} listId={listId} view={view} />\n      ))}\n    </div>\n  )\n}\n\nexport const SortByAlphabeticalInboxList = ({ view, data }: ListListProps) => {\n  return (\n    <div>\n      {data.map((feedId) => (\n        <InboxItem key={feedId} inboxId={getInboxHandleOrFeedIdFromFeedId(feedId)} view={view} />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx",
    "content": "import { isOnboardingFeedUrl } from \"@follow/store/constants/onboarding\"\nimport { useFeedStore } from \"@follow/store/feed/store\"\nimport { useSortedCategoriesByUnread } from \"@follow/store/unread/hooks\"\nimport { Fragment, memo, useCallback } from \"react\"\n\nimport { useFeedListSortSelector } from \"../atom\"\nimport { FeedCategoryAutoHideUnread } from \"../FeedCategory\"\nimport type { FeedListProps } from \"./types\"\n\nexport const SortByUnreadFeedList = memo(({ view, data, categoryOpenStateData }: FeedListProps) => {\n  const isDesc = useFeedListSortSelector((s) => s.order === \"desc\")\n  const sortedByUnread = useSortedCategoriesByUnread(data, isDesc)\n\n  // Separate categories with onboarding feeds and regular categories\n  const sortedList = useFeedStore(\n    useCallback(\n      (state) => {\n        if (!sortedByUnread) return []\n        const onboardingCategories: [string, string[]][] = []\n        const regularCategories: [string, string[]][] = []\n\n        for (const [category, ids] of sortedByUnread) {\n          const hasOnboardingFeed = ids.some((id) => {\n            const feed = state.feeds[id]\n            return feed && isOnboardingFeedUrl(feed.url)\n          })\n\n          if (hasOnboardingFeed) {\n            onboardingCategories.push([category, ids.concat()])\n          } else {\n            regularCategories.push([category, ids.concat()])\n          }\n        }\n\n        return [...onboardingCategories, ...regularCategories]\n      },\n      [sortedByUnread],\n    ),\n  )\n\n  return (\n    <Fragment>\n      {sortedList.map(([category, ids]) => (\n        <FeedCategoryAutoHideUnread\n          key={category}\n          data={ids}\n          view={view}\n          categoryOpenStateData={categoryOpenStateData}\n        />\n      ))}\n    </Fragment>\n  )\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/index.tsx",
    "content": "import { useFeedListSortSelector } from \"../atom\"\nimport {\n  SortByAlphabeticalFeedList,\n  SortByAlphabeticalInboxList,\n  SortByAlphabeticalListList,\n} from \"./SortByAlphabeticalList\"\nimport { SortByUnreadFeedList } from \"./SortByUnreadList\"\nimport type { FeedListProps, ListListProps } from \"./types\"\n\nexport const SortableFeedList = (props: FeedListProps) => {\n  const by = useFeedListSortSelector((s) => s.by)\n\n  switch (by) {\n    case \"count\": {\n      return <SortByUnreadFeedList {...props} />\n    }\n    case \"alphabetical\": {\n      return <SortByAlphabeticalFeedList {...props} />\n    }\n  }\n}\n\nexport const SortByAlphabeticalList = (props: ListListProps) => {\n  const by = useFeedListSortSelector((s) => s.by)\n\n  switch (by) {\n    default: {\n      return <SortByAlphabeticalListList {...props} />\n    }\n  }\n}\n\nexport const SortByAlphabeticalInbox = (props: ListListProps) => {\n  const by = useFeedListSortSelector((s) => s.by)\n\n  switch (by) {\n    default: {\n      return <SortByAlphabeticalInboxList {...props} />\n    }\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/types.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\n\nexport type FeedListProps = {\n  view: FeedViewType\n  data: Record<string, string[]>\n  categoryOpenStateData: Record<string, boolean>\n}\nexport type SortBy = \"count\" | \"alphabetical\"\n\nexport type ListListProps = {\n  view: FeedViewType\n  data: string[]\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/styles.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { clsx } from \"@follow/utils/utils\"\n\nexport const feedColumnStyles = {\n  item: clsx(\n    !IN_ELECTRON && tw`duration-200 hover:bg-theme-item-hover`,\n    tw`flex w-full cursor-menu items-center rounded-md pr-2.5 text-base lg:text-sm font-medium !leading-loose`,\n    tw`data-[active=true]:bg-theme-item-active`,\n  ),\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/EmptyFeedList.tsx",
    "content": "import { stopPropagation } from \"@follow/utils/dom\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useLocation } from \"react-router\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\n\nimport { SimpleDiscoverModal } from \"../SimpleDiscoverModal\"\n\nexport const EmptyFeedList = memo(({ onClick }: { onClick?: (e: React.MouseEvent) => void }) => {\n  const { t } = useTranslation()\n  const location = useLocation()\n  const isOnDiscoverPage = location.pathname === \"/discover\"\n  const { present } = useModalStack()\n\n  const handleClick = (e: React.MouseEvent) => {\n    stopPropagation(e)\n    onClick?.(e)\n\n    if (!isOnDiscoverPage) {\n      // Show simplified discover modal when already on discover page\n      present({\n        title: t(\"words.discover\"),\n        content: ({ dismiss }) => <SimpleDiscoverModal dismiss={dismiss} />,\n        clickOutsideToDismiss: true,\n      })\n    }\n  }\n\n  return (\n    <div className=\"mt-12 flex flex-1 items-center font-normal text-zinc-500\">\n      {isOnDiscoverPage ? (\n        <div\n          className=\"flex flex-1 cursor-menu flex-col items-center justify-center gap-2\"\n          onClick={handleClick}\n        >\n          <i className=\"i-mgc-arrow-right-up-cute-re text-3xl\" />\n          <span className=\"text-balance text-center text-sm\">\n            {t(\"sidebar.already_on_discover_page\")}\n          </span>\n        </div>\n      ) : (\n        <div\n          className=\"flex flex-1 cursor-menu flex-col items-center justify-center gap-2\"\n          onClick={handleClick}\n        >\n          <i className=\"i-mgc-add-cute-re text-3xl\" />\n          <span className=\"text-base\">{t(\"sidebar.add_more_feeds\")}</span>\n        </div>\n      )}\n    </div>\n  )\n})\nEmptyFeedList.displayName = \"EmptyFeedList\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/ListHeader.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { getView } from \"@follow/constants\"\nimport { useUnreadByView } from \"@follow/store/unread/hooks\"\nimport { stopPropagation } from \"@follow/utils\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\n\nimport { UnreadNumber } from \"../UnreadNumber\"\nimport { SortButton } from \"./SortButton\"\n\nexport const ListHeader = ({ view }: { view: FeedViewType }) => {\n  const { t } = useTranslation()\n\n  const totalUnread = useUnreadByView(view)\n\n  const navigateEntry = useNavigateEntry()\n\n  return (\n    <div onClick={stopPropagation} className=\"mx-3 flex items-center justify-between p-1\">\n      <div\n        className=\"text-base font-bold\"\n        onClick={(e) => {\n          e.stopPropagation()\n          if (!document.hasFocus()) return\n          if (view !== undefined) {\n            navigateEntry({\n              entryId: null,\n              feedId: null,\n              view,\n            })\n          }\n        }}\n      >\n        {view !== undefined &&\n          t(getView(view).name, {\n            ns: \"common\",\n          })}\n      </div>\n      <div className=\"ml-2 flex items-center gap-3 text-base text-text-secondary lg:text-sm\">\n        <SortButton />\n        <UnreadNumber unread={totalUnread} className=\"text-xs !text-inherit\" />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SortButton.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as HoverCard from \"@radix-ui/react-hover-card\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useOnClickOutside } from \"usehooks-ts\"\n\nimport { IconOpacityTransition } from \"~/components/ux/transition/icon\"\n\nimport { getFeedListSort, setFeedListSortBy, setFeedListSortOrder, useFeedListSort } from \"../atom\"\n\nconst SORT_LIST = [\n  { icon: tw`i-mgc-numbers-90-sort-ascending-cute-re`, by: \"count\", order: \"asc\" },\n  { icon: tw`i-mgc-numbers-90-sort-descending-cute-re`, by: \"count\", order: \"desc\" },\n\n  {\n    icon: tw`i-mgc-az-sort-ascending-letters-cute-re`,\n    by: \"alphabetical\",\n    order: \"asc\",\n  },\n  {\n    icon: tw`i-mgc-az-sort-descending-letters-cute-re`,\n    by: \"alphabetical\",\n    order: \"desc\",\n  },\n] as const\n\nexport const SortButton = () => {\n  const { by, order } = useFeedListSort()\n  const { t } = useTranslation()\n\n  const [open, setOpen] = useState(false)\n  const ref = useRef<HTMLDivElement | null>(null)\n  useOnClickOutside(ref as React.RefObject<HTMLElement>, () => {\n    setOpen(false)\n  })\n\n  return (\n    <HoverCard.Root open={open} onOpenChange={setOpen}>\n      <HoverCard.Trigger\n        onClick={() => {\n          if (isMobile()) {\n            setOpen(true)\n            return\n          }\n          setFeedListSortBy(by === \"count\" ? \"alphabetical\" : \"count\")\n        }}\n        className=\"center\"\n      >\n        <IconOpacityTransition\n          icon2={\n            order === \"asc\"\n              ? tw`i-mgc-numbers-90-sort-ascending-cute-re`\n              : tw`i-mgc-numbers-90-sort-descending-cute-re`\n          }\n          icon1={\n            order === \"asc\"\n              ? tw`i-mgc-az-sort-ascending-letters-cute-re`\n              : tw`i-mgc-az-sort-descending-letters-cute-re`\n          }\n          status={by === \"count\" ? \"done\" : \"init\"}\n        />\n      </HoverCard.Trigger>\n\n      <RootPortal>\n        <HoverCard.Content ref={ref} className=\"z-10 -translate-x-4\" sideOffset={5} forceMount>\n          <AnimatePresence>\n            {open && (\n              <m.div\n                initial={{ opacity: 0, scale: 0.98, y: 10 }}\n                animate={{ opacity: 1, scale: 1, y: 0 }}\n                exit={{ opacity: 0, scale: 0.98, y: 10 }}\n                transition={{ type: \"spring\", duration: 0.3 }}\n                className=\"shadow-context-menu relative z-10 rounded-md border border-border bg-theme-background p-3\"\n              >\n                <HoverCard.Arrow className=\"-translate-x-4 fill-border\" />\n                <section className=\"w-[170px] text-center\">\n                  <span className=\"text-[13px]\">{t(\"sidebar.select_sort_method\")}</span>\n                  <div className=\"mt-4 grid grid-cols-2 grid-rows-2 gap-2\">\n                    {SORT_LIST.map(({ icon, by, order }) => {\n                      const current = getFeedListSort()\n                      const active = by === current.by && order === current.order\n                      return (\n                        <button\n                          type=\"button\"\n                          onClick={() => {\n                            setFeedListSortBy(by)\n                            setFeedListSortOrder(order)\n                          }}\n                          key={`${by}-${order}`}\n                          className={cn(\n                            \"center flex aspect-square rounded border border-border\",\n\n                            \"ring-0 ring-accent/20 duration-200\",\n                            active && \"border-accent bg-accent/5 ring-2\",\n                          )}\n                        >\n                          <i className={`${icon} size-5`} />\n                        </button>\n                      )\n                    })}\n                  </div>\n                </section>\n              </m.div>\n            )}\n          </AnimatePresence>\n        </HoverCard.Content>\n      </RootPortal>\n    </HoverCard.Root>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/StarredItem.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { FEED_COLLECTION_LIST } from \"~/constants\"\nimport { useNavigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { useRouteFeedId } from \"~/hooks/biz/useRouteParams\"\n\nimport { feedColumnStyles } from \"../styles\"\n\nexport const StarredItem = memo(({ view }: { view: number }) => {\n  const feedId = useRouteFeedId()\n  const navigateEntry = useNavigateEntry()\n  const { t } = useTranslation()\n\n  return (\n    <div\n      data-sub={FEED_COLLECTION_LIST}\n      data-active={feedId === FEED_COLLECTION_LIST}\n      className={cn(\n        \"mt-1 flex h-8 w-full shrink-0 cursor-menu items-center gap-2 rounded-md px-2.5\",\n        feedColumnStyles.item,\n      )}\n      onClick={(e) => {\n        e.stopPropagation()\n        if (view !== undefined) {\n          navigateEntry({\n            entryId: null,\n            feedId: FEED_COLLECTION_LIST,\n            view,\n          })\n        }\n      }}\n    >\n      <i className=\"i-mgc-star-cute-fi size-4 -translate-y-px text-amber-500\" />\n      {t(\"words.starred\")}\n    </div>\n  )\n})\nStarredItem.displayName = \"StarredItem\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SubscriptionList.tsx",
    "content": "import { useDraggable } from \"@dnd-kit/core\"\nimport {\n  useFocusableContainerRef,\n  useFocusActions,\n  useGlobalFocusableScopeSelector,\n} from \"@follow/components/common/Focusable/hooks.js\"\nimport { ScrollArea } from \"@follow/components/ui/scroll-area/index.js\"\nimport { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { useInboxList } from \"@follow/store/inbox/hooks\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport {\n  useCategoryOpenStateByView,\n  useFeedsGroupedData,\n  useSubscriptionListIds,\n} from \"@follow/store/subscription/hooks\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { cn, combineCleanupFunctions, isKeyForMultiSelectPressed } from \"@follow/utils/utils\"\nimport { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport Selecto from \"react-selecto\"\nimport { useEventCallback, useEventListener } from \"usehooks-ts\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { useFeedQuery } from \"~/queries/feed\"\n\nimport { COMMAND_ID } from \"../../command/commands/id\"\nimport { useCommandBinding } from \"../../command/hooks/use-command-binding\"\nimport { useCommandHotkey } from \"../../command/hooks/use-register-hotkey\"\nimport { useIsPreviewFeed } from \"../../entry-column/hooks/useIsPreviewFeed\"\nimport {\n  resetSelectedFeedIds,\n  setFeedAreaScrollProgressValue,\n  setSelectedFeedIds,\n  useSelectedFeedIdsState,\n} from \"../atom\"\nimport { DraggableContext } from \"../context\"\nimport { FeedItem, ListItemAutoHideUnread } from \"../FeedItem\"\nimport { useShouldFreeUpSpace } from \"../hook\"\nimport { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from \"../sort-by\"\nimport { EmptyFeedList } from \"./EmptyFeedList\"\nimport { ListHeader } from \"./ListHeader\"\nimport { StarredItem } from \"./StarredItem\"\nimport type { SubscriptionProps } from \"./SubscriptionListGuard\"\n\nconst SubscriptionImpl = ({ ref, className, view, isSubscriptionLoading }: SubscriptionProps) => {\n  const autoGroup = useGeneralSettingKey(\"autoGroup\")\n  const feedsData = useFeedsGroupedData(view, autoGroup)\n\n  const listSubIds = useSubscriptionListIds(view)\n  const inboxSubIds = useInboxList(\n    useCallback(\n      (inboxes) => (view === FeedViewType.Articles ? inboxes.map((inbox) => inbox.id) : []),\n      [view],\n    ),\n  )\n\n  const categoryOpenStateData = useCategoryOpenStateByView(view)\n\n  const hasData =\n    Object.keys(feedsData).length > 0 || listSubIds.length > 0 || inboxSubIds.length > 0\n\n  const { t } = useTranslation()\n\n  const hasListData = listSubIds.length > 0\n  const hasInboxData = inboxSubIds.length > 0\n\n  const scrollerRef = useRef<HTMLDivElement | null>(null)\n  const selectoRef = useRef<Selecto>(null)\n  const [selectedFeedIds, setSelectedFeedIds] = useSelectedFeedIdsState()\n  const [currentStartFeedId, setCurrentStartFeedId] = useState<string | null>(null)\n  useEffect(() => {\n    if (selectedFeedIds.length <= 1) {\n      setCurrentStartFeedId(null)\n    }\n  }, [selectedFeedIds])\n\n  const { attributes, listeners, setNodeRef, transform } = useDraggable({\n    id: \"selected-feed\",\n    disabled: selectedFeedIds.length === 0,\n  })\n  const style = useMemo(\n    () =>\n      transform\n        ? ({\n            transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,\n            transitionDuration: \"0\",\n            transition: \"none\",\n          } as React.CSSProperties)\n        : undefined,\n    [transform],\n  )\n\n  const draggableContextValue = useMemo(\n    () => ({\n      attributes,\n      listeners,\n      style: {\n        ...style,\n        willChange: \"transform\",\n      },\n    }),\n    [attributes, listeners, style],\n  )\n\n  useImperativeHandle(ref, () => scrollerRef.current!)\n\n  useEventListener(\n    \"scroll\",\n    () => {\n      const round = (num: number) => Math.round(num * 1e2) / 1e2\n      const getPositions = () => {\n        const el = scrollerRef.current\n        if (!el) return\n\n        return {\n          x: round(el.scrollLeft / (el.scrollWidth - el.clientWidth)),\n          y: round(el.scrollTop / (el.scrollHeight - el.clientHeight)),\n        }\n      }\n\n      const newScrollValues = getPositions()\n      if (!newScrollValues) return\n\n      const { y } = newScrollValues\n      setFeedAreaScrollProgressValue(y)\n    },\n    scrollerRef as React.RefObject<HTMLElement>,\n    { capture: false, passive: true },\n  )\n\n  const shouldFreeUpSpace = useShouldFreeUpSpace()\n\n  const routerParams = useRouteParams()\n  const { listId, feedId } = routerParams\n  const isPreview = useIsPreviewFeed()\n  const isFeedPreview = isPreview && !listId\n  const isListPreview = isPreview && listId\n\n  useFeedQuery({ id: isFeedPreview ? feedId : undefined })\n  useListById(isListPreview ? listId : undefined)\n\n  useRegisterCommand()\n\n  return (\n    <div className={cn(className, \"font-medium\")}>\n      <ListHeader view={view} />\n      <Selecto\n        className=\"!border-orange-400 !bg-orange-400/60\"\n        ref={selectoRef}\n        rootContainer={document.body}\n        dragContainer={\"#feeds-area\"}\n        dragCondition={(e) => {\n          const inputEvent = e.inputEvent as MouseEvent\n          const target = inputEvent.target as HTMLElement\n          const closest = target.closest(\"[data-feed-id]\") as HTMLElement | null\n          const dataFeedId = closest?.dataset.feedId\n\n          if (\n            dataFeedId &&\n            selectedFeedIds.includes(dataFeedId) &&\n            !isKeyForMultiSelectPressed(inputEvent)\n          )\n            return false\n\n          return true\n        }}\n        onDragStart={(e) => {\n          if (!isKeyForMultiSelectPressed(e.inputEvent as MouseEvent)) {\n            resetSelectedFeedIds()\n          }\n        }}\n        selectableTargets={[\"[data-feed-id]\"]}\n        continueSelect\n        hitRate={1}\n        onSelect={(e) => {\n          const allChanged = [...e.added, ...e.removed]\n            .map((el) => el.dataset.feedId)\n            .filter((id) => id !== undefined)\n          const added = allChanged.filter((id) => !selectedFeedIds.includes(id))\n          const removed = allChanged.filter((id) => selectedFeedIds.includes(id))\n\n          if (isKeyForMultiSelectPressed(e.inputEvent as MouseEvent)) {\n            const allVisible = Array.from(document.querySelectorAll(\"[data-feed-id]\")).map(\n              (el) => (el as HTMLElement).dataset.feedId,\n            )\n            const currentSelected =\n              added.length === 1 ? added[0] : removed.length === 1 ? removed[0] : null\n            const currentIndex = currentSelected ? allVisible.indexOf(currentSelected) : -1\n\n            // command or ctrl with click, update start feed id\n            if (!(e.inputEvent as MouseEvent).shiftKey && currentSelected) {\n              setCurrentStartFeedId(currentSelected)\n            }\n\n            // shift with click, select all between\n            if ((e.inputEvent as MouseEvent).shiftKey && currentSelected) {\n              const firstSelected = currentStartFeedId ?? selectedFeedIds[0]\n              if (firstSelected) {\n                const firstIndex = allVisible.indexOf(firstSelected)\n                const order =\n                  firstIndex < currentIndex\n                    ? [firstIndex, currentIndex]\n                    : [currentIndex, firstIndex]\n                const between = allVisible.slice(order[0], order[1]! + 1) as string[]\n                setSelectedFeedIds((prev) => {\n                  // with intersection, we need to update selected ids as between\n                  // otherwise, we need to add between to selected ids\n                  const hasIntersection = between.slice(1, -1).some((id) => prev.includes(id))\n                  return [\n                    ...(hasIntersection ? prev.filter((id) => between.includes(id)) : prev),\n                    ...between,\n                  ]\n                })\n                return\n              }\n            }\n          }\n\n          setSelectedFeedIds((prev) => {\n            return [...prev.filter((id) => !removed.includes(id)), ...added]\n          })\n        }}\n        scrollOptions={{\n          container: scrollerRef.current as HTMLElement,\n          throttleTime: 30,\n          threshold: 0,\n        }}\n        onScroll={(e) => {\n          scrollerRef.current?.scrollBy(e.direction[0]! * 10, e.direction[1]! * 10)\n        }}\n      />\n\n      <ScrollArea.ScrollArea\n        focusable={false}\n        ref={scrollerRef}\n        onScroll={() => {\n          selectoRef.current?.checkScroll()\n        }}\n        mask={false}\n        flex\n        viewportClassName={cn(\"!px-1\", shouldFreeUpSpace && \"!overflow-visible\")}\n        rootClassName={cn(\"h-full\", shouldFreeUpSpace && \"overflow-visible\")}\n      >\n        <StarredItem view={view} />\n        {(hasListData || (isListPreview && listId)) && (\n          <>\n            <div className=\"mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-text-secondary transition-colors\">\n              {t(\"words.lists\")}\n            </div>\n            {isListPreview && listId && (\n              <ListItemAutoHideUnread\n                listId={listId}\n                view={view}\n                className=\"pl-2.5 pr-0\"\n                isPreview\n              />\n            )}\n            <SortByAlphabeticalList view={view} data={listSubIds} />\n          </>\n        )}\n        {hasInboxData && (\n          <>\n            <div className=\"mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-text-secondary transition-colors\">\n              {t(\"words.inbox\")}\n            </div>\n            <SortByAlphabeticalInbox view={view} data={inboxSubIds} />\n          </>\n        )}\n\n        {(hasListData || hasInboxData) && (\n          <div\n            className={cn(\n              \"mb-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-text-secondary transition-colors\",\n              Object.keys(feedsData).length === 0 ? \"mt-0\" : \"mt-1\",\n            )}\n          >\n            {t(\"words.feeds\")}\n          </div>\n        )}\n        {isFeedPreview && feedId && (\n          <FeedItem feedId={feedId} view={view} className=\"pl-2.5 pr-0.5\" isPreview />\n        )}\n        <DraggableContext value={draggableContextValue}>\n          <div className=\"space-y-px\" id=\"feeds-area\" ref={setNodeRef}>\n            {hasData ? (\n              <SortableFeedList\n                view={view}\n                data={feedsData}\n                categoryOpenStateData={categoryOpenStateData}\n              />\n            ) : isSubscriptionLoading ? (\n              <SubscriptionListSkeleton />\n            ) : (\n              <EmptyFeedList />\n            )}\n          </div>\n        </DraggableContext>\n      </ScrollArea.ScrollArea>\n    </div>\n  )\n}\n\nSubscriptionImpl.displayName = \"FeedListImpl\"\n\nexport const SubscriptionList = memo(SubscriptionImpl)\n\nconst FeedCategoryPrefix = \"feed-category-\"\n\nconst useRegisterCommand = () => {\n  const focusableContainerRef = useFocusableContainerRef()\n\n  const focusActions = useFocusActions()\n\n  const inSubscriptionScope = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList)\n\n  useCommandBinding({\n    commandId: COMMAND_ID.subscription.nextSubscription,\n    when: inSubscriptionScope,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.subscription.previousSubscription,\n    when: inSubscriptionScope,\n  })\n\n  useCommandHotkey({\n    commandId: COMMAND_ID.layout.focusToTimeline,\n    when: inSubscriptionScope,\n    shortcut: \"Enter, L, ArrowRight\",\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.subscription.toggleFolderCollapse,\n    when: inSubscriptionScope,\n  })\n\n  const getCurrentActiveSubscriptionElement = useEventCallback(() => {\n    const container = focusableContainerRef.current\n    if (!container) return\n\n    const allSubscriptions = Array.from(container.querySelectorAll(\"[data-sub]\"))\n    if (allSubscriptions.length === 0) return\n\n    const currentActive = container.querySelector(\"[data-active=true]\")\n\n    return [currentActive as HTMLElement | null, allSubscriptions] as const\n  })\n\n  useEffect(() => {\n    const handleSubscriptionNavigation = (direction: \"next\" | \"previous\") => {\n      const result = getCurrentActiveSubscriptionElement()\n      if (!result) return\n\n      const [currentActive, allSubscriptions] = result\n\n      if (!currentActive) {\n        // If no active item, select first or last based on direction\n        const defaultIndex = direction === \"next\" ? 0 : -1\n        ;(allSubscriptions.at(defaultIndex) as HTMLElement)?.click()\n        return\n      }\n\n      const currentIndex = allSubscriptions.indexOf(currentActive)\n      let targetIndex: number\n\n      if (direction === \"next\") {\n        targetIndex = (currentIndex + 1) % allSubscriptions.length\n      } else {\n        targetIndex = (currentIndex - 1 + allSubscriptions.length) % allSubscriptions.length\n      }\n\n      const targetElement = allSubscriptions[targetIndex] as HTMLElement | null\n\n      // Cleanup selected feed\n      const targetIsCategoryOrFolder = targetElement?.dataset.sub?.startsWith(FeedCategoryPrefix)\n      if (targetIsCategoryOrFolder) {\n        setSelectedFeedIds([])\n      }\n      targetElement?.click()\n    }\n\n    return combineCleanupFunctions(\n      EventBus.subscribe(COMMAND_ID.subscription.nextSubscription, () => {\n        handleSubscriptionNavigation(\"next\")\n      }),\n      EventBus.subscribe(COMMAND_ID.subscription.previousSubscription, () => {\n        handleSubscriptionNavigation(\"previous\")\n      }),\n      EventBus.subscribe(COMMAND_ID.layout.focusToSubscription, ({ highlightBoundary }) => {\n        focusableContainerRef.current?.focus()\n        if (highlightBoundary) {\n          nextFrame(() => {\n            focusActions.highlightBoundary()\n          })\n        }\n      }),\n      EventBus.subscribe(COMMAND_ID.subscription.toggleFolderCollapse, () => {\n        const result = getCurrentActiveSubscriptionElement()\n        if (!result) return\n\n        const [currentActive] = result\n\n        if (currentActive?.dataset.sub?.startsWith(FeedCategoryPrefix)) {\n          setSelectedFeedIds([])\n          ;(currentActive.querySelector('[data-type=\"collapse\"]') as HTMLElement | null)?.click()\n        }\n      }),\n    )\n  }, [focusableContainerRef, focusActions, getCurrentActiveSubscriptionElement])\n}\n\nconst SubscriptionListSkeleton = () => (\n  <div className=\"px-1\">\n    {Array.from({ length: 5 }).map((_, index) => (\n      <div key={index} className=\"flex h-8 items-center justify-between\">\n        <Skeleton className=\"h-4 w-28\" />\n        <Skeleton className=\"size-4\" />\n      </div>\n    ))}\n  </div>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SubscriptionListGuard.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { cn } from \"@follow/utils\"\n\nimport { SubscriptionList as FeedListDesktop } from \"./SubscriptionList\"\n\nexport const SubscriptionListGuard = function SubscriptionListGuard(props: SubscriptionProps) {\n  const { ref, className, view, isSubscriptionLoading } = props\n\n  if (typeof view !== \"number\") {\n    return null\n  }\n  return (\n    <FeedListDesktop\n      className={cn(\"flex size-full flex-col text-sm\", className)}\n      view={view}\n      ref={ref}\n      isSubscriptionLoading={isSubscriptionLoading}\n    />\n  )\n}\n\nexport type SubscriptionProps = ComponentType<\n  { className?: string; view: FeedViewType; isSubscriptionLoading: boolean } & {\n    ref?: React.Ref<HTMLDivElement | null> | ((node: HTMLDivElement | null) => void)\n  }\n>\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/index.ts",
    "content": "export { SubscriptionList } from \"./SubscriptionList\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/trending/index.tsx",
    "content": "import { useScrollElementUpdate } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { ResponsiveSelect } from \"@follow/components/ui/select/responsive.js\"\nimport { Skeleton } from \"@follow/components/ui/skeleton/index.jsx\"\nimport { FeedViewType, getView, getViewList } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { cloneElement, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { setUISetting, useUISettingKey } from \"~/atoms/settings/ui\"\nimport { followClient } from \"~/lib/api-client\"\n\nimport { TrendingFeedCard } from \"../discover/TrendingFeedCard\"\n\nconst LanguageOptions = [\n  {\n    label: \"words.all\",\n    value: \"all\",\n  },\n  {\n    label: \"words.english\",\n    value: \"eng\",\n  },\n  {\n    label: \"words.chinese\",\n    value: \"cmn\",\n  },\n  {\n    label: \"words.french\",\n    value: \"fra\",\n  },\n]\n\ntype Language = \"all\" | \"eng\" | \"cmn\" | \"fra\"\n\ntype View = \"all\" | string\n\nconst buildViewOptions = () => {\n  const allView = getView(FeedViewType.All)\n  return [\n    {\n      label: \"words.all\",\n      value: \"all\" as const,\n      icon: allView?.icon,\n      className: allView?.className,\n    },\n    ...getViewList().map((view) => ({\n      label: view.name,\n      value: `${view.view}`,\n      icon: view.icon,\n      className: view.className,\n    })),\n  ]\n}\n\nexport function Trending({\n  limit = 20,\n  narrow,\n  center,\n  hideHeader = false,\n}: {\n  limit?: number\n  narrow?: boolean\n  center?: boolean\n  hideHeader?: boolean\n}) {\n  const { t } = useTranslation()\n  const { t: tCommon } = useTranslation(\"common\")\n  const lang = useUISettingKey(\"discoverLanguage\")\n  const { onUpdateMaxScroll } = useScrollElementUpdate()\n  const trendingLanguage = lang === \"fra\" ? \"eng\" : lang\n\n  const [selectedView, setSelectedView] = useState<View>(\"all\")\n  const viewOptions = useMemo(() => buildViewOptions(), [])\n\n  const { data, isLoading } = useQuery({\n    queryKey: [\"trending\", lang, selectedView],\n    queryFn: async () => {\n      return await followClient.api.trending.getFeeds({\n        language: trendingLanguage === \"all\" ? undefined : trendingLanguage,\n        view: selectedView === \"all\" ? undefined : Number(selectedView),\n        limit,\n      })\n    },\n    meta: {\n      persist: true,\n    },\n  })\n\n  useEffect(() => {\n    if (!isLoading) {\n      onUpdateMaxScroll?.()\n    }\n  }, [isLoading])\n\n  return (\n    <div className={cn(\"mx-auto mt-4 w-full max-w-[800px] space-y-6\", narrow && \"max-w-[400px]\")}>\n      {!hideHeader && (\n        <div\n          className={cn(\n            \"justify-between md:flex\",\n            \"grid grid-cols-1 grid-rows-2\",\n            narrow && \"flex-col gap-4\",\n          )}\n        >\n          <div\n            className={cn(\n              \"flex w-full items-center gap-2 text-xl font-bold\",\n              narrow && center && \"justify-center\",\n            )}\n          >\n            <i className=\"i-mgc-trending-up-cute-re text-xl\" />\n            <span>{t(\"words.trending\")}</span>\n          </div>\n          <div className={cn(\"flex gap-4\", center && \"justify-end md:center\")}>\n            <div className=\"flex items-center\">\n              <span className=\"shrink-0 text-sm font-medium text-text\">{t(\"words.language\")}:</span>\n\n              <ResponsiveSelect\n                value={lang}\n                onValueChange={(value) => {\n                  setUISetting(\"discoverLanguage\", value as Language)\n                }}\n                triggerClassName=\"h-8 rounded border-0\"\n                size=\"sm\"\n                items={LanguageOptions}\n                renderItem={(item) => tCommon(item.label as any)}\n                renderValue={(item) => tCommon(item.label as any)}\n              />\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <span className=\"shrink-0 text-sm font-medium text-text\">{t(\"words.view\")}:</span>\n              <div className=\"flex items-center gap-1 rounded-lg bg-material-thin p-1\">\n                {viewOptions.map((option) => {\n                  const isSelected = selectedView === option.value\n                  return (\n                    <button\n                      key={option.value}\n                      type=\"button\"\n                      onClick={() => setSelectedView(option.value)}\n                      className={cn(\n                        \"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors\",\n                        isSelected\n                          ? \"bg-material-medium text-text\"\n                          : \"text-text-secondary hover:bg-material-thin hover:text-text\",\n                      )}\n                    >\n                      {option.icon &&\n                        cloneElement(option.icon, {\n                          className: cn(\n                            \"text-base\",\n                            isSelected && option.className,\n                            option.icon?.props?.className,\n                          ),\n                        })}\n                      <span>{tCommon(option.label as any)}</span>\n                    </button>\n                  )\n                })}\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n      {hideHeader && (\n        <div className=\"-mt-2 mb-4 flex justify-center\">\n          <div className=\"flex items-center gap-1 p-1\">\n            {viewOptions.map((option) => {\n              const isSelected = selectedView === option.value\n              return (\n                <button\n                  key={option.value}\n                  type=\"button\"\n                  onClick={() => setSelectedView(option.value)}\n                  className={cn(\n                    \"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors\",\n                    isSelected\n                      ? \"bg-material-medium text-text\"\n                      : \"text-text-secondary hover:bg-material-thin hover:text-text\",\n                  )}\n                >\n                  {option.icon &&\n                    cloneElement(option.icon, {\n                      className: cn(\n                        \"text-base\",\n                        isSelected && option.className,\n                        option.icon?.props?.className,\n                      ),\n                    })}\n                  <span>{tCommon(option.label as any)}</span>\n                </button>\n              )\n            })}\n          </div>\n        </div>\n      )}\n      <div className={cn(\"grid grid-cols-2 gap-x-7 gap-y-3\", narrow && \"grid-cols-1\")}>\n        {isLoading ? (\n          <>\n            {Array.from({ length: limit }).map((_, index) => (\n              <Skeleton key={index} className=\"h-[146px] w-[386px]\" />\n            ))}\n          </>\n        ) : (\n          data?.data?.map((item, index) => (\n            <div className=\"relative m-4\" key={item.feed.id}>\n              <TrendingFeedCard item={item} />\n              <div className=\"pointer-events-none absolute inset-0 -left-5 -top-6 overflow-hidden rounded-xl\">\n                <div\n                  className={cn(\n                    \"center absolute -left-5 -top-6 size-12 rounded-br-3xl pl-4 pt-5 text-xs\",\n                    index < 3\n                      ? cn(\n                          \"bg-accent text-white\",\n                          index === 0 && \"bg-accent\",\n                          index === 1 && \"bg-accent/90\",\n                          index === 2 && \"bg-accent/80\",\n                        )\n                      : \"bg-material-opaque\",\n                  )}\n                >\n                  {index + 1}\n                </div>\n              </div>\n            </div>\n          ))\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { tracker } from \"@follow/tracker\"\nimport { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport { useMemo, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useAudioPlayerAtomSelector } from \"~/atoms/player\"\nimport { getUpdaterStatus, setUpdaterStatus, useUpdaterStatus } from \"~/atoms/updater\"\nimport { ipcServices } from \"~/lib/client\"\n\nexport const UpdateNotice = () => {\n  const updaterStatus = useUpdaterStatus()\n  const { t } = useTranslation()\n\n  const handleClick = useRef(() => {\n    const status = getUpdaterStatus()\n    if (!status) return\n    tracker.updateRestart({\n      type: status.type,\n    })\n    switch (status.type) {\n      case \"app\": {\n        ipcServices?.app.quitAndInstall()\n        break\n      }\n      case \"renderer\": {\n        ipcServices?.app.rendererUpdateReload()\n        break\n      }\n      case \"pwa\": {\n        status.finishUpdate?.()\n        break\n      }\n      case \"distribution\": {\n        if (status.storeUrl) {\n          if (ipcServices?.app.openExternal) {\n            void ipcServices.app.openExternal(status.storeUrl)\n          } else {\n            window.open(status.storeUrl, \"_blank\")\n          }\n        }\n        break\n      }\n    }\n    setUpdaterStatus(null)\n  }).current\n\n  const playerIsShow = useAudioPlayerAtomSelector((s) => s.show)\n\n  const storeName = useMemo(() => {\n    if (updaterStatus?.type !== \"distribution\") {\n      return null\n    }\n    const { distribution } = updaterStatus\n\n    switch (distribution) {\n      case \"mas\": {\n        return t(\"notify.store.mas\")\n      }\n      case \"mss\": {\n        return t(\"notify.store.mss\")\n      }\n      default: {\n        return t(\"notify.store.default\")\n      }\n    }\n  }, [t, updaterStatus])\n\n  const subtitle = useMemo(() => {\n    if (!updaterStatus) return null\n\n    switch (updaterStatus.type) {\n      case \"app\": {\n        return t(\"notify.update_info_1\")\n      }\n      case \"renderer\": {\n        return t(\"notify.update_info_2\")\n      }\n      case \"pwa\": {\n        return t(\"notify.update_info_3\")\n      }\n      case \"distribution\": {\n        return t(\"notify.update_info_store\", { store: storeName ?? \"\" })\n      }\n      default: {\n        return null\n      }\n    }\n  }, [storeName, t, updaterStatus])\n\n  if (!updaterStatus) return null\n\n  return (\n    <m.div\n      className={cn(\n        \"group absolute inset-x-3 cursor-pointer\",\n        playerIsShow ? \"bottom-[4.5rem]\" : \"bottom-3\",\n      )}\n      onClick={handleClick}\n      initial={{ y: 20, opacity: 0, scale: 0.95 }}\n      animate={{ y: 0, opacity: 1, scale: 1 }}\n      exit={{ y: 20, opacity: 0, scale: 0.95 }}\n      transition={Spring.presets.smooth}\n      whileHover={{ scale: 1.02 }}\n      whileTap={{ scale: 0.98 }}\n    >\n      {/* Glassmorphic container */}\n      <div\n        className=\"relative overflow-hidden rounded-xl bg-background\"\n        style={{\n          borderWidth: \"1px\",\n          borderStyle: \"solid\",\n          borderColor: \"rgba(255, 92, 0, 0.2)\",\n          boxShadow:\n            \"0 8px 32px rgba(255, 92, 0, 0.08), 0 4px 16px rgba(255, 92, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)\",\n        }}\n      >\n        {/* Inner glow layer */}\n        <div\n          className=\"absolute inset-0 rounded-xl opacity-0 transition-opacity duration-500 group-hover:opacity-100\"\n          style={{\n            background:\n              \"linear-gradient(to bottom right, rgba(255, 92, 0, 0.05), transparent, rgba(255, 92, 0, 0.05))\",\n          }}\n        />\n\n        {/* Animated shine effect */}\n        <div className=\"absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray/5 to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5\" />\n\n        {/* Content */}\n        <div className=\"relative flex items-center gap-3 px-4 py-2.5\">\n          {/* Animated icon */}\n          <m.div\n            className=\"flex-shrink-0\"\n            initial={{ rotate: -10 }}\n            animate={{ rotate: 0 }}\n            transition={{ ...Spring.presets.bouncy, delay: 0.1 }}\n          >\n            <div className=\"relative flex size-9 items-center justify-center\">\n              {/* Icon */}\n              <div className=\"relative flex items-center justify-center\">\n                <i className=\"i-mgc-download-2-cute-re size-6 text-orange\" />\n              </div>\n            </div>\n          </m.div>\n\n          {/* Text content */}\n          <div className=\"min-w-0 flex-1 text-left\">\n            <m.div\n              className=\"text-sm font-medium text-text\"\n              initial={{ x: -10, opacity: 0 }}\n              animate={{ x: 0, opacity: 1 }}\n              transition={{ ...Spring.presets.smooth, delay: 0.15 }}\n            >\n              {t(\"notify.update_info\", { app_name: APP_NAME })}\n            </m.div>\n            {subtitle ? (\n              <m.div\n                className=\"mt-0.5 text-xs text-text-tertiary\"\n                initial={{ x: -10, opacity: 0 }}\n                animate={{ x: 0, opacity: 1 }}\n                transition={{ ...Spring.presets.smooth, delay: 0.2 }}\n              >\n                {subtitle}\n              </m.div>\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/upgrade/container.tsx",
    "content": "import { useOnce } from \"@follow/hooks\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { repository } from \"@pkg\"\nimport type { FC } from \"react\"\nimport { Suspense, use, useEffect, useRef } from \"react\"\nimport { toast } from \"sonner\"\n\nimport { useServerConfigs } from \"~/atoms/server-configs\"\nimport { Markdown } from \"~/components/ui/markdown/Markdown\"\nimport { PeekModal } from \"~/components/ui/modal/inspire/PeekModal\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { Paper } from \"~/components/ui/paper\"\nimport { DebugRegistry } from \"~/modules/debug/registry\"\n\nimport { linkifyChangelog } from \"./utils\"\n\nconst AppNotificationContainer: FC = () => {\n  const { present } = useModalStack()\n\n  const serverConfigs = useServerConfigs()\n\n  const onceRef = useRef(false)\n  useEffect(() => {\n    if (onceRef.current) return\n\n    if (!serverConfigs?.ANNOUNCEMENT) return\n    onceRef.current = true\n    try {\n      const payload = JSON.parse(serverConfigs.ANNOUNCEMENT) as {\n        title: string\n        id: number | string\n        content: string\n      }\n\n      if (payload.id) {\n        const storeKey = getStorageNS(`announcement-${payload.id}`)\n\n        const showPrevious = localStorage.getItem(storeKey)\n        if (showPrevious) {\n          return\n        }\n        localStorage.setItem(storeKey, payload.id.toString())\n      }\n\n      toast.info(payload.title, {\n        description: <Markdown className=\"text-sm\">{payload.content}</Markdown>,\n        duration: Infinity,\n        closeButton: true,\n      })\n    } catch (e) {\n      console.error(e)\n    }\n  }, [serverConfigs?.ANNOUNCEMENT])\n\n  useOnce(() => {\n    const toaster = () => {\n      toast.success(\"\", {\n        description: (\n          <div className=\"font-medium text-text\">\n            App is upgraded to{\" \"}\n            <a\n              href={`${repository.url}/releases/tag/v${APP_VERSION}`}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              {APP_VERSION}\n            </a>\n            , enjoy the new features! 🎉\n          </div>\n        ),\n        closeButton: true,\n        duration: 5000,\n        action: CHANGELOG_CONTENT\n          ? {\n              label: \"What's new?\",\n              onClick: () => {\n                nextFrame(() => {\n                  present({\n                    clickOutsideToDismiss: true,\n                    title: \"What's new?\",\n                    autoFocus: false,\n                    modalClassName:\n                      \"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0\",\n\n                    CustomModalComponent: ({ children }) => {\n                      return <PeekModal>{children}</PeekModal>\n                    },\n                    content: () => (\n                      <Suspense>\n                        <Changelog />\n                      </Suspense>\n                    ),\n                    overlay: true,\n                  })\n                })\n              },\n            }\n          : undefined,\n      })\n    }\n    if (window.__app_is_upgraded__) {\n      setTimeout(toaster)\n    }\n\n    DebugRegistry.add(\"App Upgraded Toast\", toaster)\n  })\n\n  return null\n}\nexport default AppNotificationContainer\n\nconst changelogContext = (async () => {\n  const repoUrl = repository.url\n  if (import.meta.env.DEV) {\n    const content = await import(\"../../../../../changelog/next.md?raw\").then((m) => m.default)\n    return linkifyChangelog(content, repoUrl)\n  }\n  return linkifyChangelog(CHANGELOG_CONTENT, repoUrl)\n})()\nconst Changelog = () => (\n  <Paper>\n    <Markdown className=\"mt-8 w-full max-w-full\">{use(changelogContext)}</Markdown>\n  </Paper>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/upgrade/lazy/index.electron.ts",
    "content": "export { default as AppNotificationContainer } from \"../container\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/upgrade/lazy/index.ts",
    "content": "import { lazy } from \"react\"\n\nexport const AppNotificationContainer = lazy(() => import(\"../container\"))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/upgrade/utils.ts",
    "content": "export const linkifyChangelog = (content: string, repoUrl: string) => {\n  if (!repoUrl) {\n    return content\n  }\n  const cleanRepoUrl = repoUrl.replace(/\\.git$/, \"\")\n\n  // Linkify commit hashes, e.g., (26c6853)\n  let linkedContent = content.replaceAll(\n    /\\((([a-f0-9]{7,40}))\\)/g,\n    (match, hash) => `([${hash}](${cleanRepoUrl}/commit/${hash}))`,\n  )\n\n  // Linkify issue/PR numbers, e.g., (#3809)\n  linkedContent = linkedContent.replaceAll(\n    /\\(#(\\d+)\\)/g,\n    (match, issue) => `([#${issue}](${cleanRepoUrl}/pull/${issue}))`,\n  )\n\n  // Linkify contributors, e.g., @ericyzhu\n  linkedContent = linkedContent.replaceAll(\n    /\\B@([a-z0-9-]+)/gi,\n    (match, username) => `[@${username}](https://github.com/${username})`,\n  )\n\n  return linkedContent\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/user/LoginButton.tsx",
    "content": "import { LucideLogIn } from \"@follow/components/icons/user.jsx\"\nimport { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport type { FC } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useLoginModal } from \"~/hooks/common\"\n\nexport interface LoginProps {\n  method?: \"redirect\" | \"modal\"\n}\nexport const LoginButton: FC<LoginProps> = (props) => {\n  const { method } = props\n  const presentLoginModal = useLoginModal()\n  const { t } = useTranslation()\n  const Content = (\n    <ActionButton\n      data-testid=\"login-button\"\n      className=\"relative z-[1]\"\n      onClick={\n        method === \"modal\"\n          ? () => {\n              presentLoginModal()\n            }\n          : undefined\n      }\n      tooltip={t(\"words.login\")}\n    >\n      <LucideLogIn className=\"size-5 text-text-secondary\" />\n    </ActionButton>\n  )\n  return method === \"modal\" ? Content : <a href=\"/login\">{Content}</a>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx",
    "content": "import { ActionButton } from \"@follow/components/ui/button/index.js\"\nimport { RSSHubLogo } from \"@follow/components/ui/platform-icon/icons.js\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport { useMeasure } from \"@follow/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { repository } from \"@pkg\"\nimport type { FC } from \"react\"\nimport { memo, useCallback, useLayoutEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useNavigate } from \"react-router\"\n\nimport { useIsInMASReview, useServerConfigs } from \"~/atoms/server-configs\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"~/components/ui/dropdown-menu/dropdown-menu\"\nimport { useFeature } from \"~/hooks/biz/useFeature\"\nimport { UrlBuilder } from \"~/lib/url-builder\"\nimport { usePresentUserProfileModal } from \"~/modules/profile/hooks\"\nimport { useSettingModal } from \"~/modules/settings/modal/use-setting-modal-hack\"\nimport { signOut, useSession } from \"~/queries/auth\"\nimport { useWallet } from \"~/queries/wallet\"\n\nimport type { LoginProps } from \"./LoginButton\"\nimport { LoginButton } from \"./LoginButton\"\nimport { UserAvatar } from \"./UserAvatar\"\nimport { UserProBadge } from \"./UserProBadge\"\n\nexport type ProfileButtonProps = LoginProps & {\n  animatedAvatar?: boolean\n}\n\nexport const ProfileButton: FC<ProfileButtonProps> = memo((props) => {\n  const serverConfig = useServerConfigs()\n  const { status, session } = useSession()\n  const { user } = session || {}\n  const settingModalPresent = useSettingModal()\n  const presentUserProfile = usePresentUserProfileModal(\"dialog\")\n  const { t } = useTranslation()\n  const aiEnabled = useFeature(\"ai\")\n  const wallet = useWallet()\n  const hasPowerToken = !!wallet.data?.[0]?.powerToken\n\n  const [dropdown, setDropdown] = useState(false)\n\n  const navigate = useNavigate()\n\n  const role = useUserRole()\n  const isInMASReview = useIsInMASReview()\n\n  if (status !== \"authenticated\") {\n    return <LoginButton {...props} />\n  }\n\n  return (\n    <DropdownMenu onOpenChange={setDropdown}>\n      <DropdownMenuTrigger\n        asChild\n        className=\"!outline-none focus-visible:bg-theme-item-hover data-[state=open]:bg-transparent\"\n        data-testid=\"profile-menu-trigger\"\n      >\n        {props.animatedAvatar ? (\n          <TransitionAvatar stage={dropdown ? \"zoom-in\" : \"\"} />\n        ) : (\n          <UserAvatar hideName className=\"size-6 p-0 [&_*]:border-0\" />\n        )}\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent\n        className=\"min-w-[240px] overflow-visible px-1 pt-6 macos:bg-material-opaque\"\n        side=\"bottom\"\n        align=\"center\"\n      >\n        <DropdownMenuLabel>\n          <div className=\"text-center leading-none\">\n            <EllipsisHorizontalTextWithTooltip className=\"mx-auto max-w-[20ch] truncate text-lg\">\n              {user?.name}\n            </EllipsisHorizontalTextWithTooltip>\n            {!isInMASReview && serverConfig?.PAYMENT_ENABLED ? (\n              <UserProBadge\n                role={role}\n                withText\n                className=\"mt-0.5 w-full justify-center\"\n                onClick={() => {\n                  settingModalPresent(\"plan\")\n                }}\n              />\n            ) : (\n              <>\n                {!!user?.handle && (\n                  <a href={UrlBuilder.profile(user.handle)} target=\"_blank\" className=\"block\">\n                    <EllipsisHorizontalTextWithTooltip className=\"mt-0.5 truncate text-xs font-medium text-zinc-500\">\n                      @{user.handle}\n                    </EllipsisHorizontalTextWithTooltip>\n                  </a>\n                )}\n              </>\n            )}\n          </div>\n        </DropdownMenuLabel>\n\n        <DropdownMenuSeparator />\n\n        {!isInMASReview && serverConfig?.PAYMENT_ENABLED && (\n          <DropdownMenuItem\n            className=\"pl-3\"\n            onClick={() => {\n              settingModalPresent(\"plan\")\n            }}\n            icon={<i className=\"i-mgc-power-outline\" />}\n          >\n            {t(\"activation.plan.title\")}\n          </DropdownMenuItem>\n        )}\n\n        {aiEnabled && (\n          <DropdownMenuItem\n            className=\"pl-3\"\n            onClick={() => {\n              navigate(\"/ai\")\n            }}\n            icon={<i className=\"i-mgc-ai-cute-re\" />}\n          >\n            {t(\"user_button.ai\")}\n          </DropdownMenuItem>\n        )}\n\n        {!isInMASReview && hasPowerToken && (\n          <DropdownMenuItem\n            className=\"pl-3\"\n            onClick={() => {\n              navigate(\"/power\")\n            }}\n            icon={<i className=\"i-mgc-power-outline\" />}\n          >\n            {t(\"user_button.power\")}\n          </DropdownMenuItem>\n        )}\n        <DropdownMenuItem\n          className=\"pl-3\"\n          onClick={() => {\n            presentUserProfile(user?.id)\n          }}\n          icon={<i className=\"i-mgc-user-3-cute-re\" />}\n        >\n          {t(\"user_button.profile\")}\n        </DropdownMenuItem>\n\n        <DropdownMenuSeparator />\n        <DropdownMenuItem\n          className=\"pl-3\"\n          data-testid=\"profile-menu-preferences\"\n          onClick={() => {\n            settingModalPresent()\n          }}\n          icon={<i className=\"i-mgc-settings-7-cute-re\" />}\n          shortcut={\"$mod+,\"}\n        >\n          {t(\"user_button.preferences\")}\n        </DropdownMenuItem>\n\n        <DropdownMenuSeparator />\n\n        <DropdownMenuItem\n          className=\"pl-3\"\n          onClick={() => {\n            navigate(\"/action\")\n          }}\n          icon={<i className=\"i-mgc-magic-2-cute-re\" />}\n        >\n          {t(\"words.actions\")}\n        </DropdownMenuItem>\n        {!isInMASReview && (\n          <DropdownMenuItem\n            className=\"pl-3\"\n            onClick={() => {\n              navigate(\"/rsshub\")\n            }}\n            icon={<RSSHubLogo className=\"size-3 grayscale\" />}\n          >\n            {t(\"words.rsshub\")}\n          </DropdownMenuItem>\n        )}\n        <DropdownMenuSeparator />\n        {!window.electron && (\n          <>\n            <DropdownMenuItem\n              className=\"pl-3\"\n              onClick={() => {\n                window.open(`${repository.url}/releases`)\n              }}\n              icon={<i className=\"i-mgc-download-2-cute-re\" />}\n            >\n              {t(\"user_button.download_desktop_app\")}\n            </DropdownMenuItem>\n            <DropdownMenuSeparator />\n          </>\n        )}\n        <DropdownMenuItem\n          className=\"pl-3\"\n          data-testid=\"profile-menu-logout\"\n          onClick={signOut}\n          icon={<i className=\"i-mgc-exit-cute-re\" />}\n        >\n          {t(\"user_button.log_out\")}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n})\nProfileButton.displayName = \"ProfileButton\"\n\nconst TransitionAvatar = ({\n  ref: forwardRef,\n  stage,\n  ...props\n}: {\n  stage: \"zoom-in\" | \"\"\n} & React.HTMLAttributes<HTMLButtonElement> & {\n    ref?: React.Ref<HTMLButtonElement | null>\n  }) => {\n  const [ref, { x, y }, forceRefresh] = useMeasure()\n  const [avatarHovered, setAvatarHovered] = useState(false)\n\n  const zoomIn = stage === \"zoom-in\"\n  const [currentZoomIn, setCurrentZoomIn] = useState(false)\n  useLayoutEffect(() => {\n    if (zoomIn) {\n      setCurrentZoomIn(true)\n    }\n  }, [zoomIn])\n\n  return (\n    <>\n      <ActionButton\n        {...props}\n        ref={forwardRef}\n        onMouseEnter={useCallback(() => {\n          forceRefresh()\n          setAvatarHovered(true)\n        }, [forceRefresh])}\n        onMouseLeave={useCallback(() => {\n          setAvatarHovered(false)\n        }, [])}\n      >\n        <UserAvatar ref={ref} className=\"h-6 p-0 [&_*]:border-0\" hideName />\n      </ActionButton>\n      {x !== 0 && y !== 0 && (avatarHovered || zoomIn || currentZoomIn) && (\n        <RootPortal>\n          <UserAvatar\n            style={{\n              left: x - (zoomIn ? 16 : 0),\n              top: y,\n            }}\n            className={cn(\n              \"pointer-events-none fixed -bottom-6 p-0 duration-200 [&_*]:border-0\",\n              \"transform-gpu will-change-[left,top,height]\",\n              zoomIn ? \"z-[99] h-14\" : \"z-[-1] h-6\",\n            )}\n            hideName\n            onTransitionEnd={() => {\n              if (!zoomIn && currentZoomIn) {\n                setCurrentZoomIn(false)\n              }\n            }}\n          />\n        </RootPortal>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/user/UserAvatar.tsx",
    "content": "import { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { UserRole } from \"@follow/constants\"\nimport { usePrefetchUser, useUserById, useUserRole, useWhoami } from \"@follow/store/user/hooks\"\nimport { getColorScheme, stringToHue } from \"@follow/utils/color\"\nimport { cn } from \"@follow/utils/utils\"\n\nimport { useServerConfigs } from \"~/atoms/server-configs\"\nimport { useReplaceImgUrlIfNeed } from \"~/lib/img-proxy\"\nimport { usePresentUserProfileModal } from \"~/modules/profile/hooks\"\n\nimport type { LoginProps } from \"./LoginButton\"\nimport { UserProBadge } from \"./UserProBadge\"\n\nexport const UserAvatar = ({\n  ref,\n  className,\n  avatarClassName,\n  hideName,\n  userId,\n  enableModal,\n  style,\n  onClick,\n  ...props\n}: {\n  className?: string\n  avatarClassName?: string\n  hideName?: boolean\n  userId?: string\n  enableModal?: boolean\n} & LoginProps &\n  React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const replaceImgUrlIfNeed = useReplaceImgUrlIfNeed()\n  const serverConfig = useServerConfigs()\n  const whoami = useWhoami()\n  const role = useUserRole()\n  const presentUserProfile = usePresentUserProfileModal(\"drawer\")\n\n  usePrefetchUser(userId)\n  const profile = useUserById(userId)\n\n  const renderUserData = userId ? profile : whoami\n  const randomColor = stringToHue(renderUserData?.name || \"\")\n  return (\n    <div\n      style={style}\n      ref={ref}\n      onClick={(e) => {\n        if (enableModal) {\n          presentUserProfile(userId)\n        }\n        onClick?.(e)\n      }}\n      {...props}\n      className={cn(\n        \"relative flex h-20 items-center justify-center gap-2 px-5 py-2 font-medium text-text-secondary\",\n        className,\n      )}\n    >\n      <Avatar\n        className={cn(\n          \"aspect-square h-full w-auto overflow-hidden rounded-full border bg-stone-300\",\n          avatarClassName,\n        )}\n      >\n        <AvatarImage\n          className=\"duration-200 animate-in fade-in-0\"\n          src={replaceImgUrlIfNeed(renderUserData?.image || undefined)}\n        />\n        <AvatarFallback\n          style={{ backgroundColor: getColorScheme(randomColor, true).light.accent }}\n          className=\"text-xs text-white\"\n        >\n          {renderUserData?.name?.[0]}\n        </AvatarFallback>\n      </Avatar>\n      {serverConfig?.PAYMENT_ENABLED &&\n        !userId &&\n        role !== UserRole.Free &&\n        role !== UserRole.Trial && (\n          <UserProBadge\n            className=\"absolute bottom-0 right-0 mb-[-6%] mr-[-6%] size-2/5 max-h-5 max-w-5\"\n            iconClassName=\"size-full\"\n            role={role}\n          />\n        )}\n      {!hideName && <div>{renderUserData?.name || renderUserData?.handle}</div>}\n    </div>\n  )\n}\n\nUserAvatar.displayName = \"UserAvatar\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/user/UserGallery.tsx",
    "content": "import type { UserModel } from \"@follow/store/user/store\"\n\nimport { UserAvatar } from \"~/modules/user/UserAvatar\"\n\ninterface UserGalleryProps {\n  users: UserModel[]\n  dedupe?: boolean\n  limit?: number\n}\n\nexport const UserGallery = ({ users, limit = 18 }: UserGalleryProps) => {\n  const displayedUsers = users.slice(0, limit)\n\n  return (\n    <div className=\"mx-auto flex w-fit max-w-80 flex-wrap gap-4\">\n      {displayedUsers.map((user) => (\n        <div key={user.id} className=\"size-8\">\n          <UserAvatar\n            className=\"h-auto p-0\"\n            avatarClassName=\"size-8\"\n            userId={user.id}\n            enableModal={true}\n            hideName={true}\n          />\n        </div>\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/user/UserProBadge.tsx",
    "content": "import { UserRole, UserRoleName } from \"@follow/constants\"\nimport { cn } from \"@follow/utils\"\n\nexport const UserProBadge = ({\n  className,\n  withText,\n  iconClassName,\n  role,\n  onClick,\n}: {\n  className?: string\n  withText?: boolean\n  iconClassName?: string\n  role?: UserRole | null\n  onClick?: () => void\n}) => {\n  if (!role) {\n    return null\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-1\",\n        role === UserRole.Trial || role === UserRole.Free ? \"text-text-secondary\" : \"text-folo\",\n        className,\n      )}\n      onClick={onClick}\n    >\n      <i className={cn(\"i-mgc-power block\", iconClassName)} />\n      {withText && <span className=\"text-xs\">{UserRoleName[role]}</span>}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/user/utils.ts",
    "content": "import type { UserModel } from \"@follow/store/user/store\"\n\nexport const deduplicateUsers = (users: UserModel[]): UserModel[] => {\n  const userMap = new Map<string, UserModel>()\n  users.forEach((user) => {\n    userMap.set(user.id, user)\n  })\n  return Array.from(userMap.values())\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/modules/wallet/balance.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipTrigger } from \"@follow/components/ui/tooltip/index.js\"\nimport { cn, toScientificNotation } from \"@follow/utils/utils\"\nimport { format } from \"dnum\"\n\nimport { useGeneralSettingSelector } from \"~/atoms/settings/general\"\n\nexport const Balance = ({\n  children,\n  value,\n  className,\n  precision = 2,\n  withSuffix = false,\n  withTooltip = false,\n  scientificThreshold = 6,\n}: {\n  /** The token balance in wei. */\n  children: bigint | string\n  value?: bigint | string\n  className?: string\n  precision?: number\n  withSuffix?: boolean\n  withTooltip?: boolean\n  scientificThreshold?: number\n}) => {\n  const language = useGeneralSettingSelector((s) => s.language)\n  let locale: Intl.Locale\n  try {\n    locale = new Intl.Locale(language.replace(\"_\", \"-\"))\n  } catch {\n    locale = new Intl.Locale(\"en-US\")\n  }\n\n  const n = [BigInt(children || 0n) || BigInt(value || 0n), 18] as const\n  const formatted = format(n, { digits: precision, trailingZeros: true, locale })\n  const formattedFull = format(n, { digits: 18, trailingZeros: true, locale })\n\n  const Content = (\n    <span className={cn(\"tabular-nums\", className)}>\n      {withSuffix && <i className=\"i-mgc-power mr-1 -translate-y-px align-middle text-folo\" />}\n      <span className=\"font-mono\">\n        {withTooltip ? toScientificNotation(n, scientificThreshold, locale) : formatted}\n      </span>\n    </span>\n  )\n\n  if (!withTooltip) return Content\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{Content}</TooltipTrigger>\n      <TooltipContent>\n        <div className=\"font-mono text-sm\">\n          <span className=\"font-bold tabular-nums\">{formattedFull}</span> <span>Power</span>\n        </div>\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(ai)/ai/index.tsx",
    "content": "import { cn } from \"@follow/utils\"\n\nimport { AIChatRoot } from \"~/modules/ai-chat/components/layouts/AIChatRoot\"\nimport { ChatPageHeader } from \"~/modules/ai-chat/components/layouts/ChatHeader\"\nimport { ChatInterface } from \"~/modules/ai-chat/components/layouts/ChatInterface\"\n\nexport const Component = () => {\n  return (\n    <div\n      className={cn(\n        \"relative flex h-screen w-full flex-col\",\n        \"[&_[data-testid=chat-input-container]]:translate-y-32 [&_[data-testid=welcome-screen-header]]:-translate-y-24\",\n      )}\n      style={{ \"--ai-chat-layout-width\": \"65rem\" } as React.CSSProperties}\n    >\n      <AIChatRoot>\n        <ChatPageHeader />\n        <ChatInterface centerInputOnEmpty visualOffsetY=\"clamp(-10vh, -8vh, -6vh)\" />\n      </AIChatRoot>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/action/index.tsx",
    "content": "import { repository } from \"@pkg\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ActionSetting } from \"~/modules/action/action-setting\"\nimport { useSubViewTitle } from \"~/modules/app-layout/subview/hooks\"\n\nexport function Component() {\n  const { t } = useTranslation(\"common\")\n\n  useSubViewTitle(\"words.actions\")\n\n  return (\n    <div className=\"-mt-6 flex size-full min-h-[calc(100vh-8rem)] flex-col px-6\">\n      {/* Simple Header */}\n      <div className=\"mx-auto max-w-6xl text-center\">\n        <h1 className=\"mb-4 text-3xl font-bold text-text\">{t(\"words.actions\")}</h1>\n\n        {/* Documentation Link */}\n        <a\n          href={`${repository.url}/wiki/Actions`}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          className=\"inline-flex items-center gap-2 text-sm text-text-secondary transition-colors hover:text-accent\"\n        >\n          <i className=\"i-mgc-book-6-cute-re size-4\" />\n          <span>{t(\"words.documentation\")}</span>\n        </a>\n      </div>\n\n      {/* Content */}\n      <div className=\"relative mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col @container\">\n        <ActionSetting />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx",
    "content": "import { EmptyIcon } from \"@follow/components/icons/empty.js\"\nimport { Card } from \"@follow/components/ui/card/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/Input.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.js\"\nimport { useScrollElementUpdate } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/EllipsisWithTooltip.js\"\nimport { CategoryMap, RSSHubCategories } from \"@follow/constants\"\nimport { cn, formatNumber } from \"@follow/utils/utils\"\nimport type { RSSHubAnalyticsResponse, RSSHubNamespace } from \"@follow-app/client-sdk\"\nimport { keepPreviousData } from \"@tanstack/react-query\"\nimport { memo, useCallback, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Link, useParams } from \"react-router\"\n\nimport { useUISettingKey } from \"~/atoms/settings/ui\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { useSubViewTitle } from \"~/modules/app-layout/subview/hooks\"\nimport { RecommendationContent } from \"~/modules/discover/RecommendationContent\"\nimport { FeedIcon } from \"~/modules/feed/feed-icon\"\nimport { Queries } from \"~/queries\"\nimport { getPreferredTitle } from \"~/store/feed/hooks\"\n\nconst LanguageMap = {\n  all: \"all\",\n  eng: \"en\",\n  cmn: \"zh-CN\",\n  fra: \"fr-FR\",\n} as const\n\nexport const Component = () => {\n  const { t } = useTranslation()\n  const lang = useUISettingKey(\"discoverLanguage\")\n  const category = useParams().category as (typeof RSSHubCategories)[number]\n  const title = t(`discover.category.${category}`, { ns: \"common\" })\n  useSubViewTitle(title as I18nKeys)\n\n  const rsshubPopular = useAuthQuery(\n    Queries.discover.rsshubCategory({\n      categories: category === \"all\" ? \"popular\" : `${category}`,\n      lang: LanguageMap[lang],\n    }),\n    {\n      staleTime: 1000 * 60 * 60 * 24, // 1 day\n      placeholderData: keepPreviousData as any as Record<string, RSSHubNamespace>,\n      meta: {\n        persist: true,\n      },\n    },\n  )\n  const { data } = rsshubPopular\n\n  const rsshubAnalytics = useAuthQuery(Queries.discover.rsshubAnalytics({ lang }), {\n    staleTime: 1000 * 60 * 60 * 24, // 1 day\n    placeholderData: keepPreviousData as any as RSSHubAnalyticsResponse,\n    meta: {\n      persist: true,\n    },\n  })\n\n  const { data: rsshubAnalyticsData } = rsshubAnalytics\n\n  const isLoading = rsshubPopular.isLoading || rsshubAnalytics.isLoading\n\n  const keys = useMemo(() => {\n    if (!data || !rsshubAnalyticsData) {\n      return []\n    }\n    return Object.keys(data).sort((a, b) => {\n      const aRoutes = Object.keys(data[a]?.routes ?? {})\n      const aHeat = aRoutes.reduce((acc, route) => {\n        return acc + (rsshubAnalyticsData?.[`/${a}${route}`]?.subscriptionCount ?? 0)\n      }, 0)\n      const bRoutes = Object.keys(data[b]?.routes ?? {})\n      const bHeat = bRoutes.reduce((acc, route) => {\n        return acc + (rsshubAnalyticsData?.[`/${b}${route}`]?.subscriptionCount ?? 0)\n      }, 0)\n\n      return bHeat - aHeat\n    })\n  }, [data, rsshubAnalyticsData])\n\n  const [search, setSearch] = useState(\"\")\n\n  const items = useMemo(\n    () =>\n      keys.map((key) => {\n        return {\n          key,\n          data: data![key],\n          routePrefix: key,\n        }\n      }),\n    [keys, data],\n  )\n\n  const filteredItems = useMemo(() => {\n    return items.filter((item) => {\n      const routes = Object.values(item.data?.routes ?? {})\n      const sources = [\n        item.data?.name,\n        item.routePrefix,\n        ...routes.map((route) => route.name),\n        ...routes.map((route) => route.path),\n      ]\n\n      return sources.some((source) => {\n        return source?.toLowerCase().includes(search.toLowerCase())\n      })\n    })\n  }, [items, search])\n\n  const { onUpdateMaxScroll } = useScrollElementUpdate()\n  useEffect(() => {\n    if (!isLoading && onUpdateMaxScroll) {\n      // Defer to next tick to avoid blocking main thread\n      const timeoutId = setTimeout(() => {\n        onUpdateMaxScroll()\n      }, 0)\n      return () => clearTimeout(timeoutId)\n    }\n  }, [isLoading])\n\n  return (\n    <div className=\"w-full max-w-[800px]\">\n      <div className=\"mb-10 flex w-full items-center justify-center gap-2 text-center text-2xl font-bold\">\n        <span>{CategoryMap[category]?.emoji}</span>\n        <span>{title}</span>\n      </div>\n      {isLoading ? (\n        <div className=\"center\">\n          <LoadingCircle size=\"large\" />\n        </div>\n      ) : items.length > 0 ? (\n        <div className=\"w-full px-8 pb-8 pt-4\">\n          <Input\n            placeholder={t(\"words.search\", { ns: \"common\" })}\n            className=\"mb-4\"\n            value={search}\n            onChange={(e) => {\n              setSearch(e.target.value)\n            }}\n          />\n          <div>\n            {filteredItems.map(\n              (item) =>\n                item?.data && (\n                  <div key={item.key} className=\"mb-4 break-inside-avoid\">\n                    <RecommendationListItem\n                      data={item.data}\n                      routePrefix={item.routePrefix}\n                      rsshubAnalyticsData={rsshubAnalyticsData}\n                    />\n                  </div>\n                ),\n            )}\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex h-full -translate-y-12 flex-col items-center justify-center text-center\">\n          <div className=\"mb-4 text-6xl\">\n            <EmptyIcon />\n          </div>\n          <p className=\"text-title2 text-text\">\n            {t(\"discover.empty.no_content\", { ns: \"common\" })}\n          </p>\n          <p className=\"mt-2 text-body text-text-secondary\">\n            {t(\"discover.empty.try_another_category_or_language\", { ns: \"common\" })}\n          </p>\n        </div>\n      )}\n    </div>\n  )\n}\n\nconst RecommendationListItem = memo(\n  ({\n    data,\n    routePrefix,\n    rsshubAnalyticsData,\n  }: {\n    data: RSSHubNamespace\n    routePrefix: string\n    rsshubAnalyticsData: RSSHubAnalyticsResponse | undefined\n  }) => {\n    const { t } = useTranslation()\n    const { present } = useModalStack()\n\n    const { maintainers, categories, routes } = useMemo(() => {\n      const maintainers = new Set<string>()\n      const categories = new Set<string>()\n      const routes = Object.keys(data.routes).sort((a, b) => {\n        const aHeat = rsshubAnalyticsData?.[`/${routePrefix}${a}`]?.subscriptionCount ?? 0\n        const bHeat = rsshubAnalyticsData?.[`/${routePrefix}${b}`]?.subscriptionCount ?? 0\n        return bHeat - aHeat\n      })\n\n      for (const route in data.routes) {\n        const routeData = data.routes[route]!\n        if (routeData.maintainers) {\n          routeData.maintainers.forEach((m) => maintainers.add(m))\n        }\n        if (routeData.categories) {\n          routeData.categories.forEach((c) => categories.add(c))\n        }\n      }\n      categories.delete(\"popular\")\n      return {\n        maintainers: Array.from(maintainers),\n        categories: Array.from(categories) as unknown as typeof RSSHubCategories,\n        routes,\n      }\n    }, [data, rsshubAnalyticsData, routePrefix])\n\n    const follow = useFollow()\n\n    const handleRouteClick = useCallback(\n      (route: string) => {\n        present({\n          id: `recommendation-content-${route}`,\n          content: () => (\n            <RecommendationContent routePrefix={routePrefix} route={data.routes[route]!} />\n          ),\n          icon: <FeedIcon className=\"size-4\" size={16} siteUrl={`https://${data.url}`} />,\n          title: `${data.name} - ${data.routes[route]!.name}`,\n        })\n      },\n      [present, routePrefix, data, data.url, data.name],\n    )\n\n    const handleFeedClick = useCallback(\n      (feedId: string) => {\n        follow({\n          isList: false,\n          id: feedId,\n        })\n      },\n      [follow],\n    )\n\n    return (\n      <Card className=\"overflow-hidden rounded-lg border border-border shadow-background transition-shadow duration-200 hover:shadow-md\">\n        <div className=\"flex items-center gap-3 border-b border-border p-4\">\n          <div className=\"size-8 overflow-hidden rounded-full bg-background\">\n            <FeedIcon className=\"mr-0 size-8\" size={32} siteUrl={`https://${data.url}`} />\n          </div>\n          <div className=\"flex w-full flex-1 justify-between\">\n            <h3 className=\"line-clamp-1 text-base font-medium\">\n              <a\n                href={`https://${data.url}`}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                className=\"hover:underline\"\n              >\n                {data.name}\n              </a>\n            </h3>\n\n            <div className=\"flex flex-wrap gap-1.5 text-xs\">\n              {categories.map((c) => (\n                <Link\n                  to={`/discover/category/${c}`}\n                  key={c}\n                  className={cn(\n                    \"cursor-pointer rounded-full bg-accent/10 px-2 py-0.5 leading-5 duration-200\",\n                    !RSSHubCategories.includes(c) ? \"pointer-events-none opacity-50\" : \"\",\n                  )}\n                >\n                  {RSSHubCategories.includes(c)\n                    ? t(`discover.category.${c}`, { ns: \"common\" })\n                    : c.charAt(0).toUpperCase() + c.slice(1)}\n                </Link>\n              ))}\n            </div>\n          </div>\n        </div>\n        <div className=\"p-4 pt-2\">\n          <ul className=\"mb-3 text-text\">\n            {routes.map((route) => (\n              <RouteItem\n                key={route}\n                route={route}\n                routeData={data.routes[route]!}\n                routePrefix={routePrefix}\n                rsshubAnalyticsData={rsshubAnalyticsData}\n                onRouteClick={handleRouteClick}\n                onFeedClick={handleFeedClick}\n              />\n            ))}\n          </ul>\n\n          {maintainers.length > 0 && (\n            <div className=\"mt-2 flex items-center text-xs text-text-secondary\">\n              <i className=\"i-mgc-hammer-cute-re mr-1 shrink-0 translate-y-0.5 self-start\" />\n              <span>\n                {maintainers.map((m, i) => (\n                  <span key={m}>\n                    <a\n                      href={`https://github.com/${m}`}\n                      className=\"hover:underline\"\n                      target=\"_blank\"\n                      rel=\"noreferrer\"\n                    >\n                      @{m}\n                    </a>\n                    {i < maintainers.length - 1 ? \", \" : \"\"}\n                  </span>\n                ))}\n              </span>\n            </div>\n          )}\n        </div>\n      </Card>\n    )\n  },\n)\n\nconst RouteItem = memo(\n  ({\n    route,\n    routeData,\n    routePrefix,\n    rsshubAnalyticsData,\n    onRouteClick,\n    onFeedClick,\n  }: {\n    route: string\n    routeData: any\n    routePrefix: string\n    rsshubAnalyticsData: any\n    onRouteClick: (route: string) => void\n    onFeedClick: (feedId: string) => void\n  }) => {\n    if (Array.isArray(routeData.path)) {\n      routeData.path = routeData.path.find((p: string) => p === route) ?? routeData.path[0]\n    }\n\n    const analytics = rsshubAnalyticsData?.[`/${routePrefix}${routeData.path}`]\n\n    return (\n      <li\n        className=\"-mx-4 rounded p-3 px-5 transition-colors hover:bg-material-opaque\"\n        role=\"button\"\n        onClick={() => onRouteClick(route)}\n      >\n        <div className=\"w-full\">\n          <div className=\"flex w-full items-center gap-8\">\n            <div className=\"flex flex-1 items-center gap-2\">\n              <div className=\"mr-2 size-1.5 rounded-full bg-accent\" />\n              <div className=\"relative h-5 grow\">\n                <div className=\"absolute inset-0 flex items-center gap-3 text-title3 font-medium\">\n                  <EllipsisHorizontalTextWithTooltip>\n                    {routeData.name}\n                  </EllipsisHorizontalTextWithTooltip>\n                  <EllipsisHorizontalTextWithTooltip className=\"text-xs text-text-secondary\">{`rsshub://${routePrefix}${routeData.path}`}</EllipsisHorizontalTextWithTooltip>\n                </div>\n              </div>\n            </div>\n            {!!analytics?.subscriptionCount && (\n              <div className=\"flex items-center gap-0.5 text-xs\">\n                <i className=\"i-mgc-fire-cute-re\" />\n                {formatNumber(analytics?.subscriptionCount || 0)}\n              </div>\n            )}\n          </div>\n          {analytics?.topFeeds && (\n            <div className=\"mt-2 flex items-center gap-10 pl-5 text-xs\">\n              {analytics.topFeeds.slice(0, 2).map((feed: any) => (\n                <div key={feed.id} className=\"flex w-2/5 flex-1 items-center text-sm\">\n                  <FeedIcon\n                    target={feed}\n                    className=\"mask-squircle mask shrink-0 rounded-none\"\n                    size={16}\n                  />\n                  <div\n                    className=\"min-w-0 leading-tight\"\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      onFeedClick(feed.id)\n                    }}\n                  >\n                    <EllipsisHorizontalTextWithTooltip className=\"truncate\">\n                      {getPreferredTitle(feed) || feed?.title}\n                    </EllipsisHorizontalTextWithTooltip>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      </li>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { ReactNode } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { AppErrorBoundary } from \"~/components/common/AppErrorBoundary\"\nimport { ErrorComponentType } from \"~/components/errors/enum\"\nimport { useSubViewTitle } from \"~/modules/app-layout/subview/hooks\"\nimport { useHasDiscoverSearchData } from \"~/modules/discover/atoms/discover\"\nimport { DiscoveryContent } from \"~/modules/discover/DiscoveryContent\"\nimport { UnifiedDiscoverForm } from \"~/modules/discover/UnifiedDiscoverForm\"\n\n// ============================================================================\n// Section Components\n// ============================================================================\n\ninterface SectionProps {\n  children: ReactNode\n  className?: string\n}\n\nfunction Section({ children, className }: SectionProps) {\n  return <section className={cn(\"mx-auto w-full max-w-5xl\", className)}>{children}</section>\n}\n\n// ============================================================================\n// Main Component\n// ============================================================================\n\nexport function Component() {\n  const { t } = useTranslation()\n\n  useSubViewTitle(\"words.discover\")\n  const hasSearchData = useHasDiscoverSearchData()\n\n  return (\n    <div className=\"flex size-full flex-col p-6\">\n      <Section className=\"mb-8\">\n        <div className=\"rounded-[28px] border border-fill-secondary bg-material-ultra-thin px-6 py-8 shadow-sm\">\n          <div className=\"text-center\">\n            <h1 className=\"mb-2 text-3xl font-bold text-text\">{t(\"words.discover\")}</h1>\n            <p className=\"text-sm text-text-secondary\">{t(\"discover.tips.search_keyword\")}</p>\n          </div>\n          <div className=\"mt-6 flex flex-col items-center\">\n            <UnifiedDiscoverForm />\n          </div>\n        </div>\n      </Section>\n\n      {!hasSearchData && (\n        <Section className=\"mt-8\">\n          <AppErrorBoundary errorType={ErrorComponentType.RSSHubDiscoverError}>\n            <DiscoveryContent />\n          </AppErrorBoundary>\n        </Section>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/layout.tsx",
    "content": "export { SubviewLayout as Component } from \"~/modules/app-layout/subview\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/power/index.tsx",
    "content": "import { AutoResizeHeight } from \"@follow/components/ui/auto-resize-height/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { useSubViewTitle } from \"~/modules/app-layout/subview/hooks\"\nimport { MyWalletSection } from \"~/modules/power/my-wallet-section\"\nimport { TransactionsSection } from \"~/modules/power/transaction-section\"\n\nexport function Component() {\n  const { t } = useTranslation()\n  useSubViewTitle(\n    <div className=\"flex items-center gap-2\">\n      <i className=\"i-mgc-power size-4 text-folo\" />\n      {t(\"words.power\")}\n    </div>,\n    t(\"words.power\"),\n  )\n  return (\n    <div className=\"px-5 md:px-10 lg:w-[768px] lg:px-0\">\n      <div className=\"center mb-8 flex h-24 items-center gap-2 text-3xl font-bold\">\n        <div className=\"motion-preset-shake center text-accent motion-delay-500\">\n          <i className=\"i-mgc-power size-20 text-folo\" />\n        </div>\n      </div>\n      <MyWalletSection />\n\n      <Divider className=\"my-8\" />\n      <AutoResizeHeight>\n        <TransactionsSection />\n      </AutoResizeHeight>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx",
    "content": "import { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.js\"\nimport { RSSHubLogo } from \"@follow/components/ui/platform-icon/icons.js\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { cn, formatNumber } from \"@follow/utils/utils\"\nimport type { RSSHubListItem } from \"@follow-app/client-sdk\"\nimport { memo, useCallback, useEffect } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ErrorTooltip } from \"~/components/common/ErrorTooltip\"\nimport { HeaderActionButton, HeaderActionGroup } from \"~/components/ui/button/HeaderActionButton\"\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { useSetSubViewRightView, useSubViewTitle } from \"~/modules/app-layout/subview/hooks\"\nimport { useTOTPModalWrapper } from \"~/modules/profile/hooks\"\nimport { AddModalContent } from \"~/modules/rsshub/add-modal-content\"\nimport { ConfirmDeleteModalContent } from \"~/modules/rsshub/delete-modal-content\"\nimport { SetModalContent } from \"~/modules/rsshub/set-modal-content\"\nimport { UserAvatar } from \"~/modules/user/UserAvatar\"\nimport { Queries } from \"~/queries\"\nimport { useSetRSSHubMutation } from \"~/queries/rsshub\"\n\nexport function Component() {\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n\n  useSubViewTitle(\"words.rsshub\")\n  const setRightView = useSetSubViewRightView()\n  const handleAddInstance = useCallback(() => {\n    present({\n      title: t(\"rsshub.add_new_instance\"),\n      content: ({ dismiss }) => <AddModalContent dismiss={dismiss} />,\n    })\n  }, [present, t])\n  useEffect(() => {\n    setRightView(\n      <HeaderActionGroup>\n        <HeaderActionButton variant=\"accent\" icon=\"i-mingcute-add-line\" onClick={handleAddInstance}>\n          {t(\"rsshub.add_new_instance\")}\n        </HeaderActionButton>\n      </HeaderActionGroup>,\n    )\n    return () => {\n      setRightView(null)\n    }\n  }, [setRightView, handleAddInstance, t])\n  const list = useAuthQuery(Queries.rsshub.list(), { meta: { persist: true } })\n\n  return (\n    <div className=\"flex size-full flex-col px-6 py-8\">\n      {/* Simple Header */}\n      <div className=\"mx-auto mb-8 max-w-6xl text-center\">\n        <div className=\"mb-4 flex justify-center\">\n          <RSSHubLogo className=\"size-16\" />\n        </div>\n        <h1 className=\"mb-4 text-3xl font-bold text-text\">{t(\"words.rsshub\", { ns: \"common\" })}</h1>\n        <p className=\"mx-auto max-w-2xl text-sm leading-relaxed text-text-secondary\">\n          {t(\"rsshub.description\")}\n        </p>\n      </div>\n\n      <div className=\"mx-auto w-full max-w-6xl\">\n        {/* Add Instance Section */}\n        <div className=\"mb-8 flex justify-center\">\n          <Button onClick={handleAddInstance}>\n            <i className=\"i-mgc-add-cute-re mr-2 size-4\" />\n            <span>{t(\"rsshub.add_new_instance\")}</span>\n          </Button>\n        </div>\n\n        {/* Instances Section */}\n        <div className=\"space-y-4\">\n          <div className=\"flex items-center justify-between\">\n            <h2 className=\"text-xl font-semibold text-text\">{t(\"rsshub.public_instances\")}</h2>\n          </div>\n          <List data={list?.data} />\n        </div>\n      </div>\n    </div>\n  )\n}\n\ntype InstanceItem = RSSHubListItem | { id: string; isOfficial: true }\n\nconst InstanceCard = memo(({ item }: { item: InstanceItem }) => {\n  const { t } = useTranslation(\"settings\")\n  const me = whoami()\n  const status = useAuthQuery(Queries.rsshub.status())\n  const setRSSHubMutation = useSetRSSHubMutation()\n  const presetTOTPModal = useTOTPModalWrapper(setRSSHubMutation.mutateAsync)\n  const { present } = useModalStack()\n\n  const isOfficial = \"isOfficial\" in item && item.isOfficial\n  const instance = isOfficial ? ({} as Partial<RSSHubListItem>) : (item as RSSHubListItem)\n\n  const isInUse = isOfficial\n    ? !status?.data?.usage?.rsshubId\n    : status?.data?.usage?.rsshubId === instance.id\n  const isOwner = !isOfficial && instance.ownerUserId === me?.id\n  const hasError = !isOfficial && !!instance.errorMessage\n\n  const headerIcon = isOfficial ? (\n    <Logo className=\"size-8\" />\n  ) : (\n    <UserAvatar\n      userId={instance.ownerUserId}\n      className=\"h-auto justify-start p-0\"\n      avatarClassName=\"size-8\"\n    />\n  )\n\n  const title = isOfficial ? \"Folo Official\" : \"\"\n  const price = isOfficial ? 0 : instance.price\n  const description = isOfficial ? \"Folo Built-in RSSHub\" : instance.description\n\n  const usersStat = isOfficial ? \"*\" : instance.userCount || 0\n  const limitStat = isOfficial\n    ? t(\"rsshub.table.unlimited\")\n    : instance.userLimit == null\n      ? t(\"rsshub.table.unlimited\")\n      : instance.userLimit > 1\n        ? instance.userLimit\n        : t(\"rsshub.table.private\")\n\n  const tags = (\n    <>\n      {isOfficial && (\n        <span className=\"rounded bg-accent/10 px-1.5 py-0.5 text-xs text-accent\">\n          {t(\"rsshub.table.official\")}\n        </span>\n      )}\n      {isInUse && (\n        <span className=\"rounded bg-green/10 px-1.5 py-0.5 text-xs text-green\">\n          {t(\"rsshub.table.inuse\")}\n        </span>\n      )}\n      {isOwner && (\n        <span className=\"rounded bg-purple/10 px-1.5 py-0.5 text-xs text-purple\">\n          {t(\"rsshub.table.yours\")}\n        </span>\n      )}\n      {hasError && <span className=\"rounded bg-red/10 px-1.5 py-0.5 text-xs text-red\">Error</span>}\n    </>\n  )\n\n  const containerClassName = cn(\n    \"border-fill-secondary relative rounded-lg border p-4\",\n    isOfficial ? \"bg-fill-vibrant-quaternary\" : \"bg-fill-vibrant-quinary\",\n    isInUse && \"ring-accent/20 ring-2\",\n    hasError && \"border-red/30 bg-red/5\",\n  )\n\n  const instanceUserCountExceptOwner = isOfficial\n    ? 0\n    : instance.userCount\n      ? instance.userCount - (instance.ownerUserId === me?.id ? 1 : 0)\n      : 0\n\n  return (\n    <div className={containerClassName}>\n      <div className=\"mb-3 flex items-start justify-between\">\n        <div className=\"flex items-center gap-2\">\n          {headerIcon}\n          <div>\n            <h3 className=\"text-sm font-semibold text-text\">{title}</h3>\n            <div className=\"flex items-center gap-1\">{tags}</div>\n          </div>\n        </div>\n        <div className=\"text-right\">\n          <div className=\"flex items-center gap-1 text-sm font-medium\">\n            {formatNumber(price ?? 0)} <i className=\"i-mgc-power size-3 text-folo\" />\n          </div>\n        </div>\n      </div>\n\n      <p className=\"mb-3 line-clamp-1 text-xs text-text-secondary\">{description}</p>\n\n      <div className=\"flex items-center justify-between text-xs\">\n        <div className=\"flex gap-4\">\n          <div>\n            <span className=\"text-text-secondary\">Users:</span>{\" \"}\n            <span className=\"text-text\">{String(usersStat)}</span>\n          </div>\n          <div>\n            <span className=\"text-text-secondary\">Limit:</span>{\" \"}\n            <span className=\"text-text\">{String(limitStat)}</span>\n          </div>\n        </div>\n      </div>\n      <div className=\"mt-3 flex items-center justify-between\">\n        <div className=\"flex gap-1\">\n          {!isOfficial && isOwner && (\n            <>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() =>\n                  present({\n                    title: t(\"rsshub.table.edit\"),\n                    content: ({ dismiss }) => (\n                      <AddModalContent dismiss={dismiss} instance={instance as RSSHubListItem} />\n                    ),\n                  })\n                }\n              >\n                Edit\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                disabled={!!instanceUserCountExceptOwner}\n                onClick={() =>\n                  present({\n                    title: t(\"rsshub.table.delete.label\"),\n                    content: ({ dismiss }) => (\n                      <ConfirmDeleteModalContent dismiss={dismiss} id={instance.id!} />\n                    ),\n                  })\n                }\n              >\n                Del\n              </Button>\n            </>\n          )}\n        </div>\n        <div className=\"flex items-center\">\n          {isOfficial ? (\n            <div>\n              {isInUse ? (\n                <Button disabled size=\"sm\">\n                  {t(\"rsshub.table.inuse\")}\n                </Button>\n              ) : (\n                <Button size=\"sm\" onClick={() => presetTOTPModal({ id: null })}>\n                  {t(\"rsshub.table.use\")}\n                </Button>\n              )}\n            </div>\n          ) : (\n            <SelectInstanceButton instance={instance as RSSHubListItem} />\n          )}\n        </div>\n      </div>\n    </div>\n  )\n})\n\nfunction List({ data }: { data?: RSSHubListItem[] }) {\n  const status = useAuthQuery(Queries.rsshub.status())\n\n  const sortedData: InstanceItem[] = [\n    // Official instance first\n    {\n      id: \"official\",\n      isOfficial: true,\n    },\n    // Then user instances\n    ...(data?.sort((a, b) => {\n      // in use first\n      if (status?.data?.usage?.rsshubId === a.id) {\n        return -1\n      }\n      if (status?.data?.usage?.rsshubId === b.id) {\n        return 1\n      }\n\n      if (a.errorMessage && !b.errorMessage) {\n        return 1\n      }\n      if (!a.errorMessage && b.errorMessage) {\n        return -1\n      }\n\n      const loadA = Math.min((a.userCount ?? 0) / (a.userLimit ?? Infinity), 1)\n      const loadB = Math.min((b.userCount ?? 0) / (b.userLimit ?? Infinity), 1)\n\n      // full load last\n      if (loadA === 1 && loadB === 1) {\n        return a.price - b.price\n      }\n      if (loadA === 1) {\n        return 1\n      }\n      if (loadB === 1) {\n        return -1\n      }\n\n      return a.price - b.price || loadA - loadB\n    }) || []),\n  ]\n\n  return (\n    <div className=\"grid gap-3 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3\">\n      {sortedData.map((item) => (\n        <InstanceCard key={item.id} item={item} />\n      ))}\n    </div>\n  )\n}\n\nfunction SelectInstanceButton({ instance }: { instance: RSSHubListItem }) {\n  const { t } = useTranslation(\"settings\")\n  const { present } = useModalStack()\n  const status = useAuthQuery(Queries.rsshub.status())\n\n  const isNotAvailable = !!instance.errorMessage\n  const limitReached =\n    instance.userCount && instance.userLimit ? instance.userCount >= instance.userLimit : false\n\n  return (\n    <ErrorTooltip errorAt={instance.errorAt} errorMessage={instance.errorMessage} showWhenError>\n      <Button\n        buttonClassName=\"shrink-0\"\n        disabled={isNotAvailable || limitReached}\n        variant={status?.data?.usage?.rsshubId === instance.id ? \"outline\" : \"primary\"}\n        onClick={() => {\n          present({\n            title: t(\"rsshub.useModal.title\"),\n            content: ({ dismiss }) => <SetModalContent dismiss={dismiss} instance={instance} />,\n          })\n        }}\n      >\n        {t(\n          status?.data?.usage?.rsshubId === instance.id\n            ? \"rsshub.table.inuse\"\n            : isNotAvailable\n              ? \"rsshub.table.unavailable\"\n              : limitReached\n                ? \"rsshub.table.limit_reached\"\n                : \"rsshub.table.use\",\n        )}\n      </Button>\n    </ErrorTooltip>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/[entryId]/index.tsx",
    "content": "export const Component = null\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/index.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { redirect } from \"react-router\"\n\nimport { ROUTE_ENTRY_PENDING } from \"~/constants\"\n\nexport const Component = () => {\n  return null\n}\nexport const loader = ({ request }: { request: Request; params: { feedId: string } }) => {\n  const url = new URL(request.url)\n  const mobile = isMobile()\n  if (!mobile) {\n    return redirect(`${url.pathname}/${ROUTE_ENTRY_PENDING}`)\n  }\n  return {}\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx",
    "content": "export { AIEnhancedTimelineLayout as Component } from \"~/modules/app-layout/ai-enhanced-timeline\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/layout.tsx",
    "content": "import { Outlet, redirect } from \"react-router\"\n\nimport { getTimelineIdByView, parseView } from \"~/hooks/biz/useRouteParams\"\n\nexport const loader = ({\n  params,\n  request,\n}: {\n  params: { timelineId?: string }\n  request: Request\n}) => {\n  const { timelineId } = params\n  if (!timelineId) return null\n\n  const view = parseView(timelineId)\n  if (view === undefined) return null\n\n  const canonicalTimelineId = getTimelineIdByView(view)\n\n  if (canonicalTimelineId === timelineId) return null\n\n  const url = new URL(request.url)\n  const segments = url.pathname.split(\"/\")\n  const timelineIndex = segments.indexOf(\"timeline\")\n\n  if (timelineIndex !== -1 && segments[timelineIndex + 1]) {\n    segments[timelineIndex + 1] = canonicalTimelineId\n    const nextPathname = segments.join(\"/\") || \"/\"\n    const nextUrl = `${nextPathname}${url.search}${url.hash}`\n    return redirect(nextUrl)\n  }\n\n  return redirect(`/timeline/${canonicalTimelineId}${url.search}${url.hash}`)\n}\n\nexport const Component = () => {\n  return <Outlet />\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useSubscriptionStore } from \"@follow/store/subscription/store\"\nimport { redirect } from \"react-router\"\n\nimport { getUISettings } from \"~/atoms/settings/ui\"\nimport { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING, ROUTE_VIEW_ALL } from \"~/constants\"\nimport { computeTimelineTabLists } from \"~/hooks/biz/useTimelineList\"\n\nexport function Component() {\n  return null\n}\n\nexport const loader = () => {\n  const uiSettings = getUISettings()\n  const subscriptionState = useSubscriptionStore.getState()\n\n  const hasAudiosSubscription =\n    subscriptionState.feedIdByView[FeedViewType.Audios].size > 0 ||\n    subscriptionState.listIdByView[FeedViewType.Audios].size > 0\n\n  const hasNotificationsSubscription =\n    subscriptionState.feedIdByView[FeedViewType.Notifications].size > 0 ||\n    subscriptionState.listIdByView[FeedViewType.Notifications].size > 0\n\n  const { visible } = computeTimelineTabLists({\n    timelineTabs: uiSettings.timelineTabs,\n    hasAudiosSubscription,\n    hasNotificationsSubscription,\n  })\n\n  const firstTimeline = visible.find((id) => id !== ROUTE_VIEW_ALL) ?? ROUTE_VIEW_ALL\n\n  return redirect(`/timeline/${firstTimeline}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/(main)/layout.tsx",
    "content": "import { withResponsiveComponent } from \"@follow/components/utils/selector.js\"\n\nimport { MainDestopLayout } from \"~/modules/app-layout/subscription-column/index\"\n\nexport const Component = withResponsiveComponent(\n  () => Promise.resolve({ default: MainDestopLayout }),\n  async () => {\n    const { default: DownloadPage } = await import(\"~/modules/download\")\n    return { default: DownloadPage }\n  },\n  (w) => w < 768,\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/about.tsx",
    "content": "import { SettingAbout } from \"~/modules/settings/tabs/about\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst priority = Number.MAX_SAFE_INTEGER\nexport const loader = defineSettingPageData({\n  icon: \"i-mgc-information-cute-re\",\n  name: \"titles.about\",\n  priority,\n})\nexport const Component = () => (\n  <>\n    <SettingsTitle />\n    <SettingAbout />\n  </>\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/ai.tsx",
    "content": "import { getFeature } from \"~/hooks/biz/useFeature\"\nimport { SettingAI } from \"~/modules/settings/tabs/ai\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-ai-cute-re\"\nconst priority = (1000 << 1) + 15\n\n// eslint-disable-next-line react-refresh/only-export-components\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.ai\",\n  priority,\n  hideIf: () => !getFeature(\"ai\"),\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingAI />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/appearance.tsx",
    "content": "import { SettingAppearance } from \"~/modules/settings/tabs/appearance\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-palette-cute-re\"\nconst priority = (1000 << 1) + 10\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.appearance\",\n  priority,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingAppearance />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\n\nimport { SettingCli } from \"~/modules/settings/tabs/cli\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-terminal-cute-re\"\nconst priority = (1000 << 1) + 25\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.cli\",\n  priority,\n  hideIf: () => !IN_ELECTRON,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingCli />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/data-control.tsx",
    "content": "import { MaterialSymbolsDatabaseOutline } from \"@follow/components/icons/Database.js\"\n\nimport { SettingDataControl } from \"~/modules/settings/tabs/data-control\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst priority = (1000 << 1) + 30\n\nexport const loader = defineSettingPageData({\n  icon: <MaterialSymbolsDatabaseOutline />,\n  name: \"titles.data_control\",\n  priority,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingDataControl />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx",
    "content": "import { SettingFeeds } from \"~/modules/settings/tabs/feeds\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-certificate-cute-re\"\nconst priority = (1000 << 2) + 20\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.feeds\",\n  priority,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingFeeds />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/general.tsx",
    "content": "import { SettingGeneral } from \"~/modules/settings/tabs/general\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-settings-7-cute-re\"\nconst priority = 1000 << 1\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.general\",\n  priority,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingGeneral />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/integration.tsx",
    "content": "import { SettingIntegration } from \"~/modules/settings/tabs/integration\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-department-cute-re\"\nconst priority = (1000 << 1) + 20\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.integration\",\n  priority,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingIntegration />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/list.tsx",
    "content": "import { SettingLists } from \"~/modules/settings/tabs/lists\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-rada-cute-re\"\nconst priority = (1000 << 2) + 10\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.lists\",\n  priority,\n  hideIf: (ctx) => ctx.isInMASReview,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingLists />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/notifications.tsx",
    "content": "import { SettingNotifications } from \"~/modules/settings/tabs/notifications\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-notification-cute-re\"\nconst priority = (1000 << 1) + 50\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.notifications\",\n  priority,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingNotifications />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/plan.tsx",
    "content": "import { SettingPlan } from \"~/modules/settings/tabs/plan\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-power-outline\"\nconst priority = (1000 << 1) + 17\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.plan.short\",\n  title: \"titles.plan.long\",\n  priority,\n  hideIf: (ctx, serverConfigs) => ctx.isInMASReview || !serverConfigs?.PAYMENT_ENABLED,\n})\n\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <SettingPlan />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/profile.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/Divider.js\"\nimport { Label } from \"@follow/components/ui/label/index.js\"\n\nimport { useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { AccountManagement } from \"~/modules/profile/account-management\"\nimport { EmailManagement } from \"~/modules/profile/email-management\"\nimport { useTOTPModalWrapper } from \"~/modules/profile/hooks\"\nimport { ProfileSettingForm } from \"~/modules/profile/profile-setting-form\"\nimport { TwoFactor } from \"~/modules/profile/two-factor\"\nimport { UpdatePasswordForm } from \"~/modules/profile/update-password-form\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\nimport { deleteUser } from \"~/queries/auth\"\n\nconst iconName = \"i-mgc-user-setting-cute-re\"\nconst priority = (1000 << 3) + 10\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.account\",\n  priority,\n})\n\nexport function Component() {\n  const { present } = useModalStack()\n  const preset = useTOTPModalWrapper(deleteUser, { force: true })\n  return (\n    <>\n      <SettingsTitle />\n      <section className=\"mt-4\">\n        <EmailManagement />\n        <ProfileSettingForm />\n\n        <Divider className=\"mb-6 mt-8\" />\n\n        <div className=\"space-y-4\">\n          <AccountManagement />\n          <UpdatePasswordForm />\n          <TwoFactor />\n          <div className=\"flex items-center justify-between\">\n            <Label>Delete Account</Label>\n            <Button\n              variant=\"outline\"\n              onClick={() => {\n                present({\n                  title: \"Delete Account\",\n                  content: () => (\n                    <div className=\"max-w-96\">\n                      <p className=\"mb-4 text-sm text-zinc-500\">\n                        Are you sure you want to delete your account? This action is irreversible\n                        and may take up to two days to take effect.\n                      </p>\n                      <Button\n                        variant=\"outline\"\n                        onClick={() => {\n                          preset({})\n                        }}\n                      >\n                        Delete\n                      </Button>\n                    </div>\n                  ),\n                })\n              }}\n            >\n              Delete\n            </Button>\n          </div>\n        </div>\n      </section>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx",
    "content": "import { isMobile } from \"@follow/components/hooks/useMobile.js\"\n\nimport { ShortcutSetting } from \"~/modules/settings/tabs/shortcut\"\nimport { SettingsTitle } from \"~/modules/settings/title\"\nimport { defineSettingPageData } from \"~/modules/settings/utils\"\n\nconst iconName = \"i-mgc-hotkey-cute-re\"\nconst priority = (1000 << 1) + 40\n\nexport const loader = defineSettingPageData({\n  icon: iconName,\n  name: \"titles.shortcuts\",\n  priority,\n  hideIf: () => isMobile(),\n})\nexport function Component() {\n  return (\n    <>\n      <SettingsTitle />\n      <ShortcutSetting />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/index.tsx",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport { useLayoutEffect } from \"react\"\n\nexport const Component = () => {\n  useLayoutEffect(() => {\n    nextFrame(() => window.router.navigate(\"/settings/general\"))\n  }, [])\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/pages/settings/layout.tsx",
    "content": "// NOTE: we disable directly nav to setting routes.\n// because we need to open the setting modal in the main window when the main window exists,\nexport const Component = () => null\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/app-grid-layout-container-provider.tsx",
    "content": "import { throttle } from \"es-toolkit/compat\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { createContext, use, useLayoutEffect, useRef, useState } from \"react\"\n\nimport { APP_GRID_CONTAINER_ID } from \"~/constants/dom\"\n\nconst AppLayoutGridContainerWidthContext = createContext<number>(0)\n\nexport const AppLayoutGridContainerProvider: FC<PropsWithChildren> = ({ children }) => {\n  const [width, setWidth] = useState(0)\n  const ref = useRef<HTMLDivElement>(null)\n  useLayoutEffect(() => {\n    const $first = ref.current?.firstElementChild\n    if (!$first) return\n    const handler = throttle(() => {\n      if (!$first) return\n\n      const { width } = $first.getBoundingClientRect()\n      setWidth(width)\n    }, 100)\n\n    handler()\n\n    const resizeObserver = new ResizeObserver(handler)\n    resizeObserver.observe($first)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  return (\n    <AppLayoutGridContainerWidthContext value={width}>\n      <div ref={ref} className=\"relative z-0 contents\" id={APP_GRID_CONTAINER_ID}>\n        {children}\n      </div>\n    </AppLayoutGridContainerWidthContext>\n  )\n}\n\nexport const useAppLayoutGridContainerWidth = () => use(AppLayoutGridContainerWidthContext)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx",
    "content": "import {\n  useGlobalFocusableHasScope,\n  useSetGlobalFocusableScope,\n} from \"@follow/components/common/Focusable/hooks.js\"\nimport { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport {\n  ContextMenu,\n  ContextMenuCheckboxItem,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuPortal,\n  ContextMenuSeparator,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuTrigger,\n} from \"@follow/components/ui/context-menu/context-menu.js\"\nimport { KbdCombined } from \"@follow/components/ui/kbd/Kbd.js\"\nimport { nextFrame, preventDefault } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport { Fragment, memo, useCallback, useEffect, useRef } from \"react\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\n\nimport type { FollowMenuItem } from \"~/atoms/context-menu\"\nimport {\n  MenuItemSeparator,\n  MenuItemText,\n  MenuItemType,\n  useContextMenuState,\n} from \"~/atoms/context-menu\"\nimport { HotkeyScope } from \"~/constants\"\n\nexport const ContextMenuProvider: Component = ({ children }) => (\n  <>\n    {children}\n    <Handler />\n  </>\n)\n\nconst Handler = () => {\n  const ref = useRef<HTMLSpanElement>(null)\n  const [contextMenuState, setContextMenuState] = useContextMenuState()\n\n  useEffect(() => {\n    if (!contextMenuState.open) return\n    const triggerElement = ref.current\n    if (!triggerElement) return\n    // [ContextMenu] Add ability to control\n    // https://github.com/radix-ui/primitives/issues/1307#issuecomment-1689754796\n    triggerElement.dispatchEvent(\n      new MouseEvent(\"contextmenu\", {\n        bubbles: true,\n        clientX: contextMenuState.position.x,\n        clientY: contextMenuState.position.y,\n      }),\n    )\n  }, [contextMenuState])\n  const setGlobalFocusableScope = useSetGlobalFocusableScope()\n\n  const handleOpenChange = useCallback(\n    (state: boolean) => {\n      setGlobalFocusableScope(HotkeyScope.Menu, state ? \"append\" : \"remove\")\n      if (state) return\n      if (!contextMenuState.open) return\n      setContextMenuState({ open: false })\n      contextMenuState.abortController.abort()\n    },\n    [contextMenuState, setContextMenuState, setGlobalFocusableScope],\n  )\n\n  return (\n    <ContextMenu onOpenChange={handleOpenChange}>\n      <ContextMenuTrigger className=\"hidden\" ref={ref} />\n      <ContextMenuContent onContextMenu={preventDefault}>\n        {contextMenuState.open &&\n          contextMenuState.menuItems.map((item, index) => {\n            const prevItem = contextMenuState.menuItems[index - 1]\n            if (prevItem instanceof MenuItemSeparator && item instanceof MenuItemSeparator) {\n              return null\n            }\n\n            if (!prevItem && item instanceof MenuItemSeparator) {\n              return null\n            }\n            const nextItem = contextMenuState.menuItems[index + 1]\n            if (!nextItem && item instanceof MenuItemSeparator) {\n              return null\n            }\n            return <Item key={index} item={item} />\n          })}\n      </ContextMenuContent>\n    </ContextMenu>\n  )\n}\n\nconst Item = memo(({ item }: { item: FollowMenuItem }) => {\n  const onClick = useCallback(() => {\n    if (\"click\" in item) {\n      // Here we need to delay one frame,\n      // so it's two raf's, in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,\n      // and the page freezes after modal is turned off.\n      nextFrame(() => {\n        item.click?.()\n      })\n    }\n  }, [item])\n  const itemRef = useRef<HTMLDivElement>(null)\n\n  useHotkeys((item as any as MenuItemText).shortcut!, () => itemRef.current?.click(), {\n    enabled:\n      useGlobalFocusableHasScope(HotkeyScope.Menu) &&\n      item instanceof MenuItemText &&\n      !!item.shortcut,\n\n    preventDefault: true,\n  })\n\n  const isMobile = useMobile()\n\n  switch (item.type) {\n    case MenuItemType.Separator: {\n      return <ContextMenuSeparator />\n    }\n    case MenuItemType.Action: {\n      const hasSubmenu = item.submenu.length > 0\n      const Wrapper = hasSubmenu\n        ? ContextMenuSubTrigger\n        : typeof item.checked === \"boolean\"\n          ? ContextMenuCheckboxItem\n          : ContextMenuItem\n\n      const Sub = hasSubmenu ? ContextMenuSub : Fragment\n\n      return (\n        <Sub>\n          <Wrapper\n            ref={itemRef}\n            disabled={item.disabled || (item.click === undefined && !hasSubmenu)}\n            onClick={onClick}\n            className=\"flex items-center gap-2\"\n            checked={item.checked}\n          >\n            {!!item.icon && (\n              <span className=\"absolute left-2 flex items-center justify-center\">{item.icon}</span>\n            )}\n            <span className={cn(item.icon && \"pl-6\")}>{item.label}</span>\n\n            {!!item.shortcut && !isMobile && (\n              <div className=\"-mr-1 ml-auto pl-4\">\n                <KbdCombined joint>{item.shortcut}</KbdCombined>\n              </div>\n            )}\n          </Wrapper>\n          {hasSubmenu && (\n            <ContextMenuPortal>\n              <ContextMenuSubContent>\n                {item.submenu.map((subItem, index) => (\n                  <Item key={index} item={subItem} />\n                ))}\n              </ContextMenuSubContent>\n            </ContextMenuPortal>\n          )}\n        </Sub>\n      )\n    }\n    default: {\n      return null\n    }\n  }\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx",
    "content": "import { Routes } from \"@follow/constants\"\nimport type { DistributionUpdateNotice } from \"@follow/shared/bridge\"\nimport { registerGlobalContext } from \"@follow/shared/bridge\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { invalidateUserSession } from \"@follow/store/user/hooks\"\nimport { useEffect } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useLocation } from \"react-router\"\nimport { toast } from \"sonner\"\n\nimport { setWindowState } from \"~/atoms/app\"\nimport { getGeneralSettings } from \"~/atoms/settings/general\"\nimport { getUISettings } from \"~/atoms/settings/ui\"\nimport { setUpdaterStatus, useUpdaterStatus } from \"~/atoms/updater\"\nimport { useDialog, useModalStack } from \"~/components/ui/modal/stacked/hooks\"\nimport { useDiscoverRSSHubRouteModal } from \"~/hooks/biz/useDiscoverRSSHubRoute\"\nimport { useFollow } from \"~/hooks/biz/useFollow\"\nimport { navigateEntry } from \"~/hooks/biz/useNavigateEntry\"\nimport { oneTimeToken } from \"~/lib/auth\"\nimport { queryClient } from \"~/lib/query-client\"\nimport { usePresentUserProfileModal } from \"~/modules/profile/hooks\"\nimport type { SettingModalOptions } from \"~/modules/settings/modal/useSettingModal\"\nimport { useSettingModal } from \"~/modules/settings/modal/useSettingModal\"\nimport { handleSessionChanges } from \"~/queries/auth\"\nimport { clearDataIfLoginOtherAccount } from \"~/store/utils/clear\"\n\ndeclare module \"@follow/components/providers/stable-router-provider.js\" {\n  interface CustomRoute {\n    showSettings: (options?: SettingModalOptions) => void\n  }\n}\n\nexport const ExtensionExposeProvider = () => {\n  const { present } = useModalStack()\n  const showSettings = useSettingModal()\n  const updaterStatus = useUpdaterStatus()\n  useEffect(() => {\n    registerGlobalContext({\n      updateDownloaded() {\n        setUpdaterStatus({\n          type: \"app\",\n          status: \"ready\",\n        })\n      },\n      distributionUpdateAvailable(payload: DistributionUpdateNotice) {\n        setUpdaterStatus({\n          type: \"distribution\",\n          status: \"ready\",\n          distribution: payload.distribution,\n          storeUrl: payload.storeUrl,\n          storeVersion: payload.storeVersion,\n          currentVersion: payload.currentVersion,\n        })\n      },\n    })\n  }, [updaterStatus])\n\n  const location = useLocation()\n\n  useEffect(() => {\n    registerGlobalContext({\n      goToDiscover: () => {\n        window.router.navigate(Routes.Discover)\n      },\n      goToFeed: ({ id, view }: { id: string; view?: number }) => {\n        navigateEntry({ feedId: id, view: view ?? 0, backPath: location.pathname })\n      },\n      goToList: ({ id, view }: { id: string; view?: number }) => {\n        navigateEntry({ listId: id, view: view ?? 0, backPath: location.pathname })\n      },\n    })\n  }, [location.pathname])\n\n  useEffect(() => {\n    registerGlobalContext({\n      showSetting: (path) => window.router.showSettings(path),\n      getGeneralSettings,\n      getUISettings,\n\n      toast,\n      getApiUrl() {\n        return env.VITE_API_URL\n      },\n      getWebUrl() {\n        return env.VITE_WEB_URL\n      },\n\n      clearIfLoginOtherAccount(newUserId: string) {\n        clearDataIfLoginOtherAccount(newUserId)\n      },\n      async applyOneTimeToken(token: string) {\n        await oneTimeToken.apply({ token })\n        handleSessionChanges()\n      },\n\n      readyToUpdate() {\n        setUpdaterStatus({\n          type: \"renderer\",\n          status: \"ready\",\n        })\n      },\n      invalidateQuery(queryKey: string | string[]) {\n        queryClient.invalidateQueries({\n          queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],\n        })\n      },\n      navigateEntry,\n    })\n  }, [])\n  useEffect(() => {\n    // @ts-expect-error\n    window.router ||= {}\n    window.router.showSettings = showSettings\n  }, [showSettings])\n\n  const { t } = useTranslation()\n\n  const follow = useFollow()\n  const presentUserProfile = usePresentUserProfileModal(\"dialog\")\n  const presentDiscoverRSSHubRoute = useDiscoverRSSHubRouteModal()\n  useEffect(() => {\n    registerGlobalContext({\n      follow,\n      profile(id, variant) {\n        presentUserProfile(id, variant)\n      },\n      rsshubRoute(route) {\n        presentDiscoverRSSHubRoute(route)\n      },\n    })\n  }, [follow, present, presentDiscoverRSSHubRoute, presentUserProfile, t])\n\n  const dialog = useDialog()\n  useEffect(() => {\n    registerGlobalContext({\n      dialog,\n    })\n  }, [dialog])\n\n  useBindElectronBridge()\n\n  useEffect(() => {\n    registerGlobalContext({\n      refreshSession: invalidateUserSession,\n    })\n  }, [dialog])\n\n  return null\n}\n\nconst useBindElectronBridge = () => {\n  useEffect(() => {\n    registerGlobalContext({\n      setWindowState,\n    })\n  }, [])\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/external-jump-in-provider.tsx",
    "content": "import { useEffect, useRef } from \"react\"\nimport { useSearchParams } from \"react-router\"\n\nimport { useFollow } from \"~/hooks/biz/useFollow\"\n\nexport const ExternalJumpInProvider = () => {\n  // ?follow=${id}&follow_type=list\n  const [searchParams, setSearchParams] = useSearchParams()\n\n  const follow = useFollow()\n  const onceRef = useRef(false)\n  useEffect(() => {\n    if (onceRef.current) {\n      return\n    }\n\n    const followId = searchParams.get(\"follow\")\n    const followType = searchParams.get(\"follow_type\")\n\n    if (followId && followType) {\n      follow({\n        id: followId,\n        isList: followType === \"list\",\n      })\n      setSearchParams((prev) => {\n        prev.delete(\"follow\")\n        prev.delete(\"follow_type\")\n        return new URLSearchParams(prev)\n      })\n    }\n\n    onceRef.current = true\n  }, [follow, searchParams, setSearchParams])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/global-focusable-provider.tsx",
    "content": "import {\n  useGlobalFocusableScopeSelector,\n  useSetGlobalFocusableScope,\n} from \"@follow/components/common/Focusable/hooks.js\"\nimport { useRefValue } from \"@follow/hooks\"\nimport type { EnhanceSet } from \"@follow/utils\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { useEffect } from \"react\"\n\nimport { useHasModal } from \"~/components/ui/modal/stacked/hooks\"\nimport { HotkeyScope } from \"~/constants\"\nimport { getRouteParams } from \"~/hooks/biz/useRouteParams\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\n\nconst checkIsFocusInIframe = (): boolean => {\n  const { activeElement } = document\n  if (!activeElement) return false\n\n  // Check if active element is an iframe or webview\n  if (activeElement.tagName === \"IFRAME\" || activeElement.tagName === \"WEBVIEW\") {\n    return true\n  }\n\n  // Check if active element is inside an iframe or webview\n  let parent = activeElement.parentElement\n  while (parent) {\n    if (parent.tagName === \"IFRAME\" || parent.tagName === \"WEBVIEW\") {\n      return true\n    }\n    parent = parent.parentElement\n  }\n\n  return false\n}\n\nconst selector = (s: EnhanceSet<string>) => s.size === 0\nexport const FocusableGuardProvider = () => {\n  const hasNoFocusable = useGlobalFocusableScopeSelector(selector)\n  const setGlobalFocusableScope = useSetGlobalFocusableScope()\n  const hasModal = useHasModal()\n  const hasModalRef = useRefValue(hasModal)\n\n  useEffect(() => {\n    const timer: ReturnType<typeof setTimeout> = setTimeout(() => {\n      if (checkIsFocusInIframe()) {\n        return\n      }\n\n      if (hasNoFocusable) {\n        if (hasModalRef.current) {\n          setGlobalFocusableScope(HotkeyScope.Modal, \"append\")\n        } else {\n          const { timelineId } = getRouteParams()\n\n          if (timelineId) {\n            EventBus.dispatch(COMMAND_ID.layout.focusToSubscription, { highlightBoundary: false })\n          }\n        }\n      }\n    }, 100)\n    return () => clearTimeout(timer)\n  }, [hasModalRef, hasNoFocusable, setGlobalFocusableScope])\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\nimport { highlightElement } from \"@follow/components/common/Focusable/utils.js\"\nimport {\n  checkIsEditableElement,\n  nextFrame,\n  preventDefault,\n  stopPropagation,\n} from \"@follow/utils/dom\"\nimport { useEffect } from \"react\"\nimport { tinykeys } from \"tinykeys\"\nimport { useEventListener } from \"usehooks-ts\"\n\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useRunCommandFn } from \"~/modules/command/hooks/use-command\"\nimport { useCommandBinding, useCommandShortcuts } from \"~/modules/command/hooks/use-command-binding\"\n\nconst useRegisterAppGlobalHotkeys = () => {\n  const notInFloatingLayerScope = useGlobalFocusableScopeSelector(\n    FocusablePresets.isNotFloatingLayerScope,\n  )\n  useCommandBinding({\n    commandId: COMMAND_ID.global.showShortcuts,\n    when: notInFloatingLayerScope,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.global.toggleCornerPlay,\n    when: notInFloatingLayerScope,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.global.quickAdd,\n    when: notInFloatingLayerScope,\n  })\n\n  useCommandBinding({\n    commandId: COMMAND_ID.global.quickSearch,\n    when: notInFloatingLayerScope,\n  })\n}\nexport const GlobalHotkeysProvider = () => {\n  useRegisterAppGlobalHotkeys()\n  useEventListener(\"keydown\", (e) => {\n    if (e.key === \"Tab\") {\n      nextFrame(() => {\n        if (checkIsEditableElement(e.target as HTMLElement)) {\n          return\n        }\n        highlightElement(document.activeElement as HTMLElement)\n      })\n    }\n  })\n\n  const commandShortcuts = useCommandShortcuts()\n\n  const runCommandFn = useRunCommandFn()\n  const isNormalLayer = useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope)\n  useEffect(() => {\n    const preHandler = (e: Event) => {\n      stopPropagation(e)\n      preventDefault(e)\n    }\n    return tinykeys(window, {\n      // Show current focused element\n      \"$mod+Period\": (e) => {\n        preHandler(e)\n        highlightElement(document.activeElement as HTMLElement)\n      },\n    })\n  }, [commandShortcuts, runCommandFn, isNormalLayer])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx",
    "content": "import { HotkeysProvider } from \"react-hotkeys-hook\"\n\nimport { GlobalHotkeysProvider } from \"./global-hotkeys-provider\"\n\nexport const HotkeyProvider: Component = ({ children }) => {\n  return (\n    <HotkeysProvider>\n      {children}\n      <GlobalHotkeysProvider />\n    </HotkeysProvider>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/i18n-provider.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport i18next from \"i18next\"\nimport { useAtom } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useEffect } from \"react\"\nimport { I18nextProvider } from \"react-i18next\"\n\nimport { getGeneralSettings } from \"~/atoms/settings/general\"\nimport { i18nAtom } from \"~/i18n\"\n\nexport const I18nProvider: FC<PropsWithChildren> = ({ children }) => {\n  const [currentI18NInstance, update] = useAtom(i18nAtom)\n\n  if (import.meta.env.DEV)\n    // eslint-disable-next-line react-hooks/rules-of-hooks\n    useEffect(\n      () =>\n        EventBus.subscribe(\"I18N_UPDATE\", () => {\n          const lang = getGeneralSettings().language\n          const nextI18n = i18next.cloneInstance({\n            lng: lang,\n          })\n\n          update(nextI18n)\n        }),\n      [update],\n    )\n\n  return <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/inject-styles-provider.tsx",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.jsx\"\nimport { RootPortal } from \"@follow/components/ui/portal/index.js\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { createContext, use, useCallback, useState } from \"react\"\n\nconst Provider = createContext<(id: string, styles: string) => () => void>(() => () => {})\nexport const PortalInjectStylesProvider: FC<PropsWithChildren> = ({ children }) => {\n  const [styles, setStyles] = useState({} as Record<string, string>)\n\n  const injectStyles = useCallback((id: string, styles: string) => {\n    const dispose = () => {\n      setStyles((prev) => {\n        const { [id]: _, ...rest } = prev\n        return rest\n      })\n    }\n    if (styles[id]) return dispose\n    setStyles((prev) => ({ ...prev, [id]: styles }))\n\n    return dispose\n  }, [])\n  return (\n    <Provider value={injectStyles}>\n      <RootPortal to={document.head}>\n        {Object.entries(styles).map(([id, style]) => (\n          <MemoedDangerousHTMLStyle key={id} id={`inject-${id}`}>\n            {style}\n          </MemoedDangerousHTMLStyle>\n        ))}\n      </RootPortal>\n      {children}\n    </Provider>\n  )\n}\n\nexport const useInjectStyles = () => {\n  return use(Provider)\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/invalidate-query-provider.tsx",
    "content": "import { usePageVisibility } from \"@follow/hooks\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useQueryClient } from \"@tanstack/react-query\"\nimport { useEffect, useRef } from \"react\"\n\nimport { appLog } from \"~/lib/log\"\n\nconst staleTime = 600_000 // 10min\n\nexport class ElectronCloseEvent extends Event {\n  static type = \"electron-close\"\n  constructor() {\n    super(\"electron-close\")\n  }\n}\nexport class ElectronShowEvent extends Event {\n  static type = \"electron-show\"\n  constructor() {\n    super(\"electron-show\")\n  }\n}\n\n/**\n * Add a event listener to invalidate all queries\n */\n\nconst InvalidateQueryProviderElectron = () => {\n  const queryClient = useQueryClient()\n\n  const currentTimeRef = useRef(0)\n\n  useEffect(() => {\n    const handler = () => {\n      currentTimeRef.current = Date.now()\n      appLog(\"Window switch to close\")\n    }\n\n    document.addEventListener(ElectronCloseEvent.type, handler)\n\n    return () => {\n      document.removeEventListener(ElectronCloseEvent.type, handler)\n    }\n  }, [queryClient])\n\n  useEffect(() => {\n    const handler = () => {\n      const now = Date.now()\n      if (!currentTimeRef.current || now - currentTimeRef.current < staleTime) {\n        appLog(\n          `Window switch to visible, but skip invalidation, ${currentTimeRef.current ? now - currentTimeRef.current : 0}`,\n        )\n      } else {\n        appLog(\"Window switch to visible, invalidate all queries except entries\")\n        queryClient.invalidateQueries({\n          predicate(query) {\n            // Ignore entries queries\n            return query.queryKey[0] !== \"entries\"\n          },\n        })\n      }\n      currentTimeRef.current = 0\n    }\n\n    document.addEventListener(ElectronShowEvent.type, handler)\n\n    return () => {\n      document.removeEventListener(ElectronShowEvent.type, handler)\n    }\n  }, [queryClient])\n  return null\n}\n\n/**\n * Invalidate all queries when the window is visible\n */\n\nconst InvalidateQueryProviderWebApp = () => {\n  const queryClient = useQueryClient()\n\n  const currentTimeRef = useRef(Date.now())\n  const currentVisibilityRef = useRef(!document.hidden)\n\n  const pageVisibility = usePageVisibility()\n\n  useEffect(() => {\n    if (currentVisibilityRef.current === pageVisibility) {\n      return\n    }\n\n    const now = Date.now()\n    if (now - currentTimeRef.current < staleTime) {\n      return\n    }\n\n    currentTimeRef.current = now\n    currentVisibilityRef.current = pageVisibility\n    if (pageVisibility) {\n      appLog(\"Window switch to visible, invalidate all queries\")\n      queryClient.invalidateQueries()\n    }\n  }, [pageVisibility, queryClient])\n  return null\n}\n\nexport const InvalidateQueryProvider = IN_ELECTRON\n  ? InvalidateQueryProviderElectron\n  : InvalidateQueryProviderWebApp\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts",
    "content": "export { ContextMenuProvider as LazyContextMenuProvider } from \"../context-menu-provider\"\nexport { ExtensionExposeProvider as LazyExtensionExposeProvider } from \"../extension-expose-provider\"\nexport { ExternalJumpInProvider as LazyExternalJumpInProvider } from \"../external-jump-in-provider\"\nexport const LazyReloadPrompt = () => null\nexport const LazyPWAPrompt = () => null\n\nexport { PopoverProvider as LazyPopoverProvider } from \"../popover-provider\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/lazy/index.ts",
    "content": "import { createElement, lazy, Suspense, useState } from \"react\"\n\nconst LazyContextMenuProvider = lazy(() =>\n  import(\"./../context-menu-provider\").then((res) => ({\n    default: res.ContextMenuProvider,\n  })),\n)\nconst LazyPopoverProvider = lazy(() =>\n  import(\"./../popover-provider\").then((res) => ({\n    default: res.PopoverProvider,\n  })),\n)\n\nconst LazyExtensionExposeProvider = lazy(() =>\n  import(\"./../extension-expose-provider\").then((res) => ({\n    default: res.ExtensionExposeProvider,\n  })),\n)\n\nconst LazyReloadPrompt = lazy(() =>\n  import(\"~/components/common/ReloadPrompt\").then((module) => ({\n    default: module.ReloadPrompt,\n  })),\n)\n\nconst LazyPWAPromptImport = lazy(() => import(\"react-ios-pwa-prompt\"))\n\nconst LazyPWAPrompt = () => {\n  const [show, setShow] = useState(true)\n  if (!show) return null\n  return createElement(\n    Suspense,\n    null,\n    createElement(LazyPWAPromptImport, {\n      onClose() {\n        setTimeout(() => {\n          setShow(false)\n        }, 250)\n      },\n\n      appIconPath: `${window.location.origin}/apple-touch-icon-180x180.png`,\n    }),\n  )\n}\n\nexport {\n  LazyContextMenuProvider,\n  LazyExtensionExposeProvider,\n  LazyPopoverProvider,\n  LazyPWAPrompt,\n  LazyReloadPrompt,\n}\n\nconst LazyExternalJumpInProvider = lazy(() =>\n  import(\"../external-jump-in-provider\").then((res) => ({\n    default: res.ExternalJumpInProvider,\n  })),\n)\nexport { LazyExternalJumpInProvider }\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/main-view-hotkeys-provider.tsx",
    "content": "import { useGlobalFocusableScopeSelector } from \"@follow/components/common/Focusable/hooks.js\"\n\nimport { FocusablePresets } from \"~/components/common/Focusable\"\nimport { COMMAND_ID } from \"~/modules/command/commands/id\"\nimport { useCommandBinding } from \"~/modules/command/hooks/use-command-binding\"\n\nexport const MainViewHotkeysProvider = () => {\n  const notInFloatingLayerScope = useGlobalFocusableScopeSelector(\n    FocusablePresets.isNotFloatingLayerScope,\n  )\n\n  useCommandBinding({\n    commandId: COMMAND_ID.global.toggleAIChat,\n    when: notInFloatingLayerScope,\n  })\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/popover-provider.tsx",
    "content": "// Import from the correct path\nimport { useSetGlobalFocusableScope } from \"@follow/components/common/Focusable/hooks.js\"\nimport { Spring } from \"@follow/components/constants/spring.js\"\nimport {\n  Popover,\n  PopoverArrow,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@follow/components/ui/popover/index.jsx\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { memo, useEffect, useRef } from \"react\"\n\nimport { usePopoverState } from \"~/atoms/popover\"\nimport { HotkeyScope } from \"~/constants\"\n\nexport const PopoverProvider: Component = ({ children }) => (\n  <>\n    {children}\n    <Handler />\n  </>\n)\n\nconst Handler = memo(() => {\n  const ref = useRef<HTMLButtonElement>(null)\n  const [popoverState, setPopoverState] = usePopoverState()\n  const setGlobalFocusableScope = useSetGlobalFocusableScope()\n\n  useEffect(() => {\n    if (!popoverState.open) return\n    const triggerElement = ref.current\n    if (!triggerElement) return\n\n    triggerElement.dispatchEvent(\n      new MouseEvent(\"click\", {\n        bubbles: true,\n        cancelable: true,\n      }),\n    )\n  }, [popoverState])\n\n  return (\n    <Popover\n      onOpenChange={(state) => {\n        if (state) {\n          setGlobalFocusableScope(HotkeyScope.DropdownMenu, \"append\")\n        } else {\n          setGlobalFocusableScope(HotkeyScope.DropdownMenu, \"remove\")\n          setPopoverState({ open: false })\n        }\n      }}\n    >\n      <PopoverTrigger\n        ref={ref}\n        className=\"pointer-events-none\"\n        style={\n          popoverState.open\n            ? { position: \"fixed\", top: popoverState.position.y, left: popoverState.position.x }\n            : {}\n        }\n      />\n      <PopoverContent asChild forceMount>\n        <AnimatePresence>\n          {popoverState.open && (\n            <m.div\n              className=\"mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background\"\n              initial={{ opacity: 0, scale: 0.95, y: -10 }}\n              animate={{ opacity: 1, scale: 1, y: 0 }}\n              exit={{ opacity: 0, scale: 0.95, y: -10 }}\n              transition={Spring.presets.smooth}\n            >\n              <PopoverArrow className=\"fill-border\" />\n              {popoverState.content}\n            </m.div>\n          )}\n        </AnimatePresence>\n      </PopoverContent>\n    </Popover>\n  )\n})\n\nHandler.displayName = \"PopoverHandler\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/root-providers.tsx",
    "content": "import { GlobalFocusableProvider } from \"@follow/components/common/Focusable/GlobalFocusableProvider.js\"\nimport { MotionProvider } from \"@follow/components/common/MotionProvider.jsx\"\nimport { EventProvider } from \"@follow/components/providers/event-provider.js\"\nimport { StableRouterProvider } from \"@follow/components/providers/stable-router-provider.js\"\nimport { Toaster } from \"@follow/components/ui/toast/index.jsx\"\nimport { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { env } from \"@follow/shared/env.desktop\"\nimport { ReactQueryDevtools } from \"@tanstack/react-query-devtools\"\nimport { PersistQueryClientProvider } from \"@tanstack/react-query-persist-client\"\nimport { Provider } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { Suspense } from \"react\"\nimport { GoogleReCaptchaProvider } from \"react-google-recaptcha-v3\"\n\nimport { LCPEndDetector } from \"~/components/common/LCPEndDetector\"\nimport { ModalStackProvider } from \"~/components/ui/modal\"\nimport { jotaiStore } from \"~/lib/jotai\"\nimport { persistConfig, queryClient } from \"~/lib/query-client\"\nimport { FollowCommandManager } from \"~/modules/command/command-manager\"\nimport { ReviewPromptProvider } from \"~/modules/review-prompt/provider\"\n\nimport { HotkeyProvider } from \"./hotkey-provider\"\nimport { I18nProvider } from \"./i18n-provider\"\nimport { InvalidateQueryProvider } from \"./invalidate-query-provider\"\nimport {\n  LazyContextMenuProvider,\n  LazyExtensionExposeProvider,\n  LazyExternalJumpInProvider,\n  LazyPopoverProvider,\n  LazyPWAPrompt,\n  LazyReloadPrompt,\n} from \"./lazy/index\"\nimport { ServerConfigsProvider } from \"./server-configs-provider\"\nimport { SettingSync } from \"./setting-sync\"\nimport { UserProvider } from \"./user-provider\"\n\nexport const RootProviders: FC<PropsWithChildren> = ({ children }) => (\n  <Provider store={jotaiStore}>\n    <RecaptchaProvider>\n      <MotionProvider>\n        <PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>\n          <GlobalFocusableProvider>\n            <HotkeyProvider>\n              <I18nProvider>\n                <ModalStackProvider>\n                  <Toaster />\n                  <EventProvider />\n\n                  <UserProvider />\n                  <ServerConfigsProvider />\n\n                  <StableRouterProvider />\n                  <SettingSync />\n                  <FollowCommandManager />\n                  <ReviewPromptProvider />\n\n                  {import.meta.env.DEV && <Devtools />}\n\n                  {children}\n                  <Suspense>\n                    <LCPEndDetector />\n                    <LazyExtensionExposeProvider />\n                    <LazyContextMenuProvider />\n                    <LazyPopoverProvider />\n                    <LazyExternalJumpInProvider />\n                    <LazyReloadPrompt />\n                    {!IN_ELECTRON && <LazyPWAPrompt />}\n                  </Suspense>\n                  {/* <FocusableGuardProvider /> */}\n                </ModalStackProvider>\n              </I18nProvider>\n            </HotkeyProvider>\n          </GlobalFocusableProvider>\n\n          <InvalidateQueryProvider />\n        </PersistQueryClientProvider>\n      </MotionProvider>\n    </RecaptchaProvider>\n  </Provider>\n)\n\nconst Devtools = () =>\n  !IN_ELECTRON && (\n    <div className=\"hidden lg:block print:hidden\">\n      <ReactQueryDevtools buttonPosition=\"bottom-left\" client={queryClient} />\n    </div>\n  )\n\nconst RecaptchaProvider: FC<PropsWithChildren> = ({ children }) => {\n  const siteKey = env.VITE_RECAPTCHA_V3_SITE_KEY\n\n  if (!siteKey) {\n    return children\n  }\n\n  return (\n    <GoogleReCaptchaProvider\n      reCaptchaKey={siteKey}\n      scriptProps={{ async: true, defer: true, appendTo: \"body\" }}\n    >\n      {children}\n    </GoogleReCaptchaProvider>\n  )\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/server-configs-provider.tsx",
    "content": "import { useEffect } from \"react\"\n\nimport { setServerConfigs } from \"~/atoms/server-configs\"\nimport { syncServerShortcuts } from \"~/atoms/settings/ai\"\nimport { useServerConfigsQuery } from \"~/queries/server-configs\"\n\nexport const ServerConfigsProvider = () => {\n  const serverConfigs = useServerConfigsQuery()\n\n  useEffect(() => {\n    if (!serverConfigs) return\n    setServerConfigs(serverConfigs)\n    syncServerShortcuts(serverConfigs.AI_SHORTCUTS)\n  }, [serverConfigs])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/setting-sync.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { getAccentColorValue } from \"@follow/shared/settings/constants\"\nimport type { UISettings } from \"@follow/shared/settings/interface\"\nimport { useUnreadAll } from \"@follow/store/unread/hooks\"\nimport { hexToHslString } from \"@follow/utils\"\nimport i18next from \"i18next\"\nimport { useEffect, useInsertionEffect, useLayoutEffect, useRef } from \"react\"\n\nimport { useGeneralSettingKey } from \"~/atoms/settings/general\"\nimport { useUISettingValue } from \"~/atoms/settings/ui\"\nimport { useSubscriptionColumnShow } from \"~/atoms/sidebar\"\nimport { I18N_LOCALE_KEY } from \"~/constants\"\nimport { useReduceMotion } from \"~/hooks/biz/useReduceMotion\"\nimport { useSyncTheme } from \"~/hooks/common\"\nimport { langChain } from \"~/i18n\"\nimport { ipcServices } from \"~/lib/client\"\nimport { loadLanguageAndApply } from \"~/lib/load-language\"\n\nconst useUpdateDockBadge = (setting: UISettings) => {\n  const unreadCount = useUnreadAll()\n\n  useEffect(() => {\n    if (setting.showDockBadge) {\n      ipcServices?.dock.pollingUpdateUnreadCount()\n    } else {\n      ipcServices?.dock.cancelPollingUpdateUnreadCount().then(() => {\n        ipcServices?.dock.setDockBadge(0)\n      })\n    }\n    return\n  }, [setting.showDockBadge])\n\n  const prevCount = useRef<null | number>(null)\n  useEffect(() => {\n    if (!setting.showDockBadge) {\n      return\n    }\n    if (prevCount.current === unreadCount) {\n      return\n    }\n\n    ipcServices?.dock.setDockBadge(unreadCount).then(() => {\n      prevCount.current = unreadCount\n    })\n  }, [unreadCount, setting.showDockBadge])\n}\nconst useUISettingSync = () => {\n  const setting = useUISettingValue()\n  const mobile = useMobile()\n  useSyncTheme()\n  useInsertionEffect(() => {\n    const root = document.documentElement\n    root.style.fontSize = `${setting.uiTextSize * (mobile ? 0.875 : 1)}px`\n  }, [setting.uiTextSize, mobile])\n\n  const isDark = useIsDark()\n  useInsertionEffect(() => {\n    const root = document.documentElement\n    // 21.6 100% 50%;\n    root.style.setProperty(\n      \"--fo-a\",\n      hexToHslString(getAccentColorValue(setting.accentColor)[isDark ? \"dark\" : \"light\"]),\n    )\n  }, [setting.accentColor, isDark])\n\n  useInsertionEffect(() => {\n    const root = document.documentElement\n    // https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#valid_family_names\n    const fontCss = `\"${setting.uiFontFamily}\", system-ui, sans-serif`\n\n    Object.assign(root.style, {\n      fontFamily: fontCss,\n    })\n    root.style.cssText += `\\n--fo-font-family: ${fontCss}`\n    root.style.cssText += `\\n--pointer: ${setting.usePointerCursor ? \"pointer\" : \"default\"}`\n    Object.assign(document.body.style, {\n      fontFamily: fontCss,\n    })\n  }, [setting.uiFontFamily, setting.usePointerCursor])\n\n  useUpdateDockBadge(setting)\n}\n\nconst useDataAttrSync = () => {\n  const columnShow = useSubscriptionColumnShow()\n  useEffect(() => {\n    document.documentElement.dataset.leftColumnHidden = (!columnShow).toString()\n  }, [columnShow])\n}\n\nconst useUXSettingSync = () => {\n  const reduceMotion = useReduceMotion()\n  useLayoutEffect(() => {\n    document.documentElement.dataset.motionReduce = reduceMotion ? \"true\" : \"false\"\n  }, [reduceMotion])\n}\n\nconst useLanguageSync = () => {\n  const language = useGeneralSettingKey(\"language\")\n  useEffect(() => {\n    let mounted = true\n\n    if (language === \"zh-TW\") {\n      loadLanguageAndApply(\"zh-CN\")\n    }\n    loadLanguageAndApply(language as string).then(() => {\n      langChain.next(() => {\n        if (mounted) {\n          localStorage.setItem(I18N_LOCALE_KEY, language as string)\n          return i18next.changeLanguage(language as string)\n        }\n      })\n    })\n\n    return () => {\n      mounted = false\n    }\n  }, [language])\n}\nconst useGeneralSettingSync = () => {\n  useGeneralSettingKey(\"voice\")\n}\n\nexport const SettingSync = () => {\n  useUISettingSync()\n  useUXSettingSync()\n  useLanguageSync()\n  useGeneralSettingSync()\n  useDataAttrSync()\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/user-provider.tsx",
    "content": "import { useEffect } from \"react\"\n\nimport { setIntegrationIdentify } from \"~/initialize/helper\"\nimport { useSession } from \"~/queries/auth\"\n\nexport const UserProvider = () => {\n  const { session } = useSession()\n\n  useEffect(() => {\n    if (!session?.user) return\n\n    setIntegrationIdentify(session.user)\n  }, [session?.user])\n\n  return null\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/providers/wrapped-element-provider.tsx",
    "content": "/* eslint-disable react-refresh/only-export-components */\nimport { useScrollViewElement } from \"@follow/components/ui/scroll-area/hooks.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { createContextState } from \"foxact/create-context-state\"\nimport { useIsomorphicLayoutEffect } from \"foxact/use-isomorphic-layout-effect\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { createContext, memo, use, useCallback, useEffect, useRef } from \"react\"\n\nimport { ProviderComposer } from \"~/components/common/ProviderComposer\"\nimport { jotaiStore } from \"~/lib/jotai\"\n\nconst [WrappedElementProviderInternal, useWrappedElement, useSetWrappedElement] =\n  createContextState<HTMLDivElement | null>(undefined as any)\n\nconst [ElementSizeProviderInternal, useWrappedElementSize, useSetWrappedElementSize] =\n  createContextState({\n    h: 0,\n    w: 0,\n  })\n\nconst ElementPositionProviderInternal = createContext<PrimitiveAtom<{ x: number; y: number }>>(\n  null!,\n)\n\nconst [IsEndOfElementProviderInternal, useIsEoFWrappedElement, useSetIsEOfElement] =\n  createContextState<boolean>(false)\n\nconst [IsStartOfElementProviderInternal, useIsSoFWrappedElement, useSetIsSOfElement] =\n  createContextState<boolean>(false)\n\nconst Providers = [\n  <WrappedElementProviderInternal key=\"ArticleElementProviderInternal\" />,\n  <ElementSizeProviderInternal key=\"ElementSizeProviderInternal\" />,\n  <ElementPositionProviderInternal\n    key=\"ElementPositionProviderInternal\"\n    value={atom({\n      x: 0,\n      y: 0,\n    })}\n  />,\n  <IsEndOfElementProviderInternal key=\"IsEndOfElementProviderInternal\" />,\n  <IsStartOfElementProviderInternal key=\"IsStartOfElementProviderInternal\" />,\n]\n\ninterface WrappedElementProviderProps {\n  boundingDetection?: boolean\n  as?: keyof React.JSX.IntrinsicElements\n}\n\nexport const WrappedElementProvider: Component<WrappedElementProviderProps> = ({\n  children,\n  className,\n  ...props\n}) => (\n  <ProviderComposer contexts={Providers}>\n    <ArticleElementResizeObserver />\n    <Content {...props} className={className}>\n      {children}\n    </Content>\n  </ProviderComposer>\n)\nconst ArticleElementResizeObserver = () => {\n  const setSize = useSetWrappedElementSize()\n\n  const setPosition = useSetAtom(use(ElementPositionProviderInternal))\n  const $element = useWrappedElement()\n  useIsomorphicLayoutEffect(() => {\n    if (!$element) return\n    const { height, width, x, y } = $element.getBoundingClientRect()\n    setSize({ h: height, w: width })\n\n    setPosition({ x, y })\n\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[0]!\n      const { height, width } = entry.contentRect\n      const { x, y } = entry.target.getBoundingClientRect()\n\n      setSize({ h: height, w: width })\n      setPosition({ x, y })\n    })\n    observer.observe($element)\n    return () => {\n      observer.unobserve($element)\n      observer.disconnect()\n    }\n  }, [$element, setPosition])\n\n  return null\n}\n\nconst Content: Component<WrappedElementProviderProps> = memo(\n  ({ children, className, boundingDetection, as = \"div\" }) => {\n    const setElement = useSetWrappedElement()\n\n    const As = as as any\n    return (\n      <As className={cn(\"relative\", className)} ref={setElement}>\n        {boundingDetection && <BoundingDetection bounding=\"start\" />}\n        {children}\n        {boundingDetection && <BoundingDetection bounding=\"end\" />}\n      </As>\n    )\n  },\n)\n\nContent.displayName = \"ArticleElementProviderContent\"\n\nconst BoundingDetection: Component<{\n  bounding: \"start\" | \"end\"\n}> = ({ bounding }) => {\n  const endSetter = useSetIsEOfElement()\n  const startSetter = useSetIsSOfElement()\n  const ref = useRef<HTMLDivElement>(null)\n  const $scrollArea = useScrollViewElement()\n  useEffect(() => {\n    if (!ref.current) return\n    const $el = ref.current\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[0]!\n\n        if (bounding === \"start\") startSetter(entry.isIntersecting)\n        else endSetter(entry.isIntersecting)\n      },\n      {\n        rootMargin: \"0px 0px 0px 0px\",\n        root: $scrollArea,\n      },\n    )\n\n    observer.observe($el)\n    return () => {\n      observer.unobserve($el)\n      observer.disconnect()\n    }\n  }, [$scrollArea, bounding, endSetter, startSetter])\n\n  return <div ref={ref} />\n}\n\nexport const useWrappedElementPosition = () => useAtomValue(use(ElementPositionProviderInternal))\n\nexport const useGetWrappedElementPosition = () => {\n  const atom = use(ElementPositionProviderInternal)\n  return useCallback(() => jotaiStore.get(atom), [atom])\n}\n\nexport {\n  useIsEoFWrappedElement,\n  useIsSoFWrappedElement,\n  useSetWrappedElement,\n  useWrappedElement,\n  useWrappedElementSize,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/push-notification.ts",
    "content": "import { env } from \"@follow/shared/env.desktop\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { initializeApp } from \"firebase/app\"\nimport { getMessaging, getToken } from \"firebase/messaging\"\n\nimport { setAppMessagingToken } from \"./atoms/app\"\nimport { followClient } from \"./lib/api-client\"\nimport { router } from \"./router\"\n\nconst firebaseConfig = env.VITE_FIREBASE_CONFIG ? JSON.parse(env.VITE_FIREBASE_CONFIG) : null\n\nexport async function registerWebPushNotifications() {\n  if (!firebaseConfig) {\n    return\n  }\n  try {\n    const user = whoami()\n    if (!user) {\n      return\n    }\n    const actions = await followClient.api.actions.get()\n    const rules = actions.data?.rules\n    const hasPushNotificationRule = rules?.some(\n      (rule) => rule.result.newEntryNotification && !rule.result.disabled,\n    )\n    if (!hasPushNotificationRule) {\n      return\n    }\n\n    const existingRegistration = await navigator.serviceWorker.getRegistration()\n    const registration = existingRegistration\n\n    if (!registration) {\n      return\n    }\n\n    await navigator.serviceWorker.ready\n\n    const app = initializeApp(firebaseConfig)\n    const messaging = getMessaging(app)\n\n    const permission = await Notification.requestPermission()\n    if (permission !== \"granted\") {\n      throw new Error(\"Notification permission denied\")\n    }\n\n    // get FCM token\n    const token = await getToken(messaging, {\n      serviceWorkerRegistration: registration,\n    })\n\n    await followClient.api.messaging.createToken({\n      token,\n      channel: \"web\",\n    })\n\n    registerPushNotificationPostMessage()\n\n    setAppMessagingToken(token)\n\n    return token\n  } catch (error) {\n    if (error instanceof Error) {\n      console.error(`Failed to register push notifications: ${error.message}`)\n    }\n  }\n}\n\ninterface NavigateEntryMessage {\n  type: \"NOTIFICATION_CLICK\"\n  action: \"NAVIGATE_ENTRY\"\n  data: {\n    feedId: string\n    entryId: string\n    view: number\n    url: string\n  }\n}\n\ntype ServiceWorkerMessage = NavigateEntryMessage\n\nconst registerPushNotificationPostMessage = () => {\n  navigator.serviceWorker.addEventListener(\"message\", (event) => {\n    const message = event.data as ServiceWorkerMessage\n\n    if (message.type === \"NOTIFICATION_CLICK\") {\n      switch (message.action) {\n        case \"NAVIGATE_ENTRY\": {\n          router.navigate(message.data.url)\n          break\n        }\n      }\n    }\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/_.ts",
    "content": "export { auth } from \"./auth\"\nexport { discover } from \"./discover\"\nexport { entries } from \"./entries\"\nexport { feed } from \"./feed\"\nexport { rsshub } from \"./rsshub\"\nexport { wallet } from \"./wallet\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/auth.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { whoamiQueryKey } from \"@follow/store/user/hooks\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport { tracker } from \"@follow/tracker\"\nimport { clearStorage } from \"@follow/utils/ns\"\nimport type { FetchError } from \"ofetch\"\n\nimport { setLoginModalShow } from \"~/atoms/user\"\nimport { QUERY_PERSIST_KEY } from \"~/constants\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { deleteUserCustom as deleteUserFn, getAccountInfo, signOut as signOutFn } from \"~/lib/auth\"\nimport { ipcServices } from \"~/lib/client\"\nimport { clearAuthSessionToken, getAuthSessionToken } from \"~/lib/client-session\"\nimport { defineQuery } from \"~/lib/defineQuery\"\nimport { clearLocalPersistStoreData } from \"~/store/utils/clear\"\n\nexport const auth = {\n  getSession: () => defineQuery(whoamiQueryKey, () => userSyncService.whoami()),\n  getAccounts: () => defineQuery([\"auth\", \"accounts\"], () => getAccountInfo()),\n}\n\nexport const useAccounts = () => {\n  return useAuthQuery(auth.getAccounts())\n}\n\nexport const useSocialAccounts = () => {\n  const accounts = useAccounts()\n  return {\n    ...accounts,\n    data: accounts.data?.data?.filter((account) => account.provider !== \"credential\"),\n  }\n}\n\nexport const useHasPassword = () => {\n  const accounts = useAccounts()\n  return {\n    ...accounts,\n    data: !!accounts.data?.data?.find((account) => account.provider === \"credential\"),\n  }\n}\n\nexport const useSession = (options?: { enabled?: boolean }) => {\n  const { data, isLoading, ...rest } = useAuthQuery(auth.getSession(), {\n    retry(failureCount, error) {\n      const fetchError = error as FetchError\n\n      if (fetchError.statusCode === undefined) {\n        return false\n      }\n\n      return !!(3 - failureCount)\n    },\n    enabled: options?.enabled ?? true,\n    refetchOnMount: true,\n    staleTime: 0,\n    meta: {\n      persist: true,\n    },\n  })\n  const { error } = rest\n  const fetchError = error as FetchError\n\n  const getAuthStatus = ():\n    | \"loading\"\n    | \"authenticated\"\n    | \"error\"\n    | \"unauthenticated\"\n    | \"unknown\" => {\n    if (isLoading) {\n      return \"loading\"\n    }\n\n    if (fetchError) {\n      return \"error\"\n    }\n\n    if (data) {\n      return \"authenticated\"\n    }\n\n    if (data === null) {\n      return \"unauthenticated\"\n    }\n\n    return \"unknown\"\n  }\n\n  return {\n    session: data,\n    ...rest,\n    status: getAuthStatus(),\n  } as const\n}\n\nexport const handleSessionChanges = () => {\n  setLoginModalShow(false)\n  ipcServices?.auth.sessionChanged()\n  window.location.reload()\n}\n\nexport const signOut = async () => {\n  const authSessionToken = getAuthSessionToken()\n  clearAuthSessionToken()\n  // Clear query cache\n  localStorage.removeItem(QUERY_PERSIST_KEY)\n\n  // clear local store data\n  await clearLocalPersistStoreData()\n\n  // Clear local storage\n  clearStorage()\n  // Sign out\n  await tracker.manager.clear()\n  if (IN_ELECTRON) {\n    void ipcServices?.auth.signOut()\n    const authService = ipcServices?.auth as\n      | ({ signOutRemote?: (token?: string) => Promise<void> } & NonNullable<\n          typeof ipcServices\n        >[\"auth\"])\n      | undefined\n    void authService?.signOutRemote?.(authSessionToken ?? undefined)\n  } else {\n    await ipcServices?.auth.signOut()\n    await signOutFn()\n  }\n  window.location.reload()\n}\n\nexport const deleteUser = async ({ TOTPCode }: { TOTPCode?: string }) => {\n  if (!TOTPCode) {\n    return\n  }\n  await deleteUserFn({\n    TOTPCode,\n  })\n  await signOut()\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/discover.ts",
    "content": "import { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\n\nexport const discover = {\n  rsshubCategory: ({\n    category,\n    categories,\n    lang,\n  }: {\n    category?: string\n    categories?: string\n    lang?: string\n  }) =>\n    defineQuery(\n      [\"discover\", \"rsshub\", \"category\", category, categories, lang],\n      async () => {\n        const res = await followClient.api.discover.rsshub({\n          category,\n          categories,\n          ...(lang !== \"all\" && { lang }),\n        })\n        return res.data\n      },\n      {\n        rootKey: [\"discover\", \"rsshub\", \"category\"],\n      },\n    ),\n  rsshubNamespace: ({ namespace }: { namespace: string }) =>\n    defineQuery([\"discover\", \"rsshub\", \"namespace\", namespace], async () => {\n      const res = await followClient.api.discover.rsshub({\n        namespace,\n      })\n      return res.data\n    }),\n  rsshubRoute: ({ route }: { route: string }) =>\n    defineQuery([\"discover\", \"rsshub\", \"route\", route], async () => {\n      const res = await followClient.api.discover.rsshubRoute({\n        route,\n      })\n      return res.data\n    }),\n  rsshubAnalytics: ({ lang }: { lang?: string }) =>\n    defineQuery([\"discover\", \"rsshub\", \"analytics\", lang], async () => {\n      const res = await followClient.api.discover.rsshubAnalytics({\n        ...(lang !== \"all\" && { lang }),\n      })\n      return res.data\n    }),\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/entries.ts",
    "content": "import { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\n\nexport const entries = {\n  preview: (id: string) =>\n    defineQuery(\n      [\"entries-preview\", id],\n      async () => {\n        const res = await followClient.api.entries.preview({\n          id,\n        })\n\n        return res.data\n      },\n      {\n        rootKey: [\"entries-preview\"],\n      },\n    ),\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/feed.ts",
    "content": "import { feedSyncServices } from \"@follow/store/feed/store\"\nimport { tracker } from \"@follow/tracker\"\nimport { formatXml } from \"@follow/utils/utils\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from \"~/constants\"\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\nimport { toastFetchError } from \"~/lib/error-parser\"\n\ntype FeedQueryParams = { id?: string; url?: string }\n\nexport const feed = {\n  byId: ({ id, url }: FeedQueryParams) =>\n    defineQuery(\n      [\"feed\", id, url],\n      async () =>\n        feedSyncServices.fetchFeedById({\n          id,\n          url,\n        }),\n      {\n        rootKey: [\"feed\"],\n      },\n    ),\n  claimMessage: ({ feedId }: { feedId: string }) =>\n    defineQuery([\"feed\", \"claimMessage\", feedId], async () =>\n      followClient.api.feeds.claim.message({ feedId }).then((res) => {\n        res.data.json = JSON.stringify(JSON.parse(res.data.json), null, 2)\n        const $document = new DOMParser().parseFromString(res.data.xml, \"text/xml\")\n        res.data.xml = formatXml(new XMLSerializer().serializeToString($document))\n        return res\n      }),\n    ),\n  claimedList: () =>\n    defineQuery([\"feed\", \"claimedList\"], async () => {\n      const res = await followClient.api.feeds.claim.list()\n      return res.data\n    }),\n}\n\nexport const useFeedQuery = ({ id, url }: FeedQueryParams) =>\n  useAuthQuery(\n    feed.byId({\n      id,\n      url,\n    }),\n    {\n      enabled:\n        (!!id || !!url) && id !== ROUTE_FEED_PENDING && !id?.startsWith(ROUTE_FEED_IN_FOLDER),\n    },\n  )\n\nexport const useClaimFeedMutation = (feedId: string) =>\n  useMutation({\n    mutationKey: [\"claimFeed\", feedId],\n    mutationFn: () => feedSyncServices.claimFeed(feedId),\n\n    async onError(err) {\n      toastFetchError(err)\n    },\n    onSuccess() {\n      tracker.feedClaimed({\n        feedId,\n      })\n    },\n  })\n\nexport const useRefreshFeedMutation = (feedId?: string) =>\n  useMutation({\n    mutationKey: [\"refreshFeed\", feedId],\n    mutationFn: () => followClient.api.feeds.refresh({ id: feedId! }),\n    async onError(err) {\n      toastFetchError(err)\n    },\n  })\n\nexport const useResetFeed = () => {\n  const { t } = useTranslation()\n  const toastIDRef = useRef<string | number | null>(null)\n\n  return useMutation({\n    mutationFn: async (feedId: string) => {\n      toastIDRef.current = toast.loading(t(\"sidebar.feed_actions.resetting_feed\"))\n      await followClient.api.feeds.reset({ id: feedId })\n    },\n    onSuccess: () => {\n      toast.success(\n        t(\"sidebar.feed_actions.reset_feed_success\"),\n        toastIDRef.current ? { id: toastIDRef.current } : undefined,\n      )\n    },\n    onError: () => {\n      toast.error(\n        t(\"sidebar.feed_actions.reset_feed_error\"),\n        toastIDRef.current ? { id: toastIDRef.current } : undefined,\n      )\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/index.ts",
    "content": "export * as Queries from \"./_\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/mcp.ts",
    "content": "import type { MCPService } from \"@follow/shared/settings/interface\"\nimport type { UpdateConnectionRequest } from \"@follow-app/client-sdk\"\n\nimport { followApi } from \"~/lib/api-client\"\n\nexport const createMCPConnection = async (connectionData: {\n  name: string\n  transportType: \"streamable-http\" | \"sse\"\n  url: string\n  headers?: Record<string, string>\n}) => {\n  return followApi.mcp.createConnection(connectionData)\n}\n\nexport const fetchMCPConnections = async (): Promise<MCPService[]> => {\n  const response = await followApi.mcp.getConnections()\n  return response.data\n}\n\nexport const updateMCPConnection = async (\n  connectionId: string,\n  updateData: Partial<UpdateConnectionRequest>,\n) => {\n  return followApi.mcp.updateConnection({ connectionId, ...updateData })\n}\n\nexport const deleteMCPConnection = async (connectionId: string): Promise<void> => {\n  await followApi.mcp.deleteConnection({ connectionId })\n}\n\nexport const refreshMCPTools = async (connectionIds?: string[]): Promise<void> => {\n  await followApi.mcp.refreshTools({ connectionIds })\n}\n\nexport const getMCPTools = async (connectionId: string) => {\n  const response = await followApi.mcp.getTools({ connectionId })\n  return response.data\n}\n\n// Query key factory for MCP queries\nexport const mcpQueryKeys = {\n  all: [\"mcp\"] as const,\n  connections: () => [...mcpQueryKeys.all, \"connections\"] as const,\n  tools: (connectionId: string) => [...mcpQueryKeys.all, \"tools\", connectionId] as const,\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/messaging.ts",
    "content": "import { useMutation } from \"@tanstack/react-query\"\n\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\n\nexport const messaging = {\n  list: () =>\n    defineQuery([\"messaging\"], () => followClient.api.messaging.getTokens(), {\n      rootKey: [\"messaging\"],\n    }),\n}\n\nexport const useMessaging = () => useAuthQuery(messaging.list())\n\nexport const useTestMessaging = () =>\n  useMutation({\n    mutationFn: ({ channel }: { channel: string }) =>\n      followClient.api.messaging.testNotification({ channel }),\n  })\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/rsshub.ts",
    "content": "import { userActions } from \"@follow/store/user/store\"\nimport type { RSSHubUseRequest } from \"@follow-app/client-sdk\"\nimport { useMutation } from \"@tanstack/react-query\"\n\nimport { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\nimport { toastFetchError } from \"~/lib/error-parser\"\n\nimport type { MutationBaseProps } from \"./types\"\n\nexport const useSetRSSHubMutation = ({ onError }: MutationBaseProps = {}) =>\n  useMutation({\n    mutationFn: (data: RSSHubUseRequest) => followClient.api.rsshub.use({ ...data }),\n\n    onSuccess: (_, variables) => {\n      rsshub.list().invalidate()\n      rsshub.status().invalidate()\n\n      if (variables.id) {\n        rsshub.get({ id: variables.id }).invalidate()\n      }\n    },\n\n    onError: (error) => {\n      onError?.(error)\n      toastFetchError(error)\n    },\n  })\n\nexport const useAddRSSHubMutation = ({ onError }: MutationBaseProps = {}) =>\n  useMutation({\n    mutationFn: ({\n      baseUrl,\n      accessKey,\n      id,\n    }: {\n      baseUrl: string\n      accessKey?: string\n      id?: string\n    }) =>\n      followClient.api.rsshub.create({\n        baseUrl,\n        accessKey,\n        id,\n      }),\n\n    onSuccess: (_) => {\n      rsshub.list().invalidate()\n      rsshub.status().invalidate()\n    },\n\n    onError: (error) => {\n      onError?.(error)\n      toastFetchError(error)\n    },\n  })\n\nexport const useDeleteRSSHubMutation = ({ onError }: MutationBaseProps = {}) =>\n  useMutation({\n    mutationFn: (id: string) => followClient.api.rsshub.delete({ id }),\n\n    onError: (error) => {\n      onError?.(error)\n      toastFetchError(error)\n    },\n  })\n\nexport const rsshub = {\n  get: ({ id }: { id: string }) =>\n    defineQuery([\"rsshub\", \"get\", id], async () => {\n      const res = await followClient.api.rsshub.get({ id })\n      return res.data\n    }),\n\n  list: () =>\n    defineQuery([\"rsshub\", \"list\"], async () => {\n      const res = await followClient.api.rsshub.list()\n      userActions.upsertMany(res.data.map((item) => item.owner).filter((item) => item !== null))\n\n      return res.data\n    }),\n\n  status: () =>\n    defineQuery([\"rsshub\", \"status\"], async () => {\n      const res = await followClient.api.rsshub.status()\n      return res.data\n    }),\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/server-configs.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\"\n\nimport { followApi } from \"~/lib/api-client\"\n\nexport const useServerConfigsQuery = () => {\n  const { data } = useQuery({\n    queryKey: [\"server-configs\"],\n    queryFn: () => followApi.status.getConfigs(),\n  })\n  return data?.data\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/settings.ts",
    "content": "import { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\n\nexport const settings = {\n  get: () => defineQuery([\"settings\"], async () => await followClient.api.settings.get()),\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/types.d.ts",
    "content": "export interface MutationBaseProps {\n  onSuccess?: () => void\n  onError?: (error: Error) => void\n}\n\nexport {}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/users.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\"\n\nimport { useIsInMASReview } from \"~/atoms/server-configs\"\nimport { getProviders } from \"~/lib/auth\"\n\nexport interface AuthProvider {\n  name: string\n  id: string\n  color: string\n  icon: string\n  icon64: string\n  iconDark64?: string\n}\nexport const useAuthProviders = () => {\n  const isInMASReview = useIsInMASReview()\n  return useQuery({\n    queryKey: [\"providers\", isInMASReview],\n    queryFn: async () => {\n      const data = (await getProviders()).data as Record<string, AuthProvider>\n      if (isInMASReview) {\n        if (data.credential) {\n          return {\n            credential: data.credential,\n          }\n        } else {\n          return {}\n        }\n      } else {\n        return data\n      }\n    },\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/queries/wallet.tsx",
    "content": "import type { TransactionQuery } from \"@follow-app/client-sdk\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { toast } from \"sonner\"\n\nimport { useAuthQuery } from \"~/hooks/common\"\nimport { followClient } from \"~/lib/api-client\"\nimport { defineQuery } from \"~/lib/defineQuery\"\nimport { getFetchErrorMessage } from \"~/lib/error-parser\"\n\nexport const wallet = {\n  get: () =>\n    defineQuery(\n      [\"wallet\"],\n      async () => {\n        const res = await followClient.api.wallets.get()\n\n        return res.data\n      },\n      {\n        rootKey: [\"wallet\"],\n      },\n    ),\n\n  transactions: {\n    get: (query: TransactionQuery) =>\n      defineQuery(\n        [\"wallet\", \"transactions\", query],\n        async () => {\n          const res = await followClient.api.wallets.transactions.get(query)\n\n          return res.data\n        },\n        {\n          rootKey: [\"wallet\", \"transactions\"],\n        },\n      ),\n  },\n}\n\nexport const useWallet = () =>\n  useAuthQuery(wallet.get(), {\n    refetchOnMount: true,\n  })\n\nexport const useWalletTransactions = (query: Parameters<typeof wallet.transactions.get>[0] = {}) =>\n  useAuthQuery(wallet.transactions.get(query))\n\nexport const useCreateWalletMutation = () =>\n  useMutation({\n    mutationKey: [\"createWallet\"],\n    mutationFn: () => followClient.api.wallets.post(),\n    async onError(err) {\n      toast.error(await getFetchErrorMessage(err))\n    },\n    onSuccess() {\n      wallet.get().invalidate()\n      toast(\"🎉 Wallet created.\")\n    },\n  })\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/router.tsx",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { createBrowserRouter, createHashRouter } from \"react-router\"\n\nimport { Component as App } from \"./App\"\nimport { ErrorElement } from \"./components/common/ErrorElement\"\nimport { NotFound } from \"./components/common/NotFound\"\n// @ts-ignore\nimport { routes as tree } from \"./generated-routes\"\n\nconst isDebugProxyRuntime =\n  !!globalThis[\"__DEBUG_PROXY__\"] || globalThis.location?.pathname?.startsWith(\"/__debug_proxy\")\n\nconst routerCreator = IN_ELECTRON || isDebugProxyRuntime ? createHashRouter : createBrowserRouter\n\nexport const router = routerCreator([\n  {\n    path: \"/\",\n    Component: App,\n    children: tree,\n    errorElement: <ErrorElement />,\n  },\n  {\n    path: \"*\",\n    element: <NotFound />,\n  },\n])\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/router.web.tsx",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { createBrowserRouter, createHashRouter } from \"react-router\"\n\nimport { ErrorElement } from \"./components/common/ErrorElement\"\nimport { NotFound } from \"./components/common/NotFound\"\n// @ts-ignore\nimport { routes as tree } from \"./generated-routes\"\n\nconst isDebugProxyRuntime =\n  !!globalThis[\"__DEBUG_PROXY__\"] || globalThis.location?.pathname?.startsWith(\"/__debug_proxy\")\n\nconst routerCreator = IN_ELECTRON || isDebugProxyRuntime ? createHashRouter : createBrowserRouter\n\nexport const router = routerCreator([\n  {\n    path: \"/\",\n    lazy: () => import(\"./App\"),\n    children: tree,\n    errorElement: <ErrorElement />,\n  },\n  {\n    path: \"*\",\n    element: <NotFound />,\n  },\n])\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/feed/hooks.ts",
    "content": "import { getView } from \"@follow/constants\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport { useFeedById, usePrefetchFeed } from \"@follow/store/feed/hooks\"\nimport { feedIconSelector } from \"@follow/store/feed/selectors\"\nimport { useListById, usePrefetchListById } from \"@follow/store/list/hooks\"\nimport { getSubscriptionByFeedId } from \"@follow/store/subscription/getter\"\nimport { useTranslation } from \"react-i18next\"\nimport { useShallow } from \"zustand/shallow\"\n\nimport {\n  FEED_COLLECTION_LIST,\n  ROUTE_FEED_IN_FOLDER,\n  ROUTE_FEED_IN_INBOX,\n  ROUTE_FEED_PENDING,\n} from \"~/constants\"\nimport { useRouteParams } from \"~/hooks/biz/useRouteParams\"\n\nexport type PreferredTitleTarget = {\n  type: string\n  id: string\n  title?: Nullable<string>\n  [key: string]: any\n}\nexport const getPreferredTitle = (\n  target?: PreferredTitleTarget,\n  entry?: Pick<EntryModel, \"authorUrl\"> | null,\n) => {\n  if (!target?.id) {\n    return target?.title\n  }\n\n  if (target.type === \"inbox\") {\n    if (entry?.authorUrl) return entry.authorUrl.replace(/^mailto:/, \"\")\n    return target.title || `${target.id.slice(0, 1).toUpperCase()}${target.id.slice(1)}'s Inbox`\n  }\n\n  const subscription = getSubscriptionByFeedId(target.id)\n  return subscription?.title || target.title\n}\n\nexport const useFeedHeaderTitle = () => {\n  const { t } = useTranslation()\n  const { feedId: currentFeedId, view, listId: currentListId } = useRouteParams()\n\n  const feedTitle = useFeedById(currentFeedId, getPreferredTitle)\n  const listTitle = useListById(currentListId, getPreferredTitle)\n\n  usePrefetchFeed(currentFeedId, { enabled: !feedTitle })\n  usePrefetchListById(currentListId, { enabled: !listTitle })\n\n  switch (currentFeedId) {\n    case ROUTE_FEED_PENDING: {\n      return t(getView(view).name, {\n        ns: \"common\",\n      })\n    }\n    case FEED_COLLECTION_LIST: {\n      return t(\"words.starred\")\n    }\n    default: {\n      if (currentFeedId?.startsWith(ROUTE_FEED_IN_FOLDER)) {\n        return currentFeedId.replace(ROUTE_FEED_IN_FOLDER, \"\")\n      }\n      if (currentFeedId?.startsWith(ROUTE_FEED_IN_INBOX)) {\n        return currentFeedId.replace(ROUTE_FEED_IN_INBOX, \"\")\n      }\n      return feedTitle || listTitle\n    }\n  }\n}\n\nexport const useFeedHeaderIcon = () => {\n  const { feedId: currentFeedId, listId: currentListId } = useRouteParams()\n\n  const feedIcon = useFeedById(currentFeedId, useShallow(feedIconSelector))\n  const listIcon = useListById(\n    currentListId,\n    useShallow((feed) => ({\n      type: feed.type,\n      ownerUserId: feed.ownerUserId,\n      id: feed.id,\n      title: feed.title,\n      url: (feed as any).url || \"\",\n      image: feed.image,\n    })),\n  )\n\n  return feedIcon || listIcon\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/image/db.ts",
    "content": "import type { UseStore } from \"idb-keyval\"\nimport { get, promisifyRequest, set } from \"idb-keyval\"\n\nimport type { StoreImageType } from \".\"\n\nfunction createStore(dbName: string, storeName: string): UseStore {\n  const request = indexedDB.open(dbName)\n  request.onupgradeneeded = () => {\n    const objectStore = request.result.createObjectStore(storeName)\n\n    objectStore.createIndex(\"src\", \"src\", { unique: true })\n    return objectStore\n  }\n  const dbp = promisifyRequest(request)\n\n  return (txMode, callback) =>\n    dbp.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)))\n}\n\nconst db = createStore(\"FOLLOW_IMAGE_DIMENSIONS\", \"image-dimensions\")\nexport const getImageDimensionsFromDb = async (url: string) => await get(url, db)\n\nexport const saveImageDimensionsToDb = async (url: string, dimensions: StoreImageType) => {\n  const oldData = await getImageDimensionsFromDb(url)\n\n  await set(\n    url,\n    {\n      ...oldData,\n      ...dimensions,\n    },\n    db,\n  )\n}\n\nexport const clearImageDimensionsDb = async () => {\n  const store = await db(\"readwrite\", (store) => store.clear())\n  return store\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/image/index.ts",
    "content": "import type { EntryModel } from \"@follow/store/entry/types\"\n\nimport { createZustandStore } from \"../utils/helper\"\nimport { getImageDimensionsFromDb } from \"./db\"\n\nexport interface StoreImageType {\n  src: string\n  width: number\n  height: number\n  ratio: number\n  blurhash?: string\n}\ninterface State {\n  images: Record<string, StoreImageType>\n}\nexport const useImageStore = createZustandStore<State>(\"image\")(() => ({\n  images: {},\n}))\n\nconst set = useImageStore.setState\nconst get = useImageStore.getState\n\nclass ImageActions {\n  getImage(src: string) {\n    return get().images[src]\n  }\n\n  saveImages(images: StoreImageType[]) {\n    set((state) => {\n      const newImages = { ...state.images }\n      for (const image of images) {\n        newImages[image.src] = image\n      }\n      return { images: newImages }\n    })\n  }\n\n  async fetchDimensionsFromDb(images: string[]) {\n    const dims = (await Promise.all(images.map((image) => getImageDimensionsFromDb(image)))).filter(\n      Boolean,\n    ) as StoreImageType[]\n    imageActions.saveImages(dims)\n  }\n\n  getImagesFromEntry(entry: EntryModel) {\n    const images = [] as string[]\n    if (!entry.media) return images\n    for (const media of entry.media) {\n      if (media.type === \"photo\") {\n        images.push(media.url)\n      }\n    }\n    return images\n  }\n}\nexport const imageActions = new ImageActions()\n/// // HOOKS\nexport const useImageDimensions = (url: string) => useImageStore((state) => state.images[url])\nexport const useImagesHasDimensions = (urls?: string[]) =>\n  useImageStore((state) => (urls ? urls?.every((url) => state.images[url]) : false))\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/search/constants.ts",
    "content": "const SearchTypeBase = {\n  Feed: 1,\n  Entry: 1 << 1,\n  Subscription: 1 << 2,\n}\n\nexport const SearchType = {\n  ...SearchTypeBase,\n  All: Object.values(SearchTypeBase).reduce((acc, cur) => acc | cur, 0),\n}\n\nexport type SearchType = (typeof SearchType)[keyof typeof SearchType]\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/search/helper.ts",
    "content": "import type { SearchInstance } from \"./types\"\n\nexport const defineSearchInstance = (instance: SearchInstance) => instance\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/search/index.ts",
    "content": "import { EntryService } from \"@follow/database/services/entry\"\nimport { FeedService } from \"@follow/database/services/feed\"\nimport { SubscriptionService } from \"@follow/database/services/subscription\"\nimport type { EntryModel } from \"@follow/store/entry/types\"\nimport type { SubscriptionModel } from \"@follow/store/subscription/types\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport type { IFuseOptions } from \"fuse.js\"\nimport Fuse from \"fuse.js\"\nimport { useAtomValue } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\n\nimport { jotaiStore } from \"~/lib/jotai\"\n\nimport { createZustandStore } from \"../utils/helper\"\nimport { SearchType } from \"./constants\"\nimport { defineSearchInstance } from \"./helper\"\nimport type { SearchResult, SearchState } from \"./types\"\n\nconst searchTypeAtom = atomWithStorage<SearchType>(\n  getStorageNS(\"search-type\"),\n  SearchType.Feed,\n  undefined,\n  { getOnInit: true },\n)\nconst createState = (): SearchState => ({\n  feeds: [],\n  entries: [],\n  subscriptions: [],\n  keyword: \"\",\n})\nexport const useSearchStore = createZustandStore<SearchState>(\"search\")(createState)\n\nconst { getState: get, setState: set } = useSearchStore\n\nclass SearchActions {\n  reset() {\n    set(createState)\n  }\n\n  private createFuse<T extends object>(data: T[], keys: (keyof T)[]) {\n    const options: IFuseOptions<T> = {\n      keys: keys as any,\n    }\n    const index = Fuse.createIndex(options.keys!, data)\n    return new Fuse(data, options, index)\n  }\n\n  async createLocalDbSearch() {\n    const [entries, feeds, subscriptions] = await Promise.all([\n      EntryService.getEntryAll(),\n      (await FeedService.getFeedAll()).map((feed) => ({\n        ...feed,\n        type: \"feed\" as const,\n      })),\n      SubscriptionService.getSubscriptionAll(),\n    ])\n\n    const feedsMap = new Map(feeds.map((feed) => [feed.id, feed]))\n\n    const entriesFuse = this.createFuse(entries, [\"title\", \"content\", \"description\", \"id\"])\n    const feedsFuse = this.createFuse(feeds, [\"title\", \"description\", \"id\", \"siteUrl\", \"url\"])\n    const subscriptionsFuse = this.createFuse(subscriptions, [\"title\", \"category\"])\n\n    return defineSearchInstance({\n      counts: {\n        entries: entries.length,\n        feeds: feeds.length,\n        subscriptions: subscriptions.length,\n      },\n      search(keyword: string) {\n        const type = jotaiStore.get(searchTypeAtom)\n        const entries = type & SearchType.Entry ? entriesFuse.search(keyword) : []\n        const feeds = type & SearchType.Feed ? feedsFuse.search(keyword) : []\n\n        const subscriptions =\n          type & SearchType.Subscription ? subscriptionsFuse.search(keyword) : []\n\n        const processedEntries = [] as SearchResult<EntryModel, { feedId: string }>[]\n        for (const entry of entries) {\n          const feedId = entry.item.feedId ? feedsMap.get(entry.item.feedId)?.id : undefined\n          if (feedId) {\n            processedEntries.push({ item: entry.item, feedId })\n          }\n        }\n\n        const processedSubscriptions = [] as SearchResult<SubscriptionModel, { feedId: string }>[]\n        for (const subscription of subscriptions) {\n          const { feedId } = subscription.item\n          if (feedId) {\n            processedSubscriptions.push({ item: subscription.item, feedId })\n          }\n        }\n\n        set({\n          keyword,\n          entries: processedEntries,\n          feeds,\n          subscriptions: processedSubscriptions,\n        })\n\n        jotaiStore.set(searchTypeAtom, type)\n\n        return get()\n      },\n    })\n  }\n\n  setSearchType(type: SearchType) {\n    jotaiStore.set(searchTypeAtom, type)\n  }\n\n  getCurrentKeyword() {\n    return get().keyword\n  }\n}\nexport const useSearchType = () => useAtomValue(searchTypeAtom)\nexport const searchActions = new SearchActions()\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/search/types.ts",
    "content": "import type { EntryModel } from \"@follow/store/entry/types\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport type { SubscriptionModel } from \"@follow/store/subscription/types\"\n\n// @ts-expect-error\nexport interface SearchResult<T extends object, A extends object = object> extends A {\n  item: T\n}\n\nexport interface SearchState {\n  feeds: SearchResult<FeedModel>[]\n  entries: SearchResult<EntryModel, { feedId: string }>[]\n  subscriptions: SearchResult<SubscriptionModel, { feedId: string }>[]\n\n  keyword: string\n}\nexport interface SearchInstance {\n  search: (keyword: string) => SearchState\n\n  counts: {\n    feeds: number\n    entries: number\n    subscriptions: number\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/utils/clear.ts",
    "content": "import { deleteDB } from \"@follow/database/db\"\nimport { getStorageNS } from \"@follow/utils/ns\"\n\nimport { clearImageDimensionsDb } from \"../image/db\"\n\nexport const clearLocalPersistStoreData = async () => {\n  await Promise.all([deleteDB(), clearImageDimensionsDb()])\n}\n\nconst storedUserId = getStorageNS(\"user_id\")\nexport const clearDataIfLoginOtherAccount = (newUserId: string) => {\n  const oldUserId = localStorage.getItem(storedUserId)\n  localStorage.setItem(storedUserId, newUserId)\n  if (oldUserId !== newUserId) {\n    return clearLocalPersistStoreData()\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/utils/helper.test.ts",
    "content": "import { fail } from \"node:assert\"\n\nimport { describe, expect, it, vi } from \"vitest\"\n\nimport { createTransaction } from \"./helper\"\n\ndescribe(\"createTransaction\", () => {\n  it(\"should execute all steps in correct order\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n\n    const transaction = createTransaction(snapshot)\n      .optimistic(async () => {\n        executionOrder.push(\"optimistic\")\n      })\n      .execute(async () => {\n        executionOrder.push(\"execute\")\n      })\n      .persist(async () => {\n        executionOrder.push(\"persist\")\n      })\n\n    await transaction.run()\n\n    expect(executionOrder).toEqual([\"optimistic\", \"execute\", \"persist\"])\n  })\n\n  it(\"should handle rollback when execution fails\", async () => {\n    const snapshot = { value: 1 }\n    const rollbackMock = vi.fn()\n    const error = new Error(\"Execution failed\")\n    const persistMock = vi.fn()\n\n    const transaction = createTransaction(snapshot)\n      .rollback(rollbackMock)\n      .execute(async () => {\n        throw error\n      })\n      .persist(persistMock)\n\n    await expect(transaction.run()).rejects.toThrow(error)\n    expect(rollbackMock).toHaveBeenCalledWith(snapshot, {})\n    expect(persistMock).not.toHaveBeenCalled()\n  })\n\n  it(\"should continue if optimistic update fails\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n    const rollbackMock = vi.fn()\n\n    const transaction = createTransaction(snapshot)\n      .rollback(rollbackMock)\n      .optimistic(async () => {\n        throw new Error(\"Optimistic update failed\")\n      })\n      .execute(async () => {\n        executionOrder.push(\"execute\")\n      })\n      .persist(async () => {\n        executionOrder.push(\"persist\")\n      })\n\n    await transaction.run()\n\n    expect(executionOrder).toEqual([\"execute\", \"persist\"])\n    expect(rollbackMock).not.toHaveBeenCalled()\n  })\n\n  it(\"should maintain method chaining\", () => {\n    const snapshot = { value: 1 }\n    const transaction = createTransaction(snapshot)\n\n    expect(transaction.optimistic(() => Promise.resolve())).toBe(transaction)\n    expect(transaction.execute(() => Promise.resolve())).toBe(transaction)\n    expect(transaction.rollback(() => Promise.resolve())).toBe(transaction)\n    expect(transaction.persist(() => Promise.resolve())).toBe(transaction)\n  })\n\n  it(\"should pass context to all functions\", async () => {\n    const snapshot = { value: 1 }\n    const ctx = { someContext: \"test\" }\n    const optimisticMock = vi.fn()\n    const executeMock = vi.fn()\n    const persistMock = vi.fn()\n\n    const transaction = createTransaction(snapshot, ctx)\n      .optimistic(optimisticMock)\n      .execute(executeMock)\n      .persist(persistMock)\n\n    await transaction.run()\n\n    expect(optimisticMock).toHaveBeenCalledWith(snapshot, ctx)\n    expect(executeMock).toHaveBeenCalledWith(snapshot, ctx)\n    expect(persistMock).toHaveBeenCalledWith(snapshot, ctx)\n  })\n\n  it(\"should pass context to rollback function when execution fails\", async () => {\n    const snapshot = { value: 1 }\n    const ctx = { someContext: \"test\" }\n    const rollbackMock = vi.fn()\n    const error = new Error(\"Execution failed\")\n\n    const transaction = createTransaction(snapshot, ctx)\n      .rollback(rollbackMock)\n      .execute(async () => {\n        throw error\n      })\n\n    await expect(transaction.run()).rejects.toThrow(error)\n    expect(rollbackMock).toHaveBeenCalledWith(snapshot, ctx)\n  })\n\n  it(\"should allow modifying context in optimistic phase for later consumption\", async () => {\n    const snapshot = { value: 1 }\n    const ctx = { someValue: \"initial\" }\n    const executionOrder: string[] = []\n\n    const transaction = createTransaction(snapshot, ctx)\n      .optimistic(async (_, context: any) => {\n        context.someValue = \"modified\"\n        executionOrder.push(`optimistic: ${context.someValue}`)\n      })\n      .execute(async (_, context: any) => {\n        executionOrder.push(`execute: ${context.someValue}`)\n      })\n      .persist(async (_, context: any) => {\n        executionOrder.push(`persist: ${context.someValue}`)\n      })\n\n    await transaction.run()\n\n    expect(executionOrder).toEqual([\n      \"optimistic: modified\",\n      \"execute: modified\",\n      \"persist: modified\",\n    ])\n    expect(ctx.someValue).toBe(\"modified\")\n  })\n\n  it(\"should propagate the exact same error instance from execute to run\", async () => {\n    const snapshot = { value: 1 }\n    const specificError = new Error(\"Specific error message\")\n\n    const transaction = createTransaction(snapshot).execute(async () => {\n      throw specificError\n    })\n\n    try {\n      await transaction.run()\n      fail(\"Expected an error to be thrown\")\n    } catch (error) {\n      expect(error).toBe(specificError)\n    }\n  })\n\n  it(\"should support synchronous functions in optimistic phase\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n\n    const transaction = createTransaction(snapshot)\n      .optimistic(() => {\n        executionOrder.push(\"optimistic\")\n      })\n      .execute(async () => {\n        executionOrder.push(\"execute\")\n      })\n\n    await transaction.run()\n    expect(executionOrder).toEqual([\"optimistic\", \"execute\"])\n  })\n\n  it(\"should support synchronous functions in execute phase\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n\n    const transaction = createTransaction(snapshot)\n      .execute(() => {\n        executionOrder.push(\"execute\")\n      })\n      .persist(async () => {\n        executionOrder.push(\"persist\")\n      })\n\n    await transaction.run()\n    expect(executionOrder).toEqual([\"execute\", \"persist\"])\n  })\n\n  it(\"should support synchronous functions in persist phase\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n\n    const transaction = createTransaction(snapshot)\n      .execute(async () => {\n        executionOrder.push(\"execute\")\n      })\n      .persist(() => {\n        executionOrder.push(\"persist\")\n      })\n\n    await transaction.run()\n    expect(executionOrder).toEqual([\"execute\", \"persist\"])\n  })\n\n  it(\"should support synchronous functions in rollback\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n    const error = new Error(\"Execution failed\")\n\n    const transaction = createTransaction(snapshot)\n      .rollback(() => {\n        executionOrder.push(\"rollback\")\n      })\n      .execute(async () => {\n        throw error\n      })\n\n    await expect(transaction.run()).rejects.toThrow(error)\n    expect(executionOrder).toEqual([\"rollback\"])\n  })\n\n  it(\"should handle errors from synchronous functions\", async () => {\n    const snapshot = { value: 1 }\n    const syncError = new Error(\"Sync error\")\n    const rollbackMock = vi.fn()\n\n    const transaction = createTransaction(snapshot)\n      .rollback(rollbackMock)\n      .execute(() => {\n        throw syncError\n      })\n\n    await expect(transaction.run()).rejects.toThrow(syncError)\n    expect(rollbackMock).toHaveBeenCalledWith(snapshot, {})\n  })\n\n  it(\"should support mix of sync and async functions\", async () => {\n    const executionOrder: string[] = []\n    const snapshot = { value: 1 }\n\n    const transaction = createTransaction(snapshot)\n      .optimistic(() => {\n        executionOrder.push(\"sync-optimistic\")\n      })\n      .execute(async () => {\n        await Promise.resolve()\n        executionOrder.push(\"async-execute\")\n      })\n      .persist(() => {\n        executionOrder.push(\"sync-persist\")\n      })\n\n    await transaction.run()\n    expect(executionOrder).toEqual([\"sync-optimistic\", \"async-execute\", \"sync-persist\"])\n  })\n})\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/store/utils/helper.ts",
    "content": "import { isDraft, original, produce } from \"immer\"\nimport type { StateCreator, StoreApi, UseBoundStore } from \"zustand\"\nimport type { PersistStorage } from \"zustand/middleware\"\nimport { devtools } from \"zustand/middleware\"\nimport { shallow } from \"zustand/shallow\"\nimport type { UseBoundStoreWithEqualityFn } from \"zustand/traditional\"\nimport { createWithEqualityFn } from \"zustand/traditional\"\n\ndeclare const window: any\nexport const localStorage: PersistStorage<any> = {\n  getItem: (name: string) => {\n    const data = window.localStorage.getItem(name)\n\n    if (data === null) {\n      return null\n    }\n\n    try {\n      return JSON.parse(data)\n    } catch {\n      return data\n    }\n  },\n  setItem: (name, value) => {\n    window.localStorage.setItem(name, JSON.stringify(value))\n  },\n  removeItem: (name: string) => {\n    window.localStorage.removeItem(name)\n  },\n}\n\nconst storeMap = {} as Record<string, UseBoundStoreWithEqualityFn<any>>\n\nexport const createZustandStore =\n  <S, T extends StateCreator<S, [], []> = StateCreator<S, [], []>>(name: string) =>\n  (store: T) => {\n    if (import.meta.env.DEV && window[`store_${name}`]) {\n      // import.meta.hot?.send(\"message\", \"The store has been changed, reloading...\")\n      // globalThis.location.reload()\n    }\n\n    const newStore = import.meta.env.DEV\n      ? createWithEqualityFn(\n          devtools(store, {\n            enabled: DEBUG,\n            name,\n          }),\n          shallow,\n        )\n      : createWithEqualityFn(store, shallow)\n\n    storeMap[name] = newStore\n\n    window.store =\n      window.store ||\n      new Proxy(\n        {},\n        {\n          get(_, prop) {\n            if (prop in storeMap) {\n              return new Proxy(() => {}, {\n                get() {\n                  return storeMap[prop as string].getState()\n                },\n                apply(target, thisArg, argumentsList) {\n                  return storeMap[prop as string].setState(\n                    produce(storeMap[prop as string].getState(), ...argumentsList),\n                  )\n                },\n              })\n            }\n            return\n          },\n        },\n      )\n\n    window[`store_${name}`] = newStore\n\n    return newStore\n  }\ntype FunctionKeys<T> = {\n  [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never\n}[keyof T]\n\ntype FunctionProps<T> = Pick<T, FunctionKeys<T>>\nexport const getStoreActions = <T extends { getState: () => any }>(\n  store: T,\n): FunctionProps<ReturnType<T[\"getState\"]>> => {\n  const actions = {}\n  const state = store.getState()\n  for (const key in state) {\n    if (typeof state[key] === \"function\") {\n      actions[key] = state[key]\n    }\n  }\n\n  return actions as any\n}\n\nexport function createImmerSetter<T>(useStore: UseBoundStore<StoreApi<T>>) {\n  return (updater: (state: T) => void) =>\n    useStore.setState((state) =>\n      produce(state, (draft) => {\n        updater(draft as T)\n      }),\n    )\n}\n\ntype MayBeDraft<T> = T\nexport const toRaw = <T>(draft: MayBeDraft<T>): T => {\n  return isDraft(draft) ? original(draft)! : draft\n}\ntype SyncOrAsync<T> = T | Promise<T>\ntype ExecutorFn<S, Ctx> = (snapshot: S, ctx: Ctx) => SyncOrAsync<void>\n\nclass Transaction<S, Ctx> {\n  private _snapshot: S\n  private _ctx: Ctx\n  private onRollback?: ExecutorFn<S, Ctx>\n  private executorFn?: ExecutorFn<S, Ctx>\n  private optimisticExecutor?: ExecutorFn<S, Ctx>\n  private onPersist?: ExecutorFn<S, Ctx>\n\n  constructor(snapshot?: S, ctx?: Ctx) {\n    this._snapshot = snapshot || ({} as S)\n    this._ctx = ctx || ({} as Ctx)\n  }\n\n  rollback(fn: ExecutorFn<S, Ctx>): this {\n    this.onRollback = fn\n    return this\n  }\n\n  execute(executor: ExecutorFn<S, Ctx>): this {\n    this.executorFn = executor\n    return this\n  }\n\n  optimistic(executor: ExecutorFn<S, Ctx>): this {\n    this.optimisticExecutor = executor\n    return this\n  }\n\n  persist(fn: ExecutorFn<S, Ctx>): this {\n    this.onPersist = fn\n    return this\n  }\n\n  async run(): Promise<void> {\n    let isOptimisticFailed = false\n\n    if (this.optimisticExecutor) {\n      try {\n        await Promise.resolve(this.optimisticExecutor(this._snapshot, this._ctx))\n      } catch (error) {\n        isOptimisticFailed = true\n        console.error(error)\n      }\n    }\n\n    if (this.executorFn) {\n      try {\n        await Promise.resolve(this.executorFn(this._snapshot, this._ctx))\n      } catch (err) {\n        if (this.onRollback && !isOptimisticFailed) {\n          await Promise.resolve(this.onRollback(this._snapshot, this._ctx))\n        }\n        throw err\n      }\n    }\n\n    if (this.onPersist) {\n      await Promise.resolve(this.onPersist!(this._snapshot, this._ctx)).catch((err) => {\n        console.error(err)\n        throw err\n      })\n    }\n  }\n}\n\nexport const createTransaction = <S, Ctx>(snapshot?: S, ctx?: Ctx): Transaction<S, Ctx> => {\n  return new Transaction(snapshot, ctx)\n}\n\nexport const createSelectorHelper = <TState>() => {\n  return function defineSelector<TSelected>(selector: (state: TState) => TSelected) {\n    return selector\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/styles/additional.css",
    "content": ".mask-both {\n  mask-image: linear-gradient(\n    rgba(255, 255, 255, 0) 0%,\n    rgb(255, 255, 255) 20px,\n    rgb(255, 255, 255) calc(100% - 20px),\n    rgba(255, 255, 255, 0) 100%\n  );\n}\n.mask-both-lg {\n  mask-image: linear-gradient(\n    rgba(255, 255, 255, 0) 0%,\n    rgb(255, 255, 255) 50px,\n    rgb(255, 255, 255) calc(100% - 50px),\n    rgba(255, 255, 255, 0) 100%\n  );\n}\n\n.mask-b {\n  mask-image: linear-gradient(rgb(255, 255, 255) calc(100% - 20px), rgba(255, 255, 255, 0) 100%);\n}\n\n.mask-b-lg {\n  mask-image: linear-gradient(rgb(255, 255, 255) calc(100% - 50px), rgba(255, 255, 255, 0) 100%);\n}\n\n.mask-t {\n  mask-image: linear-gradient(rgba(255, 255, 255, 0) 0%, rgb(255, 255, 255) 20px);\n}\n\n.mask-t-lg {\n  mask-image: linear-gradient(rgba(255, 255, 255, 0) 0%, rgb(255, 255, 255) 50px);\n}\n\n.mask-horizontal {\n  mask-image: linear-gradient(\n    90deg,\n    rgba(255, 255, 255, 0) 0%,\n    rgba(255, 255, 255, 1) 14%,\n    rgba(255, 255, 255, 1) 86%,\n    rgba(255, 255, 255, 0) 100%\n  );\n}\n\n/* Sonner customize */\n\n@media (max-width: 600px) {\n  #root [data-sonner-toaster][data-y-position=\"bottom\"] {\n    bottom: calc(env(safe-area-inset-bottom, 0px) + 20px);\n  }\n\n  #root [data-sonner-toaster][data-y-position=\"top\"] {\n    top: calc(env(safe-area-inset-top, 0px) + 20px);\n  }\n}\n\n[data-sonner-toast] {\n  background-image: linear-gradient(\n    to bottom right,\n    rgba(var(--color-background) / 0.98),\n    rgba(var(--color-background) / 0.95)\n  ) !important;\n}\n[data-sonner-toast]::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: 1rem;\n  background: linear-gradient(\n    to bottom right,\n    rgba(255, 92, 0, 0.05),\n    transparent,\n    rgba(255, 92, 0, 0.05)\n  );\n  z-index: -1;\n}\n[data-sonner-toast][data-type=\"success\"]::before {\n  background: linear-gradient(\n    to bottom right,\n    rgba(40, 205, 65, 0.05),\n    transparent,\n    rgba(40, 205, 65, 0.05)\n  );\n}\n[data-sonner-toast][data-type=\"error\"]::before {\n  background: linear-gradient(\n    to bottom right,\n    rgba(255, 69, 58, 0.05),\n    transparent,\n    rgba(255, 69, 58, 0.05)\n  );\n}\n[data-sonner-toast][data-type=\"warning\"]::before {\n  background: linear-gradient(\n    to bottom right,\n    rgba(255, 149, 0, 0.05),\n    transparent,\n    rgba(255, 149, 0, 0.05)\n  );\n}\n[data-sonner-toast][data-type=\"info\"]::before {\n  background: linear-gradient(\n    to bottom right,\n    rgba(0, 122, 255, 0.05),\n    transparent,\n    rgba(0, 122, 255, 0.05)\n  );\n}\n[data-sonner-toast][data-type=\"loading\"]::before {\n  background: linear-gradient(\n    to bottom right,\n    rgba(142, 142, 147, 0.05),\n    transparent,\n    rgba(142, 142, 147, 0.05)\n  );\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/styles/base.css",
    "content": "html,\n#shadow-html {\n  --fo-font-family: \"SN Pro\", system-ui, sans-serif;\n}\n\nhtml {\n  overflow: hidden;\n}\n:root {\n  --sat: env(safe-area-inset-top);\n  --sar: env(safe-area-inset-right);\n  --sab: env(safe-area-inset-bottom);\n  --sal: env(safe-area-inset-left);\n}\n* {\n  -webkit-tap-highlight-color: transparent;\n}\n\n[data-build-type]:not([data-build-type=\"electron\"]) #root {\n  background-color: hsl(var(--background));\n}\n\nhtml[data-viewport=\"desktop\"],\nhtml[data-viewport=\"desktop\"] body,\nhtml[data-viewport=\"desktop\"] #shadow-html,\nhtml[data-viewport=\"desktop\"] #root {\n  overscroll-behavior: none;\n}\n\nbody,\n#root {\n  @apply h-auto lg:h-screen;\n  @apply cursor-default;\n  @apply select-none;\n  /* @apply bg-white; */\n\n  @apply font-theme;\n\n  @apply print:h-auto print:overflow-auto;\n}\n\n:focus-visible {\n  /* outline: theme(colors.theme.accent.DEFAULT) auto 1px; */\n  outline: none;\n}\n\nbutton {\n  cursor: var(--pointer);\n}\n\n.prose a {\n  @apply cursor-pointer;\n}\n\n[data-theme=\"dark\"] {\n  color-scheme: dark;\n}\n\n::selection {\n  @apply bg-accent/80 text-white;\n}\n\n#react-scan-toolbar {\n  @apply no-drag-region max-lg:!hidden;\n}\n\n.grecaptcha-badge {\n  visibility: hidden !important;\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/styles/cursor.css",
    "content": "html,\n#shadow-html {\n  --cursor-button: var(--pointer);\n  --cursor-select: var(--pointer);\n  --cursor-checkbox: var(--pointer);\n  --cursor-link: var(--pointer);\n  --cursor-menu: default;\n  --cursor-radio: var(--pointer);\n  --cursor-switch: var(--pointer);\n  --cursor-card: var(--pointer);\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/styles/font.css",
    "content": "@import \"@fontsource/sn-pro/200-italic.css\";\n@import \"@fontsource/sn-pro/200.css\";\n@import \"@fontsource/sn-pro/300-italic.css\";\n@import \"@fontsource/sn-pro/300.css\";\n@import \"@fontsource/sn-pro/400-italic.css\";\n@import \"@fontsource/sn-pro/400.css\";\n@import \"@fontsource/sn-pro/500-italic.css\";\n@import \"@fontsource/sn-pro/500.css\";\n@import \"@fontsource/sn-pro/600-italic.css\";\n@import \"@fontsource/sn-pro/600.css\";\n@import \"@fontsource/sn-pro/700-italic.css\";\n@import \"@fontsource/sn-pro/700.css\";\n@import \"@fontsource/sn-pro/800-italic.css\";\n@import \"@fontsource/sn-pro/800.css\";\n@import \"@fontsource/sn-pro/900-italic.css\";\n@import \"@fontsource/sn-pro/900.css\";\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/styles/main.css",
    "content": "@import \"./base.css\";\n@import \"./additional.css\";\n@import \"./scrollbar.css\";\n@import \"./cursor.css\";\n\n@media print {\n  * {\n    overflow: visible !important;\n    page-break-after: avoid;\n    page-break-before: avoid;\n    break-inside: avoid;\n    height: max-content;\n  }\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/styles/scrollbar.css",
    "content": "body::-webkit-scrollbar {\n  height: 0;\n}\n\nhtml:not([data-viewport=\"mobile\"]) {\n  /*  for firefox */\n  scrollbar-color: theme(colors.fill);\n  scrollbar-width: thin;\n}\n\nhtml.dark:not([data-viewport=\"mobile\"]) {\n  scrollbar-color: theme(colors.neutral.500);\n}\n\nhtml:not([data-viewport=\"mobile\"])[data-theme=\"dark\"] {\n  *::-webkit-scrollbar-thumb {\n    border: 6px solid theme(colors.neutral.500);\n  }\n\n  *::-webkit-scrollbar-thumb:hover {\n    background: theme(colors.neutral.400/0.5);\n  }\n}\n\nhtml:not([data-viewport=\"mobile\"]) *::-webkit-scrollbar-thumb {\n  background-color: transparent;\n  border: 6px solid theme(colors.fill);\n  @apply rounded-xl;\n}\n\nhtml:not([data-viewport=\"mobile\"]) *::-webkit-scrollbar-thumb:hover {\n  border-color: theme(colors.neutral.400/0.8);\n}\n\nhtml:not([data-viewport=\"mobile\"]) *::-webkit-scrollbar {\n  width: 6px !important;\n  height: 6px !important;\n  background: transparent;\n}\n\nhtml:not([data-viewport=\"mobile\"]) *::-webkit-scrollbar-thumb {\n  background: theme(colors.material-medium);\n}\n\nhtml:not([data-viewport=\"mobile\"]) *::-webkit-scrollbar-thumb:hover {\n  background: theme(colors.material-thin);\n}\n\nhtml:not([data-viewport=\"mobile\"]) *::-webkit-scrollbar-corner {\n  background: theme(colors.zinc.100);\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/sw.ts",
    "content": "import \"./workers/sw/index\"\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/wdyr.ts",
    "content": "if (import.meta.env.DEV) {\n  const { scan } = await import(\"react-scan\")\n  scan({ enabled: false, log: false, showToolbar: true })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/workers/sw/index.ts",
    "content": "/// <reference lib=\"webworker\" />\nimport { CacheableResponsePlugin } from \"workbox-cacheable-response\"\nimport { ExpirationPlugin } from \"workbox-expiration\"\nimport { registerRoute } from \"workbox-routing\"\nimport { CacheFirst } from \"workbox-strategies\"\n\nimport { registerPusher } from \"./pusher\"\n\ndeclare let self: ServiceWorkerGlobalScope\n\nregisterPusher(self)\n\nregisterRoute(\n  ({ request }) => request.destination === \"image\",\n  new CacheFirst({\n    cacheName: \"image-assets\",\n    plugins: [\n      new CacheableResponsePlugin({\n        statuses: [0, 200],\n      }),\n      new ExpirationPlugin({\n        maxEntries: 100,\n        maxAgeSeconds: 10 * 24 * 60 * 60,\n        purgeOnQuotaError: true,\n      }),\n    ],\n  }),\n)\n"
  },
  {
    "path": "apps/desktop/layer/renderer/src/workers/sw/pusher.ts",
    "content": "/// <reference lib=\"webworker\" />\ninterface NewEntryMessage {\n  description: string\n  entryId: string\n  feedId: string\n  title: string\n  type: \"new-entry\"\n  view: number\n}\n\ntype Message = NewEntryMessage\n\nexport const registerPusher = (self: ServiceWorkerGlobalScope) => {\n  self.addEventListener(\"message\", (event) => {\n    if (event.data && event.data.type === \"SKIP_WAITING\") self.skipWaiting()\n  })\n\n  // Firebase Cloud Messaging handler\n  self.addEventListener(\"push\", (event) => {\n    if (event.data) {\n      const { data } = event.data.json()\n      const payload = data as Message\n\n      switch (payload.type) {\n        case \"new-entry\": {\n          const notificationPromise = self.registration.showNotification(payload.title, {\n            body: payload.description,\n            icon: \"https://app.folo.is/favicon.ico\",\n            data: {\n              type: payload.type,\n              feedId: payload.feedId,\n              entryId: payload.entryId,\n              view: Number.parseInt(payload.view as any),\n              description: payload.description,\n              title: payload.title,\n            } as NewEntryMessage,\n          })\n          event.waitUntil(notificationPromise)\n          break\n        }\n      }\n    }\n  })\n\n  self.addEventListener(\"notificationclick\", (event) => {\n    event.notification.close()\n\n    const notificationData = event.notification.data as NewEntryMessage\n    if (!notificationData) return\n\n    let urlToOpen: URL\n\n    switch (notificationData.type) {\n      case \"new-entry\": {\n        urlToOpen = new URL(\n          `/timeline/view-${notificationData.view}/${notificationData.feedId}/${notificationData.entryId}`,\n          self.location.origin,\n        )\n        break\n      }\n      default: {\n        urlToOpen = new URL(\"/\", self.location.origin)\n        break\n      }\n    }\n\n    const promiseChain = self.clients\n      .matchAll({\n        type: \"window\",\n        includeUncontrolled: true,\n      })\n      .then((windowClients) => {\n        if (windowClients.length > 0) {\n          const client = windowClients[0]\n          return client?.focus()\n        }\n        return self.clients.openWindow(urlToOpen.href)\n      })\n      .then((client) => {\n        if (client && \"postMessage\" in client) {\n          switch (notificationData.type) {\n            case \"new-entry\": {\n              client.postMessage({\n                type: \"NOTIFICATION_CLICK\",\n                action: \"NAVIGATE_ENTRY\",\n                data: {\n                  feedId: notificationData.feedId,\n                  entryId: notificationData.entryId,\n                  view: notificationData.view,\n                  url: urlToOpen.pathname,\n                },\n              })\n              break\n            }\n            default: {\n              console.warn(\"Unknown notification type:\", notificationData.type)\n              break\n            }\n          }\n        }\n      })\n\n    event.waitUntil(promiseChain)\n  })\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/tsconfig.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.web.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"moduleResolution\": \"Bundler\",\n    \"target\": \"ES2022\",\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true,\n    \"baseUrl\": \".\",\n    \"noImplicitReturns\": false,\n    \"noEmit\": true,\n    \"skipLibCheck\": true,\n    \"types\": [\n      \"vite/client\",\n      \"vite-plugin-pwa/client\",\n      \"@follow/types/global\",\n      \"@follow/types/react\"\n    ],\n    \"paths\": {\n      \"~/*\": [\"src/*\"],\n      \"@pkg\": [\"../../package.json\"],\n      \"@locales/*\": [\"../../../../locales/*\"]\n    }\n  },\n  \"references\": [{ \"path\": \"../main\" }]\n}\n"
  },
  {
    "path": "apps/desktop/layer/renderer/vitest.config.ts",
    "content": "import { readFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { resolve } from \"pathe\"\nimport tsconfigPath from \"vite-tsconfig-paths\"\nimport { defineProject } from \"vitest/config\"\n\nimport { astPlugin } from \"../../plugins/vite/ast\"\n\nconst pkg = JSON.parse(readFileSync(\"package.json\", \"utf8\"))\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\n\nexport default defineProject({\n  root: \"./\",\n  test: {\n    globals: true,\n    setupFiles: [resolve(__dirname, \"./setup-file.ts\")],\n    environment: \"happy-dom\",\n    alias: {\n      \"@pkg\": resolve(__dirname, \"./package.json\"),\n      \"@locales\": resolve(__dirname, \"../../../../locales\"),\n    },\n  },\n\n  define: {\n    APP_VERSION: JSON.stringify(pkg.version),\n    APP_NAME: JSON.stringify(pkg.name),\n    APP_DEV_CWD: JSON.stringify(process.cwd()),\n\n    GIT_COMMIT_SHA: \"'SHA'\",\n    DEBUG: process.env.DEBUG === \"true\",\n    ELECTRON: \"false\",\n  },\n\n  plugins: [\n    astPlugin,\n    tsconfigPath({\n      projects: [\"./tsconfig.json\"],\n    }),\n  ],\n})\n"
  },
  {
    "path": "apps/desktop/package.json",
    "content": "{\n  \"name\": \"Folo\",\n  \"type\": \"module\",\n  \"version\": \"1.4.0\",\n  \"private\": true,\n  \"description\": \"Follow everything in one place\",\n  \"author\": \"Folo Team\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/Folo\",\n    \"type\": \"git\"\n  },\n  \"main\": \"./dist/main/index.js\",\n  \"scripts\": {\n    \"analyze:web\": \"cross-env analyzer=1 WEB_BUILD=1 vite build\",\n    \"build:electron\": \"pnpm run build:electron-vite && electron-forge make\",\n    \"build:electron-forge\": \"electron-forge make\",\n    \"build:electron-forge:macos\": \"electron-forge make --arch=x64 --platform=darwin && electron-forge make --arch=arm64 --platform=darwin && tsx scripts/merge-yml.ts\",\n    \"build:electron-forge:mas\": \"electron-forge make --arch=universal --platform=mas\",\n    \"build:electron-forge:ms\": \"tsx scripts/generate-appx-manifest.ts && electron-forge make --platform=win32 --ms=true\",\n    \"build:electron-vite\": \"electron-vite build\",\n    \"build:render\": \"vite build -c vite.config.electron-render.ts\",\n    \"build:web\": \"rm -rf out/web && cross-env WEB_BUILD=1 vite build\",\n    \"bump\": \"vv --minor\",\n    \"dedupe:locales\": \"eslint --fix locales/**/*.json\",\n    \"depcheck\": \"npx depcheck --quiet\",\n    \"dev\": \"turbo run @follow/web#dev @follow/ssr#dev\",\n    \"dev:debug\": \"export DEBUG=true && vite --debug\",\n    \"dev:electron\": \"electron-vite dev\",\n    \"dev:server\": \"pnpm run --filter=ssr dev\",\n    \"dev:web\": \"cross-env WEB_BUILD=1 vite\",\n    \"e2e\": \"playwright test -c e2e/playwright.config.ts\",\n    \"e2e:electron\": \"playwright test -c e2e/playwright.config.ts --project=electron\",\n    \"e2e:electron:prod\": \"cross-env FOLO_E2E_PROFILE=prod playwright test -c e2e/playwright.config.ts --project=electron\",\n    \"e2e:install\": \"playwright install chromium\",\n    \"e2e:web\": \"playwright test -c e2e/playwright.config.ts --project=web\",\n    \"e2e:web:prod\": \"cross-env FOLO_E2E_PROFILE=prod playwright test -c e2e/playwright.config.ts --project=web\",\n    \"hotfix\": \"vv -c bump.hotfix.config.js --patch\",\n    \"publish\": \"electron-vite build && electron-forge publish\",\n    \"start\": \"electron-vite preview\",\n    \"update:main-hash\": \"tsx plugins/vite/generate-main-hash.ts\"\n  },\n  \"devDependencies\": {\n    \"@electron-forge/cli\": \"7.11.1\",\n    \"@electron-forge/maker-appx\": \"7.11.1\",\n    \"@electron-forge/maker-dmg\": \"7.11.1\",\n    \"@electron-forge/maker-pkg\": \"7.11.1\",\n    \"@electron-forge/maker-squirrel\": \"7.11.1\",\n    \"@electron-forge/maker-zip\": \"7.11.1\",\n    \"@electron-forge/plugin-fuses\": \"7.11.1\",\n    \"@electron-forge/publisher-github\": \"7.11.1\",\n    \"@electron-toolkit/tsconfig\": \"2.0.0\",\n    \"@follow/components\": \"workspace:*\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/hooks\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"@pengx17/electron-forge-maker-appimage\": \"1.2.1\",\n    \"@playwright/test\": \"1.58.2\",\n    \"@types/html-minifier-terser\": \"7.0.2\",\n    \"@types/js-yaml\": \"4.0.9\",\n    \"@vitejs/plugin-legacy\": \"7.2.1\",\n    \"@vitejs/plugin-react\": \"5.1.4\",\n    \"async-es\": \"3.2.6\",\n    \"autoprefixer\": \"10.4.24\",\n    \"bufferutil\": \"4.1.0\",\n    \"code-inspector-plugin\": \"1.4.2\",\n    \"cssnano\": \"7.1.2\",\n    \"drizzle-orm\": \"0.45.1\",\n    \"electron\": \"38.3.0\",\n    \"electron-devtools-installer\": \"4.0.0\",\n    \"electron-packager-languages\": \"0.6.0\",\n    \"electron-vite\": \"4.0.1\",\n    \"es-toolkit\": \"1.44.0\",\n    \"fake-indexeddb\": \"6.2.5\",\n    \"happy-dom\": \"20.6.1\",\n    \"html-minifier-terser\": \"7.2.0\",\n    \"js-yaml\": \"4.1.1\",\n    \"nbump\": \"2.1.8\",\n    \"pathe\": \"2.0.3\",\n    \"react\": \"19.0.0\",\n    \"react-dom\": \"19.0.0\",\n    \"tailwindcss\": \"3.4.17\",\n    \"tailwindcss-content-visibility\": \"1.0.2\",\n    \"tailwindcss-multi\": \"0.4.6\",\n    \"tar\": \"7.5.7\",\n    \"unplugin-ast\": \"0.16.0\",\n    \"vite-bundle-analyzer\": \"1.3.6\",\n    \"vite-plugin-mkcert\": \"1.17.9\",\n    \"vite-plugin-pwa\": \"1.2.0\",\n    \"vite-plugin-route-builder\": \"0.4.1\",\n    \"vite-tsconfig-paths\": \"6.1.1\"\n  },\n  \"productName\": \"Folo\",\n  \"mainHash\": \"0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57\"\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/ast.ts",
    "content": "import { isTaggedFunctionCallOf } from \"ast-kit\"\nimport type { Transformer } from \"unplugin-ast\"\nimport { RemoveWrapperFunction } from \"unplugin-ast/transformers\"\nimport AST from \"unplugin-ast/vite\"\n\n// Custom transformer for tw function that compresses template strings\nconst TwTransformer: Transformer<any> = {\n  // @ts-ignore\n  onNode: (node) => isTaggedFunctionCallOf(node, [\"tw\"]),\n  transform(node) {\n    if (node.type === \"TaggedTemplateExpression\") {\n      const { quasi } = node\n\n      // Process template literals\n      if (quasi.type === \"TemplateLiteral\") {\n        // Get the raw string content\n        const rawString = quasi.quasis[0]?.value?.raw || \"\"\n\n        // Compress the string: remove extra whitespace, newlines, and normalize spaces\n        const compressedString = rawString\n          .replaceAll(/\\s+/g, \" \") // Replace multiple whitespace with single space\n          .trim() // Remove leading and trailing whitespace\n\n        // Update the template literal\n        quasi.quasis[0].value.raw = compressedString\n        quasi.quasis[0].value.cooked = compressedString\n      }\n\n      return quasi\n    }\n    return node.arguments[0]\n  },\n}\n\nexport const astPlugin = AST({\n  transformer: [\n    TwTransformer,\n    RemoveWrapperFunction([\n      \"defineSettingPageData\",\n      \"t_\",\n      \"tShortcuts\",\n      \"tSettings\",\n      \"defineFollowCommand\",\n    ]),\n  ],\n})\n"
  },
  {
    "path": "apps/desktop/plugins/vite/cleanup.ts",
    "content": "import fs from \"node:fs\"\n\nimport path from \"pathe\"\nimport type { Plugin, ResolvedConfig } from \"vite\"\n\nexport function cleanupUnnecessaryFilesPlugin(files: string[]): Plugin {\n  let config: ResolvedConfig\n  return {\n    name: \"cleanup-unnecessary\",\n    enforce: \"post\",\n    configResolved(resolvedConfig) {\n      config = resolvedConfig\n    },\n    async generateBundle(_options) {\n      await Promise.all(\n        files.map((file) => {\n          console.info(`Deleting ${path.join(config.build.outDir, file)}`)\n          return fs.promises.unlink(path.join(config.build.outDir, file))\n        }),\n      )\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/compress.ts",
    "content": "import { execSync } from \"node:child_process\"\nimport { createHash } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\n\nimport path from \"pathe\"\nimport * as tar from \"tar\"\nimport type { Plugin } from \"vite\"\n\nimport rendererVersion from \"../../package.json\"\nimport { calculateMainHash } from \"./generate-main-hash\"\n\nasync function compressDirectory(sourceDir: string, outputFile: string) {\n  await tar.c(\n    {\n      gzip: true,\n      file: outputFile,\n      cwd: sourceDir,\n    },\n    [\"renderer\"],\n  )\n}\n\nfunction compressAndFingerprintPlugin(outDir: string): Plugin {\n  return {\n    name: \"compress-and-fingerprint\",\n    apply: \"build\",\n    async closeBundle() {\n      const outputFile = path.join(outDir, \"render-asset.tar.gz\")\n      const manifestFile = path.join(outDir, \"manifest.yml\")\n\n      console.info(\"Compressing and fingerprinting...\")\n      // Compress the entire output directory\n      await compressDirectory(outDir, outputFile)\n      console.info(\"Compressing and fingerprinting\", outDir, \"done\")\n\n      // Calculate the file hash\n      const fileBuffer = await fs.readFile(outputFile)\n      const hashSum = createHash(\"sha256\")\n      hashSum.update(fileBuffer)\n      const hex = hashSum.digest(\"hex\")\n\n      // Calculate main hash\n      const mainHash = await calculateMainHash(path.resolve(process.cwd(), \"layer/main\"))\n\n      const { version } = rendererVersion\n      // Write the manifest file\n      const manifestContent = `\nversion: ${version.startsWith(\"v\") ? version.slice(1) : version}\nhash: ${hex}\nmainHash: ${mainHash}\ncommit: ${execSync(\"git rev-parse HEAD\").toString().trim()}\nfilename: ${path.basename(outputFile)}\n`\n      console.info(\"Writing manifest file\", manifestContent)\n      await fs.writeFile(manifestFile, manifestContent.trim())\n    },\n  }\n}\n\nexport default compressAndFingerprintPlugin\n"
  },
  {
    "path": "apps/desktop/plugins/vite/deps.ts",
    "content": "import type { Plugin, UserConfig } from \"vite\"\n\nexport function createDependencyChunksPlugin(dependencies: string[][]): Plugin {\n  return {\n    name: \"dependency-chunks\",\n    config(config: UserConfig) {\n      config.build = config.build || {}\n      config.build.rollupOptions = config.build.rollupOptions || {}\n      config.build.rollupOptions.output = config.build.rollupOptions.output || {}\n\n      const { output } = config.build.rollupOptions\n      const outputConfig = Array.isArray(output) ? output[0] : output\n      outputConfig.assetFileNames = \"assets/[name].[hash:6][extname]\"\n      outputConfig.chunkFileNames = (chunkInfo) => {\n        return chunkInfo.name.startsWith(\"vendor/\") ? \"[name]-[hash].js\" : \"assets/[name]-[hash].js\"\n      }\n\n      outputConfig.manualChunks = (id: string, { getModuleInfo }) => {\n        const moduleInfo = getModuleInfo(id)\n        if (moduleInfo?.dynamicImporters?.length && moduleInfo?.importers?.length) {\n          return null\n        }\n\n        const matchedDep = dependencies.findIndex((dep) => {\n          return dep.some((d) => {\n            const pattern = `/node_modules/${d}/`\n            return id.includes(pattern) && !id.includes(`${pattern}node_modules/`)\n          })\n        })\n\n        if (matchedDep !== -1) {\n          return `vendor/${matchedDep}`\n        }\n\n        return null\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/generate-main-hash.ts",
    "content": "import { createHash } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport { fileURLToPath } from \"node:url\"\n\nimport fg from \"fast-glob\"\nimport path from \"pathe\"\n\nexport async function calculateMainHash(\n  mainDir: string,\n  additionalFiles: string[] = [],\n): Promise<string> {\n  // Get all TypeScript files in the main directory recursively\n  const files = fg.globSync(\"**/*.{ts,tsx}\", {\n    cwd: mainDir,\n    ignore: [\"node_modules/**\", \"dist/**\"],\n  })\n\n  files.sort()\n\n  const hashSum = createHash(\"sha256\")\n\n  // Read and update hash for each file\n  for (const file of files) {\n    const content = await fs.readFile(path.join(mainDir, file))\n    hashSum.update(content)\n  }\n\n  for (const file of additionalFiles) {\n    const content = await fs.readFile(file)\n    hashSum.update(content)\n  }\n\n  return hashSum.digest(\"hex\")\n}\n\nasync function main() {\n  const cwd = process.cwd()\n  const packageJsonPath = path.resolve(cwd, \"package.json\")\n  const hash = await calculateMainHash(path.resolve(cwd, \"layer/main\"), [packageJsonPath])\n\n  const packageJson = JSON.parse(await fs.readFile(packageJsonPath, \"utf-8\"))\n  packageJson.mainHash = hash\n\n  const nextPackageJson = `${JSON.stringify(packageJson, null, 2)}\\n`\n  const tempPackageJsonPath = `${packageJsonPath}.tmp`\n  await fs.writeFile(tempPackageJsonPath, nextPackageJson, \"utf-8\")\n  await fs.rename(tempPackageJsonPath, packageJsonPath)\n}\n\nconst isExecutedDirectly = process.argv[1]\n  ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)\n  : false\n\nif (isExecutedDirectly) {\n  void main().catch((error) => {\n    console.error(error)\n    process.exitCode = 1\n  })\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/hmr.ts",
    "content": "import { red, yellow } from \"kolorist\"\nimport path from \"pathe\"\nimport type { HmrContext, Plugin } from \"vite\"\n\nfunction isNodeWithinCircularImports(\n  node: any,\n  nodeChain: any[],\n  currentChain: any[] = [node],\n  traversedModules = new Set<any>(),\n): string[] | null {\n  if (traversedModules.has(node)) {\n    return null\n  }\n  traversedModules.add(node)\n\n  for (const importer of node.importers) {\n    if (importer === node) continue\n\n    const importerIndex = nodeChain.indexOf(importer)\n    if (importerIndex !== -1) {\n      const importChain = [\n        importer,\n        ...[...currentChain].reverse(),\n        ...nodeChain.slice(importerIndex, -1).reverse(),\n      ].map((m) => path.relative(process.cwd(), m.file))\n\n      return importChain\n    }\n\n    if (!currentChain.includes(importer)) {\n      const result = isNodeWithinCircularImports(\n        importer,\n        nodeChain,\n        currentChain.concat(importer),\n        traversedModules,\n      )\n      if (result) return result\n    }\n  }\n  return null\n}\n\nexport const circularImportRefreshPlugin = (): Plugin => ({\n  name: \"circular-import-refresh\",\n  configureServer(server) {\n    server.ws.on(\"message\", (message) => {\n      console.info(message)\n    })\n  },\n  handleHotUpdate({ file, server }: HmrContext) {\n    const mod = server.moduleGraph.getModuleById(file)\n\n    // Check for circular imports\n    if (mod) {\n      const circularPaths = isNodeWithinCircularImports(mod, [mod])\n      if (circularPaths) {\n        console.warn(yellow(`Circular imports detected: \\n${circularPaths.join(\"\\n↳  \")}`))\n\n        // Check if any path in the circular dependency contains 'store/'\n        const hasStoreFile = circularPaths.some((path) => path.includes(\"store/\"))\n\n        if (hasStoreFile) {\n          console.error(\n            red(\n              `Circular dependency detected in ${file} involving store files. Performing full page refresh.`,\n            ),\n          )\n          server.ws.send({ type: \"full-reload\" })\n          return []\n        } else {\n          console.warn(\n            yellow(\n              `Circular dependency detected. HMR might not work correctly, if page has some un-expected behavior please refresh the page manually.`,\n            ),\n          )\n        }\n      }\n    }\n\n    if (file.startsWith(path.resolve(process.cwd(), \"src/store\")) && file.endsWith(\".ts\")) {\n      console.warn(yellow(`[memory-hmr] Detected change in store file: ${file}. Reloading page.`))\n      server.ws.send({ type: \"full-reload\" })\n      return []\n    }\n  },\n})\n"
  },
  {
    "path": "apps/desktop/plugins/vite/html-inject.ts",
    "content": "import type { env as EnvType } from \"@follow/shared/env.desktop\"\nimport type { PluginOption } from \"vite\"\n\nexport function htmlInjectPlugin(env: typeof EnvType): PluginOption {\n  return {\n    name: \"html-transform\",\n    enforce: \"post\",\n    transformIndexHtml(html) {\n      return html.replace(\n        \"<!-- FOLLOW VITE BUILD INJECT -->\",\n        `<script id=\"env_injection\" type=\"module\">\n      ${function injectEnv(env: any) {\n        for (const key in env) {\n          if (env[key] === undefined) continue\n          globalThis[\"__followEnv\"] ??= {}\n          globalThis[\"__followEnv\"][key] = env[key]\n        }\n      }.toString()}\n      injectEnv(${JSON.stringify({\n        VITE_API_URL: env.VITE_API_URL,\n        VITE_WEB_URL: env.VITE_WEB_URL,\n      })})\n      </script>`,\n      )\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/i18n-hmr.ts",
    "content": "import { readFileSync } from \"node:fs\"\n\nimport type { Plugin } from \"vite\"\n\nexport function customI18nHmrPlugin(): Plugin {\n  return {\n    name: \"custom-i18n-hmr\",\n    handleHotUpdate({ file, server }) {\n      if (file.endsWith(\".json\") && file.includes(\"locales\")) {\n        server.ws.send({\n          type: \"custom\",\n          event: \"i18n-update\",\n          data: {\n            file,\n            content: readFileSync(file, \"utf-8\"),\n          },\n        })\n\n        // return empty array to prevent the default HMR\n        return []\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/locales-json.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport { set } from \"es-toolkit/compat\"\nimport path from \"pathe\"\nimport type { Logger, Plugin } from \"vite\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\nconst localesDir = path.resolve(__dirname, \"../../../../locales\")\n\nexport function localesJsonPlugin(): Plugin {\n  let logger: Logger\n  return {\n    name: \"locales-json-transform\",\n    enforce: \"pre\",\n    configResolved(config) {\n      logger = config.logger\n    },\n    async transform(code, id) {\n      if (!id.includes(localesDir) || !id.endsWith(\".json\")) {\n        return null\n      }\n\n      const content = JSON.parse(code)\n      const obj = {}\n\n      const keys = Object.keys(content as object)\n      for (const accessorKey of keys) {\n        set(obj, accessorKey, (content as any)[accessorKey])\n      }\n\n      logger.info(`[locales-json-transform] Transformed: ${id}`)\n      return {\n        code: JSON.stringify(obj),\n        map: null,\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/locales.ts",
    "content": "import fs from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { set } from \"es-toolkit/compat\"\nimport path, { dirname } from \"pathe\"\nimport type { Plugin } from \"vite\"\n\nexport function localesPlugin(): Plugin {\n  return {\n    name: \"locales-merge\",\n    enforce: \"post\",\n    generateBundle(_options, bundle) {\n      const __dirname = dirname(fileURLToPath(import.meta.url))\n\n      const localesDir = path.resolve(__dirname, \"../../../../locales\")\n\n      const namespaces = fs.readdirSync(localesDir).filter((dir) => dir !== \".DS_Store\")\n      const languageResources = {} as any\n\n      namespaces.forEach((namespace) => {\n        const namespacePath = path.join(localesDir, namespace)\n        const files = fs.readdirSync(namespacePath).filter((file) => file.endsWith(\".json\"))\n\n        files.forEach((file) => {\n          const lang = path.basename(file, \".json\")\n          const filePath = path.join(namespacePath, file)\n          const content = JSON.parse(fs.readFileSync(filePath, \"utf-8\"))\n\n          if (!languageResources[lang]) {\n            languageResources[lang] = {}\n          }\n\n          const obj = {}\n\n          const keys = Object.keys(content as object)\n          for (const accessorKey of keys) {\n            set(obj, accessorKey, (content as any)[accessorKey])\n          }\n\n          languageResources[lang][namespace] = obj\n        })\n      })\n\n      Object.entries(languageResources).forEach(([lang, resources]) => {\n        const fileName = `locales/${lang}.js`\n\n        const content = `export default ${JSON.stringify(resources)};`\n\n        this.emitFile({\n          type: \"asset\",\n          fileName,\n          source: content,\n        })\n      })\n\n      // Remove original JSON chunks\n      Object.keys(bundle).forEach((key) => {\n        if (key.startsWith(\"locales/\") && key.endsWith(\".json\")) {\n          delete bundle[key]\n        }\n      })\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/manifest.ts",
    "content": "import fs from \"node:fs\"\n\nimport path from \"pathe\"\nimport type { Plugin } from \"vite\"\n\nexport default function manifestPlugin(): Plugin {\n  let config\n\n  return {\n    name: \"manifest\",\n    enforce: \"post\",\n    configResolved(resolvedConfig) {\n      config = resolvedConfig\n    },\n    generateBundle(_options, bundle) {\n      const outputDir = config.build.outDir\n      const assetsDir = path.join(outputDir, \"assets\")\n      const outputPath = path.join(assetsDir, \"manifest.txt\")\n\n      if (!fs.existsSync(assetsDir)) {\n        fs.mkdirSync(assetsDir, { recursive: true })\n      }\n\n      const fileStream = fs.createWriteStream(outputPath)\n\n      for (const fileName in bundle) {\n        fileStream.write(`${fileName}\\n`)\n      }\n\n      fileStream.end()\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/specific-import.ts",
    "content": "import type { Plugin } from \"vite\"\n\ntype Platform = \"electron\" | \"web\"\nexport function createPlatformSpecificImportPlugin(platform: Platform): Plugin {\n  return {\n    name: \"platform-specific-import\",\n    enforce: \"pre\",\n    async resolveId(source, importer) {\n      if (!importer) {\n        return null\n      }\n\n      const allowExts = [\".js\", \".jsx\", \".ts\", \".tsx\"]\n      const sharedExts = [\".desktop\", \".desktop.ts\", \".desktop.tsx\", \".desktop.js\", \".desktop.jsx\"]\n\n      if (!allowExts.some((ext) => importer.endsWith(ext))) return null\n\n      if (importer.includes(\"node_modules\")) return null\n      const [path, query] = source.split(\"?\")\n\n      if (path.startsWith(\".\") || path.startsWith(\"/\") || path.startsWith(\"@follow/\")) {\n        let priorities: string[] = []\n        switch (platform) {\n          case \"electron\": {\n            priorities = [\n              \".electron\",\n              \".electron.ts\",\n              \".electron.tsx\",\n              \".electron.js\",\n              \".electron.jsx\",\n              ...sharedExts,\n              ...allowExts,\n            ]\n\n            break\n          }\n          case \"web\": {\n            priorities = [\n              \".web\",\n              \".web.ts\",\n              \".web.tsx\",\n              \".web.js\",\n              \".web.jsx\",\n              ...sharedExts,\n              ...allowExts,\n            ]\n\n            break\n          }\n\n          // No default\n        }\n\n        for (const ext of priorities) {\n          try {\n            const resolvedPath = await this.resolve(\n              `${path}${ext}${query ? `?${query}` : \"\"}`,\n              importer,\n              {\n                skipSelf: true,\n              },\n            )\n\n            if (resolvedPath) {\n              return resolvedPath.id\n            }\n          } catch {\n            /* empty */\n          }\n        }\n      }\n\n      return null\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/plugins/vite/utils/i18n-completeness.ts",
    "content": "import fs from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport path, { dirname } from \"pathe\"\n\ntype LanguageCompletion = Record<string, number>\n\nfunction getLanguageFiles(dir: string): string[] {\n  return fs.readdirSync(dir).filter((file) => file.endsWith(\".json\"))\n}\n\nfunction getNamespaces(localesDir: string): string[] {\n  return fs\n    .readdirSync(localesDir)\n    .filter((file) => fs.statSync(path.join(localesDir, file)).isDirectory())\n}\n\nfunction countKeys(obj: any): number {\n  let count = 0\n  for (const key in obj) {\n    if (typeof obj[key] === \"object\") {\n      count += countKeys(obj[key])\n    } else {\n      count++\n    }\n  }\n  return count\n}\n\nfunction calculateCompleteness(localesDir: string): LanguageCompletion {\n  const namespaces = getNamespaces(localesDir)\n  const languages = new Set<string>()\n  const keyCount: Record<string, number> = {}\n\n  namespaces.forEach((namespace) => {\n    const namespaceDir = path.join(localesDir, namespace)\n    const files = getLanguageFiles(namespaceDir)\n\n    files.forEach((file) => {\n      const lang = path.basename(file, \".json\")\n      languages.add(lang)\n\n      const content = JSON.parse(fs.readFileSync(path.join(namespaceDir, file), \"utf-8\"))\n      keyCount[lang] = (keyCount[lang] || 0) + countKeys(content)\n    })\n  })\n\n  const enCount = keyCount[\"en\"] || 0\n  const completeness: LanguageCompletion = {}\n\n  languages.forEach((lang) => {\n    if (lang !== \"en\") {\n      const percent = Math.floor((keyCount[lang]! / enCount) * 100)\n      completeness[lang] = percent\n    }\n  })\n\n  return completeness\n}\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\nconst i18n = calculateCompleteness(path.resolve(__dirname, \"../../../../../locales\"))\nexport default i18n\n"
  },
  {
    "path": "apps/desktop/postcss.config.cjs",
    "content": "const isWebBuild = !!process.env.WEB_BUILD || !!process.env.VERCEL\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: {},\n    \"tailwindcss/nesting\": {},\n\n    ...(isWebBuild ? { autoprefixer: {} } : {}),\n    ...(process.env.NODE_ENV === \"production\" ? { cssnano: {} } : {}),\n  },\n}\n"
  },
  {
    "path": "apps/desktop/resources/app-update.yml",
    "content": "owner: RSSNext\nrepo: follow\nprovider: custom\nprivate: false\n"
  },
  {
    "path": "apps/desktop/scripts/apply-changelog.ts",
    "content": "import { copyFileSync, readFileSync, renameSync, writeFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { dirname, join } from \"pathe\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst changelogDir = join(__dirname, \"..\", \"changelog\")\n\nconst nextFile = join(changelogDir, \"next.md\")\n\nconst new_version = process.argv[2]\n\nconst majorMinorPatch = new_version.split(\"-\")[0]\n\nconst nextContent = readFileSync(nextFile, \"utf-8\")\nwriteFileSync(nextFile, nextContent.replaceAll(\"NEXT_VERSION\", majorMinorPatch))\n\n// Rename the next.md to the new version\nrenameSync(nextFile, join(changelogDir, `${majorMinorPatch}.md`))\n// Replace the NEXT_VERSION in the next.md file with the new version\n\n// Create the new next.md file\n\ncopyFileSync(join(changelogDir, \"next.template.md\"), join(changelogDir, \"next.md\"))\n"
  },
  {
    "path": "apps/desktop/scripts/generate-appx-manifest.ts",
    "content": "#!/usr/bin/env tsx\n\nimport fs from \"node:fs\"\n\nimport path from \"pathe\"\n\ninterface AppXManifestConfig {\n  packageName: string\n  packageDisplayName: string\n  publisherDisplayName: string\n  identityName: string\n  version: string\n  publisher: string\n  packageBackgroundColor: string\n  protocols: string[]\n  description: string\n  appBundleId: string\n}\n\nfunction generateAppXManifest(config: AppXManifestConfig, templatePath: string): string {\n  // Read template file\n  const template = fs.readFileSync(templatePath, \"utf-8\")\n\n  // Generate protocol extensions\n  const protocolExtensions = config.protocols\n    .map(\n      (protocol) => `\n        <uap:Extension Category=\"windows.protocol\">\n          <uap:Protocol Name=\"${protocol}\" />\n        </uap:Extension>`,\n    )\n    .join(\"\")\n\n  // Replace template variables\n  const manifest = template\n    .replaceAll(\"{identityName}\", config.identityName)\n    .replaceAll(\"{publisherName}\", config.publisher)\n    .replaceAll(\"{packageVersion}\", config.version)\n    .replaceAll(\"{packageDisplayName}\", config.packageDisplayName)\n    .replaceAll(\"{publisherDisplayName}\", config.publisherDisplayName)\n    .replaceAll(\"{packageName}\", config.packageName)\n    .replaceAll(\"{packageExecutable}\", `app\\\\${config.packageName}.exe`)\n    .replaceAll(\"{packageBackgroundColor}\", config.packageBackgroundColor)\n    .replaceAll(\"{packageDescription}\", config.description)\n    .replaceAll(\"{protocol}\", protocolExtensions)\n\n  return manifest\n}\n\nasync function main() {\n  try {\n    // Read package.json to get app information\n    const packageJsonPath = path.resolve(process.cwd(), \"package.json\")\n    const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, \"utf-8\"))\n\n    // Get mode from command line arguments\n    const mode = process.argv.find((arg) => arg.startsWith(\"--mode\"))?.split(\"=\")[1]\n    const isStaging = mode === \"staging\"\n\n    // Parse version for AppX format (must be x.x.x.x)\n    const { version } = packageJson\n    const versionParts = version.split(\".\")\n    // Ensure we have 4 parts for AppX version format\n    while (versionParts.length < 4) {\n      versionParts.push(\"0\")\n    }\n    const appxVersion = versionParts.slice(0, 4).join(\".\")\n\n    const config: AppXManifestConfig = {\n      packageName: \"Folo\",\n      packageDisplayName: isStaging\n        ? \"Folo Staging - Follow everything in one place\"\n        : \"Folo - Follow everything in one place\",\n      publisherDisplayName: \"Natural Selection Labs\",\n      identityName: \"NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo\",\n      version: appxVersion,\n      publisher: \"CN=7CBBEB6A-9B0E-4387-BAE3-576D0ACA279E\",\n      packageBackgroundColor: \"#FF5C00\",\n      protocols: [\"folo\", \"follow\"],\n      description: \"Follow everything in one place.\",\n      appBundleId: \"is.follow\",\n    }\n\n    // Template file path\n    const templatePath = path.resolve(process.cwd(), \"build/appxmanifest-template.xml\")\n\n    if (!fs.existsSync(templatePath)) {\n      throw new Error(`Template file not found: ${templatePath}`)\n    }\n\n    const manifest = generateAppXManifest(config, templatePath)\n\n    // Ensure output directory exists\n    const outputDir = path.resolve(process.cwd(), \"build\")\n    if (!fs.existsSync(outputDir)) {\n      fs.mkdirSync(outputDir, { recursive: true })\n    }\n\n    // Write manifest file\n    const outputPath = path.resolve(outputDir, \"appxmanifest.xml\")\n    fs.writeFileSync(outputPath, manifest, \"utf-8\")\n  } catch (error) {\n    console.error(\"❌ Failed to generate AppX manifest:\", error)\n    process.exit(1)\n  }\n}\n\nmain()\n\nexport { type AppXManifestConfig, generateAppXManifest }\n"
  },
  {
    "path": "apps/desktop/scripts/merge-yml.ts",
    "content": "import fs from \"node:fs\"\n\nimport yaml from \"js-yaml\"\nimport path from \"pathe\"\n\nconst outDir = \"./out/make\"\n\nfunction findYmlFiles(dir: string): string[] {\n  let results: string[] = []\n  const items = fs.readdirSync(dir)\n\n  items.forEach((item) => {\n    const fullPath = path.join(dir, item)\n    if (fs.statSync(fullPath).isDirectory()) {\n      results = results.concat(findYmlFiles(fullPath))\n    } else if (item.endsWith(\".yml\")) {\n      results.push(fullPath)\n    }\n  })\n\n  return results\n}\n\nconst ymlFiles = findYmlFiles(outDir)\n\nlet mergedContent = {\n  version: \"\",\n  files: [],\n  releaseDate: \"\",\n}\n\nymlFiles.forEach((file) => {\n  const fileContent = fs.readFileSync(file, \"utf8\")\n  const ymlData = yaml.load(fileContent)\n\n  if (!mergedContent.version) {\n    mergedContent.version = ymlData.version\n  }\n\n  mergedContent = {\n    version: ymlData.version,\n    files: mergedContent.files.concat(ymlData.files),\n    releaseDate: ymlData.releaseDate,\n  }\n})\n\nconst mergedYml = yaml.dump(mergedContent, {\n  lineWidth: -1,\n})\nfs.mkdirSync(path.join(outDir, \"merged\"), { recursive: true })\nconst mergedFilePath = path.join(outDir, \"merged\", path.basename(ymlFiles[0]))\nfs.writeFileSync(mergedFilePath, mergedYml)\n\nymlFiles.forEach((file) => {\n  fs.unlinkSync(file)\n})\n"
  },
  {
    "path": "apps/desktop/scripts/update-windows-yml.ts",
    "content": "import crypto from \"node:crypto\"\nimport fs from \"node:fs\"\nimport { fileURLToPath, resolve } from \"node:url\"\n\nimport yaml from \"js-yaml\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\nconst basePath = resolve(__dirname, \"../out/make/squirrel.windows/x64/\")\nconst ymlPath = resolve(basePath, \"./latest.yml\")\n\nconst yml = yaml.load(fs.readFileSync(ymlPath, \"utf8\")) as {\n  version?: string\n  files: {\n    url: string\n    sha512: string\n    size: number\n  }[]\n  releaseDate?: string\n}\n\nconst file = yml.files[0].url\n\nconst fileData = fs.readFileSync(resolve(basePath, file))\nconst hash = crypto.createHash(\"sha512\").update(fileData).digest(\"base64\")\nconst { size } = fs.statSync(resolve(basePath, file))\n\nyml.files[0].sha512 = hash\nyml.files[0].size = size\n\nyml.releaseDate = new Date().toISOString()\n\nconst ymlStr = yaml.dump(yml, {\n  lineWidth: -1,\n})\nfs.writeFileSync(ymlPath, ymlStr)\n"
  },
  {
    "path": "apps/desktop/tailwind.config.ts",
    "content": "import { extendConfig } from \"@follow/configs/tailwindcss/web\"\nimport plugin from \"tailwindcss/plugin\"\n\nexport default extendConfig({\n  content: [\n    \"./layer/renderer/src/**/*.{ts,tsx}\",\n    \"./apps/web/src/**/*.{ts,tsx}\",\n\n    \"./layer/renderer/index.html\",\n    \"./apps/web/index.html\",\n    \"../../packages/**/*.{ts,tsx}\",\n    \"!../../packages/**/node_modules\",\n  ],\n\n  safelist: [\n    \"line-clamp-[1]\",\n    \"line-clamp-[2]\",\n    \"line-clamp-[3]\",\n    \"line-clamp-[4]\",\n    \"line-clamp-[5]\",\n    \"line-clamp-[6]\",\n    \"line-clamp-[7]\",\n    \"line-clamp-[8]\",\n  ],\n  theme: {\n    extend: {\n      cursor: {\n        button: \"var(--cursor-button)\",\n        select: \"var(--cursor-select)\",\n        checkbox: \"var(--cursor-checkbox)\",\n        link: \"var(--cursor-link)\",\n        menu: \"var(--cursor-menu)\",\n        radio: \"var(--cursor-radio)\",\n        switch: \"var(--cursor-switch)\",\n        card: \"var(--cursor-card)\",\n      },\n\n      width: {\n        \"feed-col\": \"var(--fo-feed-col-w)\",\n      },\n      spacing: {\n        \"safe-inset-top\": \"var(--fo-window-padding-top, 0)\",\n        \"margin-macos-traffic-light-x\": \"var(--fo-macos-traffic-light-width, 0)\",\n        \"margin-macos-traffic-light-y\": \"var(--fo-macos-traffic-light-height, 0)\",\n      },\n\n      height: {\n        screen: \"100svh\",\n        // button height 2rem (size-8) + sidebar padding top 0.625rem (pt-2.5) x 2\n        // 2 + 0.625 * 2 = 3.25\n        \"top-header\": \"3.25rem\",\n        \"top-header-with-border-b\": \"calc(3.25rem + 1px)\",\n        \"top-header-in-preview-with-border-b\": \"calc(3.25rem + 41px)\",\n      },\n      colors: {\n        sidebar: \"hsl(var(--fo-sidebar) / <alpha-value>)\",\n      },\n\n      keyframes: {\n        \"caret-blink\": {\n          \"0%,70%,100%\": { opacity: \"1\" },\n          \"20%,50%\": { opacity: \"0\" },\n        },\n        glow: {\n          \"0%, 100%\": { opacity: \"0.5\" },\n          \"50%\": { opacity: \"0.7\" },\n        },\n        \"accordion-down\": {\n          from: { height: \"0\" },\n          to: { height: \"var(--radix-accordion-content-height)\" },\n        },\n        \"accordion-up\": {\n          from: { height: \"var(--radix-accordion-content-height)\" },\n          to: { height: \"0\" },\n        },\n        \"gradient-x\": {\n          \"0%, 100%\": {\n            backgroundPosition: \"0% 50%\",\n          },\n          \"50%\": {\n            backgroundPosition: \"100% 50%\",\n          },\n        },\n        shimmer: {\n          \"0%\": {\n            backgroundPosition: \"200% 0\",\n          },\n          \"100%\": {\n            backgroundPosition: \"-200% 0\",\n          },\n        },\n      },\n      animation: {\n        \"caret-blink\": \"caret-blink 1.25s ease-out infinite\",\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n        \"gradient-x\": \"gradient-x 3s linear infinite\",\n        glow: \"glow 1.5s ease-in-out infinite\",\n        shimmer: \"shimmer 2s linear infinite\",\n      },\n    },\n  },\n  plugins: [\n    plugin(({ addVariant }) => {\n      addVariant(\"f-motion-reduce\", '[data-motion-reduce=\"true\"] &')\n      addVariant(\"group-motion-reduce\", ':merge(.group)[data-motion-reduce=\"true\"] &')\n      addVariant(\"peer-motion-reduce\", ':merge(.peer)[data-motion-reduce=\"true\"] ~ &')\n\n      addVariant(\"left-column-hidden\", \"html[data-left-column-hidden='true'] &\")\n      addVariant(\n        \"macos-left-column-hidden\",\n        \"html[data-os='macOS'][data-left-column-hidden='true'] &\",\n      )\n\n      addVariant(\"macos\", \"html[data-os='macOS'] &\")\n      addVariant(\"windows\", \"html[data-os='Windows'] &\")\n    }),\n    require(\"tailwindcss-multi\"),\n    require(\"tailwindcss-content-visibility\"),\n    plugin(({ addUtilities, matchUtilities, theme }) => {\n      addUtilities({\n        \".safe-inset-top\": {\n          top: \"var(--fo-window-padding-top, 0)\",\n        },\n      })\n\n      const safeInsetTopVariants = {}\n      for (let i = 1; i <= 16; i++) {\n        safeInsetTopVariants[`.safe-inset-top-${i}`] = {\n          top: `calc(var(--fo-window-padding-top, 0px) + ${theme(`spacing.${i}`)})`,\n        }\n      }\n      addUtilities(safeInsetTopVariants)\n\n      // left macos traffic light\n      const leftMacosTrafficLightVariants = {}\n      addUtilities({\n        \".left-macos-traffic-light\": {\n          left: \"var(--fo-macos-traffic-light-width, 0)\",\n        },\n      })\n\n      for (let i = 1; i <= 16; i++) {\n        leftMacosTrafficLightVariants[`.left-macos-traffic-light-${i}`] = {\n          left: `calc(var(--fo-macos-traffic-light-width, 0px) + ${theme(`spacing.${i}`)})`,\n        }\n      }\n      addUtilities(leftMacosTrafficLightVariants)\n\n      // Add arbitrary value support\n      matchUtilities(\n        {\n          \"safe-inset-top\": (value) => ({\n            top: `calc(var(--fo-window-padding-top, 0px) + ${value})`,\n          }),\n        },\n        { values: theme(\"spacing\") },\n      )\n    }),\n  ],\n})\n"
  },
  {
    "path": "apps/desktop/vite.config.electron-render.ts",
    "content": "import { resolve } from \"pathe\"\nimport { defineConfig } from \"vite\"\n\nimport config from \"./configs/vite.electron-render.config\"\nimport compressAndFingerprintPlugin from \"./plugins/vite/compress\"\n\nexport default defineConfig({\n  ...config,\n  base: \"./\",\n  plugins: [...config.plugins, compressAndFingerprintPlugin(resolve(import.meta.dirname, \"dist\"))],\n})\n"
  },
  {
    "path": "apps/desktop/vite.config.ts",
    "content": "import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport type { env as EnvType } from \"@follow/shared/env.desktop\"\nimport legacy from \"@vitejs/plugin-legacy\"\nimport { minify as htmlMinify } from \"html-minifier-terser\"\nimport { cyan, dim, green } from \"kolorist\"\nimport { parseHTML } from \"linkedom/worker\"\nimport { join, resolve } from \"pathe\"\nimport type { PluginOption, ResolvedConfig, ViteDevServer } from \"vite\"\nimport { defineConfig, loadEnv } from \"vite\"\nimport { analyzer } from \"vite-bundle-analyzer\"\nimport mkcert from \"vite-plugin-mkcert\"\nimport { VitePWA } from \"vite-plugin-pwa\"\nimport { routeBuilderPlugin } from \"vite-plugin-route-builder\"\n\nimport { viteRenderBaseConfig } from \"./configs/vite.render.config\"\nimport { createDependencyChunksPlugin } from \"./plugins/vite/deps\"\nimport { htmlInjectPlugin } from \"./plugins/vite/html-inject\"\nimport { localesPlugin } from \"./plugins/vite/locales\"\nimport manifestPlugin from \"./plugins/vite/manifest\"\nimport { createPlatformSpecificImportPlugin } from \"./plugins/vite/specific-import\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\nconst isCI = process.env.CI === \"true\" || process.env.CI === \"1\"\nconst ROOT = resolve(__dirname, \"./layer/renderer\")\n\nconst devPrint = (): PluginOption => ({\n  name: \"dev-print\",\n  configureServer(server: ViteDevServer) {\n    const _printUrls = server.printUrls\n    server.printUrls = () => {\n      _printUrls()\n      console.info(\n        `  ${green(\"➜\")}  ${dim(\"Production debug\")}: ${cyan(\"https://app.folo.is/__debug_proxy.html\")}`,\n      )\n      console.info(\n        `  ${green(\"➜\")}  ${dim(\"Development debug\")}: ${cyan(\n          \"https://dev.folo.is/__debug_proxy.html\",\n        )}`,\n      )\n    }\n  },\n})\n\nconst isWebBuild = process.env.WEB_BUILD === \"1\"\n// eslint-disable-next-line no-console\nconsole.log(green(\"Build type:\"), isWebBuild ? \"Web\" : \"Unknown\")\n\nconst proxyConfig = {\n  target: \"http://localhost:2234\",\n  changeOrigin: true,\n  selfHandleResponse: true,\n  configure: (proxy, _options) => {\n    proxy.on(\"proxyRes\", (proxyRes, req, res) => {\n      const body = [] as any[]\n      proxyRes.on(\"data\", (chunk: any) => body.push(chunk))\n      proxyRes.on(\"end\", () => {\n        const html = parseHTML(Buffer.concat(body).toString())\n        const doc = html.document\n\n        const $scripts = doc.querySelectorAll(\"script\")\n        $scripts.forEach((script) => {\n          const src = script.getAttribute(\"src\")\n          if (src) {\n            script.setAttribute(\"src\", `http://localhost:2234${src}`)\n          }\n        })\n\n        const $links = doc.querySelectorAll(\"link\")\n        $links.forEach((link) => {\n          const href = link.getAttribute(\"href\")\n          if (href) {\n            link.setAttribute(\"href\", `http://localhost:2234${href}`)\n          }\n        })\n\n        res.setHeader(\"Content-Type\", \"text/html; charset=utf-8\")\n\n        const modifiedHtml = doc.toString()\n        res.end(modifiedHtml)\n      })\n    })\n  },\n}\n\nexport default ({ mode }) => {\n  const env = loadEnv(mode, process.cwd())\n  const typedEnv = env as typeof EnvType\n\n  return defineConfig({\n    ...viteRenderBaseConfig,\n    root: ROOT,\n    base: \"/\",\n    envDir: resolve(__dirname, \".\"),\n    build: {\n      outDir: resolve(__dirname, \"out/web\"),\n      target: \"ES2022\",\n      sourcemap: isCI,\n      rollupOptions: {\n        input: {\n          main: resolve(ROOT, \"/index.html\"),\n        },\n      },\n    },\n\n    server: {\n      host: true,\n      port: 2233,\n      watch: {\n        ignored: [\"**/dist/**\", \"**/out/**\", \"**/public/**\", \".git/**\"],\n      },\n      cors: true,\n      headers: {\n        \"Access-Control-Allow-Origin\": \"*\",\n        \"Access-Control-Allow-Methods\": \"*\",\n        \"Access-Control-Allow-Headers\": \"*\",\n        \"Access-Control-Allow-Private-Network\": \"true\",\n      },\n      proxy: {\n        \"/login\": proxyConfig,\n        \"/forget-password\": proxyConfig,\n        \"/reset-password\": proxyConfig,\n        \"/register\": proxyConfig,\n        \"/share\": proxyConfig,\n\n        ...(env.VITE_DEV_PROXY\n          ? {\n              [env.VITE_DEV_PROXY]: {\n                target: env.VITE_DEV_PROXY_TARGET,\n                changeOrigin: true,\n                rewrite: (path) => path.replace(new RegExp(`^${env.VITE_DEV_PROXY}`), \"\"),\n              },\n            }\n          : {}),\n      },\n    },\n    resolve: {\n      alias: {\n        ...viteRenderBaseConfig.resolve?.alias,\n        \"@follow/logger\": resolve(__dirname, \"../../packages/internal/logger/web.ts\"),\n      },\n    },\n    plugins: [\n      ...((viteRenderBaseConfig.plugins ?? []) as any),\n\n      routeBuilderPlugin({\n        pagePattern: \"src/pages/**/*.tsx\",\n        outputPath: \"src/generated-routes.ts\",\n        enableInDev: true,\n      }),\n      localesPlugin(),\n      isWebBuild &&\n        VitePWA({\n          strategies: \"injectManifest\",\n          srcDir: \"src\",\n          filename: \"sw.ts\",\n          registerType: \"prompt\",\n          injectRegister: false,\n\n          injectManifest: {\n            injectionPoint: undefined,\n            globPatterns: [\n              \"**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}\",\n            ],\n\n            manifestTransforms: [\n              (manifest) => {\n                return {\n                  manifest,\n                  warnings: [],\n                  additionalManifestEntries: [\n                    {\n                      url: \"/sw.js?pwa=true\",\n                      revision: null,\n                    },\n                  ],\n                }\n              },\n            ],\n          },\n\n          manifest: {\n            theme_color: \"#000000\",\n            name: \"Folo\",\n            display: \"standalone\",\n            background_color: \"#ffffff\",\n            icons: [\n              {\n                src: \"pwa-64x64.png\",\n                sizes: \"64x64\",\n                type: \"image/png\",\n              },\n              {\n                src: \"pwa-192x192.png\",\n                sizes: \"192x192\",\n                type: \"image/png\",\n              },\n              {\n                src: \"pwa-512x512.png\",\n                sizes: \"512x512\",\n                type: \"image/png\",\n              },\n              {\n                src: \"maskable-icon-512x512.png\",\n                sizes: \"512x512\",\n                type: \"image/png\",\n                purpose: \"maskable\",\n              },\n            ],\n          },\n\n          devOptions: {\n            enabled: false,\n            navigateFallback: \"index.html\",\n            suppressWarnings: true,\n            type: \"module\",\n          },\n        }),\n      mode !== \"development\" &&\n        legacy({\n          targets: \"defaults\",\n          renderLegacyChunks: false,\n          modernTargets: \">0.3%, last 2 versions, Firefox ESR, not dead\",\n          modernPolyfills: [\n            // https://unpkg.com/browse/core-js@3.39.0/modules/\n            \"es.promise.with-resolvers\",\n          ],\n        }),\n      htmlInjectPlugin(typedEnv),\n      process.env.SSL ? mkcert() : false,\n      devPrint(),\n      createDependencyChunksPlugin([\n        //  React framework\n        [\"react\", \"react-dom\"],\n        [\"react-error-boundary\", \"react-dom/server\", \"react-router\"],\n        // Data Statement\n        [\"zustand\", \"jotai\", \"use-context-selector\", \"immer\"],\n        // Remark\n        [\n          \"remark-directive\",\n          \"remark-gfm\",\n          \"remark-parse\",\n          \"remark-stringify\",\n          \"remark-rehype\",\n          \"@microflash/remark-callout-directives\",\n          \"remark-gh-alerts\",\n        ],\n        // Rehype\n        [\n          \"rehype-parse\",\n          \"rehype-sanitize\",\n          \"rehype-stringify\",\n          \"rehype-infer-description-meta\",\n          \"hast-util-to-jsx-runtime\",\n          \"hast-util-to-text\",\n          \"react-shadow\",\n        ],\n        [\"vfile\", \"unified\"],\n        [\"es-toolkit/compat\"],\n        [\"motion/react\"],\n        [\"clsx\", \"tailwind-merge\", \"class-variance-authority\"],\n\n        [\n          \"@radix-ui/react-dialog\",\n          \"@radix-ui/react-avatar\",\n          \"@radix-ui/react-checkbox\",\n          \"@radix-ui/react-context\",\n          \"@radix-ui/react-dropdown-menu\",\n          \"@radix-ui/react-hover-card\",\n          \"@radix-ui/react-label\",\n          \"@radix-ui/react-popover\",\n          \"@radix-ui/react-radio-group\",\n          \"@radix-ui/react-scroll-area\",\n          \"@radix-ui/react-select\",\n          \"@radix-ui/react-slider\",\n          \"@radix-ui/react-slot\",\n          \"@radix-ui/react-switch\",\n          \"@radix-ui/react-tabs\",\n          \"@radix-ui/react-toast\",\n          \"@radix-ui/react-tooltip\",\n\n          \"@headlessui/react\",\n        ],\n        [\"i18next\", \"i18next-browser-languagedetector\", \"react-i18next\"],\n        // Data query\n        [\n          \"@tanstack/react-query\",\n          \"@tanstack/react-query-persist-client\",\n          \"@tanstack/query-sync-storage-persister\",\n        ],\n        [\"tldts\"],\n        [\"zod\", \"react-hook-form\", \"@hookform/resolvers\"],\n      ]),\n\n      createPlatformSpecificImportPlugin(isWebBuild ? \"web\" : \"electron\"),\n      isWebBuild && manifestPlugin(),\n      isWebBuild && htmlPlugin(typedEnv),\n      process.env.analyzer && analyzer(),\n    ],\n\n    define: {\n      ...viteRenderBaseConfig.define,\n      ELECTRON: \"false\",\n    },\n  })\n}\n\nfunction checkBrowserSupport() {\n  if (!(\"findLastIndex\" in Array.prototype) || !(\"structuredClone\" in window)) {\n    window.alert(\n      \"Folo is not compatible with your browser because your browser version is too old. You can download and use the Folo app or continue using it with the latest browser.\",\n    )\n\n    window.location.href = \"https://folo.is/download\"\n  }\n}\n\nconst htmlPlugin: (env: any) => PluginOption = (env) => {\n  let config: ResolvedConfig\n  return {\n    name: \"html-transform\",\n    configResolved(resolvedConfig) {\n      config = resolvedConfig\n    },\n    enforce: \"post\",\n    closeBundle() {\n      const { root } = config\n      const dist = config.build.outDir\n      const debugProxyHtml = join(root, \"debug_proxy.html\")\n\n      if (existsSync(debugProxyHtml)) {\n        const content = readFileSync(debugProxyHtml, \"utf-8\")\n\n        const debugProxyContent = content.replace(\n          \"import.meta.env.VITE_API_URL\",\n          `\"${env.VITE_API_URL}\"`,\n        )\n\n        mkdirSync(dist, { recursive: true })\n        mkdirSync(join(dist, \"__debug_proxy\"), { recursive: true })\n        writeFileSync(join(dist, \"__debug_proxy.html\"), debugProxyContent)\n        writeFileSync(join(dist, \"__debug_proxy\", \"index.html\"), debugProxyContent)\n      }\n    },\n    transformIndexHtml(html) {\n      return htmlMinify(\n        html.replace(\n          \"<!-- Check Browser Script Inject -->\",\n          `<script>${checkBrowserSupport.toString()}; checkBrowserSupport()</script>`,\n        ),\n        {\n          removeComments: true,\n          html5: true,\n          minifyJS: true,\n          minifyCSS: true,\n          removeTagWhitespace: true,\n          collapseWhitespace: true,\n          collapseBooleanAttributes: true,\n          collapseInlineTagWhitespace: true,\n        },\n      )\n    },\n  }\n}\n"
  },
  {
    "path": "apps/desktop/wrangler.jsonc",
    "content": "{\n  \"$schema\": \"../../node_modules/wrangler/config-schema.json\",\n  \"name\": \"folo-web\",\n  \"compatibility_date\": \"2026-02-01\",\n  \"account_id\": \"1f1d1678a2413a54c944b3081bab5c84\",\n  \"assets\": {\n    \"directory\": \"./out/web\",\n    \"not_found_handling\": \"single-page-application\",\n  },\n  \"workers_dev\": true,\n  \"routes\": [\n    {\n      \"pattern\": \"app.folo.is/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n  ],\n  \"env\": {\n    \"dev\": {\n      \"name\": \"folo-web-dev\",\n      \"workers_dev\": true,\n      \"routes\": [\n        {\n          \"pattern\": \"dev.folo.is/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n      ],\n    },\n  },\n}\n"
  },
  {
    "path": "apps/landing/.prettierrc.mjs",
    "content": "import { factory } from '@innei/prettier'\n\nexport default factory({\n  importSort: false,\n})\n"
  },
  {
    "path": "apps/landing/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/styles/tailwind.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"~/components\",\n    \"utils\": \"~/lib/utils\",\n    \"ui\": \"~/components/ui\",\n    \"lib\": \"~/lib\",\n    \"hooks\": \"~/hooks\"\n  },\n  \"registries\": {\n    \"@animate-ui\": \"https://animate-ui.com/r/{name}.json\",\n    \"@magicui\": \"https://magicui.design/r/{name}.json\"\n  }\n}\n"
  },
  {
    "path": "apps/landing/eslint.config.mjs",
    "content": "// @ts-check\nimport { defineConfig } from 'eslint-config-hyoban'\n\nimport recursiveSort from './plugins/eslint-recursive-sort.mjs'\n\nexport default defineConfig(\n  {\n    formatting: false,\n    lessOpinionated: true,\n    ignores: ['dist/**'],\n    preferESM: false,\n  },\n  {\n    settings: {\n      tailwindcss: {\n        whitelist: ['center'],\n      },\n    },\n    rules: {\n      'tailwindcss/classnames-order': 'off',\n      'tailwindcss/no-custom-classname': 'off',\n      'unicorn/prefer-math-trunc': 'off',\n      'unicorn/expiring-todo-comments': 0,\n      '@eslint-react/no-clone-element': 0,\n      '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0,\n      // NOTE: Disable this temporarily\n      'react-compiler/react-compiler': 0,\n      'no-restricted-syntax': 0,\n      'package-json/valid-name': 0,\n\n      // disable react compiler rules for now\n      'react-hooks/no-unused-directives': 'off',\n      'react-hooks/static-components': 'off',\n      'react-hooks/use-memo': 'off',\n      'react-hooks/component-hook-factories': 'off',\n      'react-hooks/preserve-manual-memoization': 'off',\n      'react-hooks/immutability': 'off',\n      'react-hooks/globals': 'off',\n      'react-hooks/refs': 'off',\n      'react-hooks/set-state-in-effect': 'off',\n      'react-hooks/error-boundaries': 'off',\n      'react-hooks/purity': 'off',\n      'react-hooks/set-state-in-render': 'off',\n      'react-hooks/unsupported-syntax': 'off',\n      'react-hooks/config': 'off',\n      'react-hooks/gating': 'off',\n\n      'no-restricted-globals': [\n        'error',\n        {\n          name: 'location',\n          message:\n            \"Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \\n\\n\" +\n            'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.',\n        },\n      ],\n    },\n  },\n  {\n    files: ['**/*.tsx'],\n    rules: {\n      '@stylistic/jsx-self-closing-comp': 'error',\n    },\n  },\n  {\n    files: ['locales/**/*.json'],\n    plugins: {\n      'recursive-sort': recursiveSort,\n    },\n    rules: {\n      'recursive-sort/recursive-sort': 'error',\n    },\n  },\n  {\n    files: ['package.json'],\n    rules: {\n      'package-json/valid-name': 0,\n    },\n  },\n)\n"
  },
  {
    "path": "apps/landing/global.d.ts",
    "content": "/* eslint-disable @typescript-eslint/no-empty-object-type */\n/* eslint-disable @typescript-eslint/method-signature-style */\nimport type { FC, PropsWithChildren } from 'react'\n\ndeclare global {\n  export type NextErrorProps = {\n    reset(): void\n    error: Error\n  }\n  export type NextPageParams<P extends {}, Props = {}> = PropsWithChildren<\n    {\n      params: P\n    } & Props\n  >\n\n  export type Component<P = {}> = FC<ComponentType & P>\n\n  export type ComponentType<P = {}> = {\n    className?: string\n  } & PropsWithChildren &\n    P\n\n  // TODO should remove in next TypeScript version\n  interface Document {\n    startViewTransition(callback?: () => void | Promise<void>): ViewTransition\n  }\n\n  interface ViewTransition {\n    finished: Promise<void>\n    ready: Promise<void>\n    updateCallbackDone: () => void\n    skipTransition(): void\n  }\n}\n\ndeclare module 'react' {\n  export interface AriaAttributes {\n    'data-hide-print'?: boolean\n    'data-event'?: string\n    'data-testid'?: string\n  }\n}\n"
  },
  {
    "path": "apps/landing/next.config.mjs",
    "content": "import { config } from 'dotenv'\nimport createNextIntlPlugin from 'next-intl/plugin'\n\nprocess.title = 'Folo Landing (vinext)'\n\nconst env = config().parsed || {}\nconst isProd = process.env.NODE_ENV === 'production'\n\nconst withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')\n\nconst nextConfig = {\n  reactStrictMode: false,\n  productionBrowserSourceMaps: true,\n  output: 'standalone',\n\n  reactCompiler: true,\n  assetPrefix: isProd ? env.ASSETPREFIX || undefined : undefined,\n  compiler: {\n    // reactRemoveProperties: { properties: ['^data-id$', '^data-(\\\\w+)-id$'] },\n  },\n\n  images: {\n    remotePatterns: [\n      {\n        protocol: 'https',\n        hostname: '**',\n      },\n    ],\n    dangerouslyAllowSVG: true,\n    contentSecurityPolicy:\n      \"default-src 'self'; script-src 'none'; sandbox; style-src 'unsafe-inline';\",\n  },\n\n  async rewrites() {\n    return {\n      beforeFiles: [\n        { source: '/atom.xml', destination: '/feed' },\n        { source: '/sitemap.xml', destination: '/sitemap' },\n        {\n          source: '/.well-known/apple-app-site-association',\n          destination: '/apple-app-site-association',\n        },\n      ],\n    }\n  },\n}\n\nexport default withNextIntl(nextConfig)\n"
  },
  {
    "path": "apps/landing/package.json",
    "content": "{\n  \"name\": \"@follow/landing\",\n  \"type\": \"module\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"cross-env NODE_ENV=production vinext build\",\n    \"cf:build\": \"cross-env NODE_ENV=production vinext build\",\n    \"cf:deploy\": \"pnpm exec vinext deploy\",\n    \"cf:deploy:dev\": \"pnpm exec vinext deploy --preview\",\n    \"dev\": \"cross-env NODE_ENV=development vinext dev -p 4399\",\n    \"start\": \"cross-env NODE_ENV=production vinext start -p 4399\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@base-ui-components/react\": \"1.0.0-beta.4\",\n    \"@floating-ui/react-dom\": \"2.1.6\",\n    \"@radix-ui/react-accordion\": \"1.2.12\",\n    \"@splinetool/react-spline\": \"4.1.0\",\n    \"@tanstack/query-async-storage-persister\": \"5.90.7\",\n    \"@tanstack/react-query\": \"5.90.5\",\n    \"@tanstack/react-query-persist-client\": \"5.90.7\",\n    \"@types/js-cookie\": \"3.0.6\",\n    \"ai\": \"5.0.68\",\n    \"axios\": \"1.13.0\",\n    \"clsx\": \"2.1.1\",\n    \"dayjs\": \"1.11.18\",\n    \"es-toolkit\": \"1.41.0\",\n    \"foxact\": \"0.2.49\",\n    \"idb-keyval\": \"6.2.2\",\n    \"immer\": \"10.2.0\",\n    \"jojoo\": \"0.3.0\",\n    \"jotai\": \"2.15.0\",\n    \"js-cookie\": \"3.0.5\",\n    \"motion\": \"12.23.24\",\n    \"next-intl\": \"4.4.0\",\n    \"next-themes\": \"0.4.6\",\n    \"ogl\": \"1.0.11\",\n    \"progressive-blur\": \"1.0.0\",\n    \"radix-ui\": \"1.4.3\",\n    \"re-resizable\": \"6.11.2\",\n    \"react\": \"19.0.0\",\n    \"react-dom\": \"19.0.0\",\n    \"react-error-boundary\": \"6.0.0\",\n    \"react-intersection-observer\": \"9.16.0\",\n    \"react-markdown\": \"10.1.0\",\n    \"react-resizable-layout\": \"0.7.3\",\n    \"rehype-stringify\": \"10.0.1\",\n    \"remark-emoji\": \"5.0.2\",\n    \"remark-parse\": \"11.0.0\",\n    \"remark-rehype\": \"11.1.2\",\n    \"rough-notation\": \"0.5.1\",\n    \"sonner\": \"2.0.7\",\n    \"tailwind-merge\": \"3.3.1\",\n    \"unified\": \"11.0.5\",\n    \"usehooks-ts\": \"3.1.1\",\n    \"vaul\": \"1.1.2\",\n    \"vinext\": \"0.0.30\"\n  },\n  \"devDependencies\": {\n    \"@cloudflare/vite-plugin\": \"1.25.5\",\n    \"@egoist/tailwindcss-icons\": \"1.9.0\",\n    \"@iconify-json/lucide\": \"1.2.71\",\n    \"@iconify-json/mingcute\": \"1.2.5\",\n    \"@iconify-json/simple-icons\": \"1.2.56\",\n    \"@iconify/tailwind\": \"1.2.0\",\n    \"@innei/prettier\": \"1.0.0\",\n    \"@tailwindcss/postcss\": \"4.1.16\",\n    \"@tailwindcss/typography\": \"0.5.19\",\n    \"@tanstack/react-query-devtools\": \"5.90.2\",\n    \"@types/node\": \"24.9.1\",\n    \"@types/react\": \"19.1.17\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"@vitejs/plugin-rsc\": \"0.5.21\",\n    \"autoprefixer\": \"10.4.21\",\n    \"babel-plugin-react-compiler\": \"1.0.0\",\n    \"code-inspector-plugin\": \"1.2.10\",\n    \"cross-env\": \"10.1.0\",\n    \"dotenv\": \"17.2.3\",\n    \"eslint\": \"9.38.0\",\n    \"eslint-config-hyoban\": \"4.0.10\",\n    \"postcss\": \"8.5.6\",\n    \"prettier\": \"3.6.2\",\n    \"rimraf\": \"6.0.1\",\n    \"tailwind-scrollbar\": \"4.0.2\",\n    \"tailwind-variants\": \"3.1.1\",\n    \"tailwindcss\": \"4.1.16\",\n    \"tailwindcss-animate\": \"1.0.7\",\n    \"tailwindcss-safe-area\": \"1.1.0\",\n    \"typescript\": \"catalog:\",\n    \"vite\": \"7.3.1\",\n    \"wrangler\": \"4.68.1\"\n  }\n}\n"
  },
  {
    "path": "apps/landing/plugins/eslint-recursive-sort.mjs",
    "content": "const sortObjectKeys = (obj) => {\n  if (typeof obj !== 'object' || obj === null) {\n    return obj\n  }\n\n  if (Array.isArray(obj)) {\n    return obj.map((element) => sortObjectKeys(element))\n  }\n\n  return Object.keys(obj)\n    .sort()\n    .reduce((acc, key) => {\n      acc[key] = sortObjectKeys(obj[key])\n      return acc\n    }, {})\n}\n/**\n * @type {import(\"eslint\").ESLint.Plugin}\n */\nexport default {\n  rules: {\n    'recursive-sort': {\n      meta: {\n        type: 'layout',\n        fixable: 'code',\n      },\n      create(context) {\n        return {\n          Program(node) {\n            if (context.getFilename().endsWith('.json')) {\n              const sourceCode = context.getSourceCode()\n              const text = sourceCode.getText()\n\n              try {\n                const json = JSON.parse(text)\n                const sortedJson = sortObjectKeys(json)\n                const sortedText = JSON.stringify(sortedJson, null, 2)\n\n                if (text.trim() !== sortedText.trim()) {\n                  context.report({\n                    node,\n                    message: 'JSON keys are not sorted recursively',\n                    fix(fixer) {\n                      return fixer.replaceText(node, sortedText)\n                    },\n                  })\n                }\n              } catch (error) {\n                context.report({\n                  node,\n                  message: `Invalid JSON: ${error.message}`,\n                })\n              }\n            }\n          },\n        }\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "apps/landing/postcss.config.mjs",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n  },\n}\n"
  },
  {
    "path": "apps/landing/public/discover-sources.json",
    "content": "[\n  {\n    \"key\": \"xiaohongshu\",\n    \"name\": \"小红书\",\n    \"host\": \"xiaohongshu.com\",\n    \"heat\": 1413942,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"twitter\",\n    \"name\": \"X (Twitter)\",\n    \"host\": \"x.com\",\n    \"heat\": 1269331,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"picnob.info\",\n    \"name\": \"Instagram\",\n    \"host\": \"instagram.com\",\n    \"heat\": 372125,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"telegram\",\n    \"name\": \"Telegram\",\n    \"host\": \"t.me\",\n    \"heat\": 285958,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"youtube\",\n    \"name\": \"YouTube\",\n    \"host\": \"youtube.com\",\n    \"heat\": 278462,\n    \"categories\": [\"social-media\", \"popular\", \"live\"]\n  },\n  {\n    \"key\": \"bilibili\",\n    \"name\": \"哔哩哔哩 bilibili\",\n    \"host\": \"bilibili.com\",\n    \"heat\": 214649,\n    \"categories\": [\"program-update\", \"social-media\", \"popular\", \"live\"]\n  },\n  {\n    \"key\": \"weibo\",\n    \"name\": \"微博\",\n    \"host\": \"weibo.com\",\n    \"heat\": 59600,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"xiaoyuzhou\",\n    \"name\": \"小宇宙\",\n    \"host\": \"xiaoyuzhoufm.com\",\n    \"heat\": 51947,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"pixiv\",\n    \"name\": \"pixiv\",\n    \"host\": \"pixiv.net\",\n    \"heat\": 48311,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"github\",\n    \"name\": \"GitHub\",\n    \"host\": \"github.com\",\n    \"heat\": 45308,\n    \"categories\": [\"programming\", \"popular\"]\n  },\n  {\n    \"key\": \"pornhub\",\n    \"name\": \"PornHub\",\n    \"host\": \"pornhub.com\",\n    \"heat\": 39528,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"sspai\",\n    \"name\": \"少数派 sspai\",\n    \"host\": \"sspai.com\",\n    \"heat\": 34311,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"jike\",\n    \"name\": \"即刻\",\n    \"host\": \"m.okjike.com\",\n    \"heat\": 32042,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"nasa\",\n    \"name\": \"NASA\",\n    \"host\": \"apod.nasa.gov\",\n    \"heat\": 29994,\n    \"categories\": [\"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"epicgames\",\n    \"name\": \"Epic Games Store\",\n    \"host\": \"store.epicgames.com\",\n    \"heat\": 26905,\n    \"categories\": [\"game\", \"popular\"]\n  },\n  {\n    \"key\": \"zhihu\",\n    \"name\": \"知乎\",\n    \"host\": \"zhihu.com\",\n    \"heat\": 26633,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"nature\",\n    \"name\": \"Nature Journal\",\n    \"host\": \"nature.com\",\n    \"heat\": 26324,\n    \"categories\": [\"journal\", \"popular\"]\n  },\n  {\n    \"key\": \"v2ex\",\n    \"name\": \"V2EX\",\n    \"host\": \"v2ex.com\",\n    \"heat\": 25321,\n    \"categories\": [\"bbs\", \"popular\", \"blog\"]\n  },\n  {\n    \"key\": \"bsky\",\n    \"name\": \"Bluesky (bsky)\",\n    \"host\": \"bsky.app\",\n    \"heat\": 25232,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"magnumphotos\",\n    \"name\": \"Magnum Photos\",\n    \"host\": \"magnumphotos.com\",\n    \"heat\": 24781,\n    \"categories\": [\"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"t66y\",\n    \"name\": \"草榴社区\",\n    \"host\": \"t66y.com\",\n    \"heat\": 24702,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"threads\",\n    \"name\": \"Threads\",\n    \"host\": \"threads.net\",\n    \"heat\": 24696,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"1x\",\n    \"name\": \"1x.com\",\n    \"host\": \"1x.com\",\n    \"heat\": 24455,\n    \"categories\": [\"design\", \"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"ps\",\n    \"name\": \"PlayStation Store\",\n    \"host\": \"playstation.com\",\n    \"heat\": 22639,\n    \"categories\": [\"game\", \"popular\"]\n  },\n  {\n    \"key\": \"gov\",\n    \"name\": \"深圳市罗湖区人民政府\",\n    \"host\": \"szlh.gov.cn\",\n    \"heat\": 18933,\n    \"categories\": [\"government\", \"forecast\", \"popular\", \"finance\"]\n  },\n  {\n    \"key\": \"36kr\",\n    \"name\": \"36kr\",\n    \"host\": \"36kr.com\",\n    \"heat\": 18922,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"2048\",\n    \"name\": \"2048 核基地\",\n    \"host\": \"hjd2048.com\",\n    \"heat\": 18179,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"caixin\",\n    \"name\": \"财新博客\",\n    \"host\": \"caixin.com\",\n    \"heat\": 16065,\n    \"categories\": [\"traditional-media\", \"blog\", \"popular\"]\n  },\n  {\n    \"key\": \"javbus\",\n    \"name\": \"JavBus\",\n    \"host\": \"javbus.com\",\n    \"heat\": 13595,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"javtiful\",\n    \"name\": \"Javtiful\",\n    \"host\": \"javtiful.com\",\n    \"heat\": 12850,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"juejin\",\n    \"name\": \"掘金\",\n    \"host\": \"juejin.cn\",\n    \"heat\": 12318,\n    \"categories\": [\"programming\", \"popular\"]\n  },\n  {\n    \"key\": \"smzdm\",\n    \"name\": \"什么值得买\",\n    \"host\": \"post.smzdm.com\",\n    \"heat\": 12263,\n    \"categories\": [\"shopping\", \"popular\"]\n  },\n  {\n    \"key\": \"rsshub\",\n    \"name\": \"RSSHub\",\n    \"host\": \"docs.rsshub.app\",\n    \"heat\": 12237,\n    \"categories\": [\"program-update\", \"popular\", \"other\"]\n  },\n  {\n    \"key\": \"javdb\",\n    \"name\": \"JavDB\",\n    \"host\": \"javdb.com\",\n    \"heat\": 11347,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"douban\",\n    \"name\": \"豆瓣\",\n    \"host\": \"douban.com\",\n    \"heat\": 10882,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"picuki\",\n    \"name\": \"TikTok\",\n    \"host\": \"tiktok.com\",\n    \"heat\": 10773,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"zaobao\",\n    \"name\": \"联合早报\",\n    \"host\": \"zaobao.com\",\n    \"heat\": 10595,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"nytimes\",\n    \"name\": \"The New York Times\",\n    \"host\": \"nytimes.com\",\n    \"heat\": 8922,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"hellogithub\",\n    \"name\": \"HelloGitHub\",\n    \"host\": \"hellogithub.com\",\n    \"heat\": 8406,\n    \"categories\": [\"programming\", \"popular\"]\n  },\n  {\n    \"key\": \"500px\",\n    \"name\": \"500px 摄影社区\",\n    \"host\": \"500px.com.cn\",\n    \"heat\": 7524,\n    \"categories\": [\"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"eastmoney\",\n    \"name\": \"东方财富\",\n    \"host\": \"data.eastmoney.com\",\n    \"heat\": 7265,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"latepost\",\n    \"name\": \"晚点 LatePost\",\n    \"host\": \"latepost.com\",\n    \"heat\": 7072,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"xsijishe\",\n    \"name\": \"司机社\",\n    \"host\": \"xsijishe.com\",\n    \"heat\": 7006,\n    \"categories\": [\"bbs\", \"popular\"]\n  },\n  {\n    \"key\": \"hackernews\",\n    \"name\": \"Hacker News\",\n    \"host\": \"ycombinator.com\",\n    \"heat\": 6563,\n    \"categories\": [\"programming\", \"popular\"]\n  },\n  {\n    \"key\": \"xueqiu\",\n    \"name\": \"雪球\",\n    \"host\": \"danjuanapp.com\",\n    \"heat\": 6382,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"jpxgmn\",\n    \"name\": \"极品性感美女\",\n    \"host\": \"jpxgmn.com\",\n    \"heat\": 6323,\n    \"categories\": [\"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"reuters\",\n    \"name\": \"Reuters\",\n    \"host\": \"reuters.com\",\n    \"heat\": 5915,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"dapenti\",\n    \"name\": \"喷嚏\",\n    \"host\": \"dapenti.com\",\n    \"heat\": 5648,\n    \"categories\": [\"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"cctv\",\n    \"name\": \"央视新闻\",\n    \"host\": \"news.cctv.com\",\n    \"heat\": 5449,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"cls\",\n    \"name\": \"财联社\",\n    \"host\": \"cls.cn\",\n    \"heat\": 5383,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"bloomberg\",\n    \"name\": \"Bloomberg\",\n    \"host\": \"bloomberg.com\",\n    \"heat\": 5339,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"follow\",\n    \"name\": \"Follow\",\n    \"host\": \"app.follow.is\",\n    \"heat\": 5303,\n    \"categories\": [\"social-media\", \"popular\"]\n  },\n  {\n    \"key\": \"baoyu\",\n    \"name\": \"宝玉\",\n    \"host\": \"baoyu.io\",\n    \"heat\": 5246,\n    \"categories\": [\"blog\", \"popular\"]\n  },\n  {\n    \"key\": \"readhub\",\n    \"name\": \"Readhub\",\n    \"host\": \"readhub.cn\",\n    \"heat\": 4762,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"thepaper\",\n    \"name\": \"澎湃新闻\",\n    \"host\": \"thepaper.cn\",\n    \"heat\": 4693,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"gelonghui\",\n    \"name\": \"格隆汇\",\n    \"host\": \"gelonghui.com\",\n    \"heat\": 4411,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"bjp\",\n    \"name\": \"北京天文馆\",\n    \"host\": \"bjp.org.cn\",\n    \"heat\": 4250,\n    \"categories\": [\"picture\", \"popular\"]\n  },\n  {\n    \"key\": \"zcool\",\n    \"name\": \"站酷\",\n    \"host\": \"zcool.com.cn\",\n    \"heat\": 3870,\n    \"categories\": [\"design\", \"popular\"]\n  },\n  {\n    \"key\": \"i-cable\",\n    \"name\": \"有線新聞\",\n    \"host\": \"i-cable.com\",\n    \"heat\": 3576,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"yyets\",\n    \"name\": \"人人影视\",\n    \"host\": \"yysub.net\",\n    \"heat\": 3542,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"163\",\n    \"name\": \"网易公开课\",\n    \"host\": \"163.com\",\n    \"heat\": 3458,\n    \"categories\": [\"game\", \"new-media\", \"multimedia\", \"popular\", \"study\"]\n  },\n  {\n    \"key\": \"7mmtv\",\n    \"name\": \"7mmtv\",\n    \"host\": \"7mmtv.tv\",\n    \"heat\": 3361,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"nga\",\n    \"name\": \"NGA\",\n    \"host\": \"bbs.nga.cn\",\n    \"heat\": 3231,\n    \"categories\": [\"bbs\", \"popular\"]\n  },\n  {\n    \"key\": \"huggingface\",\n    \"name\": \"Huggingface\",\n    \"host\": \"huggingface.co\",\n    \"heat\": 3219,\n    \"categories\": [\"programming\", \"popular\"]\n  },\n  {\n    \"key\": \"infoq\",\n    \"name\": \"InfoQ 中文\",\n    \"host\": \"infoq.cn\",\n    \"heat\": 3218,\n    \"categories\": [\"programming\", \"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"infzm\",\n    \"name\": \"南方周末\",\n    \"host\": \"infzm.com\",\n    \"heat\": 3196,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"aibase\",\n    \"name\": \"AIbase\",\n    \"host\": \"aibase.com\",\n    \"heat\": 3095,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"economist\",\n    \"name\": \"The Economist\",\n    \"host\": \"economist.com\",\n    \"heat\": 3086,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"youzhiyouxing\",\n    \"name\": \"有知有行\",\n    \"host\": \"youzhiyouxing.cn\",\n    \"heat\": 2989,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"spotify\",\n    \"name\": \"Spotify\",\n    \"host\": \"open.spotify.com\",\n    \"heat\": 2948,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"bookfere\",\n    \"name\": \"书伴\",\n    \"host\": \"bookfere.com\",\n    \"heat\": 2934,\n    \"categories\": [\"reading\", \"popular\"]\n  },\n  {\n    \"key\": \"jin10\",\n    \"name\": \"金十数据\",\n    \"host\": \"jin10.com\",\n    \"heat\": 2778,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"natgeo\",\n    \"name\": \"National Geographic\",\n    \"host\": \"nationalgeographic.com\",\n    \"heat\": 2767,\n    \"categories\": [\"picture\", \"popular\", \"travel\"]\n  },\n  {\n    \"key\": \"gofans\",\n    \"name\": \"GoFans\",\n    \"host\": \"gofans.cn\",\n    \"heat\": 2724,\n    \"categories\": [\"program-update\", \"popular\"]\n  },\n  {\n    \"key\": \"coolapk\",\n    \"name\": \"酷安\",\n    \"host\": \"coolapk.com\",\n    \"heat\": 2722,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"google\",\n    \"name\": \"Google\",\n    \"host\": \"google.com\",\n    \"heat\": 2713,\n    \"categories\": [\n      \"picture\",\n      \"other\",\n      \"journal\",\n      \"blog\",\n      \"program-update\",\n      \"design\",\n      \"new-media\"\n    ]\n  },\n  {\n    \"key\": \"sciencenet\",\n    \"name\": \"科学网\",\n    \"host\": \"blog.sciencenet.cn\",\n    \"heat\": 2682,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"sis001\",\n    \"name\": \"第一会所\",\n    \"host\": \"sis001.com\",\n    \"heat\": 2596,\n    \"categories\": [\"bbs\", \"popular\"]\n  },\n  {\n    \"key\": \"solidot\",\n    \"name\": \"Solidot\",\n    \"host\": \"solidot.org\",\n    \"heat\": 2590,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"wechat\",\n    \"name\": \"微信小程序\",\n    \"host\": \"posts.careerengine.us\",\n    \"heat\": 2539,\n    \"categories\": [\"programming\", \"new-media\"]\n  },\n  {\n    \"key\": \"bbc\",\n    \"name\": \"BBC\",\n    \"host\": \"bbc.com\",\n    \"heat\": 2307,\n    \"categories\": [\"traditional-media\", \"popular\", \"study\"]\n  },\n  {\n    \"key\": \"huxiu\",\n    \"name\": \"虎嗅\",\n    \"host\": \"huxiu.com\",\n    \"heat\": 2245,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"apple\",\n    \"name\": \"Apple\",\n    \"host\": \"apple.com\",\n    \"heat\": 2060,\n    \"categories\": [\"program-update\", \"popular\", \"design\", \"other\", \"multimedia\"]\n  },\n  {\n    \"key\": \"mckinsey\",\n    \"name\": \"麦肯锡\",\n    \"host\": \"mckinsey.com.cn\",\n    \"heat\": 2015,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"anthropic\",\n    \"name\": \"Anthropic\",\n    \"host\": \"anthropic.com\",\n    \"heat\": 2002,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"aisixiang\",\n    \"name\": \"爱思想\",\n    \"host\": \"aisixiang.com\",\n    \"heat\": 1942,\n    \"categories\": [\"reading\", \"popular\"]\n  },\n  {\n    \"key\": \"wallstreetcn\",\n    \"name\": \"华尔街见闻\",\n    \"host\": \"wallstreetcn.com\",\n    \"heat\": 1893,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"playno1\",\n    \"name\": \"PLAYNO.1 玩樂達人\",\n    \"host\": \"stno1.playno1.com\",\n    \"heat\": 1839,\n    \"categories\": [\"bbs\", \"popular\"]\n  },\n  {\n    \"key\": \"xbookcn\",\n    \"name\": \"中文成人文學網\",\n    \"host\": \"xbookcn.net\",\n    \"heat\": 1800,\n    \"categories\": [\"reading\", \"popular\"]\n  },\n  {\n    \"key\": \"geekpark\",\n    \"name\": \"极客公园\",\n    \"host\": \"geekpark.net\",\n    \"heat\": 1773,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"behance\",\n    \"name\": \"Behance\",\n    \"host\": \"behance.net\",\n    \"heat\": 1756,\n    \"categories\": [\"design\", \"popular\"]\n  },\n  {\n    \"key\": \"binance\",\n    \"name\": \"Binance\",\n    \"host\": \"binance.com\",\n    \"heat\": 1709,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"mittrchina\",\n    \"name\": \"麻省理工科技评论\",\n    \"host\": \"mittrchina.com\",\n    \"heat\": 1701,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"gamer\",\n    \"name\": \"巴哈姆特電玩資訊站\",\n    \"host\": \"acg.gamer.com.tw\",\n    \"heat\": 1700,\n    \"categories\": [\"anime\", \"popular\"]\n  },\n  {\n    \"key\": \"zhubai\",\n    \"name\": \"竹白\",\n    \"host\": \"zhubai.love\",\n    \"heat\": 1696,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"theinitium\",\n    \"name\": \"The Initium\",\n    \"host\": \"theinitium.com\",\n    \"heat\": 1683,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"xiaoheihe\",\n    \"name\": \"小黑盒\",\n    \"host\": \"xiaoheihe.cn\",\n    \"heat\": 1673,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"obsidian\",\n    \"name\": \"Obsidian\",\n    \"host\": \"obsidian.md\",\n    \"heat\": 1658,\n    \"categories\": [\"program-update\", \"popular\", \"blog\"]\n  },\n  {\n    \"key\": \"newyorker\",\n    \"name\": \"New Yorker\",\n    \"host\": \"newyorker.com\",\n    \"heat\": 1629,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"ithome\",\n    \"name\": \"iThome 台灣\",\n    \"host\": \"ithome.com\",\n    \"heat\": 1608,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"10jqka\",\n    \"name\": \"同花顺财经\",\n    \"host\": \"10jqka.com.cn\",\n    \"heat\": 1595,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"ainvest\",\n    \"name\": \"AInvest\",\n    \"host\": \"ainvest.com\",\n    \"heat\": 1548,\n    \"categories\": [\"finance\", \"popular\"]\n  },\n  {\n    \"key\": \"jinse\",\n    \"name\": \"金色财经\",\n    \"host\": \"jinse.cn\",\n    \"heat\": 1521,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"dedao\",\n    \"name\": \"得到\",\n    \"host\": \"dedao.cn\",\n    \"heat\": 1519,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"xiaomiyoupin\",\n    \"name\": \"小米有品\",\n    \"host\": \"xiaomiyoupin.com\",\n    \"heat\": 1484,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"misskon\",\n    \"name\": \"MissKON\",\n    \"host\": \"misskon.com\",\n    \"heat\": 1458,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"imdb\",\n    \"name\": \"IMDb\",\n    \"host\": \"imdb.com\",\n    \"heat\": 1441,\n    \"categories\": [\"multimedia\", \"popular\"]\n  },\n  {\n    \"key\": \"kemono\",\n    \"name\": \"Kemono\",\n    \"host\": \"kemono.cr\",\n    \"heat\": 1422,\n    \"categories\": [\"anime\", \"popular\"]\n  },\n  {\n    \"key\": \"cngal\",\n    \"name\": \"CnGal\",\n    \"host\": \"cngal.org\",\n    \"heat\": 1371,\n    \"categories\": [\"anime\", \"popular\"]\n  },\n  {\n    \"key\": \"theatlantic\",\n    \"name\": \"The Atlantic\",\n    \"host\": \"theatlantic.com\",\n    \"heat\": 1360,\n    \"categories\": [\"traditional-media\", \"popular\"]\n  },\n  {\n    \"key\": \"deepmind\",\n    \"name\": \"DeepMind\",\n    \"host\": \"deepmind.com\",\n    \"heat\": 1343,\n    \"categories\": [\"new-media\", \"popular\"]\n  },\n  {\n    \"key\": \"csdn\",\n    \"name\": \"CSDN\",\n    \"host\": \"blog.csdn.net\",\n    \"heat\": 1335,\n    \"categories\": [\"blog\", \"popular\"]\n  },\n  {\n    \"key\": \"dribbble\",\n    \"name\": \"Dribbble\",\n    \"host\": \"dribbble.com\",\n    \"heat\": 1242,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"nhk\",\n    \"name\": \"NHK\",\n    \"host\": \"www3.nhk.or.jp\",\n    \"heat\": 1241,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"yicai\",\n    \"name\": \"第一财经\",\n    \"host\": \"yicai.com\",\n    \"heat\": 1241,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"hk01\",\n    \"name\": \"香港 01\",\n    \"host\": \"hk01.com\",\n    \"heat\": 1240,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"jianshu\",\n    \"name\": \"简书\",\n    \"host\": \"jianshu.com\",\n    \"heat\": 1207,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"konachan\",\n    \"name\": \"Konachan.com Anime Wallpapers\",\n    \"host\": \"konachan.com\",\n    \"heat\": 1194,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"aliresearch\",\n    \"name\": \"阿里研究院\",\n    \"host\": \"aliresearch.com\",\n    \"heat\": 1151,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"acfun\",\n    \"name\": \"AcFun\",\n    \"host\": \"acfun.cn\",\n    \"heat\": 1150,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"yande\",\n    \"name\": \"yande.re\",\n    \"host\": \"yande.re\",\n    \"heat\": 1149,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"oschina\",\n    \"name\": \"开源中国\",\n    \"host\": \"oschina.net\",\n    \"heat\": 1136,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"dx2025\",\n    \"name\": \"东西智库\",\n    \"host\": \"dx2025.com\",\n    \"heat\": 1114,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dongqiudi\",\n    \"name\": \"懂球帝\",\n    \"host\": \"m.dongqiudi.com\",\n    \"heat\": 1088,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"tophub\",\n    \"name\": \"今日热榜\",\n    \"host\": \"tophub.today\",\n    \"heat\": 1076,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cool18\",\n    \"name\": \"禁忌书屋\",\n    \"host\": \"cool18.com\",\n    \"heat\": 1074,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"bing\",\n    \"name\": \"Bing\",\n    \"host\": \"cn.bing.com\",\n    \"heat\": 1066,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"chaping\",\n    \"name\": \"差评\",\n    \"host\": \"chaping.cn\",\n    \"heat\": 1056,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sina\",\n    \"name\": \"新浪\",\n    \"host\": \"finance.sina.com.cn\",\n    \"heat\": 1044,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"guokr\",\n    \"name\": \"果壳网\",\n    \"host\": \"guokr.com\",\n    \"heat\": 1039,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"followin\",\n    \"name\": \"Followin\",\n    \"host\": \"followin.io\",\n    \"heat\": 1037,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"guancha\",\n    \"name\": \"观察者网\",\n    \"host\": \"guancha.cn\",\n    \"heat\": 1032,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"141ppv\",\n    \"name\": \"141PPV\",\n    \"host\": \"141ppv.com\",\n    \"heat\": 1016,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"jiuyangongshe\",\n    \"name\": \"韭研公社\",\n    \"host\": \"jiuyangongshe.com\",\n    \"heat\": 996,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"meituan\",\n    \"name\": \"美团\",\n    \"host\": \"meituan.com\",\n    \"heat\": 973,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"ftchinese\",\n    \"name\": \"FT 中文网\",\n    \"host\": \"ftchinese.com\",\n    \"heat\": 972,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"4kup\",\n    \"name\": \"4KUP\",\n    \"host\": \"4kup.net\",\n    \"heat\": 964,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"hupu\",\n    \"name\": \"虎扑\",\n    \"host\": \".hupu.com\",\n    \"heat\": 946,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"taoguba\",\n    \"name\": \"淘股吧\",\n    \"host\": \"tgb.cn\",\n    \"heat\": 924,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"baidu\",\n    \"name\": \"百度\",\n    \"host\": \"baidu.com\",\n    \"heat\": 918,\n    \"categories\": [\"finance\", \"other\", \"bbs\"]\n  },\n  {\n    \"key\": \"fx678\",\n    \"name\": \"汇通网\",\n    \"host\": \"fx678.com\",\n    \"heat\": 901,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"cankaoxiaoxi\",\n    \"name\": \"参考消息\",\n    \"host\": \"cankaoxiaoxi.com\",\n    \"heat\": 888,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gcores\",\n    \"name\": \"机核网\",\n    \"host\": \"gcores.com\",\n    \"heat\": 885,\n    \"categories\": [\"game\", \"new-media\"]\n  },\n  {\n    \"key\": \"tencent\",\n    \"name\": \"腾讯\",\n    \"host\": \"tencent.com\",\n    \"heat\": 872,\n    \"categories\": [\"programming\", \"new-media\", \"game\", \"program-update\"]\n  },\n  {\n    \"key\": \"bigquant\",\n    \"name\": \"BigQuant\",\n    \"host\": \"bigquant.com\",\n    \"heat\": 859,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"spankbang\",\n    \"name\": \"SpankBang\",\n    \"host\": \"spankbang.com\",\n    \"heat\": 823,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"huanqiu\",\n    \"name\": \"环球网\",\n    \"host\": \"huanqiu.com\",\n    \"heat\": 817,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"theblockbeats\",\n    \"name\": \"律动 BlockBeats\",\n    \"host\": \"theblockbeats.info\",\n    \"heat\": 805,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"wnacg\",\n    \"name\": \"紳士漫畫\",\n    \"host\": \"wnacg.org\",\n    \"heat\": 805,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"141jav\",\n    \"name\": \"141JAV\",\n    \"host\": \"141jav.com\",\n    \"heat\": 801,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"xyzrank\",\n    \"name\": \"中文播客榜\",\n    \"host\": \"xyzrank.com\",\n    \"heat\": 799,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ifanr\",\n    \"name\": \"爱范儿\",\n    \"host\": \"ifanr.com\",\n    \"heat\": 795,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"woshipm\",\n    \"name\": \"人人都是产品经理\",\n    \"host\": \"woshipm.com\",\n    \"heat\": 783,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cosplaytele\",\n    \"name\": \"CosplayTele\",\n    \"host\": \"cosplaytele.com\",\n    \"heat\": 780,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"3dmgame\",\n    \"name\": \"3DMGame\",\n    \"host\": \"3dmgame.com\",\n    \"heat\": 750,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"qq\",\n    \"name\": \"腾讯网\",\n    \"host\": \"qq.com\",\n    \"heat\": 741,\n    \"categories\": [\"new-media\", \"anime\", \"game\", \"other\", \"social-media\", \"bbs\"]\n  },\n  {\n    \"key\": \"fastbull\",\n    \"name\": \"FastBull\",\n    \"host\": \"fastbull.com\",\n    \"heat\": 730,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"6v123\",\n    \"name\": \"6v 电影\",\n    \"host\": \"hao6v.cc\",\n    \"heat\": 707,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"rustcc\",\n    \"name\": \"Rust 语言中文社区\",\n    \"host\": \"rustcc.cn\",\n    \"heat\": 703,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"apnews\",\n    \"name\": \"AP News\",\n    \"host\": \"apnews.com\",\n    \"heat\": 701,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"hellobtc\",\n    \"name\": \"白话区块链\",\n    \"host\": \"hellobtc.com\",\n    \"heat\": 685,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"missav\",\n    \"name\": \"MissAV\",\n    \"host\": \"missav.ws\",\n    \"heat\": 681,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"cnki\",\n    \"name\": \"中国知网\",\n    \"host\": \"navi.cnki.net\",\n    \"heat\": 671,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"panewslab\",\n    \"name\": \"PANews\",\n    \"host\": \"panewslab.com\",\n    \"heat\": 666,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"1point3acres\",\n    \"name\": \"一亩三分地\",\n    \"host\": \"blog.1point3acres.com\",\n    \"heat\": 655,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"hpoi\",\n    \"name\": \"Hpoi 手办维基\",\n    \"host\": \"hpoi.net\",\n    \"heat\": 647,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"aeon\",\n    \"name\": \"AEON\",\n    \"host\": \"aeon.co\",\n    \"heat\": 633,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"techflowpost\",\n    \"name\": \"深潮 TechFlow\",\n    \"host\": \"techflowpost.com\",\n    \"heat\": 633,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"foreverblog\",\n    \"name\": \"十年之约\",\n    \"host\": \"foreverblog.cn\",\n    \"heat\": 623,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"bitget\",\n    \"name\": \"Bitget\",\n    \"host\": \"bitget.com\",\n    \"heat\": 620,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"0818tuan\",\n    \"name\": \"0818 团\",\n    \"host\": \"0818tuan.com\",\n    \"heat\": 609,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"techcrunch\",\n    \"name\": \"TechCrunch\",\n    \"host\": \"techcrunch.com\",\n    \"heat\": 604,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dlsite\",\n    \"name\": \"DLsite\",\n    \"host\": \"dlsite.com\",\n    \"heat\": 598,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"423down\",\n    \"name\": \"423Down\",\n    \"host\": \"423down.com\",\n    \"heat\": 596,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"pingwest\",\n    \"name\": \"品玩\",\n    \"host\": \"pingwest.com\",\n    \"heat\": 590,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"idaily\",\n    \"name\": \"iDaily\",\n    \"host\": \"idai.ly\",\n    \"heat\": 578,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"nikkei\",\n    \"name\": \"The Nikkei 日本経済新聞\",\n    \"host\": \"nikkei.com\",\n    \"heat\": 570,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gamersky\",\n    \"name\": \"GamerSky\",\n    \"host\": \"gamersky.com\",\n    \"heat\": 561,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"yxdzqb\",\n    \"name\": \"游戏打折情报\",\n    \"host\": \"yxdzqb.com\",\n    \"heat\": 556,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"18comic\",\n    \"name\": \"禁漫天堂\",\n    \"host\": \"18comic.org\",\n    \"heat\": 552,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"gq\",\n    \"name\": \"GQ\",\n    \"host\": \"gq.com\",\n    \"heat\": 551,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"openai\",\n    \"name\": \"OpenAI\",\n    \"host\": \"openai.com\",\n    \"heat\": 533,\n    \"categories\": [\"program-update\", \"programming\"]\n  },\n  {\n    \"key\": \"zhitongcaijing\",\n    \"name\": \"智通财经网\",\n    \"host\": \"zhitongcaijing.com\",\n    \"heat\": 533,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"yahoo\",\n    \"name\": \"Yahoo\",\n    \"host\": \"hk.news.yahoo.com\",\n    \"heat\": 532,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"paulgraham\",\n    \"name\": \"Paul Graham\",\n    \"host\": \"paulgraham.com\",\n    \"heat\": 531,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"odaily\",\n    \"name\": \"Odaily 星球日报\",\n    \"host\": \"odaily.news\",\n    \"heat\": 528,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dockerhub\",\n    \"name\": \"Docker Hub\",\n    \"host\": \"hub.docker.com\",\n    \"heat\": 527,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"jandan\",\n    \"name\": \"煎蛋\",\n    \"host\": \"jandan.net\",\n    \"heat\": 523,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"foresightnews\",\n    \"name\": \"Foresight News\",\n    \"host\": \"foresightnews.pro\",\n    \"heat\": 522,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"right\",\n    \"name\": \"恩山无线论坛\",\n    \"host\": \"right.com.cn\",\n    \"heat\": 519,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"questmobile\",\n    \"name\": \"QuestMobile\",\n    \"host\": \"questmobile.com.cn\",\n    \"heat\": 516,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"yilinzazhi\",\n    \"name\": \"意林杂志\",\n    \"host\": \"yilinzazhi.com\",\n    \"heat\": 513,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"1lou\",\n    \"name\": \"BT 之家 1LOU 站\",\n    \"host\": \"1lou.me\",\n    \"heat\": 503,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"fuliba\",\n    \"name\": \"福利吧\",\n    \"host\": \"fuliba2023.net\",\n    \"heat\": 501,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"lofter\",\n    \"name\": \"Lofter\",\n    \"host\": \"lofter.com\",\n    \"heat\": 496,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"hanime1\",\n    \"name\": \"Hanime1\",\n    \"host\": \"hanime1.me\",\n    \"heat\": 493,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"qstheory\",\n    \"name\": \"求是网\",\n    \"host\": \"qstheory.cn\",\n    \"heat\": 484,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"kanxue\",\n    \"name\": \"看雪\",\n    \"host\": \"kanxue.com\",\n    \"heat\": 475,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"damai\",\n    \"name\": \"大麦网\",\n    \"host\": \"search.damai.cn\",\n    \"heat\": 474,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"chikubi\",\n    \"name\": \"乳首ふぇち\",\n    \"host\": \"chikubi.jp\",\n    \"heat\": 472,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"agirls\",\n    \"name\": \"电獭少女\",\n    \"host\": \"agirls.aotter.net\",\n    \"heat\": 471,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"appstore\",\n    \"name\": \"App Store/Mac App Store\",\n    \"host\": \"apps.apple.com\",\n    \"heat\": 466,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"finviz\",\n    \"name\": \"finviz\",\n    \"host\": \"finviz.com\",\n    \"heat\": 445,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"mrdx\",\n    \"name\": \"新华每日电讯\",\n    \"host\": \"mrdx.cn\",\n    \"heat\": 444,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"xianbao\",\n    \"name\": \"线报酷\",\n    \"host\": \"new.xianbao.fun\",\n    \"heat\": 442,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"academia\",\n    \"name\": \"Academia\",\n    \"host\": \"academia.edu\",\n    \"heat\": 440,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"cna\",\n    \"name\": \"中央通讯社\",\n    \"host\": \"cna.com.tw\",\n    \"heat\": 435,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"qbitai\",\n    \"name\": \"量子位\",\n    \"host\": \"qbitai.com\",\n    \"heat\": 435,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"nosec\",\n    \"name\": \"NOSEC 安全讯息平台\",\n    \"host\": \"nosec.org\",\n    \"heat\": 432,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"twreporter\",\n    \"name\": \"報導者\",\n    \"host\": \"twreporter.org\",\n    \"heat\": 415,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sse\",\n    \"name\": \"上海证券交易所\",\n    \"host\": \"bond.sse.com.cn\",\n    \"heat\": 414,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"slowmist\",\n    \"name\": \"慢雾科技\",\n    \"host\": \"slowmist.com\",\n    \"heat\": 403,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"4khd\",\n    \"name\": \"4KHD\",\n    \"host\": \"4khd.com\",\n    \"heat\": 401,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"toutiao\",\n    \"name\": \"今日头条\",\n    \"host\": \"toutiao.com\",\n    \"heat\": 391,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"91porn\",\n    \"name\": \"91porn\",\n    \"host\": \"91porn.com\",\n    \"heat\": 390,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"studygolang\",\n    \"name\": \"Go 语言中文网\",\n    \"host\": \"studygolang.com\",\n    \"heat\": 389,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"inewsweek\",\n    \"name\": \"中国新闻周刊\",\n    \"host\": \"inewsweek.cn\",\n    \"heat\": 386,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"myfans\",\n    \"name\": \"myfans\",\n    \"host\": \"myfans.jp\",\n    \"heat\": 385,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"cnblogs\",\n    \"name\": \"博客园\",\n    \"host\": \"cnblogs.com\",\n    \"heat\": 382,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ollama\",\n    \"name\": \"Ollama\",\n    \"host\": \"ollama.com\",\n    \"heat\": 377,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bendibao\",\n    \"name\": \"本地宝\",\n    \"host\": \"bendibao.com\",\n    \"heat\": 375,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"medsci\",\n    \"name\": \"梅斯医学 MedSci\",\n    \"host\": \"medsci.cn\",\n    \"heat\": 375,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"timednews\",\n    \"name\": \"时刻新闻\",\n    \"host\": \"timednews.com\",\n    \"heat\": 374,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"douyin\",\n    \"name\": \"抖音直播\",\n    \"host\": \"douyin.com\",\n    \"heat\": 369,\n    \"categories\": [\"social-media\", \"live\"]\n  },\n  {\n    \"key\": \"yuque\",\n    \"name\": \"语雀\",\n    \"host\": \"yuque.com\",\n    \"heat\": 369,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"thecover\",\n    \"name\": \"封面新闻\",\n    \"host\": \"thecover.cn\",\n    \"heat\": 364,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"deepseek\",\n    \"name\": \"Deepseek\",\n    \"host\": \"api-docs.deepseek.com\",\n    \"heat\": 360,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"blogread\",\n    \"name\": \"技术头条\",\n    \"host\": \"blogread.cn\",\n    \"heat\": 357,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"jisilu\",\n    \"name\": \"集思录\",\n    \"host\": \"jisilu.cn\",\n    \"heat\": 355,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"twitch\",\n    \"name\": \"Twitch\",\n    \"host\": \"twitch.tv\",\n    \"heat\": 353,\n    \"categories\": [\"live\"]\n  },\n  {\n    \"key\": \"ifeng\",\n    \"name\": \"凤凰网\",\n    \"host\": \"feng.ifeng.com\",\n    \"heat\": 353,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cs\",\n    \"name\": \"中证网\",\n    \"host\": \"cs.com.cn\",\n    \"heat\": 352,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"people\",\n    \"name\": \"人民网\",\n    \"host\": \"people.com.cn\",\n    \"heat\": 352,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"indienova\",\n    \"name\": \"indienova 独立游戏\",\n    \"host\": \"indienova.com\",\n    \"heat\": 351,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"baozimh\",\n    \"name\": \"包子漫画\",\n    \"host\": \"baozimh.com\",\n    \"heat\": 351,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"zaker\",\n    \"name\": \"ZAKER\",\n    \"host\": \"myzaker.com\",\n    \"heat\": 349,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bangumi.tv\",\n    \"name\": \"Bangumi 番组计划\",\n    \"host\": \"bangumi.tv\",\n    \"heat\": 348,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"udn\",\n    \"name\": \"聯合新聞網\",\n    \"host\": \"udn.com\",\n    \"heat\": 348,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"chlinlearn\",\n    \"name\": \"chlinlearn 的技术博客\",\n    \"host\": \"daily-blog.chlinlearn.top\",\n    \"heat\": 347,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"mi\",\n    \"name\": \"小米\",\n    \"host\": \"mi.com\",\n    \"heat\": 343,\n    \"categories\": [\"shopping\", \"program-update\"]\n  },\n  {\n    \"key\": \"coomer\",\n    \"name\": \"Coomer\",\n    \"host\": \"coomer.st\",\n    \"heat\": 339,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"web\",\n    \"name\": \"web.dev\",\n    \"host\": \"web.dev\",\n    \"heat\": 339,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"4ksj\",\n    \"name\": \"4k 世界\",\n    \"host\": \"4ksj.com\",\n    \"heat\": 336,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"mihoyo\",\n    \"name\": \"米哈游\",\n    \"host\": \"genshin.hoyoverse.com\",\n    \"heat\": 336,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"soundon\",\n    \"name\": \"SoundOn\",\n    \"host\": \"player.soundon.fm\",\n    \"heat\": 335,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"eleduck\",\n    \"name\": \"电鸭社区\",\n    \"host\": \"eleduck.com\",\n    \"heat\": 332,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"topys\",\n    \"name\": \"TOPYS\",\n    \"host\": \"topys.cn\",\n    \"heat\": 330,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cma\",\n    \"name\": \"中国气象局\",\n    \"host\": \"weather.cma.cn\",\n    \"heat\": 323,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"matters\",\n    \"name\": \"Matters\",\n    \"host\": \"matters.town\",\n    \"heat\": 318,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"banyuetan\",\n    \"name\": \"半月谈\",\n    \"host\": \"banyuetan.org\",\n    \"heat\": 318,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"pixivision\",\n    \"name\": \"pixivision\",\n    \"host\": \"pixivision.net\",\n    \"heat\": 315,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"guangdiu\",\n    \"name\": \"逛丢\",\n    \"host\": \"guangdiu.com\",\n    \"heat\": 315,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"cnbc\",\n    \"name\": \"CNBC\",\n    \"host\": \"search.cnbc.com\",\n    \"heat\": 314,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"pconline\",\n    \"name\": \"太平洋科技\",\n    \"host\": \"pconline.com.cn\",\n    \"heat\": 314,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ccreports\",\n    \"name\": \"消费者报道\",\n    \"host\": \"ccreports.com.cn\",\n    \"heat\": 311,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"wikinews\",\n    \"name\": \"维基新闻\",\n    \"host\": \"zh.wikinews.org\",\n    \"heat\": 308,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"theverge\",\n    \"name\": \"The Verge\",\n    \"host\": \"theverge.com\",\n    \"heat\": 306,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sehuatang\",\n    \"name\": \"色花堂\",\n    \"host\": \"sehuatang.net\",\n    \"heat\": 304,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"fantia\",\n    \"name\": \"Fantia\",\n    \"host\": \"fantia.jp\",\n    \"heat\": 295,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"linkedin\",\n    \"name\": \"LinkedIn\",\n    \"host\": \"linkedin.com\",\n    \"heat\": 293,\n    \"categories\": [\"other\", \"social-media\"]\n  },\n  {\n    \"key\": \"radio\",\n    \"name\": \"云听\",\n    \"host\": \"radio.cn\",\n    \"heat\": 293,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"a9vg\",\n    \"name\": \"A9VG 电玩部落\",\n    \"host\": \"a9vg.com\",\n    \"heat\": 289,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"oncc\",\n    \"name\": \"东网\",\n    \"host\": \"hk.on.cc\",\n    \"heat\": 288,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"science\",\n    \"name\": \"Science Magazine\",\n    \"host\": \"science.org\",\n    \"heat\": 282,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"washingtonpost\",\n    \"name\": \"The Washington Post\",\n    \"host\": \"washingtonpost.com\",\n    \"heat\": 282,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"51cto\",\n    \"name\": \"51CTO\",\n    \"host\": \"51cto.com\",\n    \"heat\": 274,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"leetcode\",\n    \"name\": \"LeetCode\",\n    \"host\": \"leetcode.com\",\n    \"heat\": 274,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"notefolio\",\n    \"name\": \"Notefolio\",\n    \"host\": \"notefolio.net\",\n    \"heat\": 274,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"secrss\",\n    \"name\": \"安全内参\",\n    \"host\": \"secrss.com\",\n    \"heat\": 273,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"docschina\",\n    \"name\": \"印记中文\",\n    \"host\": \"docschina.org\",\n    \"heat\": 269,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"zhuwang\",\n    \"name\": \"中国养猪网\",\n    \"host\": \"zhujia.zhuwang.cc\",\n    \"heat\": 266,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"aeaweb\",\n    \"name\": \"American Economic Association\",\n    \"host\": \"aeaweb.org\",\n    \"heat\": 264,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"asmr-200\",\n    \"name\": \"ASMR Online\",\n    \"host\": \"asmr-200.com\",\n    \"heat\": 261,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"mastodon\",\n    \"name\": \"Mastodon\",\n    \"host\": \"mastodon.social\",\n    \"heat\": 261,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"stockedge\",\n    \"name\": \"Stock Edge\",\n    \"host\": \"web.stockedge.com\",\n    \"heat\": 257,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"substack\",\n    \"name\": \"Substack\",\n    \"host\": \"substack.com\",\n    \"heat\": 257,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"tesla\",\n    \"name\": \"特斯拉中国\",\n    \"host\": \"tesla.cn\",\n    \"heat\": 256,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"manhuagui\",\n    \"name\": \"看漫画\",\n    \"host\": \"manhuagui.com\",\n    \"heat\": 255,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"nhentai\",\n    \"name\": \"nhentai\",\n    \"host\": \"nhentai.net\",\n    \"heat\": 253,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"ieee\",\n    \"name\": \"IEEE Xplore\",\n    \"host\": \"ieee.org\",\n    \"heat\": 252,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"freecomputerbooks\",\n    \"name\": \"Free Computer Books\",\n    \"host\": \"freecomputerbooks.com\",\n    \"heat\": 248,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"caixinglobal\",\n    \"name\": \"Caixin Global\",\n    \"host\": \"caixinglobal.com\",\n    \"heat\": 246,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"agefans\",\n    \"name\": \"AGE 动漫\",\n    \"host\": \"agemys.cc\",\n    \"heat\": 245,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"nifd\",\n    \"name\": \"国家金融与发展实验室\",\n    \"host\": \"nifd.cn\",\n    \"heat\": 242,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"dytt\",\n    \"name\": \"电影天堂\",\n    \"host\": \"dydytt.net\",\n    \"heat\": 242,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"stcn\",\n    \"name\": \"证券时报网\",\n    \"host\": \"stcn.com\",\n    \"heat\": 242,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"deeplearning\",\n    \"name\": \"DeepLearning.AI\",\n    \"host\": \"deeplearning.ai\",\n    \"heat\": 241,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"podwise\",\n    \"name\": \"Podwise\",\n    \"host\": \"podwise.ai\",\n    \"heat\": 239,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"flyert\",\n    \"name\": \"飞客茶馆\",\n    \"host\": \"flyert.com.cn\",\n    \"heat\": 236,\n    \"categories\": [\"travel\", \"bbs\"]\n  },\n  {\n    \"key\": \"itc\",\n    \"name\": \"Open Github社区\",\n    \"host\": \"open.itc.cn\",\n    \"heat\": 235,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"sputniknews\",\n    \"name\": \"Sputnik News 俄罗斯卫星通讯社\",\n    \"host\": \"sputniknews.cn\",\n    \"heat\": 231,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"ulapia\",\n    \"name\": \"乌拉邦\",\n    \"host\": \"ulapia.com\",\n    \"heat\": 229,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"caijing\",\n    \"name\": \"财经网\",\n    \"host\": \"roll.caijing.com.cn\",\n    \"heat\": 229,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"cursor\",\n    \"name\": \"Cursor\",\n    \"host\": \"cursor.com\",\n    \"heat\": 222,\n    \"categories\": [\"blog\", \"program-update\"]\n  },\n  {\n    \"key\": \"pkmer\",\n    \"name\": \"PKMer\",\n    \"host\": \"pkmer.cn\",\n    \"heat\": 222,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"zhibo8\",\n    \"name\": \"直播吧\",\n    \"host\": \"zhibo8.cc\",\n    \"heat\": 222,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"pixabay\",\n    \"name\": \"Pixabay\",\n    \"host\": \"pixabay.com\",\n    \"heat\": 219,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"shcstheatre\",\n    \"name\": \"上海文化广场\",\n    \"host\": \"shcstheatre.com\",\n    \"heat\": 219,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"modelscope\",\n    \"name\": \"ModelScope 魔搭社区\",\n    \"host\": \"modelscope.cn\",\n    \"heat\": 217,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"wallhaven\",\n    \"name\": \"wallhaven\",\n    \"host\": \"wallhaven.cc\",\n    \"heat\": 214,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"springer\",\n    \"name\": \"Springer\",\n    \"host\": \"springer.com\",\n    \"heat\": 213,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"baai\",\n    \"name\": \"北京智源人工智能研究院\",\n    \"host\": \"hub.baai.ac.cn\",\n    \"heat\": 212,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"dw\",\n    \"name\": \"DW Deutsche Welle\",\n    \"host\": \"dw.com\",\n    \"heat\": 211,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"harvard\",\n    \"name\": \"Harvard Health Publishing\",\n    \"host\": \"health.harvard.edu\",\n    \"heat\": 211,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cntv\",\n    \"name\": \"CNTV\",\n    \"host\": \"navi.cctv.com\",\n    \"heat\": 209,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"feng\",\n    \"name\": \"威锋\",\n    \"host\": \"feng.com\",\n    \"heat\": 208,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"qidian\",\n    \"name\": \"起点\",\n    \"host\": \"qidian.com\",\n    \"heat\": 207,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"ymgal\",\n    \"name\": \"月幕 Galgame\",\n    \"host\": \"ymgal.games\",\n    \"heat\": 206,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"dcfever\",\n    \"name\": \"DCFever\",\n    \"host\": \"dcfever.com\",\n    \"heat\": 204,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"diershoubing\",\n    \"name\": \"二柄 APP\",\n    \"host\": \"diershoubing.com\",\n    \"heat\": 204,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"wanqu\",\n    \"name\": \"湾区日报\",\n    \"host\": \"wanqu.co\",\n    \"heat\": 203,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"wired\",\n    \"name\": \"WIRED\",\n    \"host\": \"wired.com\",\n    \"heat\": 201,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"producthunt\",\n    \"name\": \"Product Hunt\",\n    \"host\": \"producthunt.com\",\n    \"heat\": 199,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cyzone\",\n    \"name\": \"创业邦\",\n    \"host\": \"cyzone.cn\",\n    \"heat\": 197,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"appleinsider\",\n    \"name\": \"AppleInsider\",\n    \"host\": \"appleinsider.com\",\n    \"heat\": 196,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"everia\",\n    \"name\": \"EVERIA.CLUB\",\n    \"host\": \"everia.club\",\n    \"heat\": 193,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"meta\",\n    \"name\": \"Meta\",\n    \"host\": \"meta.com\",\n    \"heat\": 193,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"now\",\n    \"name\": \"Now 新聞\",\n    \"host\": \"news.now.com\",\n    \"heat\": 192,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"iwara\",\n    \"name\": \"iwara\",\n    \"host\": \"iwara.tv\",\n    \"heat\": 190,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"javtrailers\",\n    \"name\": \"JavTrailers\",\n    \"host\": \"javtrailers.com\",\n    \"heat\": 186,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"diandong\",\n    \"name\": \"电动邦\",\n    \"host\": \"diandong.com\",\n    \"heat\": 185,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"gettr\",\n    \"name\": \"GETTR\",\n    \"host\": \"gettr.com\",\n    \"heat\": 180,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"freewechat\",\n    \"name\": \"自由微信\",\n    \"host\": \"freewechat.com\",\n    \"heat\": 176,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"wufazhuce\",\n    \"name\": \"「ONE · 一个」\",\n    \"host\": \"wufazhuce.com\",\n    \"heat\": 175,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"foreignaffairs\",\n    \"name\": \"Foreign Affairs\",\n    \"host\": \"foreignaffairs.com\",\n    \"heat\": 171,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"utgd\",\n    \"name\": \"UNTAG\",\n    \"host\": \"utgd.net\",\n    \"heat\": 170,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"learnku\",\n    \"name\": \"LearnKu\",\n    \"host\": \"learnku.com\",\n    \"heat\": 167,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"langchain\",\n    \"name\": \"LangChain Blog\",\n    \"host\": \"blog.langchain.dev\",\n    \"heat\": 166,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"colamanga\",\n    \"name\": \"COLAMANGA\",\n    \"host\": \"colamanga.com\",\n    \"heat\": 162,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"scmp\",\n    \"name\": \"Corona Virus Disease 2019\",\n    \"host\": \"scmp.com\",\n    \"heat\": 161,\n    \"categories\": [\"other\", \"traditional-media\"]\n  },\n  {\n    \"key\": \"digitalcameraworld\",\n    \"name\": \"Digital Camera World\",\n    \"host\": \"digitalcameraworld.com\",\n    \"heat\": 158,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"moodysmismicrosite\",\n    \"name\": \"穆迪评级\",\n    \"host\": \"moodysmismicrosite.com\",\n    \"heat\": 157,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"dev.to\",\n    \"name\": \"DEV Community\",\n    \"host\": \"dev.to\",\n    \"heat\": 156,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"lifeweek\",\n    \"name\": \"三联生活周刊\",\n    \"host\": \"lifeweek.com.cn\",\n    \"heat\": 155,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"qianzhan\",\n    \"name\": \"前瞻网\",\n    \"host\": \"qianzhan.com\",\n    \"heat\": 155,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"95mm\",\n    \"name\": \"MM 范\",\n    \"host\": \"95mm.org\",\n    \"heat\": 154,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"chsi\",\n    \"name\": \"中国研究生招生信息网\",\n    \"host\": \"yz.chsi.com.cn\",\n    \"heat\": 154,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"tvb\",\n    \"name\": \"无线新闻\",\n    \"host\": \"tvb.com\",\n    \"heat\": 154,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"copymanga\",\n    \"name\": \"拷贝漫画\",\n    \"host\": \"copymanga.com\",\n    \"heat\": 153,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"laohu8\",\n    \"name\": \"老虎社区\",\n    \"host\": \"laohu8.com\",\n    \"heat\": 151,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"aliyun\",\n    \"name\": \"阿里云\",\n    \"host\": \"developer.aliyun.com\",\n    \"heat\": 151,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"yystv\",\n    \"name\": \"游研社\",\n    \"host\": \"yystv.cn\",\n    \"heat\": 150,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"domp4\",\n    \"name\": \"DoMP4 影视\",\n    \"host\": \"xlmp4.com\",\n    \"heat\": 149,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"earthquake\",\n    \"name\": \"地震速报\",\n    \"host\": \"ceic.ac.cn\",\n    \"heat\": 146,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"showstart\",\n    \"name\": \"秀动网\",\n    \"host\": \"showstart.com\",\n    \"heat\": 146,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"cncf\",\n    \"name\": \"CNCF\",\n    \"host\": \"cncf.io\",\n    \"heat\": 144,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bossdesign\",\n    \"name\": \"Boss 设计\",\n    \"host\": \"bossdesign.cn\",\n    \"heat\": 143,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"linkresearcher\",\n    \"name\": \"Link Research\",\n    \"host\": \"linkresearcher.com\",\n    \"heat\": 142,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"kyodonews\",\n    \"name\": \"共同网\",\n    \"host\": \"china.kyodonews.net\",\n    \"heat\": 142,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"cpcaauto\",\n    \"name\": \"cpcaauto.com\",\n    \"host\": \"xn--fiq7va501a70bq0jjma416bfx5aca76shw2duqg1k3fea09z\",\n    \"heat\": 141,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"penguin-random-house\",\n    \"name\": \"Penguin Random House\",\n    \"host\": \"penguinrandomhouse.com\",\n    \"heat\": 141,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"phoronix\",\n    \"name\": \"Phoronix\",\n    \"host\": \"phoronix.com\",\n    \"heat\": 141,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"plurk\",\n    \"name\": \"Plurk\",\n    \"host\": \"plurk.com\",\n    \"heat\": 141,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"steam\",\n    \"name\": \"Steam\",\n    \"host\": \"store.steampowered.com\",\n    \"heat\": 136,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"rss3\",\n    \"name\": \"RSS3\",\n    \"host\": \"rss3.io\",\n    \"heat\": 135,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"the\",\n    \"name\": \"The.bi\",\n    \"host\": \"the.bi\",\n    \"heat\": 134,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"piyao\",\n    \"name\": \"中国互联网联合辟谣平台\",\n    \"host\": \"piyao.org.cn\",\n    \"heat\": 132,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"nintendo\",\n    \"name\": \"Nintendo\",\n    \"host\": \"nintendo.com\",\n    \"heat\": 130,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"sohu\",\n    \"name\": \"搜狐号\",\n    \"host\": \"sohu.com\",\n    \"heat\": 130,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"niaogebiji\",\n    \"name\": \"鸟哥笔记\",\n    \"host\": \"niaogebiji.com\",\n    \"heat\": 130,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ieee-security\",\n    \"name\": \"IEEE Computer Society\",\n    \"host\": \"ieee-security.org\",\n    \"heat\": 129,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"yxrb\",\n    \"name\": \"游戏日报\",\n    \"host\": \"news.yxrb.net\",\n    \"heat\": 129,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"vocus\",\n    \"name\": \"方格子\",\n    \"host\": \"vocus.cc\",\n    \"heat\": 128,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"olevod\",\n    \"name\": \"欧乐影院\",\n    \"host\": \"olevod.one\",\n    \"heat\": 128,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"trendingpapers\",\n    \"name\": \"Trending Papers\",\n    \"host\": \"trendingpapers.com\",\n    \"heat\": 127,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"szse\",\n    \"name\": \"深圳证券交易所\",\n    \"host\": \"szse.cn\",\n    \"heat\": 127,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"xinpianchang\",\n    \"name\": \"新片场\",\n    \"host\": \"xinpianchang.com\",\n    \"heat\": 125,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"acg17\",\n    \"name\": \"ACG17\",\n    \"host\": \"acg17.com\",\n    \"heat\": 124,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"notion\",\n    \"name\": \"Notion\",\n    \"host\": \"notion.so\",\n    \"heat\": 124,\n    \"categories\": [\"other\", \"program-update\"]\n  },\n  {\n    \"key\": \"tingtingfm\",\n    \"name\": \"听听 FM\",\n    \"host\": \"mobile.tingtingfm.com\",\n    \"heat\": 124,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"cw\",\n    \"name\": \"天下雜誌\",\n    \"host\": \"cw.com.tw\",\n    \"heat\": 124,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"nowcoder\",\n    \"name\": \"牛客网\",\n    \"host\": \"nowcoder.com\",\n    \"heat\": 124,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"ajcass\",\n    \"name\": \"社科期刊网\",\n    \"host\": \"ajcass.com\",\n    \"heat\": 124,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"52hrtt\",\n    \"name\": \"52hrtt 华人头条\",\n    \"host\": \"52hrtt.com\",\n    \"heat\": 123,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"coindesk\",\n    \"name\": \"CoinDesk\",\n    \"host\": \"coindesk.com\",\n    \"heat\": 123,\n    \"categories\": [\"new-media\", \"finance\"]\n  },\n  {\n    \"key\": \"setn\",\n    \"name\": \"三立新聞網\",\n    \"host\": \"setn.com\",\n    \"heat\": 123,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"themoviedb\",\n    \"name\": \"The Movie Database\",\n    \"host\": \"themoviedb.org\",\n    \"heat\": 122,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"taobao\",\n    \"name\": \"淘宝网\",\n    \"host\": \"taobao.com\",\n    \"heat\": 122,\n    \"categories\": [\"programming\", \"shopping\"]\n  },\n  {\n    \"key\": \"dayanzai\",\n    \"name\": \"大眼仔旭\",\n    \"host\": \"dayanzai.me\",\n    \"heat\": 121,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"pinterest\",\n    \"name\": \"Pinterest\",\n    \"host\": \"pinterest.com\",\n    \"heat\": 119,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"jiemian\",\n    \"name\": \"界面新闻\",\n    \"host\": \"jiemian.com\",\n    \"heat\": 119,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"21caijing\",\n    \"name\": \"21财经\",\n    \"host\": \"21caijing.com\",\n    \"heat\": 117,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"txrjy\",\n    \"name\": \"通信人家园\",\n    \"host\": \"txrjy.com\",\n    \"heat\": 117,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"ikea\",\n    \"name\": \"IKEA\",\n    \"host\": \"ikea.com\",\n    \"heat\": 112,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"china\",\n    \"name\": \"China.com 中华网\",\n    \"host\": \"finance.china.com\",\n    \"heat\": 111,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"wsj\",\n    \"name\": \"The Wall Street Journal (WSJ) 华尔街日报\",\n    \"host\": \"cn.wsj.com\",\n    \"heat\": 111,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gamer520\",\n    \"name\": \"全球游戏交流中心\",\n    \"host\": \"gamer520.com\",\n    \"heat\": 111,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"bjnews\",\n    \"name\": \"新京报\",\n    \"host\": \"bjnews.com.cn\",\n    \"heat\": 111,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"tmtpost\",\n    \"name\": \"钛媒体\",\n    \"host\": \"tmtpost.com\",\n    \"heat\": 111,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"spglobal\",\n    \"name\": \"S&P Global\",\n    \"host\": \"spglobal.com\",\n    \"heat\": 110,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"wyzxwk\",\n    \"name\": \"乌有之乡\",\n    \"host\": \"wyzxwk.com\",\n    \"heat\": 110,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"mydrivers\",\n    \"name\": \"快科技\",\n    \"host\": \"m.mydrivers.com\",\n    \"heat\": 109,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ntdtv\",\n    \"name\": \"新唐人电视台\",\n    \"host\": \"ntdtv.com\",\n    \"heat\": 109,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"okx\",\n    \"name\": \"欧易 OKX\",\n    \"host\": \"okx.com\",\n    \"heat\": 108,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"zsxq\",\n    \"name\": \"知识星球\",\n    \"host\": \"zsxq.com\",\n    \"heat\": 108,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"pincong\",\n    \"name\": \"品葱\",\n    \"host\": \"pincong.rocks\",\n    \"heat\": 107,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"iresearch\",\n    \"name\": \"艾瑞咨询\",\n    \"host\": \"iresearch.com.cn\",\n    \"heat\": 105,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bestblogs\",\n    \"name\": \"bestblogs.dev\",\n    \"host\": \"bestblogs.dev\",\n    \"heat\": 104,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"lfsyd\",\n    \"name\": \"旅法师营地\",\n    \"host\": \"iyingdi.com\",\n    \"heat\": 103,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"yomujp\",\n    \"name\": \"日本語多読道場\",\n    \"host\": \"yomujp.com\",\n    \"heat\": 103,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"douyu\",\n    \"name\": \"斗鱼直播\",\n    \"host\": \"douyu.com\",\n    \"heat\": 102,\n    \"categories\": [\"bbs\", \"live\"]\n  },\n  {\n    \"key\": \"fortunechina\",\n    \"name\": \"财富中文网\",\n    \"host\": \"fortunechina.com\",\n    \"heat\": 102,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"abc\",\n    \"name\": \"ABC News (Australian Broadcasting Corporation)\",\n    \"host\": \"abc.net.au\",\n    \"heat\": 101,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"keylol\",\n    \"name\": \"其乐\",\n    \"host\": \"keylol.com\",\n    \"heat\": 101,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"chongbuluo\",\n    \"name\": \"虫部落\",\n    \"host\": \"chongbuluo.com\",\n    \"heat\": 99,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"komiic\",\n    \"name\": \"Komiic\",\n    \"host\": \"komiic.com\",\n    \"heat\": 97,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"30secondsofcode\",\n    \"name\": \"30 Seconds of code\",\n    \"host\": \"30secondsofcode.org\",\n    \"heat\": 96,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"uber\",\n    \"name\": \"Uber\",\n    \"host\": \"uber.com\",\n    \"heat\": 95,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"line\",\n    \"name\": \"LINE\",\n    \"host\": \"today.line.me\",\n    \"heat\": 93,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"anquanke\",\n    \"name\": \"安全客\",\n    \"host\": \"anquanke.com\",\n    \"heat\": 93,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"sigsac\",\n    \"name\": \"ACM Special Interest Group on Security Audit and Control\",\n    \"host\": \"sigsac.org\",\n    \"heat\": 92,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"hkej\",\n    \"name\": \"信报财经新闻\",\n    \"host\": \"hkej.com\",\n    \"heat\": 91,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"cih-index\",\n    \"name\": \"中指研究院\",\n    \"host\": \"cih-index.com\",\n    \"heat\": 90,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"zxcs\",\n    \"name\": \"知轩藏书\",\n    \"host\": \"zxcs.info\",\n    \"heat\": 90,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"chiphell\",\n    \"name\": \"Chiphell\",\n    \"host\": \"chiphell.com\",\n    \"heat\": 88,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"bt0\",\n    \"name\": \"不太灵影视\",\n    \"host\": \"2bt0.com\",\n    \"heat\": 88,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"furaffinity\",\n    \"name\": \"Furaffinity\",\n    \"host\": \"furaffinity.net\",\n    \"heat\": 86,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"react\",\n    \"name\": \"React\",\n    \"host\": \"react.dev\",\n    \"heat\": 86,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"voronoiapp\",\n    \"name\": \"Voronoi\",\n    \"host\": \"voronoiapp.com\",\n    \"heat\": 85,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"tkww\",\n    \"name\": \"大公文匯網\",\n    \"host\": \"tkww.hk\",\n    \"heat\": 85,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"yamibo\",\n    \"name\": \"百合会\",\n    \"host\": \"yamibo.com\",\n    \"heat\": 85,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"ajmide\",\n    \"name\": \"阿基米德 FM\",\n    \"host\": \"m.ajmide.com\",\n    \"heat\": 85,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"8264\",\n    \"name\": \"8264\",\n    \"host\": \"8264.com\",\n    \"heat\": 83,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"stdaily\",\n    \"name\": \"中国科技网\",\n    \"host\": \"epaper.stdaily.com\",\n    \"heat\": 83,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"cdi\",\n    \"name\": \"国家高端智库 / 综合开发研究院\",\n    \"host\": \"cdi.com.cn\",\n    \"heat\": 83,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"freebuf\",\n    \"name\": \"FreeBuf\",\n    \"host\": \"freebuf.com\",\n    \"heat\": 81,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"tass\",\n    \"name\": \"Russian News Agency TASS\",\n    \"host\": \"tass.com\",\n    \"heat\": 81,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"dn\",\n    \"name\": \"DN.com\",\n    \"host\": \"dn.com\",\n    \"heat\": 79,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"python\",\n    \"name\": \"Python\",\n    \"host\": \"python.org\",\n    \"heat\": 79,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"vcb-s\",\n    \"name\": \"VCB-Studio\",\n    \"host\": \"vcb-s.com\",\n    \"heat\": 79,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"eastday\",\n    \"name\": \"东方网\",\n    \"host\": \"mini.eastday.com\",\n    \"heat\": 78,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"huoxian\",\n    \"name\": \"火线\",\n    \"host\": \"zone.huoxian.cn\",\n    \"heat\": 78,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"ixigua\",\n    \"name\": \"西瓜视频\",\n    \"host\": \"ixigua.com\",\n    \"heat\": 78,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"hbr\",\n    \"name\": \"Harvard Business Review\",\n    \"host\": \"hbr.org\",\n    \"heat\": 77,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"javlibrary\",\n    \"name\": \"JAVLibrary\",\n    \"host\": \"javlibrary.com\",\n    \"heat\": 76,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"wordpress\",\n    \"name\": \"WordPress\",\n    \"host\": \"wordpress.org\",\n    \"heat\": 76,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ebc\",\n    \"name\": \"東森新聞\",\n    \"host\": \"ebc.net.tw\",\n    \"heat\": 76,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"qingting\",\n    \"name\": \"蜻蜓 FM\",\n    \"host\": \"qingting.fm\",\n    \"heat\": 75,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"jump\",\n    \"name\": \"JUMP\",\n    \"host\": \"switch.jumpvg.com\",\n    \"heat\": 73,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"whitehouse\",\n    \"name\": \"The White House\",\n    \"host\": \"whitehouse.gov\",\n    \"heat\": 73,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"neea\",\n    \"name\": \"中国教育考试网\",\n    \"host\": \"neea.cn\",\n    \"heat\": 73,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"xys\",\n    \"name\": \"新语丝\",\n    \"host\": \"xys.org\",\n    \"heat\": 73,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"espn\",\n    \"name\": \"ESPN\",\n    \"host\": \"espn.com\",\n    \"heat\": 72,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"nber\",\n    \"name\": \"National Bureau of Economic Research\",\n    \"host\": \"nber.org\",\n    \"heat\": 72,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"shu\",\n    \"name\": \"上海大学\",\n    \"host\": \"shu.edu.cn\",\n    \"heat\": 71,\n    \"categories\": [\"university\", \"journal\"]\n  },\n  {\n    \"key\": \"papers\",\n    \"name\": \"Cool Papers\",\n    \"host\": \"papers.cool\",\n    \"heat\": 70,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"augmentcode\",\n    \"name\": \"Augment Code\",\n    \"host\": \"augmentcode.com\",\n    \"heat\": 69,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"futunn\",\n    \"name\": \"Futubull 富途牛牛\",\n    \"host\": \"news.futunn.com\",\n    \"heat\": 69,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"thoughtworks\",\n    \"name\": \"ThoughtWorks\",\n    \"host\": \"thoughtworks.com\",\n    \"heat\": 69,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"joins\",\n    \"name\": \"中央日报\",\n    \"host\": \"joins.com\",\n    \"heat\": 69,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"huawei\",\n    \"name\": \"华为开发者联盟\",\n    \"host\": \"developer.huawei.com\",\n    \"heat\": 69,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"cuilingmag\",\n    \"name\": \"萃嶺网\",\n    \"host\": \"cuilingmag.com\",\n    \"heat\": 69,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"3kns\",\n    \"name\": \"3k-Switch游戏库\",\n    \"host\": \"3kns.com\",\n    \"heat\": 68,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"eagle\",\n    \"name\": \"Eagle\",\n    \"host\": \"cn.eagle.cool\",\n    \"heat\": 68,\n    \"categories\": [\"blog\", \"program-update\"]\n  },\n  {\n    \"key\": \"zjuvag\",\n    \"name\": \"浙江大学可视分析小组\",\n    \"host\": \"zjuvag.org\",\n    \"heat\": 68,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"minecraft\",\n    \"name\": \"Minecraft\",\n    \"host\": \"minecraft.net\",\n    \"heat\": 67,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"sec-wiki\",\n    \"name\": \"SecWiki - 安全维基\",\n    \"host\": \"sec-wiki.com\",\n    \"heat\": 67,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"fanbox\",\n    \"name\": \"fanbox\",\n    \"host\": \"fanbox.cc\",\n    \"heat\": 66,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"liveuamap\",\n    \"name\": \"Live Universal Awareness Map\",\n    \"host\": \"liveuamap.com\",\n    \"heat\": 66,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"nautil\",\n    \"name\": \"Nautilus\",\n    \"host\": \"nautil.us\",\n    \"heat\": 66,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"straitstimes\",\n    \"name\": \"The Strait Times\",\n    \"host\": \"straitstimes.com\",\n    \"heat\": 66,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"vimeo\",\n    \"name\": \"Vimeo\",\n    \"host\": \"vimeo.com\",\n    \"heat\": 66,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"jrj\",\n    \"name\": \"金融界\",\n    \"host\": \"jrj.com.cn\",\n    \"heat\": 66,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"pku\",\n    \"name\": \"北京大学\",\n    \"host\": \"admission.pku.edu.cn\",\n    \"heat\": 65,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"jjwxc\",\n    \"name\": \"晋江文学城\",\n    \"host\": \"jjwxc.net\",\n    \"heat\": 65,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"baobua\",\n    \"name\": \"BaoBua\",\n    \"host\": \"baobua.com\",\n    \"heat\": 64,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"cneb\",\n    \"name\": \"中国国家应急广播\",\n    \"host\": \"cneb.gov.cn\",\n    \"heat\": 64,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"ttv\",\n    \"name\": \"台視新聞網\",\n    \"host\": \"news.ttv.com.tw\",\n    \"heat\": 64,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"ali213\",\n    \"name\": \"游侠网\",\n    \"host\": \"ali213.net\",\n    \"heat\": 64,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"makerworld\",\n    \"name\": \"MakerWorld\",\n    \"host\": \"makerworld.com\",\n    \"heat\": 63,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"rfi\",\n    \"name\": \"Radio France Internationale\",\n    \"host\": \"rfi.fr\",\n    \"heat\": 63,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"junhe\",\n    \"name\": \"君合律师事务所\",\n    \"host\": \"junhe.com\",\n    \"heat\": 63,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"wmpvp\",\n    \"name\": \"完美世界电竞\",\n    \"host\": \"wmpvp.com\",\n    \"heat\": 63,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"x6d\",\n    \"name\": \"小刀娱乐网\",\n    \"host\": \"xd.x6d.com\",\n    \"heat\": 63,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cnbeta\",\n    \"name\": \"cnBeta.COM\",\n    \"host\": \"cnbeta.com.tw\",\n    \"heat\": 62,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"discord\",\n    \"name\": \"Discord\",\n    \"host\": \"discord.com\",\n    \"heat\": 62,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"withgoogle\",\n    \"name\": \"People + AI Research (PAIR)\",\n    \"host\": \"pair.withgoogle.com\",\n    \"heat\": 62,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ximalaya\",\n    \"name\": \"喜马拉雅\",\n    \"host\": \"ximalaya.com\",\n    \"heat\": 62,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"civitai\",\n    \"name\": \"Civitai\",\n    \"host\": \"civitai.com\",\n    \"heat\": 60,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"fediverse\",\n    \"name\": \"Fediverse\",\n    \"host\": \"fediverse.observer\",\n    \"heat\": 60,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"manus\",\n    \"name\": \"Manus\",\n    \"host\": \"manus.im\",\n    \"heat\": 58,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"linovelib\",\n    \"name\": \"哔哩轻小说\",\n    \"host\": \"linovelib.com\",\n    \"heat\": 58,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"baijing\",\n    \"name\": \"白鲸出海\",\n    \"host\": \"baijing.cn\",\n    \"heat\": 58,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"windsurf\",\n    \"name\": \"Windsurf\",\n    \"host\": \"windsurf.com\",\n    \"heat\": 57,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"startuplatte\",\n    \"name\": \"創新拿鐵\",\n    \"host\": \"startuplatte.com\",\n    \"heat\": 57,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"elasticsearch-cn\",\n    \"name\": \"Elastic 中文社区\",\n    \"host\": \"elasticsearch.cn\",\n    \"heat\": 56,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"npr\",\n    \"name\": \"NPR (National Public Radio)\",\n    \"host\": \"npr.org\",\n    \"heat\": 56,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"openrice\",\n    \"name\": \"Openrice開飯喇\",\n    \"host\": \"openrice.com\",\n    \"heat\": 56,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"rebase\",\n    \"name\": \"Rebase Network\",\n    \"host\": \"rebase.network\",\n    \"heat\": 56,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dmzj\",\n    \"name\": \"动漫之家\",\n    \"host\": \"news.dmzj.com\",\n    \"heat\": 56,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"wainao\",\n    \"name\": \"歪脑\",\n    \"host\": \"wainao.me\",\n    \"heat\": 56,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"yuanliao\",\n    \"name\": \"猿料\",\n    \"host\": \"yuanliao.info\",\n    \"heat\": 56,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"hypergryph\",\n    \"name\": \"鹰角网络\",\n    \"host\": \"hypergryph.com\",\n    \"heat\": 56,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"blizzard\",\n    \"name\": \"Blizzard\",\n    \"host\": \"news.blizzard.com\",\n    \"heat\": 55,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"chinamoney\",\n    \"name\": \"中国货币网\",\n    \"host\": \"chinamoney.com.cn\",\n    \"heat\": 55,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"qiche365\",\n    \"name\": \"汽车召回网\",\n    \"host\": \"qiche365.org.cn\",\n    \"heat\": 55,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"css-tricks\",\n    \"name\": \"CSS-Tricks\",\n    \"host\": \"css-tricks.com\",\n    \"heat\": 54,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"guozaoke\",\n    \"name\": \"guozaoke\",\n    \"host\": \"guozaoke.com\",\n    \"heat\": 54,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"12371\",\n    \"name\": \"共产党员网\",\n    \"host\": \"12371.cn\",\n    \"heat\": 54,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"chinaventure\",\n    \"name\": \"投中网\",\n    \"host\": \"chinaventure.com.cn\",\n    \"heat\": 54,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"gamebase\",\n    \"name\": \"遊戲基地 Gamebase\",\n    \"host\": \"news.gamebase.com.tw\",\n    \"heat\": 54,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"codeforces\",\n    \"name\": \"Codeforces\",\n    \"host\": \"codeforces.com\",\n    \"heat\": 53,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"hoyolab\",\n    \"name\": \"HoYoLAB\",\n    \"host\": \"hoyolab.com\",\n    \"heat\": 53,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"zotero\",\n    \"name\": \"Zotero\",\n    \"host\": \"zotero.org\",\n    \"heat\": 53,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"fangchan\",\n    \"name\": \"中房网\",\n    \"host\": \"fangchan.com\",\n    \"heat\": 53,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"modian\",\n    \"name\": \"摩点\",\n    \"host\": \"modian.com\",\n    \"heat\": 53,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"news\",\n    \"name\": \"新华社\",\n    \"host\": \"news.cn\",\n    \"heat\": 53,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"tgbus\",\n    \"name\": \"电玩巴士 TGBUS\",\n    \"host\": \"tgbus.com\",\n    \"heat\": 53,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"19lou\",\n    \"name\": \"19 楼\",\n    \"host\": \"19lou.com\",\n    \"heat\": 52,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"apache\",\n    \"name\": \"Apache\",\n    \"host\": \"apisix.apache.org\",\n    \"heat\": 52,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"chinacdc\",\n    \"name\": \"中国疾病预防控制中心\",\n    \"host\": \"chinacdc.cn\",\n    \"heat\": 52,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"nextjs\",\n    \"name\": \"Next.js\",\n    \"host\": \"nextjs.org\",\n    \"heat\": 51,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"finology\",\n    \"name\": \"Finology Insider\",\n    \"host\": \"insider.finology.in\",\n    \"heat\": 50,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"raycast\",\n    \"name\": \"Raycast\",\n    \"host\": \"raycast.com\",\n    \"heat\": 50,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"sciencedirect\",\n    \"name\": \"ScienceDirect\",\n    \"host\": \"sciencedirect.com\",\n    \"heat\": 50,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"unusualwhales\",\n    \"name\": \"Unusual Whales\",\n    \"host\": \"unusualwhales.com\",\n    \"heat\": 50,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"lang\",\n    \"name\": \"浪 Play 直播\",\n    \"host\": \"lang.live\",\n    \"heat\": 50,\n    \"categories\": [\"live\"]\n  },\n  {\n    \"key\": \"leiphone\",\n    \"name\": \"雷峰网\",\n    \"host\": \"leiphone.com\",\n    \"heat\": 50,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"oup\",\n    \"name\": \"Oxford University Press\",\n    \"host\": \"academic.oup.com\",\n    \"heat\": 49,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"u3c3\",\n    \"name\": \"U3C3\",\n    \"host\": \"u3c3.com\",\n    \"heat\": 49,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"iplaysoft\",\n    \"name\": \"异次元软件世界\",\n    \"host\": \"iplaysoft.com\",\n    \"heat\": 49,\n    \"categories\": [\"new-media\", \"program-update\"]\n  },\n  {\n    \"key\": \"amz123\",\n    \"name\": \"Amz123\",\n    \"host\": \"amz123.com\",\n    \"heat\": 48,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"c114\",\n    \"name\": \"C114 通信网\",\n    \"host\": \"c114.com.cn\",\n    \"heat\": 48,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cara\",\n    \"name\": \"Cara\",\n    \"host\": \"cara.app\",\n    \"heat\": 48,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"eventernote\",\n    \"name\": \"Eventernote\",\n    \"host\": \"eventernote.com\",\n    \"heat\": 48,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"thepetcity\",\n    \"name\": \"PetCity 毛孩日常\",\n    \"host\": \"thepetcity.co\",\n    \"heat\": 48,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"zhiy\",\n    \"name\": \"知园\",\n    \"host\": \"zhiy.cc\",\n    \"heat\": 48,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"storm\",\n    \"name\": \"風傳媒\",\n    \"host\": \"storm.mg\",\n    \"heat\": 48,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"nicovideo\",\n    \"name\": \"Niconico\",\n    \"host\": \"nicovideo.jp\",\n    \"heat\": 47,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"usenix\",\n    \"name\": \"USENIX\",\n    \"host\": \"usenix.org\",\n    \"heat\": 47,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"hket\",\n    \"name\": \"香港经济日报\",\n    \"host\": \"china.hket.com\",\n    \"heat\": 47,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"zed\",\n    \"name\": \"Zed\",\n    \"host\": \"zed.dev\",\n    \"heat\": 45,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"kiro\",\n    \"name\": \"Kiro\",\n    \"host\": \"kiro.dev\",\n    \"heat\": 44,\n    \"categories\": [\"programming\", \"program-update\"]\n  },\n  {\n    \"key\": \"nodejs\",\n    \"name\": \"Node.js\",\n    \"host\": \"nodejs.org\",\n    \"heat\": 44,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"cbc\",\n    \"name\": \"Canadian Broadcasting Corporation\",\n    \"host\": \"cbc.ca\",\n    \"heat\": 43,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"kpopping\",\n    \"name\": \"kpopping\",\n    \"host\": \"kpopping.com\",\n    \"heat\": 43,\n    \"categories\": [\"new-media\", \"picture\"]\n  },\n  {\n    \"key\": \"uniqlo\",\n    \"name\": \"Uniqlo\",\n    \"host\": \"uniqlo.com\",\n    \"heat\": 43,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"ccf\",\n    \"name\": \"中国计算机学会\",\n    \"host\": \"ccf.org.cn\",\n    \"heat\": 43,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"simpleinfo\",\n    \"name\": \"簡訊設計\",\n    \"host\": \"blog.simpleinfo.cc\",\n    \"heat\": 43,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"chub\",\n    \"name\": \"Chub\",\n    \"host\": \"chub.ai\",\n    \"heat\": 42,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"nogizaka46\",\n    \"name\": \"Sakamichi Series 坂道系列官网资讯\",\n    \"host\": \"news.nogizaka46.com\",\n    \"heat\": 42,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"beijingprice\",\n    \"name\": \"北京价格\",\n    \"host\": \"beijingprice.cn\",\n    \"heat\": 42,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"stheadline\",\n    \"name\": \"星島日報\",\n    \"host\": \"std.stheadline.com\",\n    \"heat\": 42,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"iguoguo\",\n    \"name\": \"爱果果\",\n    \"host\": \"iguoguo.net\",\n    \"heat\": 42,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"learnblockchain\",\n    \"name\": \"登链社区\",\n    \"host\": \"learnblockchain.cn\",\n    \"heat\": 42,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"nippon\",\n    \"name\": \"走进日本\",\n    \"host\": \"nippon.com\",\n    \"heat\": 42,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"69shu\",\n    \"name\": \"69书吧\",\n    \"host\": \"69shuba.cx\",\n    \"heat\": 41,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"jamesclear\",\n    \"name\": \"James Clear\",\n    \"host\": \"jamesclear.com\",\n    \"heat\": 41,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ndss-symposium\",\n    \"name\": \"Network and Distributed System Security (NDSS) Symposium\",\n    \"host\": \"ndss-symposium.org\",\n    \"heat\": 41,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"warp\",\n    \"name\": \"Warp\",\n    \"host\": \"warp.dev\",\n    \"heat\": 41,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"investor\",\n    \"name\": \"中国投资者网\",\n    \"host\": \"investor.org.cn\",\n    \"heat\": 41,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"cloudnative\",\n    \"name\": \"云原生社区\",\n    \"host\": \"cloudnative.to\",\n    \"heat\": 41,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"kaopu\",\n    \"name\": \"靠谱新闻\",\n    \"host\": \"kaopu.news\",\n    \"heat\": 41,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"daily\",\n    \"name\": \"Daily.dev\",\n    \"host\": \"app.daily.dev\",\n    \"heat\": 40,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"fx-markets\",\n    \"name\": \"FX Markets\",\n    \"host\": \"fx-markets.com\",\n    \"heat\": 40,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"misskey\",\n    \"name\": \"Misskey\",\n    \"host\": \"misskey.io\",\n    \"heat\": 40,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"famitsu\",\n    \"name\": \"ファミ通\",\n    \"host\": \"famitsu.com\",\n    \"heat\": 40,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"luxiangdong\",\n    \"name\": \"土猛的员外\",\n    \"host\": \"luxiangdong.com\",\n    \"heat\": 40,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"storyfm\",\n    \"name\": \"故事 FM\",\n    \"host\": \"storyfm.cn\",\n    \"heat\": 40,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"qqorw\",\n    \"name\": \"早报网\",\n    \"host\": \"qqorw.cn\",\n    \"heat\": 40,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"zjut\",\n    \"name\": \"浙江工业大学\",\n    \"host\": \"zjut.edu.cn\",\n    \"heat\": 40,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ddosi\",\n    \"name\": \"雨苁博客\",\n    \"host\": \"ddosi.org\",\n    \"heat\": 40,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"4gamers\",\n    \"name\": \"4Gamers\",\n    \"host\": \"4gamers.com.tw\",\n    \"heat\": 39,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"fansly\",\n    \"name\": \"Fansly\",\n    \"host\": \"fansly.com\",\n    \"heat\": 39,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"medium\",\n    \"name\": \"Medium\",\n    \"host\": \"medium.com\",\n    \"heat\": 39,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"saraba1st\",\n    \"name\": \"Saraba1st\",\n    \"host\": \"stage1st.com\",\n    \"heat\": 39,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"yoasobi-music\",\n    \"name\": \"Yoasobi Official\",\n    \"host\": \"yoasobi-music.jp\",\n    \"heat\": 39,\n    \"categories\": [\"live\"]\n  },\n  {\n    \"key\": \"xboxfan\",\n    \"name\": \"盒心光环\",\n    \"host\": \"xboxfan.com\",\n    \"heat\": 39,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"sakurazaka46\",\n    \"name\": \"Sakamichi Series 坂道系列官网资讯\",\n    \"host\": \"sakurazaka46.com\",\n    \"heat\": 38,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"afdian\",\n    \"name\": \"爱发电\",\n    \"host\": \"afdian.net\",\n    \"heat\": 38,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bgmlist\",\n    \"name\": \"番组放送\",\n    \"host\": \"bgmlist.com\",\n    \"heat\": 38,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"modrinth\",\n    \"name\": \"Modrinth\",\n    \"host\": \"modrinth.com\",\n    \"heat\": 37,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"skebetter\",\n    \"name\": \"Skebetter\",\n    \"host\": \"skebetter.com\",\n    \"heat\": 37,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"idolypride\",\n    \"name\": \"IDOLY PRIDE 偶像荣耀\",\n    \"host\": \"idolypride.jp\",\n    \"heat\": 36,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"quicker\",\n    \"name\": \"Quicker\",\n    \"host\": \"getquicker.net\",\n    \"heat\": 36,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"raspberrypi\",\n    \"name\": \"Raspberry Pi\",\n    \"host\": \"raspberrypi.com\",\n    \"heat\": 36,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"chinadaily\",\n    \"name\": \"中国日报网\",\n    \"host\": \"chinadaily.com.cn\",\n    \"heat\": 36,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"kurogames\",\n    \"name\": \"库洛游戏 | Kuro Games\",\n    \"host\": \"kurogames.com\",\n    \"heat\": 36,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"keep\",\n    \"name\": \"Keep\",\n    \"host\": \"gotokeep.com\",\n    \"heat\": 35,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"chinathinktanks\",\n    \"name\": \"中国智库网\",\n    \"host\": \"chinathinktanks.org.cn\",\n    \"heat\": 35,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"mwm\",\n    \"name\": \"管理世界\",\n    \"host\": \"mwm.net.cn\",\n    \"heat\": 35,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"imiker\",\n    \"name\": \"米课\",\n    \"host\": \"imiker.com\",\n    \"heat\": 35,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"p-articles\",\n    \"name\": \"虚词\",\n    \"host\": \"p-articles.com\",\n    \"heat\": 35,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"barronschina\",\n    \"name\": \"巴伦周刊中文版\",\n    \"host\": \"barronschina.com.cn\",\n    \"heat\": 34,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"mirrormedia\",\n    \"name\": \"鏡週刊 Mirror Media\",\n    \"host\": \"mirrormedia.mg\",\n    \"heat\": 34,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"btzj\",\n    \"name\": \"BT 之家\",\n    \"host\": \"btbtt20.com\",\n    \"heat\": 33,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"clickme\",\n    \"name\": \"ClickMe\",\n    \"host\": \"clickme.net\",\n    \"heat\": 33,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"lemmy\",\n    \"name\": \"Lemmy\",\n    \"host\": \"join-lemmy.org\",\n    \"heat\": 33,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"mcmod\",\n    \"name\": \"MC百科\",\n    \"host\": \"mcmod.cn\",\n    \"heat\": 33,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"nmc\",\n    \"name\": \"中央气象台\",\n    \"host\": \"nmc.cn\",\n    \"heat\": 33,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"bjx\",\n    \"name\": \"北极星电力网\",\n    \"host\": \"bjx.com.cn\",\n    \"heat\": 33,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"asiantolick\",\n    \"name\": \"Asian to lick\",\n    \"host\": \"asiantolick.com\",\n    \"heat\": 32,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"home-assistant\",\n    \"name\": \"Home Assistant\",\n    \"host\": \"home-assistant.io\",\n    \"heat\": 32,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"macfilos\",\n    \"name\": \"Macfilos\",\n    \"host\": \"macfilos.com\",\n    \"heat\": 32,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"qwenlm\",\n    \"name\": \"Qwen Blog\",\n    \"host\": \"qwenlm.github.io\",\n    \"heat\": 32,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"tailwindcss\",\n    \"name\": \"TailwindCSS\",\n    \"host\": \"tailwindcss.com\",\n    \"heat\": 32,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"visualstudio\",\n    \"name\": \"Visual Studio\",\n    \"host\": \"visualstudio.com\",\n    \"heat\": 32,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"nju\",\n    \"name\": \"南京大学\",\n    \"host\": \"admission.nju.edu.cn\",\n    \"heat\": 32,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"iehou\",\n    \"name\": \"网猴线报\",\n    \"host\": \"iehou.com\",\n    \"heat\": 32,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"amazon\",\n    \"name\": \"Amazon\",\n    \"host\": \"amazon.com\",\n    \"heat\": 31,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"grist\",\n    \"name\": \"Grist\",\n    \"host\": \"grist.org\",\n    \"heat\": 31,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"xmanhua\",\n    \"name\": \"X 漫画\",\n    \"host\": \"xmanhua.com\",\n    \"heat\": 31,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"ruankao\",\n    \"name\": \"中国计算机职业技术资格考试\",\n    \"host\": \"ruankao.org.cn\",\n    \"heat\": 31,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"ycwb\",\n    \"name\": \"羊城晚报金羊网\",\n    \"host\": \"xwlb.com.cn\",\n    \"heat\": 31,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"78dm\",\n    \"name\": \"78 动漫\",\n    \"host\": \"78dm.net\",\n    \"heat\": 30,\n    \"categories\": [\"anime\", \"new-media\"]\n  },\n  {\n    \"key\": \"9to5\",\n    \"name\": \"9To5\",\n    \"host\": \"9to5toys.com\",\n    \"heat\": 30,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"android\",\n    \"name\": \"Android\",\n    \"host\": \"developer.android.com\",\n    \"heat\": 30,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"meteor\",\n    \"name\": \"Meteor\",\n    \"host\": \"meteor.today\",\n    \"heat\": 30,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"mygopen\",\n    \"name\": \"MyGoPen\",\n    \"host\": \"mygopen.com\",\n    \"heat\": 30,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"hexun\",\n    \"name\": \"和讯网\",\n    \"host\": \"hexun.com\",\n    \"heat\": 30,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"ncpssd\",\n    \"name\": \"国家哲学社会科学文献中心\",\n    \"host\": \"ncpssd.cn\",\n    \"heat\": 30,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"zjol\",\n    \"name\": \"浙江在线\",\n    \"host\": \"zjol.com.cn\",\n    \"heat\": 30,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"cohere\",\n    \"name\": \"Cohere\",\n    \"host\": \"cohere.com\",\n    \"heat\": 29,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"lxixsxa\",\n    \"name\": \"LiSA\",\n    \"host\": \"sonymusic.co.jp\",\n    \"heat\": 29,\n    \"categories\": [\"live\"]\n  },\n  {\n    \"key\": \"miui\",\n    \"name\": \"MIUI\",\n    \"host\": \"miui.com\",\n    \"heat\": 29,\n    \"categories\": [\"bbs\", \"program-update\"]\n  },\n  {\n    \"key\": \"embassy\",\n    \"name\": \"中国驻外使领馆\",\n    \"host\": \"ca.china-embassy.org\",\n    \"heat\": 29,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"mingpao\",\n    \"name\": \"明報\",\n    \"host\": \"mingpao.com\",\n    \"heat\": 29,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"europechinese\",\n    \"name\": \"歐洲動態（國際）\",\n    \"host\": \"europechinese.blogspot.com\",\n    \"heat\": 29,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"air-level\",\n    \"name\": \"Air-Level\",\n    \"host\": \"air-level.com\",\n    \"heat\": 28,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"flashcat\",\n    \"name\": \"Flashcat\",\n    \"host\": \"flashcat.cloud\",\n    \"heat\": 28,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"inspirehep\",\n    \"name\": \"INSPIRE\",\n    \"host\": \"inspirehep.net\",\n    \"heat\": 28,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"kbs\",\n    \"name\": \"KBS\",\n    \"host\": \"world.kbs.co.kr\",\n    \"heat\": 28,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"rfa\",\n    \"name\": \"Radio Free Asia (RFA) 自由亚洲电台\",\n    \"host\": \"rfa.org\",\n    \"heat\": 28,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"xjtu\",\n    \"name\": \"西安交通大学\",\n    \"host\": \"2yuan.xjtu.edu.cn\",\n    \"heat\": 28,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"mirror\",\n    \"name\": \"Mirror\",\n    \"host\": \"mirror.xyz\",\n    \"heat\": 27,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cpta\",\n    \"name\": \"中国人事考试网\",\n    \"host\": \"cpta.com.cn\",\n    \"heat\": 27,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"gamme\",\n    \"name\": \"卡卡洛普\",\n    \"host\": \"news.gamme.com.tw\",\n    \"heat\": 27,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"dehenglaw\",\n    \"name\": \"德恒律师事务所\",\n    \"host\": \"dehenglaw.com\",\n    \"heat\": 27,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"lhratings\",\n    \"name\": \"联合资信评估股份有限公司\",\n    \"host\": \"lhratings.com\",\n    \"heat\": 27,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"agora0\",\n    \"name\": \"AG⓪RA\",\n    \"host\": \"agorahub.github.io\",\n    \"heat\": 26,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dealstreetasia\",\n    \"name\": \"DealStreetAsia\",\n    \"host\": \"dealstreetasia.com\",\n    \"heat\": 26,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gamekee\",\n    \"name\": \"GameKee | 游戏百科攻略\",\n    \"host\": \"gamekee.com\",\n    \"heat\": 26,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"greasyfork\",\n    \"name\": \"Greasy Fork\",\n    \"host\": \"greasyfork.org\",\n    \"heat\": 26,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"sankei\",\n    \"name\": \"Sankei Shimbun 産経新聞\",\n    \"host\": \"sankei.com\",\n    \"heat\": 26,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"wikipedia\",\n    \"name\": \"Wikipedia\",\n    \"host\": \"en.wikipedia.org\",\n    \"heat\": 26,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"bdys\",\n    \"name\": \"哔嘀影视\",\n    \"host\": \"52bdys.com\",\n    \"heat\": 26,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"cfr\",\n    \"name\": \"Council on Foreign Relations\",\n    \"host\": \"cfr.org\",\n    \"heat\": 25,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gitee\",\n    \"name\": \"Gitee\",\n    \"host\": \"gitee.com\",\n    \"heat\": 25,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"sjtu\",\n    \"name\": \"上海交通大学\",\n    \"host\": \"sjtu.edu.cn\",\n    \"heat\": 25,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"jimmyspa\",\n    \"name\": \"幾米 JIMMY S.P.A. Official Website\",\n    \"host\": \"jimmyspa.com\",\n    \"heat\": 25,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"newrank\",\n    \"name\": \"新榜\",\n    \"host\": \"newrank.cn\",\n    \"heat\": 25,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"pacilution\",\n    \"name\": \"普世社会科学研究所\",\n    \"host\": \"pacilution.com\",\n    \"heat\": 25,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"atcoder\",\n    \"name\": \"AtCoder\",\n    \"host\": \"atcoder.jp\",\n    \"heat\": 24,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"zimuxia\",\n    \"name\": \"FIX 字幕侠\",\n    \"host\": \"zimuxia.cn\",\n    \"heat\": 24,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"msn\",\n    \"name\": \"MSN\",\n    \"host\": \"msn.com\",\n    \"heat\": 24,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"theblock\",\n    \"name\": \"TheBlock\",\n    \"host\": \"theblock.co\",\n    \"heat\": 24,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"dxy\",\n    \"name\": \"丁香园\",\n    \"host\": \"dxy.cn\",\n    \"heat\": 24,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"ustc\",\n    \"name\": \"中国科学技术大学\",\n    \"host\": \"ustc.edu.cn\",\n    \"heat\": 24,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ff14\",\n    \"name\": \"FINAL FANTASY XIV\",\n    \"host\": \"eu.finalfantasyxiv.com\",\n    \"heat\": 23,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"koreaherald\",\n    \"name\": \"The Korea Herald\",\n    \"host\": \"koreaherald.com\",\n    \"heat\": 23,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"banshujiang\",\n    \"name\": \"搬书匠\",\n    \"host\": \"banshujiang.cn\",\n    \"heat\": 23,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"lianxh\",\n    \"name\": \"连享会\",\n    \"host\": \"lianxh.cn\",\n    \"heat\": 23,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bandcamp\",\n    \"name\": \"Bandcamp\",\n    \"host\": \"bandcamp.com\",\n    \"heat\": 22,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"cybersecurityventures\",\n    \"name\": \"Cybercrime Magazine\",\n    \"host\": \"cybersecurityventures.com\",\n    \"heat\": 22,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"deepin\",\n    \"name\": \"Deepin\",\n    \"host\": \"bbs.deepin.org\",\n    \"heat\": 22,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"mysql\",\n    \"name\": \"MySQL\",\n    \"host\": \"dev.mysql.com\",\n    \"heat\": 22,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"sobooks\",\n    \"name\": \"SoBooks\",\n    \"host\": \"sobooks.net\",\n    \"heat\": 22,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"oreno3d\",\n    \"name\": \"俺の 3D エロ動画 (oreno3d)\",\n    \"host\": \"oreno3d.com\",\n    \"heat\": 22,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"houxu\",\n    \"name\": \"后续\",\n    \"host\": \"houxu.app\",\n    \"heat\": 22,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"freexcomic\",\n    \"name\": \"漫小肆韓漫\",\n    \"host\": \"freexcomic.com\",\n    \"heat\": 22,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"uestc\",\n    \"name\": \"电子科技大学\",\n    \"host\": \"uestc.edu.cn\",\n    \"heat\": 22,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"xidian\",\n    \"name\": \"西安电子科技大学\",\n    \"host\": \"xidian.edu.cn\",\n    \"heat\": 22,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"gelbooru\",\n    \"name\": \"Gelbooru\",\n    \"host\": \"gelbooru.com\",\n    \"heat\": 21,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"mox\",\n    \"name\": \"Mox.moe\",\n    \"host\": \"mox.moe\",\n    \"heat\": 21,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"chinaratings\",\n    \"name\": \"中债资信评估有限责任公司\",\n    \"host\": \"chinaratings.com.cn\",\n    \"heat\": 21,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"oeeee\",\n    \"name\": \"南方都市报\",\n    \"host\": \"oeeee.com\",\n    \"heat\": 21,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"dewu\",\n    \"name\": \"得物\",\n    \"host\": \"dewu.com\",\n    \"heat\": 21,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"wabei\",\n    \"name\": \"挖贝网\",\n    \"host\": \"wabei.cn\",\n    \"heat\": 21,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"caareviews\",\n    \"name\": \"caa.reviews\",\n    \"host\": \"caareviews.org\",\n    \"heat\": 20,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"cbndata\",\n    \"name\": \"CBNData\",\n    \"host\": \"cbndata.com\",\n    \"heat\": 20,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"eprice\",\n    \"name\": \"ePrice\",\n    \"host\": \"eprice.com.tw\",\n    \"heat\": 20,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"joshwcomeau\",\n    \"name\": \"Josh W Comeau\",\n    \"host\": \"joshwcomeau.com\",\n    \"heat\": 20,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"manyvids\",\n    \"name\": \"ManyVids\",\n    \"host\": \"manyvids.com\",\n    \"heat\": 20,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"yna\",\n    \"name\": \"Yonhap News Agency\",\n    \"host\": \"yna.co.kr\",\n    \"heat\": 20,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"chinawriter\",\n    \"name\": \"中国作家网\",\n    \"host\": \"chinawriter.com.cn\",\n    \"heat\": 20,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"nankai\",\n    \"name\": \"南开大学\",\n    \"host\": \"yzb.nankai.edu.cn\",\n    \"heat\": 20,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"tongji\",\n    \"name\": \"同济大学\",\n    \"host\": \"bksy.tongji.edu.cn\",\n    \"heat\": 20,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"luogu\",\n    \"name\": \"洛谷\",\n    \"host\": \"luogu.com.cn\",\n    \"heat\": 20,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"meishichina\",\n    \"name\": \"美食天下\",\n    \"host\": \"meishichina.com\",\n    \"heat\": 20,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"hitcon\",\n    \"name\": \"HITCON\",\n    \"host\": \"hitcon.org\",\n    \"heat\": 19,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"paradigm\",\n    \"name\": \"Paradigm\",\n    \"host\": \"paradigm.xyz\",\n    \"heat\": 19,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"radio-canada\",\n    \"name\": \"Radio-Canada.ca\",\n    \"host\": \"ici.radio-canada.ca\",\n    \"heat\": 19,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"who\",\n    \"name\": \"World Health Organization | WHO\",\n    \"host\": \"who.int\",\n    \"heat\": 19,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"zuvio\",\n    \"name\": \"Zuvio\",\n    \"host\": \"irs.zuvio.com.tw\",\n    \"heat\": 19,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"agri\",\n    \"name\": \"中国农业农村信息网\",\n    \"host\": \"agri.cn\",\n    \"heat\": 19,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cffex\",\n    \"name\": \"中国金融期货交易所\",\n    \"host\": \"cffex.com.cn\",\n    \"heat\": 19,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"0xxx\",\n    \"name\": \"0xxx.ws\",\n    \"host\": \"0xxx.ws\",\n    \"heat\": 18,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"counter-strike\",\n    \"name\": \"Counter Strike\",\n    \"host\": \"counter-strike.net\",\n    \"heat\": 18,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"duckdb\",\n    \"name\": \"DuckDB Foundation\",\n    \"host\": \"duckdb.org\",\n    \"heat\": 18,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"loongarch\",\n    \"name\": \"LA UOSC社区\",\n    \"host\": \"loongarch.org\",\n    \"heat\": 18,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"syosetu\",\n    \"name\": \"Syosetu\",\n    \"host\": \"syosetu.com\",\n    \"heat\": 18,\n    \"categories\": [\"program-update\", \"reading\"]\n  },\n  {\n    \"key\": \"zju\",\n    \"name\": \"浙江大学\",\n    \"host\": \"physics.zju.edu.cn\",\n    \"heat\": 18,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"maoyan\",\n    \"name\": \"猫眼电影\",\n    \"host\": \"maoyan.com\",\n    \"heat\": 18,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"dtcj\",\n    \"name\": \"DT 财经\",\n    \"host\": \"dtcj.com\",\n    \"heat\": 17,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"f95zone\",\n    \"name\": \"F95zone\",\n    \"host\": \"f95zone.to\",\n    \"heat\": 17,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"hamel\",\n    \"name\": \"Hamel's Blog\",\n    \"host\": \"hamel.dev\",\n    \"heat\": 17,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"firecore\",\n    \"name\": \"Infuse\",\n    \"host\": \"firecore.com\",\n    \"heat\": 17,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"inoreader\",\n    \"name\": \"Inoreader\",\n    \"host\": \"inoreader.com\",\n    \"heat\": 17,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"iheima\",\n    \"name\": \"i黑马网\",\n    \"host\": \"iheima.com\",\n    \"heat\": 17,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"kpmg\",\n    \"name\": \"KPMG\",\n    \"host\": \"kpmg.com\",\n    \"heat\": 17,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"qbittorrent\",\n    \"name\": \"qBittorrent\",\n    \"host\": \"qbittorrent.org\",\n    \"heat\": 17,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"scientificamerican\",\n    \"name\": \"Scientific American\",\n    \"host\": \"scientificamerican.com\",\n    \"heat\": 17,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"smashingmagazine\",\n    \"name\": \"Smashing Magazine\",\n    \"host\": \"smashingmagazine.com\",\n    \"heat\": 17,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"typora\",\n    \"name\": \"Typora\",\n    \"host\": \"typora.io\",\n    \"heat\": 17,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"cn-healthcare\",\n    \"name\": \"健康界\",\n    \"host\": \"cn-healthcare.com\",\n    \"heat\": 17,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"nextapple\",\n    \"name\": \"壹蘋新聞網\",\n    \"host\": \"tw.nextapple.com\",\n    \"heat\": 17,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"xiaote\",\n    \"name\": \"小特社区\",\n    \"host\": \"xiaote.com\",\n    \"heat\": 17,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"adquan\",\n    \"name\": \"广告门\",\n    \"host\": \"adquan.com\",\n    \"heat\": 17,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"zcmu\",\n    \"name\": \"浙江中医药大学\",\n    \"host\": \"jwc.zcmu.edu.cn\",\n    \"heat\": 17,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hedwig\",\n    \"name\": \"Hedwig\",\n    \"host\": \"hedwig.pub\",\n    \"heat\": 16,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"lvv2\",\n    \"name\": \"LVV2\",\n    \"host\": \"lvv2.com\",\n    \"heat\": 16,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sfacg\",\n    \"name\": \"SF 轻小说\",\n    \"host\": \"book.sfacg.com\",\n    \"heat\": 16,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"subhd\",\n    \"name\": \"Sub HD\",\n    \"host\": \"subhd.tv\",\n    \"heat\": 16,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"vertikal\",\n    \"name\": \"Vertikal.net\",\n    \"host\": \"vertikal.net\",\n    \"heat\": 16,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"kakuyomu\",\n    \"name\": \"カクヨム\",\n    \"host\": \"kakuyomu.jp\",\n    \"heat\": 16,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"cngold\",\n    \"name\": \"中国黄金协会\",\n    \"host\": \"cngold.org.cn\",\n    \"heat\": 16,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"hnrb\",\n    \"name\": \"湖南日报\",\n    \"host\": \"voc.com.cn\",\n    \"heat\": 16,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"swjtu\",\n    \"name\": \"西南交通大学\",\n    \"host\": \"swjtu.edu.cn\",\n    \"heat\": 16,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"5eplay\",\n    \"name\": \"5EPLAY\",\n    \"host\": \"csgo.5eplay.com\",\n    \"heat\": 15,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"alistapart\",\n    \"name\": \"A List Apart\",\n    \"host\": \"alistapart.com\",\n    \"heat\": 15,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"ai-bot\",\n    \"name\": \"AI工具集\",\n    \"host\": \"ai-bot.cn\",\n    \"heat\": 15,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"bestofjs\",\n    \"name\": \"Best of JS\",\n    \"host\": \"bestofjs.org\",\n    \"heat\": 15,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bullionvault\",\n    \"name\": \"BullionVault\",\n    \"host\": \"bullionvault.com\",\n    \"heat\": 15,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"hex-rays\",\n    \"name\": \"Hex-Rays\",\n    \"host\": \"hex-rays.com\",\n    \"heat\": 15,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"hko\",\n    \"name\": \"Hong Kong Observatory\",\n    \"host\": \"hko.gov.hk\",\n    \"heat\": 15,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"hyperdash\",\n    \"name\": \"HyperDash\",\n    \"host\": \"hyperdash.info\",\n    \"heat\": 15,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"konghq\",\n    \"name\": \"Kong API 网关平台\",\n    \"host\": \"konghq.com\",\n    \"heat\": 15,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"kunchengblog\",\n    \"name\": \"Kun Cheng\",\n    \"host\": \"kunchengblog.com\",\n    \"heat\": 15,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"malaysiakini\",\n    \"name\": \"Malaysiakini\",\n    \"host\": \"malaysiakini.com\",\n    \"heat\": 15,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"psnine\",\n    \"name\": \"PSN 中文站\",\n    \"host\": \"psnine.com\",\n    \"heat\": 15,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"skeb\",\n    \"name\": \"Skeb\",\n    \"host\": \"skeb.jp\",\n    \"heat\": 15,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"thegradient\",\n    \"name\": \"The Gradient\",\n    \"host\": \"thegradient.pub\",\n    \"heat\": 15,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"wdfxw\",\n    \"name\": \"WDFXW文档分享网\",\n    \"host\": \"wdfxw.net\",\n    \"heat\": 15,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"ce\",\n    \"name\": \"中国经济网\",\n    \"host\": \"ce.cn\",\n    \"heat\": 15,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"zaimanhua\",\n    \"name\": \"再漫画\",\n    \"host\": \"manhua.zaimanhua.com\",\n    \"heat\": 15,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"hust\",\n    \"name\": \"华中科技大学\",\n    \"host\": \"hust.edu.cn\",\n    \"heat\": 15,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"scut\",\n    \"name\": \"华南理工大学\",\n    \"host\": \"jw.scut.edu.cn\",\n    \"heat\": 15,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"logclub\",\n    \"name\": \"罗戈网\",\n    \"host\": \"logclub.com\",\n    \"heat\": 15,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"booru\",\n    \"name\": \"Booru\",\n    \"host\": \"mmda.booru.org\",\n    \"heat\": 14,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"cssn\",\n    \"name\": \"Chinese Social Science Net\",\n    \"host\": \"iolaw.cssn.cn\",\n    \"heat\": 14,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"dora-world\",\n    \"name\": \"Doraemon Channel\",\n    \"host\": \"dora-world.com\",\n    \"heat\": 14,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"hackmd\",\n    \"name\": \"HackMD\",\n    \"host\": \"hackmd.io\",\n    \"heat\": 14,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"publico\",\n    \"name\": \"Público\",\n    \"host\": \"publico.es\",\n    \"heat\": 14,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"rsc\",\n    \"name\": \"Royal Society of Chemistry\",\n    \"host\": \"pubs.rsc.org\",\n    \"heat\": 14,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"tokeninsight\",\n    \"name\": \"TokenInsight\",\n    \"host\": \"tokeninsight.com\",\n    \"heat\": 14,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"crac\",\n    \"name\": \"中国无线电协会业余无线电分会\",\n    \"host\": \"crac.org.cn\",\n    \"heat\": 14,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"boc\",\n    \"name\": \"中国银行\",\n    \"host\": \"boc.cn\",\n    \"heat\": 14,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"pts\",\n    \"name\": \"公視新聞網\",\n    \"host\": \"news.pts.org.tw\",\n    \"heat\": 14,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"hit\",\n    \"name\": \"哈尔滨工业大学\",\n    \"host\": \"hit.edu.cn\",\n    \"heat\": 14,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"gmcmonline\",\n    \"name\": \"国门传媒在线\",\n    \"host\": \"gmcmonline.com\",\n    \"heat\": 14,\n    \"categories\": [\"journal\", \"reading\"]\n  },\n  {\n    \"key\": \"whu\",\n    \"name\": \"武汉大学\",\n    \"host\": \"cs.whu.edu.cn\",\n    \"heat\": 14,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"mpaypass\",\n    \"name\": \"移动支付网\",\n    \"host\": \"mpaypass.com.cn\",\n    \"heat\": 14,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"fxiaoke\",\n    \"name\": \"纷享销客 CRM\",\n    \"host\": \"fxiaoke.com\",\n    \"heat\": 14,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"modb\",\n    \"name\": \"墨天轮\",\n    \"host\": \"modb.pro\",\n    \"heat\": 14,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bfl\",\n    \"name\": \"BFL AI\",\n    \"host\": \"bfl.ai\",\n    \"heat\": 13,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"bioone\",\n    \"name\": \"BioOne\",\n    \"host\": \"bioone.org\",\n    \"heat\": 13,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"brave\",\n    \"name\": \"Brave\",\n    \"host\": \"brave.com\",\n    \"heat\": 13,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"f-droid\",\n    \"name\": \"F-Droid\",\n    \"host\": \"f-droid.org\",\n    \"heat\": 13,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"leagueoflegends\",\n    \"name\": \"League of Legends\",\n    \"host\": \"leagueoflegends.com\",\n    \"heat\": 13,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"nio\",\n    \"name\": \"NIO\",\n    \"host\": \"nio.com\",\n    \"heat\": 13,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"oshwhub\",\n    \"name\": \"oshwhub 立创开源硬件平台\",\n    \"host\": \"oshwhub.com\",\n    \"heat\": 13,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"qoo-app\",\n    \"name\": \"QooApp\",\n    \"host\": \"apps.qoo-app.com\",\n    \"heat\": 13,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"afr\",\n    \"name\": \"The Australian Financial Review\",\n    \"host\": \"afr.com\",\n    \"heat\": 13,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"uchicago\",\n    \"name\": \"The University of Chicago Press: Journals\",\n    \"host\": \"journals.uchicago.edu\",\n    \"heat\": 13,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"idolmaster\",\n    \"name\": \"アイドルマスター THE IDOLM@STER\",\n    \"host\": \"idolmaster-official.jp\",\n    \"heat\": 13,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"caai\",\n    \"name\": \"中国人工智能学会\",\n    \"host\": \"caai.cn\",\n    \"heat\": 13,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"asiafruitchina\",\n    \"name\": \"亚洲水果\",\n    \"host\": \"asiafruitchina.net\",\n    \"heat\": 13,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"kadokawa\",\n    \"name\": \"台灣角川\",\n    \"host\": \"kadokawa.com.tw\",\n    \"heat\": 13,\n    \"categories\": [\"shopping\", \"blog\"]\n  },\n  {\n    \"key\": \"xkb\",\n    \"name\": \"新快报\",\n    \"host\": \"xkb.com.cn\",\n    \"heat\": 13,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"qiyoujiage\",\n    \"name\": \"汽油价格网\",\n    \"host\": \"qiyoujiage.com\",\n    \"heat\": 13,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"aqicn\",\n    \"name\": \"空气质量\",\n    \"host\": \"aqicn.org\",\n    \"heat\": 13,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ifun\",\n    \"name\": \"趣集\",\n    \"host\": \"ifun.cool\",\n    \"heat\": 13,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ltaaa\",\n    \"name\": \"龙腾网\",\n    \"host\": \"ltaaa.cn\",\n    \"heat\": 13,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"12306\",\n    \"name\": \"12306\",\n    \"host\": \"kyfw.12306.cn\",\n    \"heat\": 12,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"dataguidance\",\n    \"name\": \"DataGuidance\",\n    \"host\": \"dataguidance.com\",\n    \"heat\": 12,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"egsea\",\n    \"name\": \"e 公司\",\n    \"host\": \"egsea.com\",\n    \"heat\": 12,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"gs\",\n    \"name\": \"Goldman Sachs\",\n    \"host\": \"goldmansachs.com\",\n    \"heat\": 12,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"itch\",\n    \"name\": \"itch.io\",\n    \"host\": \"itch.io\",\n    \"heat\": 12,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"thzt\",\n    \"name\": \"thzt\",\n    \"host\": \"thzt.github.io\",\n    \"heat\": 12,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"towardsdatascience\",\n    \"name\": \"Towards Data Science\",\n    \"host\": \"towardsdatascience.com\",\n    \"heat\": 12,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"wellcee\",\n    \"name\": \"Wellcee 唯心所寓\",\n    \"host\": \"wellcee.com\",\n    \"heat\": 12,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"worldjournal\",\n    \"name\": \"世界新聞網\",\n    \"host\": \"worldjournal.com\",\n    \"heat\": 12,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bupt\",\n    \"name\": \"北京邮电大学\",\n    \"host\": \"bupt.edu.cn\",\n    \"heat\": 12,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"nuist\",\n    \"name\": \"南京信息工程大学\",\n    \"host\": \"bulletin.nuist.edu.cn\",\n    \"heat\": 12,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"scu\",\n    \"name\": \"四川大学\",\n    \"host\": \"scu.edu.cn\",\n    \"heat\": 12,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"zhizhuan100\",\n    \"name\": \"智篆商业\",\n    \"host\": \"zhizhuan100.com.cn\",\n    \"heat\": 12,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"nbd\",\n    \"name\": \"每经网\",\n    \"host\": \"nbd.com.cn\",\n    \"heat\": 12,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"chuapp\",\n    \"name\": \"触乐\",\n    \"host\": \"chuapp.com\",\n    \"heat\": 12,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"10000link\",\n    \"name\": \"10000万联网\",\n    \"host\": \"10000link.com\",\n    \"heat\": 11,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"51read\",\n    \"name\": \"51Read\",\n    \"host\": \"m.51read.org\",\n    \"heat\": 11,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"gitstar-ranking\",\n    \"name\": \"Gitstar Ranking\",\n    \"host\": \"gitstar-ranking.com\",\n    \"heat\": 11,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"mdpi\",\n    \"name\": \"MDPI\",\n    \"host\": \"mdpi.com\",\n    \"heat\": 11,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"nyaa\",\n    \"name\": \"Nyaa\",\n    \"host\": \"nyaa.si\",\n    \"heat\": 11,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"stanford\",\n    \"name\": \"Stanford\",\n    \"host\": \"hazyresearch.stanford.edu\",\n    \"heat\": 11,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"thwiki\",\n    \"name\": \"THBWiki\",\n    \"host\": \"thwiki.cc\",\n    \"heat\": 11,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"tradingview\",\n    \"name\": \"TradingView\",\n    \"host\": \"tradingview.com\",\n    \"heat\": 11,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"x-mol\",\n    \"name\": \"X-MOL\",\n    \"host\": \"x-mol.com\",\n    \"heat\": 11,\n    \"categories\": [\"study\", \"journal\"]\n  },\n  {\n    \"key\": \"scnu\",\n    \"name\": \"华南师范大学\",\n    \"host\": \"cs.scnu.edu.cn\",\n    \"heat\": 11,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cmde\",\n    \"name\": \"国家药品监督管理局医疗器械技术审评中心\",\n    \"host\": \"cmde.org.cn\",\n    \"heat\": 11,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"apiseven\",\n    \"name\": \"支流科技\",\n    \"host\": \"apiseven.com\",\n    \"heat\": 11,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"hunanpea\",\n    \"name\": \"湖南人事考试网\",\n    \"host\": \"rsks.hunanpea.com\",\n    \"heat\": 11,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"iqnew\",\n    \"name\": \"爱 Q 生活网\",\n    \"host\": \"iqnew.com\",\n    \"heat\": 11,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"toodaylab\",\n    \"name\": \"理想生活实验室\",\n    \"host\": \"toodaylab.com\",\n    \"heat\": 11,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"tisi\",\n    \"name\": \"腾讯研究院\",\n    \"host\": \"tisi.org\",\n    \"heat\": 11,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ceph\",\n    \"name\": \"Ceph\",\n    \"host\": \"ceph.io\",\n    \"heat\": 10,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"crossbell\",\n    \"name\": \"Crossbell\",\n    \"host\": \"crossbell.io\",\n    \"heat\": 10,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"fantube\",\n    \"name\": \"FANTUBE\",\n    \"host\": \"fantube.tokyo\",\n    \"heat\": 10,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"lorientlejour\",\n    \"name\": \"L'Orient-Le Jour/L'Orient Today\",\n    \"host\": \"lorientlejour.com\",\n    \"heat\": 10,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"lovelive-anime\",\n    \"name\": \"Love Live! Official Website\",\n    \"host\": \"lovelive-anime.jp\",\n    \"heat\": 10,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"macmenubar\",\n    \"name\": \"MacMenuBar\",\n    \"host\": \"macmenubar.com\",\n    \"heat\": 10,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"neatdownloadmanager\",\n    \"name\": \"Neat Download Manager\",\n    \"host\": \"neatdownloadmanager.com\",\n    \"heat\": 10,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"npm\",\n    \"name\": \"NPM\",\n    \"host\": \"npmjs.com\",\n    \"heat\": 10,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"orcid\",\n    \"name\": \"ORCID\",\n    \"host\": \"orcid.org\",\n    \"heat\": 10,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"taptap\",\n    \"name\": \"TapTap\",\n    \"host\": \"taptap.io\",\n    \"heat\": 10,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"u9a9\",\n    \"name\": \"U9A9\",\n    \"host\": \"u9a9.com\",\n    \"heat\": 10,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"chinatimes\",\n    \"name\": \"中時新聞網\",\n    \"host\": \"chinatimes.com\",\n    \"heat\": 10,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"jlu\",\n    \"name\": \"吉林大学\",\n    \"host\": \"jlu.edu.cn\",\n    \"heat\": 10,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hrbeu\",\n    \"name\": \"哈尔滨工程大学\",\n    \"host\": \"yjsy.hrbeu.edu.cn\",\n    \"heat\": 10,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"changba\",\n    \"name\": \"唱吧\",\n    \"host\": \"changba.com\",\n    \"heat\": 10,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"samrdprc\",\n    \"name\": \"国家市场监督管理总局缺陷产品管理中心\",\n    \"host\": \"samrdprc.org.cn\",\n    \"heat\": 10,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"fanqienovel\",\n    \"name\": \"番茄小说\",\n    \"host\": \"fanqienovel.com\",\n    \"heat\": 10,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"eeo\",\n    \"name\": \"经济观察网\",\n    \"host\": \"eeo.com.cn\",\n    \"heat\": 10,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"ally\",\n    \"name\": \"艾莱资讯\",\n    \"host\": \"rail.ally.net.cn\",\n    \"heat\": 10,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"bangumi.moe\",\n    \"name\": \"萌番组\",\n    \"host\": \"bangumi.online\",\n    \"heat\": 10,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"uraaka-joshi\",\n    \"name\": \"裏垢女子まとめ\",\n    \"host\": \"uraaka-joshi.com\",\n    \"heat\": 10,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wenku8\",\n    \"name\": \"轻小说文库\",\n    \"host\": \"wenku8.net\",\n    \"heat\": 10,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"bluearchive\",\n    \"name\": \"Blue Archive\",\n    \"host\": \"bluearchive.jp\",\n    \"heat\": 9,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"deepl\",\n    \"name\": \"DeepL\",\n    \"host\": \"deepl.com\",\n    \"heat\": 9,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"koyso\",\n    \"name\": \"Koyso\",\n    \"host\": \"koyso.to\",\n    \"heat\": 9,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"logrocket\",\n    \"name\": \"logrocket blog\",\n    \"host\": \"blog.logrocket.com\",\n    \"heat\": 9,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"seekingalpha\",\n    \"name\": \"Seeking Alpha\",\n    \"host\": \"seekingalpha.com\",\n    \"heat\": 9,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"thoughtco\",\n    \"name\": \"ThoughtCo\",\n    \"host\": \"thoughtco.com\",\n    \"heat\": 9,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"newsmarket\",\n    \"name\": \"上下游 News&Market\",\n    \"host\": \"newsmarket.com.tw\",\n    \"heat\": 9,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"southcn\",\n    \"name\": \"南方网\",\n    \"host\": \"nfapp.southcn.com\",\n    \"heat\": 9,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"duozhuayu\",\n    \"name\": \"多抓鱼\",\n    \"host\": \"duozhuayu.com\",\n    \"heat\": 9,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"jinritemai\",\n    \"name\": \"抖店开放平台\",\n    \"host\": \"op.jinritemai.com\",\n    \"heat\": 9,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"hdu\",\n    \"name\": \"杭州电子科技大学\",\n    \"host\": \"hdu.edu.cn\",\n    \"heat\": 9,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"acgvinyl\",\n    \"name\": \"ACG Vinyl - 黑胶\",\n    \"host\": \"acgvinyl.com\",\n    \"heat\": 8,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"aiblog-2xv\",\n    \"name\": \"AI 博客\",\n    \"host\": \"aiblog-2xv.pages.dev\",\n    \"heat\": 8,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"asianfanfics\",\n    \"name\": \"Asianfanfics\",\n    \"host\": \"asianfanfics.com\",\n    \"heat\": 8,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"btbtla\",\n    \"name\": \"BT影视\",\n    \"host\": \"btbtla.com\",\n    \"heat\": 8,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"fashionnetwork\",\n    \"name\": \"FashionNetwork\",\n    \"host\": \"fashionnetwork.cn\",\n    \"heat\": 8,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ipsw.dev\",\n    \"name\": \"IPSW.dev\",\n    \"host\": \"ipsw.dev\",\n    \"heat\": 8,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"notateslaapp\",\n    \"name\": \"Not a Tesla App\",\n    \"host\": \"notateslaapp.com\",\n    \"heat\": 8,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"lrepacks\",\n    \"name\": \"REPACK скачать\",\n    \"host\": \"lrepacks.net\",\n    \"heat\": 8,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"vom\",\n    \"name\": \"Voice of Mongolia\",\n    \"host\": \"vom.mn\",\n    \"heat\": 8,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gaoyu\",\n    \"name\": \"Yu Gao\",\n    \"host\": \"gaoyu.me\",\n    \"heat\": 8,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"cfachina\",\n    \"name\": \"中国期货业协会\",\n    \"host\": \"cfachina.org\",\n    \"heat\": 8,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cast\",\n    \"name\": \"中国科学技术协会\",\n    \"host\": \"cast.org.cn\",\n    \"heat\": 8,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"hfut\",\n    \"name\": \"合肥工业大学\",\n    \"host\": \"hfut.edu.cn\",\n    \"heat\": 8,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ncc-cma\",\n    \"name\": \"国家气候中心\",\n    \"host\": \"cmdp.ncc-cma.net\",\n    \"heat\": 8,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"xiaozhuanlan\",\n    \"name\": \"小专栏\",\n    \"host\": \"xiaozhuanlan.com\",\n    \"heat\": 8,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sdzk\",\n    \"name\": \"山东省教育招生考试院\",\n    \"host\": \"sdzk.cn\",\n    \"heat\": 8,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"tongli\",\n    \"name\": \"東立出版社\",\n    \"host\": \"tongli.com.tw\",\n    \"heat\": 8,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"hizu\",\n    \"name\": \"珠海网\",\n    \"host\": \"hizh.cn\",\n    \"heat\": 8,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"elecfans\",\n    \"name\": \"电子发烧友\",\n    \"host\": \"elecfans.com\",\n    \"heat\": 8,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"cts\",\n    \"name\": \"華視\",\n    \"host\": \"news.cts.com.tw\",\n    \"heat\": 8,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"lanqiao\",\n    \"name\": \"蓝桥云课\",\n    \"host\": \"lanqiao.cn\",\n    \"heat\": 8,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"accessbriefing\",\n    \"name\": \"Access Briefing\",\n    \"host\": \"accessbriefing.com\",\n    \"heat\": 7,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"aicaijing\",\n    \"name\": \"AI 财经社\",\n    \"host\": \"aicaijing.com\",\n    \"heat\": 7,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"asus\",\n    \"name\": \"ASUS\",\n    \"host\": \"asus.com.cn\",\n    \"heat\": 7,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"cmu\",\n    \"name\": \"Carnegie Mellon University\",\n    \"host\": \"cmu.edu\",\n    \"heat\": 7,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"dblp\",\n    \"name\": \"DBLP\",\n    \"host\": \"dblp.org\",\n    \"heat\": 7,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"lineageos\",\n    \"name\": \"LineageOS\",\n    \"host\": \"lineageos.org\",\n    \"heat\": 7,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"newslaundry\",\n    \"name\": \"Newslaundry\",\n    \"host\": \"newslaundry.com\",\n    \"heat\": 7,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"patreon\",\n    \"name\": \"Patreon\",\n    \"host\": \"patreon.com\",\n    \"heat\": 7,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"tver\",\n    \"name\": \"TVer\",\n    \"host\": \"tver.jp\",\n    \"heat\": 7,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"unraid\",\n    \"name\": \"Unraid\",\n    \"host\": \"unraid.net\",\n    \"heat\": 7,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"seu\",\n    \"name\": \"东南大学\",\n    \"host\": \"cse.seu.edu.cn\",\n    \"heat\": 7,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"csu\",\n    \"name\": \"中南大学\",\n    \"host\": \"career.csu.edu.cn\",\n    \"heat\": 7,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cartoonmad\",\n    \"name\": \"動漫狂\",\n    \"host\": \"cartoonmad.com\",\n    \"heat\": 7,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"bjwxdxh\",\n    \"name\": \"北京无线电协会\",\n    \"host\": \"bjwxdxh.org.cn\",\n    \"heat\": 7,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"consumer\",\n    \"name\": \"消费者委员会\",\n    \"host\": \"consumer.org.hk\",\n    \"heat\": 7,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"6park\",\n    \"name\": \"留园网\",\n    \"host\": \"club.6parkbbs.com\",\n    \"heat\": 7,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"nwnu\",\n    \"name\": \"西北师范大学\",\n    \"host\": \"nwnu.edu.cn\",\n    \"heat\": 7,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"abmedia\",\n    \"name\": \"链新闻 ABMedia\",\n    \"host\": \"abmedia.io\",\n    \"heat\": 7,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"alipan\",\n    \"name\": \"阿里云盘\",\n    \"host\": \"alipan.com\",\n    \"heat\": 7,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"cockroachlabs\",\n    \"name\": \"Cockroach Labs\",\n    \"host\": \"cockroachlabs.com\",\n    \"heat\": 6,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"deltaio\",\n    \"name\": \"Delta Lake\",\n    \"host\": \"delta.io\",\n    \"heat\": 6,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"dnaindia\",\n    \"name\": \"DNA India\",\n    \"host\": \"dnaindia.com\",\n    \"heat\": 6,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"e-hentai\",\n    \"name\": \"E-Hentai\",\n    \"host\": \"e-hentai.org\",\n    \"heat\": 6,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"instructables\",\n    \"name\": \"Instructables\",\n    \"host\": \"instructables.com\",\n    \"heat\": 6,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"mindmeister\",\n    \"name\": \"MindMeister\",\n    \"host\": \"mindmeister.com\",\n    \"heat\": 6,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"newzmz\",\n    \"name\": \"NEW 字幕组\",\n    \"host\": \"newzmz.com\",\n    \"heat\": 6,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"ntrblog\",\n    \"name\": \"NTR BLOG（寝取られブログ）\",\n    \"host\": \"ntrblog.com\",\n    \"heat\": 6,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"patagonia\",\n    \"name\": \"Patagonia\",\n    \"host\": \"patagonia.com\",\n    \"heat\": 6,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"hinatazaka46\",\n    \"name\": \"Sakamichi Series 坂道系列官网资讯\",\n    \"host\": \"hinatazaka46.com\",\n    \"heat\": 6,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"thehindu\",\n    \"name\": \"The Hindu\",\n    \"host\": \"thehindu.com\",\n    \"heat\": 6,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"tju\",\n    \"name\": \"Tianjin University 天津大学\",\n    \"host\": \"cic.tju.edu.cn\",\n    \"heat\": 6,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"neu\",\n    \"name\": \"东北大学\",\n    \"host\": \"neunews.neu.edu.cn\",\n    \"heat\": 6,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cgtn\",\n    \"name\": \"中国环球电视网\",\n    \"host\": \"cgtn.com\",\n    \"heat\": 6,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"5music\",\n    \"name\": \"五大唱片\",\n    \"host\": \"5music.com.tw\",\n    \"heat\": 6,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"catti\",\n    \"name\": \"全国翻译专业资格水平考试 (CATTI)\",\n    \"host\": \"catticenter.com\",\n    \"heat\": 6,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"buaa\",\n    \"name\": \"北京航空航天大学\",\n    \"host\": \"news.buaa.edu.cn\",\n    \"heat\": 6,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njust\",\n    \"name\": \"南京理工大学\",\n    \"host\": \"jwc.njust.edu.cn\",\n    \"heat\": 6,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"coolpc\",\n    \"name\": \"原價屋\",\n    \"host\": \"coolpc.com.tw\",\n    \"heat\": 6,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"dahecube\",\n    \"name\": \"大河财立方\",\n    \"host\": \"dahecube.com\",\n    \"heat\": 6,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dykszx\",\n    \"name\": \"德阳人事考试网\",\n    \"host\": \"dykszx.com\",\n    \"heat\": 6,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"kuaishou\",\n    \"name\": \"快手\",\n    \"host\": \"kuaishou.com\",\n    \"heat\": 6,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"xwenming\",\n    \"name\": \"未知文明\",\n    \"host\": \"xwenming.com\",\n    \"heat\": 6,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"alpinelinux\",\n    \"name\": \"Alpine Linux\",\n    \"host\": \"alpinelinux.org\",\n    \"heat\": 5,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"app-center\",\n    \"name\": \"App Center\",\n    \"host\": \"install.appcenter.ms\",\n    \"heat\": 5,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"cmpxchg8b\",\n    \"name\": \"cmpxchg8b\",\n    \"host\": \"lock.cmpxchg8b.com\",\n    \"heat\": 5,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"esquirehk\",\n    \"name\": \"Esquire Hong Kong\",\n    \"host\": \"esquirehk.com\",\n    \"heat\": 5,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"forklog\",\n    \"name\": \"Forklog\",\n    \"host\": \"forklog.com\",\n    \"heat\": 5,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"mixcloud\",\n    \"name\": \"Mixcloud\",\n    \"host\": \"mixcloud.com\",\n    \"heat\": 5,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"nltimes\",\n    \"name\": \"NL Times\",\n    \"host\": \"nltimes.nl\",\n    \"heat\": 5,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"samsung\",\n    \"name\": \"Samsung\",\n    \"host\": \"research.samsung.com\",\n    \"heat\": 5,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sega\",\n    \"name\": \"SEGA\",\n    \"host\": \"pjsekai.sega.jp\",\n    \"heat\": 5,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"segmentfault\",\n    \"name\": \"SegmentFault\",\n    \"host\": \"segmentfault.com\",\n    \"heat\": 5,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"shoppingdesign\",\n    \"name\": \"Shopping Design\",\n    \"host\": \"shoppingdesign.com.tw\",\n    \"heat\": 5,\n    \"categories\": [\"design\"]\n  },\n  {\n    \"key\": \"tidb\",\n    \"name\": \"TiDB 社区\",\n    \"host\": \"tidb.net\",\n    \"heat\": 5,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"tiddlywiki\",\n    \"name\": \"TiddlyWiki\",\n    \"host\": \"tiddlywiki.com\",\n    \"heat\": 5,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"warthunder\",\n    \"name\": \"War Thunder\",\n    \"host\": \"warthunder.com\",\n    \"heat\": 5,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"shmeea\",\n    \"name\": \"上海市教育考试院\",\n    \"host\": \"shmeea.edu.cn\",\n    \"heat\": 5,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"casssp\",\n    \"name\": \"中国科学学与科技政策研究会\",\n    \"host\": \"casssp.org.cn\",\n    \"heat\": 5,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"kepu\",\n    \"name\": \"中国科普博览\",\n    \"host\": \"live.kepu.net.cn\",\n    \"heat\": 5,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"tingshuitz\",\n    \"name\": \"停水通知\",\n    \"host\": \"swj.dl.gov.cn\",\n    \"heat\": 5,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"ncepu\",\n    \"name\": \"华北电力大学\",\n    \"host\": \"yjsy.ncepu.edu.cn\",\n    \"heat\": 5,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"sctv\",\n    \"name\": \"四川广播电视台\",\n    \"host\": \"sctv.com\",\n    \"heat\": 5,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"byteclicks\",\n    \"name\": \"字节点击\",\n    \"host\": \"byteclicks.com\",\n    \"heat\": 5,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"gdufs\",\n    \"name\": \"广东外语外贸大学\",\n    \"host\": \"gdufs.edu.cn\",\n    \"heat\": 5,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"aijishu\",\n    \"name\": \"极术社区\",\n    \"host\": \"aijishu\",\n    \"heat\": 5,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"2023game\",\n    \"name\": \"游戏星辰\",\n    \"host\": \"2023game.com\",\n    \"heat\": 5,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"dingshao\",\n    \"name\": \"盯梢\",\n    \"host\": \"dingshao.cn\",\n    \"heat\": 5,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"zongheng\",\n    \"name\": \"纵横中文网\",\n    \"host\": \"zongheng.com\",\n    \"heat\": 5,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"landiannews\",\n    \"name\": \"蓝点网\",\n    \"host\": \"landiannews.com\",\n    \"heat\": 5,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"anytxt\",\n    \"name\": \"Anytxt Searcher\",\n    \"host\": \"anytxt.net\",\n    \"heat\": 4,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"ft\",\n    \"name\": \"Financial Times\",\n    \"host\": \"ft.com\",\n    \"heat\": 4,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"thegadgetflow\",\n    \"name\": \"Gadget Flow\",\n    \"host\": \"thegadgetflow.com\",\n    \"heat\": 4,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"mashiro\",\n    \"name\": \"Mashiro's Baumkuchen\",\n    \"host\": \"mashiro.best\",\n    \"heat\": 4,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"chnmuseum\",\n    \"name\": \"National Museum Of China\",\n    \"host\": \"chnmuseum.cn\",\n    \"heat\": 4,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"nielsberglund\",\n    \"name\": \"Niels Berglund Blog\",\n    \"host\": \"nielsberglund.com\",\n    \"heat\": 4,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"pubmed\",\n    \"name\": \"PubMed\",\n    \"host\": \"pubmed.ncbi.nlm.nih.gov\",\n    \"heat\": 4,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"sensortower\",\n    \"name\": \"Sensor Tower\",\n    \"host\": \"sensortower.com\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"indianexpress\",\n    \"name\": \"The Indian Express\",\n    \"host\": \"indianexpress.com\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"trendforce\",\n    \"name\": \"TrendForce\",\n    \"host\": \"trendforce.com\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"typst\",\n    \"name\": \"Typst\",\n    \"host\": \"typst.com\",\n    \"heat\": 4,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"urbandictionary\",\n    \"name\": \"Urban Dictionary\",\n    \"host\": \"urbandictionary.com\",\n    \"heat\": 4,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wise\",\n    \"name\": \"Wise\",\n    \"host\": \"wise.com\",\n    \"heat\": 4,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"zyw\",\n    \"name\": \"zyw\",\n    \"host\": \"hot.zyw.asia\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"81\",\n    \"name\": \"中国军网\",\n    \"host\": \"81.cn\",\n    \"heat\": 4,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"ucas\",\n    \"name\": \"中国科学院大学\",\n    \"host\": \"ai.ucas.ac.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"chinaisa\",\n    \"name\": \"中国钢铁工业协会\",\n    \"host\": \"chinaisa.org.cn\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cbirc\",\n    \"name\": \"中国银行保险监督管理委员会\",\n    \"host\": \"cbirc.gov.cn\",\n    \"heat\": 4,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"huijin-inv\",\n    \"name\": \"中央汇金投资有限责任公司\",\n    \"host\": \"huijin-inv.cn\",\n    \"heat\": 4,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"zaozao\",\n    \"name\": \"前端早早聊\",\n    \"host\": \"zaozao.run\",\n    \"heat\": 4,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bnu\",\n    \"name\": \"北京师范大学\",\n    \"host\": \"bs.bnu.edu.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ustb\",\n    \"name\": \"北京科技大学\",\n    \"host\": \"gs.ustb.edu.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"mrm\",\n    \"name\": \"华储网\",\n    \"host\": \"mrm.com.cn\",\n    \"heat\": 4,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"ncwu\",\n    \"name\": \"华北水利水电大学\",\n    \"host\": \"ncwu.edu.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ncu\",\n    \"name\": \"南昌大学\",\n    \"host\": \"jwc.ncu.edu.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"xmnn\",\n    \"name\": \"厦门网\",\n    \"host\": \"epaper.xmnn.cn\",\n    \"heat\": 4,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"scpta\",\n    \"name\": \"四川省人力资源和社会保障厅人事考试专栏\",\n    \"host\": \"scpta.com.cn\",\n    \"heat\": 4,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"bakamh\",\n    \"name\": \"巴卡漫画\",\n    \"host\": \"bakamh.com\",\n    \"heat\": 4,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"gxmzu\",\n    \"name\": \"广西民族大学\",\n    \"host\": \"ai.gxmzu.edu.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"bc3ts\",\n    \"name\": \"爆料公社\",\n    \"host\": \"web.bc3ts.net\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"fjksbm\",\n    \"name\": \"福建考试报名网\",\n    \"host\": \"fjksbm.com\",\n    \"heat\": 4,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"swpu\",\n    \"name\": \"西南石油大学\",\n    \"host\": \"swpu.edu.cn\",\n    \"heat\": 4,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"aamacau\",\n    \"name\": \"論盡媒體 AllAboutMacau Media\",\n    \"host\": \"aamacau.com\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"chiculture\",\n    \"name\": \"通識・現代中國\",\n    \"host\": \"chiculture.org.hk\",\n    \"heat\": 4,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"acs\",\n    \"name\": \"ACS Publications\",\n    \"host\": \"pubs.acs.org\",\n    \"heat\": 3,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"apnic\",\n    \"name\": \"APNIC\",\n    \"host\": \"blog.apnic.net\",\n    \"heat\": 3,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"devolverdigital\",\n    \"name\": \"DevolverDigital\",\n    \"host\": \"devolverdigital.com\",\n    \"heat\": 3,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"ekantipur\",\n    \"name\": \"Ekantipur / कान्तिपुर (Nepal)\",\n    \"host\": \"ekantipur.com\",\n    \"heat\": 3,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"fortnite\",\n    \"name\": \"Fortnite\",\n    \"host\": \"fortnite.com\",\n    \"heat\": 3,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"galxe\",\n    \"name\": \"Galxe\",\n    \"host\": \"app.galxe.com\",\n    \"heat\": 3,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"gitpod\",\n    \"name\": \"Gitpod\",\n    \"host\": \"gitpod.io\",\n    \"heat\": 3,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"iknowwhatyoudownload\",\n    \"name\": \"I Know What You Download\",\n    \"host\": \"iknowwhatyoudownload.com\",\n    \"heat\": 3,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"isct\",\n    \"name\": \"Institute of Science Tokyo\",\n    \"host\": \"isct.ac.jp\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ipsw\",\n    \"name\": \"IPSW.me\",\n    \"host\": \"ipsw.me\",\n    \"heat\": 3,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"joneslanglasalle\",\n    \"name\": \"Jones Lang LaSalle\",\n    \"host\": \"joneslanglasalle.com.cn\",\n    \"heat\": 3,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"kcna\",\n    \"name\": \"Korean Central News Agency (KCNA) 朝鲜中央通讯社\",\n    \"host\": \"kcna.kp\",\n    \"heat\": 3,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"firefox\",\n    \"name\": \"Mozilla\",\n    \"host\": \"monitor.firefox.com\",\n    \"heat\": 3,\n    \"categories\": [\"program-update\", \"other\"]\n  },\n  {\n    \"key\": \"ncku\",\n    \"name\": \"National Cheng Kung University\",\n    \"host\": \"ncku.edu.tw\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"nautiljon\",\n    \"name\": \"Nautiljon\",\n    \"host\": \"nautiljon.com\",\n    \"heat\": 3,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"peopo\",\n    \"name\": \"PeoPo 公民新聞\",\n    \"host\": \"peopo.org\",\n    \"heat\": 3,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"rawkuma\",\n    \"name\": \"Rawkuma\",\n    \"host\": \"rawkuma.com\",\n    \"heat\": 3,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"reactiflux\",\n    \"name\": \"Reactiflux\",\n    \"host\": \"reactiflux.com\",\n    \"heat\": 3,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"tfc-taiwan\",\n    \"name\": \"Taiwan FactCheck Center\",\n    \"host\": \"tfc-taiwan.org.tw\",\n    \"heat\": 3,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"typeless\",\n    \"name\": \"Typeless\",\n    \"host\": \"typeless.com\",\n    \"heat\": 3,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"parliament.uk\",\n    \"name\": \"UK Parliament\",\n    \"host\": \"parliament.uk\",\n    \"heat\": 3,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"webcatalog\",\n    \"name\": \"WebCatalog\",\n    \"host\": \"desktop.webcatalog.io\",\n    \"heat\": 3,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"eshukan\",\n    \"name\": \"万维书刊网\",\n    \"host\": \"eshukan.com\",\n    \"heat\": 3,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"shoac\",\n    \"name\": \"上海东方艺术中心\",\n    \"host\": \"shoac.com.cn\",\n    \"heat\": 3,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"nudt\",\n    \"name\": \"中国人民解放军国防科技大学\",\n    \"host\": \"nudt.edu.cn\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cuc\",\n    \"name\": \"中国传媒大学\",\n    \"host\": \"yz.cuc.edu.cn\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"upc\",\n    \"name\": \"中国石油大学（华东）\",\n    \"host\": \"computer.upc.edu.cn\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"camchina\",\n    \"name\": \"中国管理现代化研究会\",\n    \"host\": \"cste.org.cn\",\n    \"heat\": 3,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"hostmonit\",\n    \"name\": \"全球主机监控\",\n    \"host\": \"stock.hostmonit.com\",\n    \"heat\": 3,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bjsk\",\n    \"name\": \"北京社科网\",\n    \"host\": \"bjsk.org.cn\",\n    \"heat\": 3,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"hrbust\",\n    \"name\": \"哈尔滨理工大学\",\n    \"host\": \"hrbust.edu.cn\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"laimanhua\",\n    \"name\": \"来漫画\",\n    \"host\": \"laimanhua8.com\",\n    \"heat\": 3,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"getitfree\",\n    \"name\": \"正版中国\",\n    \"host\": \"getitfree.cn\",\n    \"heat\": 3,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"tsinghua\",\n    \"name\": \"清华大学\",\n    \"host\": \"tsinghua.edu.cn\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hnu\",\n    \"name\": \"湖南大学\",\n    \"host\": \"scc.hnu.edu.cn\",\n    \"heat\": 3,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hkushop\",\n    \"name\": \"環球唱片(香港)官方網上商店\",\n    \"host\": \"hkushop.com\",\n    \"heat\": 3,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"sdo\",\n    \"name\": \"盛趣游戏在线\",\n    \"host\": \"sdo.com\",\n    \"heat\": 3,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"oilchem\",\n    \"name\": \"隆众资讯\",\n    \"host\": \"oilchem.net\",\n    \"heat\": 3,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"8kcos\",\n    \"name\": \"8KCosplay\",\n    \"host\": \"8kcosplay.com\",\n    \"heat\": 2,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"abskoop\",\n    \"name\": \"A 姐分享\",\n    \"host\": \"nsfw.abskoop.com\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"alicesoft\",\n    \"name\": \"ALICESOFT\",\n    \"host\": \"alicesoft.com\",\n    \"heat\": 2,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"app-sales\",\n    \"name\": \"AppSales\",\n    \"host\": \"app-sales.net\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"appstare\",\n    \"name\": \"AppStare\",\n    \"host\": \"appstare.net\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"bmkg\",\n    \"name\": \"BADAN METEOROLOGI, KLIMATOLOGI, DAN GEOFISIKA(Indonesian)\",\n    \"host\": \"bmkg.go.id\",\n    \"heat\": 2,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"bjtu\",\n    \"name\": \"Beijing Jiaotong University\",\n    \"host\": \"bjtu.edu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"bitmovin\",\n    \"name\": \"Bitmovin\",\n    \"host\": \"bitmovin.com\",\n    \"heat\": 2,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"cags\",\n    \"name\": \"Chinese Academy of Geological Sciences\",\n    \"host\": \"cags.cgs.gov.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"coolidge\",\n    \"name\": \"Coolidge Corner Theatre\",\n    \"host\": \"coolidge.org\",\n    \"heat\": 2,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"cupl\",\n    \"name\": \"CUPL\",\n    \"host\": \"jwc.cupl.edu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"digitalpolicyalert\",\n    \"name\": \"Digital Policy Alert\",\n    \"host\": \"digitalpolicyalert.org\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ea\",\n    \"name\": \"EA Games\",\n    \"host\": \"ea.com\",\n    \"heat\": 2,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"ecnu\",\n    \"name\": \"East China Normal University 华东师范大学\",\n    \"host\": \"ecnu.edu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"disinfo\",\n    \"name\": \"EU Disinfo Lab\",\n    \"host\": \"disinfo.eu\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"focustaiwan\",\n    \"name\": \"Focus Taiwan\",\n    \"host\": \"focustaiwan.tw\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"furstar\",\n    \"name\": \"Furstar\",\n    \"host\": \"furstar.jp\",\n    \"heat\": 2,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"hiring.cafe\",\n    \"name\": \"HiringCafe\",\n    \"host\": \"hiring.cafe\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"hudsonrivertrading\",\n    \"name\": \"Hudson River Trading\",\n    \"host\": \"hudsonrivertrading.com\",\n    \"heat\": 2,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"immich\",\n    \"name\": \"Immich\",\n    \"host\": \"immich.app\",\n    \"heat\": 2,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ktown4u\",\n    \"name\": \"Ktown4u\",\n    \"host\": \"ktown4u.com\",\n    \"heat\": 2,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"lancedb\",\n    \"name\": \"LanceDB\",\n    \"host\": \"lancedb.com\",\n    \"heat\": 2,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"letterboxd\",\n    \"name\": \"Letterboxd\",\n    \"host\": \"letterboxd.com\",\n    \"heat\": 2,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"liquipedia\",\n    \"name\": \"Liquipedia\",\n    \"host\": \"liquipedia.net\",\n    \"heat\": 2,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"luma\",\n    \"name\": \"LuMa\",\n    \"host\": \"lu.ma\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"mit\",\n    \"name\": \"Massachusetts Institute of Technology\",\n    \"host\": \"mit.edu\",\n    \"heat\": 2,\n    \"categories\": [\"blog\", \"social-media\"]\n  },\n  {\n    \"key\": \"mathpix\",\n    \"name\": \"Mathpix\",\n    \"host\": \"mathpix.com\",\n    \"heat\": 2,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"mixi2\",\n    \"name\": \"mixi2\",\n    \"host\": \"mixi.social\",\n    \"heat\": 2,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"musify\",\n    \"name\": \"musify\",\n    \"host\": \"musify.club\",\n    \"heat\": 2,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"netflix\",\n    \"name\": \"Netflix\",\n    \"host\": \"netflix.com\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"perplexity\",\n    \"name\": \"Perplexity\",\n    \"host\": \"perplexity.ai\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"pixelstech\",\n    \"name\": \"PixelsTech\",\n    \"host\": \"pixelstech.net\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"postman\",\n    \"name\": \"Postman\",\n    \"host\": \"postman.com\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"priconne-redive\",\n    \"name\": \"PRINCESS CONNECT! Re Dive プリンセスコネクト！Re Dive\",\n    \"host\": \"priconne-redive.jp\",\n    \"heat\": 2,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"psyche\",\n    \"name\": \"Psyche\",\n    \"host\": \"psyche.co\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"putty\",\n    \"name\": \"PuTTY\",\n    \"host\": \"chiark.greenend.org.uk\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"quantamagazine\",\n    \"name\": \"Quanta Magazine\",\n    \"host\": \"quantamagazine.org\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"remnote\",\n    \"name\": \"RemNote\",\n    \"host\": \"remnote.com\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"ruc\",\n    \"name\": \"Renmin University of China\",\n    \"host\": \"ruc.edu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"scoop\",\n    \"name\": \"Scoop\",\n    \"host\": \"scoop.sh\",\n    \"heat\": 2,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"secretsanfrancisco\",\n    \"name\": \"Secret San francisco\",\n    \"host\": \"secretsanfrancisco.com\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"snowpeak\",\n    \"name\": \"Snow Peak\",\n    \"host\": \"snowpeak.com\",\n    \"heat\": 2,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"sara\",\n    \"name\": \"上海业余无线电协会\",\n    \"host\": \"sara.org.cn\",\n    \"heat\": 2,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"shmtu\",\n    \"name\": \"上海海事大学\",\n    \"host\": \"jwc.shmtu.edu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cisia\",\n    \"name\": \"中国无机盐工业协会\",\n    \"host\": \"cisia.org\",\n    \"heat\": 2,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"kamen-rider-official\",\n    \"name\": \"仮面ライダ\",\n    \"host\": \"kamen-rider-official.com\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"txks\",\n    \"name\": \"全国通信专业技术人员职业水平考试\",\n    \"host\": \"txks.org.cn\",\n    \"heat\": 2,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"qm120\",\n    \"name\": \"全民健康网\",\n    \"host\": \"qm120.com\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ccg\",\n    \"name\": \"全球化智库\",\n    \"host\": \"ccg.org.cn\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"wchscu\",\n    \"name\": \"华西医院\",\n    \"host\": \"wchscu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"qweather\",\n    \"name\": \"和风天气\",\n    \"host\": \"qweather.com\",\n    \"heat\": 2,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"sicau\",\n    \"name\": \"四川农业大学\",\n    \"host\": \"sicau.edu.cn\",\n    \"heat\": 2,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cde\",\n    \"name\": \"国家药品审评网站\",\n    \"host\": \"cde.org.cn\",\n    \"heat\": 2,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"005\",\n    \"name\": \"幻之羁绊动漫网\",\n    \"host\": \"005.tv\",\n    \"heat\": 2,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"dangdang\",\n    \"name\": \"当当开放平台\",\n    \"host\": \"open.dangdang.com\",\n    \"heat\": 2,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"56kog\",\n    \"name\": \"明月中文网\",\n    \"host\": \"56kog.com\",\n    \"heat\": 2,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"dushu\",\n    \"name\": \"樊登读书\",\n    \"host\": \"card.dushu.io\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"samd\",\n    \"name\": \"深圳市医疗器械行业协会\",\n    \"host\": \"samd.org.cn\",\n    \"heat\": 2,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"yxdown\",\n    \"name\": \"游讯网\",\n    \"host\": \"yxdown.com\",\n    \"heat\": 2,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"iqiyi\",\n    \"name\": \"爱奇艺\",\n    \"host\": \"iq.com\",\n    \"heat\": 2,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"jumeili\",\n    \"name\": \"聚美丽\",\n    \"host\": \"jumeili.cn\",\n    \"heat\": 2,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ke\",\n    \"name\": \"贝壳研究院\",\n    \"host\": \"research.ke.com\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"guduodata\",\n    \"name\": \"骨朵数据\",\n    \"host\": \"data.guduodata.com\",\n    \"heat\": 2,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"amazfitwatchfaces\",\n    \"name\": \"Amazfitwatchfaces\",\n    \"host\": \"amazfitwatchfaces.com\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"atptour\",\n    \"name\": \"ATP Tour\",\n    \"host\": \"atptour.com\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"backlinko\",\n    \"name\": \"Backlinko\",\n    \"host\": \"backlinko.com\",\n    \"heat\": 1,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"bitbucket\",\n    \"name\": \"Bitbucket\",\n    \"host\": \"bitbucket.com\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bookwalker\",\n    \"name\": \"BOOKWALKER電子書\",\n    \"host\": \"bookwalker.com.tw\",\n    \"heat\": 1,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"bugzilla\",\n    \"name\": \"Bugzilla\",\n    \"host\": \"bugzilla.org\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"chocolatey\",\n    \"name\": \"Chocolatey\",\n    \"host\": \"chocolatey.org\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"claude\",\n    \"name\": \"Claude\",\n    \"host\": \"claude.com\",\n    \"heat\": 1,\n    \"categories\": [\"programming\", \"program-update\"]\n  },\n  {\n    \"key\": \"comicat\",\n    \"name\": \"Comicat\",\n    \"host\": \"comicat.org\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"cpuid\",\n    \"name\": \"CPUID\",\n    \"host\": \"cpuid.com\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"crush\",\n    \"name\": \"CrushNinja\",\n    \"host\": \"crush.ninja\",\n    \"heat\": 1,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"dorohedoro\",\n    \"name\": \"Dorohedoro\",\n    \"host\": \"dorohedoro.net\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"engineering\",\n    \"name\": \"Engineering.fyi\",\n    \"host\": \"engineering.fyi\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"enterprisecraftsmanship\",\n    \"name\": \"Enterprise Craftsmanship\",\n    \"host\": \"enterprisecraftsmanship.com\",\n    \"heat\": 1,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"farcaster\",\n    \"name\": \"Farcaster\",\n    \"host\": \"farcaster.xyz\",\n    \"heat\": 1,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"gihyo\",\n    \"name\": \"gihyo.jp\",\n    \"host\": \"gihyo.jp\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"gitkraken\",\n    \"name\": \"GitKraken\",\n    \"host\": \"gitkraken.com\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"gogoanimehd\",\n    \"name\": \"Gogoanimehd\",\n    \"host\": \"developer.anitaku.to\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"hongkong\",\n    \"name\": \"Hong Kong Department of Health 香港卫生署\",\n    \"host\": \"dh.gov.hk\",\n    \"heat\": 1,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"hotukdeals\",\n    \"name\": \"hotukdeals\",\n    \"host\": \"hotukdeals.com\",\n    \"heat\": 1,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"imagemagick\",\n    \"name\": \"ImageMagick\",\n    \"host\": \"imagemagick.org\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"imop\",\n    \"name\": \"imop\",\n    \"host\": \"imop.com\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"go\",\n    \"name\": \"JapanGov\",\n    \"host\": \"go.jp\",\n    \"heat\": 1,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"kisskiss\",\n    \"name\": \"KISS\",\n    \"host\": \"kisskiss.tv\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"macupdate\",\n    \"name\": \"MacUpdate\",\n    \"host\": \"macupdate.com\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"mangadex\",\n    \"name\": \"MangaDex\",\n    \"host\": \"mangadex.org\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"mercari\",\n    \"name\": \"Mercari\",\n    \"host\": \"jp.mercari.com\",\n    \"heat\": 1,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"meteoblue\",\n    \"name\": \"meteoblue\",\n    \"host\": \"meteoblue.com\",\n    \"heat\": 1,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"mycard520\",\n    \"name\": \"MyCard娛樂中心\",\n    \"host\": \"mycard520.com.tw\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"nua\",\n    \"name\": \"Nanjing University of the Arts 南京艺术学院\",\n    \"host\": \"index.nua.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ngocn2\",\n    \"name\": \"NGOCN\",\n    \"host\": \"ngocn2.org\",\n    \"heat\": 1,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"nymity\",\n    \"name\": \"nymity\",\n    \"host\": \"censorbib.nymity.ch\",\n    \"heat\": 1,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"onet\",\n    \"name\": \"Onet\",\n    \"host\": \"wiadomosci.onet.pl\",\n    \"heat\": 1,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"osu\",\n    \"name\": \"osu!\",\n    \"host\": \"osu.ppy.sh\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"questn\",\n    \"name\": \"QuestN\",\n    \"host\": \"app.questn.com\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"rattibha\",\n    \"name\": \"Rattibha\",\n    \"host\": \"rattibha.com\",\n    \"heat\": 1,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"resetera\",\n    \"name\": \"ResetEra\",\n    \"host\": \"resetera.com\",\n    \"heat\": 1,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"rockthejvm\",\n    \"name\": \"Rock the JVM\",\n    \"host\": \"rockthejvm.com\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"smartlink\",\n    \"name\": \"SmartLink\",\n    \"host\": \"smartlink.bio\",\n    \"heat\": 1,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"sycl\",\n    \"name\": \"SYCL\",\n    \"host\": \"sycl.tech\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"tctmd\",\n    \"name\": \"TCTMD\",\n    \"host\": \"tctmd.com\",\n    \"heat\": 1,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"techpowerup\",\n    \"name\": \"TechPowerUp\",\n    \"host\": \"techpowerup.com\",\n    \"heat\": 1,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"visionias\",\n    \"name\": \"VisionIAS\",\n    \"host\": \"visionias.in\",\n    \"heat\": 1,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"diskanalyzer\",\n    \"name\": \"WizTree\",\n    \"host\": \"diskanalyzer.com\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"worldofwarships\",\n    \"name\": \"World of Warships\",\n    \"host\": \"worldofwarships.com\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"collabo-cafe\",\n    \"name\": \"コラボカフェ\",\n    \"host\": \"collabo-cafe.com\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"toranoana\",\n    \"name\": \"とらのあな\",\n    \"host\": \"toranoana.jp\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"shisu\",\n    \"name\": \"上海外国语大学\",\n    \"host\": \"shisu.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"shiep\",\n    \"name\": \"上海电力大学\",\n    \"host\": \"bwc.shiep.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"dgut\",\n    \"name\": \"东莞理工学院\",\n    \"host\": \"dgut.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"zuel\",\n    \"name\": \"中南财经政法大学\",\n    \"host\": \"wap.zuel.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cccmc\",\n    \"name\": \"中国五矿化工进出口商会\",\n    \"host\": \"cccmc.org.cn\",\n    \"heat\": 1,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"icbc\",\n    \"name\": \"中国工商银行\",\n    \"host\": \"icbc.com.cn\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cas\",\n    \"name\": \"中国科学院\",\n    \"host\": \"cas.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cahkms\",\n    \"name\": \"全国港澳研究会\",\n    \"host\": \"cahkms.org\",\n    \"heat\": 1,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"m-78\",\n    \"name\": \"円谷ステーション\",\n    \"host\": \"m-78.jp\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"ciweimao\",\n    \"name\": \"刺猬猫\",\n    \"host\": \"wap.ciweimao.com\",\n    \"heat\": 1,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"sustech\",\n    \"name\": \"南方科技大学\",\n    \"host\": \"biddingoffice.sustech.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"taiwanmobile\",\n    \"name\": \"台灣大哥大\",\n    \"host\": \"taiwanmobile.com\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"hitwh\",\n    \"name\": \"哈尔滨工业大学（威海）\",\n    \"host\": \"hitwh.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hitsz\",\n    \"name\": \"哈尔滨工业大学（深圳）\",\n    \"host\": \"hitsz.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"dut\",\n    \"name\": \"大连理工大学\",\n    \"host\": \"dutdice.dlut.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"hakkatv\",\n    \"name\": \"客家電視台\",\n    \"host\": \"hakkatv.org.tw\",\n    \"heat\": 1,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gf-cn\",\n    \"name\": \"少女前线\",\n    \"host\": \"sunborngame.com\",\n    \"heat\": 1,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"chuanliu\",\n    \"name\": \"川流\",\n    \"host\": \"chuanliu.org\",\n    \"heat\": 1,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"soundofhope\",\n    \"name\": \"希望之声\",\n    \"host\": \"soundofhope.org\",\n    \"heat\": 1,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"gzdaily\",\n    \"name\": \"广州日报\",\n    \"host\": \"gzdaily.cn\",\n    \"heat\": 1,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"cdzjryb\",\n    \"name\": \"成都住建蓉 e 办\",\n    \"host\": \"zw.cdzjryb.com\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"liulinblog\",\n    \"name\": \"木木博客\",\n    \"host\": \"liulinblog.com\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"hbooker\",\n    \"name\": \"欢乐书客\",\n    \"host\": \"hbooker.com\",\n    \"heat\": 1,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"szu\",\n    \"name\": \"深圳大学\",\n    \"host\": \"yz.szu.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"codefather\",\n    \"name\": \"编程导航\",\n    \"host\": \"codefather.cn\",\n    \"heat\": 1,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"lenovo\",\n    \"name\": \"联想\",\n    \"host\": \"lenovo.com.cn\",\n    \"heat\": 1,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"xunhupay\",\n    \"name\": \"虎皮椒\",\n    \"host\": \"xunhupay.com\",\n    \"heat\": 1,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"xaufe\",\n    \"name\": \"西安财经大学\",\n    \"host\": \"jiaowu.xaufe.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"gmu\",\n    \"name\": \"赣南医科大学\",\n    \"host\": \"gmu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ruancan\",\n    \"name\": \"软餐\",\n    \"host\": \"ruancan.com\",\n    \"heat\": 1,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cqwu\",\n    \"name\": \"重庆文理学院\",\n    \"host\": \"cqwu.net\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cqgas\",\n    \"name\": \"重庆燃气\",\n    \"host\": \"cqgas.cn\",\n    \"heat\": 1,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"fffdm\",\n    \"name\": \"风之动漫\",\n    \"host\": \"manhua.fffdm.com\",\n    \"heat\": 1,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"qlu\",\n    \"name\": \"齐鲁工业大学\",\n    \"host\": \"qlu.edu.cn\",\n    \"heat\": 1,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"iqilu\",\n    \"name\": \"齐鲁网\",\n    \"host\": \"v.iqilu.com\",\n    \"heat\": 1,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"4chan\",\n    \"name\": \"4chan\",\n    \"host\": \"4chan.org\",\n    \"heat\": 0,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"591\",\n    \"name\": \"591 Rental house\",\n    \"host\": \"rent.591.com.tw\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"8world\",\n    \"name\": \"8 视界\",\n    \"host\": \"8world.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"aflcio\",\n    \"name\": \"AFL-CIO\",\n    \"host\": \"aflcio.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"aiaa\",\n    \"name\": \"AIAA Aerospace Research Central\",\n    \"host\": \"arc.aiaa.org\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"aljazeera\",\n    \"name\": \"Aljazeera\",\n    \"host\": \"aljazeera.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"alternativeto\",\n    \"name\": \"AlternativeTo\",\n    \"host\": \"alternativeto.net\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"altotrain\",\n    \"name\": \"Alto - Toronto-Québec City High-Speed Rail Network\",\n    \"host\": \"altotrain.ca\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"alwayscontrol\",\n    \"name\": \"Always Control\",\n    \"host\": \"alwayscontrol.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"aip\",\n    \"name\": \"American Institute of Physics\",\n    \"host\": \"pubs.aip.org\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"aschmelyun\",\n    \"name\": \"Andrew Schmelyun\",\n    \"host\": \"aschmelyun.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"anime1\",\n    \"name\": \"Anime1\",\n    \"host\": \"anime1.me\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"annualreviews\",\n    \"name\": \"Annual Reviews\",\n    \"host\": \"annualreviews.org\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"apkpure\",\n    \"name\": \"APKPure\",\n    \"host\": \"apkpure.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"appstorrent\",\n    \"name\": \"AppsTorrent\",\n    \"host\": \"appstorrent.ru\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"aqara\",\n    \"name\": \"Aqara\",\n    \"host\": \"aqara.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"arcteryx\",\n    \"name\": \"Arcteryx\",\n    \"host\": \"arcteryx.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"artstation\",\n    \"name\": \"ArtStation\",\n    \"host\": \"artstation.com\",\n    \"heat\": 0,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"aiea\",\n    \"name\": \"Asian Innovation and Entrepreneurship Association\",\n    \"host\": \"aiea.org\",\n    \"heat\": 0,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"azul\",\n    \"name\": \"Azul\",\n    \"host\": \"azul.com\",\n    \"heat\": 0,\n    \"categories\": [\"programming\", \"program-update\"]\n  },\n  {\n    \"key\": \"azurlane\",\n    \"name\": \"Azur Lane\",\n    \"host\": \"azurlane.jp\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"bad\",\n    \"name\": \"Bad.news\",\n    \"host\": \"bad.news\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bandisoft\",\n    \"name\": \"Bandisoft\",\n    \"host\": \"bandisoft.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"bbcnewslabs\",\n    \"name\": \"BBC News Labs\",\n    \"host\": \"bbcnewslabs.co.uk\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"bellroy\",\n    \"name\": \"Bellroy\",\n    \"host\": \"bellroy.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"bvisness\",\n    \"name\": \"Ben Visness\",\n    \"host\": \"bvisness.me\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"biodiscover\",\n    \"name\": \"biodiscover.com 生物探索\",\n    \"host\": \"biodiscover.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"blockworks\",\n    \"name\": \"Blockworks\",\n    \"host\": \"blockworks.co\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"bluestacks\",\n    \"name\": \"BlueStacks\",\n    \"host\": \"bluestacks.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"bntnews\",\n    \"name\": \"bntnews\",\n    \"host\": \"bntnews.co.kr\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"brooklynmuseum\",\n    \"name\": \"Brooklyn Museum\",\n    \"host\": \"brooklynmuseum.org\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"bwsg\",\n    \"name\": \"BWSG\",\n    \"host\": \"bwsg.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"canada.ca\",\n    \"name\": \"Canada.ca\",\n    \"host\": \"canada.ca\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"capitalmind\",\n    \"name\": \"Capitalmind\",\n    \"host\": \"capitalmind.in\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"carousell\",\n    \"name\": \"Carousell\",\n    \"host\": \"carousell.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"creative-comic\",\n    \"name\": \"CCC 創作集\",\n    \"host\": \"creative-comic.tw\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"cherrytimes\",\n    \"name\": \"Cherry Times\",\n    \"host\": \"cherrytimes.it\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cjlu\",\n    \"name\": \"China Jiliang University\",\n    \"host\": \"cjlu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cloudflarestatus\",\n    \"name\": \"Cloudflare Status\",\n    \"host\": \"cloudflarestatus.com\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"cointelegraph\",\n    \"name\": \"Cointelegraph\",\n    \"host\": \"cointelegraph.com\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"comic-fuz\",\n    \"name\": \"COMIC FUZ\",\n    \"host\": \"comic-fuz.com\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"comicskingdom\",\n    \"name\": \"Comics Kingdom\",\n    \"host\": \"comicskingdom.com\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"verfghbw\",\n    \"name\": \"Constitutional Court of Baden-Württemberg (Germany)\",\n    \"host\": \"verfgh.baden-wuerttemberg.de\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"costar\",\n    \"name\": \"CoStar\",\n    \"host\": \"costar.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cryptoslate\",\n    \"name\": \"CryptoSlate\",\n    \"host\": \"cryptoslate.com\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"curiouscat\",\n    \"name\": \"CuriousCat\",\n    \"host\": \"curiouscat.live\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"curius\",\n    \"name\": \"Curius\",\n    \"host\": \"curius.app\",\n    \"heat\": 0,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"dailypush\",\n    \"name\": \"DailyPush\",\n    \"host\": \"dailypush.dev\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"darwinawards\",\n    \"name\": \"Darwin Awards\",\n    \"host\": \"darwinawards.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"dbaplus\",\n    \"name\": \"dbaplus社群\",\n    \"host\": \"dbaplus.cn\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"dcard\",\n    \"name\": \"Dcard\",\n    \"host\": \"dcard.tw\",\n    \"heat\": 0,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"deadbydaylight\",\n    \"name\": \"DeadbyDaylight\",\n    \"host\": \"deadbydaylight.com\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"deadline\",\n    \"name\": \"Deadline\",\n    \"host\": \"deadline.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"decrypt\",\n    \"name\": \"Decrypt\",\n    \"host\": \"decrypt.co\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"devtrium\",\n    \"name\": \"Devtrium\",\n    \"host\": \"devtrium.com\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"diariofruticola\",\n    \"name\": \"Diario Frutícola\",\n    \"host\": \"diariofruticola.cl\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"digg\",\n    \"name\": \"Digg\",\n    \"host\": \"digg.com\",\n    \"heat\": 0,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"discuz\",\n    \"name\": \"Discuz\",\n    \"host\": \"discuz.vip\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"distill\",\n    \"name\": \"Distill\",\n    \"host\": \"distill.pub\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"dlnews\",\n    \"name\": \"DL NEWS\",\n    \"host\": \"dlnews.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"elamigos\",\n    \"name\": \"ElAmigos\",\n    \"host\": \"elamigos.site\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"eventbrite\",\n    \"name\": \"Eventbrite\",\n    \"host\": \"eventbrite.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"expats\",\n    \"name\": \"Expats.cz\",\n    \"host\": \"expats.cz\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"farmatters\",\n    \"name\": \"Farmatters\",\n    \"host\": \"farmatters.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"fishshell\",\n    \"name\": \"fish shell\",\n    \"host\": \"fishshell.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"fisher-spb\",\n    \"name\": \"Fisher Spb\",\n    \"host\": \"fisher.spb.ru\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ftm\",\n    \"name\": \"Follow The Money\",\n    \"host\": \"ftm.eu\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"foodtalks\",\n    \"name\": \"FoodTalks全球食品资讯网\",\n    \"host\": \"foodtalks.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"fosshub\",\n    \"name\": \"FossHub\",\n    \"host\": \"fosshub.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"gameapps\",\n    \"name\": \"GameApps.hk 香港手机游戏网\",\n    \"host\": \"gameapps.hk\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"gamersecret\",\n    \"name\": \"Gamer Secret\",\n    \"host\": \"gamersecret.com\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"ganjingworld\",\n    \"name\": \"Ganjing World\",\n    \"host\": \"ganjingworld.com\",\n    \"heat\": 0,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"genossenschaften\",\n    \"name\": \"Genossenschaften.immo\",\n    \"host\": \"genossenschaften.immo\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"geocaching\",\n    \"name\": \"Geocaching\",\n    \"host\": \"geocaching.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"gesiba\",\n    \"name\": \"Gesiba\",\n    \"host\": \"gesiba.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"gisreportsonline\",\n    \"name\": \"GIS Reports\",\n    \"host\": \"gisreportsonline.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"gitcode\",\n    \"name\": \"GitCode\",\n    \"host\": \"gitcode.com\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"gocn\",\n    \"name\": \"GoCN\",\n    \"host\": \"gocn.vip\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"grubstreet\",\n    \"name\": \"Grub Street\",\n    \"host\": \"grubstreet.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"gumroad\",\n    \"name\": \"Gumroad\",\n    \"host\": \"gumroad.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"hackertalk\",\n    \"name\": \"HACKER TALK 黑客说\",\n    \"host\": \"hackertalk.net\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"hacking8\",\n    \"name\": \"Hacking8\",\n    \"host\": \"hacking8.com\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"hackyournews\",\n    \"name\": \"HackYourNews\",\n    \"host\": \"hackyournews.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"hameln\",\n    \"name\": \"hameln\",\n    \"host\": \"syosetu.org\",\n    \"heat\": 0,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"hashnode\",\n    \"name\": \"hashnode\",\n    \"host\": \"hashnode.dev\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"hicairo\",\n    \"name\": \"HiFeng'Blog\",\n    \"host\": \"hicairo.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"hkepc\",\n    \"name\": \"HKEPC\",\n    \"host\": \"hkepc.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"hkjunkcall\",\n    \"name\": \"HKJunkCall 資訊中心\",\n    \"host\": \"hkjunkcall.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"icac\",\n    \"name\": \"Hong Kong Independent Commission Against Corruption 香港廉政公署\",\n    \"host\": \"icac.org.hk\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"hottoys\",\n    \"name\": \"Hot Toys\",\n    \"host\": \"hottoys.com.hk\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"howtoforge\",\n    \"name\": \"Howtoforge Linux Tutorials\",\n    \"host\": \"howtoforge.com\",\n    \"heat\": 0,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"ianspriggs\",\n    \"name\": \"Ian Spriggss\",\n    \"host\": \"ianspriggs.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ielts\",\n    \"name\": \"IELTS 雅思\",\n    \"host\": \"ielts.neea.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ifi-audio\",\n    \"name\": \"iFi audio\",\n    \"host\": \"ifi-audio.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"imhcg\",\n    \"name\": \"imhcg的信息站\",\n    \"host\": \"infos.imhcg.cn\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"inceptionlabs\",\n    \"name\": \"Inception Labs\",\n    \"host\": \"inceptionlabs.ai\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"indiansinkuwait\",\n    \"name\": \"Indians in Kuwait\",\n    \"host\": \"indiansinkuwait.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"informs\",\n    \"name\": \"INFORMS\",\n    \"host\": \"pubsonline.informs.org\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"issuehunt\",\n    \"name\": \"Issue Hunt\",\n    \"host\": \"issuehunt.io\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"jbma\",\n    \"name\": \"Japan Bullion Market Association\",\n    \"host\": \"jbma.net\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"japanpost\",\n    \"name\": \"Japanpost\",\n    \"host\": \"trackings.post.japanpost.jp\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"jetbrains\",\n    \"name\": \"JetBrains\",\n    \"host\": \"jetbrains.com\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"jpmorganchase\",\n    \"name\": \"JPMorgan Chase\",\n    \"host\": \"jpmorganchase.com\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"jseea\",\n    \"name\": \"jseea\",\n    \"host\": \"jseea.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"kantarworldpanel\",\n    \"name\": \"Kantar Worldpanel\",\n    \"host\": \"kantarworldpanel.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"keepass\",\n    \"name\": \"KeePass\",\n    \"host\": \"keepass.info\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"kelownacapnews\",\n    \"name\": \"Kelowna Capital News\",\n    \"host\": \"kelownacapnews.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"kovidgoyal\",\n    \"name\": \"Kovid's software projects\",\n    \"host\": \"sw.kovidgoyal.net\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"kuwaitlocal\",\n    \"name\": \"Kuwait Local\",\n    \"host\": \"kuwaitlocal.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"jornada\",\n    \"name\": \"La Jornada\",\n    \"host\": \"jornada.com.mx\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"last-origin\",\n    \"name\": \"LastOrigin\",\n    \"host\": \"last-origin.com\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"layoffs\",\n    \"name\": \"Layoffs.fyi\",\n    \"host\": \"layoffs.fyi\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"lephoceen\",\n    \"name\": \"Le Phocéen\",\n    \"host\": \"lephoceen.fr\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"lens\",\n    \"name\": \"Lens\",\n    \"host\": \"lens.xyz\",\n    \"heat\": 0,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"likeshop\",\n    \"name\": \"LikeShop\",\n    \"host\": \"likeshop.me\",\n    \"heat\": 0,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"link3\",\n    \"name\": \"Link3\",\n    \"host\": \"link3.to\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"literotica\",\n    \"name\": \"Literotica\",\n    \"host\": \"literotica.com\",\n    \"heat\": 0,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"logonews\",\n    \"name\": \"LogoNews 标志情报局\",\n    \"host\": \"logonews.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"lmu\",\n    \"name\": \"Ludwig Maximilian University of Munich\",\n    \"host\": \"lmu.de\",\n    \"heat\": 0,\n    \"categories\": [\"university\", \"study\"]\n  },\n  {\n    \"key\": \"ccac\",\n    \"name\": \"Macau Independent Commission Against Corruption 澳门廉政公署\",\n    \"host\": \"ccac.org.mo\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"magazinelib\",\n    \"name\": \"MagazineLib\",\n    \"host\": \"magazinelib.com\",\n    \"heat\": 0,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"maven\",\n    \"name\": \"Maven\",\n    \"host\": \"repo1.maven.org\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"meritalk\",\n    \"name\": \"MeriTalk\",\n    \"host\": \"meritalk.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"metacritic\",\n    \"name\": \"Metacritic\",\n    \"host\": \"metacritic.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"microsoft\",\n    \"name\": \"Microsoft\",\n    \"host\": \"microsoft.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"miniflux\",\n    \"name\": \"MiniFlux\",\n    \"host\": \"miniflux.app\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"mrinalxdev\",\n    \"name\": \"Mrinal Pramanick\",\n    \"host\": \"mrinalxdev.github.io\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"musikguru\",\n    \"name\": \"MusikGuru\",\n    \"host\": \"musikguru.de\",\n    \"heat\": 0,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"myfigurecollection\",\n    \"name\": \"MyFigureCollection\",\n    \"host\": \"myfigurecollection.net\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"mymusicsheet\",\n    \"name\": \"mymusic5 (MyMusicSheet)\",\n    \"host\": \"mymusicfive.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"nycu\",\n    \"name\": \"National Yang Ming Chiao Tung University\",\n    \"host\": \"nycu.edu.tw\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"naturalism\",\n    \"name\": \"Naturalism.org\",\n    \"host\": \"naturalism.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"netflav\",\n    \"name\": \"Netflav\",\n    \"host\": \"netflav.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"newmuseum\",\n    \"name\": \"New Museum\",\n    \"host\": \"newmuseum.org\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"nyc\",\n    \"name\": \"New York City Government\",\n    \"host\": \"nyc.gov\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"newswav\",\n    \"name\": \"Newswav\",\n    \"host\": \"newswav.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"njuferret\",\n    \"name\": \"njuferret\",\n    \"host\": \"njuferret.github.io\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ntdm\",\n    \"name\": \"NT动漫\",\n    \"host\": \"ntdm9.com\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"oo-software\",\n    \"name\": \"O&O Software\",\n    \"host\": \"oo-software.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"ornl\",\n    \"name\": \"Oak Ridge National Laboratory\",\n    \"host\": \"ornl.gov\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"oct0pu5\",\n    \"name\": \"Oct0pu5 blog\",\n    \"host\": \"oct0pu5.cn\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"openalex\",\n    \"name\": \"OpenAlex\",\n    \"host\": \"openalex.org\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"openwrt\",\n    \"name\": \"OpenWrt\",\n    \"host\": \"openwrt.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"oesw\",\n    \"name\": \"ÖSW\",\n    \"host\": \"oesw.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"otobanana\",\n    \"name\": \"OTOBANANA\",\n    \"host\": \"otobanana.com\",\n    \"heat\": 0,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"outagereport\",\n    \"name\": \"Outage.Report\",\n    \"host\": \"outage.report\",\n    \"heat\": 0,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"oevw\",\n    \"name\": \"ÖVW\",\n    \"host\": \"oevw.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"usepanda\",\n    \"name\": \"Panda\",\n    \"host\": \"usepanda.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"pikabu\",\n    \"name\": \"Pikabu\",\n    \"host\": \"pikabu.ru\",\n    \"heat\": 0,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"gc.ca\",\n    \"name\": \"Prime Minister of Canada\",\n    \"host\": \"pm.gc.ca\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"pnas\",\n    \"name\": \"Proceedings of The National Academy of Sciences\",\n    \"host\": \"pnas.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"producereport\",\n    \"name\": \"Produce Report\",\n    \"host\": \"producereport.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"pubscholar\",\n    \"name\": \"PubScholar 公益学术平台\",\n    \"host\": \"pubscholar.cn\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"pwc\",\n    \"name\": \"PwC Strategy&\",\n    \"host\": \"strategyand.pwc.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"railway\",\n    \"name\": \"Railway\",\n    \"host\": \"railway.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"rarehistoricalphotos\",\n    \"name\": \"Rare Historical Photos\",\n    \"host\": \"rarehistoricalphotos.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"readsomethingwonderful\",\n    \"name\": \"Read Something Wonderful\",\n    \"host\": \"readsomethingwonderful.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"readwise\",\n    \"name\": \"Readwise\",\n    \"host\": \"readwise.io\",\n    \"heat\": 0,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"researchgate\",\n    \"name\": \"ResearchGate\",\n    \"host\": \"researchgate.net\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"resonac\",\n    \"name\": \"Resonac\",\n    \"host\": \"resonac.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"rodong\",\n    \"name\": \"Rodong Sinmun 劳动新闻\",\n    \"host\": \"rodong.rep.kp\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"routledge\",\n    \"name\": \"Routledge\",\n    \"host\": \"routledge.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"sec-in\",\n    \"name\": \"SecIN 信息安全技术社区\",\n    \"host\": \"sec-in.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"semiconductors\",\n    \"name\": \"Semiconductor Industry Association\",\n    \"host\": \"semiconductors.org\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"snnu\",\n    \"name\": \"Shaanxi Normal University\",\n    \"host\": \"snnu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"shopback\",\n    \"name\": \"ShopBack\",\n    \"host\": \"shopback.com.tw\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"shopify\",\n    \"name\": \"Shopify\",\n    \"host\": \"shopify.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"sketis\",\n    \"name\": \"Sketis | Website of Dr. Makarius Wenzel\",\n    \"host\": \"sketis.net\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"skysports\",\n    \"name\": \"Sky Sports\",\n    \"host\": \"skysports.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"slashdot\",\n    \"name\": \"Slashdot\",\n    \"host\": \"slashdot.org\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sony\",\n    \"name\": \"Sony\",\n    \"host\": \"sony.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"sourceforge\",\n    \"name\": \"SourceForge\",\n    \"host\": \"sourceforge.net\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"stratechery\",\n    \"name\": \"Stratechery by Ben Thompson\",\n    \"host\": \"blog.stratechery.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"supchina\",\n    \"name\": \"SupChina\",\n    \"host\": \"supchina.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"supercell\",\n    \"name\": \"Supercell\",\n    \"host\": \"supercell.com\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"surfshark\",\n    \"name\": \"Surfshark\",\n    \"host\": \"surfshark.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sustainabilitymag\",\n    \"name\": \"Sustainability Magazine\",\n    \"host\": \"sustainabilitymag.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"swissinfo\",\n    \"name\": \"swissinfo\",\n    \"host\": \"swissinfo.ch\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"tableau\",\n    \"name\": \"Tableau\",\n    \"host\": \"public.tableau.com\",\n    \"heat\": 0,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"taiwannews\",\n    \"name\": \"Taiwan News\",\n    \"host\": \"taiwannews.com.tw\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"techsir\",\n    \"name\": \"TechSir\",\n    \"host\": \"techsir.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"telecompaper\",\n    \"name\": \"Telecompaper\",\n    \"host\": \"telecompaper.com\",\n    \"heat\": 0,\n    \"categories\": [\"journal\"]\n  },\n  {\n    \"key\": \"dol\",\n    \"name\": \"Thailand Department of Lands\",\n    \"host\": \"announce.dol.go.th\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"parliament\",\n    \"name\": \"Thailand Parliament\",\n    \"host\": \"parliament.go.th\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"jewishmuseum\",\n    \"name\": \"The Jewish Museum\",\n    \"host\": \"thejewishmuseum.org\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"kimlaw\",\n    \"name\": \"The Korea Institute of Marine Law\",\n    \"host\": \"kimlaw.or.kr\",\n    \"heat\": 0,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"metmuseum\",\n    \"name\": \"The Metropolitan Museum of Art\",\n    \"host\": \"metmuseum.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"thenewslens\",\n    \"name\": \"The News Lens 關鍵評論\",\n    \"host\": \"thenewslens.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"trow\",\n    \"name\": \"The Ring of Wonder\",\n    \"host\": \"trow.cc\",\n    \"heat\": 0,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"thewirehindi\",\n    \"name\": \"The Wire Hindi\",\n    \"host\": \"thewirehindi.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"thebrain\",\n    \"name\": \"TheBrain\",\n    \"host\": \"thebrain.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"transcriptforest\",\n    \"name\": \"Transcript Forest\",\n    \"host\": \"transcriptforest.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"transformer-circuits\",\n    \"name\": \"Transformer Circuits\",\n    \"host\": \"transformer-circuits.pub\",\n    \"heat\": 0,\n    \"categories\": [\"programming\"]\n  },\n  {\n    \"key\": \"tribalfootball\",\n    \"name\": \"Tribal Football\",\n    \"host\": \"tribalfootball.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"tumblr\",\n    \"name\": \"Tumblr\",\n    \"host\": \"tumblr.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\", \"social-media\"]\n  },\n  {\n    \"key\": \"tvtropes\",\n    \"name\": \"TV Tropes\",\n    \"host\": \"tvtropes.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"fda\",\n    \"name\": \"U.S. Food and Drug Administration\",\n    \"host\": \"fda.gov\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bytes\",\n    \"name\": \"ui.dev\",\n    \"host\": \"bytes.dev\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"unipd\",\n    \"name\": \"Università di Padova\",\n    \"host\": \"unipd.it\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"uw\",\n    \"name\": \"University of Washington\",\n    \"host\": \"gixnetwork.org\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ups\",\n    \"name\": \"UPS\",\n    \"host\": \"ups.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"uptimerobot\",\n    \"name\": \"Uptime Robot\",\n    \"host\": \"rss.uptimerobot.com\",\n    \"heat\": 0,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"v1tx\",\n    \"name\": \"v1tx\",\n    \"host\": \"v1tx.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"v2rayshare\",\n    \"name\": \"V2rayShare\",\n    \"host\": \"v2rayshare.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"vice\",\n    \"name\": \"VICE\",\n    \"host\": \"vice.com\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"wallpaperhub\",\n    \"name\": \"WallpaperHub\",\n    \"host\": \"wallpaperhub.app\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"warhammer-community\",\n    \"name\": \"Warhammer Community\",\n    \"host\": \"warhammer-community.com\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"wbv-gpa\",\n    \"name\": \"WBV-GPA\",\n    \"host\": \"wbv-gpa.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"web3caff\",\n    \"name\": \"Web3Caff\",\n    \"host\": \"web3caff.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wdc\",\n    \"name\": \"Western Digital\",\n    \"host\": \"support.wdc.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"wfdf\",\n    \"name\": \"WFDF\",\n    \"host\": \"wfdf.sport\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wiensued\",\n    \"name\": \"Wien-Süd\",\n    \"host\": \"wiensued.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"winstall\",\n    \"name\": \"winstall\",\n    \"host\": \"winstall.app\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"wogem\",\n    \"name\": \"WOGEM\",\n    \"host\": \"wogem.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wohnnet\",\n    \"name\": \"wohnnet.at\",\n    \"host\": \"wohnnet.at\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"0x80\",\n    \"name\": \"Wojciech Muła\",\n    \"host\": \"0x80.pl\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"wmc-bj\",\n    \"name\": \"World Meteorological Centre Beijing\",\n    \"host\": \"wmc-bj.net\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wizfile\",\n    \"name\": \"WziFile\",\n    \"host\": \"antibody-software.com\",\n    \"heat\": 0,\n    \"categories\": [\"program-update\"]\n  },\n  {\n    \"key\": \"xjtlu\",\n    \"name\": \"Xi'an Jiaotong-Liverpool University\",\n    \"host\": \"xjtlu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"xmu\",\n    \"name\": \"Xiamen University\",\n    \"host\": \"soe.xmu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"yamap\",\n    \"name\": \"YAMAP\",\n    \"host\": \"yamap.com\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"yenpress\",\n    \"name\": \"Yen Press\",\n    \"host\": \"yenpress.com\",\n    \"heat\": 0,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"yomiuri\",\n    \"name\": \"Yomiuri Shimbun 読売新聞\",\n    \"host\": \"yomiuri.co.jp\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"youmemark\",\n    \"name\": \"YouMeMark\",\n    \"host\": \"youmemark.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"zagg\",\n    \"name\": \"Zagg\",\n    \"host\": \"zagg.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"zodgame\",\n    \"name\": \"ZodGame\",\n    \"host\": \"zodgame.xyz\",\n    \"heat\": 0,\n    \"categories\": [\"bbs\"]\n  },\n  {\n    \"key\": \"autocentre\",\n    \"name\": \"Автоцентр.ua\",\n    \"host\": \"autocentre.ua\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"comic-walker\",\n    \"name\": \"カドコミ(Kadocomi)\",\n    \"host\": \"comic-walker.com\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"melonbooks\",\n    \"name\": \"メロンブックス\",\n    \"host\": \"melonbooks.co.jp\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"sass\",\n    \"name\": \"上海社会科学院\",\n    \"host\": \"gs.sass.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"sspu\",\n    \"name\": \"上海第二工业大学\",\n    \"host\": \"jwc.sspu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bulianglin\",\n    \"name\": \"不良林\",\n    \"host\": \"bulianglin.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"nenu\",\n    \"name\": \"东北师范大学\",\n    \"host\": \"sohac.nenu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"dhu\",\n    \"name\": \"东华大学\",\n    \"host\": \"dhu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"dgjyw\",\n    \"name\": \"东莞教研网\",\n    \"host\": \"dgjyw.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"zhonglun\",\n    \"name\": \"中伦律师事务所\",\n    \"host\": \"zhonglun.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"chinadegrees\",\n    \"name\": \"中华人民共和国学位证书查询\",\n    \"host\": \"chinadegrees.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"acpaa\",\n    \"name\": \"中华全国专利代理师协会\",\n    \"host\": \"acpaa.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cebbank\",\n    \"name\": \"中国光大银行\",\n    \"host\": \"cebbank.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cib\",\n    \"name\": \"中国兴业银行\",\n    \"host\": \"cib.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cau\",\n    \"name\": \"中国农业大学\",\n    \"host\": \"ciee.cau.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"nlc\",\n    \"name\": \"中国国家图书馆\",\n    \"host\": \"read.nlc.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"airchina\",\n    \"name\": \"中国国际航空公司\",\n    \"host\": \"airchina.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"ciidbnu\",\n    \"name\": \"中国收入分配研究院\",\n    \"host\": \"ciidbnu.org\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"chinanews\",\n    \"name\": \"中国新闻网\",\n    \"host\": \"chinanews.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"chinania\",\n    \"name\": \"中国有色金属工业网\",\n    \"host\": \"chinania.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cfmmc\",\n    \"name\": \"中国期货市场监控中心\",\n    \"host\": \"cfmmc.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"caam\",\n    \"name\": \"中国汽车工业协会\",\n    \"host\": \"caam.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"auto-stats\",\n    \"name\": \"中国汽车工业协会统计信息网\",\n    \"host\": \"auto-stats.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ouc\",\n    \"name\": \"中国海洋大学\",\n    \"host\": \"it.ouc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cnljxh\",\n    \"name\": \"中国炼焦行业协会\",\n    \"host\": \"cnljxh.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ccagm\",\n    \"name\": \"中国百货商业协会\",\n    \"host\": \"ccagm.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"medieval-china\",\n    \"name\": \"中国的中古\",\n    \"host\": \"medieval-china.club\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cbpanet\",\n    \"name\": \"中国豆制品网\",\n    \"host\": \"cbpanet.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"ccfa\",\n    \"name\": \"中国连锁经营协会\",\n    \"host\": \"ccfa.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cccfna\",\n    \"name\": \"中国食品土畜进出口商会\",\n    \"host\": \"cccfna.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"ctinews\",\n    \"name\": \"中天新聞網\",\n    \"host\": \"ctinews.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"sysu\",\n    \"name\": \"中山大学\",\n    \"host\": \"cse.sysu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"miyuki\",\n    \"name\": \"中島みゆき Official\",\n    \"host\": \"miyuki.jp\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"lsnu\",\n    \"name\": \"乐山师范学院\",\n    \"host\": \"lsnu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"2cycd\",\n    \"name\": \"二次元虫洞\",\n    \"host\": \"2cycd.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"sorrycc\",\n    \"name\": \"云谦的博客\",\n    \"host\": \"sorrycc.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"jiaoliudao\",\n    \"name\": \"交流岛资源网\",\n    \"host\": \"jiaoliudao.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"jd\",\n    \"name\": \"京东\",\n    \"host\": \"item.jd.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"iiilab\",\n    \"name\": \"人人都是自媒体\",\n    \"host\": \"iiilab.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"youku\",\n    \"name\": \"优酷\",\n    \"host\": \"i.youku.com\",\n    \"heat\": 0,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"pianyivps\",\n    \"name\": \"便宜VPS网\",\n    \"host\": \"pianyivps.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"chongdiantou\",\n    \"name\": \"充电头网\",\n    \"host\": \"chongdiantou.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"free\",\n    \"name\": \"免費資源網路社群\",\n    \"host\": \"free.com.tw\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"nmtv\",\n    \"name\": \"内蒙古广播电视台\",\n    \"host\": \"nmtv.cn\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"caus\",\n    \"name\": \"加美财经\",\n    \"host\": \"caus.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"buct\",\n    \"name\": \"北京化工大学\",\n    \"host\": \"buct.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"pumc\",\n    \"name\": \"北京协和医学院\",\n    \"host\": \"mdadmission.pumc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"bast\",\n    \"name\": \"北京市科学技术协会\",\n    \"host\": \"bast.net.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"bjfu\",\n    \"name\": \"北京林业大学\",\n    \"host\": \"graduate.bjfu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"bit\",\n    \"name\": \"北京理工大学\",\n    \"host\": \"cs.bit.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"bse\",\n    \"name\": \"北京证券交易所\",\n    \"host\": \"bse.cn\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"ynet\",\n    \"name\": \"北青网\",\n    \"host\": \"ynet.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"qianp\",\n    \"name\": \"千篇网\",\n    \"host\": \"qianp.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ecust\",\n    \"name\": \"华东理工大学\",\n    \"host\": \"e.ecust.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ccnu\",\n    \"name\": \"华中师范大学\",\n    \"host\": \"ccnu.91wllm.com\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"scau\",\n    \"name\": \"华南农业大学\",\n    \"host\": \"yzb.scau.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njucm\",\n    \"name\": \"南京中医药大学\",\n    \"host\": \"lib.njucm.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njit\",\n    \"name\": \"南京工程学院\",\n    \"host\": \"jwc.njit.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njnu\",\n    \"name\": \"南京师范大学\",\n    \"host\": \"ceai.njnu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njxzc\",\n    \"name\": \"南京晓庄学院\",\n    \"host\": \"lib.njxzc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"nuaa\",\n    \"name\": \"南京航空航天大学\",\n    \"host\": \"aao.nuaa.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njupt\",\n    \"name\": \"南京邮电大学\",\n    \"host\": \"jwc.njupt.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"njglyy\",\n    \"name\": \"南京鼓楼医院\",\n    \"host\": \"njglyy.com\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"cnjxol\",\n    \"name\": \"南湖清风\",\n    \"host\": \"cnjxol.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"xmut\",\n    \"name\": \"厦门理工大学\",\n    \"host\": \"jwc.xmut.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cpcey\",\n    \"name\": \"台湾行政院消费者保护会\",\n    \"host\": \"cpc.ey.gov.tw\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"mohw\",\n    \"name\": \"台灣衛生福利部\",\n    \"host\": \"mohw.gov.tw\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"jl1mall\",\n    \"name\": \"吉林一号网\",\n    \"host\": \"jl1mall.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"jsu\",\n    \"name\": \"吉首大学\",\n    \"host\": \"jsu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"tangshufang\",\n    \"name\": \"唐书房\",\n    \"host\": \"tangshufang.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"stbu\",\n    \"name\": \"四川工商学院\",\n    \"host\": \"stbu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"scvtc\",\n    \"name\": \"四川职业技术学院\",\n    \"host\": \"scvtc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"m4\",\n    \"name\": \"四月网\",\n    \"host\": \"news.m4.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"szftedu\",\n    \"name\": \"园岭小学\",\n    \"host\": \"ylxx.szftedu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"zhujiceping\",\n    \"name\": \"国外主机测评\",\n    \"host\": \"zhujiceping.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"grainoil\",\n    \"name\": \"国家粮油信息中心\",\n    \"host\": \"load.grainoil.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"aa1\",\n    \"name\": \"夏柔\",\n    \"host\": \"aa1.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"duozhi\",\n    \"name\": \"多知网\",\n    \"host\": \"duozhi.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"dianping\",\n    \"name\": \"大众点评\",\n    \"host\": \"dianping.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"tsdm39\",\n    \"name\": \"天使动漫论坛\",\n    \"host\": \"tsdm39.com\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"tynu\",\n    \"name\": \"太原师范学院\",\n    \"host\": \"tynu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"qipamaijia\",\n    \"name\": \"奇葩买家秀\",\n    \"host\": \"qipamaijia.com\",\n    \"heat\": 0,\n    \"categories\": [\"picture\"]\n  },\n  {\n    \"key\": \"ippa\",\n    \"name\": \"子方有料\",\n    \"host\": \"ippa.top\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"ahjzu\",\n    \"name\": \"安徽建筑大学\",\n    \"host\": \"news.ahjzu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"j-test\",\n    \"name\": \"实用日本语鉴定考试（J.TEST）\",\n    \"host\": \"j-test.com\",\n    \"heat\": 0,\n    \"categories\": [\"study\"]\n  },\n  {\n    \"key\": \"uibe\",\n    \"name\": \"对外经济贸易大学\",\n    \"host\": \"hr.uibe.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"sdu\",\n    \"name\": \"山东大学\",\n    \"host\": \"sdu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"sdust\",\n    \"name\": \"山东科技大学\",\n    \"host\": \"sdust.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"oceanengine\",\n    \"name\": \"巨量算数 - 算数指数\",\n    \"host\": \"trendinsight.oceanengine.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"gdut\",\n    \"name\": \"广东工业大学\",\n    \"host\": \"oas.gdut.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"gdsrx\",\n    \"name\": \"广东省食品药品审评认证技术协会\",\n    \"host\": \"gdsrx.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"guangzhoumetro\",\n    \"name\": \"广州地铁\",\n    \"host\": \"gzmtr.com\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"gzhu\",\n    \"name\": \"广州大学\",\n    \"host\": \"yjsy.gzhu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"commonhealth\",\n    \"name\": \"康健\",\n    \"host\": \"commonhealth.com.tw\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"kuaidi100\",\n    \"name\": \"快递 100\",\n    \"host\": \"kuaidi100.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cdu\",\n    \"name\": \"成都大学\",\n    \"host\": \"cdu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"onehu\",\n    \"name\": \"我不是盐神\",\n    \"host\": \"onehu.xyz\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"sogou\",\n    \"name\": \"搜狗\",\n    \"host\": \"sogou.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"dgtle\",\n    \"name\": \"数字尾巴\",\n    \"host\": \"dgtle.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"bnext\",\n    \"name\": \"數位時代 BusinessNext\",\n    \"host\": \"bnext.com.tw\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"xyu\",\n    \"name\": \"新余学院\",\n    \"host\": \"xyc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"weekendhk\",\n    \"name\": \"新假期周刊\",\n    \"host\": \"weekendhk.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"sqmc\",\n    \"name\": \"新乡医学院三全学院\",\n    \"host\": \"sqmc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"newseed\",\n    \"name\": \"新芽\",\n    \"host\": \"newseed.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"cztv\",\n    \"name\": \"新蓝网（浙江广播电视集团）\",\n    \"host\": \"cztv.com\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"sinchew\",\n    \"name\": \"星洲网\",\n    \"host\": \"sinchew.com.my\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"chinafactcheck\",\n    \"name\": \"有据\",\n    \"host\": \"chinafactcheck.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"wsyu\",\n    \"name\": \"武昌首义学院\",\n    \"host\": \"wsyu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"wtu\",\n    \"name\": \"武汉纺织大学\",\n    \"host\": \"wtu.91wllm.com\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"shuiguopai\",\n    \"name\": \"水果派\",\n    \"host\": \"shuiguopai.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"jou\",\n    \"name\": \"江苏海洋大学\",\n    \"host\": \"jou.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hebtv\",\n    \"name\": \"河北网络广播电视台\",\n    \"host\": \"web.cmc.hebtv.com\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"hafu\",\n    \"name\": \"河南财政金融学院\",\n    \"host\": \"hafu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"qztc\",\n    \"name\": \"泉州师范学院\",\n    \"host\": \"qztc.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"zjgtjy\",\n    \"name\": \"浙江省土地使用权网上交易系统\",\n    \"host\": \"zjgtjy.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"121\",\n    \"name\": \"深圳台风网\",\n    \"host\": \"121.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"forecast\"]\n  },\n  {\n    \"key\": \"wzu\",\n    \"name\": \"温州大学\",\n    \"host\": \"wzu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"gamegene\",\n    \"name\": \"游戏基因\",\n    \"host\": \"news.gamegene.cn\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"hubu\",\n    \"name\": \"湖北大学\",\n    \"host\": \"hubu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hunau\",\n    \"name\": \"湖南农业大学\",\n    \"host\": \"gfxy.hunau.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"wfu\",\n    \"name\": \"潍坊学院\",\n    \"host\": \"jwc.wfu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ssm\",\n    \"name\": \"澳门卫生局\",\n    \"host\": \"ssm.gov.mo\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"huitun\",\n    \"name\": \"灰豚数据\",\n    \"host\": \"huitun.com\",\n    \"heat\": 0,\n    \"categories\": [\"social-media\"]\n  },\n  {\n    \"key\": \"pianyuan\",\n    \"name\": \"片源网\",\n    \"host\": \"pianyuan.org\",\n    \"heat\": 0,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"coolbuy\",\n    \"name\": \"玩物志\",\n    \"host\": \"coolbuy.com\",\n    \"heat\": 0,\n    \"categories\": [\"shopping\"]\n  },\n  {\n    \"key\": \"globallawreview\",\n    \"name\": \"环球法律评论\",\n    \"host\": \"globallawreview.org\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cntheory\",\n    \"name\": \"理论网\",\n    \"host\": \"paper.cntheory.com\",\n    \"heat\": 0,\n    \"categories\": [\"traditional-media\"]\n  },\n  {\n    \"key\": \"ygkkk\",\n    \"name\": \"甬哥侃侃侃YouTube教程摘要随笔\",\n    \"host\": \"ygkkk.blogspot.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"playpcesor\",\n    \"name\": \"电脑玩物\",\n    \"host\": \"playpcesor.com\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"fzmtr\",\n    \"name\": \"福州地铁\",\n    \"host\": \"fzmtr.com\",\n    \"heat\": 0,\n    \"categories\": [\"travel\"]\n  },\n  {\n    \"key\": \"qq88\",\n    \"name\": \"秋爸日字\",\n    \"host\": \"qq88.info\",\n    \"heat\": 0,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"scitechvista\",\n    \"name\": \"科技大觀園\",\n    \"host\": \"scitechvista.nat.gov.tw\",\n    \"heat\": 0,\n    \"categories\": [\"government\"]\n  },\n  {\n    \"key\": \"biquge\",\n    \"name\": \"笔趣阁\",\n    \"host\": \"xbiquwx.la\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cbnweek\",\n    \"name\": \"第一财经杂志\",\n    \"host\": \"cbnweek.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"jingzhengu\",\n    \"name\": \"精真估\",\n    \"host\": \"jingzhengu.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"cbaigui\",\n    \"name\": \"纪妖\",\n    \"host\": \"cbaigui.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"50forum\",\n    \"name\": \"经济 50 人论坛\",\n    \"host\": \"50forum.org.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"zyshow\",\n    \"name\": \"综艺秀\",\n    \"host\": \"zyshow.net\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"luolei\",\n    \"name\": \"罗磊的独立博客\",\n    \"host\": \"luolei.org\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"my-formosa\",\n    \"name\": \"美麗島電子報\",\n    \"host\": \"my-formosa.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"usts\",\n    \"name\": \"苏州科技大学\",\n    \"host\": \"jwch.usts.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"englishhome\",\n    \"name\": \"英語之家\",\n    \"host\": \"englishhome.org\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"loltw\",\n    \"name\": \"英雄联盟\",\n    \"host\": \"lol.garena.tw\",\n    \"heat\": 0,\n    \"categories\": [\"game\"]\n  },\n  {\n    \"key\": \"lala\",\n    \"name\": \"荒岛\",\n    \"host\": \"lala.im\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"nwafu\",\n    \"name\": \"西北农林科技大学\",\n    \"host\": \"nwafu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"xbmu\",\n    \"name\": \"西北民族大学\",\n    \"host\": \"xbmu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"xaut\",\n    \"name\": \"西安理工大学\",\n    \"host\": \"xaut.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"guanhai\",\n    \"name\": \"观海新闻\",\n    \"host\": \"guanhai.com.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"zrblog\",\n    \"name\": \"赵容部落\",\n    \"host\": \"zrblog.net\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"chaoxing\",\n    \"name\": \"超星\",\n    \"host\": \"chaoxing.com\",\n    \"heat\": 0,\n    \"categories\": [\"reading\"]\n  },\n  {\n    \"key\": \"getdr\",\n    \"name\": \"趨勢科技防詐達人\",\n    \"host\": \"getdr.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"runtrail\",\n    \"name\": \"跑野大爆炸\",\n    \"host\": \"runtrail.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"lightnovel\",\n    \"name\": \"轻之国度\",\n    \"host\": \"lightnovel.us\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"easynomad\",\n    \"name\": \"轻松游牧-远程工作聚集地\",\n    \"host\": \"easynomad.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"stream-capital\",\n    \"name\": \"远川研究所\",\n    \"host\": \"stream-capital.com\",\n    \"heat\": 0,\n    \"categories\": [\"finance\"]\n  },\n  {\n    \"key\": \"fanxinzhui\",\n    \"name\": \"追新番\",\n    \"host\": \"fanxinzhui.com\",\n    \"heat\": 0,\n    \"categories\": [\"multimedia\"]\n  },\n  {\n    \"key\": \"daoxuan\",\n    \"name\": \"道宣的窝\",\n    \"host\": \"daoxuan.cc\",\n    \"heat\": 0,\n    \"categories\": [\"blog\"]\n  },\n  {\n    \"key\": \"zzu\",\n    \"name\": \"郑州大学\",\n    \"host\": \"zzu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cqu\",\n    \"name\": \"重庆大学\",\n    \"host\": \"cqu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"ctbu\",\n    \"name\": \"重庆工商大学\",\n    \"host\": \"ctbu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"pencilnews\",\n    \"name\": \"铅笔道\",\n    \"host\": \"pencilnews.cn\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"chaincatcher\",\n    \"name\": \"链捕手 ChainCatcher\",\n    \"host\": \"chaincatcher.com\",\n    \"heat\": 0,\n    \"categories\": [\"new-media\"]\n  },\n  {\n    \"key\": \"yangtzeu\",\n    \"name\": \"长江大学\",\n    \"host\": \"yangtzeu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"ccmn\",\n    \"name\": \"长江有色网\",\n    \"host\": \"ccmn.cn\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  },\n  {\n    \"key\": \"csust\",\n    \"name\": \"长沙理工大学\",\n    \"host\": \"csust.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"denonbu\",\n    \"name\": \"電音部\",\n    \"host\": \"denonbu.jp\",\n    \"heat\": 0,\n    \"categories\": [\"anime\"]\n  },\n  {\n    \"key\": \"qdu\",\n    \"name\": \"青岛大学\",\n    \"host\": \"jwc.qdu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"qust\",\n    \"name\": \"青岛科技大学\",\n    \"host\": \"jw.qust.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"cnu\",\n    \"name\": \"首都师范大学\",\n    \"host\": \"cnu.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hljucm\",\n    \"name\": \"黑龙江中医药大学\",\n    \"host\": \"yjsy.hljucm.net\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"byau\",\n    \"name\": \"黑龙江八一农垦大学\",\n    \"host\": \"byau.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"hlju\",\n    \"name\": \"黑龙江大学\",\n    \"host\": \"hlju.edu.cn\",\n    \"heat\": 0,\n    \"categories\": [\"university\"]\n  },\n  {\n    \"key\": \"lkong\",\n    \"name\": \"龙空\",\n    \"host\": \"lkong.com\",\n    \"heat\": 0,\n    \"categories\": [\"other\"]\n  }\n]\n"
  },
  {
    "path": "apps/landing/public/manifest.json",
    "content": "{\n  \"theme_color\": \"#ff5c00\",\n  \"name\": \"Folo\",\n  \"icons\": [\n    {\n      \"src\": \"/icon-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/icon-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/landing/src/app/ClientInit.tsx",
    "content": "'use client'\n\nimport { init } from './init'\n\ninit()\nexport const ClientInit = () => null\n"
  },
  {
    "path": "apps/landing/src/app/InitInClient.ts",
    "content": "'use client'\n\nimport { init } from './init'\n\ninit()\n\nexport const InitInClient = () => {\n  return null\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/download/page.tsx",
    "content": "import type { Metadata } from 'next'\nimport { headers } from 'next/headers'\nimport { getTranslations } from 'next-intl/server'\n\nimport { DownloadHero } from '~/components/widgets/download/DownloadHero'\nimport { PlatformDownloads } from '~/components/widgets/download/PlatformDownloads'\nimport { defaultLocale, locales } from '~/i18n/routing'\nimport { detectPlatform } from '~/lib/platform'\n\ntype LocaleParams = { locale?: string }\n\nconst localeSet = new Set(locales)\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<LocaleParams> | LocaleParams | undefined\n}): Promise<Metadata> {\n  const localeFromParams = params ? (await params).locale : undefined\n  const locale =\n    localeFromParams &&\n    localeSet.has(localeFromParams as (typeof locales)[number])\n      ? localeFromParams\n      : defaultLocale\n  const t = await getTranslations({ locale, namespace: 'download.metadata' })\n\n  return {\n    title: t('title'),\n    description: t('description'),\n  }\n}\n\nexport default async function DownloadPage() {\n  const ua = (await headers()).get('user-agent')?.toLowerCase()\n\n  return (\n    <>\n      <DownloadHero />\n      <PlatformDownloads detectedOS={detectPlatform(ua || '')} />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/error.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { useEffect } from 'react'\n\nimport { NormalContainer } from '~/components/layout/container/Normal'\nimport { Button } from '~/components/ui/button'\n\nexport default ({ error, reset }: any) => {\n  const errorT = useTranslations('common.error')\n  useEffect(() => {\n    console.error(error)\n    // captureException(error)\n  }, [error])\n\n  return (\n    <NormalContainer>\n      <div className=\"center flex min-h-[calc(100vh-10rem)] flex-col\">\n        <h2 className=\"mb-5\">{errorT('title')}</h2>\n        <Button variant=\"primary\" onClick={reset}>\n          {errorT('action')}\n        </Button>\n      </div>\n    </NormalContainer>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/layout.tsx",
    "content": "import type { Metadata, Viewport } from 'next'\nimport { NextIntlClientProvider } from 'next-intl'\nimport { getMessages, getTranslations } from 'next-intl/server'\n\nimport { HydrationEndDetector } from '~/components/common/HydrationEndDetector'\nimport { ScrollTop } from '~/components/common/ScrollTop'\nimport { Root } from '~/components/layout/root/Root'\nimport { LightRays } from '~/components/ui/light-rays'\nimport { LandingHeader } from '~/components/widgets/landing/Header'\nimport { siteInfo } from '~/constants/site'\nimport { defaultLocale, locales } from '~/i18n/routing'\nimport { sansFont } from '~/lib/fonts'\n\nimport { Providers } from '../../providers/root'\nimport { ClientInit } from '../ClientInit'\nimport { init } from '../init'\nimport { InitInClient } from '../InitInClient'\n\ninit()\n\ntype LocaleParams = { locale?: string }\n\ntype MaybeAsyncLocaleParams = LocaleParams | Promise<LocaleParams> | undefined\n\nconst localeSet = new Set(locales)\n\nconst resolveLocale = async (params: MaybeAsyncLocaleParams) => {\n  const locale = params ? (await params).locale : undefined\n\n  if (locale && localeSet.has(locale as (typeof locales)[number])) {\n    return locale\n  }\n\n  return defaultLocale\n}\n\nexport function generateStaticParams() {\n  return locales.map((locale) => ({ locale }))\n}\n\nexport async function generateMetadata(props: {\n  params: MaybeAsyncLocaleParams\n}): Promise<Metadata> {\n  const locale = await resolveLocale(props.params)\n  const t = await getTranslations({ locale, namespace: 'metadata' })\n\n  const title = t('title', { defaultValue: siteInfo.title })\n  const description = t('description', {\n    defaultValue: siteInfo.description,\n  })\n\n  const keywordsString = t('keywords', {\n    defaultValue: siteInfo.seo.keywords.join(', '),\n  })\n  const keywords = keywordsString.split(',').map((keyword) => keyword.trim())\n\n  return {\n    metadataBase: new URL(siteInfo.webUrl),\n    title: {\n      template: `%s · ${title}`,\n      default: `${title} — ${description}`,\n    },\n    description,\n    keywords,\n    icons: [\n      {\n        rel: 'icon',\n        url: '/favicon.ico',\n      },\n    ],\n    alternates: {\n      canonical: '/',\n    },\n    robots: {\n      index: true,\n      follow: true,\n      googleBot: {\n        index: true,\n        follow: true,\n        'max-video-preview': -1,\n        'max-image-preview': 'large',\n        'max-snippet': -1,\n      },\n    },\n    openGraph: {\n      title: {\n        default: `${title} — ${description}`,\n        template: `%s · ${title}`,\n      },\n      description,\n      siteName: title,\n      locale: locale === 'en' ? 'en_US' : locale,\n      type: 'website',\n      url: siteInfo.webUrl,\n      images: [{ url: '/og.png' }],\n    },\n    twitter: {\n      card: 'summary_large_image',\n      title: `${title} — ${description}`,\n      description,\n      images: ['/og.png'],\n    },\n  }\n}\n\nexport function generateViewport(): Viewport {\n  return {\n    themeColor: [\n      { media: '(prefers-color-scheme: dark)', color: '#000212' },\n      { media: '(prefers-color-scheme: light)', color: '#fafafa' },\n    ],\n    initialScale: 1,\n    viewportFit: 'cover',\n    width: 'device-width',\n    maximumScale: 1,\n    minimumScale: 1,\n    userScalable: false,\n  }\n}\n\nexport default async function LocaleLayout({\n  children,\n  params,\n}: {\n  children: React.ReactNode\n  params: MaybeAsyncLocaleParams\n}) {\n  const locale = await resolveLocale(params)\n  const messages = await getMessages({ locale })\n\n  return (\n    <>\n      <ClientInit />\n      <html lang={locale} suppressHydrationWarning>\n        <head>\n          <HydrationEndDetector />\n        </head>\n        <body className={`${sansFont.variable} m-0 h-full p-0 font-sans`}>\n          <NextIntlClientProvider locale={locale} messages={messages}>\n            <Providers>\n              <div data-theme>\n                <Root>\n                  <LightRays\n                    length=\"600px\"\n                    className=\"absolute inset-x-0 -top-6 h-[750px]\"\n                    color=\"#ff5c0010\"\n                  />\n\n                  <LandingHeader />\n                  {children}\n                </Root>\n              </div>\n            </Providers>\n          </NextIntlClientProvider>\n\n          <ScrollTop />\n          <InitInClient />\n        </body>\n      </html>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/page.tsx",
    "content": "import * as React from 'react'\n\nimport { BuiltOpen } from '~/components/widgets/landing/BuiltOpen'\nimport { Features } from '~/components/widgets/landing/Features'\nimport { LandingHero } from '~/components/widgets/landing/Hero'\nimport { TrustedBy } from '~/components/widgets/landing/TrustedBy'\nimport {\n  DISCOVER_FALLBACK,\n  getHeroTimelineItems,\n  getLandingMetrics,\n  TRUSTED_COMPANIES,\n} from '~/lib/landing-data'\n\ntype LocaleParams = { locale?: string }\n\nexport default async function Home({\n  params,\n}: {\n  params: Promise<LocaleParams> | LocaleParams | undefined\n}) {\n  const locale = params ? (await params).locale : undefined\n  const [heroItems, metrics] = await Promise.all([\n    getHeroTimelineItems(locale),\n    getLandingMetrics(),\n  ])\n\n  return (\n    <>\n      <LandingHero items={heroItems} />\n      <TrustedBy companies={TRUSTED_COMPANIES} metrics={metrics} />\n      <Features discoverSources={DISCOVER_FALLBACK} />\n      {/* <ViewsShowcase /> */}\n      {/* <Audience /> */}\n      <BuiltOpen />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/pricing/page.tsx",
    "content": "import type { Metadata } from 'next'\nimport { getTranslations } from 'next-intl/server'\n\nimport { PricingPlans } from '~/components/widgets/pricing/PricingPlans'\nimport { defaultLocale, locales } from '~/i18n/routing'\nimport { fetchPricingPlans } from '~/lib/pricing-data'\n\ntype LocaleParams = { locale?: string }\n\nconst localeSet = new Set(locales)\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<LocaleParams> | LocaleParams | undefined\n}): Promise<Metadata> {\n  const localeFromParams = params ? (await params).locale : undefined\n  const locale =\n    localeFromParams &&\n    localeSet.has(localeFromParams as (typeof locales)[number])\n      ? localeFromParams\n      : defaultLocale\n  const t = await getTranslations({ locale, namespace: 'pricing.metadata' })\n\n  return {\n    title: t('title'),\n    description: t('description'),\n  }\n}\n\nexport default async function PricingPage() {\n  const plans = await fetchPricingPlans()\n\n  return <PricingPlans plans={plans} />\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/privacy-policy/page.tsx",
    "content": "import type { Metadata } from 'next/types'\n\nimport { getMarkdownContent, MarkdownContent } from '~/components/ui/markdown'\nimport { siteInfo } from '~/constants/site'\n\nexport const metadata: Metadata = {\n  title: 'Privacy Policy',\n  description:\n    \"Read Folo's privacy policy to understand how we collect, use, and protect your personal information when using our next-generation information browser.\",\n  robots: {\n    index: true,\n    follow: true,\n  },\n  openGraph: {\n    ...siteInfo.seo.openGraph,\n    title: 'Privacy Policy - Folo',\n    description:\n      \"Read Folo's privacy policy to understand how we protect your data and privacy.\",\n    url: `${siteInfo.webUrl}/privacy-policy`,\n  },\n  twitter: {\n    ...siteInfo.seo.twitter,\n    title: 'Privacy Policy - Folo',\n    description:\n      \"Read Folo's privacy policy to understand how we protect your data and privacy.\",\n  },\n  alternates: {\n    canonical: '/privacy-policy',\n  },\n}\n\nexport default async function PrivacyPolicyPage() {\n  const { content } = await getMarkdownContent('legal/privacy.md')\n\n  return <MarkdownContent content={content} />\n}\n"
  },
  {
    "path": "apps/landing/src/app/[locale]/terms-of-service/page.tsx",
    "content": "import type { Metadata } from 'next/types'\n\nimport { getMarkdownContent, MarkdownContent } from '~/components/ui/markdown'\nimport { siteInfo } from '~/constants/site'\n\nexport const metadata: Metadata = {\n  title: 'Terms of Service',\n  description:\n    \"Read Folo's terms of service to understand the rules and guidelines for using our next-generation information browser platform.\",\n  robots: {\n    index: true,\n    follow: true,\n  },\n  openGraph: {\n    ...siteInfo.seo.openGraph,\n    title: 'Terms of Service - Folo',\n    description: \"Read Folo's terms of service and usage guidelines.\",\n    url: `${siteInfo.webUrl}/terms-of-service`,\n  },\n  twitter: {\n    ...siteInfo.seo.twitter,\n    title: 'Terms of Service - Folo',\n    description: \"Read Folo's terms of service and usage guidelines.\",\n  },\n  alternates: {\n    canonical: '/terms-of-service',\n  },\n}\n\nexport default async function TermsOfServicePage() {\n  const { content } = await getMarkdownContent('legal/tos.md')\n\n  return <MarkdownContent content={content} />\n}\n"
  },
  {
    "path": "apps/landing/src/app/apple-app-site-association/route.ts",
    "content": "import { APPLE_APP_SITE_ASSOCIATION } from '~/lib/apple-app-site-association'\n\nexport function GET() {\n  return Response.json(APPLE_APP_SITE_ASSOCIATION, {\n    headers: {\n      'Cache-Control': 'public, max-age=300',\n    },\n  })\n}\n"
  },
  {
    "path": "apps/landing/src/app/discover-sources/route.ts",
    "content": "import type { RSSHubRoutesIndex } from '~/lib/landing-data'\nimport {\n  buildDiscoverSourcesFromIndex,\n  DISCOVER_FALLBACK,\n  PRODUCTION_RSSHUB_ROUTES_URL,\n} from '~/lib/landing-data'\n\nconst CACHE_TTL_MS = 12 * 60 * 60 * 1000\n\nlet cached:\n  | {\n      expiresAt: number\n      data: ReturnType<typeof buildDiscoverSourcesFromIndex>\n    }\n  | undefined\n\nexport async function GET() {\n  const now = Date.now()\n\n  if (cached && cached.expiresAt > now) {\n    return Response.json(cached.data, {\n      headers: {\n        'Cache-Control': 'public, max-age=600',\n      },\n    })\n  }\n\n  try {\n    const response = await fetch(PRODUCTION_RSSHUB_ROUTES_URL, {\n      headers: {\n        accept: 'application/json',\n      },\n    })\n\n    if (!response.ok) {\n      throw new Error(`Failed to fetch RSSHub routes: ${response.status}`)\n    }\n\n    const result = (await response.json()) as RSSHubRoutesIndex\n    const data = buildDiscoverSourcesFromIndex(result)\n\n    cached = {\n      data,\n      expiresAt: now + CACHE_TTL_MS,\n    }\n\n    return Response.json(data, {\n      headers: {\n        'Cache-Control': 'public, max-age=600',\n      },\n    })\n  } catch {\n    return Response.json(DISCOVER_FALLBACK, {\n      headers: {\n        'Cache-Control': 'public, max-age=60',\n      },\n    })\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/app/globals.css",
    "content": "@import '../styles/globals.css';\n"
  },
  {
    "path": "apps/landing/src/app/init.ts",
    "content": "import 'dayjs/locale/zh-cn'\n\nimport dayjs from 'dayjs'\n\nexport const init = () => {\n  dayjs.locale('zh-cn')\n}\n"
  },
  {
    "path": "apps/landing/src/app/layout.tsx",
    "content": "import './globals.css'\n\nexport default function RootLayout({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  return children\n}\n"
  },
  {
    "path": "apps/landing/src/app/robots.ts",
    "content": "import type { MetadataRoute } from 'next'\n\nexport default function robots(): MetadataRoute.Robots {\n  return {\n    rules: {\n      userAgent: '*',\n      allow: '/',\n      disallow: ['/login/'],\n    },\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/atoms/css-media.ts",
    "content": "import { createAtomHooks } from 'jojoo/react'\nimport { atom } from 'jotai'\n\nexport const [, , useIsPrintMode, , , setIsPrintMode] = createAtomHooks(\n  atom(false),\n)\n"
  },
  {
    "path": "apps/landing/src/atoms/index.ts",
    "content": "export * from './css-media'\nexport * from './viewport'\n"
  },
  {
    "path": "apps/landing/src/atoms/is-interactive.ts",
    "content": "import { atom, useAtomValue } from 'jotai'\n\nimport { jotaiStore } from '~/lib/store'\n\nconst isInteractiveAtom = atom(false)\nexport const useIsInteractive = () => useAtomValue(isInteractiveAtom)\n\nexport const getIsInteractive = () => jotaiStore.get(isInteractiveAtom)\nexport const setIsInteractive = (value: boolean) =>\n  jotaiStore.set(isInteractiveAtom, value)\n"
  },
  {
    "path": "apps/landing/src/atoms/viewport.ts",
    "content": "import type { ExtractAtomValue } from 'jotai'\nimport { atom, useAtomValue } from 'jotai'\nimport { selectAtom } from 'jotai/utils'\nimport { useCallback } from 'react'\n\nexport const viewportAtom = atom({\n  /**\n   * 640px\n   */\n  sm: false,\n\n  /**\n   * 768px\n   */\n  md: false,\n\n  /**\n   * 1024px\n   */\n  lg: false,\n\n  /**\n   * 1280px\n   */\n  xl: false,\n\n  /**\n   * 1536px\n   */\n  '2xl': false,\n\n  h: 0,\n  w: 0,\n})\n\nexport const useViewport = <T>(\n  selector: (value: ExtractAtomValue<typeof viewportAtom>) => T,\n): T =>\n  useAtomValue(\n    // @ts-ignore\n    selectAtom(\n      viewportAtom,\n      useCallback((atomValue) => selector(atomValue), []),\n    ),\n  )\n\nexport const useIsMobile = () =>\n  useViewport(\n    useCallback(\n      (v: ExtractAtomValue<typeof viewportAtom>) =>\n        (v.sm || v.md || !v.sm) && !v.lg,\n      [],\n    ),\n  )\n"
  },
  {
    "path": "apps/landing/src/components/brand/Folo.tsx",
    "content": "import * as React from 'react'\n\nexport const Folo = ({\n  ref,\n  ...props\n}: React.SVGProps<SVGSVGElement> & {\n  ref?: React.Ref<SVGSVGElement | null>\n}) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n    ref={ref}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M.899 16.997c-.567 0-.899-.358-.899-.994v-7.77c0-.637.36-.996 1.01-.996h4.34c.595 0 .927.29.927.788 0 .497-.332.774-.926.774H1.797v2.336H5.06c.595 0 .927.263.927.76 0 .512-.332.775-.927.775H1.797v3.332c0 .636-.318.996-.898.996m9.035.125c-2.101 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.13 0 3.567 1.534 3.567 3.705 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185m5.266 1.367c-.553 0-.857-.359-.857-.967V7.845c0-.608.304-.968.857-.968s.857.36.857.968v8.185c0 .608-.29.967-.857.967m5.234.125c-2.102 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.129 0 3.566 1.534 3.566 3.704 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185\"\n    />\n  </svg>\n)\n"
  },
  {
    "path": "apps/landing/src/components/brand/Logo.tsx",
    "content": "import * as React from 'react'\n\nexport const Logo = ({\n  ref,\n  ...props\n}: React.SVGProps<SVGSVGElement> & {\n  ref?: React.Ref<SVGSVGElement | null>\n  accentColor?: string\n}) => {\n  const { accentColor, ...rest } = props\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      {...rest}\n      ref={ref}\n    >\n      <title>Folo</title>\n      <path\n        fill={accentColor || '#ff5c00'}\n        d=\"M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z\"\n      />\n      <path\n        fill=\"#fff\"\n        d=\"M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/ClientOnly.tsx",
    "content": "'use client'\n\nimport { useIsClient } from '~/hooks/common/use-is-client'\n\nexport const ClientOnly: Component = (props) => {\n  const isClient = useIsClient()\n  if (!isClient) return null\n  return <>{props.children}</>\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/ErrorBoundary.tsx",
    "content": "'use client'\n\nimport type { FC, PropsWithChildren } from 'react'\nimport { ErrorBoundary as ErrorBoundaryLib } from 'react-error-boundary'\n\nimport { Button } from '../ui/button'\n\nconst FallbackComponent = () => {\n  return (\n    <div className=\"center flex w-full flex-col py-6\">\n      Something went wrong.\n      <Button\n        onClick={() => {\n          window.location.reload()\n        }}\n      >\n        Reload Page\n      </Button>\n    </div>\n  )\n}\nexport const ErrorBoundary: FC<PropsWithChildren> = ({ children }) => {\n  return (\n    <ErrorBoundaryLib\n      FallbackComponent={FallbackComponent}\n      onError={(e) => {\n        console.error(e)\n\n        // TODO  sentry\n\n        // captureException(e)\n      }}\n    >\n      {children}\n    </ErrorBoundaryLib>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/GithubTrending.tsx",
    "content": "export default function GithubTrending() {\n  return (\n    <svg\n      className=\"max-h-full\"\n      data-date-format=\"longDate\"\n      height=\"55\"\n      width=\"250\"\n      viewBox=\"0 0 250 53\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <rect fill=\"#111111\" height=\"53\" rx=\"10\" x=\"0.5\" y=\"0.5\" />\n      <svg\n        fill=\"currentColor\"\n        height=\"45\"\n        width=\"48\"\n        version=\"1.1\"\n        viewBox=\"0 0 80 80\"\n        x=\"0\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n        y=\"8\"\n      >\n        <path\n          fill=\"currentColor\"\n          stroke=\"currentColor\"\n          d=\"M70.71,40.31C75.74,44.3,80,37.86,80,37.86s-5.64-2.17-8.55,0.61c0.59-1.62,1.02-3.31,1.28-5.01  c4.08,2.16,6.44-2.95,6.44-2.95s-4.41-0.97-6.26,1.4c0.08-0.91,0.12-1.82,0.1-2.73c-0.01-0.36-0.02-0.73-0.05-1.09  c2.96-3.68-1.73-6.99-1.73-6.99s-2.14,5.09,0.98,7.09c0.02,0.33,0.03,0.66,0.03,1c0.01,0.76-0.03,1.52-0.1,2.27  c-0.85-2.69-4.91-3.69-4.91-3.69s-0.13,5.78,4.68,5.48c-0.28,1.69-0.73,3.35-1.34,4.95c-0.19-4.03-5.79-6.33-5.79-6.33  s-1.33,7.55,5.01,8.16c-0.38,0.8-0.8,1.57-1.25,2.32c-0.56,0.95-1.21,1.84-1.89,2.71c0.97-3.99-3.96-7.72-3.96-7.72  s-3.18,6.94,2.73,9.15c-0.38,0.43-0.8,0.81-1.2,1.21c-0.21,0.2-0.43,0.38-0.64,0.58l-0.32,0.29c-0.11,0.09-0.22,0.18-0.33,0.27  l-0.67,0.54l-0.7,0.51c-0.08,0.05-0.16,0.11-0.23,0.16c1.62-3.42-2.07-7.77-2.07-7.77s-4.21,5.55,0.49,8.78  c-1.34,0.79-2.74,1.45-4.2,1.98c1.91-2.59-0.23-6.89-0.23-6.89s-4.66,3.77-1.52,7.46c-1.15,0.33-2.33,0.57-3.51,0.74  c1.46-1.68,0.55-4.83,0.55-4.83s-3.7,2.03-2.18,5c-0.52,0.03-1.05,0.07-1.57,0.06c-0.29,0-0.57,0.01-0.86,0l-0.86-0.04  c-0.85-0.06-1.7-0.15-2.54-0.28l0.68-0.27l0.42-0.17l0.41-0.19l0.82-0.38c0,0,0.01,0,0.01,0c0.39-0.18,0.55-0.65,0.37-1.03  c-0.18-0.39-0.65-0.55-1.03-0.37l-0.04,0.02l-0.77,0.37l-0.39,0.18l-0.39,0.16l-0.79,0.33l-0.8,0.29l-0.4,0.14l-0.41,0.12L40,53.6  l-0.51-0.15l-0.41-0.12l-0.4-0.14l-0.8-0.29l-0.79-0.33l-0.39-0.16l-0.39-0.18l-0.77-0.37l-0.04-0.02c0,0,0,0-0.01,0  c-0.39-0.18-0.85-0.01-1.03,0.38c-0.18,0.39-0.01,0.85,0.38,1.03l0.82,0.38l0.41,0.19l0.42,0.17l0.68,0.27  c-0.84,0.14-1.69,0.22-2.54,0.28l-0.86,0.04c-0.29,0.01-0.57,0-0.86,0c-0.53,0.01-1.05-0.03-1.57-0.06c1.51-2.98-2.18-5-2.18-5  s-0.92,3.15,0.55,4.83c-1.19-0.16-2.36-0.41-3.51-0.74c3.15-3.7-1.52-7.46-1.52-7.46s-2.14,4.31-0.23,6.89  c-1.46-0.53-2.86-1.19-4.2-1.98c4.7-3.22,0.49-8.78,0.49-8.78s-3.69,4.34-2.07,7.77c-0.08-0.05-0.16-0.1-0.23-0.16l-0.7-0.51  l-0.67-0.54c-0.11-0.09-0.23-0.18-0.33-0.27l-0.32-0.29c-0.21-0.19-0.43-0.38-0.64-0.58c-0.4-0.4-0.82-0.79-1.2-1.21  c5.91-2.21,2.73-9.15,2.73-9.15s-4.93,3.73-3.96,7.72c-0.68-0.86-1.33-1.76-1.89-2.71c-0.46-0.75-0.87-1.53-1.25-2.32  c6.33-0.61,5.01-8.16,5.01-8.16s-5.6,2.31-5.79,6.33c-0.61-1.6-1.06-3.26-1.34-4.95c4.81,0.3,4.68-5.48,4.68-5.48  s-4.05,0.99-4.91,3.69c-0.07-0.76-0.1-1.51-0.1-2.27c0-0.33,0.01-0.66,0.03-1c3.11-2.01,0.98-7.09,0.98-7.09s-4.69,3.31-1.73,6.99  C7,28.46,6.99,28.82,6.98,29.18c-0.02,0.91,0.01,1.82,0.1,2.73c-1.84-2.38-6.26-1.4-6.26-1.4s2.37,5.11,6.44,2.95  c0.26,1.71,0.69,3.39,1.28,5.01C5.64,35.69,0,37.86,0,37.86s4.26,6.43,9.29,2.45c0.39,0.87,0.83,1.72,1.31,2.54  c0.47,0.83,1.01,1.63,1.58,2.4C8.71,43.7,4.11,47,4.11,47s5.7,5.1,9.56,0.08c0.04,0.04,0.07,0.08,0.11,0.12  c0.39,0.45,0.82,0.87,1.24,1.3c0.21,0.21,0.44,0.41,0.66,0.61l0.33,0.3c0.11,0.1,0.23,0.19,0.34,0.29l0.69,0.57l0.23,0.17  c-3.34-0.34-6.58,3.29-6.58,3.29s6.19,3.47,8.69-1.83c1.2,0.75,2.47,1.41,3.78,1.96c-2.76,0.6-4.62,4.13-4.62,4.13  s5.89,1.62,6.98-3.26c1.03,0.32,2.07,0.58,3.13,0.78c-1.63,0.99-2.39,3.38-2.39,3.38s4.31,0.39,4.61-3.07  c0.07,0.01,0.14,0.02,0.21,0.02c0.6,0.04,1.2,0.1,1.8,0.09c0.3,0,0.6,0.02,0.9,0.01l0.9-0.03c1.2-0.07,2.41-0.18,3.59-0.42  l0.45-0.08c0.15-0.03,0.29-0.07,0.44-0.1L40,55.13l0.81,0.19c0.15,0.03,0.29,0.07,0.44,0.1l0.45,0.08c1.18,0.23,2.39,0.35,3.59,0.42  l0.9,0.03c0.3,0.01,0.6-0.01,0.9-0.01c0.6,0,1.2-0.06,1.8-0.09c0.07-0.01,0.14-0.02,0.21-0.02c0.31,3.45,4.61,3.07,4.61,3.07  s-0.76-2.39-2.39-3.38c1.06-0.2,2.11-0.46,3.13-0.78c1.09,4.88,6.98,3.26,6.98,3.26s-1.86-3.52-4.62-4.13  c1.31-0.55,2.57-1.21,3.78-1.96c2.5,5.3,8.69,1.83,8.69,1.83s-3.24-3.63-6.58-3.29l0.23-0.17l0.69-0.57  c0.11-0.1,0.23-0.19,0.34-0.29l0.33-0.3c0.22-0.2,0.45-0.4,0.66-0.61c0.42-0.43,0.85-0.84,1.24-1.3c0.03-0.04,0.07-0.08,0.11-0.12  C70.19,52.1,75.89,47,75.89,47s-4.6-3.31-8.08-1.75c0.57-0.77,1.11-1.56,1.58-2.4C69.88,42.03,70.31,41.18,70.71,40.31z\"\n        />\n      </svg>\n\n      <foreignObject\n        height=\"36\"\n        width=\"48\"\n        x=\"0\"\n        y=\"9\"\n        style={{\n          color: 'currentColor',\n          fontSize: '18px',\n          fontWeight: 'bold',\n          letterSpacing: 0,\n          lineHeight: 1.5,\n          textAlign: 'center',\n        }}\n      >\n        <div>1</div>\n      </foreignObject>\n\n      <foreignObject\n        height=\"17\"\n        width=\"196\"\n        x=\"54\"\n        y=\"10\"\n        style={{\n          color: 'currentColor',\n          fontSize: '9px',\n          fontWeight: 'normal',\n          letterSpacing: 0,\n          lineHeight: 1.5,\n          textAlign: 'left',\n        }}\n      >\n        <div>GITHUB TRENDING</div>\n      </foreignObject>\n\n      <foreignObject\n        style={{\n          color: 'currentColor',\n          fontWeight: 'bold',\n          fontSize: '12px',\n          letterSpacing: 0,\n          lineHeight: 1.5,\n          textAlign: 'left',\n        }}\n        height=\"31\"\n        width=\"196\"\n        x=\"54\"\n        y=\"24\"\n      >\n        <div>#1 Repository Of The Month</div>\n      </foreignObject>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/HydrationEndDetector.tsx",
    "content": "'use client'\n\nimport { atom } from 'jotai'\nimport { useEffect } from 'react'\n\nimport { jotaiStore } from '~/lib/store'\n\nconst hydrateEndAtom = atom(false)\n\n/**\n * To skip page transition when first load, improve LCP\n */\nexport const HydrationEndDetector = () => {\n  useEffect(() => {\n    // waiting for hydration end and animation end\n    setTimeout(() => {\n      jotaiStore.set(hydrateEndAtom, true)\n    }, 2000)\n  }, [])\n  return null\n}\n\nexport const isHydrationEnded = () => jotaiStore.get(hydrateEndAtom)\n"
  },
  {
    "path": "apps/landing/src/components/common/Lazyload.tsx",
    "content": "'use client'\n\nimport type { FC, PropsWithChildren } from 'react'\nimport * as React from 'react'\nimport { useEffect } from 'react'\nimport type { IntersectionOptions } from 'react-intersection-observer'\nimport { useInView } from 'react-intersection-observer'\n\nexport type LazyLoadProps = {\n  offset?: number\n  placeholder?: React.ReactNode\n} & IntersectionOptions\nexport const LazyLoad: FC<PropsWithChildren & LazyLoadProps> = (props) => {\n  const { placeholder = null, offset = 0, ...rest } = props\n  const { ref, inView } = useInView({\n    triggerOnce: true,\n    rootMargin: `${offset || 0}px`,\n    ...rest,\n  })\n  const [isLoaded, setIsLoaded] = React.useState(false)\n  useEffect(() => {\n    if (inView) {\n      setIsLoaded(true)\n    }\n  }, [inView])\n\n  return (\n    <>\n      {!isLoaded && (\n        <span data-hide-print data-testid=\"lazyload-indicator\" ref={ref} />\n      )}\n      {!inView ? placeholder : props.children}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/LightRays.tsx",
    "content": "// https://reactbits.dev/backgrounds/light-rays\n'use client'\n\nimport { Mesh, Program, Renderer, Triangle } from 'ogl'\nimport { useEffect, useRef, useState } from 'react'\n\nimport { useIsDark } from '~/hooks/common/use-is-dark'\nimport { clsxm } from '~/lib/cn'\n\nexport type RaysOrigin =\n  | 'top-center'\n  | 'top-left'\n  | 'top-right'\n  | 'right'\n  | 'left'\n  | 'bottom-center'\n  | 'bottom-right'\n  | 'bottom-left'\n\ninterface LightRaysProps {\n  raysOrigin?: RaysOrigin\n  raysColor?: string\n  raysSpeed?: number\n  lightSpread?: number\n  rayLength?: number\n  pulsating?: boolean\n  fadeDistance?: number\n  saturation?: number\n  followMouse?: boolean\n  mouseInfluence?: number\n  noiseAmount?: number\n  distortion?: number\n  // Edge feather amounts in CSS pixels\n  edgeFadeLeft?: number\n  edgeFadeRight?: number\n  edgeFadeTop?: number\n  edgeFadeBottom?: number\n  className?: string\n}\n\nconst DEFAULT_COLOR = '#ffffff'\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n  const m = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n  return m\n    ? [\n        Number.parseInt(m[1], 16) / 255,\n        Number.parseInt(m[2], 16) / 255,\n        Number.parseInt(m[3], 16) / 255,\n      ]\n    : [1, 1, 1]\n}\n\nconst getAnchorAndDir = (\n  origin: RaysOrigin,\n  w: number,\n  h: number,\n): { anchor: [number, number]; dir: [number, number] } => {\n  const outside = 0.2\n  switch (origin) {\n    case 'top-left': {\n      return { anchor: [0, -outside * h], dir: [0, 1] }\n    }\n    case 'top-right': {\n      return { anchor: [w, -outside * h], dir: [0, 1] }\n    }\n    case 'left': {\n      return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }\n    }\n    case 'right': {\n      return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }\n    }\n    case 'bottom-left': {\n      return { anchor: [0, (1 + outside) * h], dir: [0, -1] }\n    }\n    case 'bottom-center': {\n      return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }\n    }\n    case 'bottom-right': {\n      return { anchor: [w, (1 + outside) * h], dir: [0, -1] }\n    }\n    default: {\n      // \"top-center\"\n      return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }\n    }\n  }\n}\n\nexport const LightRays: React.FC<LightRaysProps> = ({\n  raysOrigin = 'top-center',\n  raysColor = DEFAULT_COLOR,\n  raysSpeed = 1,\n  lightSpread = 1,\n  rayLength = 2,\n  pulsating = false,\n  fadeDistance = 1,\n  saturation = 1,\n  followMouse = true,\n  mouseInfluence = 0.1,\n  noiseAmount = 0,\n  distortion = 0,\n  edgeFadeLeft = 40,\n  edgeFadeRight = 40,\n  edgeFadeTop = 20,\n  edgeFadeBottom = 160,\n  className = '',\n}) => {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const uniformsRef = useRef<any>(null)\n  const rendererRef = useRef<Renderer | null>(null)\n  const mouseRef = useRef({ x: 0.5, y: 0.5 })\n  const smoothMouseRef = useRef({ x: 0.5, y: 0.5 })\n  const animationIdRef = useRef<number | null>(null)\n  const meshRef = useRef<any>(null)\n  const cleanupFunctionRef = useRef<(() => void) | null>(null)\n  const [isVisible, setIsVisible] = useState(false)\n  const observerRef = useRef<IntersectionObserver | null>(null)\n\n  const isDark = useIsDark()\n  const effectiveRaysColor =\n    !isDark && raysColor === DEFAULT_COLOR ? '#ff5c00' : raysColor\n\n  useEffect(() => {\n    if (!containerRef.current) return\n\n    observerRef.current = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[0]\n        setIsVisible(entry.isIntersecting)\n      },\n      { threshold: 0.1 },\n    )\n\n    observerRef.current.observe(containerRef.current)\n\n    return () => {\n      if (observerRef.current) {\n        observerRef.current.disconnect()\n        observerRef.current = null\n      }\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!isVisible || !containerRef.current) return\n\n    if (cleanupFunctionRef.current) {\n      cleanupFunctionRef.current()\n      cleanupFunctionRef.current = null\n    }\n\n    const initializeWebGL = async () => {\n      if (!containerRef.current) return\n\n      // Ensure layout is ready without lingering timers\n      await Promise.resolve()\n\n      if (!containerRef.current) return\n\n      const renderer = new Renderer({\n        dpr: Math.min(window.devicePixelRatio, 2),\n        alpha: true,\n      })\n      rendererRef.current = renderer\n\n      const { gl } = renderer\n      gl.canvas.style.width = '100%'\n      gl.canvas.style.height = '100%'\n      gl.canvas.style.backgroundColor = 'transparent'\n      gl.canvas.style.mixBlendMode = isDark ? 'normal' : 'plus-lighter'\n\n      while (containerRef.current.firstChild) {\n        containerRef.current.firstChild.remove()\n      }\n      containerRef.current.append(gl.canvas)\n\n      const vert = `\nattribute vec2 position;\nvarying vec2 vUv;\nvoid main() {\n  vUv = position * 0.5 + 0.5;\n  gl_Position = vec4(position, 0.0, 1.0);\n}`\n\n      const frag = `precision highp float;\n\nuniform float iTime;\nuniform vec2  iResolution;\n\nuniform vec2  rayPos;\nuniform vec2  rayDir;\nuniform vec3  raysColor;\nuniform float raysSpeed;\nuniform float lightSpread;\nuniform float rayLength;\nuniform float pulsating;\nuniform float fadeDistance;\nuniform float saturation;\nuniform vec2  mousePos;\nuniform float mouseInfluence;\nuniform float noiseAmount;\nuniform float distortion;\nuniform float globalOpacity;\nuniform float alphaCutoff;\n\n// Per-edge feathering (in device pixels)\nuniform float edgeFadeLeft;\nuniform float edgeFadeRight;\nuniform float edgeFadeTop;\nuniform float edgeFadeBottom;\n\nvarying vec2 vUv;\n\nfloat noise(vec2 st) {\n  return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);\n}\n\nfloat rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,\n                  float seedA, float seedB, float speed) {\n  vec2 sourceToCoord = coord - raySource;\n  vec2 dirNorm = normalize(sourceToCoord);\n  float cosAngle = dot(dirNorm, rayRefDirection);\n\n  float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;\n  \n  float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));\n\n  float distance = length(sourceToCoord);\n  float maxDistance = iResolution.x * rayLength;\n  float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);\n  \n  // Allow rays to fully fade to 0 to avoid gray/dark wash in light themes\n  float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.0, 1.0);\n  float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;\n\n  float baseStrength = clamp(\n    (0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +\n    (0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),\n    0.0, 1.0\n  );\n\n  return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;\n}\n\nvoid mainImage(out vec4 fragColor, in vec2 fragCoord) {\n  vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);\n  \n  vec2 finalRayDir = rayDir;\n  if (mouseInfluence > 0.0) {\n    vec2 mouseScreenPos = mousePos * iResolution.xy;\n    vec2 mouseDirection = normalize(mouseScreenPos - rayPos);\n    finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));\n  }\n\n  vec4 rays1 = vec4(1.0) *\n               rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,\n                           1.5 * raysSpeed);\n  vec4 rays2 = vec4(1.0) *\n               rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,\n                           1.1 * raysSpeed);\n\n  fragColor = vec4(1.0);\n  float intensity = (rays1.a * 0.5 + rays2.a * 0.4);\n  fragColor.rgb = vec3(intensity);\n  fragColor.a = intensity;\n\n  if (noiseAmount > 0.0) {\n    float n = noise(coord * 0.01 + iTime * 0.1);\n    fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);\n  }\n\n  // Subtle vertical brightening without color channel darkening\n  float brightness = 1.0 - (coord.y / iResolution.y);\n  float brightnessScale = mix(0.7, 1.0, brightness);\n  fragColor.rgb *= brightnessScale;\n\n  // Remove grayscale mixing which could introduce unintended dark hues\n\n  // Apply rays color as a tint while preserving brightness\n  fragColor.rgb = mix(fragColor.rgb, fragColor.rgb * raysColor, 0.8);\n\n  // Thin out low-intensity areas to avoid gray wash; then apply global opacity\n  float a = fragColor.a;\n  a = smoothstep(alphaCutoff, alphaCutoff + 0.2, a);\n  fragColor.a = a * globalOpacity;\n\n  // Per-edge feather to avoid harsh clipping at container boundaries\n  float fadeL = smoothstep(0.0, edgeFadeLeft, fragCoord.x);\n  float fadeR = smoothstep(0.0, edgeFadeRight, iResolution.x - fragCoord.x);\n  float fadeT = smoothstep(0.0, edgeFadeTop, fragCoord.y);\n  float fadeB = smoothstep(0.0, edgeFadeBottom, iResolution.y - fragCoord.y);\n  fragColor.a *= fadeL * fadeR * fadeT * fadeB;\n  \n  // Keep additive look; no extra premultiplying which can dim highlights under plus-lighter\n}\n\nvoid main() {\n  vec4 color;\n  mainImage(color, gl_FragCoord.xy);\n  gl_FragColor  = color;\n}`\n\n      const baseOpacity = isDark ? 0.55 : 0.22\n      const baseCutoff = isDark ? 0.08 : 0.18\n\n      const uniforms = {\n        iTime: { value: 0 },\n        iResolution: { value: [1, 1] },\n\n        rayPos: { value: [0, 0] },\n        rayDir: { value: [0, 1] },\n\n        raysColor: { value: hexToRgb(effectiveRaysColor) },\n        raysSpeed: { value: raysSpeed },\n        lightSpread: { value: lightSpread },\n        rayLength: { value: rayLength },\n        pulsating: { value: pulsating ? 1 : 0 },\n        fadeDistance: { value: fadeDistance },\n        saturation: { value: saturation },\n        mousePos: { value: [0.5, 0.5] },\n        mouseInfluence: { value: mouseInfluence },\n        noiseAmount: { value: noiseAmount },\n        distortion: { value: distortion },\n        globalOpacity: { value: baseOpacity },\n        alphaCutoff: { value: baseCutoff },\n        edgeFadeLeft: { value: 40 },\n        edgeFadeRight: { value: 40 },\n        edgeFadeTop: { value: 20 },\n        edgeFadeBottom: { value: 160 },\n      }\n      uniformsRef.current = uniforms\n\n      const geometry = new Triangle(gl)\n      const program = new Program(gl, {\n        vertex: vert,\n        fragment: frag,\n        uniforms,\n      })\n      const mesh = new Mesh(gl, { geometry, program })\n      meshRef.current = mesh\n\n      const updatePlacement = () => {\n        if (!containerRef.current || !renderer) return\n\n        renderer.dpr = Math.min(window.devicePixelRatio, 2)\n\n        const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current\n        renderer.setSize(wCSS, hCSS)\n\n        const { dpr } = renderer\n        const w = wCSS * dpr\n        const h = hCSS * dpr\n\n        uniforms.iResolution.value = [w, h]\n\n        // Scale edge feather from CSS pixels to device pixels\n        uniforms.edgeFadeLeft.value = edgeFadeLeft * dpr\n        uniforms.edgeFadeRight.value = edgeFadeRight * dpr\n        uniforms.edgeFadeTop.value = edgeFadeTop * dpr\n        uniforms.edgeFadeBottom.value = edgeFadeBottom * dpr\n\n        const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h)\n        uniforms.rayPos.value = anchor\n        uniforms.rayDir.value = dir\n      }\n\n      const loop = (t: number) => {\n        if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {\n          return\n        }\n\n        uniforms.iTime.value = t * 0.001\n\n        if (followMouse && mouseInfluence > 0) {\n          const smoothing = 0.92\n\n          smoothMouseRef.current.x =\n            smoothMouseRef.current.x * smoothing +\n            mouseRef.current.x * (1 - smoothing)\n          smoothMouseRef.current.y =\n            smoothMouseRef.current.y * smoothing +\n            mouseRef.current.y * (1 - smoothing)\n\n          uniforms.mousePos.value = [\n            smoothMouseRef.current.x,\n            smoothMouseRef.current.y,\n          ]\n        }\n\n        try {\n          renderer.render({ scene: mesh })\n          animationIdRef.current = requestAnimationFrame(loop)\n        } catch (error) {\n          console.warn('WebGL rendering error:', error)\n          return\n        }\n      }\n\n      updatePlacement()\n      animationIdRef.current = requestAnimationFrame(loop)\n\n      cleanupFunctionRef.current = () => {\n        if (animationIdRef.current) {\n          cancelAnimationFrame(animationIdRef.current)\n          animationIdRef.current = null\n        }\n\n        if (renderer) {\n          try {\n            const { canvas } = renderer.gl\n            const loseContextExt =\n              renderer.gl.getExtension('WEBGL_lose_context')\n            if (loseContextExt) {\n              loseContextExt.loseContext()\n            }\n\n            if (canvas && canvas.parentNode) {\n              canvas.remove()\n            }\n          } catch (error) {\n            console.warn('Error during WebGL cleanup:', error)\n          }\n        }\n\n        rendererRef.current = null\n        uniformsRef.current = null\n        meshRef.current = null\n      }\n    }\n\n    initializeWebGL()\n\n    return () => {\n      if (cleanupFunctionRef.current) {\n        cleanupFunctionRef.current()\n        cleanupFunctionRef.current = null\n      }\n    }\n  }, [\n    isVisible,\n    raysOrigin,\n    raysColor,\n    effectiveRaysColor,\n    isDark,\n    raysSpeed,\n    lightSpread,\n    rayLength,\n    pulsating,\n    fadeDistance,\n    saturation,\n    followMouse,\n    mouseInfluence,\n    noiseAmount,\n    distortion,\n    edgeFadeLeft,\n    edgeFadeRight,\n    edgeFadeTop,\n    edgeFadeBottom,\n  ])\n\n  useEffect(() => {\n    const renderer = rendererRef.current\n    const uniforms = uniformsRef.current\n    if (!isVisible || !renderer || !containerRef.current || !uniforms) return\n\n    const handleResize = () => {\n      if (!containerRef.current) return\n      renderer.dpr = Math.min(window.devicePixelRatio, 2)\n      const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current\n      renderer.setSize(wCSS, hCSS)\n\n      const { dpr } = renderer\n      const w = wCSS * dpr\n      const h = hCSS * dpr\n\n      uniforms.iResolution.value = [w, h]\n\n      // Update edge feather values on resize (scale with DPR)\n      uniforms.edgeFadeLeft.value = edgeFadeLeft * dpr\n      uniforms.edgeFadeRight.value = edgeFadeRight * dpr\n      uniforms.edgeFadeTop.value = edgeFadeTop * dpr\n      uniforms.edgeFadeBottom.value = edgeFadeBottom * dpr\n\n      const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h)\n      uniforms.rayPos.value = anchor\n      uniforms.rayDir.value = dir\n    }\n\n    window.addEventListener('resize', handleResize)\n    return () => window.removeEventListener('resize', handleResize)\n  }, [\n    isVisible,\n    raysOrigin,\n    edgeFadeLeft,\n    edgeFadeRight,\n    edgeFadeTop,\n    edgeFadeBottom,\n  ])\n\n  useEffect(() => {\n    if (!uniformsRef.current || !containerRef.current || !rendererRef.current)\n      return\n\n    const u = uniformsRef.current\n    const renderer = rendererRef.current\n\n    u.raysColor.value = hexToRgb(effectiveRaysColor)\n    u.raysSpeed.value = raysSpeed\n    u.lightSpread.value = lightSpread\n    u.rayLength.value = rayLength\n    u.pulsating.value = pulsating ? 1 : 0\n    u.fadeDistance.value = fadeDistance\n    u.saturation.value = saturation\n    u.mouseInfluence.value = mouseInfluence\n    u.noiseAmount.value = noiseAmount\n    u.distortion.value = distortion\n    u.globalOpacity.value = isDark ? 0.55 : 0.22\n    u.alphaCutoff.value = isDark ? 0.08 : 0.18\n\n    // Edge feather uniforms (scale with DPR)\n    u.edgeFadeLeft.value = edgeFadeLeft * renderer.dpr\n    u.edgeFadeRight.value = edgeFadeRight * renderer.dpr\n    u.edgeFadeTop.value = edgeFadeTop * renderer.dpr\n    u.edgeFadeBottom.value = edgeFadeBottom * renderer.dpr\n\n    const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current\n    const { dpr } = renderer\n    const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr)\n    u.rayPos.value = anchor\n    u.rayDir.value = dir\n  }, [\n    raysColor,\n    isDark,\n    effectiveRaysColor,\n    raysSpeed,\n    lightSpread,\n    raysOrigin,\n    rayLength,\n    pulsating,\n    fadeDistance,\n    saturation,\n    mouseInfluence,\n    noiseAmount,\n    distortion,\n    edgeFadeLeft,\n    edgeFadeRight,\n    edgeFadeTop,\n    edgeFadeBottom,\n  ])\n\n  useEffect(() => {\n    if (!rendererRef.current) return\n    const { gl } = rendererRef.current\n    gl.canvas.style.mixBlendMode = isDark ? 'normal' : 'plus-lighter'\n  }, [isDark])\n\n  useEffect(() => {\n    const handleMouseMove = (e: MouseEvent) => {\n      if (!containerRef.current || !rendererRef.current) return\n      const rect = containerRef.current.getBoundingClientRect()\n      const x = (e.clientX - rect.left) / rect.width\n      const y = (e.clientY - rect.top) / rect.height\n      mouseRef.current = { x, y }\n    }\n\n    if (followMouse) {\n      window.addEventListener('mousemove', handleMouseMove)\n      return () => window.removeEventListener('mousemove', handleMouseMove)\n    }\n  }, [followMouse])\n\n  return (\n    <div\n      ref={containerRef}\n      className={clsxm(\n        'w-full h-full pointer-events-none overflow-hidden relative',\n        className,\n      )}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/ProviderComposer.tsx",
    "content": "'use client'\n\nimport type { JSX } from 'react'\nimport * as React from 'react'\n\nexport const ProviderComposer: Component<{\n  contexts: JSX.Element[]\n}> = ({ contexts, children }) => {\n  return contexts.reduceRight((kids: any, parent: any) => {\n    return React.cloneElement(parent, { children: kids })\n  }, children)\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/QueryHydrate.tsx",
    "content": "'use client'\n\nimport type { HydrationBoundaryProps } from '@tanstack/react-query'\nimport { HydrationBoundary as RQHydrate } from '@tanstack/react-query'\n\nexport function QueryHydrate(props: HydrationBoundaryProps) {\n  return <RQHydrate {...props} />\n}\n"
  },
  {
    "path": "apps/landing/src/components/common/ScrollTop.tsx",
    "content": "'use client'\n\nimport { usePathname } from 'next/navigation'\nimport { memo, useEffect } from 'react'\n\nimport { isDev } from '~/lib/env'\nimport { springScrollToTop } from '~/lib/scroller'\n\nexport const ScrollTop = memo(() => {\n  const pathname = usePathname()\n  useEffect(() => {\n    if (isDev) return\n    springScrollToTop()\n  }, [pathname])\n  return null\n})\n\nScrollTop.displayName = 'ScrollTop'\n"
  },
  {
    "path": "apps/landing/src/components/hoc/with-no-ssr.tsx",
    "content": "import type { FC, PropsWithChildren } from 'react'\n\nimport { useIsClientTransition } from '~/hooks/common/use-is-client'\n\nexport const withNoSSR = <P,>(\n  Component: FC<PropsWithChildren<P>>,\n): FC<PropsWithChildren<P>> => {\n  return function NoSSRWrapper(props: PropsWithChildren<P>) {\n    const isClient = useIsClientTransition()\n    if (!isClient) return null\n    return <Component {...props} />\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/components/layout/container/Normal.tsx",
    "content": "import { clsxm } from '~/lib/helper'\n\nexport const NormalContainer: Component = (props) => {\n  const { children, className } = props\n\n  return (\n    <div\n      className={clsxm(\n        'mx-auto mt-14 max-w-3xl px-2 lg:mt-[80px] lg:px-0 2xl:max-w-4xl',\n        '[&_header.prose]:mb-[80px]',\n        className,\n      )}\n    >\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/layout/content/Content.tsx",
    "content": "export const Content: Component = ({ children }) => {\n  return (\n    <main className=\"relative pb-24 z-[1] h-fit px-4 pt-[4.5rem] md:px-0\">\n      {children}\n    </main>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/layout/content/index.ts",
    "content": "export * from './Content'\n"
  },
  {
    "path": "apps/landing/src/components/layout/footer/Footer.tsx",
    "content": "'use client'\n\nimport Link from 'next/link'\n\nimport { Logo } from '~/components/brand/Logo'\nimport { cx, focusRing } from '~/lib/cn'\n\ntype LinkItem = { label: string; href: string; external?: boolean }\n\nconst productLinks: LinkItem[] = [\n  { label: 'Web App', href: 'https://app.folo.is', external: true },\n  { label: 'Download', href: '/download', external: false },\n  { label: 'Pricing', href: '/pricing', external: false },\n  { label: 'Built Open', href: '/#open', external: false },\n]\n\nconst communityLinks: LinkItem[] = [\n  { label: 'Discord', href: 'https://discord.gg/AwWcAQ7euc', external: true },\n  {\n    label: 'GitHub',\n    href: 'https://github.com/RSSNext/Folo',\n    external: true,\n  },\n  { label: 'Twitter', href: 'https://x.com/folo_is', external: true },\n]\n\nconst legalLinks: LinkItem[] = [\n  { label: 'Privacy Policy', href: 'privacy-policy' },\n  { label: 'Terms of Service', href: 'terms-of-service' },\n  // { label: 'Security', href: '#security' },\n  // { label: 'Cookie', href: '#cookie' },\n]\n\nconst BrandBlock = () => (\n  <div className=\"max-w-md\">\n    <Link href=\"/\" className={cx('inline-flex items-center gap-3', focusRing)}>\n      <Logo className=\"size-10 shrink-0\" aria-hidden />\n      <span className=\"text-2xl font-semibold tracking-tight\">Folo</span>\n    </Link>\n    <p className=\"mt-6 text-base leading-relaxed text-text-secondary\">\n      The AI that reads the internet for you,\n    </p>\n    <p className=\"mt-4 text-sm text-text-tertiary\">\n      cutting through noise to surface the knowledge you actually care about.\n    </p>\n  </div>\n)\n\nfunction LinkColumn({ title, links }: { title: string; links: LinkItem[] }) {\n  return (\n    <div>\n      <h3 className=\"text-base font-semibold text-text\">{title}</h3>\n      <ul className=\"mt-5 space-y-3\">\n        {links.map((link) => (\n          <li key={link.label}>\n            <Link\n              href={link.href}\n              target={link.external ? '_blank' : undefined}\n              rel={link.external ? 'noreferrer noopener' : undefined}\n              className={cx(\n                'text-base text-text-secondary transition-colors hover:text-text',\n                focusRing,\n              )}\n            >\n              {link.label}\n            </Link>\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n\n/** Props for Footer component */\nexport interface FooterProps {\n  className?: string\n}\n\nexport const Footer: Component<FooterProps> = ({ className }) => {\n  const year = new Date().getFullYear()\n\n  return (\n    <footer\n      className={cx(\n        'relative border-t border-border/80 bg-background',\n        className,\n      )}\n      role=\"contentinfo\"\n    >\n      <div className=\"relative mx-auto w-full max-w-[var(--container-max-width-2xl)] px-6 py-16 lg:px-8 lg:py-20\">\n        {/* Main footer content */}\n        <div className=\"grid grid-cols-1 gap-12 lg:grid-cols-12 lg:gap-8\">\n          {/* Brand section with stats */}\n          <div className=\"lg:col-span-5\">\n            <BrandBlock />\n          </div>\n\n          {/* Navigation columns */}\n          <div className=\"grid grid-cols-2 gap-8 lg:col-span-7\">\n            <LinkColumn title=\"Product\" links={productLinks} />\n            <LinkColumn title=\"Community\" links={communityLinks} />\n          </div>\n        </div>\n\n        {/* Bottom bar */}\n        <div className=\"mt-16 flex flex-col items-center justify-between gap-4 border-t border-border/60 pt-8 sm:flex-row\">\n          <p className=\"text-sm text-text-secondary\">\n            © {year} Folo. All rights reserved.\n          </p>\n\n          <div className=\"flex flex-wrap items-center gap-6\">\n            {legalLinks.map((link) => (\n              <Link\n                key={link.label}\n                href={link.href}\n                className={cx(\n                  'text-sm text-text-secondary transition-colors hover:text-text',\n                  focusRing,\n                )}\n              >\n                {link.label}\n              </Link>\n            ))}\n          </div>\n        </div>\n      </div>\n    </footer>\n  )\n}\n\nFooter.displayName = 'Footer'\n"
  },
  {
    "path": "apps/landing/src/components/layout/root/Root.tsx",
    "content": "import { Footer } from '~/components/layout/footer/Footer'\n\nimport { Content } from '../content/Content'\n\nexport const Root: Component = ({ children }) => {\n  return (\n    <>\n      <header />\n      <Content>{children}</Content>\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/3d-models/AISpline.tsx",
    "content": "'use client'\n\nimport { lazy, Suspense, useEffect, useState } from 'react'\n\nimport { ErrorBoundary } from '~/components/common/ErrorBoundary'\nimport { cn } from '~/lib/cn'\n\nconst AISplineLoader = lazy(() =>\n  import('./AISplineLoader').then((res) => ({ default: res.AISplineLoader })),\n)\n\nconst supportsWebGL = () => {\n  try {\n    const canvas = document.createElement('canvas')\n    return !!(\n      canvas.getContext('webgl') || canvas.getContext('experimental-webgl')\n    )\n  } catch {\n    return false\n  }\n}\n\nexport const AISpline = ({ className }: { className?: string }) => {\n  const [canRender, setCanRender] = useState(false)\n\n  useEffect(() => {\n    setCanRender(supportsWebGL())\n  }, [])\n\n  if (!canRender) {\n    return <div className={cn('mx-auto size-20', className)} />\n  }\n\n  return (\n    <ErrorBoundary>\n      <Suspense fallback={<div className={cn('mx-auto size-20', className)} />}>\n        <AISplineLoader className={className} />\n      </Suspense>\n    </ErrorBoundary>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/3d-models/AISplineLoader.tsx",
    "content": "import Spline from '@splinetool/react-spline'\nimport { useCallback, useRef } from 'react'\n\nimport { cn } from '~/lib/cn'\n\n// TODO: use folo cdn\nconst resolvedAIIconUrl =\n  'https://prod.spline.design/n2hjp93nWReC-512/scene.splinecode'\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max)\n\nexport const AISplineLoader = ({ className }: { className?: string }) => {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const headRef = useRef<any>(null)\n\n  // Angle conversion function: degrees to radians\n  const degToRad = (degrees: number) => degrees * (Math.PI / 180)\n\n  // Calculate the angle the head should look at\n  const calculateHeadRotation = useCallback(\n    (mouseX: number, mouseY: number, containerRect: DOMRect) => {\n      const containerCenterX = containerRect.left + containerRect.width / 2\n      const containerCenterY = containerRect.top + containerRect.height / 2\n\n      // Calculate mouse position relative to container center (-1 to 1)\n      const relativeX = (mouseX - containerCenterX) / (window.innerWidth / 2)\n      const relativeY = (mouseY - containerCenterY) / (window.innerHeight / 2)\n\n      // Clamp range\n      const clampedX = Math.max(-1, Math.min(1, relativeX))\n      const clampedY = Math.max(-1, Math.min(1, relativeY))\n\n      // Calculate head rotation angle based on relative position\n      // Y-axis rotation (left-right): -70 to 70 degrees\n      const headRotationY = clampedX * 20\n\n      // X-axis rotation (up-down): -60 to 60 degrees\n      const headRotationX = clampedY * 20\n\n      return {\n        x: degToRad(headRotationX),\n        y: degToRad(headRotationY),\n      }\n    },\n    [],\n  )\n\n  const handleLoad = useCallback(\n    (app: any) => {\n      const head = app.findObjectByName('Folo Character_V3')\n\n      if (!head) {\n        console.warn('Cannot find Head or Body object')\n        return\n      }\n\n      headRef.current = head\n\n      const onMove = (e: MouseEvent) => {\n        if (!containerRef.current || !headRef.current) return\n\n        const containerRect = containerRef.current.getBoundingClientRect()\n\n        // Calculate head rotation\n        const headRotation = calculateHeadRotation(\n          e.clientX,\n          e.clientY,\n          containerRect,\n        )\n        headRef.current.rotation.x = clamp(headRotation.x, -0.5, 0.5)\n        headRef.current.rotation.y = clamp(headRotation.y, -0.5, 0.5)\n      }\n\n      // Reset to default position when mouse leaves\n      const onMouseLeave = () => {\n        if (!headRef.current) return\n\n        // Smooth transition back to default position\n        const resetAnimation = () => {\n          if (!headRef.current) return\n\n          const currentHeadX = headRef.current.rotation.x\n          const currentHeadY = headRef.current.rotation.y\n\n          // Simple linear interpolation to smoothly return rotation to 0\n          headRef.current.rotation.x = currentHeadX * 0.9\n          headRef.current.rotation.y = currentHeadY * 0.9\n\n          // Continue animation if not fully returned to 0\n          if (Math.abs(currentHeadX) > 0.01 || Math.abs(currentHeadY) > 0.01) {\n            requestAnimationFrame(resetAnimation)\n          } else {\n            // Complete reset to 0\n            headRef.current.rotation.x = 0\n            headRef.current.rotation.y = 0\n          }\n        }\n\n        resetAnimation()\n      }\n\n      const onClick = () => {\n        app.emitEvent('mouseDown', 'Folo Character_V3')\n      }\n      onClick()\n\n      window.addEventListener('pointermove', onMove)\n      document.addEventListener('mouseleave', onMouseLeave)\n      window.addEventListener('click', onClick)\n\n      return () => {\n        window.removeEventListener('pointermove', onMove)\n        document.removeEventListener('mouseleave', onMouseLeave)\n        window.removeEventListener('click', onClick)\n      }\n    },\n    [calculateHeadRotation],\n  )\n\n  return (\n    <div ref={containerRef} className={cn('size-20', className)}>\n      <Spline\n        scene={resolvedAIIconUrl}\n        onLoad={handleLoad}\n        className=\"size-full\"\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/accordion/Accordion.tsx",
    "content": "'use client'\n\n/**\n * @see https://www.zhangxinxu.com/wordpress/2024/06/css-transition-behavior/\n * @see https://www.zhangxinxu.com/wordpress/2024/11/css-calc-interpolate-size/\n */\nimport * as AccordionPrimitive from '@radix-ui/react-accordion'\nimport type { FC } from 'react'\nimport * as React from 'react'\nimport { createContext, use, useState } from 'react'\n\nimport { cn } from '~/lib/cn'\n\ninterface CollapseContextValue {\n  openStates: Record<string, boolean>\n  setOpenState: (id: string, open: boolean) => void\n}\n\nconst CollapseContext = createContext<CollapseContextValue | null>(null)\n\nconst useCollapseContext = () => {\n  const ctx = use(CollapseContext)\n  if (!ctx) {\n    throw new Error('useCollapseContext must be used within CollapseGroup')\n  }\n  return ctx\n}\n\ninterface CollapseGroupProps {\n  defaultOpenId?: string\n  onOpenChange?: (state: Record<string, boolean>) => void\n  children: React.ReactNode\n}\n\nexport const CollapseCssGroup: FC<CollapseGroupProps> = ({\n  children,\n  defaultOpenId,\n  onOpenChange,\n}) => {\n  const [openStates, setOpenStates] = useState<Record<string, boolean>>(() => {\n    return defaultOpenId ? { [defaultOpenId]: true } : {}\n  })\n\n  const setOpenState = React.useCallback(\n    (id: string, open: boolean) => {\n      setOpenStates((prev) => {\n        const newState = { ...prev, [id]: open }\n        onOpenChange?.(newState)\n        return newState\n      })\n    },\n    [onOpenChange],\n  )\n\n  const ctxValue = React.useMemo<CollapseContextValue>(\n    () => ({\n      openStates,\n      setOpenState,\n    }),\n    [openStates, setOpenState],\n  )\n\n  return <CollapseContext value={ctxValue}>{children}</CollapseContext>\n}\n\ninterface CollapseProps {\n  title: React.ReactNode\n  hideArrow?: boolean\n  defaultOpen?: boolean\n  isOpened?: boolean // For controlled usage\n  collapseId?: string\n  onOpenChange?: (isOpened: boolean) => void\n  contentClassName?: string\n  className?: string\n  children: React.ReactNode\n  innerClassName?: string\n}\n\nexport const CollapseCss: FC<CollapseProps> = ({\n  title,\n  hideArrow,\n  defaultOpen = false,\n  isOpened: controlledIsOpened,\n  collapseId,\n  onOpenChange,\n  contentClassName,\n  className,\n  innerClassName,\n  children,\n}) => {\n  const reactId = React.useId()\n  const id = collapseId ?? reactId\n  const { openStates, setOpenState } = useCollapseContext()\n\n  // Use controlled value if provided, otherwise use context state or defaultOpen\n  const isOpened = controlledIsOpened ?? openStates[id] ?? defaultOpen\n\n  const handleToggle = React.useCallback(() => {\n    const newOpened = !isOpened\n    // Only update context state if not controlled\n    if (controlledIsOpened === undefined) {\n      setOpenState(id, newOpened)\n    }\n    onOpenChange?.(newOpened)\n  }, [id, isOpened, controlledIsOpened, setOpenState, onOpenChange])\n\n  return (\n    <div\n      className={cn('flex flex-col', className)}\n      data-state={isOpened ? 'open' : 'hidden'}\n    >\n      <div\n        className=\"relative flex w-full cursor-pointer items-center justify-between\"\n        onClick={controlledIsOpened === undefined ? handleToggle : undefined}\n      >\n        <span className=\"w-0 shrink grow truncate\">{title}</span>\n        {!hideArrow && (\n          <div className=\"text-text-secondary mr-4 inline-flex shrink-0 items-center\">\n            <i\n              className={cn(\n                'i-mingcute-down-line transition-transform duration-300 ease-in-out',\n                isOpened ? 'rotate-180' : '',\n              )}\n            />\n          </div>\n        )}\n      </div>\n      <CollapseCssContent\n        isOpened={isOpened}\n        className={contentClassName}\n        innerClassName={innerClassName}\n      >\n        {children}\n      </CollapseCssContent>\n    </div>\n  )\n}\n\ninterface CollapseContentProps {\n  isOpened: boolean\n  className?: string\n  children: React.ReactNode\n  innerClassName?: string\n}\n\nconst CollapseCssContent: FC<CollapseContentProps> = ({\n  isOpened,\n  className,\n  children,\n  innerClassName,\n}) => {\n  const contentRef = React.useRef<HTMLDivElement>(null)\n\n  return (\n    <div\n      ref={contentRef}\n      className={cn(\n        'overflow-hidden [transition-behavior:allow-discrete] [interpolate-size:allow-keywords]',\n        'transition-[height,opacity,display] duration-300 ease-in-out',\n        '[@starting-style]:h-0 [@starting-style]:opacity-0',\n        className,\n        isOpened\n          ? 'block h-[calc-size(auto)] opacity-100'\n          : 'hidden h-0 opacity-0',\n      )}\n      data-state={isOpened ? 'open' : 'closed'}\n    >\n      <div\n        className={cn(\n          'transition-transform duration-300 ease-in-out',\n          '[@starting-style]:translate-y-[-8px]',\n          isOpened ? 'translate-y-0' : 'translate-y-[-8px]',\n          innerClassName,\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n\n// Radix Accordion Components\nconst AccordionRoot = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & {\n  ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Root> | null>\n}) => <AccordionPrimitive.Root ref={ref} className={cn(className)} {...props} />\nAccordionRoot.displayName = 'Accordion'\n\nconst AccordionItem = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {\n  ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item> | null>\n}) => <AccordionPrimitive.Item ref={ref} className={cn(className)} {...props} />\nAccordionItem.displayName = 'AccordionItem'\n\nconst AccordionTrigger = ({\n  ref,\n  className,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {\n  ref?: React.RefObject<React.ElementRef<\n    typeof AccordionPrimitive.Trigger\n  > | null>\n}) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        'flex flex-1 items-center justify-between text-left font-medium transition-all [&[data-state=open]>i]:rotate-180',\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <i className=\"i-mingcute-down-line size-4 shrink-0 text-text-secondary transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n)\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = ({\n  ref,\n  className,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {\n  ref?: React.RefObject<React.ElementRef<\n    typeof AccordionPrimitive.Content\n  > | null>\n}) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn('pt-0', className)}>{children}</div>\n  </AccordionPrimitive.Content>\n)\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport const Accordion = Object.assign(AccordionRoot, {\n  Item: AccordionItem,\n  Trigger: AccordionTrigger,\n  Content: AccordionContent,\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/border-beam.tsx",
    "content": "import type { MotionStyle, Transition } from 'motion/react'\nimport { m as motion } from 'motion/react'\n\nimport { cn } from '~/lib/cn'\n\ninterface BorderBeamProps {\n  /**\n   * The size of the border beam.\n   */\n  size?: number\n  /**\n   * The duration of the border beam.\n   */\n  duration?: number\n  /**\n   * The delay of the border beam.\n   */\n  delay?: number\n  /**\n   * The color of the border beam from.\n   */\n  colorFrom?: string\n  /**\n   * The color of the border beam to.\n   */\n  colorTo?: string\n  /**\n   * The motion transition of the border beam.\n   */\n  transition?: Transition\n  /**\n   * The class name of the border beam.\n   */\n  className?: string\n  /**\n   * The style of the border beam.\n   */\n  style?: React.CSSProperties\n  /**\n   * Whether to reverse the animation direction.\n   */\n  reverse?: boolean\n  /**\n   * The initial offset position (0-100).\n   */\n  initialOffset?: number\n  /**\n   * The border width of the beam.\n   */\n  borderWidth?: number\n}\n\nexport const BorderBeam = ({\n  className,\n  size = 50,\n  delay = 0,\n  duration = 6,\n  colorFrom = '#ffaa40',\n  colorTo = '#9c40ff',\n  transition,\n  style,\n  reverse = false,\n  initialOffset = 0,\n  borderWidth = 1,\n}: BorderBeamProps) => {\n  return (\n    <div\n      className=\"pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] [mask-composite:intersect] [mask-clip:padding-box,border-box]\"\n      style={\n        {\n          '--border-beam-width': `${borderWidth}px`,\n        } as React.CSSProperties\n      }\n    >\n      <motion.div\n        className={cn(\n          'absolute aspect-square',\n          'bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent',\n          className,\n        )}\n        style={\n          {\n            width: size,\n            offsetPath: `rect(0 auto auto 0 round ${size}px)`,\n            '--color-from': colorFrom,\n            '--color-to': colorTo,\n            ...style,\n          } as MotionStyle\n        }\n        initial={{ offsetDistance: `${initialOffset}%` }}\n        animate={{\n          offsetDistance: reverse\n            ? [`${100 - initialOffset}%`, `${-initialOffset}%`]\n            : [`${initialOffset}%`, `${100 + initialOffset}%`],\n        }}\n        transition={{\n          repeat: Infinity,\n          ease: 'linear',\n          duration,\n          delay: -delay,\n          ...transition,\n        }}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/button/Button.tsx",
    "content": "'use client'\n\n// Tremor Button [v0.2.0]\nimport { Slot as RadixSlot } from 'radix-ui'\nimport * as React from 'react'\nimport type { VariantProps } from 'tailwind-variants'\nimport { tv } from 'tailwind-variants'\n\nimport { cx, focusRing } from '~/lib/cn'\n\nconst { Slot } = RadixSlot\n\nconst buttonVariants = tv({\n  base: [\n    // base - pill shape, spacing and glass-friendly shadow\n    'relative box-content inline-flex pointer-events-auto no-drag-region items-center justify-center whitespace-nowrap rounded-full border text-center font-medium shadow-sm transition-all duration-200 ease-out',\n    // disabled\n    'disabled:pointer-events-none disabled:shadow-none disabled:text-disabled-text',\n    // focus\n    focusRing,\n  ],\n  variants: {\n    variant: {\n      primary: [\n        // border\n        '!border-transparent',\n        // text color\n        'text-accent-foreground',\n        // gradient accent\n        'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-60)] border-0',\n\n        // hover state\n        'hover:brightness-110',\n        // active state\n        'active:scale-[0.98]',\n        // disabled\n        'disabled:bg-disabled-control',\n      ],\n      secondary: [\n        // glass button\n        'border-border text-text bg-material-medium/60 backdrop-blur',\n        // hover / active\n        'hover:bg-fill-secondary shadow-none hover:shadow-sm active:bg-fill-tertiary active:scale-[0.98]',\n        // disabled\n        'disabled:bg-fill disabled:text-disabled-text disabled:border-border disabled:shadow-none',\n      ],\n      light: [\n        // base\n        'shadow-none',\n        // border\n        'border-transparent',\n        // text color\n        'text-text',\n        // background color\n        'bg-fill',\n        // hover color\n        'hover:bg-fill-tertiary hover:shadow-sm',\n        // active state\n        'active:bg-fill-quaternary active:scale-[0.98]',\n        // disabled\n        'disabled:bg-fill disabled:text-disabled-text',\n      ],\n      ghost: [\n        // base\n        'shadow-none',\n        // border\n        'border-transparent',\n        // text color\n        'text-text-secondary',\n        // hover color\n        'bg-transparent hover:bg-fill/80 hover:text-text',\n        // active state\n        'active:bg-fill active:scale-[0.98]',\n        // disabled\n        'disabled:text-disabled-text',\n      ],\n      destructive: [\n        // text color\n        'text-background',\n        // border\n        'border-transparent',\n        // background color\n        'bg-red',\n        // hover color\n        'hover:bg-red/90 hover:shadow-md',\n        // active state\n        'active:bg-red/80 active:scale-[0.98]',\n        // disabled\n        'disabled:bg-red/50 disabled:text-background/70',\n      ],\n    },\n    size: {\n      sm: ['px-4 py-1.5 text-sm rounded-full'],\n      md: ['px-4 py-2 text-sm rounded-full'],\n      lg: ['px-5 py-2.5 text-base rounded-full'],\n    },\n  },\n  defaultVariants: {\n    variant: 'primary',\n    size: 'md',\n  },\n})\n\ninterface ButtonProps\n  extends\n    React.ComponentPropsWithoutRef<'button'>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n  isLoading?: boolean\n  loadingText?: string\n  size?: 'sm' | 'md' | 'lg'\n  variant?: 'primary' | 'secondary' | 'light' | 'ghost' | 'destructive'\n}\n\nconst Button = ({\n  ref: forwardedRef,\n  asChild,\n  isLoading = false,\n  loadingText,\n  className,\n  disabled,\n  variant,\n  size = 'md',\n  children,\n  ...props\n}: ButtonProps & { ref?: React.RefObject<HTMLButtonElement | null> }) => {\n  const Component = asChild ? Slot : 'button'\n  return (\n    <Component\n      ref={forwardedRef}\n      className={cx(buttonVariants({ variant, size }), className)}\n      disabled={disabled || isLoading}\n      tremor-id=\"tremor-raw\"\n      {...props}\n    >\n      {isLoading ? (\n        <span className=\"pointer-events-none flex shrink-0 items-center justify-center gap-1.5\">\n          <i\n            className=\"i-mingcute-loading-3-line size-4 shrink-0 animate-spin\"\n            aria-hidden=\"true\"\n          />\n\n          {loadingText ?? children}\n        </span>\n      ) : (\n        children\n      )}\n    </Component>\n  )\n}\n\nButton.displayName = 'Button'\n\nexport { Button, type ButtonProps }\n"
  },
  {
    "path": "apps/landing/src/components/ui/button/MotionButton.tsx",
    "content": "'use client'\n\nimport type { HTMLMotionProps } from 'motion/react'\nimport { m } from 'motion/react'\n\nexport const MotionButtonBase = ({\n  ref,\n  children,\n  ...rest\n}: HTMLMotionProps<'button'> & {\n  ref?: React.Ref<HTMLButtonElement>\n}) => {\n  return (\n    <m.button\n      initial={true}\n      whileFocus={{ scale: 1.02 }}\n      whileHover={{ scale: 1.02 }}\n      whileTap={{ scale: 0.95 }}\n      {...rest}\n      ref={ref}\n    >\n      {children}\n    </m.button>\n  )\n}\n\nMotionButtonBase.displayName = 'MotionButtonBase'\n"
  },
  {
    "path": "apps/landing/src/components/ui/button/index.ts",
    "content": "export * from './Button'\nexport * from './MotionButton'\n"
  },
  {
    "path": "apps/landing/src/components/ui/checkbox/Checkbox.tsx",
    "content": "'use client'\n\nimport type { HTMLMotionProps } from 'motion/react'\nimport { m as motion } from 'motion/react'\nimport { Checkbox as CheckboxPrimitive } from 'radix-ui'\nimport * as React from 'react'\nimport type { VariantProps } from 'tailwind-variants'\nimport { tv } from 'tailwind-variants'\n\nimport { clsxm } from '~/lib/cn'\n\nconst checkboxStyles = tv({\n  base: [\n    'peer flex items-center justify-center shrink-0 rounded-sm bg-gray9/10 transition-colors duration-500',\n    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',\n    'disabled:cursor-not-allowed disabled:opacity-50',\n    'data-[state=checked]:bg-accent data-[state=checked]:text-white',\n  ],\n  variants: {\n    size: {\n      sm: 'size-4',\n      md: 'size-5',\n    },\n  },\n  defaultVariants: {\n    size: 'md',\n  },\n})\n\nconst checkboxIndicatorStyles = tv({\n  variants: {\n    size: {\n      sm: 'size-2.5',\n      md: 'size-3.5',\n    },\n  },\n  defaultVariants: {\n    size: 'md',\n  },\n})\n\ntype CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> &\n  HTMLMotionProps<'button'> &\n  VariantProps<typeof checkboxStyles> & {\n    indeterminate?: boolean\n  }\n\nfunction Checkbox({\n  className,\n  onCheckedChange,\n  indeterminate,\n  size = 'md',\n  ...props\n}: CheckboxProps) {\n  const [isChecked, setIsChecked] = React.useState(\n    props?.checked ?? props?.defaultChecked ?? false,\n  )\n\n  React.useEffect(() => {\n    if (props?.checked !== undefined) setIsChecked(props.checked)\n  }, [props?.checked])\n\n  // Determine the actual state including indeterminate\n  const checkboxState = indeterminate\n    ? 'indeterminate'\n    : isChecked\n      ? 'checked'\n      : 'unchecked'\n\n  const handleCheckedChange = React.useCallback(\n    (checked: boolean) => {\n      setIsChecked(checked)\n      onCheckedChange?.(checked)\n    },\n    [onCheckedChange],\n  )\n\n  return (\n    <CheckboxPrimitive.Root\n      {...props}\n      onCheckedChange={handleCheckedChange}\n      asChild\n    >\n      <motion.button\n        data-slot=\"checkbox\"\n        className={clsxm(\n          checkboxStyles({ size }),\n          indeterminate && 'bg-accent text-white',\n          className,\n        )}\n        whileTap={{ scale: 0.95 }}\n        whileHover={{ scale: 1.05 }}\n        {...props}\n      >\n        <CheckboxPrimitive.Indicator forceMount asChild>\n          <motion.svg\n            data-slot=\"checkbox-indicator\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            strokeWidth=\"3.5\"\n            stroke=\"currentColor\"\n            className={checkboxIndicatorStyles({ size })}\n            initial={checkboxState}\n            animate={checkboxState}\n          >\n            {/* Checkmark path */}\n            <motion.path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              d=\"M4.5 12.75l6 6 9-13.5\"\n              variants={{\n                checked: {\n                  pathLength: 1,\n                  opacity: 1,\n                  transition: {\n                    duration: 0.2,\n                    delay: 0.2,\n                  },\n                },\n                unchecked: {\n                  pathLength: 0,\n                  opacity: 0,\n                  transition: {\n                    duration: 0.2,\n                  },\n                },\n                indeterminate: {\n                  pathLength: 0,\n                  opacity: 0,\n                  transition: {\n                    duration: 0.1,\n                  },\n                },\n              }}\n            />\n            {/* Indeterminate line */}\n            <motion.path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              d=\"M6 12h12\"\n              variants={{\n                checked: {\n                  pathLength: 0,\n                  opacity: 0,\n                  transition: {\n                    duration: 0.1,\n                  },\n                },\n                unchecked: {\n                  pathLength: 0,\n                  opacity: 0,\n                  transition: {\n                    duration: 0.1,\n                  },\n                },\n                indeterminate: {\n                  pathLength: 1,\n                  opacity: 1,\n                  transition: {\n                    duration: 0.2,\n                    delay: 0.1,\n                  },\n                },\n              }}\n            />\n          </motion.svg>\n        </CheckboxPrimitive.Indicator>\n      </motion.button>\n    </CheckboxPrimitive.Root>\n  )\n}\n\nexport { Checkbox, type CheckboxProps }\n"
  },
  {
    "path": "apps/landing/src/components/ui/checkbox/index.ts",
    "content": "export * from './Checkbox'\n"
  },
  {
    "path": "apps/landing/src/components/ui/collapse/CollapseCss.tsx",
    "content": "/**\n * @see https://www.zhangxinxu.com/wordpress/2024/06/css-transition-behavior/\n * @see https://www.zhangxinxu.com/wordpress/2024/11/css-calc-interpolate-size/\n */\n\nimport type { FC } from 'react'\nimport * as React from 'react'\nimport { createContext, use, useState } from 'react'\n\nimport { cn } from '~/lib/cn'\n\ninterface CollapseContextValue {\n  openStates: Record<string, boolean>\n  setOpenState: (id: string, open: boolean) => void\n}\n\nconst CollapseContext = createContext<CollapseContextValue | null>(null)\n\nconst useCollapseContext = () => {\n  const ctx = use(CollapseContext)\n  if (!ctx) {\n    throw new Error('useCollapseContext must be used within CollapseGroup')\n  }\n  return ctx\n}\n\ninterface CollapseGroupProps {\n  defaultOpenId?: string\n  onOpenChange?: (state: Record<string, boolean>) => void\n  children: React.ReactNode\n}\n\nexport const CollapseCssGroup: FC<CollapseGroupProps> = ({\n  children,\n  defaultOpenId,\n  onOpenChange,\n}) => {\n  const [openStates, setOpenStates] = useState<Record<string, boolean>>(() => {\n    return defaultOpenId ? { [defaultOpenId]: true } : {}\n  })\n\n  const setOpenState = React.useCallback(\n    (id: string, open: boolean) => {\n      setOpenStates((prev) => {\n        const newState = { ...prev, [id]: open }\n        onOpenChange?.(newState)\n        return newState\n      })\n    },\n    [onOpenChange],\n  )\n\n  const ctxValue = React.useMemo<CollapseContextValue>(\n    () => ({\n      openStates,\n      setOpenState,\n    }),\n    [openStates, setOpenState],\n  )\n\n  return <CollapseContext value={ctxValue}>{children}</CollapseContext>\n}\n\ninterface CollapseProps {\n  title: React.ReactNode\n  hideArrow?: boolean\n  defaultOpen?: boolean\n  isOpened?: boolean // For controlled usage\n  collapseId?: string\n  onOpenChange?: (isOpened: boolean) => void\n  contentClassName?: string\n  className?: string\n  children: React.ReactNode\n  innerClassName?: string\n  ref?: React.Ref<CollapseCssRef>\n}\n\nexport interface CollapseCssRef {\n  setIsOpened: (isOpened: boolean) => void\n}\nexport const CollapseCss: FC<CollapseProps> = ({\n  title,\n  hideArrow,\n  defaultOpen = false,\n  isOpened: controlledIsOpened,\n  collapseId,\n  onOpenChange,\n  contentClassName,\n  className,\n  innerClassName,\n  children,\n  ref,\n}) => {\n  const reactId = React.useId()\n  const id = collapseId ?? reactId\n  const { openStates, setOpenState } = useCollapseContext()\n\n  // Use controlled value if provided, otherwise use context state or defaultOpen\n  const isOpened = controlledIsOpened ?? openStates[id] ?? defaultOpen\n\n  const handleToggle = React.useCallback(() => {\n    const newOpened = !isOpened\n    // Only update context state if not controlled\n    if (controlledIsOpened === undefined) {\n      setOpenState(id, newOpened)\n    }\n    onOpenChange?.(newOpened)\n  }, [id, isOpened, controlledIsOpened, setOpenState, onOpenChange])\n\n  React.useImperativeHandle(ref, () => ({\n    setIsOpened: (isOpened: boolean) => {\n      setOpenState(id, isOpened)\n    },\n  }))\n  return (\n    <div\n      className={cn('flex flex-col', className)}\n      data-state={isOpened ? 'open' : 'hidden'}\n    >\n      <div\n        className=\"relative flex w-full cursor-pointer items-center justify-between\"\n        onClick={controlledIsOpened === undefined ? handleToggle : undefined}\n      >\n        <span className=\"w-0 shrink grow truncate\">{title}</span>\n        {!hideArrow && (\n          <div className=\"text-text-secondary inline-flex shrink-0 items-center\">\n            <i\n              className={cn(\n                'i-mingcute-down-line transition-transform duration-300 ease-in-out',\n                isOpened ? 'rotate-180' : '',\n              )}\n            />\n          </div>\n        )}\n      </div>\n      <CollapseCssContent\n        isOpened={isOpened}\n        className={contentClassName}\n        innerClassName={innerClassName}\n      >\n        {children}\n      </CollapseCssContent>\n    </div>\n  )\n}\n\ninterface CollapseContentProps {\n  isOpened: boolean\n  className?: string\n  children: React.ReactNode\n  innerClassName?: string\n}\n\nconst CollapseCssContent: FC<CollapseContentProps> = ({\n  isOpened,\n  className,\n  children,\n  innerClassName,\n}) => {\n  const contentRef = React.useRef<HTMLDivElement>(null)\n\n  return (\n    <div\n      ref={contentRef}\n      className={cn(\n        'overflow-hidden [interpolate-size:allow-keywords] [transition-behavior:allow-discrete]',\n        'transition-[height,opacity,display] duration-300 ease-in-out',\n        '[@starting-style]:h-0 [@starting-style]:opacity-0',\n        className,\n        isOpened\n          ? 'block h-[calc-size(auto)] opacity-100'\n          : 'hidden h-0 opacity-0',\n      )}\n      data-state={isOpened ? 'open' : 'closed'}\n    >\n      <div\n        className={cn(\n          'transition-transform duration-300 ease-in-out',\n          '[@starting-style]:translate-y-[-8px]',\n          isOpened ? 'translate-y-0' : 'translate-y-[-8px]',\n          innerClassName,\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/collapse/hooks.tsx",
    "content": "import type { PrimitiveAtom } from 'jotai'\nimport { createContext, use } from 'react'\n\nexport interface CollapseContextValue {\n  currentOpenCollapseIdAtom: PrimitiveAtom<string | null>\n  collapseGroupItemStateAtom: PrimitiveAtom<Record<string, boolean>>\n}\nexport const CollaspeContext = createContext<CollapseContextValue>(null!)\nexport const useCollapseContext = () => {\n  const ctx = use(CollaspeContext)\n  if (!ctx) {\n    throw new Error('CollapseContext not found')\n  }\n  return ctx\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/collapse/index.ts",
    "content": "export * from './CollapseCss'\n"
  },
  {
    "path": "apps/landing/src/components/ui/dialog/Dialog.tsx",
    "content": "'use client'\n\nimport type { HTMLMotionProps, Transition } from 'motion/react'\nimport { AnimatePresence, m as motion } from 'motion/react'\nimport { Dialog as DialogPrimitive } from 'radix-ui'\nimport * as React from 'react'\n\nimport { cn } from '~/lib/cn'\nimport { stopPropagation } from '~/lib/dom'\n\ntype DialogContextType = {\n  isOpen: boolean\n}\n\nconst DialogContext = React.createContext<DialogContextType | undefined>(\n  undefined,\n)\n\nconst useDialog = (): DialogContextType => {\n  const context = React.use(DialogContext)\n  if (!context) {\n    throw new Error('useDialog must be used within a Dialog')\n  }\n  return context\n}\n\ntype DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>\n\nfunction Dialog({ children, ...props }: DialogProps) {\n  const [isOpen, setIsOpen] = React.useState(\n    props?.open ?? props?.defaultOpen ?? false,\n  )\n\n  React.useEffect(() => {\n    if (props?.open !== undefined) setIsOpen(props.open)\n  }, [props?.open])\n\n  const handleOpenChange = React.useCallback(\n    (open: boolean) => {\n      setIsOpen(open)\n      props.onOpenChange?.(open)\n    },\n    [props],\n  )\n\n  return (\n    <DialogContext value={React.useMemo(() => ({ isOpen }), [isOpen])}>\n      <DialogPrimitive.Root\n        data-slot=\"dialog\"\n        {...props}\n        onOpenChange={handleOpenChange}\n      >\n        {children}\n      </DialogPrimitive.Root>\n    </DialogContext>\n  )\n}\n\ntype DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>\n\nfunction DialogTrigger(props: DialogTriggerProps) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\ntype DialogPortalProps = React.ComponentProps<typeof DialogPrimitive.Portal>\n\nfunction DialogPortal(props: DialogPortalProps) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\ntype DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>\n\nfunction DialogClose(props: DialogCloseProps) {\n  return (\n    <DialogPrimitive.Close\n      data-slot=\"dialog-close\"\n      {...props}\n      className={cn('contents', props.className)}\n    />\n  )\n}\n\ntype DialogOverlayProps = React.ComponentProps<typeof DialogPrimitive.Overlay>\n\nfunction DialogOverlay({ className, ...props }: DialogOverlayProps) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        'bg-material-medium data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nexport type DialogContentProps = React.ComponentProps<\n  typeof DialogPrimitive.Content\n> &\n  HTMLMotionProps<'div'> & {\n    transition?: Transition\n    showCloseButton?: boolean\n    disableOverlayClickToClose?: boolean\n    disableTransition?: boolean\n  }\n\nconst contentTransition: Transition = {\n  type: 'spring',\n  stiffness: 300,\n  damping: 30,\n}\n\nfunction DialogContent({\n  className,\n  children,\n  transition = contentTransition,\n  showCloseButton = true,\n\n  disableOverlayClickToClose = false,\n  disableTransition = false,\n  ...props\n}: DialogContentProps) {\n  const { isOpen } = useDialog()\n\n  const transitionVariants = React.useMemo(() => {\n    if (disableTransition) {\n      return {\n        initial: { opacity: 0.96 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n      }\n    }\n    return {\n      initial: { opacity: 0, scale: 0.95, y: -20 },\n      animate: { opacity: 1, scale: 1, y: 0 },\n      exit: { opacity: 0, scale: 0.95, y: -20 },\n    }\n  }, [disableTransition])\n\n  return (\n    <AnimatePresence>\n      {isOpen && (\n        <DialogPortal forceMount data-slot=\"dialog-portal\">\n          <DialogOverlay asChild forceMount>\n            <motion.div\n              key=\"dialog-overlay\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.2, ease: 'easeInOut' }}\n              onClick={stopPropagation}\n            />\n          </DialogOverlay>\n          <DialogPrimitive.Content asChild forceMount {...props}>\n            <motion.div\n              key=\"dialog-content\"\n              data-slot=\"dialog-content\"\n              initial={transitionVariants.initial}\n              animate={transitionVariants.animate}\n              exit={transitionVariants.exit}\n              transition={transition}\n              className={cn(\n                'border-border bg-background fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100svh-3rem)] w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-4 shadow-lg',\n                disableOverlayClickToClose\n                  ? 'pointer-events-none [&_*]:pointer-events-auto'\n                  : '',\n                className,\n              )}\n              {...props}\n            >\n              {children}\n              {showCloseButton && (\n                <DialogPrimitive.Close className=\"focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 flex size-6 items-center justify-center rounded-sm focus:outline-none disabled:pointer-events-none\">\n                  <i className=\"i-mingcute-close-line size-4\" />\n                  <span className=\"sr-only\">Close</span>\n                </DialogPrimitive.Close>\n              )}\n            </motion.div>\n          </DialogPrimitive.Content>\n        </DialogPortal>\n      )}\n    </AnimatePresence>\n  )\n}\n\ntype DialogHeaderProps = React.ComponentProps<'div'>\n\nfunction DialogHeader({ className, ...props }: DialogHeaderProps) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\n        'flex flex-col space-y-1.5 text-center sm:text-left',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\ntype DialogFooterProps = React.ComponentProps<'div'>\n\nfunction DialogFooter({ className, ...props }: DialogFooterProps) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\ntype DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>\n\nfunction DialogTitle({ className, ...props }: DialogTitleProps) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\n        'text-lg leading-none font-semibold tracking-tight',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\ntype DialogDescriptionProps = React.ComponentProps<\n  typeof DialogPrimitive.Description\n>\n\nfunction DialogDescription({ className, ...props }: DialogDescriptionProps) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  type DialogCloseProps,\n  DialogContent,\n  type DialogContextType,\n  DialogDescription,\n  type DialogDescriptionProps,\n  DialogFooter,\n  type DialogFooterProps,\n  DialogHeader,\n  type DialogHeaderProps,\n  DialogOverlay,\n  type DialogOverlayProps,\n  DialogPortal,\n  type DialogPortalProps,\n  type DialogProps,\n  DialogTitle,\n  type DialogTitleProps,\n  DialogTrigger,\n  type DialogTriggerProps,\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/dialog/index.ts",
    "content": "export {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  type DialogContentProps,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogTitle,\n  DialogTrigger,\n} from './Dialog'\n"
  },
  {
    "path": "apps/landing/src/components/ui/divider/Divider.tsx",
    "content": "import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react'\nimport * as React from 'react'\n\nimport { clsxm } from '~/lib/helper'\n\nexport const Divider: FC<\n  DetailedHTMLProps<HTMLAttributes<HTMLHRElement>, HTMLHRElement>\n> = (props) => {\n  const { className, ...rest } = props\n  return (\n    <hr\n      className={clsxm(\n        'bg-always-black dark:bg-always-white my-4 h-[0.5px] border-0 !bg-opacity-30',\n        className,\n      )}\n      {...rest}\n    />\n  )\n}\n\nexport const DividerVertical: FC<\n  DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>\n> = (props) => {\n  const { className, ...rest } = props\n  return (\n    <span\n      className={clsxm(\n        'bg-always-black dark:bg-always-white mx-4 inline-block h-full w-[0.5px] select-none !bg-opacity-30 text-transparent',\n        className,\n      )}\n      {...rest}\n    >\n      w\n    </span>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/divider/index.ts",
    "content": "export * from './Divider'\n"
  },
  {
    "path": "apps/landing/src/components/ui/dropdown-menu/DropdownMenu.tsx",
    "content": "'use client'\n\nimport { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'\nimport * as React from 'react'\n\nimport { clsxm } from '~/lib/cn'\n\nimport { RootPortal } from '../portal'\n\nconst DropdownMenu = DropdownMenuPrimitive.Root\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nconst DropdownMenuSubTrigger = ({\n  ref,\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<\n    typeof DropdownMenuPrimitive.SubTrigger\n  > | null>\n}) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={clsxm(\n      'cursor-menu focus:bg-accent data-[state=open]:bg-accent flex items-center rounded-[5px] px-2.5 py-1 outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      'text-sm focus-within:outline-transparent',\n      'h-[28px] w-full',\n      inset && 'pl-8',\n      className,\n    )}\n    {...props}\n  >\n    {children}\n    <i className=\"i-mingcute-right-line ml-auto size-3\" />\n  </DropdownMenuPrimitive.SubTrigger>\n)\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName\n\nconst DropdownMenuSubContent = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {\n  ref?: React.Ref<React.ElementRef<\n    typeof DropdownMenuPrimitive.SubContent\n  > | null>\n}) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={clsxm(\n      'bg-material-medium backdrop-blur-background text-text border-border z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1',\n      'shadow-context-menu',\n      '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]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n      className,\n    )}\n    {...props}\n  />\n)\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName\n\nconst DropdownMenuContent = ({\n  ref,\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Content> | null>\n}) => (\n  <RootPortal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={clsxm(\n        'bg-material-medium backdrop-blur-background text-text border-border z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1',\n        'shadow-context-menu',\n        '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]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        className,\n      )}\n      {...props}\n    />\n  </RootPortal>\n)\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName\n\nconst DropdownMenuItem = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Item> | null>\n}) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={clsxm(\n      'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] px-2.5 py-1 transition-colors outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      'text-sm focus-within:outline-transparent',\n      'h-[28px] w-full',\n      inset && 'pl-8',\n      className,\n    )}\n    {...props}\n  />\n)\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName\n\nconst DropdownMenuCheckboxItem = ({\n  ref,\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {\n  ref?: React.Ref<React.ElementRef<\n    typeof DropdownMenuPrimitive.CheckboxItem\n  > | null>\n}) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={clsxm(\n      'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] py-1 pr-2.5 pl-8 transition-colors outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      'text-sm focus-within:outline-transparent',\n      'h-[28px] w-full',\n      className,\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex size-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <i className=\"i-mingcute-check-fill size-3\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n)\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName\n\nconst DropdownMenuRadioItem = ({\n  ref,\n  className,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {\n  ref?: React.Ref<React.ElementRef<\n    typeof DropdownMenuPrimitive.RadioItem\n  > | null>\n}) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={clsxm(\n      'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] py-1 pr-2.5 pl-8 transition-colors outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      'text-sm focus-within:outline-transparent',\n      'h-[28px] w-full',\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex size-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <i className=\"i-mingcute-check-fill size-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n)\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName\n\nconst DropdownMenuLabel = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Label> | null>\n}) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={clsxm(\n      'text-text px-2 py-1.5 font-semibold',\n      inset && 'pl-8',\n      className,\n    )}\n    {...props}\n  />\n)\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName\n\nconst DropdownMenuSeparator = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {\n  ref?: React.Ref<React.ElementRef<\n    typeof DropdownMenuPrimitive.Separator\n  > | null>\n}) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={clsxm('backdrop-blur-background mx-2 my-1 h-px', className)}\n    {...props}\n  />\n)\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={clsxm(\n        'text-text-secondary ml-auto text-xs tracking-widest opacity-60',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut'\n\nexport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuPortal,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/effects/GridGuides.tsx",
    "content": "import * as React from 'react'\n\ntype GridGuidesProps = React.PropsWithChildren<{\n  className?: string\n  /** Show horizontal baseline guides, default: true on md+ */\n  showRows?: boolean\n  /** Number of columns for vertical guides (desktop). Default 12 */\n  cols?: number\n}>\n\n/**\n * Subtle design guide lines overlay for sections.\n * Uses background gradients for vertical (columns) and horizontal (baseline) guides.\n * Non-interactive; fades toward edges via mask-image.\n */\nexport function GridGuides({\n  className,\n  showRows,\n  cols = 12,\n}: GridGuidesProps) {\n  // Tailwind arbitrary values for background-size require constants at build time.\n  // We keep `cols` configurable for potential future extension, but today use 12.\n  const colSize = `calc(100%/${cols}) 100%`\n  const rowSize = `100% 24px`\n\n  return (\n    <div\n      aria-hidden\n      className={[\n        'pointer-events-none absolute inset-0 -z-10 hidden lg:block',\n        'opacity-30 md:opacity-40',\n        className,\n      ]\n        .filter(Boolean)\n        .join(' ')}\n      style={{\n        backgroundImage:\n          'linear-gradient(to right, var(--color-border, hsl(0 0% 100% / 0.12)) 1px, transparent 1px),\\\nlinear-gradient(to bottom, var(--color-border, hsl(0 0% 100% / 0.12)) 1px, transparent 1px)',\n        backgroundSize: `${colSize}, ${showRows !== false ? rowSize : '0 0'}`,\n        backgroundPosition: '0 0, 0 0',\n        maskImage:\n          'radial-gradient(60% 60% at 50% 40%, black 55%, transparent 100%)',\n        WebkitMaskImage:\n          'radial-gradient(60% 60% at 50% 40%, black 55%, transparent 100%)',\n      }}\n    />\n  )\n}\n\nGridGuides.displayName = 'GridGuides'\n"
  },
  {
    "path": "apps/landing/src/components/ui/effects/ParticlesAura.tsx",
    "content": "'use client'\n\nimport { m } from 'motion/react'\nimport * as React from 'react'\n\ntype ParticlesAuraProps = {\n  className?: string\n  color?: string // CSS color, default accent\n  count?: number // 6-12 reasonable\n}\n\n/**\n * Lightweight, decorative particles for CTA aura.\n * Rendered as blurred dots with subtle float animation.\n */\nexport function ParticlesAura({\n  className,\n  color = 'var(--color-accent)',\n  count = 8,\n}: ParticlesAuraProps) {\n  const items = React.useMemo(() => Array.from({ length: count }), [count])\n  return (\n    <div\n      className={['pointer-events-none absolute inset-0 -z-10', className].join(\n        ' ',\n      )}\n      aria-hidden\n    >\n      {items.map((_, i) => {\n        const size = 4 + ((i * 17) % 8) // 4-11px\n        const left = (i * 137) % 100 // pseudo-random\n        const top = (i * 73) % 100\n        const delay = (i * 0.22) % 2\n        const duration = 2.5 + ((i * 0.37) % 2)\n        return (\n          <m.span\n            key={i}\n            className=\"absolute rounded-full blur-[2px]\"\n            style={{\n              width: size,\n              height: size,\n              left: `${left}%`,\n              top: `${top}%`,\n              background: color,\n              opacity: 0.35,\n              filter: 'drop-shadow(0 0 8px rgba(255,92,0,0.45))',\n            }}\n            initial={{ y: 0, opacity: 0.2 }}\n            animate={{ y: -8, opacity: 0.4 }}\n            transition={{\n              repeat: Infinity,\n              repeatType: 'mirror',\n              ease: 'easeInOut',\n              duration,\n              delay,\n            }}\n          />\n        )\n      })}\n    </div>\n  )\n}\n\nexport default ParticlesAura\n"
  },
  {
    "path": "apps/landing/src/components/ui/effects/TiltCard.tsx",
    "content": "'use client'\n\nimport { m, useMotionValue, useSpring, useTransform } from 'motion/react'\nimport * as React from 'react'\n\ntype TiltCardProps = React.PropsWithChildren<{\n  className?: string\n  intensity?: number // degrees, default 12\n  glare?: boolean\n}>\n\n/**\n * Glass-friendly 3D tilt container with smooth springs and optional glare.\n * Enhanced visual feedback with more pronounced tilt and glare effects.\n */\nexport function TiltCard({\n  className,\n  children,\n  intensity = 12,\n  glare = true,\n}: TiltCardProps) {\n  const ref = React.useRef<HTMLDivElement | null>(null)\n  const rx = useMotionValue(0)\n  const ry = useMotionValue(0)\n  const px = useMotionValue(0)\n  const py = useMotionValue(0)\n\n  // responsive springs with slightly more bounce\n  const srx = useSpring(rx, { stiffness: 140, damping: 14, mass: 0.3 })\n  const sry = useSpring(ry, { stiffness: 140, damping: 14, mass: 0.3 })\n\n  const rotateX = useTransform(sry, (v) => `${v}deg`)\n  const rotateY = useTransform(srx, (v) => `${v}deg`)\n\n  const spotlightX = useSpring(px, { stiffness: 150, damping: 18 })\n  const spotlightY = useSpring(py, { stiffness: 150, damping: 18 })\n\n  const onPointerMove = (e: React.PointerEvent) => {\n    const el = ref.current\n    if (!el) return\n    const rect = el.getBoundingClientRect()\n    const x = e.clientX - rect.left\n    const y = e.clientY - rect.top\n    const cx = rect.width / 2\n    const cy = rect.height / 2\n    const dx = (x - cx) / cx\n    const dy = (y - cy) / cy\n    rx.set(dx * intensity)\n    ry.set(-dy * intensity)\n    px.set(x)\n    py.set(y)\n  }\n\n  const reset = () => {\n    rx.set(0)\n    ry.set(0)\n  }\n\n  return (\n    <m.div\n      ref={ref}\n      className={className}\n      style={{\n        perspective: 1200,\n        transformStyle: 'preserve-3d',\n      }}\n      onPointerMove={onPointerMove}\n      onPointerLeave={reset}\n    >\n      <m.div\n        style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}\n        className=\"will-change-transform relative rounded-xl\"\n      >\n        {glare ? (\n          <>\n            {/* Main spotlight effect with brand color */}\n            <m.span\n              aria-hidden\n              className=\"tilt-spotlight pointer-events-none absolute inset-0 z-10 rounded-[inherit] mix-blend-overlay\"\n              style={{\n                // @ts-expect-error CSS variable MotionValues\n                '--mx': spotlightX,\n                '--my': spotlightY,\n                background:\n                  'radial-gradient(300px 300px at calc(var(--mx) * 1px) calc(var(--my) * 1px), color-mix(in oklab,var(--color-accent),transparent 80%), transparent 40%)',\n              }}\n            />\n            {/* Secondary white glow */}\n            <m.span\n              aria-hidden\n              className=\"tilt-spotlight pointer-events-none absolute inset-0 z-10 rounded-[inherit] mix-blend-soft-light\"\n              style={{\n                // @ts-expect-error CSS variable MotionValues\n                '--mx': spotlightX,\n                '--my': spotlightY,\n                background:\n                  'radial-gradient(250px 250px at calc(var(--mx) * 1px) calc(var(--my) * 1px), color-mix(in oklab,var(--color-background),transparent 75%), transparent 45%)',\n              }}\n            />\n          </>\n        ) : null}\n        {children}\n      </m.div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/glass/index.tsx",
    "content": "// https://reactbits.dev/components/glass-surface\nimport * as React from 'react'\nimport { useEffect, useId, useRef } from 'react'\n\nimport { useIsDark } from '~/hooks/common/use-is-dark'\nimport { cn } from '~/lib/cn'\n\nexport interface GlassSurfaceProps {\n  children?: React.ReactNode\n  width?: number | string\n  height?: number | string\n  borderRadius?: number\n  borderWidth?: number\n  brightness?: number\n  opacity?: number\n  blur?: number\n  displace?: number\n  backgroundOpacity?: number\n  saturation?: number\n  distortionScale?: number\n  redOffset?: number\n  greenOffset?: number\n  blueOffset?: number\n  xChannel?: 'R' | 'G' | 'B'\n  yChannel?: 'R' | 'G' | 'B'\n  mixBlendMode?:\n    | 'normal'\n    | 'multiply'\n    | 'screen'\n    | 'overlay'\n    | 'darken'\n    | 'lighten'\n    | 'color-dodge'\n    | 'color-burn'\n    | 'hard-light'\n    | 'soft-light'\n    | 'difference'\n    | 'exclusion'\n    | 'hue'\n    | 'saturation'\n    | 'color'\n    | 'luminosity'\n    | 'plus-darker'\n    | 'plus-lighter'\n  className?: string\n  style?: React.CSSProperties\n}\n\nexport const GlassSurface: React.FC<GlassSurfaceProps> = ({\n  children,\n  width = 200,\n  height = 80,\n  borderRadius = 20,\n  borderWidth = 0.07,\n  brightness = 50,\n  opacity = 0.93,\n  blur = 11,\n  displace = 0,\n  backgroundOpacity = 0,\n  saturation = 1,\n  distortionScale = -180,\n  redOffset = 0,\n  greenOffset = 10,\n  blueOffset = 20,\n  xChannel = 'R',\n  yChannel = 'G',\n  mixBlendMode = 'difference',\n  className = '',\n  style = {},\n}) => {\n  const uniqueId = useId().replaceAll(':', '-')\n  const filterId = `glass-filter-${uniqueId}`\n  const redGradId = `red-grad-${uniqueId}`\n  const blueGradId = `blue-grad-${uniqueId}`\n\n  const containerRef = useRef<HTMLDivElement>(null)\n  const feImageRef = useRef<SVGFEImageElement>(null)\n  const redChannelRef = useRef<SVGFEDisplacementMapElement>(null)\n  const greenChannelRef = useRef<SVGFEDisplacementMapElement>(null)\n  const blueChannelRef = useRef<SVGFEDisplacementMapElement>(null)\n  const gaussianBlurRef = useRef<SVGFEGaussianBlurElement>(null)\n\n  const isDarkMode = useIsDark()\n\n  const generateDisplacementMap = () => {\n    const rect = containerRef.current?.getBoundingClientRect()\n    const actualWidth = rect?.width || 400\n    const actualHeight = rect?.height || 200\n    const edgeSize = Math.min(actualWidth, actualHeight) * (borderWidth * 0.5)\n\n    const svgContent = `\n      <svg viewBox=\"0 0 ${actualWidth} ${actualHeight}\" xmlns=\"http://www.w3.org/2000/svg\">\n        <defs>\n          <linearGradient id=\"${redGradId}\" x1=\"100%\" y1=\"0%\" x2=\"0%\" y2=\"0%\">\n            <stop offset=\"0%\" stop-color=\"${isDarkMode ? '#fff' : '#000'}\"/>\n            <stop offset=\"100%\" stop-color=\"red\"/>\n          </linearGradient>\n          <linearGradient id=\"${blueGradId}\" x1=\"0%\" y1=\"0%\" x2=\"0%\" y2=\"100%\">\n            <stop offset=\"0%\" stop-color=\"${isDarkMode ? '#fff' : '#000'}\"/>\n            <stop offset=\"100%\" stop-color=\"blue\"/>\n          </linearGradient>\n        </defs>\n        <rect x=\"0\" y=\"0\" width=\"${actualWidth}\" height=\"${actualHeight}\" fill=\"${isDarkMode ? '#000' : '#fff'}\"></rect>\n        <rect x=\"0\" y=\"0\" width=\"${actualWidth}\" height=\"${actualHeight}\" rx=\"${borderRadius}\" fill=\"url(#${redGradId})\" />\n        <rect x=\"0\" y=\"0\" width=\"${actualWidth}\" height=\"${actualHeight}\" rx=\"${borderRadius}\" fill=\"url(#${blueGradId})\" style=\"mix-blend-mode: ${mixBlendMode}\" />\n        <rect x=\"${edgeSize}\" y=\"${edgeSize}\" width=\"${actualWidth - edgeSize * 2}\" height=\"${actualHeight - edgeSize * 2}\" rx=\"${borderRadius}\" fill=\"hsl(0 0% ${brightness}% / ${opacity})\" style=\"filter:blur(${blur}px)\" />\n      </svg>\n    `\n\n    return `data:image/svg+xml,${encodeURIComponent(svgContent)}`\n  }\n\n  const updateDisplacementMap = () => {\n    feImageRef.current?.setAttribute('href', generateDisplacementMap())\n  }\n\n  useEffect(() => {\n    updateDisplacementMap()\n    ;[\n      { ref: redChannelRef, offset: redOffset },\n      { ref: greenChannelRef, offset: greenOffset },\n      { ref: blueChannelRef, offset: blueOffset },\n    ].forEach(({ ref, offset }) => {\n      if (ref.current) {\n        ref.current.setAttribute('scale', (distortionScale + offset).toString())\n        ref.current.setAttribute('xChannelSelector', xChannel)\n        ref.current.setAttribute('yChannelSelector', yChannel)\n      }\n    })\n\n    gaussianBlurRef.current?.setAttribute('stdDeviation', displace.toString())\n  }, [\n    width,\n    height,\n    borderRadius,\n    borderWidth,\n    brightness,\n    opacity,\n    blur,\n    displace,\n    distortionScale,\n    redOffset,\n    greenOffset,\n    blueOffset,\n    xChannel,\n    yChannel,\n    mixBlendMode,\n    isDarkMode,\n  ])\n\n  useEffect(() => {\n    if (!containerRef.current) return\n\n    const resizeObserver = new ResizeObserver(() => {\n      setTimeout(updateDisplacementMap, 0)\n    })\n\n    resizeObserver.observe(containerRef.current)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!containerRef.current) return\n\n    const resizeObserver = new ResizeObserver(() => {\n      setTimeout(updateDisplacementMap, 0)\n    })\n\n    resizeObserver.observe(containerRef.current)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  useEffect(() => {\n    setTimeout(updateDisplacementMap, 0)\n  }, [width, height, isDarkMode])\n\n  const supportsSVGFilters = () => {\n    const isWebkit =\n      /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)\n    const isFirefox = /Firefox/.test(navigator.userAgent)\n\n    if (isWebkit || isFirefox) {\n      return false\n    }\n\n    const div = document.createElement('div')\n    div.style.backdropFilter = `url(#${filterId})`\n    return div.style.backdropFilter !== ''\n  }\n\n  const supportsBackdropFilter = () => {\n    if (typeof window === 'undefined') return false\n    return CSS.supports('backdrop-filter', 'blur(10px)')\n  }\n\n  const getContainerStyles = (): React.CSSProperties => {\n    const baseStyles: React.CSSProperties = {\n      ...style,\n      width: typeof width === 'number' ? `${width}px` : width,\n      height: typeof height === 'number' ? `${height}px` : height,\n      borderRadius: `${borderRadius}px`,\n      '--glass-frost': backgroundOpacity,\n      '--glass-saturation': saturation,\n    } as React.CSSProperties\n\n    const svgSupported = supportsSVGFilters()\n    const backdropFilterSupported = supportsBackdropFilter()\n\n    if (svgSupported) {\n      return {\n        ...baseStyles,\n        background: isDarkMode\n          ? `hsl(0 0% 0% / ${backgroundOpacity})`\n          : `hsl(0 0% 100% / ${backgroundOpacity})`,\n        backdropFilter: `url(#${filterId}) saturate(${saturation})`,\n        boxShadow: isDarkMode\n          ? `0 0 2px 1px color-mix(in oklch, white, transparent 65%) inset,\n           0 0 10px 4px color-mix(in oklch, white, transparent 85%) inset,\n           0px 4px 16px rgba(17, 17, 26, 0.05),\n           0px 8px 24px rgba(17, 17, 26, 0.05),\n           0px 16px 56px rgba(17, 17, 26, 0.05),\n           0px 4px 16px rgba(17, 17, 26, 0.05) inset,\n           0px 8px 24px rgba(17, 17, 26, 0.05) inset,\n           0px 16px 56px rgba(17, 17, 26, 0.05) inset`\n          : `0 0 2px 1px color-mix(in oklch, oklch(85% 0.1 70), transparent 75%) inset,\n           0 0 10px 4px color-mix(in oklch, oklch(90% 0.1 70), transparent 85%) inset,\n           0px 4px 16px rgba(255, 180, 120, 0.08),\n           0px 8px 24px rgba(255, 180, 120, 0.08),\n           0px 16px 56px rgba(255, 180, 120, 0.08),\n           0px 4px 16px rgba(255, 180, 120, 0.08) inset,\n           0px 8px 24px rgba(255, 180, 120, 0.08) inset,\n           0px 16px 56px rgba(255, 180, 120, 0.08) inset`,\n      }\n    } else {\n      if (isDarkMode) {\n        if (!backdropFilterSupported) {\n          return {\n            ...baseStyles,\n            background: 'rgba(0, 0, 0, 0.4)',\n            border: '1px solid rgba(255, 255, 255, 0.2)',\n            boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.2),\n                        inset 0 -1px 0 0 rgba(255, 255, 255, 0.1)`,\n          }\n        } else {\n          return {\n            ...baseStyles,\n            background: 'rgba(255, 255, 255, 0.1)',\n            backdropFilter: 'blur(12px) saturate(1.8) brightness(1.2)',\n            WebkitBackdropFilter: 'blur(12px) saturate(1.8) brightness(1.2)',\n            border: '1px solid rgba(255, 255, 255, 0.2)',\n            boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.2),\n                        inset 0 -1px 0 0 rgba(255, 255, 255, 0.1)`,\n          }\n        }\n      } else {\n        if (!backdropFilterSupported) {\n          return {\n            ...baseStyles,\n            background: 'rgba(255, 255, 255, 0.4)',\n            border: '1px solid rgba(255, 255, 255, 0.3)',\n            boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.5),\n                        inset 0 -1px 0 0 rgba(255, 255, 255, 0.3)`,\n          }\n        } else {\n          return {\n            ...baseStyles,\n            background: 'rgba(255, 255, 255, 0.25)',\n            backdropFilter: 'blur(12px) saturate(1.8) brightness(1.1)',\n            WebkitBackdropFilter: 'blur(12px) saturate(1.8) brightness(1.1)',\n            border: '1px solid rgba(255, 255, 255, 0.3)',\n            boxShadow: `0 8px 32px 0 rgba(31, 38, 135, 0.2),\n                        0 2px 16px 0 rgba(31, 38, 135, 0.1),\n                        inset 0 1px 0 0 rgba(255, 255, 255, 0.4),\n                        inset 0 -1px 0 0 rgba(255, 255, 255, 0.2)`,\n          }\n        }\n      }\n    }\n  }\n\n  const glassSurfaceClasses =\n    'relative flex items-center justify-center overflow-hidden transition-opacity duration-[260ms] ease-out'\n\n  const focusVisibleClasses = isDarkMode\n    ? 'focus-visible:outline-2 focus-visible:outline-[#0A84FF] focus-visible:outline-offset-2'\n    : 'focus-visible:outline-2 focus-visible:outline-[#007AFF] focus-visible:outline-offset-2'\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(glassSurfaceClasses, focusVisibleClasses, className)}\n      style={getContainerStyles()}\n    >\n      <svg\n        className=\"size-full pointer-events-none absolute inset-0 opacity-0 -z-10\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <defs>\n          <filter\n            id={filterId}\n            colorInterpolationFilters=\"sRGB\"\n            x=\"0%\"\n            y=\"0%\"\n            width=\"100%\"\n            height=\"100%\"\n          >\n            <feImage\n              ref={feImageRef}\n              x=\"0\"\n              y=\"0\"\n              width=\"100%\"\n              height=\"100%\"\n              preserveAspectRatio=\"none\"\n              result=\"map\"\n            />\n\n            <feDisplacementMap\n              ref={redChannelRef}\n              in=\"SourceGraphic\"\n              in2=\"map\"\n              id=\"redchannel\"\n              result=\"dispRed\"\n            />\n            <feColorMatrix\n              in=\"dispRed\"\n              type=\"matrix\"\n              values=\"1 0 0 0 0\n                      0 0 0 0 0\n                      0 0 0 0 0\n                      0 0 0 1 0\"\n              result=\"red\"\n            />\n\n            <feDisplacementMap\n              ref={greenChannelRef}\n              in=\"SourceGraphic\"\n              in2=\"map\"\n              id=\"greenchannel\"\n              result=\"dispGreen\"\n            />\n            <feColorMatrix\n              in=\"dispGreen\"\n              type=\"matrix\"\n              values=\"0 0 0 0 0\n                      0 1 0 0 0\n                      0 0 0 0 0\n                      0 0 0 1 0\"\n              result=\"green\"\n            />\n\n            <feDisplacementMap\n              ref={blueChannelRef}\n              in=\"SourceGraphic\"\n              in2=\"map\"\n              id=\"bluechannel\"\n              result=\"dispBlue\"\n            />\n            <feColorMatrix\n              in=\"dispBlue\"\n              type=\"matrix\"\n              values=\"0 0 0 0 0\n                      0 0 0 0 0\n                      0 0 1 0 0\n                      0 0 0 1 0\"\n              result=\"blue\"\n            />\n\n            <feBlend in=\"red\" in2=\"green\" mode=\"screen\" result=\"rg\" />\n            <feBlend in=\"rg\" in2=\"blue\" mode=\"screen\" result=\"output\" />\n            <feGaussianBlur\n              ref={gaussianBlurRef}\n              in=\"output\"\n              stdDeviation=\"0.7\"\n            />\n          </filter>\n        </defs>\n      </svg>\n\n      <div className=\"size-full flex items-center justify-center p-2 rounded-[inherit] relative z-10\">\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/highlighter.tsx",
    "content": "import { useInView } from 'motion/react'\nimport type * as React from 'react'\nimport { useEffect, useRef } from 'react'\nimport { annotate } from 'rough-notation'\nimport type { RoughAnnotation } from 'rough-notation/lib/model'\n\ntype AnnotationAction =\n  | 'highlight'\n  | 'underline'\n  | 'box'\n  | 'circle'\n  | 'strike-through'\n  | 'crossed-off'\n  | 'bracket'\n\ninterface HighlighterProps {\n  children: React.ReactNode\n  action?: AnnotationAction\n  color?: string\n  strokeWidth?: number\n  animationDuration?: number\n  iterations?: number\n  padding?: number\n  multiline?: boolean\n  isView?: boolean\n}\n\nexport function Highlighter({\n  children,\n  action = 'highlight',\n  color = '#ffd1dc',\n  strokeWidth = 1.5,\n  animationDuration = 600,\n  iterations = 2,\n  padding = 2,\n  multiline = true,\n  isView = false,\n}: HighlighterProps) {\n  const elementRef = useRef<HTMLSpanElement>(null)\n  const annotationRef = useRef<RoughAnnotation | null>(null)\n\n  const isInView = useInView(elementRef, {\n    once: true,\n    margin: '-10%',\n  })\n\n  // If isView is false, always show. If isView is true, wait for inView\n  const shouldShow = !isView || isInView\n\n  useEffect(() => {\n    if (!shouldShow) return\n\n    const element = elementRef.current\n    if (!element) return\n\n    const annotationConfig = {\n      type: action,\n      color,\n      strokeWidth,\n      animationDuration,\n      iterations,\n      padding,\n      multiline,\n    }\n\n    const annotation = annotate(element, annotationConfig)\n\n    annotationRef.current = annotation\n    annotationRef.current.show()\n\n    const resizeObserver = new ResizeObserver(() => {\n      annotation.hide()\n      annotation.show()\n    })\n\n    resizeObserver.observe(element)\n    resizeObserver.observe(document.body)\n\n    return () => {\n      if (element) {\n        annotate(element, { type: action }).remove()\n        resizeObserver.disconnect()\n      }\n    }\n  }, [\n    shouldShow,\n    action,\n    color,\n    strokeWidth,\n    animationDuration,\n    iterations,\n    padding,\n    multiline,\n  ])\n\n  return (\n    <span ref={elementRef} className=\"relative inline-block bg-transparent\">\n      {children}\n    </span>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/hover-card/HoverCard.tsx",
    "content": "'use client'\n\nimport { HoverCard as HoverCardPrimitive } from 'radix-ui'\nimport * as React from 'react'\n\nimport { clsxm } from '~/lib/cn'\n\nimport { RootPortal } from '../portal'\n\ntype HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root>\n\ntype HoverCardTriggerProps = React.ComponentProps<\n  typeof HoverCardPrimitive.Trigger\n>\n\ntype HoverCardContentProps = React.ComponentProps<\n  typeof HoverCardPrimitive.Content\n>\n\nconst HoverCard = HoverCardPrimitive.Root\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger\n\nconst HoverCardContent = ({\n  ref,\n  className,\n  align = 'center',\n  sideOffset = 8,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {\n  ref?: React.RefObject<React.ElementRef<\n    typeof HoverCardPrimitive.Content\n  > | null>\n}) => (\n  <RootPortal>\n    <HoverCardPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={clsxm(\n        'bg-material-high/95 backdrop-blur-background text-text border-border z-[60] w-[320px] max-w-[calc(100vw-2rem)] rounded-[12px] border p-4 shadow-lg',\n        '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]:zoom-out-95 data-[state=open]:zoom-in-95',\n        'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2',\n        className,\n      )}\n      {...props}\n    />\n  </RootPortal>\n)\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName\n\nconst HoverCardArrow = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Arrow> & {\n  ref?: React.RefObject<React.ElementRef<\n    typeof HoverCardPrimitive.Arrow\n  > | null>\n}) => (\n  <HoverCardPrimitive.Arrow\n    ref={ref}\n    className={clsxm('fill-border/80 text-border/80', className)}\n    {...props}\n  />\n)\nHoverCardArrow.displayName = HoverCardPrimitive.Arrow.displayName\n\nexport {\n  HoverCard,\n  HoverCardArrow,\n  HoverCardContent,\n  type HoverCardContentProps,\n  type HoverCardProps,\n  HoverCardTrigger,\n  type HoverCardTriggerProps,\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/hover-card/index.ts",
    "content": "export {\n  HoverCard,\n  HoverCardArrow,\n  HoverCardContent,\n  type HoverCardContentProps,\n  type HoverCardProps,\n  HoverCardTrigger,\n  type HoverCardTriggerProps,\n} from './HoverCard'\n"
  },
  {
    "path": "apps/landing/src/components/ui/input/Input.tsx",
    "content": "'use client'\n\n// Tremor Input [v2.0.0]\nimport * as React from 'react'\nimport type { VariantProps } from 'tailwind-variants'\nimport { tv } from 'tailwind-variants'\n\nimport { useInputComposition } from '~/hooks/common/use-input-composition'\nimport { clsxm, focusInput, focusRing, hasErrorInput } from '~/lib/cn'\n\nconst inputStyles = tv({\n  base: [\n    // base\n    'relative block w-full appearance-none border shadow-xs outline-hidden transition rounded-full',\n    // electron\n    'no-drag-region',\n    // border color\n    'border-border',\n    // text color\n    'text-text',\n    // placeholder color\n    'placeholder:text-placeholder-text',\n    // background color (slightly translucent for glass feel)\n    'bg-background/80 backdrop-blur',\n    // disabled\n    'disabled:border-border disabled:bg-disabled-control disabled:text-disabled-text',\n\n    // file\n    [\n      'file:cursor-pointer file:rounded-l-[999px] file:rounded-r-none file:border-0 file:px-3 file:outline-hidden focus:outline-hidden disabled:pointer-events-none file:disabled:pointer-events-none',\n      'file:border-solid file:border-border file:bg-fill file:text-placeholder-text file:hover:bg-fill-secondary',\n      'file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem]',\n      'file:disabled:bg-disabled-control file:disabled:text-disabled-text',\n    ],\n    // focus\n    focusInput,\n    // invalid (optional)\n    'aria-invalid:ring-2 aria-invalid:ring-red/20 aria-invalid:border-red invalid:ring-2 invalid:ring-red/20 invalid:border-red',\n    // remove search cancel button (optional)\n    '[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden',\n  ],\n  variants: {\n    hasError: {\n      true: hasErrorInput,\n    },\n    // number input\n    enableStepper: {\n      false:\n        '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',\n    },\n    size: {\n      sm: [\n        'px-4 py-1.5 text-sm rounded-full',\n        'file:-my-1.5 file:-ml-4 file:py-1.5',\n      ],\n      md: [\n        'px-3 text-sm rounded-full h-10',\n        'file:-my-2 file:-ml-2.5 file:py-2',\n      ],\n    },\n  },\n  defaultVariants: {\n    size: 'md',\n  },\n})\n\ninterface InputProps\n  extends\n    Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,\n    VariantProps<typeof inputStyles> {\n  inputClassName?: string\n  /**\n   * Optional node to render at the end (right side) of the input.\n   * Useful for inline actions like Save/Clear.\n   */\n  endAdornment?: React.ReactNode\n  /**\n   * Visibility strategy for endAdornment.\n   * - 'focus': show only when input is focused (default)\n   * - 'always': always visible\n   */\n  endAdornmentVisibility?: 'focus' | 'always'\n}\n\nconst Input = ({\n  ref: forwardedRef,\n  className,\n  inputClassName,\n  hasError,\n  enableStepper = true,\n  size = 'md',\n  type,\n  endAdornment,\n  endAdornmentVisibility = 'focus',\n  onFocus,\n  onBlur,\n  disabled,\n  style,\n  ...props\n}: InputProps & { ref?: React.RefObject<HTMLInputElement | null> }) => {\n  const [typeState, setTypeState] = React.useState(type)\n  const [focused, setFocused] = React.useState(false)\n\n  const isPassword = type === 'password'\n  const isSearch = type === 'search'\n  const inputProps = useInputComposition(props)\n\n  const showEndAdornment =\n    Boolean(endAdornment) &&\n    !disabled &&\n    (endAdornmentVisibility === 'always' || focused)\n\n  const rightControlsRef = React.useRef<HTMLDivElement | null>(null)\n  const [rightControlsWidth, setRightControlsWidth] = React.useState(0)\n\n  React.useEffect(() => {\n    if (!(isPassword || showEndAdornment)) {\n      setRightControlsWidth(0)\n    }\n  }, [isPassword, showEndAdornment])\n\n  React.useLayoutEffect(() => {\n    if (!rightControlsRef.current) return\n\n    const node = rightControlsRef.current\n\n    const updateWidth = () => {\n      const { width } = node.getBoundingClientRect()\n      setRightControlsWidth(width)\n    }\n\n    updateWidth()\n    const observer = new ResizeObserver(updateWidth)\n    observer.observe(node)\n\n    return () => {\n      observer.disconnect()\n    }\n  }, [isPassword, showEndAdornment])\n\n  const computedStyle = React.useMemo(() => {\n    if (rightControlsWidth > 0) {\n      const padding = rightControlsWidth + 8\n      const paddingValue = `${padding}px`\n      if (\n        style &&\n        Object.prototype.hasOwnProperty.call(style, 'paddingRight')\n      ) {\n        return style\n      }\n      return { ...style, paddingRight: paddingValue }\n    }\n    return style\n  }, [style, rightControlsWidth])\n\n  return (\n    <div className={clsxm('relative w-full', className)} tremor-id=\"tremor-raw\">\n      <input\n        ref={forwardedRef}\n        type={isPassword ? typeState : type}\n        className={clsxm(\n          inputStyles({ hasError, enableStepper, size }),\n          {\n            'pl-8': isSearch,\n          },\n          inputClassName,\n        )}\n        disabled={disabled}\n        style={computedStyle}\n        {...props}\n        onFocus={(e) => {\n          setFocused(true)\n          onFocus?.(e)\n        }}\n        onBlur={(e) => {\n          setFocused(false)\n          onBlur?.(e)\n        }}\n        {...inputProps}\n      />\n      {isSearch && (\n        <div\n          className={clsxm(\n            // base\n            'pointer-events-none absolute bottom-0 left-2 flex h-full items-center justify-center',\n            // text color\n            'text-placeholder-text',\n          )}\n        >\n          <i\n            className=\"i-mingcute-search-line size-[1.125rem] shrink-0\"\n            aria-hidden=\"true\"\n          />\n        </div>\n      )}\n\n      {(isPassword || showEndAdornment) && (\n        <div\n          className={clsxm(\n            'absolute inset-y-0 right-0 flex items-center gap-1 px-2',\n          )}\n          ref={rightControlsRef}\n        >\n          {/* Inline actions / custom adornment */}\n          {showEndAdornment ? (\n            <div className=\"pointer-events-auto flex items-center gap-1\">\n              {endAdornment}\n            </div>\n          ) : null}\n\n          {/* Password visibility toggle */}\n          {isPassword && (\n            <button\n              aria-label=\"Change password visibility\"\n              className={clsxm(\n                // base\n                'flex h-full w-fit items-center rounded-xs outline-hidden transition-all',\n                // text\n                'text-placeholder-text',\n                // hover\n                'hover:text-text',\n                focusRing,\n              )}\n              type=\"button\"\n              onClick={() => {\n                setTypeState(typeState === 'password' ? 'text' : 'password')\n              }}\n            >\n              <span className=\"sr-only\">\n                {typeState === 'password' ? 'Show password' : 'Hide password'}\n              </span>\n              {typeState === 'password' ? (\n                <i\n                  className=\"i-mingcute-eye-line size-5 shrink-0\"\n                  aria-hidden=\"true\"\n                />\n              ) : (\n                <i\n                  className=\"i-mingcute-eye-close-line size-5 shrink-0\"\n                  aria-hidden=\"true\"\n                />\n              )}\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n\nInput.displayName = 'Input'\n\nexport { Input, type InputProps }\n"
  },
  {
    "path": "apps/landing/src/components/ui/input/TextArea.tsx",
    "content": "'use client'\n\n// Tremor Textarea [v1.0.0]\nimport * as React from 'react'\n\nimport { useInputComposition } from '~/hooks/common/use-input-composition'\nimport { cx, focusInput, hasErrorInput } from '~/lib/cn'\n\ninterface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  hasError?: boolean\n}\n\nconst Textarea = ({\n  ref: forwardedRef,\n  className,\n  hasError,\n  ...props\n}: TextareaProps & { ref?: React.RefObject<HTMLTextAreaElement | null> }) => {\n  const inputProps = useInputComposition<HTMLTextAreaElement>(props)\n  return (\n    <textarea\n      ref={forwardedRef}\n      className={cx(\n        // base\n        'flex min-h-[4rem] w-full rounded-md border px-3 py-1.5 shadow-xs outline-hidden transition-colors sm:text-sm',\n        // text color\n        'text-text',\n        // border color\n        'border-border',\n        // background color\n        'bg-background',\n        // placeholder color\n        'placeholder:text-placeholder-text',\n        // disabled\n        'disabled:border-border disabled:bg-disabled-control disabled:text-disabled-text',\n        // focus\n        focusInput,\n        // error\n        hasError ? hasErrorInput : '',\n        // invalid (optional)\n        // \"dark:aria-invalid:ring-red-400/20 aria-invalid:ring-2 aria-invalid:ring-red-200 aria-invalid:border-red-500 invalid:ring-2 invalid:ring-red-200 invalid:border-red-500\"\n        className,\n      )}\n      tremor-id=\"tremor-raw\"\n      {...props}\n      {...inputProps}\n    />\n  )\n}\n\nTextarea.displayName = 'Textarea'\n\nexport { Textarea, type TextareaProps }\n"
  },
  {
    "path": "apps/landing/src/components/ui/input/index.ts",
    "content": "export * from './Input'\n"
  },
  {
    "path": "apps/landing/src/components/ui/json-highlighter/index.tsx",
    "content": "import * as React from 'react'\n\nimport { cn } from '~/lib/cn'\n\nexport interface JsonHighlighterProps {\n  /** JSON string to highlight */\n  json: string\n  /** Additional CSS class name */\n  className?: string\n  /** Whether to show indentation */\n  showIndentation?: boolean\n  /** Whether to show line numbers */\n  showLineNumbers?: boolean\n  /** Maximum height before scrolling */\n  maxHeight?: string\n}\n\n/**\n * A lightweight JSON syntax highlighter component that uses regex matching\n * and TailwindCSS for styling without external highlighting libraries.\n */\nexport const JsonHighlighter = ({\n  ref,\n  json,\n  className,\n  showIndentation = true,\n  showLineNumbers = false,\n  maxHeight,\n  ...props\n}: JsonHighlighterProps & { ref?: React.RefObject<HTMLPreElement | null> }) => {\n  const highlightedJson = React.useMemo(() => {\n    try {\n      // Try to parse and format the JSON first\n      const parsed = JSON.parse(json)\n      const formatted = JSON.stringify(parsed, null, showIndentation ? 2 : 0)\n      return highlightJson(formatted)\n    } catch {\n      // If parsing fails, highlight the raw string\n      return highlightJson(json)\n    }\n  }, [json, showIndentation])\n\n  const lines = React.useMemo(() => {\n    return highlightedJson.split('\\n')\n  }, [highlightedJson])\n\n  return (\n    <pre\n      ref={ref}\n      className={cn(\n        'bg-material-ultra-thin text-text overflow-auto rounded-md border p-4 text-sm',\n        'font-mono leading-relaxed',\n        className,\n      )}\n      style={{ maxHeight }}\n      {...props}\n    >\n      <code className=\"block\">\n        {showLineNumbers ? (\n          <div className=\"flex\">\n            <div className=\"text-text-tertiary border-fill mr-4 select-none border-r pr-4\">\n              {lines.map((_, index) => (\n                <div key={index} className=\"text-right\">\n                  {index + 1}\n                </div>\n              ))}\n            </div>\n            <div className=\"flex-1\">\n              {lines.map((line, index) => (\n                <div key={index} dangerouslySetInnerHTML={{ __html: line }} />\n              ))}\n            </div>\n          </div>\n        ) : (\n          lines.map((line, index) => (\n            <div key={index} dangerouslySetInnerHTML={{ __html: line }} />\n          ))\n        )}\n      </code>\n    </pre>\n  )\n}\n\nJsonHighlighter.displayName = 'JsonHighlighter'\n\n/**\n * Token types for JSON highlighting\n */\ninterface Token {\n  type:\n    | 'key'\n    | 'string'\n    | 'number'\n    | 'boolean'\n    | 'null'\n    | 'punctuation'\n    | 'whitespace'\n  value: string\n  start: number\n  end: number\n}\n\n/**\n * Highlights JSON syntax using precise tokenization and UIKit colors\n */\nfunction highlightJson(jsonString: string): string {\n  // Escape HTML entities first\n  const escaped = jsonString\n    ?.replaceAll('&', '&amp;')\n    .replaceAll('<', '&lt;')\n    .replaceAll('>', '&gt;')\n\n  const tokens = tokenizeJson(escaped)\n  return renderTokens(tokens, escaped)\n}\n\n/**\n * Tokenizes JSON string into semantic tokens\n */\nfunction tokenizeJson(json: string): Token[] {\n  const tokens: Token[] = []\n  let i = 0\n\n  // Track context to distinguish keys from string values\n  const contextStack: ('object' | 'array')[] = []\n  let expectingKey = false\n\n  while (i < json.length) {\n    const char = json[i]!\n\n    // Skip whitespace but track it for proper rendering\n    if (/\\s/.test(char)) {\n      const start = i\n      while (i < json.length && /\\s/.test(json[i]!)) {\n        i++\n      }\n      tokens.push({\n        type: 'whitespace',\n        value: json.slice(start, i),\n        start,\n        end: i,\n      })\n      continue\n    }\n\n    // Handle strings (keys and values)\n    if (char === '\"') {\n      const start = i\n      i++ // Skip opening quote\n      let value = '\"'\n\n      // Parse string content, handling escapes\n      while (i < json.length) {\n        const current = json[i]\n        value += current\n\n        if (current === '\"') {\n          i++\n          break\n        }\n\n        // Handle escape sequences\n        if (current === '\\\\' && i + 1 < json.length) {\n          i++\n          value += json[i]\n        }\n        i++\n      }\n\n      // Determine if this is a key or string value\n      const isKey =\n        expectingKey ||\n        (contextStack.at(-1) === 'object' && isFollowedByColon(json, i))\n\n      tokens.push({\n        type: isKey ? 'key' : 'string',\n        value,\n        start,\n        end: i,\n      })\n\n      if (isKey) {\n        expectingKey = false\n      }\n      continue\n    }\n\n    // Handle numbers\n    if (/[-\\d]/.test(char)) {\n      const start = i\n      let value = ''\n\n      // Handle negative sign\n      if (char === '-') {\n        value += char\n        i++\n      }\n\n      // Parse integer part\n      if (i < json.length && /\\d/.test(json[i]!)) {\n        while (i < json.length && /\\d/.test(json[i]!)) {\n          value += json[i]!\n          i++\n        }\n\n        // Parse decimal part\n        if (i < json.length && json[i] === '.') {\n          value += json[i]!\n          i++\n          while (i < json.length && /\\d/.test(json[i]!)) {\n            value += json[i]!\n            i++\n          }\n        }\n\n        // Parse exponent part\n        if (i < json.length && /e/i.test(json[i]!)) {\n          value += json[i]!\n          i++\n          if (i < json.length && /[+-]/.test(json[i]!)) {\n            value += json[i]!\n            i++\n          }\n          while (i < json.length && /\\d/.test(json[i]!)) {\n            value += json[i]!\n            i++\n          }\n        }\n\n        tokens.push({\n          type: 'number',\n          value,\n          start,\n          end: i,\n        })\n        continue\n      } else {\n        // Not a valid number, treat as punctuation\n        tokens.push({\n          type: 'punctuation',\n          value: char,\n          start,\n          end: i + 1,\n        })\n        i++\n        continue\n      }\n    }\n\n    // Handle boolean and null literals\n    if (/[tfn]/.test(char)) {\n      const start = i\n\n      // Check for 'true'\n      if (json.slice(i, i + 4) === 'true') {\n        tokens.push({\n          type: 'boolean',\n          value: 'true',\n          start,\n          end: i + 4,\n        })\n        i += 4\n        continue\n      }\n\n      // Check for 'false'\n      if (json.slice(i, i + 5) === 'false') {\n        tokens.push({\n          type: 'boolean',\n          value: 'false',\n          start,\n          end: i + 5,\n        })\n        i += 5\n        continue\n      }\n\n      // Check for 'null'\n      if (json.slice(i, i + 4) === 'null') {\n        tokens.push({\n          type: 'null',\n          value: 'null',\n          start,\n          end: i + 4,\n        })\n        i += 4\n        continue\n      }\n    }\n\n    // Handle punctuation\n    if (/[{}[\\],:]/.test(char)) {\n      // Update context stack\n      switch (char) {\n        case '{': {\n          contextStack.push('object')\n          expectingKey = true\n\n          break\n        }\n        case '[': {\n          contextStack.push('array')\n\n          break\n        }\n        case '}':\n        case ']': {\n          contextStack.pop()\n          expectingKey = contextStack.at(-1) === 'object'\n\n          break\n        }\n        case ',': {\n          expectingKey = contextStack.at(-1) === 'object'\n\n          break\n        }\n        case ':': {\n          expectingKey = false\n\n          break\n        }\n        // No default\n      }\n\n      tokens.push({\n        type: 'punctuation',\n        value: char,\n        start: i,\n        end: i + 1,\n      })\n      i++\n      continue\n    }\n\n    // Unknown character, skip\n    i++\n  }\n\n  return tokens\n}\n\n/**\n * Checks if a string token is followed by a colon (indicating it's a key)\n */\nfunction isFollowedByColon(json: string, startIndex: number): boolean {\n  let i = startIndex\n\n  // Skip whitespace\n  while (i < json.length && /\\s/.test(json[i]!)) {\n    i++\n  }\n\n  return i < json.length && json[i] === ':'\n}\n\n/**\n * Renders tokens with appropriate UIKit colors\n */\nfunction renderTokens(tokens: Token[], originalJson: string): string {\n  let result = ''\n  let lastEnd = 0\n\n  for (const token of tokens) {\n    // Add any characters between tokens (shouldn't happen with proper tokenization)\n    if (token.start > lastEnd) {\n      result += originalJson.slice(lastEnd, token.start)\n    }\n\n    // Apply semantic coloring with enhanced Tailwind colors\n    switch (token.type) {\n      case 'key': {\n        result += `<span class=\"text-sky-600 dark:text-sky-400 font-semibold\">${token.value}</span>`\n        break\n      }\n      case 'string': {\n        result += `<span class=\"text-emerald-600 dark:text-emerald-400\">${token.value}</span>`\n        break\n      }\n      case 'number': {\n        result += `<span class=\"text-amber-600 dark:text-amber-400\">${token.value}</span>`\n        break\n      }\n      case 'boolean': {\n        result += `<span class=\"text-violet-600 dark:text-violet-400 font-medium\">${token.value}</span>`\n        break\n      }\n      case 'null': {\n        result += `<span class=\"text-slate-500 dark:text-slate-400 italic\">${token.value}</span>`\n        break\n      }\n      case 'punctuation': {\n        // Use different colors for different punctuation types\n        if (token.value === ':') {\n          result += `<span class=\"text-slate-600 dark:text-slate-300\">${token.value}</span>`\n        } else if (/[{}[\\]]/.test(token.value)) {\n          result += `<span class=\"text-indigo-600 dark:text-indigo-400 font-semibold\">${token.value}</span>`\n        } else {\n          result += `<span class=\"text-slate-500 dark:text-slate-400\">${token.value}</span>`\n        }\n        break\n      }\n      case 'whitespace': {\n        result += token.value\n        break\n      }\n      default: {\n        result += token.value\n        break\n      }\n    }\n\n    lastEnd = token.end\n  }\n\n  // Add any remaining characters\n  if (lastEnd < originalJson.length) {\n    result += originalJson.slice(lastEnd)\n  }\n\n  return result\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/label/Label.tsx",
    "content": "// Tremor Label [v0.0.2]\n\nimport { Label as LabelPrimitives } from 'radix-ui'\nimport * as React from 'react'\nimport { tv } from 'tailwind-variants'\n\ninterface LabelProps extends React.ComponentPropsWithoutRef<\n  typeof LabelPrimitives.Root\n> {\n  disabled?: boolean\n\n  variant?: 'form' | 'default'\n}\n\nconst styles = tv({\n  base: 'text-sm leading-none text-text',\n  variants: {\n    variant: {\n      form: 'text-sm leading-none text-text pl-2.5 pb-1 block',\n      default: 'text-sm leading-none text-text',\n    },\n    disabled: {\n      true: 'text-disabled-text',\n    },\n  },\n})\n\nconst Label = ({\n  ref: forwardedRef,\n  className,\n  disabled,\n  variant = 'default',\n  ...props\n}: LabelProps & {\n  ref?: React.RefObject<React.ElementRef<typeof LabelPrimitives.Root> | null>\n}) => (\n  <LabelPrimitives.Root\n    ref={forwardedRef}\n    className={styles({ variant, disabled, className })}\n    aria-disabled={disabled}\n    tremor-id=\"tremor-raw\"\n    {...props}\n  />\n)\n\nLabel.displayName = 'Label'\n\nexport { Label }\n"
  },
  {
    "path": "apps/landing/src/components/ui/light-rays.tsx",
    "content": "'use client'\nimport { m as motion } from 'motion/react'\nimport type { CSSProperties } from 'react'\nimport { useEffect, useState } from 'react'\n\nimport { cn } from '~/lib/cn'\n\ninterface LightRaysProps extends React.HTMLAttributes<HTMLDivElement> {\n  ref?: React.Ref<HTMLDivElement>\n  count?: number\n  color?: string\n  blur?: number\n  speed?: number\n  length?: string\n}\n\ntype LightRay = {\n  id: string\n  left: number\n  rotate: number\n  width: number\n  swing: number\n  delay: number\n  duration: number\n  intensity: number\n}\n\nconst createRays = (count: number, cycle: number): LightRay[] => {\n  if (count <= 0) return []\n\n  return Array.from({ length: count }, (_, index) => {\n    const left = 8 + Math.random() * 84\n    const rotate = -28 + Math.random() * 56\n    const width = 160 + Math.random() * 160\n    const swing = 0.8 + Math.random() * 1.8\n    const delay = Math.random() * cycle\n    const duration = cycle * (0.75 + Math.random() * 0.5)\n    const intensity = 0.6 + Math.random() * 0.5\n\n    return {\n      id: `${index}-${Math.round(left * 10)}`,\n      left,\n      rotate,\n      width,\n      swing,\n      delay,\n      duration,\n      intensity,\n    }\n  })\n}\n\nconst Ray = ({\n  left,\n  rotate,\n  width,\n  swing,\n  delay,\n  duration,\n  intensity,\n}: LightRay) => {\n  return (\n    <motion.div\n      className=\"pointer-events-none absolute -top-[12%] left-[var(--ray-left)] h-[var(--light-rays-length)] w-[var(--ray-width)] origin-top -translate-x-1/2 rounded-full bg-gradient-to-b from-[color-mix(in_srgb,var(--light-rays-color)_70%,transparent)] to-transparent opacity-0 mix-blend-screen blur-[var(--light-rays-blur)]\"\n      style={\n        {\n          '--ray-left': `${left}%`,\n          '--ray-width': `${width}px`,\n        } as CSSProperties\n      }\n      initial={{ rotate }}\n      animate={{\n        opacity: [0, intensity, 0],\n        rotate: [rotate - swing, rotate + swing, rotate - swing],\n      }}\n      transition={{\n        duration,\n        repeat: Infinity,\n        ease: 'easeInOut',\n        delay,\n        repeatDelay: duration * 0.1,\n      }}\n    />\n  )\n}\n\nexport function LightRays({\n  className,\n  style,\n  count = 7,\n  color = 'rgba(160, 210, 255, 0.2)',\n  blur = 36,\n  speed = 14,\n  length = '70vh',\n  ref,\n  ...props\n}: LightRaysProps) {\n  const [rays, setRays] = useState<LightRay[]>([])\n  const cycleDuration = Math.max(speed, 0.1)\n\n  useEffect(() => {\n    setRays(createRays(count, cycleDuration))\n  }, [count, cycleDuration])\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        'pointer-events-none absolute inset-0 isolate overflow-hidden rounded-[inherit]',\n        className,\n      )}\n      style={\n        {\n          '--light-rays-color': color,\n          '--light-rays-blur': `${blur}px`,\n          '--light-rays-length': length,\n          ...style,\n        } as CSSProperties\n      }\n      {...props}\n    >\n      <div className=\"absolute inset-0 overflow-hidden\">\n        <div\n          aria-hidden\n          className=\"absolute inset-0 opacity-60\"\n          style={\n            {\n              background:\n                'radial-gradient(circle at 20% 15%, color-mix(in srgb, var(--light-rays-color) 45%, transparent), transparent 70%)',\n            } as CSSProperties\n          }\n        />\n        <div\n          aria-hidden\n          className=\"absolute inset-0 opacity-60\"\n          style={\n            {\n              background:\n                'radial-gradient(circle at 80% 10%, color-mix(in srgb, var(--light-rays-color) 35%, transparent), transparent 75%)',\n            } as CSSProperties\n          }\n        />\n        {rays.map((ray) => (\n          <Ray key={ray.id} {...ray} />\n        ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/loading/index.tsx",
    "content": "import * as React from 'react'\n\nimport { clsxm } from '~/lib/helper'\n\nexport type LoadingProps = {\n  loadingText?: string\n  useDefaultLoadingText?: boolean\n}\n\nconst defaultLoadingText = '别着急，坐和放宽'\nexport const Loading: Component<LoadingProps> = ({\n  loadingText,\n  className,\n  useDefaultLoadingText = false,\n}) => {\n  const nextLoadingText = useDefaultLoadingText\n    ? defaultLoadingText\n    : loadingText\n  return (\n    <div\n      data-hide-print\n      className={clsxm('center flex my-20 flex-col', className)}\n    >\n      <span className=\"loading loading-ball loading-lg\" />\n      {!!nextLoadingText && (\n        <span className=\"mt-6 block\">{nextLoadingText}</span>\n      )}\n    </div>\n  )\n}\n\nexport const FullPageLoading = () => (\n  <Loading useDefaultLoadingText className=\"h-[calc(100vh-6.5rem-10rem)]\" />\n)\n"
  },
  {
    "path": "apps/landing/src/components/ui/magic-card.tsx",
    "content": "'use client'\nimport { m, useMotionTemplate, useMotionValue } from 'motion/react'\nimport * as React from 'react'\nimport { useCallback, useEffect } from 'react'\n\nimport { cn } from '~/lib/cn'\n\ninterface MagicCardProps {\n  children?: React.ReactNode\n  className?: string\n  gradientSize?: number\n  gradientColor?: string\n  gradientOpacity?: number\n  gradientFrom?: string\n  gradientTo?: string\n}\n\nexport function MagicCard({\n  children,\n  className,\n  gradientSize = 200,\n  gradientColor = '#262626',\n  gradientOpacity = 0.8,\n  gradientFrom = '#9E7AFF',\n  gradientTo = '#FE8BBB',\n}: MagicCardProps) {\n  const mouseX = useMotionValue(-gradientSize)\n  const mouseY = useMotionValue(-gradientSize)\n  const reset = useCallback(() => {\n    mouseX.set(-gradientSize)\n    mouseY.set(-gradientSize)\n  }, [gradientSize, mouseX, mouseY])\n\n  const handlePointerMove = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      const rect = e.currentTarget.getBoundingClientRect()\n      mouseX.set(e.clientX - rect.left)\n      mouseY.set(e.clientY - rect.top)\n    },\n    [mouseX, mouseY],\n  )\n\n  useEffect(() => {\n    reset()\n  }, [reset])\n\n  useEffect(() => {\n    const handleGlobalPointerOut = (e: PointerEvent) => {\n      if (!e.relatedTarget) {\n        reset()\n      }\n    }\n\n    const handleVisibility = () => {\n      if (document.visibilityState !== 'visible') {\n        reset()\n      }\n    }\n\n    window.addEventListener('pointerout', handleGlobalPointerOut)\n    window.addEventListener('blur', reset)\n    document.addEventListener('visibilitychange', handleVisibility)\n\n    return () => {\n      window.removeEventListener('pointerout', handleGlobalPointerOut)\n      window.removeEventListener('blur', reset)\n      document.removeEventListener('visibilitychange', handleVisibility)\n    }\n  }, [reset])\n\n  return (\n    <div\n      className={cn('group relative rounded-[inherit]', className)}\n      onPointerMove={handlePointerMove}\n      onPointerLeave={reset}\n      onPointerEnter={reset}\n    >\n      <m.div\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100\"\n        style={{\n          background: useMotionTemplate`\n          radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,\n          ${gradientFrom}, \n          ${gradientTo}, \n          var(--border) 100%\n          )\n          `,\n        }}\n      />\n      <div className=\"absolute inset-px rounded-[inherit] bg-background\" />\n      <m.div\n        className=\"pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100\"\n        style={{\n          background: useMotionTemplate`\n            radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)\n          `,\n          opacity: gradientOpacity,\n        }}\n      />\n      <div className=\"relative h-full\">{children}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/markdown/index.tsx",
    "content": "import fs from 'node:fs'\n\nimport { join } from 'pathe'\nimport rehypeStringify from 'rehype-stringify'\nimport remarkParse from 'remark-parse'\nimport remarkRehype from 'remark-rehype'\nimport { unified } from 'unified'\n\ninterface MarkdownContentProps {\n  content: string\n}\n\nexport function MarkdownContent({ content }: MarkdownContentProps) {\n  return (\n    <div className=\"container\">\n      <div className=\"mx-auto max-w-4xl\">\n        {/* Content */}\n        <div className=\"prose prose-lg prose-gray dark:prose-invert prose-headings:font-semibold prose-headings:text-gray-900 dark:prose-headings:text-gray-100 prose-p:text-gray-700 dark:prose-p:text-gray-300 prose-a:text-orange-600 dark:prose-a:text-orange-400 prose-strong:text-gray-900 dark:prose-strong:text-gray-100 prose-code:text-orange-600 dark:prose-code:text-orange-400 prose-pre:bg-gray-100 dark:prose-pre:bg-gray-800 prose-blockquote:border-orange-300 dark:prose-blockquote:border-orange-600 prose-li:text-gray-700 dark:prose-li:text-gray-300 max-w-none\">\n          <div\n            className=\"leading-relaxed\"\n            dangerouslySetInnerHTML={{ __html: content }}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\n// Utility function to read and process markdown files\nexport async function getMarkdownContent(filePath: string) {\n  try {\n    const fullPath = join(process.cwd(), 'src', filePath)\n    const fileContents = fs.readFileSync(fullPath, 'utf8')\n\n    // Process markdown to HTML using unified\n    const processedContent = await unified()\n      .use(remarkParse)\n      .use(remarkRehype, { allowDangerousHtml: true })\n      .use(rehypeStringify, { allowDangerousHtml: true })\n      .process(fileContents)\n\n    return {\n      content: processedContent.toString(),\n    }\n  } catch (error) {\n    console.error('Error reading markdown file:', error)\n    return {\n      content: '<p>Error loading content. Please try again later.</p>',\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/modal/ModalContainer.tsx",
    "content": "'use client'\n\nimport { useAtomValue } from 'jotai'\nimport { AnimatePresence, useDragControls } from 'motion/react'\nimport type { PointerEventHandler } from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useEventCallback } from 'usehooks-ts'\n\nimport { Dialog, DialogContent } from '~/components/ui/dialog'\nimport { cn } from '~/lib/cn'\nimport { Spring } from '~/lib/spring'\nimport { jotaiStore } from '~/lib/store'\n\nimport { ModalContext } from './hooks'\nimport type { ModalItem } from './ModalManager'\nimport { Modal, modalItemsAtom } from './ModalManager'\nimport type { ModalComponent } from './types'\n\nexport const ModalContainer = () => {\n  const items = useAtomValue(modalItemsAtom)\n\n  return (\n    <div id=\"global-modal-container\">\n      <AnimatePresence initial={false}>\n        {items.map((item) => (\n          <ModalWrapper key={item.id} item={item} />\n        ))}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nconst ModalWrapper = ({ item }: { item: ModalItem }) => {\n  const [open, setOpen] = useState(true)\n  const modalRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    Modal.__registerCloser(item.id, () => setOpen(false))\n    return () => {\n      Modal.__unregisterCloser(item.id)\n    }\n  }, [item.id])\n\n  const dismiss = useMemo(\n    () => () => {\n      setOpen(false)\n    },\n    [],\n  )\n\n  const handleOpenChange = (o: boolean) => {\n    setOpen(o)\n  }\n\n  // After exit animation, remove from store\n  const handleAnimationComplete = useEventCallback(() => {\n    if (!open) {\n      const items = jotaiStore.get(modalItemsAtom)\n      jotaiStore.set(\n        modalItemsAtom,\n        items.filter((m) => m.id !== item.id),\n      )\n    }\n  })\n\n  // Calculate dynamic drag constraints based on actual modal size\n  const getDragConstraints = useCallback(() => {\n    if (!modalRef.current) {\n      return {\n        left: -window.innerWidth / 2 + 200,\n        right: window.innerWidth / 2 - 200,\n        top: -window.innerHeight / 2 + 150,\n        bottom: window.innerHeight / 2 - 150,\n      }\n    }\n\n    const modalRect = modalRef.current.getBoundingClientRect()\n    const viewportWidth = window.innerWidth\n    const viewportHeight = window.innerHeight\n    const padding = 20\n\n    // Calculate constraints to keep modal within viewport bounds\n    const maxLeft = -(viewportWidth / 2 - modalRect.width / 2 - padding)\n    const maxRight = viewportWidth / 2 - modalRect.width / 2 - padding\n    const maxTop = -(viewportHeight / 2 - modalRect.height / 2 - padding)\n    const maxBottom = viewportHeight / 2 - modalRect.height / 2 - padding\n\n    return {\n      left: maxLeft,\n      right: maxRight,\n      top: maxTop,\n      bottom: maxBottom,\n    }\n  }, [])\n\n  // Handle drag start to update constraints\n  const handleDragStart = useCallback(() => {\n    // Update constraints when drag starts to ensure they're based on current modal size\n    if (modalRef.current) {\n      return getDragConstraints()\n    }\n  }, [getDragConstraints])\n\n  const Component = item.component as ModalComponent<any>\n\n  const {\n    contentProps,\n    contentClassName,\n    showCloseButton,\n    disableDrag,\n    disableOverlayClickToClose,\n    disableTransition,\n  } = Component\n\n  const contextValue = useMemo(() => ({ dismiss }), [dismiss])\n  const dragControls = useDragControls()\n\n  const handleDrag: PointerEventHandler<HTMLDivElement> = useCallback(\n    (e) => {\n      dragControls.start(e)\n    },\n    [dragControls],\n  )\n  return (\n    <Dialog open={open} onOpenChange={handleOpenChange}>\n      <DialogContent\n        ref={modalRef}\n        onInteractOutside={(e) => {\n          if (disableOverlayClickToClose) e.preventDefault()\n        }}\n        dragElastic={0}\n        dragListener={false}\n        dragMomentum={false}\n        className={cn('w-full max-w-md', contentClassName)}\n        transition={Spring.smooth(0.2, 0.1)}\n        onAnimationComplete={handleAnimationComplete}\n        drag={!disableDrag}\n        dragControls={dragControls}\n        dragConstraints={getDragConstraints()}\n        onDragStart={handleDragStart}\n        showCloseButton={showCloseButton}\n        disableOverlayClickToClose={disableOverlayClickToClose}\n        disableTransition={disableTransition}\n        {...contentProps}\n        {...item.modalContent}\n      >\n        <ModalContext value={contextValue}>\n          <div\n            className=\"absolute inset-x-0 top-0 h-6\"\n            onPointerDownCapture={handleDrag}\n          />\n          <Component\n            modalId={item.id}\n            dismiss={dismiss}\n            {...(item.props as any)}\n          />\n        </ModalContext>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/modal/ModalManager.ts",
    "content": "'use client'\n\nimport { atom } from 'jotai'\n\nimport { jotaiStore } from '~/lib/store'\n\nimport type { ModalComponent, ModalContentConfig, ModalItem } from './types'\n\nexport const modalItemsAtom = atom<ModalItem[]>([])\n\nconst modalCloseRegistry = new Map<string, () => void>()\n\nexport const Modal = {\n  present<P = unknown>(\n    Component: ModalComponent<P>,\n    props?: P,\n    modalContent?: ModalContentConfig,\n  ): string {\n    const items = jotaiStore.get(modalItemsAtom)\n\n    // Enforce single instance per ModalComponent. If an instance exists,\n    // move it to the top and update its props/content, returning its id.\n    const existingIndex = items.findIndex(\n      (m) => m.component === (Component as ModalComponent<any>),\n    )\n    if (existingIndex !== -1) {\n      const existing = items[existingIndex]\n      const updated = {\n        ...existing,\n        // Update props/content when re-invoked\n        props: (props as any) ?? existing.props,\n        modalContent: modalContent ?? existing.modalContent,\n      }\n\n      const next = items.filter((_, i) => i !== existingIndex)\n      next.push(updated)\n      jotaiStore.set(modalItemsAtom, next)\n      return existing.id\n    }\n\n    const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n    jotaiStore.set(modalItemsAtom, [\n      ...items,\n      { id, component: Component as ModalComponent<any>, props, modalContent },\n    ])\n    return id\n  },\n\n  dismiss(id: string): void {\n    const closer = modalCloseRegistry.get(id)\n    if (closer) {\n      closer()\n      return\n    }\n    // Fallback: remove immediately if closer not registered yet\n    const items = jotaiStore.get(modalItemsAtom)\n    jotaiStore.set(\n      modalItemsAtom,\n      items.filter((m) => m.id !== id),\n    )\n  },\n\n  /** Internal: used by container to manage close hooks */\n  __registerCloser(id: string, fn: () => void) {\n    modalCloseRegistry.set(id, fn)\n  },\n  __unregisterCloser(id: string) {\n    modalCloseRegistry.delete(id)\n  },\n}\n\nexport { type ModalItem } from './types'\n"
  },
  {
    "path": "apps/landing/src/components/ui/modal/hooks.ts",
    "content": "import { createContext, use } from 'react'\n\nexport const ModalContext = createContext({\n  dismiss: () => {},\n})\n\nexport const useModal = () => {\n  return use(ModalContext)\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/modal/index.ts",
    "content": "export * from '../prompts/BasePrompt'\nexport * from './ModalContainer'\nexport * from './ModalManager'\nexport * from './types'\n"
  },
  {
    "path": "apps/landing/src/components/ui/modal/types.ts",
    "content": "import type { FC } from 'react'\n\nimport type { DialogContentProps } from '../dialog'\n\nexport type ModalComponentProps = {\n  modalId: string\n  dismiss: () => void\n}\n\nexport type ModalComponent<P = unknown> = FC<ModalComponentProps & P> & {\n  contentProps?: Partial<DialogContentProps>\n  contentClassName?: string\n  showCloseButton?: boolean\n  disableDrag?: boolean\n  disableOverlayClickToClose?: boolean\n  disableTransition?: boolean\n}\n\nexport type ModalContentConfig = Partial<DialogContentProps>\n\nexport type ModalItem = {\n  id: string\n  component: ModalComponent<any>\n  props?: unknown\n  modalContent?: ModalContentConfig\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/panel/PanelSplitter.tsx",
    "content": "import * as React from 'react'\n\nimport { cn } from '~/lib/cn'\n\nexport const PanelSplitter = (\n  props: React.DetailedHTMLProps<\n    React.HTMLAttributes<HTMLDivElement>,\n    HTMLDivElement\n  > & {\n    isDragging?: boolean\n    cursor?: string\n\n    tooltip?: React.ReactNode\n  },\n) => {\n  const { isDragging, cursor, tooltip, className, ...rest } = props\n\n  React.useEffect(() => {\n    if (!isDragging) return\n    const $css = document.createElement('style')\n\n    $css.innerHTML = `\n      * {\n        cursor: ${cursor} !important;\n      }\n    `\n\n    document.head.append($css)\n    return () => {\n      $css.remove()\n    }\n  }, [cursor, isDragging])\n\n  return (\n    <div className=\"relative h-full w-0 shrink-0 z-3\" data-hide-in-print>\n      <div\n        tabIndex={-1}\n        {...rest}\n        className={cn(\n          'active:bg-accent! absolute inset-0 z-3 w-[2px] -translate-x-1/2 cursor-ew-resize bg-transparent hover:bg-gray-400 hover:dark:bg-neutral-500',\n          isDragging ? 'bg-accent' : '',\n          className,\n        )}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/portal/index.tsx",
    "content": "import type { FC, PropsWithChildren } from 'react'\nimport { createPortal } from 'react-dom'\n\nimport { useIsClient } from '~/hooks/common/use-is-client'\n\nimport { useRootPortal } from './provider'\n\nexport const RootPortal: FC<\n  {\n    to?: HTMLElement\n  } & PropsWithChildren\n> = (props) => {\n  const isClient = useIsClient()\n  const to = useRootPortal()\n  if (!isClient) {\n    return null\n  }\n\n  return createPortal(props.children, props.to || to || document.body)\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/portal/provider.tsx",
    "content": "import { createContext, use } from 'react'\n\nimport { isClientSide } from '~/lib/env'\n\nexport const useRootPortal = () => {\n  const ctx = use(RootPortalContext)\n  if (!isClientSide) {\n    return null\n  }\n  return ctx.to || document.body\n}\n\nconst RootPortalContext = createContext<{\n  to?: HTMLElement | undefined\n}>({\n  to: undefined,\n})\n\nexport const RootPortalProvider = RootPortalContext.Provider\n"
  },
  {
    "path": "apps/landing/src/components/ui/prompts/BasePrompt.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\n\nimport { Button } from '~/components/ui/button/Button'\nimport { Modal } from '~/components/ui/modal/ModalManager'\nimport type {\n  ModalComponent,\n  ModalComponentProps,\n} from '~/components/ui/modal/types'\n\nimport {\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '../dialog'\n\ntype PromptVariant = 'danger' | 'info'\n\nexport type PromptOptions = {\n  title: string\n  description?: string\n  variant?: PromptVariant\n  onConfirmText?: string\n  onCancelText?: string\n  onConfirm?: () => void | Promise<void>\n  onCancel?: () => void | Promise<void>\n  content?: React.ReactNode\n}\n\nexport const BasePrompt: ModalComponent<PromptOptions> = ({\n  modalId,\n  dismiss,\n  title,\n  description,\n  variant = 'info',\n  onConfirmText = 'Confirm',\n  onCancelText = 'Cancel',\n  onConfirm,\n  onCancel,\n  content,\n}: ModalComponentProps & PromptOptions) => {\n  const [submitting, setSubmitting] = useState(false)\n\n  const handleCancel = async () => {\n    try {\n      await onCancel?.()\n    } finally {\n      dismiss()\n    }\n  }\n\n  const handleConfirm = async () => {\n    try {\n      setSubmitting(true)\n      await onConfirm?.()\n    } finally {\n      setSubmitting(false)\n      Modal.dismiss(modalId)\n    }\n  }\n\n  return (\n    <div>\n      <DialogHeader className=\"mb-2\">\n        <DialogTitle>{title}</DialogTitle>\n        {description ? (\n          <DialogDescription className=\"text-text-secondary\">\n            {description}\n          </DialogDescription>\n        ) : null}\n      </DialogHeader>\n      {content != null ? <div className=\"mt-4\">{content}</div> : null}\n      <DialogFooter className=\"mt-4\">\n        <Button\n          size=\"sm\"\n          variant=\"secondary\"\n          onClick={handleCancel}\n          disabled={submitting}\n        >\n          {onCancelText}\n        </Button>\n        <Button\n          size=\"sm\"\n          variant={variant === 'danger' ? 'destructive' : 'primary'}\n          onClick={handleConfirm}\n          isLoading={submitting}\n          loadingText={onConfirmText}\n        >\n          {onConfirmText}\n        </Button>\n      </DialogFooter>\n    </div>\n  )\n}\n\nBasePrompt.contentClassName = 'max-w-sm'\n\nexport type { PromptVariant }\n"
  },
  {
    "path": "apps/landing/src/components/ui/prompts/InputPrompt.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\n\nimport { Button } from '~/components/ui/button/Button'\nimport { Modal } from '~/components/ui/modal/ModalManager'\nimport type {\n  ModalComponent,\n  ModalComponentProps,\n} from '~/components/ui/modal/types'\n\nimport {\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '../dialog'\nimport { Input } from '../input'\n\ntype InputPromptVariant = 'danger' | 'info'\n\nexport type InputPromptOptions = {\n  title: string\n  description?: string\n  defaultValue?: string\n  placeholder?: string\n  variant?: InputPromptVariant\n  type?: 'password' | 'text'\n  onConfirmText?: string\n  onCancelText?: string\n  onConfirm?: (value: string) => void | Promise<void>\n  onCancel?: () => void | Promise<void>\n}\n\nexport const InputPrompt: ModalComponent<InputPromptOptions> = ({\n  modalId,\n  dismiss,\n  title,\n  description,\n  defaultValue = '',\n  placeholder,\n  variant = 'info',\n  type = 'text',\n  onConfirmText = 'Confirm',\n  onCancelText = 'Cancel',\n  onConfirm,\n  onCancel,\n}: ModalComponentProps & InputPromptOptions) => {\n  const [inputValue, setInputValue] = useState(defaultValue)\n  const [submitting, setSubmitting] = useState(false)\n\n  const handleCancel = async () => {\n    try {\n      await onCancel?.()\n    } finally {\n      dismiss()\n    }\n  }\n\n  const handleConfirm = async () => {\n    try {\n      setSubmitting(true)\n      await onConfirm?.(inputValue)\n    } finally {\n      setSubmitting(false)\n      Modal.dismiss(modalId)\n    }\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault()\n      handleConfirm()\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      handleCancel()\n    }\n  }\n\n  return (\n    <div>\n      <DialogHeader>\n        <DialogTitle>{title}</DialogTitle>\n        {description ? (\n          <DialogDescription className=\"text-text-secondary\">\n            {description}\n          </DialogDescription>\n        ) : null}\n      </DialogHeader>\n      <div className=\"mt-4\">\n        <Input\n          value={inputValue}\n          onChange={(e) => setInputValue(e.target.value)}\n          placeholder={placeholder}\n          onKeyDown={handleKeyDown}\n          autoFocus\n          type={type}\n        />\n      </div>\n      <DialogFooter className=\"mt-4\">\n        <Button\n          size=\"sm\"\n          variant=\"secondary\"\n          onClick={handleCancel}\n          disabled={submitting}\n        >\n          {onCancelText}\n        </Button>\n        <Button\n          size=\"sm\"\n          variant={variant === 'danger' ? 'destructive' : 'primary'}\n          onClick={handleConfirm}\n          isLoading={submitting}\n          loadingText={onConfirmText}\n        >\n          {onConfirmText}\n        </Button>\n      </DialogFooter>\n    </div>\n  )\n}\n\nInputPrompt.contentClassName = 'max-w-sm'\n\nexport type { InputPromptVariant }\n"
  },
  {
    "path": "apps/landing/src/components/ui/prompts/Prompt.ts",
    "content": "import { Modal } from '../modal'\nimport type { PromptOptions } from './BasePrompt'\nimport { BasePrompt } from './BasePrompt'\nimport type { InputPromptOptions } from './InputPrompt'\nimport { InputPrompt } from './InputPrompt'\n\nexport const Prompt = {\n  prompt(options: PromptOptions) {\n    return Modal.present(BasePrompt, options)\n  },\n  input(options: InputPromptOptions): Promise<string | null> {\n    return new Promise((resolve) => {\n      Modal.present(InputPrompt, {\n        ...options,\n        onConfirm: async (value: string) => {\n          await options.onConfirm?.(value)\n          resolve(value)\n        },\n        onCancel: async () => {\n          await options.onCancel?.()\n          resolve(null)\n        },\n      })\n    })\n  },\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/prompts/index.ts",
    "content": "export * from './BasePrompt'\nexport * from './InputPrompt'\nexport * from './Prompt'\n"
  },
  {
    "path": "apps/landing/src/components/ui/radio/index.tsx",
    "content": "'use client'\n\n// Tremor RadioGroup [v1.0.0]\nimport type { HTMLMotionProps } from 'motion/react'\nimport { m as motion } from 'motion/react'\nimport { RadioGroup as RadioGroupPrimitives } from 'radix-ui'\nimport * as React from 'react'\n\nimport { cx, focusRing } from '~/lib/cn'\n\nconst RadioGroup = ({\n  ref: forwardedRef,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitives.Root> & {\n  ref?: React.RefObject<React.ElementRef<\n    typeof RadioGroupPrimitives.Root\n  > | null>\n}) => {\n  return (\n    <RadioGroupPrimitives.Root\n      ref={forwardedRef}\n      className={cx('grid gap-2', className)}\n      tremor-id=\"tremor-raw\"\n      {...props}\n    />\n  )\n}\n\nRadioGroup.displayName = 'RadioGroup'\n\nconst RadioGroupIndicator = ({\n  ref: forwardedRef,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitives.Indicator> & {\n  ref?: React.RefObject<React.ElementRef<\n    typeof RadioGroupPrimitives.Indicator\n  > | null>\n}) => {\n  return (\n    <RadioGroupPrimitives.Indicator\n      ref={forwardedRef}\n      className={cx('flex items-center justify-center', className)}\n      {...props}\n      asChild\n    >\n      <motion.div\n        className={cx(\n          // base\n          'size-1.5 shrink-0 rounded-full',\n          // indicator\n          'bg-white',\n          // disabled\n          'group-data-disabled:bg-disabled-control',\n        )}\n        initial={{ scale: 0, opacity: 0 }}\n        animate={{ scale: 1, opacity: 1 }}\n        exit={{ scale: 0, opacity: 0 }}\n        transition={{\n          type: 'spring',\n          stiffness: 300,\n          damping: 30,\n          duration: 0.2,\n        }}\n      />\n    </RadioGroupPrimitives.Indicator>\n  )\n}\n\nRadioGroupIndicator.displayName = 'RadioGroupIndicator'\n\nconst RadioGroupItem = ({\n  ref: forwardedRef,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitives.Item> &\n  HTMLMotionProps<'button'> & {\n    ref?: React.RefObject<React.ElementRef<\n      typeof RadioGroupPrimitives.Item\n    > | null>\n  }) => {\n  return (\n    <RadioGroupPrimitives.Item\n      ref={forwardedRef}\n      className={cx(\n        'group relative flex size-4 appearance-none items-center justify-center outline-hidden',\n        className,\n      )}\n      {...props}\n      asChild\n    >\n      <motion.button\n        whileTap={{ scale: 0.95 }}\n        whileHover={{ scale: 1.05 }}\n        transition={{\n          type: 'spring',\n          stiffness: 400,\n          damping: 25,\n        }}\n      >\n        <motion.div\n          className={cx(\n            // base\n            'flex size-4 shrink-0 items-center justify-center rounded-full border shadow-xs transition-colors duration-200',\n            // border color\n            'border-border',\n            // background color\n            'bg-background',\n            // checked\n            'group-data-[state=checked]:bg-accent group-data-[state=checked]:border-0 group-data-[state=checked]:border-transparent',\n            // disabled\n            'group-data-disabled:border',\n            'group-data-disabled:border-border group-data-disabled:bg-disabled-control group-data-disabled:text-disabled-text',\n            // focus\n            focusRing,\n          )}\n          animate={{\n            scale: props.checked ? 1.1 : 1,\n          }}\n          transition={{\n            type: 'spring',\n            stiffness: 300,\n            damping: 20,\n            duration: 0.15,\n          }}\n        >\n          <RadioGroupIndicator />\n        </motion.div>\n      </motion.button>\n    </RadioGroupPrimitives.Item>\n  )\n}\n\nRadioGroupItem.displayName = 'RadioGroupItem'\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "apps/landing/src/components/ui/relative-time/RelativeTime.tsx",
    "content": "'use client'\n\nimport dayjs from 'dayjs'\nimport type { FC } from 'react'\nimport { useEffect, useState } from 'react'\n\nimport { parseDate, relativeTimeFromNow } from '~/lib/datetime'\n\nexport const RelativeTime: FC<{\n  date: string | Date\n  displayAbsoluteTimeAfterDay?: number\n}> = (props) => {\n  const [relative, setRelative] = useState<string>(\n    relativeTimeFromNow(props.date),\n  )\n\n  const { displayAbsoluteTimeAfterDay = 29 } = props\n\n  useEffect(() => {\n    setRelative(relativeTimeFromNow(props.date))\n    let timer: any = setInterval(() => {\n      setRelative(relativeTimeFromNow(props.date))\n    }, 1000)\n\n    if (\n      Math.abs(dayjs(props.date).diff(new Date(), 'd')) >\n      displayAbsoluteTimeAfterDay\n    ) {\n      timer = clearInterval(timer)\n      // @ts-expect-error\n      setRelative(parseDate(props.date, 'YY 年 M 月 D 日'))\n    }\n    return () => {\n      timer = clearInterval(timer)\n    }\n  }, [props.date, displayAbsoluteTimeAfterDay])\n\n  return <>{relative}</>\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/relative-time/index.ts",
    "content": "export * from './RelativeTime'\n"
  },
  {
    "path": "apps/landing/src/components/ui/scroll-areas/ScrollArea.tsx",
    "content": "'use client'\n\nimport { ScrollArea as ScrollAreaBase } from 'radix-ui'\nimport * as React from 'react'\n\nimport { clsxm } from '~/lib/cn'\n\nimport { ScrollElementContext } from './ctx'\n\nconst Thumb = ({\n  ref: forwardedRef,\n  className,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Thumb> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Thumb> | null>\n}) => (\n  <ScrollAreaBase.Thumb\n    {...rest}\n    onClick={(e) => {\n      e.stopPropagation()\n      rest.onClick?.(e)\n    }}\n    ref={forwardedRef}\n    className={clsxm(\n      'relative w-full flex-1 rounded-xl backdrop-blur-3xl transition-colors duration-150',\n      'bg-zinc-500/50 hover:bg-zinc-500/70',\n      'active:bg-zinc-500/70',\n      'before:absolute before:-top-1/2 before:-left-1/2 before:h-full before:min-h-[44]',\n      'before:w-full before:min-w-[44] before:-translate-x-full before:-translate-y-full before:content-[\"\"]',\n\n      className,\n    )}\n  />\n)\nThumb.displayName = 'ScrollArea.Thumb'\n\nconst Scrollbar = ({\n  ref: forwardedRef,\n  className,\n  children,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Scrollbar> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Scrollbar> | null>\n}) => {\n  const { orientation = 'vertical' } = rest\n  return (\n    <ScrollAreaBase.Scrollbar\n      {...rest}\n      ref={forwardedRef}\n      className={clsxm(\n        'flex w-2.5 touch-none p-0.5 select-none',\n        orientation === 'horizontal'\n          ? `h-2.5 w-full flex-col`\n          : `w-2.5 flex-row`,\n        'animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in mb-1 duration-200',\n        className,\n      )}\n    >\n      {children}\n      <Thumb />\n    </ScrollAreaBase.Scrollbar>\n  )\n}\nScrollbar.displayName = 'ScrollArea.Scrollbar'\n\nconst Viewport = ({\n  ref: forwardedRef,\n  className,\n  mask: _mask,\n  focusable = true,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & {\n  mask?: boolean\n  focusable?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Viewport> | null>\n}) => {\n  const ref = React.useRef<HTMLDivElement>(null)\n\n  React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement)\n  return (\n    <ScrollAreaBase.Viewport\n      {...rest}\n      ref={ref}\n      tabIndex={focusable ? -1 : void 0}\n      className={clsxm(\n        'block size-full',\n\n        className,\n      )}\n    />\n  )\n}\nViewport.displayName = 'ScrollArea.Viewport'\n\nconst Root = ({\n  ref: forwardedRef,\n  className,\n  children,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Root> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Root> | null>\n}) => (\n  <ScrollAreaBase.Root\n    {...rest}\n    scrollHideDelay={0}\n    ref={forwardedRef}\n    className={clsxm('overflow-hidden', className)}\n  >\n    {children}\n  </ScrollAreaBase.Root>\n)\n\nRoot.displayName = 'ScrollArea.Root'\nexport const ScrollArea = ({\n  ref,\n  flex,\n  children,\n  rootClassName,\n  viewportClassName,\n  scrollbarClassName,\n  mask = false,\n  onScroll,\n  orientation = 'vertical',\n  asChild = false,\n\n  focusable = true,\n  id,\n  style,\n}: React.PropsWithChildren & {\n  rootClassName?: string\n  viewportClassName?: string\n  scrollbarClassName?: string\n  flex?: boolean\n  mask?: boolean\n  onScroll?: (e: React.UIEvent<HTMLDivElement>) => void\n  orientation?: 'vertical' | 'horizontal' | 'both'\n  asChild?: boolean\n  focusable?: boolean\n  id?: string\n  style?: React.CSSProperties\n} & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(\n    null,\n  )\n  React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)\n\n  return (\n    <ScrollElementContext value={viewportRef}>\n      <Root className={rootClassName} id={id} style={style}>\n        <Viewport\n          ref={setViewportRef}\n          className={clsxm(\n            flex ? '[&>div]:!flex [&>div]:!flex-col' : '',\n            viewportClassName,\n          )}\n          mask={mask}\n          asChild={asChild}\n          onScroll={onScroll}\n          focusable={focusable}\n        >\n          {children}\n        </Viewport>\n        {orientation === 'both' ? (\n          <>\n            <Scrollbar orientation=\"vertical\" className={scrollbarClassName} />\n            <Scrollbar\n              orientation=\"horizontal\"\n              className={scrollbarClassName}\n            />\n          </>\n        ) : (\n          <Scrollbar orientation={orientation} className={scrollbarClassName} />\n        )}\n      </Root>\n    </ScrollElementContext>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/scroll-areas/ctx.ts",
    "content": "import { createContext } from 'react'\n\nexport const ScrollElementContext = createContext<HTMLElement | null>(\n  typeof window === 'undefined' ? null : document.documentElement,\n)\n"
  },
  {
    "path": "apps/landing/src/components/ui/scroll-areas/hooks.ts",
    "content": "import { use } from 'react'\n\nimport { ScrollElementContext } from './ctx'\n\n/**\n * Get the scroll area element when in radix scroll area\n * @returns\n */\nexport const useScrollViewElement = () => use(ScrollElementContext)\n"
  },
  {
    "path": "apps/landing/src/components/ui/segment-tab/SegmentTab.tsx",
    "content": "'use client'\n\nimport clsx from 'clsx'\nimport { m } from 'motion/react'\nimport type { ReactNode, Ref } from 'react'\nimport { useId } from 'react'\n\nimport { cn } from '~/lib/cn'\n\nexport interface SegmentTabItem<T = string> {\n  value: T\n  label: ReactNode\n  icon?: ReactNode\n}\n\nexport interface SegmentTabProps<T = string> {\n  items: SegmentTabItem<T>[]\n  value: T\n  onChange: (value: T) => void\n  className?: string\n  containerClassName?: string\n  activeClassName?: string\n  inactiveClassName?: string\n  size?: 'sm' | 'md' | 'lg'\n  variant?: 'default' | 'compact'\n  distribution?: 'fill' | 'fit'\n  disabled?: boolean\n  responsiveWrap?: boolean\n  ref?: Ref<HTMLDivElement>\n}\n\nconst sizeClasses = {\n  default: {\n    // iOS-like compact heights and rounded pill look\n    sm: 'h-7 px-3 text-xs',\n    md: 'h-8 px-4 text-sm',\n    lg: 'h-10 px-5 text-base',\n  },\n  compact: {\n    sm: 'h-7 px-2.5 text-xs',\n    md: 'h-8 px-3 text-sm',\n    lg: 'h-9 px-4 text-base',\n  },\n} as const\n\nconst variantClasses = {\n  default: {\n    container:\n      'p-1 rounded-full border border-border backdrop-blur bg-material-medium/60',\n    button: 'rounded-full',\n    indicator:\n      'bg-background-secondary rounded-full border border-border/80 shadow-sm',\n  },\n  compact: {\n    container:\n      'px-1 py-0.5 rounded-full border border-border backdrop-blur bg-material-medium/60',\n    button: 'rounded-full',\n    indicator:\n      'bg-background-secondary rounded-full border border-border/80 shadow-sm',\n  },\n} as const\n\nexport function SegmentTab<T = string>({\n  items,\n  value,\n  onChange,\n  className,\n  containerClassName,\n  activeClassName,\n  inactiveClassName,\n  size = 'md',\n  variant = 'default',\n  distribution = 'fill',\n  disabled = false,\n  responsiveWrap = false,\n  ref,\n}: SegmentTabProps<T>) {\n  const id = useId()\n  const styles = variantClasses[variant]\n  const sizeClass = sizeClasses[variant][size]\n  return (\n    <div\n      ref={ref}\n      role=\"tablist\"\n      aria-disabled={disabled || undefined}\n      className={cn(\n        'container-type-[inline-size] relative flex items-center',\n        distribution === 'fill' ? 'w-full' : 'w-fit max-w-full',\n        styles.container,\n        disabled && 'pointer-events-none opacity-60',\n        containerClassName,\n      )}\n    >\n      <div\n        className={cn(\n          'relative flex items-center',\n          distribution === 'fill' ? 'w-full' : 'w-fit max-w-full',\n          responsiveWrap && 'flex-wrap',\n        )}\n      >\n        {/* 标签按钮 */}\n        {items.map((item) => {\n          const isActive = item.value === value\n\n          return (\n            <m.button\n              key={String(item.value)}\n              type=\"button\"\n              onClick={() => !disabled && onChange(item.value)}\n              className={cn(\n                // Text legibility and pill shape\n                'relative font-medium transition-colors duration-200',\n                'flex items-center justify-center gap-2 py-1 whitespace-nowrap',\n                'focus-visible:ring-accent/30 focus:outline-none focus-visible:ring-2',\n                sizeClass,\n                styles.button,\n                distribution === 'fit'\n                  ? 'flex-none'\n                  : responsiveWrap\n                    ? clsx(\n                        '@[0px]:flex-none @[0px]:basis-[calc(50%-0.125rem)] @[0px]:px-2',\n                        '@[420px]:flex-1 @[420px]:basis-auto @[420px]:px-3',\n                      )\n                    : clsx('flex-1', '@[0px]:px-2 @[420px]:px-3'),\n                isActive\n                  ? cn('text-text', activeClassName)\n                  : cn(\n                      'text-text-secondary hover:text-text',\n                      inactiveClassName,\n                    ),\n              )}\n              style={{ zIndex: 2 }}\n              disabled={disabled}\n              role=\"tab\"\n              aria-selected={isActive}\n              whileTap={{ scale: 0.985 }}\n            >\n              {item.icon != null ? (\n                <span className=\"flex items-center justify-center\">\n                  {item.icon}\n                </span>\n              ) : null}\n              <span\n                className={cn(\n                  !responsiveWrap && '@[0px]:hidden @[420px]:inline',\n                )}\n              >\n                {item.label}\n              </span>\n\n              {isActive && (\n                <m.div\n                  key={`${id}-${String(item.value)}`}\n                  layoutId={`${id}-segment-tab-indicator`}\n                  className={cn(\n                    // Floating pill indicator with subtle border & shadow\n                    'pointer-events-none absolute inset-x-0 inset-y-0.5 z-[-1]',\n                    styles.indicator,\n                    className,\n                  )}\n                />\n              )}\n            </m.button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/segment-tab/index.ts",
    "content": "export type { SegmentTabItem, SegmentTabProps } from './SegmentTab'\nexport { SegmentTab } from './SegmentTab'\n"
  },
  {
    "path": "apps/landing/src/components/ui/select/ComboboxSelect.tsx",
    "content": "'use client'\n\nimport { Select as SelectPrimitive } from 'radix-ui'\nimport type { FC } from 'react'\nimport * as React from 'react'\nimport { useCallback, useState } from 'react'\n\nimport { clsxm, focusRing } from '~/lib/cn'\n\nimport { Modal } from '../modal/ModalManager'\nimport { RootPortal } from '../portal'\nimport { InputPrompt } from '../prompts/InputPrompt'\nimport { SelectItem, SelectSeparator } from './Select'\n\ninterface ComboboxSelectProps {\n  value?: string\n  onValueChange?: (value: string) => void\n  placeholder?: string\n  options?: string[]\n  allowCustom?: boolean\n  disabled?: boolean\n  size?: 'default' | 'sm'\n  className?: string\n  label?: string\n  customInputTitle?: string\n  customInputDescription?: string\n  customInputPlaceholder?: string\n}\n\nconst DEFAULT_OPTIONS: string[] = []\n\nexport const ComboboxSelect: FC<ComboboxSelectProps> = ({\n  value,\n  onValueChange,\n  placeholder = 'Select an option...',\n  options = DEFAULT_OPTIONS,\n  allowCustom = true,\n  disabled = false,\n  size = 'default',\n  className,\n  label,\n  customInputTitle = 'Add Custom Value',\n  customInputDescription = 'Enter a new custom value',\n  customInputPlaceholder = 'Enter custom value...',\n}) => {\n  const [isOpen, setIsOpen] = useState(false)\n\n  const handleValueChange = useCallback(\n    (newValue: string) => {\n      if (newValue === '__ADD_CUSTOM__') {\n        // Close the select dropdown first\n        setIsOpen(false)\n\n        // Present the input modal\n        Modal.present(InputPrompt, {\n          title: customInputTitle,\n          description: customInputDescription,\n          placeholder: customInputPlaceholder,\n          onConfirm: (customValue: string) => {\n            if (customValue.trim()) {\n              onValueChange?.(customValue.trim())\n            }\n          },\n        })\n        return\n      }\n      // Convert special empty placeholder back to empty string\n      const actualValue = newValue === '__EMPTY__' ? '' : newValue\n      onValueChange?.(actualValue)\n      setIsOpen(false)\n    },\n    [\n      onValueChange,\n      customInputTitle,\n      customInputDescription,\n      customInputPlaceholder,\n    ],\n  )\n\n  return (\n    <div className=\"w-full\">\n      {label && (\n        <label className=\"text-text mb-2 block text-sm font-medium\">\n          {label}\n        </label>\n      )}\n      <SelectPrimitive.Root\n        value={value === '' ? '__EMPTY__' : value}\n        onValueChange={handleValueChange}\n        open={isOpen}\n        onOpenChange={setIsOpen}\n        disabled={disabled}\n      >\n        <SelectPrimitive.Trigger\n          className={clsxm(\n            'flex w-full items-center justify-between rounded-lg bg-transparent whitespace-nowrap',\n            focusRing,\n            'transition-all duration-200 outline-none',\n            'border-border hover:border-fill border',\n            size === 'sm' ? 'h-8 px-3 text-sm' : 'h-9 px-3.5 py-2 text-sm',\n            'placeholder:text-text-secondary',\n            'disabled:cursor-not-allowed disabled:opacity-50',\n            '[&>span]:line-clamp-1',\n            'shadow-sm shadow-zinc-100 hover:shadow dark:shadow-zinc-800',\n            className,\n            disabled && 'cursor-not-allowed opacity-30',\n          )}\n        >\n          <SelectPrimitive.Value placeholder={placeholder} />\n          <SelectPrimitive.Icon asChild>\n            <i className=\"i-mingcute-down-line text-text-secondary -mr-1 ml-2 size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[state=open]:rotate-180\" />\n          </SelectPrimitive.Icon>\n        </SelectPrimitive.Trigger>\n\n        <RootPortal>\n          <SelectPrimitive.Content\n            className={clsxm(\n              'bg-material-medium backdrop-blur-background text-text border-border z-[60] max-h-96 min-w-32 overflow-hidden rounded-[6px] border p-1',\n              'shadow-context-menu',\n            )}\n            position=\"item-aligned\"\n          >\n            <SelectPrimitive.Viewport className=\"p-0\">\n              {options.length === 0 && !allowCustom ? (\n                <div className=\"text-text-secondary px-2.5 py-1 text-sm\">\n                  No options available\n                </div>\n              ) : (\n                <>\n                  {options.map((option) => (\n                    <SelectItem\n                      key={option || '__EMPTY__'}\n                      value={option || '__EMPTY__'}\n                    >\n                      {option || 'No category'}\n                    </SelectItem>\n                  ))}\n\n                  {allowCustom && (\n                    <>\n                      {options.length > 0 && <SelectSeparator />}\n                      <SelectItem value=\"__ADD_CUSTOM__\">\n                        <span className=\"flex items-center gap-2\">\n                          <i className=\"i-mingcute-add-line size-3\" />\n                          Add custom...\n                        </span>\n                      </SelectItem>\n                    </>\n                  )}\n                </>\n              )}\n            </SelectPrimitive.Viewport>\n          </SelectPrimitive.Content>\n        </RootPortal>\n      </SelectPrimitive.Root>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/select/MultiSelect.tsx",
    "content": "'use client'\n\nimport { AnimatePresence, m } from 'motion/react'\nimport type { FC } from 'react'\nimport * as React from 'react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { clsxm as cn, focusRing } from '~/lib/cn'\nimport { Spring } from '~/lib/spring'\n\ninterface MultiSelectProps {\n  value?: string[]\n  onChange?: (value: string[]) => void\n  placeholder?: string\n  options?: string[]\n  allowCustom?: boolean\n  disabled?: boolean\n  size?: 'default' | 'sm'\n  className?: string\n  label?: string\n  maxHeight?: string\n}\n\nconst DEFAULT_VALUE: string[] = []\nconst DEFAULT_OPTIONS: string[] = []\n\nexport const MultiSelect: FC<MultiSelectProps> = ({\n  value = DEFAULT_VALUE,\n  onChange,\n  placeholder = 'Select tags...',\n  options = DEFAULT_OPTIONS,\n  allowCustom = true,\n  disabled = false,\n  size = 'default',\n  className,\n  label,\n  maxHeight = 'max-h-48',\n}) => {\n  const [isOpen, setIsOpen] = useState(false)\n  const [customValue, setCustomValue] = useState('')\n  const containerRef = useRef<HTMLDivElement | null>(null)\n\n  const handleToggleOption = useCallback(\n    (option: string) => {\n      const newValue = value.includes(option)\n        ? value.filter((v) => v !== option)\n        : [...value, option]\n      onChange?.(newValue)\n    },\n    [value, onChange],\n  )\n\n  const handleAddCustom = useCallback(() => {\n    if (customValue.trim() && !value.includes(customValue.trim())) {\n      onChange?.([...value, customValue.trim()])\n      setCustomValue('')\n    }\n  }, [customValue, value, onChange])\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault()\n        handleAddCustom()\n      }\n    },\n    [handleAddCustom],\n  )\n\n  // Close when clicking outside\n  useEffect(() => {\n    if (!isOpen) return\n    const handlePointerDown = (e: MouseEvent) => {\n      if (!containerRef.current) return\n      if (!containerRef.current.contains(e.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handlePointerDown)\n    return () => document.removeEventListener('mousedown', handlePointerDown)\n  }, [isOpen])\n\n  return (\n    <div ref={containerRef} className={cn('relative w-full', className)}>\n      {label && (\n        <label className=\"text-text mb-2 block text-sm font-medium\">\n          {label}\n        </label>\n      )}\n\n      {/* Trigger button (aligned with SelectTrigger) */}\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen((v) => !v)}\n        disabled={disabled}\n        className={cn(\n          'flex w-full items-center justify-between rounded-lg bg-transparent whitespace-nowrap',\n          focusRing,\n          'transition-all duration-200 outline-none',\n          'border-border hover:border-fill border',\n          size === 'sm' ? 'h-7 px-2.5 py-1 text-sm' : 'h-8 px-3 py-1.5 text-sm',\n          'placeholder:text-text-secondary',\n          'disabled:cursor-not-allowed disabled:opacity-50',\n          '[&>span]:line-clamp-1',\n          'shadow-sm hover:shadow',\n          disabled && 'cursor-not-allowed opacity-30',\n        )}\n      >\n        <span\n          className={cn('text-text-secondary', value.length > 0 && 'text-text')}\n        >\n          {value.length > 0 ? `${value.length} selected` : placeholder}\n        </span>\n        <i\n          className={cn(\n            'i-mingcute-down-line text-text-secondary -mr-1 ml-2 size-4 shrink-0 opacity-60 transition-transform duration-200',\n            isOpen && 'rotate-180',\n          )}\n        />\n      </button>\n\n      {/* Dropdown content with motion (aligned with popover styles) */}\n      <AnimatePresence>\n        {isOpen && (\n          <m.div\n            key=\"multi-select-content\"\n            initial={{ opacity: 0, scale: 0.96, y: -4 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={{ opacity: 0, scale: 0.96, y: -4 }}\n            transition={Spring.presets.smooth}\n            className={cn(\n              'bg-background border-border absolute z-[60] mt-1 w-full overflow-hidden rounded-lg border p-1.5 shadow-md',\n              maxHeight,\n            )}\n          >\n            <div className=\"overflow-y-auto\">\n              {/* Custom input */}\n              {allowCustom && (\n                <div className=\"border-border mb-1 border-b p-1\">\n                  <div className=\"flex gap-1\">\n                    <Input\n                      value={customValue}\n                      onChange={(e) => setCustomValue(e.target.value)}\n                      onKeyDown={handleKeyDown}\n                      placeholder=\"Add custom tag...\"\n                      inputClassName=\"bg-transparent border-transparent appearance-none shadow-none h-7 px-0 !ring-0 !border-0\"\n                      className=\"focus:border-border h-7 rounded-md border-transparent bg-transparent px-2 py-1 text-sm shadow-none\"\n                    />\n                    <Button\n                      size=\"sm\"\n                      variant=\"secondary\"\n                      className=\"h-7 rounded-md\"\n                      onClick={handleAddCustom}\n                      disabled={\n                        !customValue.trim() ||\n                        value.includes(customValue.trim())\n                      }\n                    >\n                      Add\n                    </Button>\n                  </div>\n                </div>\n              )}\n\n              {/* Options */}\n              <div className=\"py-0.5\">\n                {options.length === 0 ? (\n                  <div className=\"text-text-secondary px-2 py-1.5 text-sm\">\n                    No tags available\n                  </div>\n                ) : (\n                  options.map((option) => {\n                    const selected = value.includes(option)\n                    return (\n                      <button\n                        key={option}\n                        type=\"button\"\n                        onClick={() => handleToggleOption(option)}\n                        className={cn(\n                          'cursor-menu focus:bg-accent relative flex w-full items-center rounded-[5px] px-2.5 py-1 text-left text-sm outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n                          'focus-within:outline-transparent',\n                          'h-[28px]',\n                          selected\n                            ? 'bg-accent text-white'\n                            : 'hover:bg-accent hover:text-white',\n                        )}\n                      >\n                        <span className=\"pr-5\">{option}</span>\n                        {selected && (\n                          <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n                            <i className=\"i-mingcute-check-fill size-3\" />\n                          </span>\n                        )}\n                      </button>\n                    )\n                  })\n                )}\n              </div>\n            </div>\n          </m.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/select/Select.tsx",
    "content": "import { Select as SelectPrimitive } from 'radix-ui'\nimport * as React from 'react'\n\nimport { clsxm, focusRing } from '~/lib/cn'\n\nimport { Divider } from '../divider/Divider'\nimport { RootPortal } from '../portal'\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = ({\n  ref,\n  size = 'default',\n  className,\n  children,\n  loading,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {\n  size?: 'default' | 'sm'\n  loading?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Trigger> | null>\n}) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={clsxm(\n      'flex w-full items-center justify-between rounded-lg bg-transparent whitespace-nowrap',\n      focusRing,\n      'transition-all duration-200 outline-none',\n      'border-border border',\n      size === 'sm' ? 'h-8 px-3 text-sm' : 'h-9 px-3.5 py-2 text-sm',\n      'placeholder:text-text-secondary',\n      'disabled:cursor-not-allowed disabled:opacity-50',\n      '[&>span]:line-clamp-1',\n      'shadow-sm shadow-zinc-100 hover:shadow dark:shadow-zinc-800',\n      className,\n      props.disabled && 'cursor-not-allowed opacity-30',\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      {loading ? (\n        <i className=\"i-mingcute-loading-3-line text-text-tertiary size-4 animate-spin\" />\n      ) : (\n        <i className=\"i-mingcute-down-line text-text-secondary -mr-1 ml-2 size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[state=open]:rotate-180\" />\n      )}\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n)\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> & {\n  ref?: React.Ref<React.ElementRef<\n    typeof SelectPrimitive.ScrollUpButton\n  > | null>\n}) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={clsxm(\n      'cursor-menu flex items-center justify-center py-1',\n      className,\n    )}\n    {...props}\n  >\n    <i className=\"i-mingcute-up-line size-3.5\" />\n  </SelectPrimitive.ScrollUpButton>\n)\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> & {\n  ref?: React.Ref<React.ElementRef<\n    typeof SelectPrimitive.ScrollDownButton\n  > | null>\n}) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={clsxm(\n      'cursor-menu flex items-center justify-center py-1',\n      className,\n    )}\n    {...props}\n  >\n    <i className=\"i-mingcute-down-line size-3.5\" />\n  </SelectPrimitive.ScrollDownButton>\n)\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = ({\n  ref,\n  className,\n  children,\n  position = 'item-aligned',\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Content> | null>\n}) => (\n  <RootPortal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={clsxm(\n        'bg-material-medium backdrop-blur-background no-drag-region text-text border-border pointer-events-auto z-[60] max-h-96 min-w-32 overflow-hidden rounded-[6px] border p-1',\n        'shadow-context-menu',\n        className,\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={clsxm(\n          'p-0',\n          position === 'popper' &&\n            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </RootPortal>\n)\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> & {\n  inset?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Label> | null>\n}) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={clsxm(\n      'text-text px-2 py-1.5 font-semibold',\n      inset && 'pl-8',\n      className,\n    )}\n    {...props}\n  />\n)\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = ({\n  ref,\n  className,\n  children,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {\n  inset?: boolean\n} & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Item> | null>\n}) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={clsxm(\n      'cursor-menu focus:bg-accent relative flex items-center rounded-[5px] px-2.5 py-1 outline-none select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      'data-[highlighted]:bg-accent text-sm focus-within:outline-transparent',\n      'h-[28px] w-full',\n      inset && 'pl-8',\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <i className=\"i-mingcute-check-fill size-3\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n)\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = ({\n  ref,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Separator> | null>\n}) => (\n  <SelectPrimitive.Separator\n    className=\"backdrop-blur-background mx-2 my-1 h-px\"\n    asChild\n    ref={ref}\n    {...props}\n  >\n    <Divider />\n  </SelectPrimitive.Separator>\n)\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/select/index.ts",
    "content": "export * from './ComboboxSelect'\nexport * from './MultiSelect'\nexport * from './Select'\n"
  },
  {
    "path": "apps/landing/src/components/ui/sheet/Sheet.tsx",
    "content": "import { atom, useStore } from 'jotai'\nimport type { FC, JSX, PropsWithChildren } from 'react'\nimport * as React from 'react'\nimport { useEffect, useMemo, useState } from 'react'\nimport { Drawer } from 'vaul'\n\nexport interface PresentSheetProps {\n  content: JSX.Element | FC\n  open?: boolean\n  onOpenChange?: (value: boolean) => void\n  title?: string\n  zIndex?: number\n  dismissible?: boolean\n}\n\nexport const sheetStackAtom = atom([] as HTMLDivElement[])\n\nexport const PresentSheet: FC<PropsWithChildren<PresentSheetProps>> = (\n  props,\n) => {\n  const { content, children, zIndex = 998, title, dismissible = true } = props\n  const nextRootProps = useMemo(() => {\n    const nextProps = {} as any\n    if (props.open !== undefined) {\n      nextProps.open = props.open\n    }\n\n    if (props.onOpenChange !== undefined) {\n      nextProps.onOpenChange = props.onOpenChange\n    }\n\n    return nextProps\n  }, [props])\n  const [holderRef, setHolderRef] = useState<HTMLDivElement | null>()\n  const store = useStore()\n\n  useEffect(() => {\n    const holder = holderRef\n    if (!holder) return\n    store.set(sheetStackAtom, (p) => {\n      return p.concat(holder)\n    })\n\n    return () => {\n      store.set(sheetStackAtom, (p) => {\n        return p.filter((item) => item !== holder)\n      })\n    }\n  }, [holderRef, store])\n\n  const { Root } = Drawer\n\n  const overlayZIndex = zIndex - 1\n  const contentZIndex = zIndex\n\n  return (\n    <Root dismissible={dismissible} {...nextRootProps}>\n      <Drawer.Trigger asChild>{children}</Drawer.Trigger>\n      <Drawer.Portal>\n        <Drawer.Content\n          style={{\n            zIndex: contentZIndex,\n          }}\n          className=\"bg-background flex fixed inset-x-0 bottom-0 mt-24 max-h-[95vh] flex-col rounded-t-[10px] p-4\"\n        >\n          {dismissible && (\n            <div className=\"mx-auto mb-8 h-1.5 w-12 shrink-0 rounded-full bg-zinc-300 dark:bg-neutral-800\" />\n          )}\n\n          {title && <Drawer.Title>{title}</Drawer.Title>}\n\n          {React.isValidElement(content)\n            ? content\n            : typeof content === 'function'\n              ? React.createElement(content)\n              : null}\n          <div ref={setHolderRef} />\n        </Drawer.Content>\n        <Drawer.Overlay\n          className=\"fixed inset-0 bg-neutral-800/40\"\n          style={{\n            zIndex: overlayZIndex,\n          }}\n        />\n      </Drawer.Portal>\n    </Root>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/sheet/index.ts",
    "content": "export * from './Sheet'\n"
  },
  {
    "path": "apps/landing/src/components/ui/skeleton/Skeleton.tsx",
    "content": "import { clsxm } from '~/lib/helper'\n\nexport const Skeleton: Component = ({ className }) => {\n  return (\n    <div className={clsxm('flex animate-pulse flex-col gap-3', className)}>\n      <div className=\"h-6 w-full rounded-lg bg-gray-200 dark:bg-zinc-800/80\" />\n      <div className=\"h-6 w-full rounded-lg bg-gray-200 dark:bg-zinc-800/80\" />\n      <div className=\"h-6 w-full rounded-lg bg-gray-200 dark:bg-zinc-800/80\" />\n      <div className=\"h-6 w-full rounded-lg bg-gray-200 dark:bg-zinc-800/80\" />\n      <span className=\"sr-only\">Loading...</span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/skeleton/index.ts",
    "content": "export * from './Skeleton'\n"
  },
  {
    "path": "apps/landing/src/components/ui/switch/index.tsx",
    "content": "'use client'\n\nimport type { HTMLMotionProps } from 'motion/react'\nimport { m as motion } from 'motion/react'\nimport { Switch as SwitchPrimitives } from 'radix-ui'\nimport * as React from 'react'\n\nimport { cn } from '~/lib/cn'\n\ntype SwitchProps = React.ComponentProps<typeof SwitchPrimitives.Root> &\n  HTMLMotionProps<'button'> & {\n    leftIcon?: React.ReactNode\n    rightIcon?: React.ReactNode\n    thumbIcon?: React.ReactNode\n  }\n\nfunction Switch({\n  className,\n  leftIcon,\n  rightIcon,\n  thumbIcon,\n  onCheckedChange,\n  ...props\n}: SwitchProps) {\n  const [isChecked, setIsChecked] = React.useState(\n    props?.checked ?? props?.defaultChecked ?? false,\n  )\n  const [isTapped, setIsTapped] = React.useState(false)\n\n  React.useEffect(() => {\n    if (props?.checked !== undefined) setIsChecked(props.checked)\n  }, [props?.checked])\n\n  const handleCheckedChange = React.useCallback(\n    (checked: boolean) => {\n      setIsChecked(checked)\n      onCheckedChange?.(checked)\n    },\n    [onCheckedChange],\n  )\n  // Avoid motion layout animations; use explicit transform animation instead\n  const TRACK_WIDTH = 40 // w-10 => 2.5rem => 40px\n  const TRACK_PADDING = 3 // p-[3px]\n  const THUMB_SIZE = 18\n  const CHECKED_X = TRACK_WIDTH - TRACK_PADDING * 2 - THUMB_SIZE\n  return (\n    <SwitchPrimitives.Root\n      {...props}\n      onCheckedChange={handleCheckedChange}\n      asChild\n    >\n      <motion.button\n        data-slot=\"switch\"\n        className={cn(\n          'cursor-switch focus-visible:ring-accent focus-visible:ring-offset-background data-[state=checked]:bg-accent data-[state=unchecked]:bg-fill-secondary relative flex h-6 w-10 shrink-0 items-center justify-start rounded-full p-[3px] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',\n          className,\n        )}\n        whileTap=\"tap\"\n        initial={false}\n        onTapStart={() => setIsTapped(true)}\n        onTapCancel={() => setIsTapped(false)}\n        onTap={() => setIsTapped(false)}\n        {...props}\n      >\n        {leftIcon != null ? (\n          <motion.div\n            data-slot=\"switch-left-icon\"\n            animate={\n              isChecked ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }\n            }\n            transition={{ type: 'spring', bounce: 0 }}\n            className=\"text-text-secondary absolute top-1/2 left-1 -translate-y-1/2 [&_svg]:size-3\"\n          >\n            {typeof leftIcon !== 'string' ? leftIcon : null}\n          </motion.div>\n        ) : null}\n\n        {rightIcon != null ? (\n          <motion.div\n            data-slot=\"switch-right-icon\"\n            animate={\n              isChecked ? { scale: 0, opacity: 0 } : { scale: 1, opacity: 1 }\n            }\n            transition={{ type: 'spring', bounce: 0 }}\n            className=\"text-text-secondary absolute top-1/2 right-1 -translate-y-1/2 [&_svg]:size-3\"\n          >\n            {typeof rightIcon !== 'string' ? rightIcon : null}\n          </motion.div>\n        ) : null}\n\n        <SwitchPrimitives.Thumb asChild>\n          <motion.div\n            data-slot=\"switch-thumb\"\n            whileTap=\"tab\"\n            className={\n              'bg-background text-text-secondary relative z-[1] flex items-center justify-center rounded-full shadow-lg ring-0 [&_svg]:size-3'\n            }\n            transition={{\n              x: { type: 'spring', stiffness: 300, damping: 25 },\n              width: { duration: 0.1 },\n            }}\n            style={{\n              width: THUMB_SIZE,\n              height: THUMB_SIZE,\n            }}\n            initial\n            animate={{\n              x: isChecked ? CHECKED_X : 0,\n              width: isTapped ? 21 : THUMB_SIZE,\n            }}\n          >\n            {thumbIcon && typeof thumbIcon !== 'string' ? thumbIcon : null}\n          </motion.div>\n        </SwitchPrimitives.Thumb>\n      </motion.button>\n    </SwitchPrimitives.Root>\n  )\n}\n\nexport { Switch, type SwitchProps }\n"
  },
  {
    "path": "apps/landing/src/components/ui/theme-switcher/ThemeSwitcher.tsx",
    "content": "/* eslint-disable @eslint-react/dom/no-flush-sync */\n'use client'\n\nimport { useTheme } from 'next-themes'\nimport { flushSync } from 'react-dom'\nimport { tv } from 'tailwind-variants'\n\nimport { useIsClient } from '~/hooks/common/use-is-client'\nimport { transitionViewIfSupported } from '~/lib/dom'\n\nconst styles = tv({\n  base: 'rounded-inherit inline-flex h-[32px] w-[32px] items-center justify-center border-0 text-current',\n  variants: {\n    status: {\n      active: '',\n    },\n  },\n})\n\nconst iconClassNames = 'h-4 w-4 text-current'\n\nconst SunIcon = () => {\n  return (\n    <svg\n      className={iconClassNames}\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"5\" />\n      <path d=\"M12 1v2\" />\n      <path d=\"M12 21v2\" />\n      <path d=\"M4.22 4.22l1.42 1.42\" />\n      <path d=\"M18.36 18.36l1.42 1.42\" />\n      <path d=\"M1 12h2\" />\n      <path d=\"M21 12h2\" />\n      <path d=\"M4.22 19.78l1.42-1.42\" />\n      <path d=\"M18.36 5.64l1.42-1.42\" />\n    </svg>\n  )\n}\n\nconst SystemIcon = () => {\n  return (\n    <svg\n      className={iconClassNames}\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n    >\n      <rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\" ry=\"2\" />\n      <path d=\"M8 21h8\" />\n      <path d=\"M12 17v4\" />\n    </svg>\n  )\n}\n\nconst DarkIcon = () => {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={iconClassNames}\n    >\n      <path d=\"M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z\" />\n    </svg>\n  )\n}\n\nexport const ThemeSwitcher = () => {\n  return (\n    <div className=\"relative inline-block\">\n      <ThemeIndicator />\n      <ButtonGroup />\n    </div>\n  )\n}\n\nconst ThemeIndicator = () => {\n  const { theme } = useTheme()\n\n  const isClient = useIsClient()\n\n  if (!isClient) return null\n  if (!theme) return null\n  return (\n    <div\n      className=\"z-1 absolute top-[4px] size-[32px] rounded-full bg-foreground-500/10 shadow-[0_1px_2px_0_rgba(127.5,127.5,127.5,.2),_0_1px_3px_0_rgba(127.5,127.5,127.5,.1)] duration-200\"\n      style={{\n        left: { light: 4, system: 36, dark: 68 }[theme],\n      }}\n    />\n  )\n}\n\nconst ButtonGroup = () => {\n  const { setTheme } = useTheme()\n\n  const buildThemeTransition = (theme: 'light' | 'dark' | 'system') => {\n    transitionViewIfSupported(() => {\n      flushSync(() => setTheme(theme))\n    })\n  }\n\n  return (\n    <div className=\"w-fit-content inline-flex rounded-full border border-slate-200 p-[3px] dark:border-neutral-800\">\n      <button\n        aria-label=\"Switch to light theme\"\n        type=\"button\"\n        className={styles.base}\n        onClick={() => {\n          buildThemeTransition('light')\n        }}\n      >\n        <SunIcon />\n      </button>\n      <button\n        aria-label=\"Switch to system theme\"\n        className={styles.base}\n        type=\"button\"\n        onClick={() => {\n          buildThemeTransition('system')\n        }}\n      >\n        <SystemIcon />\n      </button>\n      <button\n        aria-label=\"Switch to dark theme\"\n        className={styles.base}\n        type=\"button\"\n        onClick={() => {\n          buildThemeTransition('dark')\n        }}\n      >\n        <DarkIcon />\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/theme-switcher/index.ts",
    "content": "export * from './ThemeSwitcher'\n"
  },
  {
    "path": "apps/landing/src/components/ui/tooltip/index.tsx",
    "content": "import { Tooltip as TooltipPrimitive } from '@base-ui-components/react/tooltip'\nimport { m } from 'motion/react'\nimport * as React from 'react'\n\nimport { cn } from '~/lib/cn'\nimport { Spring } from '~/lib/spring'\n\nimport { tooltipStyle } from './styles'\n\n// Base UI Tooltip Provider wrapper\nconst TooltipProvider = TooltipPrimitive.Provider\n\n// Simplified wrapper component that combines Provider and Root\nconst Tooltip = ({\n  children,\n  delayDuration = 200,\n  ...props\n}: {\n  children: React.ReactNode\n  delayDuration?: number\n}) => (\n  <TooltipProvider delay={delayDuration}>\n    <TooltipPrimitive.Root {...props}>{children}</TooltipPrimitive.Root>\n  </TooltipProvider>\n)\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst TooltipContent = ({\n  className,\n  sideOffset = 4,\n  children,\n  ref,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Popup> & {\n  sideOffset?: number\n  ref?: React.Ref<HTMLDivElement>\n}) => (\n  <TooltipPrimitive.Portal>\n    <TooltipPrimitive.Positioner\n      className={cn(tooltipStyle.positioner)}\n      sideOffset={sideOffset}\n    >\n      <TooltipPrimitive.Popup\n        ref={ref}\n        className={cn(tooltipStyle.content, className)}\n        {...props}\n      >\n        <m.div\n          initial={{ opacity: 0.82, scale: 0.95 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={Spring.snappy(0.1)}\n        >\n          <TooltipPrimitive.Arrow className=\"z-50 [&>svg]:fill-white dark:[&>svg]:fill-neutral-950 dark:[&>svg]:drop-shadow-[0_0_1px_theme(colors.background/0.5)]\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"16\"\n              height=\"8\"\n              viewBox=\"0 0 16 8\"\n            >\n              <path d=\"M0 8L8 0L16 8\" />\n            </svg>\n          </TooltipPrimitive.Arrow>\n          {children}\n        </m.div>\n      </TooltipPrimitive.Popup>\n    </TooltipPrimitive.Positioner>\n  </TooltipPrimitive.Portal>\n)\n\nTooltipContent.displayName = 'TooltipContent'\n\nconst TooltipRoot = TooltipPrimitive.Root\n\nexport { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger }\n"
  },
  {
    "path": "apps/landing/src/components/ui/tooltip/styles.ts",
    "content": "export const tooltipStyle = {\n  content: [\n    'relative z-[101] bg-white px-2 py-1 text-text dark:bg-neutral-950',\n    'data-[closed]:animate-out data-[closed]:fade-out-0',\n    'rounded-lg text-sm',\n    'max-w-[75ch] select-text',\n    'drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top',\n    `dark:drop-shadow-[0_0_1px_theme(colors.white/0.2)]`,\n  ],\n  positioner: ['z-[101]'],\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/BottomToUpSoftScaleTransitionView.tsx",
    "content": "'use client'\n\nimport { softSpringPreset } from '~/constants/spring'\n\nimport { createTransitionView } from './factor'\n\nexport const BottomToUpSoftScaleTransitionView = createTransitionView({\n  from: { opacity: 0.00001, scale: 0.96, y: 10 },\n  to: {\n    y: 0,\n    scale: 1,\n    opacity: 1,\n  },\n  preset: softSpringPreset,\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/BottomToUpTransitionView.tsx",
    "content": "'use client'\n\nimport { softBouncePreset } from '~/constants/spring'\n\nimport { createTransitionView } from './factor'\n\nexport const BottomToUpTransitionView = createTransitionView({\n  from: {\n    y: 50,\n    opacity: 0.001,\n  },\n  to: {\n    y: 0,\n    opacity: 1,\n  },\n  preset: softBouncePreset,\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/FadeInOutTransitionView.tsx",
    "content": "'use client'\n\nimport type { FC, PropsWithChildren } from 'react'\n\nimport { createTransitionView } from './factor'\nimport type { BaseTransitionProps } from './typings'\n\nexport const FadeInOutTransitionView: FC<\n  PropsWithChildren<BaseTransitionProps>\n> = createTransitionView({\n  from: {\n    opacity: 0.001,\n  },\n  to: {\n    opacity: 1,\n  },\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/IconTransiton.tsx",
    "content": "'use client'\n\nimport { useAnimationControls } from 'motion/react'\nimport type { FC, JSX } from 'react'\nimport * as React from 'react'\nimport { useEffect, useState } from 'react'\n\nimport { FadeInOutTransitionView } from '~/components/ui/transition/FadeInOutTransitionView'\n\ninterface IconTransitionProps {\n  solidIcon: JSX.Element\n  regularIcon: JSX.Element\n  currentState: 'solid' | 'regular'\n}\nexport const IconTransition: FC<IconTransitionProps> = (props) => {\n  const { currentState, regularIcon, solidIcon } = props\n\n  const map = {\n    solid: solidIcon,\n    regular: regularIcon,\n  }\n  const [currentIcon, setCurrentIcon] = useState(map[currentState])\n  const controls = useAnimationControls()\n\n  useEffect(() => {\n    controls.start({ opacity: 0.001 }).then(() => {\n      setCurrentIcon(map[currentState])\n      requestAnimationFrame(() => {\n        controls.start({ opacity: 1 })\n      })\n    })\n  }, [currentState])\n\n  return (\n    <FadeInOutTransitionView\n      initial\n      animate={controls}\n      transition={{ duration: 0.2 }}\n      key={currentState}\n    >\n      {currentIcon}\n    </FadeInOutTransitionView>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/LeftToRightTransitionView.tsx",
    "content": "'use client'\n\nimport { createTransitionView } from './factor'\n\nexport const LeftToRightTransitionView = createTransitionView({\n  from: {\n    translateX: -70,\n    opacity: 0.001,\n  },\n  to: {\n    translateX: 0,\n    opacity: 1,\n  },\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/RightToLeftTransitionView.tsx",
    "content": "'use client'\n\nimport { createTransitionView } from './factor'\n\nexport const RightToLeftTransitionView = createTransitionView({\n  from: {\n    translateX: 42,\n    opacity: 0.001,\n  },\n  to: {\n    translateX: 0,\n    opacity: 1,\n  },\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/ScaleTransitionView.tsx",
    "content": "'use client'\n\nimport { createTransitionView } from './factor'\n\nexport const ScaleTransitionView = createTransitionView({\n  from: {\n    scale: 0.001,\n    opacity: 0.001,\n  },\n  to: {\n    scale: 1,\n    opacity: 1,\n  },\n})\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/TextUpTransitionView.tsx",
    "content": "'use client'\n\nimport { m } from 'motion/react'\nimport type { FC, JSX } from 'react'\nimport * as React from 'react'\n\nimport { microReboundPreset } from '~/constants/spring'\n\nexport const TextUpTransitionView: FC<\n  {\n    text?: string\n    children?: string\n\n    appear?: boolean\n\n    eachDelay?: number\n    initialDelay?: number\n  } & JSX.IntrinsicElements['div']\n> = (props) => {\n  const {\n    appear = true,\n    eachDelay = 0.1,\n    initialDelay = 0,\n    children,\n    text,\n    ...rest\n  } = props\n\n  if (!appear) {\n    // @ts-ignore\n    return <div {...rest}>{text ?? children}</div>\n  }\n\n  return (\n    <div {...rest}>\n      {Array.from(text ?? (children as string)).map((char, i) => (\n        <m.span\n          key={i}\n          className=\"inline-block whitespace-pre\"\n          initial={{ transform: 'translateY(10px)', opacity: 0.001 }}\n          animate={{\n            transform: 'translateY(0px)',\n\n            opacity: 1,\n            transition: {\n              ...microReboundPreset,\n              duration: 0.1,\n              delay: i * eachDelay + initialDelay,\n            },\n          }}\n        >\n          {char}\n        </m.span>\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/factor.tsx",
    "content": "'use client'\n\nimport type {\n  HTMLMotionProps,\n  Spring,\n  Target,\n  TargetAndTransition,\n} from 'motion/react'\nimport { m } from 'motion/react'\nimport type { FC, PropsWithChildren } from 'react'\nimport { memo, useMemo } from 'react'\n\nimport { isHydrationEnded } from '~/components/common/HydrationEndDetector'\nimport { microReboundPreset } from '~/constants/spring'\n\nimport type { BaseTransitionProps } from './typings'\n\ninterface TransitionViewParams {\n  from: Target\n  to: Target\n  initial?: Target\n  preset?: Spring\n}\n\nexport const createTransitionView = (\n  params: TransitionViewParams,\n): FC<PropsWithChildren<BaseTransitionProps>> => {\n  const { from, to, initial, preset } = params\n\n  const TransitionView = (props: PropsWithChildren<BaseTransitionProps>) => {\n    const {\n      timeout = {},\n      duration = 0.5,\n\n      animation = {},\n      as = 'div',\n      delay = 0,\n      lcpOptimization = false,\n      ...rest\n    } = props\n\n    const { enter = delay, exit = delay } = timeout\n\n    const MotionComponent = m[as] as FC<HTMLMotionProps<any>>\n\n    return (\n      <MotionComponent\n        initial={useMemo(\n          () =>\n            lcpOptimization\n              ? isHydrationEnded()\n                ? initial || from\n                : true\n              : initial || from,\n          [],\n        )}\n        animate={{\n          ...to,\n          transition: {\n            duration,\n            ...(preset || microReboundPreset),\n            ...animation.enter,\n            delay: enter / 1000,\n          },\n        }}\n        exit={{\n          ...from,\n          transition: {\n            duration,\n            ...animation.exit,\n            delay: exit / 1000,\n          } as TargetAndTransition['transition'],\n        }}\n        transition={{\n          duration,\n        }}\n        {...rest}\n      >\n        {props.children}\n      </MotionComponent>\n    )\n  }\n  const MemoedTransitionView = memo(TransitionView)\n  MemoedTransitionView.displayName = `MemoedTransitionView`\n  return MemoedTransitionView\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/transition/typings.ts",
    "content": "import type { HTMLMotionProps, m, TargetAndTransition } from 'motion/react'\n\nexport interface BaseTransitionProps extends HTMLMotionProps<'div'> {\n  duration?: number\n\n  timeout?: {\n    exit?: number\n    enter?: number\n  }\n\n  delay?: number\n\n  animation?: {\n    enter?: TargetAndTransition['transition']\n    exit?: TargetAndTransition['transition']\n  }\n\n  lcpOptimization?: boolean\n  as?: keyof typeof m\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/viewport/OnlyDesktop.tsx",
    "content": "'use client'\n\nimport { useAtomValue } from 'jotai'\nimport { selectAtom } from 'jotai/utils'\nimport type { ExtractAtomValue } from 'jotai/vanilla'\n\nimport { viewportAtom } from '~/atoms/viewport'\nimport { useIsClient } from '~/hooks/common/use-is-client'\n\nconst selector = (v: ExtractAtomValue<typeof viewportAtom>) => v.lg && v.w !== 0\nexport const OnlyDesktop: Component = ({ children }) => {\n  const isClient = useIsClient()\n\n  const isLg = useAtomValue(selectAtom(viewportAtom, selector))\n  if (!isClient) return null\n\n  if (!isLg) return null\n\n  return children\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/viewport/OnlyMobile.tsx",
    "content": "'use client'\n\nimport { useIsMobile } from '~/atoms/viewport'\nimport { useIsClient } from '~/hooks/common/use-is-client'\n\nexport const OnlyMobile: Component = ({ children }) => {\n  const isClient = useIsClient()\n\n  const isMobile = useIsMobile()\n\n  if (!isClient) return null\n\n  if (!isMobile) return null\n\n  return children\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/viewport/index.ts",
    "content": "export * from './OnlyDesktop'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/download/DownloadHero.tsx",
    "content": "'use client'\n\nimport clsx from 'clsx'\nimport { m, useScroll, useTransform } from 'motion/react'\nimport { useTranslations } from 'next-intl'\nimport * as React from 'react'\n\nimport { Highlighter } from '~/components/ui/highlighter'\n\nexport const DownloadHero: Component = () => {\n  const ref = React.useRef<HTMLElement | null>(null)\n  const { scrollYProgress } = useScroll({\n    target: ref,\n    offset: ['start end', 'end start'],\n  })\n  const bgY = useTransform(scrollYProgress, [0, 1], [0, -120])\n  const heroT = useTranslations('download.hero')\n\n  return (\n    <section ref={ref} className=\"relative isolate w-full\">\n      {/* Background glow + ultra-subtle grid */}\n      <m.div\n        className=\"pointer-events-none absolute inset-x-0 -inset-y-8 -z-10\"\n        style={{ y: bgY }}\n      >\n        <div className=\"mx-auto h-[380px] w-[800px] rounded-full bg-accent/10 blur-[130px]\" />\n        <div\n          className={clsx(\n            'pointer-events-none absolute inset-0 hidden md:block',\n            'dark:bg-[linear-gradient(to_right,rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.035)_1px,transparent_1px)] [background-size:48px_48px,48px_48px]',\n          )}\n        />\n      </m.div>\n\n      <div className=\"max-w-max-width-2xl px-4 mx-auto mt-28\">\n        <div className=\"mx-auto max-w-4xl text-center\">\n          <h1 className=\"text-text mt-6 text-4xl font-semibold leading-[1.05] tracking-tight text-balance md:text-6xl lg:text-7xl\">\n            {heroT.rich('title', {\n              brand: (chunks) => (\n                <Highlighter action=\"underline\">\n                  <span className=\"bg-linear-to-r from-accent to-accent/70 bg-clip-text text-transparent\">\n                    {chunks}\n                  </span>\n                </Highlighter>\n              ),\n            })}\n          </h1>\n\n          <p className=\"text-text-secondary mt-6 max-w-2xl mx-auto text-lg md:text-xl\">\n            {heroT('subtitle')}\n          </p>\n        </div>\n      </div>\n    </section>\n  )\n}\n\nDownloadHero.displayName = 'DownloadHero'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/download/PlatformDownloads.tsx",
    "content": "'use client'\n\nimport { m } from 'motion/react'\nimport Link from 'next/link'\nimport { useTranslations } from 'next-intl'\nimport * as React from 'react'\n\nimport type {\n  OS,\n  PlatformDownloadChannel,\n  PlatformDownloadGroup,\n} from '~/constants/download'\nimport { PlatformDownloadGroups } from '~/constants/download'\nimport { Link as LocalizedLink } from '~/i18n/routing'\nimport { cx, focusRing } from '~/lib/cn'\nimport { Spring } from '~/lib/spring'\n\ntype PlatformDownloadsProps = {\n  detectedOS: OS | null\n}\n\nconst getPlatformGroups = (\n  detectedOS: OS | null,\n  showAllPlatforms: boolean,\n): PlatformDownloadGroup[] => {\n  if (!detectedOS) {\n    return PlatformDownloadGroups\n  }\n\n  const currentPlatform = PlatformDownloadGroups.find(\n    (group) => group.os === detectedOS,\n  )\n  const otherPlatforms = PlatformDownloadGroups.filter(\n    (group) => group.os !== detectedOS,\n  )\n\n  return showAllPlatforms\n    ? ([currentPlatform, ...otherPlatforms].filter(\n        Boolean,\n      ) as PlatformDownloadGroup[])\n    : currentPlatform\n      ? [currentPlatform]\n      : PlatformDownloadGroups\n}\n\nexport const PlatformDownloads: Component<PlatformDownloadsProps> = ({\n  detectedOS,\n}) => {\n  const platformT = useTranslations('download.platforms')\n  const [showAllPlatforms, setShowAllPlatforms] = React.useState(false)\n\n  const platformGroups = getPlatformGroups(detectedOS, showAllPlatforms)\n  const webTitle = platformT('web.title')\n  const webSubtitle = platformT('web.subtitle')\n  const footer = platformT.rich('footer', {\n    terms: (chunks) => (\n      <LocalizedLink\n        href=\"/terms-of-service\"\n        className=\"text-text-secondary hover:text-accent transition-colors\"\n      >\n        {chunks}\n      </LocalizedLink>\n    ),\n    privacy: (chunks) => (\n      <LocalizedLink\n        href=\"/privacy-policy\"\n        className=\"text-text-secondary hover:text-accent transition-colors\"\n      >\n        {chunks}\n      </LocalizedLink>\n    ),\n  })\n\n  return (\n    <section\n      id=\"downloads\"\n      className=\"mx-auto mt-16 md:mt-20 w-full max-w-max-width-2xl px-4 pb-32\"\n    >\n      <div className=\"mx-auto max-w-4xl\">\n        <div className=\"space-y-8\">\n          {platformGroups.map((group, groupIndex) => (\n            <m.div\n              key={group.os}\n              initial={{ opacity: 0, y: 20 }}\n              animate={{ opacity: 1, y: 0 }}\n              transition={{\n                ...Spring.presets.smooth,\n                delay: groupIndex * 0.05,\n              }}\n            >\n              <div className=\"mb-5 flex items-end justify-between gap-4\">\n                <div className=\"flex items-center gap-4\">\n                  <i\n                    className={cx(group.icon, 'size-12 shrink-0 text-accent')}\n                    aria-hidden\n                  />\n\n                  <div className=\"space-y-1\">\n                    <p className=\"text-sm text-text-secondary\">\n                      {detectedOS === group.os\n                        ? platformT('recommended')\n                        : platformT('allPlatforms')}\n                    </p>\n                    <h3 className=\"text-3xl leading-none font-semibold text-text\">\n                      {platformT(`${group.os}.label`, {\n                        defaultValue: group.label,\n                      })}\n                    </h3>\n                  </div>\n                </div>\n\n                <div className=\"text-sm text-text-tertiary\">\n                  <p className=\"text-sm\">{group.channels.length} options</p>\n                </div>\n              </div>\n\n              <div className=\"grid gap-4 md:grid-cols-2\">\n                {group.channels.map((channel) => (\n                  <DownloadChannelCard key={channel.id} channel={channel} />\n                ))}\n              </div>\n            </m.div>\n          ))}\n        </div>\n\n        {detectedOS && (\n          <div className=\"mt-8 text-center\">\n            <button\n              type=\"button\"\n              onClick={() => setShowAllPlatforms((value) => !value)}\n              className={cx(\n                'inline-flex items-center gap-2 rounded-full border border-border bg-background/70 px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text hover:border-accent/30',\n                focusRing,\n              )}\n            >\n              {showAllPlatforms\n                ? platformT('lessDownloads')\n                : platformT('moreDownloads')}\n              <i\n                className={cx(\n                  'i-mingcute-down-line transition-transform',\n                  showAllPlatforms && 'rotate-180',\n                )}\n                aria-hidden\n              />\n            </button>\n          </div>\n        )}\n\n        <m.div\n          className=\"mt-8\"\n          initial={{ opacity: 0, y: 20 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ ...Spring.presets.smooth, delay: 0.25 }}\n        >\n          <Link\n            href=\"https://app.folo.is\"\n            target=\"_blank\"\n            rel=\"noreferrer noopener\"\n            className={cx(\n              'group block rounded-xl border border-border bg-material-medium/40 backdrop-blur p-6 transition-all duration-200',\n              'hover:bg-material-medium/60 hover:shadow-md',\n              focusRing,\n            )}\n          >\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-4\">\n                <m.i\n                  className=\"i-mingcute-globe-2-line size-8 text-text-secondary group-hover:text-accent transition-colors\"\n                  aria-hidden\n                  whileHover={{ rotate: 360 }}\n                  transition={{ duration: 0.5 }}\n                />\n                <div>\n                  <h3 className=\"text-lg font-semibold text-text\">\n                    {webTitle}\n                  </h3>\n                  <p className=\"text-sm text-text-secondary\">{webSubtitle}</p>\n                </div>\n              </div>\n              <m.i\n                className=\"i-mingcute-arrow-right-line size-6 text-text-secondary group-hover:text-accent transition-colors\"\n                aria-hidden\n                whileHover={{ x: 4 }}\n                transition={{ duration: 0.2 }}\n              />\n            </div>\n          </Link>\n        </m.div>\n\n        <m.p\n          className=\"mt-12 text-center text-sm text-text-tertiary\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={{ delay: 0.4, duration: 0.4 }}\n        >\n          {footer}\n        </m.p>\n      </div>\n    </section>\n  )\n}\n\nconst DownloadChannelCard = ({\n  channel,\n}: {\n  channel: PlatformDownloadChannel\n}) => {\n  return (\n    <Link\n      href={channel.href}\n      target=\"_blank\"\n      rel=\"noreferrer noopener\"\n      className={cx(\n        'group block rounded-xl border border-border bg-material-medium/60 backdrop-blur p-6 transition-all duration-200',\n        'hover:bg-material-thick/70 hover:shadow-lg hover:scale-[1.01]',\n        focusRing,\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-4\">\n        <div className=\"min-w-0\">\n          <h4 className=\"mt-1 truncate text-lg font-semibold text-text\">\n            {channel.name}\n          </h4>\n        </div>\n\n        <m.i\n          className=\"i-mingcute-download-2-line size-6 shrink-0 text-text-secondary group-hover:text-accent transition-colors\"\n          aria-hidden\n          whileHover={{ y: 2 }}\n          transition={{ duration: 0.2 }}\n        />\n      </div>\n    </Link>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/Audience.tsx",
    "content": "'use client'\nimport { AnimatePresence, m } from 'motion/react'\nimport * as React from 'react'\n\nimport { cx, focusRing } from '~/lib/cn'\nimport { Spring } from '~/lib/spring'\n\nexport const Audience: Component = () => {\n  const audiences = React.useMemo(\n    () => [\n      {\n        key: 'researchers',\n        label: 'Folo.is for Researchers',\n        title: (\n          <>\n            <a\n              className=\"text-accent underline underline-offset-4\"\n              href=\"http://Folo.is\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              Folo.is\n            </a>{' '}\n            for Researchers\n          </>\n        ),\n        description:\n          'Have an AI twin that reads the literature, tracks sources, and surfaces only what matters.',\n      },\n      {\n        key: 'builders',\n        label: 'Folo.is for Builders',\n        title: (\n          <>\n            <a\n              className=\"text-accent underline underline-offset-4\"\n              href=\"http://Folo.is\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              Folo.is\n            </a>{' '}\n            for Builders\n          </>\n        ),\n        description:\n          'Turn endless product news and tech updates into focused signals that drive what you build.',\n      },\n      {\n        key: 'creators',\n        label: 'Folo.is for Creators',\n        title: (\n          <>\n            <a\n              className=\"text-accent underline underline-offset-4\"\n              href=\"http://Folo.is\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              Folo.is\n            </a>{' '}\n            for Creators\n          </>\n        ),\n        description:\n          'Stay ahead of trends and conversations without drowning in feeds — AI reads them for you.',\n      },\n      {\n        key: 'investors',\n        label: 'Folo.is for Investors',\n        title: (\n          <>\n            <a\n              className=\"text-accent underline underline-offset-4\"\n              href=\"http://Folo.is\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              Folo.is\n            </a>{' '}\n            for Investors\n          </>\n        ),\n        description:\n          'From markets to memos, let AI digest the noise and deliver the signals that move capital.',\n      },\n    ],\n    [],\n  )\n\n  const [activeIndex, setActiveIndex] = React.useState(0)\n  const buttonsRef = React.useRef<Array<HTMLButtonElement | null>>([])\n\n  const [autoPlay, setAutoPlay] = React.useState(true)\n  const [videoRef, setVideoRef] = React.useState<HTMLDivElement | null>(null)\n\n  React.useEffect(() => {\n    const intersectionObserver = new IntersectionObserver((entries) => {\n      entries.forEach((entry) => {\n        if (entry.isIntersecting) {\n          // entry.target.play()\n          setAutoPlay(true)\n        } else {\n          setAutoPlay(false)\n        }\n      })\n    })\n    if (videoRef) {\n      intersectionObserver.observe(videoRef)\n    }\n    return () => {\n      intersectionObserver.disconnect()\n    }\n  }, [videoRef])\n  // TODO when video plays, stop autoplay\n  // autoplay: cycle every 6s\n  React.useEffect(() => {\n    const id = setInterval(() => {\n      if (!autoPlay) return\n      setActiveIndex((i) => (i + 1) % audiences.length)\n    }, 6000)\n    return () => clearInterval(id)\n  }, [audiences.length, autoPlay])\n\n  const onKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {\n      let next = index\n      switch (e.key) {\n        case 'ArrowRight': {\n          e.preventDefault()\n          next = (index + 1) % audiences.length\n          break\n        }\n        case 'ArrowLeft': {\n          e.preventDefault()\n          next = (index - 1 + audiences.length) % audiences.length\n          break\n        }\n        case 'ArrowDown': {\n          e.preventDefault()\n          next = (index + 1) % audiences.length\n          break\n        }\n        case 'ArrowUp': {\n          e.preventDefault()\n          next = (index - 1 + audiences.length) % audiences.length\n          break\n        }\n        case 'Home': {\n          e.preventDefault()\n          next = 0\n          break\n        }\n        case 'End': {\n          e.preventDefault()\n          next = audiences.length - 1\n          break\n        }\n        case 'Enter': {\n          e.preventDefault()\n          setActiveIndex(index)\n          return\n        }\n        case ' ': {\n          e.preventDefault()\n          setActiveIndex(index)\n          return\n        }\n        default: {\n          break\n        }\n      }\n      if (next !== index) {\n        buttonsRef.current[next]?.focus()\n        setActiveIndex(next)\n      }\n    },\n    [audiences.length],\n  )\n\n  const active = audiences[activeIndex]\n\n  return (\n    <section\n      id=\"audience\"\n      className=\"mx-auto mt-28 md:mt-32 lg:mt-40 w-full max-w-[var(--container-max-width-2xl)] px-4\"\n    >\n      {/* Header */}\n      <div className=\"mx-auto grid max-w-5xl grid-cols-1 items-end gap-8 lg:grid-cols-2\">\n        <h2 className=\"text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl\">\n          Made for modern knowledge workers\n        </h2>\n        <p className=\"text-pretty text-text-secondary\">\n          Folo is shaped by practices that keep you focused: clean timelines,\n          contextual AI, and performance built in. Switch from scattered tabs to\n          signal-first reading.{' '}\n          <a\n            className=\"text-accent underline underline-offset-4\"\n            href=\"https://app.folo.is\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n          >\n            Make the switch\n          </a>\n        </p>\n      </div>\n\n      {/* Stepper Board - steps on top, stage below */}\n      <div className=\"mx-auto mt-10 max-w-5xl flex flex-col gap-4\">\n        {/* Steps (horizontal) */}\n        <div\n          role=\"tablist\"\n          aria-orientation=\"horizontal\"\n          className=\"flex flex-wrap items-center gap-2\"\n        >\n          {audiences.map((item, i) => {\n            const selected = i === activeIndex\n            return (\n              <button\n                key={item.key}\n                ref={(el) => {\n                  buttonsRef.current[i] = el\n                }}\n                role=\"tab\"\n                id={`audience-tab-${item.key}`}\n                aria-selected={selected}\n                aria-controls=\"audience-panel\"\n                tabIndex={selected ? 0 : -1}\n                type=\"button\"\n                onClick={() => setActiveIndex(i)}\n                onKeyDown={(e) => onKeyDown(e, i)}\n                className={cx(\n                  'group inline-flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3 text-left transition-colors',\n                  selected ? 'bg-material-medium/60' : 'hover:bg-fill/60',\n                  focusRing,\n                )}\n              >\n                <span\n                  className={cx(\n                    'tabular-nums text-xs text-text-secondary',\n                    selected && 'text-accent',\n                  )}\n                >\n                  {String(i + 1).padStart(2, '0')}\n                </span>\n                <span\n                  className={cx(\n                    'text-sm font-medium tracking-tight',\n                    selected ? 'text-text' : 'text-text-secondary',\n                  )}\n                >\n                  {item.label}\n                </span>\n              </button>\n            )\n          })}\n        </div>\n\n        {/* Stage */}\n        <div ref={setVideoRef}>\n          <AnimatePresence mode=\"popLayout\">\n            <m.div\n              initial={{ opacity: 0, y: 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 0 }}\n              transition={Spring.presets.smooth}\n              key={active.key}\n              role=\"tabpanel\"\n              id=\"audience-panel\"\n              aria-labelledby={`audience-tab-${active.key}`}\n              className=\"rounded-2xl border border-border bg-background p-4 aspect-[16/9] w-full overflow-hidden\"\n            >\n              <div className=\"relative size-full rounded-xl border border-border bg-fill-secondary\">\n                <div className=\"absolute left-2 top-2 rounded bg-background/60 px-2 py-1 text-xs text-text-secondary\">\n                  video · pending demo app\n                </div>\n              </div>\n              <div className=\"mt-4\">\n                <h3 className=\"text-lg font-semibold tracking-tight sm:text-xl\">\n                  {active.title}\n                </h3>\n                <p className=\"text-pretty text-sm text-text-secondary mt-1\">\n                  {active.description}\n                </p>\n              </div>\n            </m.div>\n          </AnimatePresence>\n        </div>\n      </div>\n    </section>\n  )\n}\n\nAudience.displayName = 'Audience'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/BuiltOpen.tsx",
    "content": "import { useTranslations } from 'next-intl'\n\nimport GithubTrending from '~/components/common/GithubTrending'\n\nimport { RepoStats } from './RepoStats'\n\nexport const BuiltOpen: Component = () => {\n  const builtOpenT = useTranslations('landing.builtOpen')\n\n  return (\n    <section\n      id=\"open\"\n      className=\"mx-auto mt-24 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-28 lg:mt-32\"\n    >\n      <div className=\"flex flex-col gap-8\">\n        <div>\n          <p className=\"text-[11px] font-medium uppercase tracking-[0.28em] text-accent/90\">\n            {builtOpenT('title')}\n          </p>\n          <h2 className=\"mt-2 text-2xl font-semibold tracking-tight\">\n            {builtOpenT('body')}\n          </h2>\n          <p className=\"mt-3 text-sm text-text-secondary\">\n            {builtOpenT('note')}\n          </p>\n\n          <div className=\"mt-5\">\n            <GithubTrending />\n          </div>\n        </div>\n\n        <RepoStats />\n      </div>\n    </section>\n  )\n}\n\nBuiltOpen.displayName = 'BuiltOpen'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/Features.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport * as React from 'react'\n\nimport { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'\nimport { cx } from '~/lib/cn'\nimport type { DiscoverSource } from '~/lib/landing-data'\n\nimport { EntryPageDemo } from '../simulators/EntryPage'\nimport { WindowChrome } from './WindowChrome'\n\ntype FeaturesProps = {\n  discoverSources: DiscoverSource[]\n}\n\nexport const Features: Component<FeaturesProps> = ({ discoverSources }) => {\n  const featuresT = useTranslations('landing.features')\n\n  return (\n    <section\n      id=\"features\"\n      className=\"mx-auto mt-24 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-28 lg:mt-32\"\n    >\n      <div className=\"mx-auto max-w-5xl text-center\">\n        <h2 className=\"text-4xl font-semibold tracking-tight\">\n          {featuresT('headline')}\n        </h2>\n      </div>\n\n      <div className=\"mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-y-20\">\n        <FeatureGridItem\n          eyebrow={\n            <span className=\"inline-flex items-center gap-2 text-sm text-text-secondary\">\n              <i\n                className=\"i-mingcute-compass-3-line text-accent\"\n                aria-hidden\n              />\n              {featuresT('discover.label')}\n            </span>\n          }\n          titleStrong={featuresT('discover.titleStrong')}\n          titleRest={featuresT('discover.titleRest')}\n        >\n          <DiscoverWindow sources={discoverSources} />\n        </FeatureGridItem>\n\n        <FeatureGridItem\n          eyebrow={\n            <span className=\"inline-flex items-center gap-2 text-sm text-text-secondary\">\n              <i className=\"i-mingcute-sparkles-line text-accent\" aria-hidden />\n              {featuresT('vibe.label')}\n            </span>\n          }\n          titleStrong={featuresT('vibe.titleStrong')}\n          titleRest={featuresT('vibe.titleRest')}\n        >\n          <WindowChrome>\n            <div className=\"relative h-[880px] w-full overflow-hidden bg-background-secondary lg:h-[720px]\">\n              <EntryPageDemo />\n            </div>\n          </WindowChrome>\n        </FeatureGridItem>\n      </div>\n    </section>\n  )\n}\n\ntype FGProps = {\n  eyebrow: React.ReactNode\n  titleStrong: string\n  titleRest?: string\n  children: React.ReactNode\n  className?: string\n}\n\nfunction FeatureGridItem({\n  eyebrow,\n  titleStrong,\n  titleRest,\n  children,\n  className,\n}: FGProps) {\n  return (\n    <div className={cx('relative', className)}>\n      <div className=\"mb-3 flex items-center gap-2 pl-1\">{eyebrow}</div>\n      <h3 className=\"text-xl font-semibold leading-snug tracking-tight sm:text-[2rem]\">\n        {titleStrong}{' '}\n        {titleRest ? (\n          <span className=\"font-normal text-text-secondary\">{titleRest}</span>\n        ) : null}\n      </h3>\n      <div className=\"mt-5\">{children}</div>\n    </div>\n  )\n}\n\nfunction DiscoverWindow({ sources }: { sources: DiscoverSource[] }) {\n  const [allSources, setAllSources] = React.useState(sources)\n\n  React.useEffect(() => {\n    let cancelled = false\n\n    const load = async () => {\n      try {\n        const response = await fetch('/discover-sources.json')\n        if (!response.ok) return\n\n        const nextSources = (await response.json()) as DiscoverSource[]\n        if (!cancelled && nextSources.length > 0) {\n          setAllSources(nextSources)\n        }\n      } catch {\n        // Ignore network failures in dev and keep the bundled fallback data.\n      }\n    }\n\n    void load()\n\n    return () => {\n      cancelled = true\n    }\n  }, [])\n\n  return (\n    <WindowChrome>\n      <div className=\"bg-background-secondary p-6 md:p-8\">\n        <div className=\"rounded-[30px] bg-background/92 px-5 py-5 shadow-[0_24px_80px_-56px_rgba(0,0,0,0.3)]\">\n          <div className=\"flex items-end justify-between gap-4\">\n            <div>\n              <p className=\"text-sm font-semibold text-text\">\n                {allSources.length.toLocaleString('en-US')} official sources\n              </p>\n              <p className=\"mt-1 text-xs text-text-tertiary\">\n                Browse supported websites.\n              </p>\n            </div>\n          </div>\n\n          <ScrollArea rootClassName=\"mt-5 h-[560px]\" viewportClassName=\"pr-3\">\n            <div className=\"grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4\">\n              {allSources.map((source) => (\n                <div\n                  key={source.host}\n                  className=\"flex items-center gap-3 rounded-2xl bg-background-secondary/80 px-3 py-3\"\n                >\n                  <div className=\"flex size-10 items-center justify-center rounded-2xl bg-white p-2 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.35)]\">\n                    <img\n                      src={`https://icons.folo.is/${source.host}`}\n                      alt={source.name}\n                      className=\"size-full rounded-xl object-cover\"\n                      loading=\"lazy\"\n                    />\n                  </div>\n\n                  <div className=\"min-w-0\">\n                    <p className=\"truncate text-sm font-medium text-text\">\n                      {source.name}\n                    </p>\n                    <p className=\"truncate text-xs text-text-tertiary\">\n                      {source.host}\n                    </p>\n                  </div>\n                </div>\n              ))}\n            </div>\n          </ScrollArea>\n        </div>\n      </div>\n    </WindowChrome>\n  )\n}\n\nFeatures.displayName = 'Features'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/Header.tsx",
    "content": "'use client'\n\nimport Link from 'next/link'\nimport { useTranslations } from 'next-intl'\nimport { LinearBlur } from 'progressive-blur'\n\nimport { useIsMobile } from '~/atoms'\nimport { Folo } from '~/components/brand/Folo'\nimport { Logo } from '~/components/brand/Logo'\nimport { Button } from '~/components/ui/button/Button'\nimport { GlassSurface } from '~/components/ui/glass'\nimport { Link as LocalizedLink } from '~/i18n/routing'\nimport { cn } from '~/lib/cn'\nimport { usePageScrollLocationSelector } from '~/providers/root/page-scroll-info-provider'\n\nexport const LandingHeader: Component = () => {\n  // const { data: githubStars } = useGithubStar()\n\n  // const formatStars = (n?: number) =>\n  //   typeof n === 'number' && n >= 0 ? n.toLocaleString('en-US') : 'GitHub'\n\n  const isMobile = useIsMobile()\n  const isOverflowPage = usePageScrollLocationSelector((s) => s > 100)\n  const actionsT = useTranslations('common.actions')\n  const navItems = [\n    { label: 'Download', href: '/download' },\n    { label: 'Pricing', href: '/pricing' },\n  ] as const\n\n  return (\n    <div className=\"fixed inset-x-0 top-0 z-50\">\n      <LinearBlur className=\"absolute top-0 inset-x-0 h-16 z-[-1]\" />\n\n      <div className=\"mx-auto w-full max-w-5xl relative\">\n        <nav\n          aria-label=\"Primary\"\n          className=\"flex items-center justify-between px-3 lg:h-20 relative h-16 lg:-mx-6\"\n        >\n          {!isMobile && (\n            <GlassSurface\n              blueOffset={20}\n              blur={10}\n              backgroundOpacity={0.5}\n              displace={5}\n              className={cn(\n                'absolute left-0 top-[10px] z-[-1] transition-opacity duration-300',\n                !isOverflowPage && 'opacity-0',\n              )}\n              borderRadius={9999}\n              height={60}\n              width={'100%'}\n            />\n          )}\n\n          {isMobile && (\n            <div\n              className={cn(\n                'absolute left-0 top-0 z-[-1] transition-opacity duration-300 backdrop-blur-background h-16 w-full border-b border-border',\n                !isOverflowPage && 'opacity-0',\n              )}\n            />\n          )}\n          <div className=\"flex items-center gap-8\">\n            <LocalizedLink href=\"/\" className=\"flex items-center gap-2 ml-4\">\n              <Logo width={26} height={26} aria-hidden accentColor=\"#FF5C00\" />\n\n              <Folo className=\"size-8\" />\n            </LocalizedLink>\n\n            <ul className=\"hidden items-center gap-5 md:flex\">\n              {navItems.map((item) => (\n                <li key={item.href}>\n                  <LocalizedLink\n                    href={item.href}\n                    className=\"text-sm text-text-secondary transition-colors hover:text-text\"\n                  >\n                    {item.label}\n                  </LocalizedLink>\n                </li>\n              ))}\n            </ul>\n          </div>\n\n          {/* Actions */}\n          <div className=\"flex items-center gap-2\">\n            {/* <Link\n              href=\"https://github.com/RSSNext/Folo\"\n              target=\"_blank\"\n              rel=\"noreferrer noopener\"\n              className={cx(\n                'hidden md:inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors',\n                'border-border bg-background text-text hover:bg-fill-secondary',\n              )}\n              aria-label=\"Star Folo on GitHub\"\n            >\n              <i className=\"i-simple-icons-github size-4\" aria-hidden />\n              <span className=\"whitespace-nowrap\">GitHub Stars</span>\n              <i\n                className=\"i-mingcute-star-fill text-[oklch(var(--color-yellow-60))]\"\n                aria-hidden\n              />\n              <span className=\"tabular-nums\">{formatStars(githubStars)}</span>\n            </Link> */}\n\n            <Link\n              href=\"https://app.folo.is\"\n              target=\"_blank\"\n              rel=\"noreferrer noopener\"\n            >\n              <Button size=\"md\" className=\"px-4 py-2\">\n                {actionsT('getStarted')}\n              </Button>\n            </Link>\n\n            {/* Mobile hamburger */}\n            {/* <PresentSheet\n              title=\"\"\n              content={\n                <MobileMenuContent\n                  githubStarsLabel={formatStars(githubStars)}\n                />\n              }\n            >\n              <button\n                type=\"button\"\n                aria-label=\"Open menu\"\n                className={cx(\n                  'md:hidden inline-flex items-center justify-center rounded-full border border-border bg-material-medium/60 p-2',\n                  focusRing,\n                )}\n              >\n                <i className=\"i-mingcute-menu-line size-4\" aria-hidden />\n              </button>\n            </PresentSheet> */}\n          </div>\n        </nav>\n      </div>\n    </div>\n  )\n}\n\nLandingHeader.displayName = 'LandingHeader'\n\n// function MobileMenuContent({ githubStarsLabel }: { githubStarsLabel: string }) {\n//   return (\n//     <div className=\"space-y-4\">\n//       <nav className=\"flex flex-col gap-2\">\n//         {NAV_ITEMS.map((item) => (\n//           <a\n//             key={item.id}\n//             href={`#${item.id}`}\n//             className=\"rounded-lg px-3 py-2 text-sm text-text hover:bg-fill\"\n//           >\n//             {item.label}\n//           </a>\n//         ))}\n//       </nav>\n\n//       <div className=\"h-px bg-border\" />\n\n//       <div className=\"flex flex-col gap-3\">\n//         <Link\n//           href=\"https://github.com/RSSNext/Folo\"\n//           target=\"_blank\"\n//           rel=\"noreferrer noopener\"\n//           className={cx(\n//             'inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors',\n//             'border-border bg-background text-text hover:bg-fill-secondary',\n//           )}\n//           aria-label=\"Star Folo on GitHub\"\n//         >\n//           <i className=\"i-simple-icons-github size-4\" aria-hidden />\n//           <span className=\"whitespace-nowrap\">GitHub Stars</span>\n//           <i\n//             className=\"i-mingcute-star-fill text-[oklch(var(--color-yellow-60))]\"\n//             aria-hidden\n//           />\n//           <span className=\"tabular-nums\">{githubStarsLabel}</span>\n//         </Link>\n\n//         <Link\n//           href=\"https://app.folo.is\"\n//           target=\"_blank\"\n//           rel=\"noreferrer noopener\"\n//           className=\"relative w-full flex\"\n//           legacyBehavior\n//         >\n//           <Button className=\"flex\">Download</Button>\n//         </Link>\n//       </div>\n//     </div>\n//   )\n// }\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/Hero.tsx",
    "content": "'use client'\n\nimport clsx from 'clsx'\nimport { m, useScroll, useTransform } from 'motion/react'\nimport NextLink from 'next/link'\nimport { useTranslations } from 'next-intl'\nimport * as React from 'react'\nimport { useRef } from 'react'\nimport { useResizable } from 'react-resizable-layout'\n\nimport { useIsMobile } from '~/atoms'\nimport { BorderBeam } from '~/components/ui/border-beam'\nimport { Button } from '~/components/ui/button'\nimport { ParticlesAura } from '~/components/ui/effects/ParticlesAura'\nimport { TiltCard } from '~/components/ui/effects/TiltCard'\nimport { Highlighter } from '~/components/ui/highlighter'\nimport { PanelSplitter } from '~/components/ui/panel/PanelSplitter'\nimport { SegmentTab } from '~/components/ui/segment-tab'\nimport { Link as LocalizedLink } from '~/i18n/routing'\nimport type { HeroTimelineItem } from '~/lib/landing-data'\n\nimport { ListDemo } from '../simulators/ListDemo'\nimport { TimelineChatDemo } from '../simulators/TimelineChatDemo'\nimport { WindowChrome } from './WindowChrome'\n\ntype LandingHeroProps = {\n  items: HeroTimelineItem[]\n}\n\ntype AudienceKey = 'human' | 'agent'\n\nexport const LandingHero: Component<LandingHeroProps> = ({ items }) => {\n  const ref = React.useRef<HTMLElement | null>(null)\n  const { scrollYProgress } = useScroll({\n    target: ref,\n    offset: ['start end', 'end start'],\n  })\n  const bgY = useTransform(scrollYProgress, [0, 1], [0, -150])\n  const heroT = useTranslations('landing.hero')\n  const actionsT = useTranslations('common.actions')\n  const [audience, setAudience] = React.useState<AudienceKey>('human')\n\n  const audienceItems: {\n    value: AudienceKey\n    label: string\n    icon: React.ReactNode\n  }[] = [\n    {\n      value: 'human',\n      label: heroT('humanTab'),\n      icon: (\n        <i className=\"i-mingcute-user-3-line size-4 shrink-0\" aria-hidden />\n      ),\n    },\n    {\n      value: 'agent',\n      label: heroT('agentTab'),\n      icon: (\n        <i className=\"i-mingcute-android-2-line size-4 shrink-0\" aria-hidden />\n      ),\n    },\n  ]\n\n  return (\n    <section ref={ref} className=\"relative isolate w-full\">\n      {/* Background glow + ultra-subtle grid */}\n      <m.div\n        className=\"pointer-events-none absolute inset-x-0 -inset-y-8 -z-10\"\n        style={{ y: bgY }}\n      >\n        <div className=\"mx-auto h-[420px] w-[900px] rounded-full bg-accent/10 blur-[140px]\" />\n        <div\n          className={clsx(\n            'pointer-events-none absolute inset-0 hidden md:block',\n            'dark:bg-[linear-gradient(to_right,rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.035)_1px,transparent_1px)] [background-size:48px_48px,48px_48px]',\n          )}\n        />\n      </m.div>\n\n      <div className=\"max-w-max-width-2xl px-4 mx-auto mt-28\">\n        <div className=\"mx-auto max-w-5xl text-left\">\n          <h1 className=\"text-text mt-4 text-4xl font-semibold leading-[1.05] tracking-tight text-balance md:text-7xl\">\n            {heroT.rich('title', {\n              brand: (chunks) => (\n                <span className=\"bg-linear-to-r from-accent to-accent/70 bg-clip-text text-transparent\">\n                  {chunks}\n                </span>\n              ),\n              highlight: (chunks) => (\n                <Highlighter action=\"underline\" color=\"#FF9800\">\n                  <span className=\"bg-linear-to-r from-accent to-accent/70 bg-clip-text text-transparent\">\n                    {chunks}\n                  </span>\n                </Highlighter>\n              ),\n            })}\n          </h1>\n          <div className=\"text-text-secondary mt-6 max-w-2xl text-lg md:text-xl\">\n            <p>{heroT('bodyLine1')}</p>\n            <p>{heroT('bodyLine2')}</p>\n          </div>\n\n          <div className=\"mt-8 max-w-xl\">\n            <SegmentTab\n              items={audienceItems}\n              value={audience}\n              onChange={(value) => setAudience(value as AudienceKey)}\n              containerClassName=\"inline-flex w-fit max-w-full rounded-full border-border/70 bg-background/45 shadow-[0_20px_40px_-32px_rgba(255,107,0,0.4)]\"\n              className=\"!inset-y-0 !rounded-full !border-accent/20 !bg-background !shadow-[0_12px_24px_-16px_rgba(255,107,0,0.6)]\"\n              activeClassName=\"text-accent\"\n              inactiveClassName=\"text-text-secondary/75 hover:text-text\"\n              distribution=\"fit\"\n              size=\"lg\"\n              responsiveWrap\n            />\n\n            {audience === 'human' ? (\n              <div className=\"mt-4 flex flex-wrap items-center gap-3\">\n                <NextLink\n                  href=\"https://app.folo.is\"\n                  target=\"_blank\"\n                  rel=\"noreferrer noopener\"\n                >\n                  <span className=\"relative inline-flex\">\n                    <Button className=\"group relative overflow-hidden rounded-xl bg-accent px-5 py-2.5 text-base text-accent-foreground shadow-[0_0_0_1px_var(--color-accent-40)] ![filter:drop-shadow(0_0_24px_color-mix(in_oklab,var(--color-accent)_35%,transparent))]\">\n                      <span\n                        aria-hidden\n                        className={clsx(\n                          'pointer-events-none absolute -inset-1 rounded-[inherit] opacity-70 blur-md',\n                          'bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--color-accent)_55%,transparent)_0%,transparent_70%)]',\n                        )}\n                      />\n\n                      <span className=\"relative z-10 inline-flex items-center\">\n                        {actionsT('getStarted')}\n                      </span>\n                      <BorderBeam colorFrom=\"#fff\" colorTo=\"#ff5c00\" />\n                    </Button>\n\n                    <ParticlesAura className=\"-inset-2\" />\n                  </span>\n                </NextLink>\n                <LocalizedLink href=\"/download\">\n                  <Button variant=\"ghost\">\n                    <span className=\"relative z-10 inline-flex items-center text-base\">\n                      {actionsT('download')}\n                    </span>\n                  </Button>\n                </LocalizedLink>\n              </div>\n            ) : (\n              <div className=\"bg-material-medium/60 border-border mt-4 rounded-2xl border p-5 backdrop-blur-md\">\n                <p className=\"text-text text-sm font-semibold\">\n                  {heroT('agentTitle')}\n                </p>\n                <p className=\"text-text-secondary mt-2 text-sm leading-6\">\n                  {heroT.rich('agentBody', {\n                    skill: (chunks) => (\n                      <a\n                        href=\"https://api.folo.is/skill.md\"\n                        target=\"_blank\"\n                        rel=\"noreferrer noopener\"\n                        className=\"text-accent underline decoration-accent/40 underline-offset-4 transition-colors hover:text-accent/80\"\n                      >\n                        {chunks}\n                      </a>\n                    ),\n                  })}\n                </p>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Preview card with tilt + parallax; prompt focus zoom */}\n        <PreviewAppDemo items={items} />\n      </div>\n    </section>\n  )\n}\n\nconst PreviewAppDemo = ({ items }: LandingHeroProps) => {\n  const layoutContainerRef = useRef<HTMLDivElement | null>(null)\n  const {\n    position: columnWidth,\n    separatorProps,\n    isDragging: isDragging,\n  } = useResizable({\n    axis: 'x',\n    min: 300,\n    max: 500,\n    initial: 300,\n    containerRef: layoutContainerRef as React.RefObject<HTMLElement>,\n  })\n\n  const isMobile = useIsMobile()\n\n  return (\n    <div className=\"mx-auto mt-10 max-w-5xl relative\">\n      <TiltCard intensity={3} glare className=\"tilt\">\n        <WindowChrome showTryOnWeb={false}>\n          <div\n            ref={layoutContainerRef}\n            className=\"relative lg:aspect-video h-[800px] lg:h-auto w-full bg-background-secondary\"\n          >\n            <div style={{ width: columnWidth }} className=\"size-full\">\n              <ListDemo items={items} />\n              {/* <ListSkeletonDemo /> */}\n            </div>\n\n            <div\n              className=\"absolute right-0 inset-y-0\"\n              style={{ left: columnWidth }}\n            >\n              <PanelSplitter {...separatorProps} isDragging={isDragging} />\n            </div>\n            <div\n              className=\"absolute lg:right-0 inset-0 border-t lg:border-t-0  lg:top-0 top-1/6 lg:border-l\"\n              style={{ left: isMobile ? undefined : columnWidth }}\n            >\n              {isMobile && (\n                <div\n                  className=\"absolute top-0 inset-x-0 shadow-2xl z-0\"\n                  style={{\n                    boxShadow: '0 25px 50px 63px #00000020',\n                  }}\n                />\n              )}\n              <div className=\"size-full z-1 relative\">\n                <TimelineChatDemo />\n              </div>\n            </div>\n          </div>\n        </WindowChrome>\n      </TiltCard>\n    </div>\n  )\n}\nLandingHero.displayName = 'LandingHero'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/PromptDemo.tsx",
    "content": "'use client'\n\nimport * as React from 'react'\n\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\n\nexport const PromptDemo: Component = () => {\n  const [value, setValue] = React.useState(\n    'Summarize this article in 3 key points',\n  )\n  const [reply, setReply] = React.useState<string | null>(null)\n  const [loading, setLoading] = React.useState(false)\n\n  const handleAsk = async () => {\n    setLoading(true)\n    // Fake response\n    await new Promise((r) => setTimeout(r, 600))\n    setReply(\n      '1) Key idea extracted. 2) Context preserved. 3) Actionable insight surfaced.',\n    )\n    setLoading(false)\n  }\n\n  return (\n    <section className=\"mx-auto mt-14 w-full max-w-[var(--container-max-width-2xl)] px-4\">\n      <div className=\"mx-auto max-w-3xl text-center\">\n        <h2 className=\"text-2xl font-semibold tracking-tight\">\n          Ask in context\n        </h2>\n        <p className=\"text-text-secondary mt-2\">\n          Translate, summarize, and ask follow-ups—right inside what you read.\n        </p>\n      </div>\n\n      <div className=\"border-border bg-material-medium/60 mx-auto mt-6 max-w-3xl rounded-xl border p-3 backdrop-blur-md\">\n        <div className=\"border-border bg-background rounded-lg border p-3\">\n          <div className=\"flex items-center gap-2\">\n            <Input\n              value={value}\n              onChange={(e) => setValue(e.target.value)}\n              placeholder=\"Ask anything…\"\n              className=\"flex-1\"\n            />\n            <Button\n              onClick={handleAsk}\n              isLoading={loading}\n              loadingText=\"Asking…\"\n            >\n              Ask\n            </Button>\n          </div>\n          {reply && (\n            <div className=\"border-border bg-fill text-text-secondary mt-3 rounded-lg border p-3 text-sm\">\n              {reply}\n            </div>\n          )}\n        </div>\n      </div>\n    </section>\n  )\n}\n\nPromptDemo.displayName = 'PromptDemo'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/RepoStats.tsx",
    "content": "'use client'\nimport Link from 'next/link'\nimport { useTranslations } from 'next-intl'\nimport * as React from 'react'\n\nimport { useGithubStar } from '~/hooks/biz/use-github-star'\nimport { cx, focusRing } from '~/lib/cn'\n\nexport function RepoStats() {\n  const { data, isLoading } = useGithubStar()\n  const stars = typeof data === 'number' && data >= 0 ? data : undefined\n  const t = useTranslations('landing.repoStats')\n\n  const format = (n?: number) =>\n    typeof n === 'number' ? n.toLocaleString('en-US') : '—'\n\n  return (\n    <div className=\"grid grid-cols-2 sm:grid-cols-3 gap-2 z-[1]\">\n      <StatBox\n        icon=\"i-mingcute-star-fill text-[oklch(var(--color-yellow-60))]\"\n        label={t('githubStars')}\n        value={isLoading ? '…' : format(stars)}\n      />\n\n      <StatBox\n        icon=\"i-mingcute-shield-line\"\n        label={t('license')}\n        value={t('licenseValue')}\n      />\n\n      <Link\n        href=\"https://github.com/RSSNext/Folo\"\n        target=\"_blank\"\n        rel=\"noreferrer noopener\"\n        className={cx(\n          'rounded-lg bg-material-medium/60 hover:bg-fill-secondary transition-colors px-3 py-2 flex',\n          'flex flex-col relative align-start text-left justify-start',\n          'col-span-2 lg:col-span-1',\n          focusRing,\n        )}\n        aria-label={t('repoAriaLabel')}\n      >\n        <div className=\"flex items-center gap-2 text-text\">\n          <i className=\"i-simple-icons-github size-4\" aria-hidden />\n          <span className=\"text-sm\">{t('repository')}</span>\n        </div>\n        <div className=\"mt-1 text-base tabular-nums text-text-tertiary\">\n          RSSNext/Folo\n        </div>\n        <i\n          className=\"i-mingcute-arrow-right-up-line size-4 text-text-tertiary absolute right-3 top-8 lg:top-1/2 -translate-y-1/2\"\n          aria-hidden\n        />\n\n        <p className=\"text-xs block lg:hidden text-text-secondary mt-2\">\n          {t('repoDescriptionMobile')}\n        </p>\n      </Link>\n    </div>\n  )\n}\n\nfunction StatBox({\n  icon,\n  label,\n  value,\n}: {\n  icon: string\n  label: string\n  value: string | number\n}) {\n  return (\n    <div className=\"rounded-lg z-[1] bg-material-medium/60 px-3 py-2\">\n      <div className=\"flex items-center gap-2 text-text\">\n        <i className={cx('size-4', icon)} aria-hidden />\n        <span className=\"text-sm\">{label}</span>\n      </div>\n      <div className=\"mt-1 text-base tabular-nums text-text-tertiary\">\n        {value}\n      </div>\n    </div>\n  )\n}\n\nRepoStats.displayName = 'RepoStats'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/SocialProof.tsx",
    "content": "import { useTranslations } from 'next-intl'\nimport * as React from 'react'\n\nimport { GridGuides } from '~/components/ui/effects/GridGuides'\nimport { MagicCard } from '~/components/ui/magic-card'\n\nconst tweetList = [\n  {\n    id: '1833056589135442345',\n    text: \"Very nice news aggregation, and it gives 2 power token everyday, so far I just try move front end people I followed in, haven't done yet, will try move more rss subscribe.\",\n    name: '🦋 AnneInCoding',\n    screenName: '@anneincoding',\n    profileImageUrl:\n      'https://pbs.twimg.com/profile_images/1785961711150960640/33lS68gu_normal.jpg',\n  },\n  {\n    id: '1832896505528930551',\n    text: 'Folo is sick!!!! It is indeed the best RSS app on this planet, way better than any pure RSS app such as Reeder, Inoreader, or any apps with RSS features like Readwise Reader!!!',\n    name: \"Poor Delmar's Handbook\",\n    screenName: '@delmarshandbook',\n    profileImageUrl:\n      'https://pbs.twimg.com/profile_images/1351213836100120579/x-n-YSQR_normal.jpg',\n  },\n  {\n    id: '1832725192860393593',\n    text: 'Thanks for making information such a pleasant thing.',\n    name: '$H!NDGEKYUME',\n    screenName: '@shindgewongxj',\n    profileImageUrl:\n      'https://pbs.twimg.com/profile_images/1926131191217840128/TzsBgEv-_normal.png',\n  },\n  {\n    id: '1819361867359535603',\n    text: \"Just switched my RSS reader to Folo and it's a game-changer! The built-in AI summaries are saving me so much time. If you're drowning in feeds, this app might be your new best friend.\",\n    name: 'runes780',\n    screenName: '@runes780',\n    profileImageUrl:\n      'https://pbs.twimg.com/profile_images/815928464150700032/1FJAoURS_normal.jpg',\n  },\n\n  {\n    id: '1818653250381574206',\n    text: \"Awesome! I'm using it too.\",\n    name: 'MingCute',\n    screenName: '@MingCute_icon',\n    profileImageUrl:\n      'https://pbs.twimg.com/profile_images/1785665962752233472/vU2SVqok_normal.jpg',\n  },\n  {\n    id: 'manual-1',\n    text: \"I'm really enjoying it so far... it's super smooth and gorgeous. It being multiplatform is amazing, cuz there's literally zero good rss apps for windows.\",\n    name: '@adamfergusonart',\n    screenName: 'Adam',\n    profileImageUrl:\n      'https://pbs.twimg.com/profile_images/1787910265876500480/RfnkdD9r_400x400.jpg',\n  },\n]\n\nexport const SocialProof: Component = () => {\n  const socialT = useTranslations('landing.socialProof')\n  const actionsT = useTranslations('common.actions')\n  const spans = [\n    'md:col-span-3 lg:col-span-4',\n    'md:col-span-3 lg:col-span-4',\n    'md:col-span-3 lg:col-span-4',\n    'md:col-span-3 lg:col-span-4',\n    'md:col-span-3 lg:col-span-4',\n  ] as const\n\n  const getTweetUrl = (tweet: (typeof tweetList)[number]) => {\n    if (tweet.id.startsWith('manual-')) return '#'\n    return `https://twitter.com/${tweet.screenName.replace('@', '')}/status/${tweet.id}`\n  }\n\n  return (\n    <section\n      id=\"social\"\n      className=\"relative z-[1] mx-auto mt-28 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-32 lg:mt-40\"\n    >\n      <GridGuides />\n\n      <header className=\"mx-auto max-w-5xl\">\n        <p className=\"mb-2 inline-flex items-center gap-2 rounded-full border border-border/80 bg-material-medium/60 px-3 py-1 text-[11px] font-medium text-text-secondary backdrop-blur\">\n          <i className=\"i-lucide-heart text-accent\" aria-hidden />\n          {socialT('eyebrow')}\n        </p>\n        <h2 className=\"text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl\">\n          {socialT('headline')}\n        </h2>\n      </header>\n\n      {/* Masonry-style grid with variable spans */}\n      <div className=\"mt-8 grid grid-cols-1 auto-rows-auto gap-4 md:grid-cols-6 lg:grid-cols-12\">\n        {tweetList.map((tweet, idx) => (\n          <MagicCard\n            key={tweet.id}\n            className={`${spans[idx % spans.length]} rounded-2xl h-full`}\n            gradientSize={240}\n            gradientFrom=\"#ff5c00\"\n            gradientTo=\"#ff3e03\"\n            gradientColor=\"#1111\"\n            gradientOpacity={0.1}\n          >\n            <article className=\"flex h-full flex-col p-6\">\n              {/* Header with profile */}\n              <header className=\"mb-4 flex items-start gap-3\">\n                <img\n                  src={tweet.profileImageUrl}\n                  alt={`${tweet.name} profile`}\n                  className=\"size-10 shrink-0 rounded-full border border-border/50\"\n                  loading=\"lazy\"\n                />\n                <div className=\"min-w-0 flex-1\">\n                  <p className=\"truncate text-sm font-semibold text-text\">\n                    {tweet.name}\n                  </p>\n                  <p className=\"truncate text-xs text-text-tertiary\">\n                    {tweet.screenName}\n                  </p>\n                </div>\n                <i\n                  className=\"i-mingcute-twitter-line shrink-0 text-lg text-text-tertiary/60\"\n                  aria-hidden\n                />\n              </header>\n\n              {/* Tweet content */}\n              <blockquote className=\"flex-1 text-pretty text-[13px] leading-relaxed text-text-secondary\">\n                {tweet.text}\n              </blockquote>\n\n              {/* Footer with link */}\n              {!tweet.id.startsWith('manual-') && (\n                <footer className=\"mt-4 pt-4 border-t border-border/30\">\n                  <a\n                    href={getTweetUrl(tweet)}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"inline-flex items-center gap-1.5 text-xs font-medium text-text-tertiary transition-colors hover:text-accent\"\n                  >\n                    {actionsT('viewOnTwitter')}\n                    <i\n                      className=\"i-lucide-arrow-up-right text-sm\"\n                      aria-hidden\n                    />\n                  </a>\n                </footer>\n              )}\n            </article>\n          </MagicCard>\n        ))}\n      </div>\n    </section>\n  )\n}\n\nSocialProof.displayName = 'SocialProof'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/TrustedBy.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\n\nimport type { LandingMetrics, TrustedCompany } from '~/lib/landing-data'\n\ntype TrustedByProps = {\n  companies: TrustedCompany[]\n  metrics: LandingMetrics\n}\n\nconst compactFormatter = new Intl.NumberFormat('en-US', {\n  notation: 'compact',\n  maximumFractionDigits: 1,\n})\n\nconst formatCompact = (value: number) => compactFormatter.format(value)\n\nexport const TrustedBy: Component<TrustedByProps> = ({\n  companies,\n  metrics,\n}) => {\n  const trustedT = useTranslations('landing.trustedBy')\n  const metricsT = useTranslations('landing.metrics')\n\n  const metricCards = [\n    {\n      label: metricsT('cards.feeds.label'),\n      value: formatCompact(metrics.feeds),\n    },\n    {\n      label: metricsT('cards.entries.label'),\n      value: formatCompact(metrics.entries),\n    },\n  ]\n\n  return (\n    <section className=\"mx-auto mt-14 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-16 lg:mt-20\">\n      <div className=\"flex flex-col gap-16\">\n        <div className=\"flex flex-col items-center gap-5 text-center\">\n          <p className=\"text-[11px] font-medium uppercase tracking-[0.28em] text-accent/90\">\n            {trustedT('eyebrow')}\n          </p>\n\n          <div className=\"flex flex-wrap items-center justify-center gap-x-8 gap-y-4\">\n            {companies.map((company) => (\n              <div\n                key={company.name}\n                className=\"inline-flex items-center gap-2.5\"\n              >\n                <div className=\"flex size-8 items-center justify-center rounded-full bg-background-secondary p-1\">\n                  <img\n                    src={`https://icons.folo.is/${company.host}`}\n                    alt={company.name}\n                    className=\"size-full rounded-full object-cover grayscale\"\n                    loading=\"lazy\"\n                  />\n                </div>\n                <span className=\"text-sm font-medium text-text\">\n                  {company.name}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"flex flex-col items-center gap-4 text-center\">\n          <p className=\"text-[11px] font-medium uppercase tracking-[0.28em] text-text-tertiary\">\n            {metricsT('headline')}\n          </p>\n          <p className=\"text-sm text-text-secondary\">{metricsT('body')}</p>\n\n          <div className=\"flex flex-wrap items-end justify-center gap-x-14 gap-y-4\">\n            {metricCards.map((card) => (\n              <div key={card.label}>\n                <p className=\"text-[11px] uppercase tracking-[0.22em] text-text-tertiary\">\n                  {card.label}\n                </p>\n                <p className=\"mt-1 text-3xl font-semibold tracking-tight text-text\">\n                  {card.value}\n                </p>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </section>\n  )\n}\n\nTrustedBy.displayName = 'TrustedBy'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/ViewsShowcase.tsx",
    "content": "'use client'\n\nimport * as React from 'react'\n\nimport { SegmentTab } from '~/components/ui/segment-tab'\n\ntype ViewKey = 'list' | 'social' | 'masonry' | 'grid'\n\nconst items = [\n  { value: 'list', label: 'List' },\n  { value: 'social', label: 'Social' },\n  { value: 'masonry', label: 'Masonry' },\n  { value: 'grid', label: 'Grid' },\n] as const satisfies { value: ViewKey; label: string }[]\n\nconst ViewContent: React.FC<{ view: ViewKey }> = ({ view }) => {\n  switch (view) {\n    case 'list': {\n      return (\n        <div className=\"flex flex-col gap-2\">\n          {Array.from({ length: 6 }).map((_, i) => (\n            <div\n              key={i}\n              className=\"border-border bg-fill-secondary h-14 rounded-lg border\"\n            />\n          ))}\n        </div>\n      )\n    }\n    case 'social': {\n      return (\n        <div className=\"flex flex-col gap-3\">\n          {Array.from({ length: 4 }).map((_, i) => (\n            <div\n              key={i}\n              className=\"border-border bg-fill-secondary rounded-xl border p-3\"\n            >\n              <div className=\"bg-fill-tertiary mb-2 h-3 w-24 rounded\" />\n              <div className=\"bg-fill-tertiary h-4 w-3/4 rounded\" />\n            </div>\n          ))}\n        </div>\n      )\n    }\n    case 'masonry': {\n      return (\n        <div className=\"columns-2 gap-2 md:columns-3\">\n          {Array.from({ length: 9 }).map((_, i) => (\n            <div\n              key={i}\n              className=\"border-border bg-fill-secondary mb-2 break-inside-avoid rounded-lg border\"\n              style={{ height: 80 + ((i * 37) % 120) }}\n            />\n          ))}\n        </div>\n      )\n    }\n    case 'grid': {\n      return (\n        <div className=\"grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4\">\n          {Array.from({ length: 8 }).map((_, i) => (\n            <div\n              key={i}\n              className=\"border-border bg-fill-secondary aspect-4/3 rounded-lg border\"\n            />\n          ))}\n        </div>\n      )\n    }\n  }\n}\n\nexport const ViewsShowcase: Component = () => {\n  const [view, setView] = React.useState<ViewKey>('list')\n  return (\n    <section\n      id=\"views\"\n      className=\"mx-auto mt-28 md:mt-32 lg:mt-40 w-full max-w-max-width-2xl px-4\"\n    >\n      <div className=\"mx-auto max-w-3xl text-center\">\n        <h2 className=\"text-4xl font-semibold tracking-tight\">\n          Reading experience reimagined in the AI era\n        </h2>\n        <p className=\"text-text-secondary mt-2\">\n          Views for every signal: List, Social timeline, Masonry, and Grid.\n        </p>\n      </div>\n\n      <div className=\"mx-auto mt-6 max-w-5xl\">\n        <SegmentTab\n          items={items}\n          value={view}\n          onChange={(v) => setView(v as ViewKey)}\n          containerClassName=\"mx-auto max-w-md\"\n        />\n\n        <div className=\"border-border bg-material-medium/60 mt-4 rounded-xl border p-3 backdrop-blur-md\">\n          <div className=\"border-border bg-background rounded-lg border p-3\">\n            <ViewContent view={view} />\n          </div>\n        </div>\n      </div>\n    </section>\n  )\n}\n\nViewsShowcase.displayName = 'ViewsShowcase'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/landing/WindowChrome.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport type * as React from 'react'\n\nimport { Folo } from '~/components/brand/Folo'\n\nexport function WindowChrome({\n  children,\n  showTryOnWeb = true,\n}: {\n  children: React.ReactNode\n  showTryOnWeb?: boolean\n}) {\n  const actionsT = useTranslations('common.actions')\n\n  return (\n    <div className=\"overflow-hidden rounded-xl border border-border bg-background\">\n      <div className=\"flex items-center gap-2 border-b border-border/80 px-3 py-2\">\n        <span className=\"inline-block size-2.5 rounded-full bg-red\" />\n        <span className=\"inline-block size-2.5 rounded-full bg-yellow\" />\n        <span className=\"inline-block size-2.5 rounded-full bg-green\" />\n        <div className=\"ml-3 h-5 flex items-center gap-2\">\n          {/* <Logo className=\"size-4\" /> */}\n          <Folo className=\"size-5\" />\n        </div>\n\n        {showTryOnWeb ? (\n          <a\n            href=\"https://app.folo.is\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"ml-auto text-xs  pointer-events-auto text-text-secondary cursor-pointer hover:text-text transition-colors\"\n          >\n            {actionsT('tryOnWeb')}\n          </a>\n        ) : null}\n      </div>\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/pricing/PricingPlans.tsx",
    "content": "'use client'\n\nimport Link from 'next/link'\nimport { useTranslations } from 'next-intl'\nimport * as React from 'react'\n\nimport { Button } from '~/components/ui/button/Button'\nimport { cx } from '~/lib/cn'\nimport type { PricingPlan, PricingPlanLimit } from '~/lib/pricing-data'\nimport { FEATURE_ORDER } from '~/lib/pricing-data'\n\ntype BillingPeriod = 'monthly' | 'yearly'\n\nconst AI_MODEL_SELECTION_LABELS = {\n  none: 'No AI model selection',\n  curated: 'Curated models',\n  high_performance: 'All high-end models',\n} as const\n\nconst formatCurrency = (value: number) =>\n  new Intl.NumberFormat('en-US', {\n    style: 'currency',\n    currency: 'USD',\n    trailingZeroDisplay: 'stripIfInteger',\n  }).format(value)\n\nconst formatFeatureValue = (\n  key: keyof PricingPlanLimit,\n  value: PricingPlanLimit[keyof PricingPlanLimit] | null | undefined,\n) => {\n  if (value == null) {\n    return '—'\n  }\n\n  if (key === 'AI_MODEL_SELECTION' && typeof value === 'string') {\n    return AI_MODEL_SELECTION_LABELS[\n      value as keyof typeof AI_MODEL_SELECTION_LABELS\n    ]\n  }\n\n  if (typeof value === 'boolean') {\n    return value ? '✓' : '—'\n  }\n\n  if (value === Number.MAX_SAFE_INTEGER) {\n    return 'Unlimited'\n  }\n\n  if (typeof value === 'number') {\n    return new Intl.NumberFormat('en-US', {\n      notation: 'compact',\n      maximumFractionDigits: 1,\n    }).format(value)\n  }\n\n  if (Array.isArray(value)) {\n    return value.join(' · ')\n  }\n\n  return String(value)\n}\n\nconst getVisibleFeatures = (plans: PricingPlan[]) =>\n  FEATURE_ORDER.filter((key) =>\n    plans.some((plan) => {\n      const value = plan.limit[key]\n      if (value == null) return false\n      if (typeof value === 'boolean') return value\n      if (typeof value === 'number') return value > 0\n      if (Array.isArray(value)) return value.length > 0\n      return value !== '0'\n    }),\n  )\n\nexport const PricingPlans: Component<{ plans: PricingPlan[] }> = ({\n  plans,\n}) => {\n  const pricingT = useTranslations('pricing')\n  const [billingPeriod, setBillingPeriod] =\n    React.useState<BillingPeriod>('yearly')\n\n  const visiblePlans = React.useMemo(\n    () => plans.filter((plan) => plan.priceInDollars > 0),\n    [plans],\n  )\n  const visibleFeatures = React.useMemo(\n    () => getVisibleFeatures(plans),\n    [plans],\n  )\n\n  const yearlySavings = React.useMemo(() => {\n    const paidPlans = visiblePlans.filter(\n      (plan) => plan.priceInDollars > 0 && plan.priceInDollarsAnnual > 0,\n    )\n    if (paidPlans.length === 0) return 0\n\n    const total = paidPlans.reduce((acc, plan) => {\n      const monthlyTotal = plan.priceInDollars * 12\n      const yearlyTotal = plan.priceInDollarsAnnual\n      return acc + ((monthlyTotal - yearlyTotal) / monthlyTotal) * 100\n    }, 0)\n\n    return Math.round(total / paidPlans.length)\n  }, [visiblePlans])\n\n  return (\n    <section className=\"mx-auto mt-14 w-full max-w-[var(--container-max-width-2xl)] px-4 pb-28\">\n      <div className=\"mx-auto max-w-5xl text-center\">\n        <p className=\"text-[11px] font-medium uppercase tracking-[0.28em] text-accent/90\">\n          {pricingT('eyebrow')}\n        </p>\n        <h1 className=\"mt-3 text-4xl font-semibold tracking-tight sm:text-5xl\">\n          {pricingT('headline')}\n        </h1>\n        <p className=\"mx-auto mt-4 max-w-2xl text-base leading-relaxed text-text-secondary\">\n          {pricingT('body')}\n        </p>\n      </div>\n\n      <div className=\"mt-8 flex justify-center\">\n        <div className=\"inline-flex rounded-full border border-border bg-background-secondary p-1\">\n          <BillingTab\n            active={billingPeriod === 'monthly'}\n            label={pricingT('monthly')}\n            onClick={() => setBillingPeriod('monthly')}\n          />\n          <BillingTab\n            active={billingPeriod === 'yearly'}\n            label={`${pricingT('yearly')} · ${pricingT('save', {\n              percent: yearlySavings,\n            })}`}\n            onClick={() => setBillingPeriod('yearly')}\n          />\n        </div>\n      </div>\n\n      <div className=\"mx-auto mt-10 grid max-w-6xl gap-4 lg:grid-cols-3\">\n        {visiblePlans.map((plan) => (\n          <PlanCard key={plan.name} billingPeriod={billingPeriod} plan={plan} />\n        ))}\n      </div>\n\n      <div className=\"mx-auto mt-12 max-w-6xl overflow-hidden rounded-[28px] border border-border/70 bg-background/90\">\n        <div className=\"overflow-x-auto\">\n          <table className=\"w-full min-w-[920px] border-collapse\">\n            <thead>\n              <tr className=\"border-b border-border/70 bg-background-secondary/70\">\n                <th className=\"sticky left-0 z-10 bg-background-secondary/70 px-5 py-4 text-left text-sm font-semibold\">\n                  {pricingT('table.features')}\n                </th>\n                {plans.map((plan) => (\n                  <th\n                    key={plan.name}\n                    className=\"px-5 py-4 text-center text-sm font-semibold\"\n                  >\n                    {plan.name}\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody>\n              {visibleFeatures.map((featureKey, index) => (\n                <tr\n                  key={featureKey}\n                  className={cx(\n                    'border-b border-border/70',\n                    index % 2 === 0\n                      ? 'bg-background'\n                      : 'bg-background-secondary/30',\n                  )}\n                >\n                  <td className=\"sticky left-0 z-10 bg-inherit px-5 py-4 text-sm font-medium text-text\">\n                    {pricingT(`features.${featureKey}`)}\n                  </td>\n                  {plans.map((plan) => (\n                    <td\n                      key={`${plan.name}-${featureKey}`}\n                      className=\"px-5 py-4 text-center text-sm text-text-secondary\"\n                    >\n                      {formatFeatureValue(featureKey, plan.limit[featureKey])}\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n    </section>\n  )\n}\n\nconst BillingTab = ({\n  active,\n  label,\n  onClick,\n}: {\n  active: boolean\n  label: string\n  onClick: () => void\n}) => (\n  <button\n    type=\"button\"\n    onClick={onClick}\n    className={cx(\n      'rounded-full px-4 py-2 text-sm transition-colors',\n      active\n        ? 'bg-accent text-white shadow-[0_12px_30px_-18px_rgba(255,92,0,0.65)]'\n        : 'text-text-secondary hover:text-text',\n    )}\n  >\n    {label}\n  </button>\n)\n\nconst PlanCard = ({\n  billingPeriod,\n  plan,\n}: {\n  billingPeriod: BillingPeriod\n  plan: PricingPlan\n}) => {\n  const pricingT = useTranslations('pricing')\n  const isYearly = billingPeriod === 'yearly'\n  const price = isYearly ? plan.priceInDollarsAnnual / 12 : plan.priceInDollars\n  const annualTotal = plan.priceInDollarsAnnual\n\n  return (\n    <div\n      className={cx(\n        'relative flex h-full flex-col justify-between rounded-[28px] border p-6 transition-colors',\n        plan.isPopular\n          ? 'border-accent/35 bg-background shadow-[0_26px_80px_-56px_rgba(255,92,0,0.55)]'\n          : 'border-border/70 bg-background/88',\n      )}\n    >\n      {plan.isPopular ? (\n        <div className=\"absolute right-5 top-5 rounded-full bg-accent px-2.5 py-1 text-[11px] font-semibold text-white\">\n          {pricingT('mostPopular')}\n        </div>\n      ) : null}\n\n      <div>\n        <p className=\"text-2xl font-semibold text-text\">{plan.name}</p>\n        <p className=\"mt-2 text-sm leading-relaxed text-text-secondary\">\n          {pricingT(`descriptions.${plan.role}`)}\n        </p>\n\n        <div className=\"mt-6 flex items-end gap-2\">\n          <span className=\"text-4xl font-semibold tracking-tight text-text\">\n            {formatCurrency(price)}\n          </span>\n          <span className=\"pb-1 text-sm text-text-secondary\">\n            / {pricingT('month')}\n          </span>\n        </div>\n\n        {isYearly && annualTotal > 0 ? (\n          <p className=\"mt-2 text-sm text-text-tertiary\">\n            {pricingT('billedYearly', {\n              total: formatCurrency(annualTotal),\n            })}\n          </p>\n        ) : null}\n\n        <ul className=\"mt-6 space-y-3\">\n          {FEATURE_ORDER.slice(0, 6).map((featureKey) => (\n            <li\n              key={featureKey}\n              className=\"flex gap-3 text-sm text-text-secondary\"\n            >\n              <span className=\"mt-[7px] size-1.5 shrink-0 rounded-full bg-accent\" />\n              <span>\n                <span className=\"text-text\">\n                  {formatFeatureValue(featureKey, plan.limit[featureKey])}\n                </span>{' '}\n                {pricingT(`features.${featureKey}`)}\n              </span>\n            </li>\n          ))}\n        </ul>\n      </div>\n\n      <div className=\"mt-8\">\n        <Link\n          href=\"https://app.folo.is\"\n          target=\"_blank\"\n          rel=\"noreferrer noopener\"\n        >\n          <Button className=\"box-border w-full max-w-full px-4 text-sm\">\n            {plan.upgradeButtonText || pricingT('getStarted')}\n          </Button>\n        </Link>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/EntryChatPanel.tsx",
    "content": "'use client'\nimport * as React from 'react'\n\nimport { ListChatPlayer } from './components/chat/ListChatPlayer'\nimport { AI_SUMMARY_STEPS } from './mocks'\n\ninterface EntryChatPanelProps {\n  playTimeline?: boolean\n}\n\nexport const EntryChatPanel: React.FC<EntryChatPanelProps> = ({\n  playTimeline = false,\n}) => {\n  return (\n    <aside className=\"absolute shadow-2xl z-10 right-0 inset-y-0 flex flex-col w-[calc(100vw-100px)] lg:w-[360px] bg-background border-l\">\n      <ListChatPlayer\n        autoplay={playTimeline}\n        steps={AI_SUMMARY_STEPS}\n        rootClassName=\"contents\"\n        scrollRootClassName=\"h-0 grow\"\n        scrollViewportClassName=\"px-3 min-w-0\"\n      />\n    </aside>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/EntryPage.tsx",
    "content": "'use client'\nimport { useInView } from 'motion/react'\nimport * as React from 'react'\n\nimport { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'\n\nimport { EntryPageOverlay } from './components/EntryPageOverlay'\nimport { EntryChatPanel } from './EntryChatPanel'\nimport { ENTRY_DETAIL } from './mocks'\n\nexport const EntryPageDemo = () => {\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const isInView = useInView(containerRef, {\n    once: true,\n    margin: '-20% 0px',\n  })\n\n  return (\n    <div ref={containerRef} className=\"relative size-full\">\n      <EntryPageOverlay />\n      <ScrollArea\n        data-simulator=\"entry-page\"\n        rootClassName=\"relative h-full @container\"\n        viewportClassName=\"px-8 py-12 lg:pl-16 lg:pr-[390px]\"\n      >\n        <div className=\"flex min-h-[640px] items-center\">\n          <div className=\"group relative block w-full max-w-[860px] min-w-0\">\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex flex-wrap items-center gap-3 text-sm text-text-secondary\">\n                <div className=\"flex items-center gap-2\">\n                  <div className=\"flex size-6 items-center justify-center rounded-full border border-border/70 bg-white p-1\">\n                    <img\n                      src={ENTRY_DETAIL.source.icon}\n                      alt={ENTRY_DETAIL.source.name}\n                      className=\"size-full rounded-full object-cover\"\n                    />\n                  </div>\n                  <span className=\"text-xs font-medium\">\n                    {ENTRY_DETAIL.source.name}\n                  </span>\n                </div>\n                <span className=\"text-xs text-text-tertiary\">\n                  {ENTRY_DETAIL.source.host}\n                </span>\n              </div>\n\n              <h2 className=\"inline-block select-text wrap-break-word text-[2.1rem] font-bold leading-tight\">\n                <span className=\"text-text inline-block select-text hyphens-auto\">\n                  {ENTRY_DETAIL.title}\n                </span>\n              </h2>\n\n              <div className=\"flex flex-wrap items-center gap-x-6 gap-y-2 text-sm\">\n                <div className=\"flex flex-wrap items-center gap-4 text-text-secondary\">\n                  <div className=\"flex items-center gap-1.5\">\n                    <i className=\"i-mgc-user-3-cute-re text-base\" />\n                    <span className=\"text-xs font-medium\">\n                      {ENTRY_DETAIL.author.name}\n                    </span>\n                  </div>\n\n                  <div className=\"flex items-center gap-1.5\">\n                    <i className=\"i-mgc-tag-3-cute-re text-base\" />\n                    <span className=\"text-xs font-medium\">\n                      {ENTRY_DETAIL.author.role}\n                    </span>\n                  </div>\n\n                  <div className=\"flex items-center gap-1.5\">\n                    <i className=\"i-mgc-calendar-time-add-cute-re text-base\" />\n                    <span className=\"text-xs tabular-nums\">\n                      {ENTRY_DETAIL.author.dateLabel}\n                    </span>\n                  </div>\n\n                  <div className=\"flex items-center gap-1.5\">\n                    <i className=\"i-mgc-time-cute-re text-base\" />\n                    <span className=\"text-xs tabular-nums\">\n                      {ENTRY_DETAIL.author.readingTime}\n                    </span>\n                  </div>\n                </div>\n              </div>\n\n              <EntryContentPreview />\n            </div>\n          </div>\n        </div>\n      </ScrollArea>\n      <EntryChatPanel playTimeline={isInView} />\n    </div>\n  )\n}\n\nconst bodyParagraphs = [\n  'The newest generation of agent tooling is forcing teams to think less about prompts in isolation and more about end-to-end system design.',\n  'Once a model can search, call tools, draft structured work, or trigger external actions, reliability becomes a product question rather than a model benchmark question.',\n]\n\nconst sectionBlocks = [\n  {\n    title: 'What teams are optimizing for',\n    paragraphs: [\n      'Accuracy still matters, but operational quality is now the real differentiator: predictable tool use, bounded retries, and evaluation sets that mirror production traffic.',\n      'Many failures that look like model mistakes are really interface mistakes: vague tool descriptions, missing state, or workflows that ask one agent to do too much in a single step.',\n    ],\n  },\n]\n\nconst checklistItems = [\n  'Start with one narrow task and one or two tools.',\n  'Log every tool call, retry, and failure reason.',\n  'Add evals before widening the workflow.',\n  'Keep a human in the loop for risky actions.',\n]\n\nconst EntryContentPreview = () => {\n  return (\n    <div className=\"mt-5 space-y-5\">\n      <div className=\"space-y-4 text-[15px] leading-8 text-text-secondary\">\n        {bodyParagraphs.map((paragraph) => (\n          <p key={paragraph}>{paragraph}</p>\n        ))}\n      </div>\n\n      {sectionBlocks.map((section) => (\n        <section key={section.title} className=\"space-y-3\">\n          <h3 className=\"text-xl font-semibold tracking-tight text-text\">\n            {section.title}\n          </h3>\n          <div className=\"space-y-4 text-[15px] leading-8 text-text-secondary\">\n            {section.paragraphs.map((paragraph) => (\n              <p key={paragraph}>{paragraph}</p>\n            ))}\n          </div>\n        </section>\n      ))}\n\n      <blockquote className=\"border-l-2 border-accent pl-4 text-[15px] leading-8 text-text-secondary\">\n        “The most dependable agent products are the ones that expose their\n        workflow clearly enough for teams to inspect, test, and intervene.”\n      </blockquote>\n\n      <section className=\"space-y-3\">\n        <h3 className=\"text-xl font-semibold tracking-tight text-text\">\n          Operating checklist\n        </h3>\n        <ul className=\"space-y-3 text-[15px] leading-8 text-text-secondary\">\n          {checklistItems.map((item) => (\n            <li key={item} className=\"flex gap-3\">\n              <span className=\"mt-[13px] size-1.5 shrink-0 rounded-full bg-accent\" />\n              <span>{item}</span>\n            </li>\n          ))}\n        </ul>\n      </section>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/ListDemo.tsx",
    "content": "import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'\nimport type { HeroTimelineItem } from '~/lib/landing-data'\n\nexport const ListDemo = ({\n  items = DEFAULT_ITEMS,\n}: {\n  items?: HeroTimelineItem[]\n}) => {\n  return (\n    <div className=\"flex flex-col min-w-0 select-none bg-background\">\n      {items.map((item) => (\n        <div\n          key={item.title}\n          className=\"group flex min-w-0 max-w-full items-center gap-3 border-b border-border/40 px-4 py-3 transition-colors hover:bg-fill-secondary/80\"\n        >\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <img\n              src={`https://icons.folo.is/${item.host}`}\n              alt={item.title}\n              className=\"size-8 rounded-lg border border-border/60 bg-white/85 p-1\"\n            />\n            <div className=\"min-w-0 shrink\">\n              <div className=\"flex min-w-0 items-center gap-2\">\n                <h3 className=\"min-w-0 shrink truncate text-sm font-bold\">\n                  {item.title}\n                </h3>\n                <span className=\"shrink-0 rounded-full border border-border/70 bg-background px-2 py-0.5 text-[10px] font-medium text-text-tertiary\">\n                  {formatCompact(item.subscriptions)}\n                </span>\n              </div>\n              <p className=\"mt-0.5 truncate text-sm text-text-secondary\">\n                {item.description}\n              </p>\n            </div>\n          </div>\n\n          <p className=\"min-w-0 flex-1 truncate text-right text-xs text-text-tertiary transition-colors group-hover:text-text-secondary\">\n            {item.host}\n          </p>\n        </div>\n      ))}\n    </div>\n  )\n}\n\nconst compactFormatter = new Intl.NumberFormat('en-US', {\n  notation: 'compact',\n  maximumFractionDigits: 1,\n})\n\nconst formatCompact = (value: number) => compactFormatter.format(value)\n\nconst DEFAULT_ITEMS: HeroTimelineItem[] = [\n  {\n    host: 'openai.com',\n    title: 'OpenAI News',\n    description: 'The OpenAI blog.',\n    href: 'https://openai.com/news',\n    subscriptions: 27710,\n  },\n  {\n    host: 'anthropic.com',\n    title: 'Anthropic News',\n    description: 'Latest news from Anthropic.',\n    href: 'https://www.anthropic.com/news',\n    subscriptions: 998,\n  },\n  {\n    host: 'github.blog',\n    title: 'The GitHub Blog',\n    description: 'Updates, ideas, and inspiration from GitHub.',\n    href: 'https://github.blog/',\n    subscriptions: 24774,\n  },\n  {\n    host: 'nature.com',\n    title: 'Nature',\n    description: 'Read the latest research articles from Nature.',\n    href: 'https://www.nature.com/nature/research-articles',\n    subscriptions: 24491,\n  },\n  {\n    host: 'theverge.com',\n    title: 'The Verge',\n    description: 'Breaking news, reviews, and features about tech.',\n    href: 'https://www.theverge.com/',\n    subscriptions: 24833,\n  },\n  {\n    host: 'apod.nasa.gov',\n    title: 'NASA Astronomy Picture of the Day',\n    description: 'The daily image and story from the universe around us.',\n    href: 'https://apod.nasa.gov/apod/archivepix.html',\n    subscriptions: 29277,\n  },\n  {\n    host: 'lastweekin.ai',\n    title: 'Last Week in AI',\n    description: 'Weekly text and audio summaries of the biggest AI stories.',\n    href: 'https://lastweekin.ai/',\n    subscriptions: 30381,\n  },\n  {\n    host: 'magazine.sebastianraschka.com',\n    title: 'Ahead of AI',\n    description: 'Machine learning and AI research for readers who stay ahead.',\n    href: 'https://magazine.sebastianraschka.com/',\n    subscriptions: 27310,\n  },\n  {\n    host: 'ted.com',\n    title: 'TED Talks Daily',\n    description: 'Thought-provoking ideas in audio, delivered every day.',\n    href: 'https://www.ted.com/',\n    subscriptions: 25784,\n  },\n  {\n    host: 'nytimes.com',\n    title: 'The Daily',\n    description: 'The biggest stories of our time, told by NYT journalists.',\n    href: 'https://www.nytimes.com/the-daily',\n    subscriptions: 23638,\n  },\n  {\n    host: 'youtube.com',\n    title: '3Blue1Brown',\n    description: 'Visual explanations in math, physics, and computer science.',\n    href: 'https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw',\n    subscriptions: 24897,\n  },\n  {\n    host: 'bsky.app',\n    title: 'Bluesky',\n    description: 'The official Bluesky account and product updates.',\n    href: 'https://bsky.app/profile/bsky.app',\n    subscriptions: 24116,\n  },\n]\n\nexport const ListSkeletonDemo = () => {\n  const skeletonRows = Array.from(\n    { length: 20 },\n    (_, index) => `skeleton-${index}`,\n  )\n\n  return (\n    <div className=\"flex flex-col min-w-0 relative grow size-full\">\n      <div className=\"absolute inset-0\">\n        <ScrollArea rootClassName=\"size-full\">\n          {skeletonRows.map((key) => (\n            <div\n              key={key}\n              className=\"py-3 pl-4 min-w-0 flex truncate max-w-full hover:bg-material-medium\"\n            >\n              <div className=\"flex items-center gap-2 grow\">\n                <div className=\"size-4 rounded-md bg-fill-secondary\" />\n                <div className=\"flex flex-row grow items-center gap-2\">\n                  <h3 className=\"text-sm font-bold min-w-0 shrink flex-2 truncate\">\n                    <div className=\"w-full h-4 rounded-md bg-fill-secondary\" />\n                  </h3>\n                  <div className=\"text-sm text-text-secondary min-w-0 truncate mr-2\">\n                    <div className=\"w-12 h-4 rounded-md bg-fill-secondary\" />\n                  </div>\n                </div>\n              </div>\n            </div>\n          ))}\n        </ScrollArea>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/TimelineChatDemo.tsx",
    "content": "'use client'\nimport { ListChatPlayer } from './components/chat/ListChatPlayer'\nimport { TIMELINE_SUMMARY_STEPS } from './mocks'\n\nconst INITIAL_HEADER_TITLE = 'Folo AI - Timeline Summary'\n\nexport const TimelineChatDemo = () => {\n  return (\n    <ListChatPlayer\n      steps={TIMELINE_SUMMARY_STEPS}\n      initialTitle={INITIAL_HEADER_TITLE}\n      rootClassName=\"size-full flex flex-col bg-background\"\n      scrollRootClassName=\"h-0 grow\"\n      scrollViewportClassName=\"px-3 min-w-0\"\n      showChatPanelRightDownload\n    />\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/EntryPageOverlay.tsx",
    "content": "export const EntryPageOverlay = () => {\n  return (\n    <div className=\"absolute inset-0 z-8 bg-linear-to-r from-background/6 via-background/8 to-background/72\" />\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/AIChainOfThought.tsx",
    "content": "import type { ReasoningUIPart, ToolUIPart } from 'ai'\nimport { isToolUIPart } from 'ai'\nimport clsx from 'clsx'\nimport { AnimatePresence, m } from 'motion/react'\nimport * as React from 'react'\n\nimport type { CollapseCssRef } from '~/components/ui/collapse'\nimport { CollapseCss, CollapseCssGroup } from '~/components/ui/collapse'\nimport { clsxm } from '~/lib/cn'\nimport { Spring } from '~/lib/spring'\n\nimport { AIReasoningPart } from './AIReasoningPart'\nimport { ShinyText } from './shiny-text/ShinyText'\nimport { ToolInvocationComponent } from './ToolInvocationComponent'\n\nexport type ChainReasoningPart = ReasoningUIPart | ToolUIPart\ninterface AIChainOfThoughtProps {\n  groups: ReadonlyArray<ChainReasoningPart>\n  isStreaming?: boolean\n  className?: string\n}\nexport const AIChainOfThought: React.FC<AIChainOfThoughtProps> = React.memo(\n  ({ groups, isStreaming, className }) => {\n    const collapseId = React.useMemo(\n      () => `chain-${Math.random().toString(36).slice(2)}`,\n      [],\n    )\n\n    const collapseRef = React.useRef<CollapseCssRef>(null)\n\n    const currentChainReasoningIsFinished = React.useMemo(() => {\n      let allDone = true\n      for (const part of groups) {\n        if (part.type.startsWith('tool-')) {\n          continue\n        }\n        if (part.state !== 'done') {\n          allDone = false\n          break\n        }\n      }\n\n      const isEndWithTool = groups.at?.(-1)?.type.startsWith('tool-')\n      if (isEndWithTool) {\n        allDone = false\n      }\n      return allDone\n    }, [groups])\n    const currentReasoningTitle = React.useMemo(() => {\n      if (!isStreaming) return null\n\n      const lastPart = groups.at?.(-1)\n\n      if (!lastPart) return null\n\n      if (lastPart.type.startsWith('tool-')) {\n        return `Calling [${lastPart.type.replace('tool-', '')}]`\n      }\n\n      const lastPartText = (lastPart as ReasoningUIPart).text\n      return extractHeading(lastPartText)\n    }, [groups, isStreaming])\n\n    React.useEffect(() => {\n      collapseRef.current?.setIsOpened(!currentChainReasoningIsFinished)\n    }, [collapseRef, currentChainReasoningIsFinished])\n\n    if (!groups || groups.length === 0) return null\n\n    return (\n      <div\n        className={clsxm(\n          'border-border w-[calc(var(--ai-chat-message-container-width,65ch))] min-w-0 text-left',\n          className,\n        )}\n      >\n        <CollapseCssGroup>\n          <CollapseCss\n            ref={collapseRef}\n            hideArrow\n            collapseId={collapseId}\n            defaultOpen={!currentChainReasoningIsFinished}\n            title={\n              <div className=\"group flex h-6 w-[calc(var(--ai-chat-message-container-width,65ch))] min-w-0 flex-1 items-center py-0\">\n                <div className=\"flex items-center gap-2 text-xs\">\n                  <span className=\"text-text-secondary\">\n                    {!currentChainReasoningIsFinished ? (\n                      <span className=\"flex items-center gap-2\">\n                        Thinking:{' '}\n                        <span className=\"min-w-0 truncate\">\n                          <AnimatePresence initial={false} mode=\"popLayout\">\n                            <m.span\n                              key={currentReasoningTitle ?? 'empty'}\n                              initial={{\n                                opacity: 0,\n                                y: 6,\n                                filter: 'blur(4px)',\n                              }}\n                              animate={{\n                                opacity: 1,\n                                y: 0,\n                                filter: 'blur(0px)',\n                              }}\n                              exit={{ opacity: 0, y: -6, filter: 'blur(4px)' }}\n                              transition={Spring.presets.smooth}\n                              className=\"inline-block\"\n                            >\n                              <ShinyText className=\"font-medium\">\n                                {currentReasoningTitle ?? ''}\n                              </ShinyText>\n                            </m.span>\n                          </AnimatePresence>\n                        </span>\n                      </span>\n                    ) : (\n                      'Finished Thinking'\n                    )}\n                  </span>\n                </div>\n                <div className=\"ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100\">\n                  <i className=\"i-mingcute-right-line size-3 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90\" />\n                </div>\n              </div>\n            }\n            className=\"group w-full border-none\"\n            contentClassName=\"pb-2 pt-1\"\n          >\n            <div className=\"relative\">\n              <div\n                aria-hidden\n                className=\"border-fill absolute inset-y-0 left-2 border-l\"\n              />\n              {groups.map((part, index) => {\n                const innerCollapseId = `${collapseId}-${index}`\n                if (isToolUIPart(part)) {\n                  return (\n                    <ToolInvocationComponent\n                      variant=\"loose\"\n                      key={innerCollapseId}\n                      toolName={part.type.replace('tool-', '')}\n                      input={part.input as string}\n                      output={part.output as string}\n                    />\n                  )\n                }\n                const mergedText = part.text\n\n                const title = extractHeading(part.text)\n                const groupStreaming = part.state === 'streaming'\n\n                return (\n                  <div\n                    key={innerCollapseId}\n                    className=\"relative pb-3 pl-8 last:pb-0\"\n                  >\n                    <div\n                      aria-hidden\n                      className={clsx(\n                        'absolute left-2 top-2 size-2 -translate-x-1/2 rounded-full border',\n                        'border-fill bg-fill-vibrant',\n                      )}\n                    >\n                      <i className=\"i-mingcute-brain-line absolute top-1/2 -translate-x-1/4 -translate-y-1/2\" />\n                    </div>\n\n                    <AIInnerReasoningPart\n                      title={title}\n                      text={mergedText}\n                      groupStreaming={groupStreaming}\n                    />\n                  </div>\n                )\n              })}\n            </div>\n          </CollapseCss>\n        </CollapseCssGroup>\n      </div>\n    )\n  },\n)\n\nconst AIInnerReasoningPart: React.FC<{\n  title: string | undefined\n  text: string\n  groupStreaming: boolean\n}> = React.memo(({ title, text, groupStreaming }) => {\n  const id = React.useId()\n  const collapseRef = React.useRef<CollapseCssRef>(null)\n\n  React.useEffect(() => {\n    collapseRef.current?.setIsOpened(groupStreaming)\n  }, [groupStreaming, collapseRef])\n\n  return (\n    <CollapseCss\n      ref={collapseRef}\n      hideArrow\n      collapseId={id}\n      defaultOpen\n      title={\n        <div className=\"group/inner flex h-6 min-w-0 flex-1 items-center py-0\">\n          <div className=\"text-text-secondary flex items-center gap-2 text-xs\">\n            {title ? (\n              <span className=\"truncate\">\n                {'Reason: '}\n                <span className=\"text-text font-medium\">{title}</span>\n              </span>\n            ) : (\n              <span>{groupStreaming ? 'Reasoning...' : 'Reasoning'}</span>\n            )}\n          </div>\n          <div className=\"ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover/inner:opacity-100\">\n            <i className=\"i-mingcute-right-line size-3 shrink-0 transition-transform duration-200 group-data-[state=open]/inner:rotate-90\" />\n          </div>\n        </div>\n      }\n      className=\"group/inner w-full border-none\"\n    >\n      <AIReasoningPart text={text} isStreaming={groupStreaming} />\n    </CollapseCss>\n  )\n})\n\nAIChainOfThought.displayName = 'AIChainOfThought'\n\nconst extractHeading = (text?: string): string | undefined => {\n  if (!text) return\n  const lines = text.split(/\\r?\\n/)\n  for (const raw of lines) {\n    const line = raw.trim()\n    if (!line) continue\n    if (line.startsWith('#')) {\n      let idx = 0\n      while (idx < line.length && line.charAt(idx) === '#') idx++\n      let content = line.slice(idx).trim()\n      while (content.endsWith('#')) content = content.slice(0, -1).trim()\n      return content || undefined\n    }\n    if (line.startsWith('**') && line.endsWith('**') && line.length > 4) {\n      return line.slice(2, -2).trim() || undefined\n    }\n    break\n  }\n  return\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/AIMarkdownMessage.tsx",
    "content": "import { memo, useRef } from 'react'\n\nimport { cn } from '~/lib/cn'\n\nimport { MarkdownAnimateText } from './animated/AnimatedMarkdown'\nimport { parseIncompleteMarkdown } from './parse-incomplete-markdown'\n\nexport const AIMarkdownStreamingMessage = memo(\n  ({\n    text,\n    className: classNameProp,\n    isStreaming,\n  }: {\n    text: string\n    className?: string\n    isStreaming?: boolean\n  }) => {\n    const className = `prose max-w-full dark:prose-invert prose-sm\n  prose-h1:text-2xl prose-h2:text-xl prose-h2:mt-2 prose-h3:text-lg prose-h4:text-base prose-h5:text-base prose-h6:text-sm\n  prose-li:list-disc prose-li:marker:text-accent prose-hr:border-border prose-hr:mx-8\n  w-[calc(var(--ai-chat-message-container-width,65ch))]\n  prose-pre:text-base!\n  prose-strong:font-bold prose-headings:font-bold\n  [&_ol>li]:list-decimal\n  `\n\n    const stableStreamingState = useRef(isStreaming)\n    return (\n      <div className={cn(className, classNameProp)}>\n        <MarkdownAnimateText\n          content={parseIncompleteMarkdown(text)}\n          isStreaming={stableStreamingState.current}\n        />\n      </div>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/AIReasoningPart.tsx",
    "content": "import clsx from 'clsx'\nimport * as React from 'react'\n\ninterface AIReasoningPartProps {\n  text: string\n  isStreaming?: boolean\n  className?: string\n}\n\nexport const AIReasoningPart: React.FC<AIReasoningPartProps> = React.memo(\n  ({ text, isStreaming, className }) => {\n    const [renderedText, setRenderedText] = React.useState(text)\n    const streamingTimerRef = React.useRef<number | null>(null)\n\n    React.useEffect(() => {\n      if (streamingTimerRef.current) {\n        window.clearInterval(streamingTimerRef.current)\n        streamingTimerRef.current = null\n      }\n\n      if (!isStreaming) {\n        setRenderedText(text)\n        return\n      }\n\n      if (!text) {\n        setRenderedText('')\n        return\n      }\n\n      const totalLength = text.length\n      const chunkSize = Math.max(8, Math.ceil(totalLength / 70))\n      let index = Math.min(chunkSize, totalLength)\n\n      setRenderedText(text.slice(0, index))\n\n      streamingTimerRef.current = window.setInterval(() => {\n        index = Math.min(totalLength, index + chunkSize)\n        setRenderedText(text.slice(0, index))\n\n        if (index >= totalLength && streamingTimerRef.current) {\n          window.clearInterval(streamingTimerRef.current)\n          streamingTimerRef.current = null\n        }\n      }, 70)\n\n      return () => {\n        if (streamingTimerRef.current) {\n          window.clearInterval(streamingTimerRef.current)\n          streamingTimerRef.current = null\n        }\n      }\n    }, [text, isStreaming])\n    if (!text) return null\n    return (\n      <div className={clsx('min-w-0 max-w-full text-left', className)}>\n        <div className=\"w-[calc(var(--ai-chat-message-container-width,65ch))] max-w-full\" />\n        <div className=\"text-xs\">\n          <pre className=\"text-text-secondary bg-material-medium overflow-x-auto whitespace-pre-wrap rounded p-3 text-[11px] leading-relaxed\">\n            {renderedText}\n          </pre>\n        </div>\n      </div>\n    )\n  },\n)\n\nAIReasoningPart.displayName = 'AIReasoningPart'\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/ToolInvocationComponent.tsx",
    "content": "import clsx from 'clsx'\nimport * as React from 'react'\n\nimport { CollapseCss, CollapseCssGroup } from '~/components/ui/collapse'\nimport { JsonHighlighter } from '~/components/ui/json-highlighter'\n\ninterface ToolInvocationComponentProps {\n  toolName: string\n  input: string\n  output: string\n\n  variant: 'loose' | 'tight'\n}\n\nexport const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> =\n  React.memo(({ toolName, input, output, variant }) => {\n    const id = React.useId()\n    const hasArgs = !!input\n    const hasResult = !!output\n    return (\n      <div\n        className={clsx(\n          'relative pl-8 last:pb-0',\n          variant === 'tight' ? 'pb-0' : 'pb-3',\n        )}\n      >\n        <div\n          aria-hidden\n          className={`border-fill bg-fill absolute left-2 top-2 size-2 -translate-x-1/2 rounded-full border`}\n        >\n          <i\n            className={`i-mingcute-tool-line absolute top-1/2 -translate-x-1/4 -translate-y-1/2`}\n          />\n        </div>\n\n        <CollapseCssGroup>\n          <CollapseCss\n            collapseId={id}\n            hideArrow\n            className=\"group/collapse border-none\"\n            title={\n              <div className=\"group/tool flex h-6 min-w-0 flex-1 items-center py-0\">\n                <div className=\"text-text-secondary flex items-center gap-2 text-xs\">\n                  <span>{'Tool Called:'}</span>\n                  <span className={`truncate font-medium text-text`}>\n                    {toolName}\n                  </span>\n                </div>\n                <div className=\"ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover/tool:opacity-100\">\n                  <i className=\"i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]/collapse:rotate-90\" />\n                </div>\n              </div>\n            }\n            contentClassName=\"pb-0 pt-2 min-w-0\"\n          >\n            <div className=\"space-y-2 text-xs\">\n              {/* Show tool arguments if available */}\n              {hasArgs ? (\n                <div>\n                  <div className=\"text-text-secondary mb-1 font-medium\">\n                    Arguments:\n                  </div>\n                  <JsonHighlighter\n                    className=\"text-text-tertiary bg-fill-secondary overflow-x-auto w-[330px] rounded p-2 text-[11px]\"\n                    json={JSON.stringify(input, null, 2)}\n                  />\n                </div>\n              ) : null}\n\n              {/* Show tool result if available */}\n              {hasResult ? (\n                <div>\n                  <div className=\"text-text-secondary mb-1 font-medium\">\n                    Result:\n                  </div>\n                  <JsonHighlighter\n                    className=\"text-text-tertiary bg-fill-secondary overflow-x-auto w-[330px] rounded p-2 text-[11px]\"\n                    json={JSON.stringify(output, null, 2)}\n                  />\n                </div>\n              ) : null}\n            </div>\n          </CollapseCss>\n        </CollapseCssGroup>\n      </div>\n    )\n  })\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/animated/AnimatedMarkdown.tsx",
    "content": "/**\n * @see https://github.com/Ephibbs/flowtoken/blob/main/src/components/AnimatedMarkdown.tsx\n */\n\nimport * as React from 'react'\nimport type { Components } from 'react-markdown'\nimport ReactMarkdown from 'react-markdown'\nimport remarkEmoji from 'remark-emoji'\n\nimport { ANIMATION_STYLE as ANIMATION_STYLE_DEFAULT } from './constants'\nimport { TokenizedText } from './TokenizedText'\n\ninterface MarkdownAnimateTextProps {\n  content: string\n  isStreaming?: boolean\n}\n\nconst emptyObject = {}\nconst animateText: (text: string | Array<any>) => React.ReactNode = (\n  text: string | Array<any>,\n) => {\n  text = Array.isArray(text) ? text : [text]\n  let keyCounter = 0\n  const processText: (input: any, keyPrefix?: string) => React.ReactNode = (\n    input: any,\n    keyPrefix = 'item',\n  ) => {\n    if (Array.isArray(input)) {\n      // Process each element in the array\n      return input.map((element, index) => (\n        <React.Fragment key={`${keyPrefix}-${index}`}>\n          {processText(element, `${keyPrefix}-${index}`)}\n        </React.Fragment>\n      ))\n    } else if (typeof input === 'string') {\n      return <TokenizedText key={`pcc-${keyCounter++}`} input={input} />\n    } else if (typeof input === 'number') {\n      return <TokenizedText key={`pcc-${keyCounter++}`} input={String(input)} />\n    } else if (React.isValidElement(input)) {\n      // Preserve element structure and do not wrap block elements (avoid <span><ul>...)\n      return React.cloneElement(input as React.ReactElement, {\n        key: `pcc-${keyCounter++}`,\n      })\n    } else {\n      // Return other inputs unchanged (null, undefined, booleans, etc.)\n      return input\n    }\n  }\n\n  return processText(text)\n}\n\nconst createAiMessageMarkdownElementsRender = (canAnimate: boolean) => {\n  const ANIMATION_STYLE = canAnimate ? ANIMATION_STYLE_DEFAULT : emptyObject\n\n  const textAnimator = canAnimate\n    ? animateText\n    : (text: string | Array<any>) => text\n\n  return {\n    // Folo Special Tags\n    'mention-entry': () => null,\n    'mention-feed': () => null,\n\n    text: ({ node, ...props }: any) => (\n      <span {...props}>{textAnimator(props.children)}</span>\n    ),\n    h1: ({ node, ...props }: any) => (\n      <h1 {...props}>{textAnimator(props.children)}</h1>\n    ),\n    h2: ({ node, ...props }: any) => (\n      <h2 {...props}>{textAnimator(props.children)}</h2>\n    ),\n    h3: ({ node, ...props }: any) => (\n      <h3 {...props}>{textAnimator(props.children)}</h3>\n    ),\n    h4: ({ node, ...props }: any) => (\n      <h4 {...props}>{textAnimator(props.children)}</h4>\n    ),\n    h5: ({ node, ...props }: any) => (\n      <h5 {...props}>{textAnimator(props.children)}</h5>\n    ),\n    h6: ({ node, ...props }: any) => (\n      <h6 {...props}>{textAnimator(props.children)}</h6>\n    ),\n    p: ({ node, ...props }: any) => (\n      <p {...props}>{textAnimator(props.children)}</p>\n    ),\n    li: ({ node, ...props }: any) => (\n      <li {...props} style={ANIMATION_STYLE}>\n        {textAnimator(props.children)}\n      </li>\n    ),\n\n    strong: ({ node, ...props }: any) => (\n      <strong {...props}>{textAnimator(props.children)}</strong>\n    ),\n    em: ({ node, ...props }: any) => (\n      <em {...props}>{textAnimator(props.children)}</em>\n    ),\n\n    hr: ({ node, ...props }: any) => (\n      <hr {...props} className=\"whitespace-pre-wrap\" style={ANIMATION_STYLE} />\n    ),\n\n    table: ({ children, ref, node, ...props }) => {\n      return (\n        <div className=\"border-border bg-material-thin overflow-x-auto rounded-lg border\">\n          <table\n            {...props}\n            style={ANIMATION_STYLE}\n            className=\"divide-border my-0 min-w-full divide-y text-sm\"\n          >\n            {children}\n          </table>\n        </div>\n      )\n    },\n    thead: ({ children, ref, node, ...props }) => {\n      return (\n        <thead {...props} className=\"bg-fill-tertiary\">\n          {children}\n        </thead>\n      )\n    },\n    th: ({ children, ref, node, ...props }) => {\n      return (\n        <th\n          {...props}\n          className=\"text-text-secondary whitespace-nowrap px-4 py-3 text-left text-xs font-medium uppercase tracking-wider\"\n        >\n          {children}\n        </th>\n      )\n    },\n    tbody: ({ children, ref, node, ...props }) => {\n      return (\n        <tbody\n          {...props}\n          className=\"bg-material-ultra-thin divide-border divide-y\"\n        >\n          {children}\n        </tbody>\n      )\n    },\n    tr: ({ children, ref, node, ...props }) => {\n      return (\n        <tr\n          {...props}\n          className=\"hover:bg-material-thin transition-colors duration-150\"\n        >\n          {textAnimator(children as any)}\n        </tr>\n      )\n    },\n    td: ({ children, ref, node, ...props }) => {\n      return (\n        <td\n          {...props}\n          className=\"text-text whitespace-nowrap px-4 py-3 text-sm\"\n        >\n          {textAnimator(children as any)}\n        </td>\n      )\n    },\n  } as Components\n}\n\nconst animatedComponents = createAiMessageMarkdownElementsRender(true)\nconst staticComponents = createAiMessageMarkdownElementsRender(false)\nexport const MarkdownAnimateText: React.FC<MarkdownAnimateTextProps> = ({\n  content,\n  isStreaming,\n}) => {\n  const components = isStreaming ? animatedComponents : staticComponents\n\n  return (\n    <ReactMarkdown components={components} remarkPlugins={[remarkEmoji]}>\n      {content}\n    </ReactMarkdown>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/animated/TokenizedText.tsx",
    "content": "/**\n * @see https://github.com/Ephibbs/flowtoken/blob/main/src/components/SplitText.tsx\n */\nimport type { ReactElement } from 'react'\nimport * as React from 'react'\nimport { useEffect, useRef } from 'react'\n\nimport { ANIMATION_STYLE } from './constants'\n\ninterface TokenWithSource {\n  text: string\n  source: number\n}\n// Helper function to check if token is a TokenWithSource type\nconst isTokenWithSource = (token: TokenType): token is TokenWithSource => {\n  return (\n    token !== null &&\n    typeof token === 'object' &&\n    'text' in token &&\n    'source' in token\n  )\n}\ntype TokenType = string | TokenWithSource | ReactElement\n\nexport const TokenizedText = ({ input }: { input: React.ReactNode }) => {\n  // Track previous input to detect changes\n  const prevInputRef = useRef<string>('')\n  // Track tokens with their source for proper keying in diff mode\n  const tokensWithSources = useRef<TokenWithSource[]>([])\n\n  // For detecting and handling duplicated content\n  const fullTextRef = useRef<string>('')\n\n  const tokens = React.useMemo(() => {\n    if (React.isValidElement(input)) return [input]\n\n    if (typeof input !== 'string') return null\n\n    // If this is the first render or we've gone backward, reset everything\n    if (!prevInputRef.current || input.length < prevInputRef.current.length) {\n      tokensWithSources.current = []\n      fullTextRef.current = ''\n    }\n\n    // Only process input if it's different from previous\n    if (input !== prevInputRef.current) {\n      // Find the true unique content by comparing with our tracked full text\n      // This handles cases where the input contains duplicates\n\n      // First check if we're just seeing the same content repeated\n      if (input.includes(fullTextRef.current)) {\n        const uniqueNewContent = input.slice(fullTextRef.current.length)\n\n        // Only add if there's actual new content\n        if (uniqueNewContent.length > 0) {\n          tokensWithSources.current.push({\n            text: uniqueNewContent,\n            source: tokensWithSources.current.length,\n          })\n\n          // Update our full text tracking\n          fullTextRef.current = input\n        }\n      } else {\n        // Handle case when input completely changes\n        // Just take the whole thing as a new token\n        tokensWithSources.current = [\n          {\n            text: input,\n            source: 0,\n          },\n        ]\n        fullTextRef.current = input\n      }\n    }\n\n    // Return the tokensWithSources directly\n    return tokensWithSources.current\n  }, [input])\n\n  // Update previous input after processing\n  useEffect(() => {\n    if (typeof input === 'string') {\n      prevInputRef.current = input\n    }\n  }, [input])\n\n  return (\n    <>\n      {tokens?.map((token, index) => {\n        // Determine the key and text based on token type\n        let key = index\n        let text = ''\n\n        if (isTokenWithSource(token)) {\n          key = token.source\n          text = token.text\n        } else if (typeof token === 'string') {\n          key = index\n          text = token\n        } else if (React.isValidElement(token)) {\n          key = index\n          text = ''\n          return React.cloneElement(token, { key })\n        }\n\n        // Skip rendering completely empty tokens\n        if (text.length === 0) {\n          return null\n        }\n\n        // For whitespace-only tokens, preserve spacing without adding a DOM element\n        if (/^\\s+$/.test(text)) {\n          return <React.Fragment key={key}>{text}</React.Fragment>\n        }\n\n        return (\n          <span\n            key={key}\n            className=\"inline whitespace-pre-wrap\"\n            style={ANIMATION_STYLE}\n          >\n            {text}\n          </span>\n        )\n      })}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/animated/constants.ts",
    "content": "export const DEFAULT_ANIMATION = 'mask-left-to-right 0.5s ease-in-out'\nexport const ANIMATION_STYLE = {\n  animation: DEFAULT_ANIMATION,\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/mocks.ts",
    "content": "import ReasoningGroups from './reasoning-mock.json'\n\nexport const REASONING_GROUPS = ReasoningGroups.groups as any\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/parse-incomplete-markdown.ts",
    "content": "// @copy https://github.com/vercel/streamdown/blob/main/packages/streamdown/lib/parse-incomplete-markdown.ts\n/* eslint-disable unicorn/prefer-string-slice */\nconst linkImagePattern = /(!?\\[)([^\\]]*)$/\nconst boldPattern = /(\\*\\*)([^*]*)$/\nconst italicPattern = /(__)([^_]*)$/\nconst boldItalicPattern = /(\\*\\*\\*)([^*]*)$/\nconst singleAsteriskPattern = /(\\*)([^*]*)$/\nconst singleUnderscorePattern = /(_)([^_]*)$/\nconst inlineCodePattern = /(`)([^`]*)$/\nconst strikethroughPattern = /(~~)([^~]*)$/\n\n// Detect custom inline reference tags\nconst mentionTagStartPattern = /<\\s*mention-(?:entry|feed)\\b/gi\nconst mentionTagCompletePattern = /^<\\s*(mention-(?:entry|feed))/i\n\n// Finds the end index of a mention tag (self-closing or paired) starting at `startIndex`.\n// Returns the index of the closing `>` when found outside of quotes; otherwise -1.\nconst findMentionTagEnd = (text: string, startIndex: number): number => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) return -1\n\n  let inQuote: '\"' | \"'\" | null = null\n  let openingTagEnd = -1\n  for (let i = startIndex; i < text.length; i++) {\n    const char = text[i]\n    if (inQuote) {\n      if (char === inQuote && text[i - 1] !== '\\\\') {\n        inQuote = null\n      }\n      continue\n    }\n    if (char === '\"' || char === \"'\") {\n      inQuote = char\n      continue\n    }\n    if (char === '/' && text[i + 1] === '>') {\n      return i + 1 // index of '>' in `/>`\n    }\n    if (char === '>') {\n      openingTagEnd = i\n      break\n    }\n  }\n\n  if (openingTagEnd === -1) {\n    return -1\n  }\n\n  const openingTag = text.substring(startIndex, openingTagEnd + 1)\n  const tagNameMatch = openingTag.match(mentionTagCompletePattern)\n  if (!tagNameMatch) {\n    return -1\n  }\n\n  // If the tag is already self-closing (allow whitespace before `/`)\n  if (/\\/\\s*>$/.test(openingTag)) {\n    return openingTagEnd\n  }\n\n  const tagName = (tagNameMatch[1] ?? '').toLowerCase()\n  if (!tagName) {\n    return -1\n  }\n  const afterOpening = text.substring(openingTagEnd + 1)\n  const closingTagPattern = new RegExp(`<\\\\s*/\\\\s*${tagName}\\\\s*>`, 'i')\n  const closingMatch = closingTagPattern.exec(afterOpening)\n\n  if (!closingMatch) {\n    return -1\n  }\n\n  return openingTagEnd + 1 + closingMatch.index + closingMatch[0].length - 1\n}\n\n// Trims trailing, incomplete `<mention-entry ...>` or `<mention-feed ...>` tags to avoid\n// injecting broken raw HTML into markdown while streaming.\nconst handleIncompleteMentionTags = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  let cutIndex: number | null = null\n  let match: RegExpExecArray | null\n  mentionTagStartPattern.lastIndex = 0\n  while ((match = mentionTagStartPattern.exec(text))) {\n    const start = match.index\n    const end = findMentionTagEnd(text, start)\n    if (end === -1) {\n      cutIndex = start\n      break\n    } else {\n      // continue scanning after this complete tag\n      mentionTagStartPattern.lastIndex = end + 1\n    }\n  }\n\n  if (cutIndex !== null) {\n    const nextNewlineIndex = text.indexOf('\\n', cutIndex)\n    if (nextNewlineIndex !== -1) {\n      // Remove only the incomplete tag segment and preserve following lines\n      return text.substring(0, cutIndex) + text.substring(nextNewlineIndex)\n    }\n    // No newline after the incomplete tag; drop the trailing incomplete segment\n    return text.substring(0, cutIndex)\n  }\n  return text\n}\n\n// Handles `<Use: ...>` wrappers that contain mention tags (self-closing or paired) by:\n// - Replacing the whole wrapper with only the inner `<mention-...>` when complete\n// - Trimming from `<Use:` if the inner mention tag is incomplete while streaming\nconst handleUseWrapper = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) return text\n\n  const usePattern = /<\\s*Use:\\s*/gi\n  let result = text\n  let match: RegExpExecArray | null\n  usePattern.lastIndex = 0\n\n  // We rebuild iteratively in case of multiple occurrences\n  while ((match = usePattern.exec(result))) {\n    const useStart = match.index\n    const mentionStart = result.indexOf('<mention-', useStart)\n    if (mentionStart === -1) {\n      // Incomplete `<Use:` without a mention yet → remove only the incomplete segment\n      const nextNewlineIndex = result.indexOf('\\n', useStart)\n      return nextNewlineIndex !== -1\n        ? result.substring(0, useStart) + result.substring(nextNewlineIndex)\n        : result.substring(0, useStart)\n    }\n\n    // Ensure mention is the immediate content of the Use wrapper (allow whitespace)\n    const between = result.substring(useStart + match[0].length, mentionStart)\n    if (!/^\\s*$/.test(between)) {\n      // Unexpected content between Use and mention → treat as plain text, continue\n      continue\n    }\n\n    const mentionEnd = findMentionTagEnd(result, mentionStart)\n    if (mentionEnd === -1) {\n      // Mention not finished yet → remove only the incomplete wrapper segment\n      const nextNewlineIndex = result.indexOf('\\n', useStart)\n      return nextNewlineIndex !== -1\n        ? result.substring(0, useStart) + result.substring(nextNewlineIndex)\n        : result.substring(0, useStart)\n    }\n\n    // Replace `<Use: <mention-...>` with `<mention-...>`\n    const before = result.substring(0, useStart)\n    const mentionTag = result.substring(mentionStart, mentionEnd + 1)\n    const after = result.substring(mentionEnd + 1)\n    result = before + mentionTag + after\n\n    // Reset the regex lastIndex to continue scanning after the replaced tag\n    usePattern.lastIndex = before.length + mentionTag.length\n  }\n\n  return result\n}\n\n// Helper function to check if we have a complete code block\nconst hasCompleteCodeBlock = (text: string): boolean => {\n  const tripleBackticks = (text.match(/```/g) || []).length\n  return tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes('\\n')\n}\n\n// Handles incomplete links and images by preserving them with a special marker\nconst handleIncompleteLinksAndImages = (text: string): string => {\n  const linkMatch = text.match(linkImagePattern)\n\n  if (linkMatch) {\n    const isImage = linkMatch[1]?.startsWith('!')\n\n    // For images, we still remove them as they can't show skeleton\n    if (isImage) {\n      const startIndex = text.lastIndexOf(linkMatch[1]!)\n      return text.substring(0, startIndex)\n    }\n\n    // For links, preserve the text and close the link with a\n    // special placeholder URL that indicates it's incomplete\n    return `${text}](streamdown:incomplete-link)`\n  }\n\n  return text\n}\n\n// Completes incomplete bold formatting (**)\nconst handleIncompleteBold = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  const boldMatch = text.match(boldPattern)\n\n  if (boldMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // boldMatch[2] contains the content after **\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = boldMatch[2]\n    if (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n      return text\n    }\n\n    // Check if the bold marker is in a list item context\n    // Find the position of the matched bold marker\n    const markerIndex = text.lastIndexOf(boldMatch[1]!)\n    const beforeMarker = text.substring(0, markerIndex)\n    const lastNewlineBeforeMarker = beforeMarker.lastIndexOf('\\n')\n    const lineStart =\n      lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1\n    const lineBeforeMarker = text.substring(lineStart, markerIndex)\n\n    // Check if this line is a list item with just the bold marker\n    if (/^\\s*[-*+]\\s+$/.test(lineBeforeMarker)) {\n      // This is a list item with just emphasis markers\n      // Check if content after marker spans multiple lines\n      const hasNewlineInContent = contentAfterMarker.includes('\\n')\n      if (hasNewlineInContent) {\n        // Don't complete if the content spans to another line\n        return text\n      }\n    }\n\n    const asteriskPairs = (text.match(/\\*\\*/g) || []).length\n    if (asteriskPairs % 2 === 1) {\n      return `${text}**`\n    }\n  }\n\n  return text\n}\n\n// Completes incomplete italic formatting with double underscores (__)\nconst handleIncompleteDoubleUnderscoreItalic = (text: string): string => {\n  const italicMatch = text.match(italicPattern)\n\n  if (italicMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // italicMatch[2] contains the content after __\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = italicMatch[2]\n    if (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n      return text\n    }\n\n    // Check if the underscore marker is in a list item context\n    // Find the position of the matched underscore marker\n    const markerIndex = text.lastIndexOf(italicMatch[1]!)\n    const beforeMarker = text.substring(0, markerIndex)\n    const lastNewlineBeforeMarker = beforeMarker.lastIndexOf('\\n')\n    const lineStart =\n      lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1\n    const lineBeforeMarker = text.substring(lineStart, markerIndex)\n\n    // Check if this line is a list item with just the underscore marker\n    if (/^\\s*[-*+]\\s+$/.test(lineBeforeMarker)) {\n      // This is a list item with just emphasis markers\n      // Check if content after marker spans multiple lines\n      const hasNewlineInContent = contentAfterMarker.includes('\\n')\n      if (hasNewlineInContent) {\n        // Don't complete if the content spans to another line\n        return text\n      }\n    }\n\n    const underscorePairs = (text.match(/__/g) || []).length\n    if (underscorePairs % 2 === 1) {\n      return `${text}__`\n    }\n  }\n\n  return text\n}\n\n// Counts single asterisks that are not part of double asterisks, not escaped, and not list markers\nconst countSingleAsterisks = (text: string): number => {\n  return text.split('').reduce((acc, char, index) => {\n    if (char === '*') {\n      const prevChar = text[index - 1]\n      const nextChar = text[index + 1]\n      // Skip if escaped with backslash\n      if (prevChar === '\\\\') {\n        return acc\n      }\n      // Check if this is a list marker (asterisk at start of line followed by space)\n      // Look backwards to find the start of the current line\n      let lineStartIndex = index\n      for (let i = index - 1; i >= 0; i--) {\n        if (text[i] === '\\n') {\n          lineStartIndex = i + 1\n          break\n        }\n        if (i === 0) {\n          lineStartIndex = 0\n          break\n        }\n      }\n      // Check if this asterisk is at the beginning of a line (with optional whitespace)\n      const beforeAsterisk = text.substring(lineStartIndex, index)\n      if (\n        beforeAsterisk.trim() === '' &&\n        (nextChar === ' ' || nextChar === '\\t')\n      ) {\n        // This is likely a list marker, don't count it\n        return acc\n      }\n      if (prevChar !== '*' && nextChar !== '*') {\n        return acc + 1\n      }\n    }\n    return acc\n  }, 0)\n}\n\n// Completes incomplete italic formatting with single asterisks (*)\nconst handleIncompleteSingleAsteriskItalic = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  const singleAsteriskMatch = text.match(singleAsteriskPattern)\n\n  if (singleAsteriskMatch) {\n    // Find the first single asterisk position (not part of **)\n    let firstSingleAsteriskIndex = -1\n    for (let i = 0; i < text.length; i++) {\n      if (text[i] === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {\n        firstSingleAsteriskIndex = i\n        break\n      }\n    }\n\n    if (firstSingleAsteriskIndex === -1) {\n      return text\n    }\n\n    // Get content after the first single asterisk\n    const contentAfterFirstAsterisk = text.substring(\n      firstSingleAsteriskIndex + 1,\n    )\n\n    // Check if there's meaningful content after the asterisk\n    // Don't close if content is only whitespace or emphasis markers\n    if (\n      !contentAfterFirstAsterisk ||\n      /^[\\s_~*`]*$/.test(contentAfterFirstAsterisk)\n    ) {\n      return text\n    }\n\n    const singleAsterisks = countSingleAsterisks(text)\n    if (singleAsterisks % 2 === 1) {\n      return `${text}*`\n    }\n  }\n\n  return text\n}\n\n// Check if a position is within a math block (between $ or $$)\nconst isWithinMathBlock = (text: string, position: number): boolean => {\n  // Count dollar signs before this position\n  let inInlineMath = false\n  let inBlockMath = false\n\n  for (let i = 0; i < text.length && i < position; i++) {\n    // Skip escaped dollar signs\n    if (text[i] === '\\\\' && text[i + 1] === '$') {\n      i++ // Skip the next character\n      continue\n    }\n\n    if (text[i] === '$') {\n      // Check for block math ($$)\n      if (text[i + 1] === '$') {\n        inBlockMath = !inBlockMath\n        i++ // Skip the second $\n        inInlineMath = false // Block math takes precedence\n      } else if (!inBlockMath) {\n        // Only toggle inline math if not in block math\n        inInlineMath = !inInlineMath\n      }\n    }\n  }\n\n  return inInlineMath || inBlockMath\n}\n\n// Counts single underscores that are not part of double underscores, not escaped, and not in math blocks\nconst countSingleUnderscores = (text: string): number => {\n  return text.split('').reduce((acc, char, index) => {\n    if (char === '_') {\n      const prevChar = text[index - 1]\n      const nextChar = text[index + 1]\n      // Skip if escaped with backslash\n      if (prevChar === '\\\\') {\n        return acc\n      }\n      // Skip if within math block\n      if (isWithinMathBlock(text, index)) {\n        return acc\n      }\n      // Skip if underscore is word-internal (between word characters)\n      if (\n        prevChar &&\n        nextChar &&\n        /[\\p{L}\\p{N}_]/u.test(prevChar) &&\n        /[\\p{L}\\p{N}_]/u.test(nextChar)\n      ) {\n        return acc\n      }\n      if (prevChar !== '_' && nextChar !== '_') {\n        return acc + 1\n      }\n    }\n    return acc\n  }, 0)\n}\n\n// Completes incomplete italic formatting with single underscores (_)\nconst handleIncompleteSingleUnderscoreItalic = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  const singleUnderscoreMatch = text.match(singleUnderscorePattern)\n\n  if (singleUnderscoreMatch) {\n    // Find the first single underscore position (not part of __ and not word-internal)\n    let firstSingleUnderscoreIndex = -1\n    for (let i = 0; i < text.length; i++) {\n      if (\n        text[i] === '_' &&\n        text[i - 1] !== '_' &&\n        text[i + 1] !== '_' &&\n        text[i - 1] !== '\\\\' &&\n        !isWithinMathBlock(text, i)\n      ) {\n        // Check if underscore is word-internal (between word characters)\n        const prevChar = i > 0 ? text[i - 1] : ''\n        const nextChar = i < text.length - 1 ? text[i + 1] : ''\n        if (\n          prevChar &&\n          nextChar &&\n          /[\\p{L}\\p{N}_]/u.test(prevChar) &&\n          /[\\p{L}\\p{N}_]/u.test(nextChar)\n        ) {\n          continue\n        }\n\n        firstSingleUnderscoreIndex = i\n        break\n      }\n    }\n\n    if (firstSingleUnderscoreIndex === -1) {\n      return text\n    }\n\n    // Get content after the first single underscore\n    const contentAfterFirstUnderscore = text.substring(\n      firstSingleUnderscoreIndex + 1,\n    )\n\n    // Check if there's meaningful content after the underscore\n    // Don't close if content is only whitespace or emphasis markers\n    if (\n      !contentAfterFirstUnderscore ||\n      /^[\\s_~*`]*$/.test(contentAfterFirstUnderscore)\n    ) {\n      return text\n    }\n\n    const singleUnderscores = countSingleUnderscores(text)\n    if (singleUnderscores % 2 === 1) {\n      // If text ends with newline(s), insert underscore before them\n      const trailingNewlineMatch = text.match(/\\n+$/)\n      if (trailingNewlineMatch) {\n        const textBeforeNewlines = text.slice(\n          0,\n          -trailingNewlineMatch[0].length,\n        )\n        return `${textBeforeNewlines}_${trailingNewlineMatch[0]}`\n      }\n      return `${text}_`\n    }\n  }\n\n  return text\n}\n\n// Checks if a backtick at position i is part of a triple backtick sequence\nconst isPartOfTripleBacktick = (text: string, i: number): boolean => {\n  const isTripleStart = text.substring(i, i + 3) === '```'\n  const isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === '```'\n  const isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === '```'\n\n  return isTripleStart || isTripleMiddle || isTripleEnd\n}\n\n// Counts single backticks that are not part of triple backticks\nconst countSingleBackticks = (text: string): number => {\n  let count = 0\n  for (let i = 0; i < text.length; i++) {\n    if (text[i] === '`' && !isPartOfTripleBacktick(text, i)) {\n      count++\n    }\n  }\n  return count\n}\n\n// Completes incomplete inline code formatting (`)\n// Avoids completing if inside an incomplete code block\nconst handleIncompleteInlineCode = (text: string): string => {\n  // Check if we have inline triple backticks (starts with ``` and should end with ```)\n  // This pattern should ONLY match truly inline code (no newlines)\n  // Examples: ```code``` or ```python code```\n  const inlineTripleBacktickMatch = text.match(/^```[^`\\n]*```?$/)\n  if (inlineTripleBacktickMatch && !text.includes('\\n')) {\n    // Check if it ends with exactly 2 backticks (incomplete)\n    if (text.endsWith('``') && !text.endsWith('```')) {\n      return `${text}\\``\n    }\n    // Already complete inline triple backticks\n    return text\n  }\n\n  // Check if we're inside a multi-line code block (complete or incomplete)\n  const allTripleBackticks = (text.match(/```/g) || []).length\n  const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1\n\n  // Don't modify text if we have complete multi-line code blocks (even pairs of ```)\n  if (\n    allTripleBackticks > 0 &&\n    allTripleBackticks % 2 === 0 &&\n    text.includes('\\n')\n  ) {\n    // We have complete multi-line code blocks, don't add any backticks\n    return text\n  }\n\n  // Special case: if text ends with ```\\n (triple backticks followed by newline)\n  // This is actually a complete code block, not incomplete\n  if (\n    (text.endsWith('```\\n') || text.endsWith('```')) && // Count all triple backticks - if even, it's complete\n    allTripleBackticks % 2 === 0\n  ) {\n    return text\n  }\n\n  const inlineCodeMatch = text.match(inlineCodePattern)\n\n  if (inlineCodeMatch && !insideIncompleteCodeBlock) {\n    // Don't close if there's no meaningful content after the opening marker\n    // inlineCodeMatch[2] contains the content after `\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = inlineCodeMatch[2]\n    if (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n      return text\n    }\n\n    const singleBacktickCount = countSingleBackticks(text)\n    if (singleBacktickCount % 2 === 1) {\n      return `${text}\\``\n    }\n  }\n\n  return text\n}\n\n// Completes incomplete strikethrough formatting (~~)\nconst handleIncompleteStrikethrough = (text: string): string => {\n  const strikethroughMatch = text.match(strikethroughPattern)\n\n  if (strikethroughMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // strikethroughMatch[2] contains the content after ~~\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = strikethroughMatch[2]\n    if (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n      return text\n    }\n\n    const tildePairs = (text.match(/~~/g) || []).length\n    if (tildePairs % 2 === 1) {\n      return `${text}~~`\n    }\n  }\n\n  return text\n}\n\n// Counts single dollar signs that are not part of double dollar signs and not escaped\nconst _countSingleDollarSigns = (text: string): number => {\n  return text.split('').reduce((acc, char, index) => {\n    if (char === '$') {\n      const prevChar = text[index - 1]\n      const nextChar = text[index + 1]\n      // Skip if escaped with backslash\n      if (prevChar === '\\\\') {\n        return acc\n      }\n      if (prevChar !== '$' && nextChar !== '$') {\n        return acc + 1\n      }\n    }\n    return acc\n  }, 0)\n}\n\n// Completes incomplete block KaTeX formatting ($$)\nconst handleIncompleteBlockKatex = (text: string): string => {\n  // Count all $$ pairs in the text\n  const dollarPairs = (text.match(/\\$\\$/g) || []).length\n\n  // If we have an even number of $$, the block is complete\n  if (dollarPairs % 2 === 0) {\n    return text\n  }\n\n  // If we have an odd number, add closing $$\n  // Check if this looks like a multi-line math block (contains newlines after opening $$)\n  const firstDollarIndex = text.indexOf('$$')\n  const hasNewlineAfterStart =\n    firstDollarIndex !== -1 && text.includes('\\n', firstDollarIndex)\n\n  // For multi-line blocks, add newline before closing $$ if not present\n  if (hasNewlineAfterStart && !text.endsWith('\\n')) {\n    return `${text}\\n$$`\n  }\n\n  // For inline blocks or when already ending with newline, just add $$\n  return `${text}$$`\n}\n\n// Counts triple asterisks that are not part of quadruple or more asterisks\nconst countTripleAsterisks = (text: string): number => {\n  let count = 0\n  const matches = text.match(/\\*+/g) || []\n\n  for (const match of matches) {\n    // Count how many complete triple asterisks are in this sequence\n    const asteriskCount = match.length\n    if (asteriskCount >= 3) {\n      // Each group of exactly 3 asterisks counts as one triple asterisk marker\n      count += Math.floor(asteriskCount / 3)\n    }\n  }\n\n  return count\n}\n\n// Completes incomplete bold-italic formatting (***)\nconst handleIncompleteBoldItalic = (text: string): string => {\n  // Don't process if inside a complete code block\n  if (hasCompleteCodeBlock(text)) {\n    return text\n  }\n\n  // Don't process if text is only asterisks and has 4 or more consecutive asterisks\n  // This prevents cases like **** from being treated as incomplete ***\n  if (/^\\*{4,}$/.test(text)) {\n    return text\n  }\n\n  const boldItalicMatch = text.match(boldItalicPattern)\n\n  if (boldItalicMatch) {\n    // Don't close if there's no meaningful content after the opening markers\n    // boldItalicMatch[2] contains the content after ***\n    // Check if content is only whitespace or other emphasis markers\n    const contentAfterMarker = boldItalicMatch[2]\n    if (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n      return text\n    }\n\n    const tripleAsteriskCount = countTripleAsterisks(text)\n    if (tripleAsteriskCount % 2 === 1) {\n      return `${text}***`\n    }\n  }\n\n  return text\n}\n\n// Parses markdown text and removes incomplete tokens to prevent partial rendering\nexport const parseIncompleteMarkdown = (text: string): string => {\n  if (!text || typeof text !== 'string') {\n    return text\n  }\n\n  let result = text\n\n  // Handle incomplete links and images first\n  const processedResult = handleIncompleteLinksAndImages(result)\n\n  // If we added an incomplete link marker, don't process other formatting\n  // as the content inside the link should be preserved as-is\n  if (processedResult.endsWith('](streamdown:incomplete-link)')) {\n    return processedResult\n  }\n\n  result = processedResult\n\n  // Handle various formatting completions\n  // Handle triple asterisks first (most specific)\n  result = handleIncompleteBoldItalic(result)\n  // Normalize and guard the `<Use:` wrapper first so inner tags are handled correctly\n  result = handleUseWrapper(result)\n  // Handle custom mention tags trimming before other single-character completions\n  result = handleIncompleteMentionTags(result)\n  result = handleIncompleteBold(result)\n  result = handleIncompleteDoubleUnderscoreItalic(result)\n  result = handleIncompleteSingleAsteriskItalic(result)\n  result = handleIncompleteSingleUnderscoreItalic(result)\n  result = handleIncompleteInlineCode(result)\n  result = handleIncompleteStrikethrough(result)\n\n  // Handle KaTeX formatting (only block math with $$)\n  result = handleIncompleteBlockKatex(result)\n  // Note: We don't handle inline KaTeX with single $ as they're likely currency symbols\n\n  return result\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/reasoning-mock.json",
    "content": "{\n  \"groups\": [\n    {\n      \"type\": \"reasoning\",\n      \"text\": \"**Summarizing actionable steps**\\n\\nI need to provide a summary of the article's main points and how to address the identified problem effectively. First, I'll focus on categorizing dependencies using pnpm catalogs and utilizing tooling support such as a VSCode extension. The actionable steps should include setting up pnpm-workspace.yaml, defining categories like test and build, and running audits. My step-by-step plan involves mapping categories, adding catalogs, updating dependencies, installing...\",\n      \"state\": \"done\"\n    },\n    {\n      \"type\": \"reasoning\",\n      \"text\": \"**Updating CI and Tooling**\\n\\nI need to detail steps for updating CI and tooling, including using taze and eslint-plugin-pnpm, along with adjusting the build configuration to reference catalogs. I'll run tests and builds while modifying optimizeDeps in Vite/unbuild using readWorkspaceYaml. Additionally, I should verify with node-modules-inspector and implement a gradual rollout by migrating packages in phases while monitoring for dependency diffs and vulnerabilities.\\n\\nIt's important to note the b...\",\n      \"state\": \"done\"\n    },\n    {\n      \"type\": \"reasoning\",\n      \"text\": \"**Providing Actionable Steps**\\n\\nI want to include actionable steps and command snippets for using pnpm, such as in pnpm-workspace.yaml and package.json, while avoiding heavy formatting. It's okay to mention the entry instead of repeating full code, but I'll provide enough snippets to be helpful. \\n\\nI'll ensure to use Markdown effectively, including inline citations when mentioning the pnpm catalogs feature. The goal is to be concise while offering clear, actionable steps. I'll also mention releva...\",\n      \"providerMetadata\": {\n        \"openai\": \"{itemId: \\\"rs_029b75e62e8c7bd10068ea6fbdf3ec8194b944…}\"\n      },\n      \"state\": \"done\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/shiny-text/ShinyText.tsx",
    "content": "import type { ComponentPropsWithoutRef, CSSProperties, FC } from 'react'\n\nimport { cn } from '~/lib/cn'\n\nimport styles from './index.module.css'\n\nexport interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<'span'> {\n  shimmerWidth?: number\n}\n\nexport const ShinyText: FC<AnimatedShinyTextProps> = ({\n  children,\n  className,\n  shimmerWidth = 100,\n  ...props\n}) => {\n  return (\n    <span\n      style={\n        {\n          '--shiny-width': `${shimmerWidth}px`,\n        } as CSSProperties\n      }\n      className={cn(\n        'text-text-secondary mx-auto max-w-md',\n\n        // Shine effect\n        'bg-clip-text bg-repeat-x',\n\n        styles['shiny-text'],\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </span>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/ai/shiny-text/index.module.css",
    "content": ".shiny-text {\n  background-image:\n    none, linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.85), transparent);\n\n  background-size:\n    cover,\n    200% 100%;\n  background-repeat: no-repeat, repeat-x;\n  background-position:\n    center center,\n    0% 0;\n\n  -webkit-background-clip: text;\n  color: transparent;\n\n  animation: shiny-text 2s linear infinite;\n}\n\n[data-theme='dark'] .shiny-text {\n  background-image:\n    none,\n    linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.85), transparent);\n}\n\n@keyframes shiny-text {\n  from {\n    background-position:\n      center center,\n      0% 0;\n  }\n\n  to {\n    background-position:\n      center center,\n      -200% 0;\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/chat/AiMessageContextBar.tsx",
    "content": "import type { UserContext } from '../../mocks'\n\nexport const AiMessageContextBar = ({ context }: { context: UserContext }) => {\n  return (\n    <div className=\"flex justify-end\">\n      <div className=\"max-w-[calc(100%-1rem)]\">\n        <div className=\"min-w-0 max-w-full text-left\">\n          <div className=\"inline-flex flex-wrap items-center gap-1.5 pl-2 pr-1\">\n            <div className=\"inline-flex items-center gap-1 rounded px-1.5 py-0.5 border bg-linear-to-r backdrop-blur-sm from-orange/5 to-orange/10 border-orange/20 hover:border-orange/30\">\n              <div className=\"flex size-4 shrink-0 items-center justify-center rounded bg-orange/10 text-orange\">\n                {context.icon}\n              </div>\n              <div className=\"flex min-w-0 items-center gap-1\">\n                {context.current && (\n                  <span className={`text-xs font-medium ${context.className}`}>\n                    {context.current}\n                  </span>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/chat/AiMockMessage.tsx",
    "content": "import type { ReasoningUIPart, ToolUIPart } from 'ai'\nimport * as React from 'react'\n\nimport type { AI_CHAT_STEP } from '../../mocks'\nimport { AI_SUMMARY_STEPS } from '../../mocks'\nimport type { ChainReasoningPart } from '../ai/AIChainOfThought'\nimport { AIChainOfThought } from '../ai/AIChainOfThought'\nimport { MarkdownMessage } from './MarkdownMessage'\nimport { streamText } from './stream'\n\ninterface AiMockMessageProps {\n  showToolInvocation?: boolean\n  visibleReasoningCount?: number\n  isReasoningStreaming?: boolean\n  showMarkdown?: boolean\n  isMarkdownStreaming?: boolean\n  steps?: ReadonlyArray<AI_CHAT_STEP>\n  onMarkdownDone?: () => void\n}\n\nexport const AiMockMessage: React.FC<AiMockMessageProps> = ({\n  showToolInvocation = false,\n  visibleReasoningCount = 0,\n  isReasoningStreaming = false,\n  showMarkdown = false,\n  isMarkdownStreaming = false,\n  steps,\n  onMarkdownDone,\n}) => {\n  const [streamingText, setStreamingText] = React.useState<string>('')\n  const streamHandleRef = React.useRef<ReturnType<typeof streamText> | null>(\n    null,\n  )\n  const stepsSource = React.useMemo(\n    () => (steps && steps.length > 0 ? steps : AI_SUMMARY_STEPS),\n    [steps],\n  )\n\n  React.useEffect(() => {\n    streamHandleRef.current?.cancel()\n    streamHandleRef.current = null\n\n    if (!showMarkdown) {\n      setStreamingText('')\n      return\n    }\n\n    const markdownStep = stepsSource.find(\n      (s) => s.role === 'assistant' && s.type === 'markdown',\n    ) as { role: 'assistant'; type: 'markdown'; message: string } | undefined\n    const fullText = markdownStep?.message ?? ''\n\n    if (!isMarkdownStreaming) {\n      setStreamingText(fullText)\n      onMarkdownDone?.()\n      return\n    }\n\n    setStreamingText('')\n\n    streamHandleRef.current = streamText(fullText, {\n      onUpdate: setStreamingText,\n      intervalMs: 70,\n      initialDelayMs: 0,\n      onDone: () => onMarkdownDone?.(),\n    })\n\n    return () => {\n      streamHandleRef.current?.cancel()\n      streamHandleRef.current = null\n    }\n  }, [showMarkdown, isMarkdownStreaming, stepsSource, onMarkdownDone])\n\n  const reasoningGroups = React.useMemo<ChainReasoningPart[]>(() => {\n    const groups: ChainReasoningPart[] = []\n\n    // Tool invocation step from summary steps\n    if (showToolInvocation) {\n      const toolStep = stepsSource.find(\n        (s) => s.role === 'assistant' && s.type === 'tool-invocation',\n      ) as\n        | {\n            role: 'assistant'\n            type: 'tool-invocation'\n            data: { toolName: string; input: string; output: string }\n          }\n        | undefined\n\n      if (toolStep) {\n        const toolGroup: ToolUIPart = {\n          type: `tool-${toolStep.data.toolName}`,\n          toolCallId: 'mock-tool-call',\n          input: toolStep.data.input,\n          output: toolStep.data.output,\n          state: 'output-available',\n        }\n        groups.push(toolGroup)\n      }\n    }\n\n    // Reasoning steps\n    if (visibleReasoningCount) {\n      const allReasoning = stepsSource.filter(\n        (s) => s.role === 'assistant' && s.type === 'reasoning',\n      ) as Array<{ role: 'assistant'; type: 'reasoning'; text: string }>\n\n      const selected = allReasoning.slice(0, visibleReasoningCount)\n\n      groups.push(\n        ...selected.map((step, index, arr) => {\n          const isLast = index === arr.length - 1\n          const state: ReasoningUIPart['state'] =\n            isLast && isReasoningStreaming ? 'streaming' : 'done'\n          return {\n            type: 'reasoning',\n            state,\n            text: step.text,\n          } as ReasoningUIPart\n        }),\n      )\n    }\n\n    return groups\n  }, [\n    showToolInvocation,\n    visibleReasoningCount,\n    isReasoningStreaming,\n    stepsSource,\n  ])\n\n  return (\n    <div className=\"min-w-0 flex flex-col gap-3\">\n      {reasoningGroups.length > 0 ? (\n        <AIChainOfThought\n          groups={reasoningGroups}\n          isStreaming={isReasoningStreaming}\n        />\n      ) : null}\n\n      {showMarkdown ? (\n        <MarkdownMessage\n          text={streamingText}\n          isStreaming={isMarkdownStreaming}\n        />\n      ) : null}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/chat/AiUserMessage.tsx",
    "content": "import type { UserContext } from '../../mocks'\nimport { AiMessageContextBar } from './AiMessageContextBar'\n\nexport const AiUserMessage = ({\n  userMessage,\n  context,\n}: {\n  userMessage?: string\n  context?: UserContext\n}) => {\n  return (\n    <div className=\"relative flex flex-col gap-1\">\n      {context && <AiMessageContextBar context={context} />}\n      <div className=\"group flex justify-end\">\n        <div className=\"text-text relative flex max-w-[calc(100%-1rem)] flex-col gap-2\">\n          <div className=\"text-text bg-fill-secondary/50 rounded-2xl px-4 py-2.5\">\n            <div className=\"flex select-text flex-col gap-2 text-sm\">\n              <div className=\"relative cursor-text text-sm text-text\">\n                <p className=\"mb-1 last:mb-0\" dir=\"ltr\">\n                  {userMessage}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"h-6\" />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/chat/ListChatPlayer.tsx",
    "content": "'use client'\nimport { AnimatePresence, m } from 'motion/react'\nimport Link from 'next/link'\nimport * as React from 'react'\n\nimport { AISpline } from '~/components/ui/3d-models/AISpline'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'\nimport { cn } from '~/lib/cn'\nimport { sleep } from '~/lib/sleep'\nimport { Spring } from '~/lib/spring'\n\nimport type { AI_CHAT_STEP } from '../../mocks'\nimport type { ChainReasoningPart } from '../ai/AIChainOfThought'\nimport { AIChainOfThought } from '../ai/AIChainOfThought'\nimport { AiMessageContextBar } from './AiMessageContextBar'\nimport { AiUserMessage } from './AiUserMessage'\nimport { MarkdownMessage } from './MarkdownMessage'\nimport { streamText } from './stream'\n\nconst DownloadAppTip: React.FC = () => {\n  return (\n    <div className=\"max-w-full w-full flex text-sm text-text\">\n      <div className=\"flex items-start gap-2 whitespace-pre\">\n        <i\n          className=\"i-mingcute-download-2-line mt-1.5 text-accent\"\n          aria-hidden\n        />\n        <div className=\"min-w-0\">\n          <div className=\"mt-1 text-text-secondary\">\n            Get the full experience on Desktop or try it on the Web.{' '}\n            <Link href=\"/download\" className=\"text-accent\">\n              Download\n            </Link>{' '}\n            or{' '}\n            <Link\n              href=\"https://app.folo.is\"\n              target=\"_blank\"\n              rel=\"noreferrer noopener\"\n              className=\"text-accent\"\n            >\n              Try the Web\n            </Link>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\ninterface ListChatPlayerProps {\n  steps: ReadonlyArray<AI_CHAT_STEP>\n  initialTitle?: string\n  rootClassName?: string\n  scrollRootClassName?: string\n  scrollViewportClassName?: string\n  showChatPanelRightDownload?: boolean\n  autoplay?: boolean\n}\n\nexport const ListChatPlayer: React.FC<ListChatPlayerProps> = ({\n  steps,\n  initialTitle = 'Chat',\n  rootClassName,\n  scrollRootClassName,\n  scrollViewportClassName,\n  showChatPanelRightDownload = false,\n  autoplay = true,\n}) => {\n  const [width, setWidth] = React.useState(0)\n  const mainContainerRef = React.useRef<HTMLElement>(null)\n  const viewportRef = React.useRef<HTMLDivElement | null>(null)\n  const inputRef = React.useRef<HTMLInputElement | null>(null)\n  const [title, setTitle] = React.useState(initialTitle)\n\n  const [messages, setMessages] = React.useState<\n    Array<{\n      id: string\n      role: 'user' | 'assistant'\n      message?: string\n      component?: React.ReactNode\n      context?: any\n      kind?: 'chain'\n    }>\n  >([])\n  const [streamingText, setStreamingText] = React.useState<string>('')\n  const [isStreaming, setIsStreaming] = React.useState(false)\n  const streamHandleRef = React.useRef<ReturnType<typeof streamText> | null>(\n    null,\n  )\n  const hasStartedRef = React.useRef(false)\n  const idCounterRef = React.useRef(0)\n  const chainMessageIdRef = React.useRef<string | null>(null)\n  const [chainGroups, setChainGroups] = React.useState<ChainReasoningPart[]>([])\n  const [chainStreaming, setChainStreaming] = React.useState(false)\n  const [inputValue, setInputValue] = React.useState('')\n\n  const canSend = !isStreaming && !chainStreaming\n\n  const handleSend = React.useCallback(\n    (e?: React.FormEvent) => {\n      e?.preventDefault()\n      const text = inputValue.trim()\n      if (!text || !canSend) return\n      const id1 = `m-${idCounterRef.current++}`\n      const id2 = `m-${idCounterRef.current++}`\n      setMessages((prev) => [\n        ...prev,\n        { id: id1, role: 'user', message: text },\n        { id: id2, role: 'user', component: <DownloadAppTip /> },\n      ])\n      setInputValue('')\n    },\n    [canSend, inputValue],\n  )\n\n  React.useLayoutEffect(() => {\n    const $ = mainContainerRef.current\n    if (!$) return\n    const handler = () => {\n      setWidth($.getBoundingClientRect().width)\n    }\n    const resizeObserver = new ResizeObserver(handler)\n    resizeObserver.observe($)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  // Drive the chat sequentially by provided steps\n  React.useEffect(() => {\n    if (hasStartedRef.current || !autoplay) return\n    hasStartedRef.current = true\n\n    const run = async () => {\n      for (const step of steps) {\n        // cancel any previous streaming\n        streamHandleRef.current?.cancel()\n        streamHandleRef.current = null\n\n        if (step.role === 'user') {\n          const id = `m-${idCounterRef.current++}`\n          const anyStep = step as unknown as {\n            message?: string\n            component?: React.ReactNode\n            context?: any\n          }\n          setMessages((prev) => [\n            ...prev,\n            {\n              id,\n              role: 'user',\n              message: anyStep.message,\n              component: anyStep.component,\n              context: anyStep.context,\n            },\n          ])\n          await sleep(600)\n          continue\n        }\n\n        // action step: set-title\n        if ((step as any).role === 'action') {\n          const action = step as {\n            role: 'action'\n            type: 'set-title'\n            title: string\n          }\n          if (action.type === 'set-title') setTitle(action.title)\n          await sleep(100)\n          continue\n        }\n\n        const anyStep = step as unknown as {\n          type?: string\n          message?: string\n          component?: React.ReactNode\n          data?: { toolName: string; input: string; output: string }\n        }\n\n        // assistant component without text\n        if (anyStep.component && !anyStep.message) {\n          const id = `m-${idCounterRef.current++}`\n          setMessages((prev) => [\n            ...prev,\n            { id, role: 'assistant', component: anyStep.component },\n          ])\n          await sleep(400)\n          continue\n        }\n\n        // tool invocation step -> render chain container and append tool group\n        if (anyStep.type === 'tool-invocation' && anyStep.data) {\n          if (!chainMessageIdRef.current) {\n            const id = `m-${idCounterRef.current++}`\n            chainMessageIdRef.current = id\n            setMessages((prev) => [\n              ...prev,\n              { id, role: 'assistant', kind: 'chain' },\n            ])\n          }\n          const tool = anyStep.data!\n          setChainGroups((prev) => [\n            ...prev,\n            {\n              type: `tool-${tool.toolName}`,\n              toolCallId: 'mock-tool-call',\n              input: tool.input,\n              output: tool.output,\n              state: 'output-available',\n            } as any,\n          ])\n          await sleep(400)\n          continue\n        }\n\n        // reasoning step -> ensure chain container and stream\n        if (\n          anyStep.type === 'reasoning' &&\n          typeof (step as any).text === 'string'\n        ) {\n          if (!chainMessageIdRef.current) {\n            const id = `m-${idCounterRef.current++}`\n            chainMessageIdRef.current = id\n            setMessages((prev) => [\n              ...prev,\n              { id, role: 'assistant', kind: 'chain' },\n            ])\n          }\n          const text = (step as any).text as string\n          setChainStreaming(true)\n          setChainGroups((prev) => [\n            ...prev,\n            { type: 'reasoning', state: 'streaming', text } as any,\n          ])\n          // wait proportional to text length\n          await sleep(\n            Math.min(2000, Math.max(500, Math.round(text.length * 3.5))),\n          )\n          // mark last as done\n          setChainGroups((prev) => {\n            const next = [...prev]\n            const last = next.pop() as any\n            if (last && last.type === 'reasoning') {\n              next.push({ ...last, state: 'done' })\n            } else if (last) {\n              next.push(last)\n            }\n            return next\n          })\n          setChainStreaming(false)\n          continue\n        }\n\n        // assistant markdown/message streaming\n        if (anyStep.message && (anyStep.type === 'markdown' || !anyStep.type)) {\n          const fullText = anyStep.message\n          setIsStreaming(true)\n          setStreamingText('')\n          await sleep(100)\n\n          streamHandleRef.current = streamText(fullText, {\n            onUpdate: setStreamingText,\n            intervalMs: 70,\n            initialDelayMs: 0,\n          })\n          await streamHandleRef.current.done\n          setIsStreaming(false)\n          const id = `m-${idCounterRef.current++}`\n          setMessages((prev) => [\n            ...prev,\n            { id, role: 'assistant', message: fullText },\n          ])\n          setStreamingText('')\n          await sleep(500)\n        }\n      }\n    }\n\n    void run().then(() => {\n      // if (inputRef.current) {\n      //   inputRef.current.focus()\n      // }\n    })\n\n    return () => {\n      streamHandleRef.current?.cancel()\n    }\n  }, [autoplay, steps])\n\n  // Auto scroll to bottom when messages/streaming updates\n  React.useEffect(() => {\n    const el = viewportRef.current\n    if (!el) return\n    if (typeof el.scrollTo === 'function') {\n      el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })\n    } else {\n      el.scrollTop = el.scrollHeight\n    }\n  }, [messages, streamingText])\n\n  return (\n    <div className={rootClassName}>\n      <header className=\"px-3 py-2 border-b flex items-center gap-2\">\n        <AISpline className=\"size-9 absolute\" />\n        <h1\n          className=\"truncate font-bold text-text animate-mask-left-to-right [--animation-duration:1s]\"\n          key={title}\n        >\n          <span className=\"ml-10\">{title}</span>\n        </h1>\n\n        {/* Download App Tip */}\n        {showChatPanelRightDownload ? (\n          <Button\n            variant=\"ghost\"\n            className=\"ml-auto p-1 rounded\"\n            onClick={() => {\n              window.open('/download', '_blank')\n            }}\n          >\n            <i className=\"i-mingcute-download-2-line\" />\n            <span className=\"sr-only\">Download</span>\n          </Button>\n        ) : null}\n      </header>\n\n      <ScrollArea\n        ref={viewportRef}\n        rootClassName={scrollRootClassName ?? 'h-0 grow'}\n        viewportClassName={scrollViewportClassName ?? 'px-3 min-w-0'}\n        flex\n      >\n        <main\n          ref={mainContainerRef}\n          className=\"my-4\"\n          style={{\n            ['--ai-chat-message-container-width' as any]: `${width}px`,\n          }}\n        >\n          <AnimatePresence mode=\"popLayout\">\n            {messages.map((msg) => {\n              if (msg.role === 'user') {\n                return (\n                  <m.div\n                    key={msg.id}\n                    initial={{ opacity: 0, y: 12 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, y: -8 }}\n                    transition={Spring.presets.smooth}\n                    className=\"relative flex flex-col gap-3 mt-4\"\n                  >\n                    {msg.component ? (\n                      <div className=\"flex flex-col\">\n                        {msg.context && (\n                          <AiMessageContextBar context={msg.context} />\n                        )}\n                        <div className=\"flex flex-col items-end mb-4\">\n                          {msg.component}\n                        </div>\n                      </div>\n                    ) : (\n                      <AiUserMessage\n                        userMessage={msg.message ?? ''}\n                        context={msg.context}\n                      />\n                    )}\n                  </m.div>\n                )\n              }\n\n              return (\n                <div key={msg.id} className=\"min-w-0 flex flex-col gap-3\">\n                  {msg.kind === 'chain' ? (\n                    <AIChainOfThought\n                      groups={chainGroups}\n                      isStreaming={chainStreaming}\n                    />\n                  ) : msg.component ? (\n                    <>{msg.component}</>\n                  ) : msg.message ? (\n                    <MarkdownMessage text={msg.message} />\n                  ) : null}\n                </div>\n              )\n            })}\n          </AnimatePresence>\n\n          {isStreaming && streamingText ? (\n            <m.div\n              key=\"assistant-streaming\"\n              initial={{ opacity: 0, y: 16 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -10 }}\n              transition={Spring.presets.smooth}\n              className=\"min-w-0 flex flex-col gap-3\"\n            >\n              <MarkdownMessage text={streamingText} isStreaming />\n            </m.div>\n          ) : null}\n        </main>\n      </ScrollArea>\n\n      <form\n        onSubmit={handleSend}\n        className=\"p-3 border-t bg-background/60 backdrop-blur\"\n      >\n        <Input\n          className=\"rounded-lg\"\n          inputClassName=\"rounded-lg\"\n          placeholder={'Ask Folo anything…'}\n          value={inputValue}\n          onChange={(e) => setInputValue(e.target.value)}\n          ref={inputRef}\n          endAdornment={\n            <button\n              type=\"button\"\n              className={cn(\n                'inline-flex select-none items-center justify-center outline-offset-2 active:transition-none disabled:cursor-not-allowed disabled:ring-0 align-middle focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 text-sm font-semibold ring-0! hover:contrast-[1.10] hover:shadow-md hover:shadow-accent/20 active:contrast-125 active:shadow-none disabled:bg-theme-disabled disabled:dark:text-zinc-50 disabled:shadow-none focus-visible:ring-accent/30 text-zinc-50 size-8 rounded-xl p-0 transition-all duration-300 active:scale-95 disabled:bg-disabled-control cursor-not-allowed backdrop-blur-sm bg-accent',\n                'size-6',\n                'rounded-lg',\n              )}\n              disabled={!inputValue.trim() || !canSend}\n              onClick={handleSend}\n            >\n              <span className=\"contents\">\n                <span className=\"flex items-center justify-center\">\n                  <i className=\"i-mingcute-send-plane-fill size-3.5 text-white\" />\n                </span>\n              </span>\n            </button>\n          }\n          endAdornmentVisibility=\"always\"\n        />\n      </form>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/chat/MarkdownMessage.tsx",
    "content": "import { AIMarkdownStreamingMessage } from '../ai/AIMarkdownMessage'\n\nexport const MarkdownMessage = ({\n  text,\n  className,\n  isStreaming,\n}: {\n  text: string\n  className?: string\n  isStreaming?: boolean\n}) => {\n  return (\n    <AIMarkdownStreamingMessage\n      text={text}\n      className={className}\n      isStreaming={isStreaming}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/components/chat/stream.ts",
    "content": "export interface StreamOptions {\n  onUpdate: (slice: string) => void\n  onDone?: () => void\n  intervalMs?: number\n  chunkSize?: number\n  initialDelayMs?: number\n}\n\nexport interface StreamHandle {\n  cancel: () => void\n  done: Promise<void>\n}\n\n/**\n * Stream a long string by periodically emitting growing slices of the text.\n * Returns a handle with a cancel function and a promise that resolves when streaming completes.\n */\nexport function streamText(text: string, options: StreamOptions): StreamHandle {\n  const {\n    onUpdate,\n    onDone,\n    intervalMs = 70,\n    chunkSize,\n    initialDelayMs = 0,\n  } = options\n\n  const totalLength = text.length\n  const size =\n    typeof chunkSize === 'number' && chunkSize > 0\n      ? chunkSize\n      : Math.max(12, Math.ceil(totalLength / 90))\n\n  let index = Math.min(size, totalLength)\n  let interval: number | null = null\n  let startTimeout: number | null = null\n\n  let resolveDone: () => void\n  const done = new Promise<void>((resolve) => {\n    resolveDone = resolve\n  })\n\n  const start = () => {\n    // Emit first slice\n    onUpdate(text.slice(0, index))\n\n    interval = window.setInterval(() => {\n      index = Math.min(totalLength, index + size)\n      onUpdate(text.slice(0, index))\n\n      if (index >= totalLength && interval) {\n        window.clearInterval(interval)\n        interval = null\n        onDone?.()\n        resolveDone()\n      }\n    }, intervalMs)\n  }\n\n  if (initialDelayMs > 0) {\n    startTimeout = window.setTimeout(start, initialDelayMs)\n  } else {\n    start()\n  }\n\n  const cancel = () => {\n    if (startTimeout) {\n      window.clearTimeout(startTimeout)\n      startTimeout = null\n    }\n    if (interval) {\n      window.clearInterval(interval)\n      interval = null\n    }\n  }\n\n  return { cancel, done }\n}\n"
  },
  {
    "path": "apps/landing/src/components/widgets/simulators/mocks.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from '~/components/ui/tooltip'\n\nexport type EntryDetail = {\n  title: string\n  source: {\n    name: string\n    host: string\n    icon: string\n  }\n  author: {\n    name: string\n    role: string\n    dateLabel: string\n    readingTime: string\n  }\n}\n\nexport const ENTRY_DETAIL: EntryDetail = {\n  title: 'Building effective agents for production teams',\n  source: {\n    name: 'Anthropic',\n    host: 'anthropic.com',\n    icon: 'https://icons.folo.is/anthropic.com',\n  },\n  author: {\n    name: 'Anthropic Research',\n    role: 'Agent systems guide',\n    dateLabel: 'Updated recently',\n    readingTime: '8 min read',\n  },\n}\n\ntype ToolInvocation = {\n  toolName: string\n  input: string\n  output: string\n}\n\nexport const AI_TOOL_INVOCATION: ToolInvocation = {\n  toolName: 'getEntry',\n  input: `{\n  \"id\": \"184203101927882752\",\n  \"mode\": \"detailed\"\n}`,\n  output: `{\n  \"id\": \"184203101927882752\",\n  \"title\": \"Building effective agents for production teams\",\n  \"url\": \"https://www.anthropic.com/news\",\n  \"author\": \"Anthropic Research\",\n  \"publishedAt\": \"2026-03-14T09:30:00.000Z\",\n  \"feedId\": \"249743826993702912\",\n  \"subtitles\": null,\n  \"transcription\": null\n}\n`,\n}\nexport type UserContext = {\n  current?: string\n  className?: string\n  icon?: React.ReactNode\n}\n\ntype UserPlainTextStep = {\n  role: 'user'\n  message: string\n  context?: UserContext\n}\ntype UserComponentStep = {\n  role: 'user'\n  component: React.ReactNode\n  context?: {\n    current?: string\n    className?: string\n    icon?: React.ReactNode\n  }\n}\ntype UserStep = UserPlainTextStep | UserComponentStep\ntype AIReasoningPart = {\n  type: 'reasoning'\n  text: string\n}\ntype ToolUIPart = {\n  type: 'tool-invocation'\n  data: ToolInvocation\n}\ntype MarkdownUIPart = {\n  type: 'markdown'\n  message: string\n}\ntype AssistantStep = (AIReasoningPart | ToolUIPart | MarkdownUIPart) & {\n  role: 'assistant'\n}\ntype ActionStep = {\n  role: 'action'\n  type: 'set-title'\n  title: string\n}\nexport type AI_CHAT_STEP = UserStep | AssistantStep | ActionStep\n\nexport const AI_SUMMARY_STEPS: AI_CHAT_STEP[] = [\n  {\n    role: 'user',\n    message:\n      'What changed here, and what should an engineering team actually do next?',\n    context: {\n      current: 'Building effective agents for production teams',\n      className: 'text-orange',\n      icon: <i className=\"size-2.5 i-mingcute-star-fill\" />,\n    },\n  },\n  {\n    role: 'assistant',\n    type: 'tool-invocation',\n    data: AI_TOOL_INVOCATION,\n  },\n\n  {\n    role: 'assistant',\n    type: 'reasoning',\n    text: '**Finding the operational delta**\\n\\nThe article is not pitching agents as a prompt trick. It is framing production agents as a systems problem: tool boundaries, state, evals, retry policy, and approval gates matter more than flashy demos. I should separate platform claims from the concrete engineering steps a team would need to ship safely.',\n  },\n  {\n    role: 'assistant',\n    type: 'reasoning',\n    text: '**Reducing it to a production checklist**\\n\\nThe strongest through-line is that agent quality comes from narrower loops and better observability. The answer should emphasize explicit tool contracts, structured outputs, test cases for failure modes, and human review at expensive or risky steps.',\n  },\n  {\n    role: 'assistant',\n    type: 'reasoning',\n    text: '**Shaping the final response**\\n\\nI can keep this useful by translating the article into three layers: what the article claims, what signals are worth keeping, and what a team should instrument before calling an agent \"production-ready.\"',\n  },\n  {\n    role: 'action',\n    type: 'set-title',\n    title: 'How to move AI agents from demos to production',\n  },\n  {\n    role: 'assistant',\n    type: 'markdown',\n    message: `Short summary\n- The article argues that strong agent products come from tighter system design, not just stronger prompts. Teams are getting better results by shrinking the loop: clear tool contracts, explicit handoffs, structured outputs, and smaller scopes per step.\n\nWhat matters\n- The main bottleneck is reliability. Once an agent can call tools or trigger side effects, every unclear interface becomes an operational bug.\n- Evaluation is part of the product. Teams need regression suites for tool use, refusal behavior, retries, latency, and cost.\n- Human approval still matters at high-risk steps. Good production systems use agents to draft, classify, and route work before they let them act.\n\nWhat to do next\n1. Start with a narrow task and one or two tools.\n2. Force structured outputs instead of free-form text.\n3. Log every tool call, retry, and failure mode.\n4. Add evals before expanding scope.\n5. Put human review in front of expensive, external, or irreversible actions.\n\nBottom line\n- The winning pattern is not “one model does everything.” It is a measured workflow where the model reasons inside clear boundaries and the product team can inspect every step.\n`,\n  },\n]\n\nexport const TIMELINE_SUMMARY_STEPS: AI_CHAT_STEP[] = [\n  {\n    role: 'user',\n    context: {\n      current: 'All',\n      className: 'text-xs font-medium',\n      icon: <i className=\"size-2.5 i-mingcute-bubble-fill\" />,\n    },\n\n    component: (\n      <Tooltip>\n        <TooltipTrigger>\n          <span className=\"relative inline-flex select-none items-center rounded-md px-2 py-0.5 text-xs font-medium transition-colors border border-fill font-mono bg-fill-secondary/50 hover:bg-fill-secondary text-text-secondary hover:text-text cursor-default\">\n            <span className=\"mr-0.5 text-[10px] text-text-tertiary opacity-60\">\n              /\n            </span>\n            <span className=\"flex items-center justify-center mr-1 size-3\">\n              <i className=\"i-mgc-send-plane-cute-re\" />\n            </span>\n            <span className=\"truncate text-xs\">Summarize</span>\n          </span>\n        </TooltipTrigger>\n        <TooltipContent>\n          Generate a concise timeline summary based on entries within the\n          current timeline within 24 hours. Recap the day in a casual,\n          conversational tone instead of a rigid report. Open with a few relaxed\n          sentences or light bullets that call out standout topics or shifts.\n          Wrap up by casually noting any other interesting threads; if nothing\n          else stands out, say so naturally.\n        </TooltipContent>\n      </Tooltip>\n    ),\n  },\n  {\n    role: 'assistant',\n    type: 'markdown',\n    message: `Hey there! It looks like the last 24 hours have been buzzing with conversations around AI, from its fundamental principles to its impact on the world.\n\nHere’s a quick look at what’s been happening:\n\n- There's a running theme on the evolution of Artificial Intelligence. One article argues that AI safety and scalability aren't mutually exclusive goals. Adding to that, a new podcast, \"The Cyber60,\" is set to explore how we can forge security in a world powered by AI. And for a deeper dive, Raghu Raghuram discusses how AI and robotics are fundamentally reshaping tech infrastructure.\n- Shifting gears to business and leadership, a16z shared an interesting take on why Stanford GSB might not be the go-to for finding leaders.\n\nWrapping things up, there was also a bit of chatter in the crypto space about Zcash as a privacy-focused alternative to Bitcoin. And that's the quick and dirty of your timeline today\n`,\n  },\n  {\n    role: 'user',\n    context: {\n      current: 'All',\n      className: 'text-xs font-medium',\n      icon: <i className=\"size-2.5 i-mingcute-bubble-fill\" />,\n    },\n    message: `how will robotics reshape tech infrastructure?`,\n  },\n\n  {\n    role: 'assistant',\n    type: 'markdown',\n    message: `Of course! I've just reviewed the discussion with Raghu Raghuram on the a16z podcast to get you the details.\n\nThe core idea is that the explosive growth of AI is creating a demand for new hardware and data centers that is too massive and rapid to be built by human labor alone. Here's how they see robotics reshaping the tech infrastructure:\n\n- :building_construction: Automating the Foundation of AI\n\nAccording to Raghu, the process of building and assembling the powerful AI servers packed with GPUs is currently very manual—as much as 70-80% labor. To meet the incredible demand for AI compute, robotics will be essential to automate the construction of everything from the servers themselves to the entire data centers that house them. He believes it's the only way to scale to the level required.\n\n- :jigsaw: Building New Layers of Infrastructure\n\nThe development of robotics for these specific tasks (like data center assembly) will lead to the creation of new, more general infrastructure. As robots are deployed, there will be a need for:\n\n- Horizontal Tools: Software and platforms for things like simulation and managing sensor data will become necessary.\n\n- New Data Infrastructure: Systems will be needed to synchronize all the data coming from various sensors on the robots to train them effectively.\n\nEssentially, by solving the very specific problem of building data centers, a whole new set of horizontal tools and platforms will emerge, creating a new layer of the tech infrastructure stack.`,\n  },\n]\n"
  },
  {
    "path": "apps/landing/src/constants/download.ts",
    "content": "import { siteInfo } from './site'\n\nexport type OS = 'iOS' | 'Android' | 'macOS' | 'Windows' | 'Linux'\n\nexport type PlatformDownloadChannel = {\n  id: string\n  href: string\n  name: string\n  description: string\n}\n\nexport type PlatformDownloadGroup = {\n  os: OS\n  label: string\n  icon: string\n  channels: PlatformDownloadChannel[]\n}\n\nexport const PlatformDownloadGroups: PlatformDownloadGroup[] = [\n  {\n    os: 'iOS',\n    label: 'iOS',\n    icon: 'i-simple-icons-apple',\n    channels: [\n      {\n        id: 'ios-app-store',\n        href: 'https://apps.apple.com/us/app/folo-follow-everything/id6739802604',\n        name: 'App Store',\n        description: 'Install on iPhone and iPad',\n      },\n    ],\n  },\n  {\n    os: 'Android',\n    label: 'Android',\n    icon: 'i-simple-icons-googleplay',\n    channels: [\n      {\n        id: 'android-google-play',\n        href: 'https://play.google.com/store/apps/details?id=is.follow',\n        name: 'Google Play',\n        description: 'Automatic updates',\n      },\n      {\n        id: 'android-apk',\n        href: siteInfo.releaseLink,\n        name: 'Direct Download (APK)',\n        description: 'Install from the latest GitHub release',\n      },\n    ],\n  },\n  {\n    os: 'macOS',\n    label: 'macOS',\n    icon: 'i-simple-icons-apple',\n    channels: [\n      {\n        id: 'macos-app-store',\n        href: 'https://apps.apple.com/us/app/folo-follow-everything/id6739802604',\n        name: 'Mac App Store',\n        description: 'Automatic updates',\n      },\n      {\n        id: 'macos-dmg',\n        href: siteInfo.releaseLink,\n        name: 'Direct Download (DMG)',\n        description: 'Install from the latest GitHub release',\n      },\n    ],\n  },\n  {\n    os: 'Windows',\n    label: 'Windows',\n    icon: 'i-simple-icons-windows',\n    channels: [\n      {\n        id: 'windows-store',\n        href: 'https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct',\n        name: 'Microsoft Store',\n        description: 'Automatic updates',\n      },\n      {\n        id: 'windows-exe',\n        href: siteInfo.releaseLink,\n        name: 'Direct Download (EXE)',\n        description: 'Install from the latest GitHub release',\n      },\n    ],\n  },\n  {\n    os: 'Linux',\n    label: 'Linux',\n    icon: 'i-simple-icons-linux',\n    channels: [\n      {\n        id: 'linux-github',\n        href: siteInfo.releaseLink,\n        name: 'GitHub Release',\n        description: 'AppImage and other release assets',\n      },\n    ],\n  },\n]\n"
  },
  {
    "path": "apps/landing/src/constants/env.ts",
    "content": "import { isClientSide, isDev } from '~/lib/env'\n\nexport const API_URL: string = (() => {\n  if (isDev) return process.env.NEXT_PUBLIC_API_URL\n\n  if (isClientSide && process.env.NEXT_PUBLIC_CHINA_API_URL) {\n    return process.env.NEXT_PUBLIC_CHINA_API_URL\n  }\n\n  return process.env.NEXT_PUBLIC_API_URL || '/api/v2'\n})() as string\nexport const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL || ''\n"
  },
  {
    "path": "apps/landing/src/constants/site.ts",
    "content": "export const APP_NAME = 'Folo'\n\nexport const siteInfo = {\n  title: APP_NAME,\n  description:\n    'AI-powered RSS reader for deep, noise-free reading with contextual AI.',\n  webUrl: 'https://folo.is',\n  appUrl: 'https://app.folo.is',\n  githubLink: 'https://github.com/RSSNext/Folo',\n  githubApiLink: 'https://ungh.cc/repos/RSSNext/Folo',\n  xLink: 'https://x.com/folo_is',\n  discordLink: 'https://discord.gg/followapp',\n  productHuntLink: 'https://www.producthunt.com/posts/follow-5',\n  releaseLink: 'https://github.com/RSSNext/Folo/releases/latest',\n  navigation: [\n    { title: 'Features', href: '/#features' },\n    { title: 'Testimonials', href: '/#testimonials' },\n    { title: 'FAQ', href: '/#faq' },\n  ],\n  seo: {\n    titleTemplate: '%s — AI-powered RSS Reader',\n    defaultTitle: 'Folo — AI-powered RSS Reader',\n    description:\n      'AI-powered RSS reader for deep, noise-free reading with contextual AI.',\n    keywords: [\n      'RSS reader',\n      'RSS',\n      'RSSHub',\n      'information browser',\n      'content curation',\n      'news aggregator',\n      'feed reader',\n      'AI-powered',\n      'real-time updates',\n      'content discovery',\n      'social media integration',\n      'cross-platform',\n    ] as string[],\n    authors: [{ name: 'Follow Team' }] as Array<{ name: string }>,\n    creator: 'Follow Team',\n    publisher: 'Follow Team',\n    openGraph: {\n      type: 'website' as const,\n      locale: 'en_US',\n      url: 'https://folo.is',\n      siteName: 'Folo',\n      title: 'Folo — AI-powered RSS Reader',\n      description:\n        'AI-powered RSS reader for deep, noise-free reading with contextual AI.',\n      images: [\n        {\n          url: 'https://folo.is/opengraph-image.png',\n          width: 1200,\n          height: 630,\n          alt: 'Folo — AI-powered RSS Reader',\n        },\n      ] as Array<{\n        url: string\n        width: number\n        height: number\n        alt: string\n      }>,\n    },\n    twitter: {\n      card: 'summary_large_image' as const,\n      site: '@folo_is',\n      creator: '@folo_is',\n      title: 'Folo — AI-powered RSS Reader',\n      description:\n        'AI-powered RSS reader for deep, noise-free reading with contextual AI.',\n      images: ['https://folo.is/opengraph-image.png'] as string[],\n    },\n    robots: {\n      index: true,\n      follow: true,\n      googleBot: {\n        index: true,\n        follow: true,\n        'max-video-preview': -1,\n        'max-image-preview': 'large',\n        'max-snippet': -1,\n      },\n    },\n  },\n} as const\n"
  },
  {
    "path": "apps/landing/src/constants/spring.ts",
    "content": "import type { Transition } from 'motion/react'\n\nexport const reboundPreset: Transition = {\n  type: 'spring',\n  bounce: 10,\n  stiffness: 140,\n  damping: 8,\n}\n\nexport const microDampingPreset: Transition = {\n  type: 'spring',\n  damping: 24,\n}\n\nexport const microReboundPreset: Transition = {\n  type: 'spring',\n  stiffness: 300,\n  damping: 20,\n}\n\nexport const softSpringPreset: Transition = {\n  duration: 0.35,\n  type: 'spring',\n  stiffness: 120,\n  damping: 20,\n}\n\nexport const softBouncePreset: Transition = {\n  type: 'spring',\n  damping: 10,\n  stiffness: 100,\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/biz/use-github-star.ts",
    "content": "'use client'\nimport { useQuery } from '@tanstack/react-query'\n\ntype GithubRepoStatsResponse = {\n  repo?: {\n    stars?: number\n  }\n}\n\nexport const useGithubStar = () =>\n  useQuery({\n    queryKey: ['github-star'],\n    queryFn: async () => {\n      try {\n        const response = await fetch('https://ungh.cc/repos/RSSNext/Folo')\n\n        if (!response.ok) {\n          throw new Error(`Failed to fetch repo stats: ${response.status}`)\n        }\n\n        const data = (await response.json()) as GithubRepoStatsResponse\n        return typeof data.repo?.stars === 'number' ? data.repo.stars : -1\n      } catch (error) {\n        console.error(error)\n        return -1\n      }\n    },\n    staleTime: 1000 * 60 * 10,\n  })\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-before-mounted.ts",
    "content": "import { useRef } from 'react'\n\nexport const useBeforeMounted = (fn: () => any) => {\n  const effectOnce = useRef(false)\n\n  if (!effectOnce.current) {\n    effectOnce.current = true\n    fn?.()\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-click-away.ts",
    "content": "/* eslint-disable @typescript-eslint/no-unsafe-function-type */\n// @copy https://github.com/streamich/react-use/blob/master/src/useClickAway.ts\nimport type { RefObject } from 'react'\nimport { useEffect, useRef } from 'react'\n\nfunction on<T extends Window | Document | HTMLElement | EventTarget>(\n  obj: T | null,\n  ...args: Parameters<T['addEventListener']> | [string, Function | null, ...any]\n): void {\n  if (obj && obj.addEventListener) {\n    obj.addEventListener(\n      ...(args as Parameters<HTMLElement['addEventListener']>),\n    )\n  }\n}\n\nfunction off<T extends Window | Document | HTMLElement | EventTarget>(\n  obj: T | null,\n  ...args:\n    | Parameters<T['removeEventListener']>\n    | [string, Function | null, ...any]\n): void {\n  if (obj && obj.removeEventListener) {\n    obj.removeEventListener(\n      ...(args as Parameters<HTMLElement['removeEventListener']>),\n    )\n  }\n}\n\nconst defaultEvents = ['mousedown', 'touchstart']\n\nconst useClickAway = <E extends Event = Event>(\n  ref: RefObject<HTMLElement | null>,\n  onClickAway: (event: E) => void,\n  events: string[] = defaultEvents,\n) => {\n  const savedCallback = useRef(onClickAway)\n  useEffect(() => {\n    savedCallback.current = onClickAway\n  }, [onClickAway])\n  useEffect(() => {\n    const handler = (event: React.MouseEvent) => {\n      const { current: el } = ref\n      el &&\n        !el.contains(event.target as any) &&\n        savedCallback.current(event as any)\n    }\n    for (const eventName of events) {\n      on(document, eventName, handler)\n    }\n    return () => {\n      for (const eventName of events) {\n        off(document, eventName, handler)\n      }\n    }\n  }, [events, ref])\n}\n\nexport default useClickAway\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-debounce-value.ts",
    "content": "import { useEffect, useState } from 'react'\n\nexport default function useDebounceValue<T>(value: T, delay: number): T {\n  const [debouncedValue, setDebouncedValue] = useState(value)\n\n  useEffect(() => {\n    const handler = setTimeout(() => {\n      setDebouncedValue(value)\n    }, delay)\n\n    return () => {\n      clearTimeout(handler)\n    }\n  }, [value, delay])\n\n  return debouncedValue\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-event-callback.ts",
    "content": "import { useCallback, useRef } from 'react'\n\nexport const useEventCallback = <T extends (...args: any[]) => any>(fn: T) => {\n  const ref = useRef<T>(fn)\n  ref.current = fn\n\n  return useCallback((...args: any[]) => ref.current(...args), []) as T\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-input-composition.ts",
    "content": "import type { CompositionEventHandler } from 'react'\nimport { useCallback, useEffect, useRef } from 'react'\n\ntype InputElementAttributes = React.DetailedHTMLProps<\n  React.InputHTMLAttributes<HTMLInputElement>,\n  HTMLInputElement\n>\ntype TextareaElementAttributes = React.DetailedHTMLProps<\n  React.TextareaHTMLAttributes<HTMLTextAreaElement>,\n  HTMLTextAreaElement\n>\nexport const useInputComposition = <E = HTMLInputElement>(\n  props: Pick<\n    E extends HTMLInputElement\n      ? InputElementAttributes\n      : E extends HTMLTextAreaElement\n        ? TextareaElementAttributes\n        : never,\n    'onKeyDown' | 'onCompositionEnd' | 'onCompositionStart' | 'onKeyDownCapture'\n  >,\n) => {\n  const { onKeyDown, onCompositionStart, onCompositionEnd } = props\n\n  const isCompositionRef = useRef(false)\n\n  const currentInputTargetRef = useRef<E | null>(null)\n\n  const handleCompositionStart: CompositionEventHandler<E> = useCallback(\n    (e) => {\n      currentInputTargetRef.current = e.target as E\n\n      isCompositionRef.current = true\n      onCompositionStart?.(e as any)\n    },\n    [onCompositionStart],\n  )\n\n  const handleCompositionEnd: CompositionEventHandler<E> = useCallback(\n    (e) => {\n      currentInputTargetRef.current = null\n      isCompositionRef.current = false\n      onCompositionEnd?.(e as any)\n    },\n    [onCompositionEnd],\n  )\n\n  const handleKeyDown: React.KeyboardEventHandler<E> = useCallback(\n    (e: any) => {\n      // The keydown event stop emit when the composition is being entered\n      if (isCompositionRef.current) {\n        e.stopPropagation()\n        return\n      }\n      onKeyDown?.(e)\n\n      if (e.key === 'Escape') {\n        e.preventDefault()\n        e.stopPropagation()\n\n        if (!isCompositionRef.current) {\n          e.currentTarget.blur()\n        }\n      }\n    },\n    [onKeyDown],\n  )\n\n  // Register a global capture keydown listener to prevent the radix `useEscapeKeydown` from working\n  useEffect(() => {\n    const handleGlobalKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && currentInputTargetRef.current) {\n        e.stopPropagation()\n        e.preventDefault()\n      }\n    }\n\n    document.addEventListener('keydown', handleGlobalKeyDown, { capture: true })\n\n    return () => {\n      document.removeEventListener('keydown', handleGlobalKeyDown, {\n        capture: true,\n      })\n    }\n  }, [])\n\n  const ret = {\n    onCompositionEnd: handleCompositionEnd,\n    onCompositionStart: handleCompositionStart,\n    onKeyDown: handleKeyDown,\n  }\n  Object.defineProperty(ret, 'isCompositionRef', {\n    value: isCompositionRef,\n    enumerable: false,\n  })\n  return ret as typeof ret & {\n    isCompositionRef: typeof isCompositionRef\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-is-active.ts",
    "content": "import { useSyncExternalStore } from 'react'\n\nconst subscribe = (cb: () => void) => {\n  document.addEventListener('visibilitychange', cb)\n  return () => {\n    document.removeEventListener('visibilitychange', cb)\n  }\n}\n\nconst getSnapshot = () => document.visibilityState === 'visible'\nconst getServerSnapshot = () => true\nexport const usePageIsActive = () => {\n  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-is-client.ts",
    "content": "'use client'\n\nimport { startTransition, useEffect, useState } from 'react'\n\nexport const useIsClient = () => {\n  const [isClient, setIsClient] = useState(false)\n\n  useEffect(() => {\n    setIsClient(true)\n  }, [])\n  return isClient\n}\n\nexport const useIsClientTransition = () => {\n  const [isClient, setIsClient] = useState(false)\n\n  useEffect(() => {\n    startTransition(() => {\n      setIsClient(true)\n    })\n  }, [])\n  return isClient\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-is-dark.ts",
    "content": "import { useTheme } from 'next-themes'\n\nexport const useIsDark = () => {\n  const { theme, systemTheme } = useTheme()\n  return theme === 'dark' || (theme === 'system' && systemTheme === 'dark')\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-is-mounted.ts",
    "content": "import { useEffect, useState } from 'react'\n\nexport const useIsMountedState = () => {\n  const [mounted, setMounted] = useState(false)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n  return mounted\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-is-unmounted.ts",
    "content": "import { useEffect, useRef } from 'react'\n\nexport const useIsUnMounted = () => {\n  const unmounted = useRef(false)\n  useEffect(() => {\n    return () => {\n      unmounted.current = true\n    }\n  }, [])\n\n  return unmounted\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-previous.ts",
    "content": "import { useEffect, useRef } from 'react'\n\nexport const usePrevious = <T>(value: T): (() => T | undefined) => {\n  const ref = useRef<T>(undefined as T)\n  useEffect(() => {\n    ref.current = value\n  }, [value])\n  return () => ref.current\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-ref-value.ts",
    "content": "import { useRef } from 'react'\n\n// @see https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents\nexport const useRefValue = <T>(value: () => T): T => {\n  const ref = useRef<T>(undefined as T)\n\n  if (!ref.current) {\n    ref.current = value()\n  }\n\n  return ref.current!\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-safe-setState.ts",
    "content": "import type { Dispatch, MutableRefObject, SetStateAction } from 'react'\n\nexport const useSafeSetState = <S>(\n  setState: Dispatch<SetStateAction<S>>,\n  unmountedRef: MutableRefObject<boolean>,\n) => {\n  const setSafeState = (state: S) => {\n    if (!unmountedRef.current) {\n      setState(state)\n    }\n  }\n  return setSafeState\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-state-ref.ts",
    "content": "import { useEffect, useRef } from 'react'\n\nexport const useStateToRef = <T>(state: T) => {\n  const ref = useRef(state)\n  useEffect(() => {\n    ref.current = state\n  }, [state])\n  return ref\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/use-sync-effect.ts",
    "content": "import { useEffect, useRef } from 'react'\n\ntype CleanupFn = () => void | undefined\nconst noop = () => {}\nexport const useSyncEffectOnce = (effect: (() => CleanupFn) | (() => void)) => {\n  const ref = useRef(false)\n  const cleanupRef = useRef<(() => void) | null>(null)\n  useEffect(() => {\n    return cleanupRef.current || noop\n  }, [])\n\n  if (ref.current) return\n  cleanupRef.current = effect() || null\n  ref.current = true\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/common/useMeasure.ts",
    "content": "// @copy https://github.com/pmndrs/react-use-measure/blob/master/src/web/index.ts\n\nimport { debounce } from 'es-toolkit/compat'\nimport { useEffect, useMemo, useRef, useState } from 'react'\n\nconst createDebounce = debounce\ndeclare type ResizeObserverCallback = (\n  entries: any[],\n  observer: ResizeObserver,\n) => void\ndeclare class ResizeObserver {\n  constructor(callback: ResizeObserverCallback)\n  observe(target: Element, options?: any): void\n  unobserve(target: Element): void\n  disconnect(): void\n  static toString(): string\n}\n\nexport interface RectReadOnly {\n  readonly x: number\n  readonly y: number\n  readonly width: number\n  readonly height: number\n  readonly top: number\n  readonly right: number\n  readonly bottom: number\n  readonly left: number\n  [key: string]: number\n}\n\ntype HTMLOrSVGElement = HTMLElement | SVGElement\n\ntype Result = [\n  (element: HTMLOrSVGElement | null) => void,\n  RectReadOnly,\n  () => void,\n]\n\ntype State = {\n  element: HTMLOrSVGElement | null\n  scrollContainers: HTMLOrSVGElement[] | null\n  resizeObserver: ResizeObserver | null\n  lastBounds: RectReadOnly\n}\n\nexport type Options = {\n  debounce?: number | { scroll: number; resize: number }\n  scroll?: boolean\n  offsetSize?: boolean\n}\n\nconst defaultOptions: Options = {\n  debounce: 0,\n  scroll: false,\n  offsetSize: false,\n}\nexport function useMeasure({\n  debounce,\n  scroll,\n  offsetSize,\n}: Options = defaultOptions): Result {\n  const [bounds, set] = useState<RectReadOnly>({\n    left: 0,\n    top: 0,\n    width: 0,\n    height: 0,\n    bottom: 0,\n    right: 0,\n    x: 0,\n    y: 0,\n  })\n\n  // keep all state in a ref\n  const state = useRef<State>({\n    element: null,\n    scrollContainers: null,\n    resizeObserver: null,\n    lastBounds: bounds,\n  })\n\n  // set actual debounce values early, so effects know if they should react accordingly\n  const scrollDebounce = debounce\n    ? typeof debounce === 'number'\n      ? debounce\n      : debounce.scroll\n    : null\n  const resizeDebounce = debounce\n    ? typeof debounce === 'number'\n      ? debounce\n      : debounce.resize\n    : null\n\n  // make sure to update state only as long as the component is truly mounted\n  const mounted = useRef(false)\n  useEffect(() => {\n    mounted.current = true\n    return () => void (mounted.current = false)\n  })\n\n  // memoize handlers, so event-listeners know when they should update\n  const [forceRefresh, resizeChange, scrollChange] = useMemo(() => {\n    const callback = () => {\n      if (!state.current.element) return\n      const { left, top, width, height, bottom, right, x, y } =\n        state.current.element.getBoundingClientRect() as unknown as RectReadOnly\n\n      const size = {\n        left,\n        top,\n        width,\n        height,\n        bottom,\n        right,\n        x,\n        y,\n      }\n\n      if (state.current.element instanceof HTMLElement && offsetSize) {\n        size.height = state.current.element.offsetHeight\n        size.width = state.current.element.offsetWidth\n      }\n\n      Object.freeze(size)\n      if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {\n        set((state.current.lastBounds = size))\n      }\n    }\n    return [\n      callback,\n      resizeDebounce ? createDebounce(callback, resizeDebounce) : callback,\n      scrollDebounce ? createDebounce(callback, scrollDebounce) : callback,\n    ]\n  }, [set, offsetSize, scrollDebounce, resizeDebounce])\n\n  // cleanup current scroll-listeners / observers\n  function removeListeners() {\n    if (state.current.scrollContainers) {\n      state.current.scrollContainers.forEach((element) =>\n        element.removeEventListener('scroll', scrollChange, true),\n      )\n      state.current.scrollContainers = null\n    }\n\n    if (state.current.resizeObserver) {\n      state.current.resizeObserver.disconnect()\n      state.current.resizeObserver = null\n    }\n  }\n\n  // add scroll-listeners / observers\n  function addListeners() {\n    if (!state.current.element) return\n    state.current.resizeObserver = new ResizeObserver(scrollChange)\n    state.current.resizeObserver!.observe(state.current.element)\n    if (scroll && state.current.scrollContainers) {\n      state.current.scrollContainers.forEach((scrollContainer) =>\n        scrollContainer.addEventListener('scroll', scrollChange, {\n          capture: true,\n          passive: true,\n        }),\n      )\n    }\n  }\n\n  // the ref we expose to the user\n  const ref = (node: HTMLOrSVGElement | null) => {\n    if (!node || node === state.current.element) return\n    removeListeners()\n    state.current.element = node\n    state.current.scrollContainers = findScrollContainers(node)\n    addListeners()\n  }\n\n  // add general event listeners\n  useOnWindowScroll(scrollChange, Boolean(scroll))\n  useOnWindowResize(resizeChange)\n\n  // respond to changes that are relevant for the listeners\n  useEffect(() => {\n    removeListeners()\n    addListeners()\n  }, [scroll, scrollChange, resizeChange])\n\n  // remove all listeners when the components unmounts\n  useEffect(() => removeListeners, [])\n  return [ref, bounds, forceRefresh]\n}\n\n// Adds native resize listener to window\nfunction useOnWindowResize(onWindowResize: (event: Event) => void) {\n  useEffect(() => {\n    const cb = onWindowResize\n    window.addEventListener('resize', cb)\n    return () => void window.removeEventListener('resize', cb)\n  }, [onWindowResize])\n}\nfunction useOnWindowScroll(onScroll: () => void, enabled: boolean) {\n  useEffect(() => {\n    if (enabled) {\n      const cb = onScroll\n      window.addEventListener('scroll', cb, { capture: true, passive: true })\n      return () => void window.removeEventListener('scroll', cb, true)\n    }\n  }, [onScroll, enabled])\n}\n\n// Returns a list of scroll offsets\nfunction findScrollContainers(\n  element: HTMLOrSVGElement | null,\n): HTMLOrSVGElement[] {\n  const result: HTMLOrSVGElement[] = []\n  if (!element || element === document.body) return result\n  const { overflow, overflowX, overflowY } = window.getComputedStyle(element)\n  if (\n    [overflow, overflowX, overflowY].some(\n      (prop) => prop === 'auto' || prop === 'scroll',\n    )\n  ) {\n    result.push(element)\n  }\n  return [...result, ...findScrollContainers(element.parentElement)]\n}\n\n// Checks if element boundaries are equal\nconst keys: (keyof RectReadOnly)[] = [\n  'x',\n  'y',\n  'top',\n  'bottom',\n  'left',\n  'right',\n  'width',\n  'height',\n]\nconst areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean =>\n  keys.every((key) => a[key] === b[key])\n"
  },
  {
    "path": "apps/landing/src/hooks/shared/use-mask-scrollarea.ts",
    "content": "import clsx from 'clsx'\nimport { useEffect, useRef, useState } from 'react'\n\nimport { useViewport } from '~/atoms'\n\nimport { useEventCallback } from '../common/use-event-callback'\n\nexport const useMaskScrollArea = <T extends HTMLElement = HTMLElement>() => {\n  const containerRef = useRef<T>(null)\n  const [isScrollToBottom, setIsScrollToBottom] = useState(false)\n  const [isScrollToTop, setIsScrollToTop] = useState(false)\n  const [canScroll, setCanScroll] = useState(false)\n  const h = useViewport((v) => v.h)\n\n  const eventHandler = useEventCallback(() => {\n    const $ = containerRef.current\n\n    if (!$) return\n\n    // if $ can not scroll, return null\n    if ($.scrollHeight <= $.clientHeight + 2) {\n      setCanScroll(false)\n      setIsScrollToBottom(false)\n      setIsScrollToTop(false)\n      return\n    }\n\n    setCanScroll(true)\n\n    // to bottom\n    if ($.scrollTop + $.clientHeight + 20 > $.scrollHeight) {\n      setIsScrollToBottom(true)\n      setIsScrollToTop(false)\n    }\n\n    // if scroll to top,\n    // set isScrollToTop to true\n    else if ($.scrollTop < 20) {\n      setIsScrollToTop(true)\n      setIsScrollToBottom(false)\n    } else {\n      setIsScrollToBottom(false)\n      setIsScrollToTop(false)\n    }\n  })\n  useEffect(() => {\n    const $ = containerRef.current\n    if (!$) return\n    $.addEventListener('scroll', eventHandler)\n\n    eventHandler()\n\n    return () => {\n      $.removeEventListener('scroll', eventHandler)\n    }\n  }, [eventHandler])\n\n  useEffect(() => {\n    eventHandler()\n  }, [eventHandler, h])\n\n  return [\n    containerRef,\n    canScroll\n      ? clsx(\n          isScrollToBottom && 'mask-t',\n          isScrollToTop && 'mask-b',\n          !isScrollToBottom && !isScrollToTop && 'mask-both',\n        )\n      : '',\n  ] as const\n}\n"
  },
  {
    "path": "apps/landing/src/i18n/request.ts",
    "content": "import { getRequestConfig } from 'next-intl/server'\n\ntype RequestConfigParams = {\n  locale?: string\n  requestLocale?: Promise<string | undefined> | string | undefined\n}\n\nconst SUPPORTED_LOCALES = ['en', 'zh', 'jp'] as const\nconst DEFAULT_LOCALE = 'en'\nconst localeSet = new Set<string>(SUPPORTED_LOCALES)\n\nexport default getRequestConfig(async (params?: RequestConfigParams) => {\n  const localeFromParam = params?.locale\n  const requestLocaleValue = params?.requestLocale\n  const localeFromRequest = requestLocaleValue\n    ? await requestLocaleValue\n    : undefined\n  const requestedLocale = localeFromParam || localeFromRequest\n  const localeValue =\n    requestedLocale && localeSet.has(requestedLocale)\n      ? requestedLocale\n      : DEFAULT_LOCALE\n\n  return {\n    locale: localeValue,\n    messages: (await import(`../messages/${localeValue}.json`)).default,\n  }\n})\n"
  },
  {
    "path": "apps/landing/src/i18n/routing.ts",
    "content": "import { createNavigation } from 'next-intl/navigation'\nimport { defineRouting } from 'next-intl/routing'\n\nexport const routing = defineRouting({\n  locales: ['en', 'zh', 'jp'],\n  defaultLocale: 'en',\n  localePrefix: 'as-needed',\n})\n\nexport const { locales, defaultLocale } = routing\n\nexport const { Link, redirect, usePathname, useRouter, getPathname } =\n  createNavigation(routing)\n"
  },
  {
    "path": "apps/landing/src/legal/privacy.md",
    "content": "# Privacy Policy\n\n**Last Updated: 2025-09-03**\n\nThis is the privacy policy of NATURAL SELECTION LABS PTE. LTD. (“Company,” “we,” or “Folo”). This Privacy Policy explains how we collect, use, share, store, and protect your personal data when you use our website <https://folo.is> and the Folo application (collectively, the “Service”). By accessing or using the Service, you agree to this Privacy Policy and the Terms of Service. If you do not agree, please discontinue use immediately.\n\nWe may update this Privacy Policy from time to time. Any changes will be reflected on our Site with a new Last Updated date. Continued use of the Service after updates constitutes acceptance of the revised Privacy Policy.\n\n## What Data We Collect\n\nWe collect personal data to provide, improve, and personalize the Service.\n\n## Information You Provide\n\n- **Account Information**: When you create a Folo account, we collect your email address, username, and password. You may optionally provide additional details, such as your name or profile preferences.\n- **User Content**: Data you submit, such as RSS feed subscriptions, comments, or content you upload or share via the Service.\n- **Communications**: Information you provide when contacting us via <support@folo.is> or in-app support.\n\n## Information We Collect Automatically\n\n- **Usage Data**: Information about how you interact with the Service, including pages visited, features used (e.g., AI-powered translation or recommendations), RSS feeds subscribed.\n- **Device and Technical Data**: IP address, device type, operating system, browser type, and unique device identifiers.\n- **Analytics Data**: Aggregated data on user behavior, such as time spent on the app or frequency of feature use, collected via analytics tools.\n\n## Information from Third Parties\n\n- **Third-Party RSS Feeds**: Content from RSS feeds you subscribe to, which may include metadata (e.g., article titles, publication dates) .\n- **Payment Processors**:For premium features, third-party processors collect payment details, which we do not store directly.\n\n## We do not collect sensitive personal data unless voluntarily provided\n\n## How We Use Your Data\n\nWe use your data to provide, improve, and personalize the Service.\n\n- **Service Delivery**: Manage your account, display RSS feeds, and enable features like AI-powered translation, summarization, and recommendations.\n- **Personalization**: Tailor content and recommendations based on your subscriptions and usage patterns.\n- **Analytics and Improvement**: Analyze usage trends, troubleshoot issues, and enhance the Service’s functionality and user experience.\n- **Security**: Detect and prevent fraud, unauthorized access, or malicious activities .\n- **Communication**: Respond to your inquiries, send service-related notification.\n- **Legal Compliance**: Comply with applicable laws, regulations, or legal requests .\n\n## How We Share Your Data\n\nWe may share your data in the following circumstances:\n\n- **Service Providers**: Trusted third parties assisting with hosting, analytics, payments, or support..\n- **Legal Obligations**: When required by law, court order, or government authority..\n- **Business Transfers**: In the event of a merger, acquisition, or sale of assets.\n- **With Your Consent**: When explicitly authorized by you.\n\n## Your Rights and Choices\n\nYou have rights over your personal data, subject to applicable laws. These include:\n\n- Access the personal data we hold about you.\n- Request correction or rectification of inaccurate or incomplete information.\n- Request deletion of your personal data, subject to legal retention requirements.\n- Request portability of your data in a structured, machine-readable format.\n- Object to or restrict certain types of data processing.\n- Withdraw consent where processing is based on consent (e.g., promotional communications).\n\n## Data Retention\n\nWe retain your personal data only as long as necessary for the purposes outlined in this policy or as required by law:\n\n- **Account Data**: Retained while your account is active and for 6 months after account deletion to address disputes or legal requirements, unless a longer period is mandated.\n- **Usage Data**: Retained in aggregated, anonymized form for analytics purposes.\n- **User Content**: Retained until you request deletion or your account is terminated, subject to legal obligations.\n\nAfter the retention period, we will securely delete or anonymize your data. Contact us for specific retention details.\n\n## Data Security\n\nWe implement reasonable technical and organizational measures to protect your data, including:\n\n- Encryption of data in transit and at rest where feasible.\n- Access controls to limit data access to authorized personnel.\n- Regular security assessments to identify and address vulnerabilities.\n\nDespite these measures, no security system is entirely foolproof. In the event of a data breach, we will notify affected individuals and relevant authorities in accordance with applicable law.\n\n## International Data Transfers\n\nPersonal information may be transferred to, processed, and stored in Singapore, the United States, the European Union (EU), the European Economic Area (EEA), the United Kingdom (UK), the Cayman Islands, or other jurisdictions where we or our service providers operate.\n\nWhile these jurisdictions may have different data protection laws, we implement lawful transfer mechanisms to ensure compliance with applicable requirements.\n\nBy using the Service, you consent to such international transfers, subject to the protections described in this Privacy Policy.\n\n## Children’s Privacy\n\nTo use the Service, you must be of legal age to enter into a binding agreement in your jurisdiction. We do not knowingly collect personal data from children. If you believe that a child has provided data to us, please contact us immediately, and we will take appropriate steps to delete such information.\n\n## Third-Party Links and Services\n\nThe Service incorporates third-party RSS feeds and may contain links to external sites. We are not responsible for the data protection practices of such third parties. Please review their respective privacy policies before providing personal data.\n\n## AI Features and Data Use\n\nFolo provides AI-powered functionalities such as summarization, translation, and categorization. These features process input data solely to generate requested outputs.We will not use your personal data to train or improve AI models without your explicit consent. All AI-generated outputs are provided “as is”, and users are advised to verify accuracy before reliance.\n\n## Governing Law and Dispute Resolution\n\nThis Privacy Policy shall be governed by, and construed in accordance with, the laws of Singapore, without regard to conflict of laws principles.\n\nAny dispute, claim, or controversy arising out of or relating to this Privacy Policy shall first be resolved through good-faith negotiation. If unresolved, the matter shall be submitted to binding arbitration administered by the Singapore International Arbitration Centre (SIAC) under its prevailing rules.\n\n## Contact Us\n\nIf you have any questions regarding this Privacy Policy, to submit a complaint, or to access or correct your information, or to withdraw your consent to this Privacy Policy, please contact our Privacy Officer via the contact information below:\n\nGeneral Support: <support@folo.is>\n\nLegal and Copyright Matters: <legal@folo.is>\n"
  },
  {
    "path": "apps/landing/src/legal/tos.md",
    "content": "# Terms of Service\n\n**Effective Date:** 2025-05-15\n\nLast Updated：2025-09-03\n\nThis version of the Terms of Service is the most recent update and supersedes all prior versions. These Terms may be revised from time to time, and continued use of the Service constitutes acceptance of the latest version.\n\nThe Folo platform (“Folo” or the “Service”) is owned, operated, and provided by NATURAL SELECTION LABS PTE. LTD.. All references to “we,” “our,” or “us” in these Terms refer to NATURAL SELECTION LABS PTE. LTD..By accessing or using Folo, you agree to be bound by these Terms of Service and all applicable laws and regulations. If you do not agree, you must not use the Service. You also confirm that you are of the legal age of majority in your jurisdiction.\n\n## Eligibility\n\nYou must be at least 16 years of age, or the age of majority in your jurisdiction (whichever is higher), to access and use the Service. By using the Service, you represent and warrant that you:\n\n- Meet the minimum age requirement;\n- Have the legal capacity and authority to enter into a binding agreement with Folo;\n- Are not barred from using the Service under any applicable laws or regulations; and\n- Will use the Service only in compliance with these Terms and all applicable laws.\n\nIf you are accessing the Service on behalf of a company, organization, or other legal entity, you represent and warrant that you have the authority to bind such entity to these Terms, in which case “you” will refer to that entity.\n\n## Service Description\n\nFolo provides a dynamic information aggregation and browsing platform that enables users to:\n\n- Aggregate publicly available content from multiple sources (articles, videos, podcasts, feeds).\n- Present such content in a personalized, distraction-free timeline.\n- Use AI-powered features (translation, summaries, insights, categorization) to enhance browsing.\n- Create, save, and share curated subscription lists and collections.\n\nImportant Note: Folo may modify, expand, or discontinue any features, tools, or aspects of the Service at its sole discretion.\n\n## User Account\n\nYou are solely responsible for maintaining the confidentiality and security of your account credentials, including your username and password, and for all activities conducted under your account. You agree to notify Folo immediately if you become aware of any unauthorized use or security breach related to your account. Folo reserves the right, at its sole discretion, to suspend, restrict, or terminate accounts that violate these Terms, engage in unlawful activity, or otherwise pose a security or legal risk, and may impose additional requirements, such as verification procedures, to protect the Service and its users.\n\n## Permitted Use\n\nYou agree to use Folo solely for lawful purposes and in a manner that respects the rights of others, and you shall not engage in any unlawful, harmful, or malicious activities, including transmitting malware, viruses, or phishing attempts, or interfering with the operation, security, or availability of the Service. Any unauthorized attempts to access, manipulate, or bypass the Service through hacking or other unlawful means are prohibited and may result in suspension or termination of your account.\n\nYou may not exploit the Service for commercial purposes unless explicitly authorized by Us.\n\n## Service Fees\n\nBasic access to and use of Folo is free of charge. Certain premium features, including advanced AI functionalities or additional services, may require payment of subscription or usage fees. All applicable fees will be clearly disclosed to you prior to purchase. Payments for such features are non-refundable, except where required by applicable law. By making a payment, you agree to provide accurate and complete payment information and authorize Folo to charge the designated payment method. Folo reserves the right to modify its fees, billing methods, or payment terms at any time, with notice provided as appropriate.\n\n## Intellectual Property\n\nFolo Technology: All intellectual property rights in Folo, including but not limited to its software, user interface, underlying algorithms, AI models, RSSHub integration, and any associated documentation or designs, are owned by Folo or licensed to Folo. These rights are protected by copyright, trademark, patent, and other applicable intellectual property laws. You are granted a limited, non-exclusive, non-transferable right to use the Service for personal, non-commercial purposes in accordance with these Terms.\n\nThird-Party Content: All rights to third-party content aggregated or otherwise accessed through the Service remain with the respective content owners. Folo does not claim ownership, control, or responsibility for such content, and provides access solely for informational and organizational purposes. Any reliance on third-party content is at your own risk.\n\nUser Contributions: Users may create, organize, and share curated lists or collections within the Service. By contributing, you grant Folo a non-exclusive, worldwide, royalty-free license to host, display, distribute, and use your contributions solely within the Service. You represent and warrant that your contributions do not infringe upon the rights of any third party, including intellectual property rights, privacy rights, or publicity rights.\n\n## AI Features Disclaimer\n\nFolo provides AI-generated features, including summaries, translations, insights, and content categorization, to enhance your browsing experience. You acknowledge and agree that such AI-generated content may be produced by third-party AI models, and Folo does not independently generate or control these outputs. By using these features, you acknowledge and agree that:\n\n- AI outputs may contain inaccuracies, errors, omissions, or biases, and may not always reflect complete or up-to-date information.\n- You remain solely responsible for independently verifying the accuracy, reliability, and suitability of AI-generated outputs before relying on them for any purpose.\n- Folo does not guarantee the correctness, completeness, or usefulness of AI-generated content and expressly disclaims any liability for losses, damages, or actions resulting from the use or reliance on such outputs.\n\n## Privacy and Data Use\n\nFolo collects, processes, and stores user data in accordance with its Privacy Policy, including information such as usage patterns, RSS subscriptions, interactions with AI features, and other anonymized or aggregated analytics data. The collected data is only used to improve and personalize the Service, optimize AI outputs, and ensure security and functionality. Please refer to the Privacy Policy for full details regarding data collection, usage, sharing, and retention practices.\n\n## Third-Party Content and Links\n\nThe Service aggregates publicly available content from third-party sources for informational and organizational purposes. Folo does not control, endorse, or guarantee the accuracy, legality, or availability of such content. You acknowledge that any reliance on third-party content accessed through the Service is at your own risk.\n\nLinks to external websites may be provided for convenience. Folo does not assume responsibility for the content, practices, or privacy policies of linked sites. Users are encouraged to review the terms and privacy policies of any external sites they visit.\n\n## Copyright Complaints\n\nIf you believe that material accessible through Folo infringes your copyright or other intellectual property rights, please contact us with the following information:\n\n- A clear identification of the copyrighted work(s) you claim has been infringed.\n- URL(s) or detailed description(s) of the allegedly infringing material.\n- Your full contact information, including name, email address, and telephone number.\n- A statement under penalty of perjury that you have a good faith belief that the disputed use is not authorized by the copyright owner, its agent, or the law.\n\nFolo will review and respond to copyright complaints in accordance with applicable copyright laws. Folo may remove or disable access to allegedly infringing content pending review and may terminate repeat infringers in accordance with applicable law.\n\n## Termination and Suspension\n\nFolo reserves the right, at its sole discretion, to suspend or terminate your access to the Service, with or without prior notice, if you:\n\n- Violate any provision of these Terms；\n- Engage in unlawful, harmful, or malicious activities；\n- Misuse AI features, attempt to exploit the Service, or interfere with its operation or security.\n\nUpon termination or suspension, your right to access the Service will immediately cease, and Folo may delete or deactivate your account and content, subject to applicable law. Folo shall not be liable for any loss or damage resulting from such termination or suspension.\n\n## Disclaimers\n\nThe Service is provided on an “as is” and “as available” basis. Folo makes no warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, accuracy, or non-infringement.\n\nFolo does not guarantee uninterrupted, secure, or error-free access to the Service. You acknowledge and accept that your use of the Service is at your own risk.\n\n## Limitation of Liability\n\nTo the maximum extent permitted by law, Folo and its affiliates, officers, and employees shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to loss of profits, data, business opportunities, or goodwill.\n\nFolo’s total aggregate liability for any claim arising out of or in connection with the Service shall not exceed the total amount you paid (if any) to access or use the Service during the twelve (12) months preceding the claim.\n\nThese limitations apply regardless of the form of action, whether in contract, tort, strict liability, or otherwise, except where prohibited by applicable law.\n\n## Indemnification\n\nYou agree to indemnify, defend, and hold harmless Folo and its affiliates, officers, employees, and agents from any and all claims, liabilities, damages, losses, costs, or expenses (including reasonable legal fees) arising out of or related to:\n\n- Your use or misuse of the Service.\n- Your violation of these Terms.\n- Your infringement of any third-party rights.\n\n## Governing Law and Dispute Resolution\n\nThese Terms shall be governed by and construed in accordance with the laws of Singapore, without regard to conflict of law principles.\n\nYou agree that any dispute, claim, or controversy arising out of or in connection with these Terms shall first be attempted to resolve through good-faith negotiations. If the dispute cannot be resolved through negotiation, it shall be submitted to binding arbitration administered by the Singapore International Arbitration Centre (SIAC) under its rules.\n\n## Changes to the Terms\n\nFolo may revise or update these Terms at any time. Any changes will become effective upon posting the revised Terms to our website with a new “Last Updated” date. Continued use of the Service after such posting constitutes your acceptance of the updated Terms. You are responsible for reviewing the Terms periodically.\n\n## Contact Us\n\nFor any questions or concerns regarding these Terms, please contact us at:\n\n- General Support: <support@folo.is>\n- Legal and Copyright Matters: <legal@folo.is>\n"
  },
  {
    "path": "apps/landing/src/lib/apple-app-site-association.ts",
    "content": "export const APPLE_APP_SITE_ASSOCIATION = {\n  applinks: {\n    apps: [],\n    details: [\n      {\n        appID: '492J8Q67PF.is.follow',\n        paths: ['*'],\n      },\n    ],\n  },\n} as const\n"
  },
  {
    "path": "apps/landing/src/lib/cn.ts",
    "content": "// Tremor Raw cx [v0.0.0]\n\nimport type { ClassValue } from 'clsx'\nimport clsx from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport const clsxm = (...args: any[]) => {\n  return twMerge(clsx(args))\n}\n\nexport function cx(...args: ClassValue[]) {\n  return twMerge(clsx(...args))\n}\nexport { cx as cn }\n\n// Tremor focusInput [v0.0.2]\n\nexport const focusInput = [\n  // base\n  'focus:ring-2',\n  // ring color\n  'focus:ring-accent/20',\n  // border color\n  'focus:border-accent',\n]\n\n// Tremor Raw focusRing [v0.0.1]\n\nexport const focusRing = [\n  // base\n  'outline-offset-2 outline-0 focus-visible:outline-2',\n  // outline color\n  'outline-accent',\n]\n\n// Tremor Raw hasErrorInput [v0.0.1]\n\nexport const hasErrorInput = [\n  // base\n  'ring-2',\n  // border color\n  'border-red',\n  // ring color\n  'ring-red/20',\n]\n"
  },
  {
    "path": "apps/landing/src/lib/color.ts",
    "content": "/* eslint-disable unicorn/prefer-code-point */\nconst getRandomColor = (\n  lightness: [number, number],\n  saturation: [number, number],\n  hue: number,\n) => {\n  const satAccent = Math.floor(\n    Math.random() * (saturation[1] - saturation[0] + 1) + saturation[0],\n  )\n  const lightAccent = Math.floor(\n    Math.random() * (lightness[1] - lightness[0] + 1) + lightness[0],\n  )\n\n  // Generate the background color by increasing the lightness and decreasing the saturation\n  const satBackground = satAccent > 30 ? satAccent - 30 : 0\n  const lightBackground = lightAccent < 80 ? lightAccent + 20 : 100\n\n  return {\n    accent: `hsl(${hue}, ${satAccent}%, ${lightAccent}%)`,\n    background: `hsl(${hue}, ${satBackground}%, ${lightBackground}%)`,\n  }\n}\n\nexport function stringToHue(str: string) {\n  let hash = 0\n  for (let i = 0; i < str.length; i++) {\n    hash = str.charCodeAt(i) + ((hash << 5) - hash)\n  }\n  const hue = hash % 360\n  return hue < 0 ? hue + 360 : hue\n}\n\nexport const getColorScheme = (hue?: number) => {\n  const baseHue = hue ?? Math.floor(Math.random() * 361)\n  const complementaryHue = (baseHue + 180) % 360\n\n  // For light theme, we limit the lightness between 40 and 70 to avoid too bright colors for accent\n  const lightColors = getRandomColor([40, 70], [70, 90], baseHue)\n\n  // For dark theme, we limit the lightness between 20 and 50 to avoid too dark colors for accent\n  const darkColors = getRandomColor([20, 50], [70, 90], complementaryHue)\n\n  return {\n    light: {\n      accent: lightColors.accent,\n      background: lightColors.background,\n    },\n    dark: {\n      accent: darkColors.accent,\n      background: darkColors.background,\n    },\n  }\n}\nexport function addAlphaToHex(hex: string, alpha: number): string {\n  if (!/^#(?:[A-F0-9]{3}){1,2}$/i.test(hex)) {\n    throw new Error('Invalid hex color value')\n  }\n\n  let color = ''\n  if (hex.length === 4) {\n    color = `#${[1, 2, 3]\n      .map(\n        (index) =>\n          Number.parseInt(hex.charAt(index), 16).toString(16) +\n          Number.parseInt(hex.charAt(index), 16).toString(16),\n      )\n      .join('')}`\n  } else {\n    color = hex\n  }\n\n  const r = Number.parseInt(color.slice(1, 3), 16)\n  const g = Number.parseInt(color.slice(3, 5), 16)\n  const b = Number.parseInt(color.slice(5, 7), 16)\n\n  return `rgba(${r},${g},${b},${alpha})`\n}\n\nexport function addAlphaToHSL(hsl: string, alpha: number): string {\n  if (!/^hsl\\(\\d{1,3},\\s*[\\d.]+%,\\s*[\\d.]+%\\)$/.test(hsl)) {\n    throw new Error('Invalid HSL color value')\n  }\n\n  const hsla = `${hsl.slice(0, -1)}, ${alpha})`\n  return hsla.replace('hsl', 'hsla')\n}\n\nexport function hexToHsl(hex: string) {\n  // Remove the '#' symbol from the hex code\n  hex = hex.replace('#', '')\n\n  // Convert hex values to RGB\n  const r = Number.parseInt(hex.slice(0, 2), 16) / 255\n  const g = Number.parseInt(hex.slice(2, 4), 16) / 255\n  const b = Number.parseInt(hex.slice(4, 6), 16) / 255\n\n  // Find the minimum and maximum values among R, G, and B\n  const min = Math.min(r, g, b)\n  const max = Math.max(r, g, b)\n\n  // Calculate the hue\n  let h = 0\n  switch (max) {\n    case min: {\n      h = 0 // No hue for achromatic colors\n\n      break\n    }\n    case r: {\n      h = ((g - b) / (max - min)) % 6\n\n      break\n    }\n    case g: {\n      h = (2 + (b - r) / (max - min)) % 6\n\n      break\n    }\n    default: {\n      h = (4 + (r - g) / (max - min)) % 6\n    }\n  }\n  h = Math.round(h * 60)\n\n  // Calculate the lightness\n  const l = (max + min) / 2\n\n  // Calculate the saturation\n  let s = 0\n  if (max !== min) {\n    s = (max - min) / (1 - Math.abs(2 * l - 1))\n  }\n  s = Math.round(s * 100)\n\n  // Return the HSL values as a string\n  return [h, s, Math.round(l * 100)]\n}\n\nexport function generateTransitionColors(\n  startColor: string,\n  targetColor: string,\n  step: number,\n): string[] {\n  // Convert startColor and targetColor to RGB values\n  const startRed = Number.parseInt(startColor.slice(1, 3), 16)\n  const startGreen = Number.parseInt(startColor.slice(3, 5), 16)\n  const startBlue = Number.parseInt(startColor.slice(5, 7), 16)\n\n  const targetRed = Number.parseInt(targetColor.slice(1, 3), 16)\n  const targetGreen = Number.parseInt(targetColor.slice(3, 5), 16)\n  const targetBlue = Number.parseInt(targetColor.slice(5, 7), 16)\n\n  // Calculate increments for each color channel\n  const redIncrement = (targetRed - startRed) / step\n  const greenIncrement = (targetGreen - startGreen) / step\n  const blueIncrement = (targetBlue - startBlue) / step\n\n  const transitionColors: string[] = []\n\n  // Generate transition colors\n  for (let i = 0; i < step; i++) {\n    // Calculate transition color values\n    const transitionRed = Math.round(startRed + redIncrement * i)\n    const transitionGreen = Math.round(startGreen + greenIncrement * i)\n    const transitionBlue = Math.round(startBlue + blueIncrement * i)\n\n    // Convert RGB values to hex format\n    const hexColor = `#${(\n      (1 << 24) |\n      (transitionRed << 16) |\n      (transitionGreen << 8) |\n      transitionBlue\n    )\n      .toString(16)\n      .slice(1)}`\n\n    // Add transition color to the result array\n    transitionColors.push(hexColor)\n  }\n\n  return Array.from(new Set(transitionColors))\n}\n"
  },
  {
    "path": "apps/landing/src/lib/cookie.ts",
    "content": "import Cookies from 'js-cookie'\n\nexport const TokenKey = 'mx-token'\n\nexport function getToken(): string | null {\n  const token = Cookies.get(TokenKey)\n\n  return token || null\n}\n\nexport function setToken(token: string) {\n  if (typeof token !== 'string') {\n    return\n  }\n  return Cookies.set(TokenKey, token, {\n    expires: 14,\n  })\n}\n\nexport function removeToken() {\n  return Cookies.remove(TokenKey)\n}\n"
  },
  {
    "path": "apps/landing/src/lib/datetime.ts",
    "content": "import 'dayjs/locale/zh-cn'\n\nimport dayjs from 'dayjs'\nimport customParseFormat from 'dayjs/plugin/customParseFormat'\nimport LocalizedFormat from 'dayjs/plugin/localizedFormat'\n\ndayjs.extend(customParseFormat)\ndayjs.extend(LocalizedFormat)\ndayjs.locale('zh-cn')\n\nexport enum DateFormat {\n  'MMM DD YYYY',\n  'HH:mm',\n  'LLLL',\n  'H:mm:ss A',\n  'YYYY-MM-DD',\n  'YYYY-MM-DD dddd',\n  'YYYY-MM-DD ddd',\n  'MM-DD ddd',\n\n  'YYYY 年 M 月 D 日 dddd',\n}\n\nexport const parseDate = (\n  time: string | Date,\n  format: keyof typeof DateFormat,\n) => dayjs(time).format(format)\n\nexport const relativeTimeFromNow = (\n  time: Date | string,\n  current = new Date(),\n) => {\n  if (!time) {\n    return ''\n  }\n  time = new Date(time)\n  const msPerMinute = 60 * 1000\n  const msPerHour = msPerMinute * 60\n  const msPerDay = msPerHour * 24\n  const msPerMonth = msPerDay * 30\n  const msPerYear = msPerDay * 365\n\n  const elapsed = +current - +time\n\n  if (elapsed < msPerMinute) {\n    const gap = Math.ceil(elapsed / 1000)\n    return gap <= 0 ? '刚刚' : `${gap} 秒前`\n  } else if (elapsed < msPerHour) {\n    return `${Math.round(elapsed / msPerMinute)} 分钟前`\n  } else if (elapsed < msPerDay) {\n    return `${Math.round(elapsed / msPerHour)} 小时前`\n  } else if (elapsed < msPerMonth) {\n    return `${Math.round(elapsed / msPerDay)} 天前`\n  } else if (elapsed < msPerYear) {\n    return `${Math.round(elapsed / msPerMonth)} 个月前`\n  } else {\n    return `${Math.round(elapsed / msPerYear)} 年前`\n  }\n}\nexport const dayOfYear = () => {\n  const now = new Date()\n  const start = new Date(now.getFullYear(), 0, 0)\n  const diff = now.getTime() - start.getTime()\n  const oneDay = 1000 * 60 * 60 * 24\n  const day = Math.floor(diff / oneDay)\n  return day\n}\n\nexport function daysOfYear(year?: number) {\n  return isLeapYear(year ?? new Date().getFullYear()) ? 366 : 365\n}\n\nexport function isLeapYear(year: number) {\n  return year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0)\n}\n\nexport const secondOfDay = () => {\n  const dt = new Date()\n  const secs = dt.getSeconds() + 60 * (dt.getMinutes() + 60 * dt.getHours())\n  return secs\n}\n\nexport const secondOfDays = 86400\n"
  },
  {
    "path": "apps/landing/src/lib/dom.ts",
    "content": "import type { ReactEventHandler } from 'react'\n\nexport const stopPropagation: ReactEventHandler<any> = (e) =>\n  e.stopPropagation()\n\nexport const preventDefault: ReactEventHandler<any> = (e) => e.preventDefault()\n\nexport const transitionViewIfSupported = (updateCb: () => any) => {\n  if (window.matchMedia(`(prefers-reduced-motion: reduce)`).matches) {\n    updateCb()\n    return\n  }\n  if (document.startViewTransition) {\n    document.startViewTransition(updateCb)\n  } else {\n    updateCb()\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/lib/env.ts",
    "content": "export const isClientSide = typeof window !== 'undefined'\nexport const isServerSide = !isClientSide\n\nexport const isDev = process.env.NODE_ENV === 'development'\n"
  },
  {
    "path": "apps/landing/src/lib/fonts.ts",
    "content": "import { Geist } from 'next/font/google'\n\nconst sansFont = Geist({\n  subsets: ['latin'],\n  weight: ['300', '400', '500'],\n  variable: '--font-sans',\n  display: 'swap',\n})\n\nexport { sansFont }\n"
  },
  {
    "path": "apps/landing/src/lib/helper.ts",
    "content": "import clsx from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport const clsxm = (...args: any[]) => {\n  return twMerge(clsx(args))\n}\n\nexport const escapeHTMLTag = (html: string) => {\n  const lt = /</g,\n    gt = />/g,\n    ap = /'/g,\n    ic = /\"/g\n  return html\n    .toString()\n    .replaceAll(lt, '&lt;')\n    .replaceAll(gt, '&gt;')\n    .replaceAll(ap, '&#39;')\n    .replaceAll(ic, '&#34;')\n}\n"
  },
  {
    "path": "apps/landing/src/lib/jotai.ts",
    "content": "import type { Atom, PrimitiveAtom } from 'jotai'\nimport { useAtom, useAtomValue, useSetAtom } from 'jotai'\nimport { selectAtom } from 'jotai/utils'\nimport { useCallback } from 'react'\n\nimport { jotaiStore } from './store'\n\nexport const createAtomAccessor = <T>(atom: PrimitiveAtom<T>) =>\n  [\n    () => jotaiStore.get(atom),\n    (value: T) => jotaiStore.set(atom, value),\n  ] as const\n\nconst options = { store: jotaiStore }\n/**\n * @param atom - jotai\n * @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set]\n */\nexport const createAtomHooks = <T>(atom: PrimitiveAtom<T>) =>\n  [\n    atom,\n    () => useAtom(atom, options),\n    () => useAtomValue(atom, options),\n    () => useSetAtom(atom, options),\n    ...createAtomAccessor(atom),\n  ] as const\n\nexport const createAtomSelector = <T>(atom: Atom<T>) => {\n  const useHook = <R>(selector: (a: T) => R, deps: any[] = []) =>\n    useAtomValue(\n      selectAtom(\n        atom,\n        useCallback((a) => selector(a as T), deps),\n      ),\n    )\n\n  useHook.__atom = atom\n  return useHook\n}\n\nexport { jotaiStore } from './store'\n"
  },
  {
    "path": "apps/landing/src/lib/landing-data.ts",
    "content": "const PRODUCTION_API_URL = 'https://api.follow.is'\nexport const PRODUCTION_RSSHUB_ROUTES_URL =\n  'https://docs.rsshub.app/routes.json'\nconst isDevelopment = process.env.NODE_ENV !== 'production'\n\nconst HERO_ITEM_LIMIT = 12\n\nconst HERO_HOST_LIMITS: Record<string, number> = {\n  'youtube.com': 1,\n  'x.com': 1,\n}\n\nexport type HeroTimelineItem = {\n  title: string\n  description: string\n  host: string\n  href: string\n  subscriptions: number\n}\n\nexport type DiscoverSource = {\n  key: string\n  name: string\n  host: string\n  heat: number\n  categories: string[]\n}\n\nexport type TrustedCompany = {\n  name: string\n  host: string\n  users: number\n}\n\nexport type LandingMetrics = {\n  feeds: number\n  entries: number\n}\n\ntype TrendingFeedsResponse = {\n  code: number\n  data: Array<{\n    feed: {\n      title: string\n      description?: string | null\n      siteUrl?: string | null\n      url: string\n    }\n    analytics: {\n      subscriptionCount: number\n    }\n  }>\n}\n\nexport type RSSHubRoutesIndex = Record<\n  string,\n  {\n    name: string\n    url: string\n    heat?: number\n    categories?: string[]\n  }\n>\n\ntype LandingMetricsResponse = {\n  code: number\n  data: LandingMetrics\n}\n\nconst HERO_FALLBACK: HeroTimelineItem[] = [\n  {\n    title: 'OpenAI News',\n    description: 'The OpenAI blog.',\n    host: 'openai.com',\n    href: 'https://openai.com/news',\n    subscriptions: 27710,\n  },\n  {\n    title: 'Anthropic News',\n    description: 'Latest news from Anthropic.',\n    host: 'anthropic.com',\n    href: 'https://www.anthropic.com/news',\n    subscriptions: 998,\n  },\n  {\n    title: 'The GitHub Blog',\n    description:\n      'Updates, ideas, and inspiration from GitHub to help developers build and design software.',\n    host: 'github.blog',\n    href: 'https://github.blog/',\n    subscriptions: 24774,\n  },\n  {\n    title: 'Nature',\n    description: 'Read the latest research articles from Nature.',\n    host: 'nature.com',\n    href: 'https://www.nature.com/nature/research-articles',\n    subscriptions: 24491,\n  },\n  {\n    title: 'The Verge',\n    description:\n      'Breaking news, reviews, and features about how technology changes the world.',\n    host: 'theverge.com',\n    href: 'https://www.theverge.com/',\n    subscriptions: 24833,\n  },\n  {\n    title: 'NASA Astronomy Picture of the Day',\n    description: 'The daily image and story from the universe around us.',\n    host: 'apod.nasa.gov',\n    href: 'https://apod.nasa.gov/apod/archivepix.html',\n    subscriptions: 29277,\n  },\n  {\n    title: 'Last Week in AI',\n    description:\n      'Weekly text and audio summaries of the most important AI stories.',\n    host: 'lastweekin.ai',\n    href: 'https://lastweekin.ai/',\n    subscriptions: 30381,\n  },\n  {\n    title: 'Ahead of AI',\n    description:\n      'Machine learning and AI research for readers who want to stay ahead.',\n    host: 'magazine.sebastianraschka.com',\n    href: 'https://magazine.sebastianraschka.com/',\n    subscriptions: 27310,\n  },\n  {\n    title: 'TED Talks Daily',\n    description: 'Thought-provoking ideas in audio, delivered every day.',\n    host: 'ted.com',\n    href: 'https://www.ted.com/',\n    subscriptions: 25784,\n  },\n  {\n    title: 'The Daily',\n    description:\n      'The biggest stories of our time, told by New York Times journalists.',\n    host: 'nytimes.com',\n    href: 'https://www.nytimes.com/the-daily',\n    subscriptions: 23638,\n  },\n  {\n    title: '3Blue1Brown',\n    description: 'Visual explanations in math, physics, and computer science.',\n    host: 'youtube.com',\n    href: 'https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw',\n    subscriptions: 24897,\n  },\n  {\n    title: 'Bluesky',\n    description: 'The official Bluesky account and product updates.',\n    host: 'bsky.app',\n    href: 'https://bsky.app/profile/bsky.app',\n    subscriptions: 24116,\n  },\n]\n\nexport const DISCOVER_FALLBACK: DiscoverSource[] = [\n  {\n    key: 'xiaohongshu',\n    name: 'Xiaohongshu',\n    host: 'xiaohongshu.com',\n    heat: 1413942,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'twitter',\n    name: 'X',\n    host: 'x.com',\n    heat: 1269331,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'telegram',\n    name: 'Telegram',\n    host: 't.me',\n    heat: 285958,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'youtube',\n    name: 'YouTube',\n    host: 'youtube.com',\n    heat: 278462,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'bilibili',\n    name: 'bilibili',\n    host: 'bilibili.com',\n    heat: 214649,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'weibo',\n    name: 'Weibo',\n    host: 'weibo.com',\n    heat: 59600,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'xiaoyuzhou',\n    name: 'Xiaoyuzhou',\n    host: 'xiaoyuzhoufm.com',\n    heat: 51947,\n    categories: ['multimedia', 'popular'],\n  },\n  {\n    key: 'pixiv',\n    name: 'pixiv',\n    host: 'pixiv.net',\n    heat: 48311,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'github',\n    name: 'GitHub',\n    host: 'github.com',\n    heat: 45308,\n    categories: ['programming', 'popular'],\n  },\n  {\n    key: 'sspai',\n    name: 'SSPAI',\n    host: 'sspai.com',\n    heat: 34311,\n    categories: ['new-media', 'popular'],\n  },\n  {\n    key: 'jike',\n    name: 'Jike',\n    host: 'm.okjike.com',\n    heat: 32042,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'nasa',\n    name: 'NASA',\n    host: 'apod.nasa.gov',\n    heat: 29994,\n    categories: ['picture', 'popular'],\n  },\n  {\n    key: 'nature',\n    name: 'Nature',\n    host: 'nature.com',\n    heat: 26324,\n    categories: ['journal', 'popular'],\n  },\n  {\n    key: 'zhihu',\n    name: 'Zhihu',\n    host: 'zhihu.com',\n    heat: 26633,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: '36kr',\n    name: '36Kr',\n    host: '36kr.com',\n    heat: 18922,\n    categories: ['new-media', 'popular'],\n  },\n  {\n    key: 'juejin',\n    name: 'Juejin',\n    host: 'juejin.cn',\n    heat: 12318,\n    categories: ['programming', 'popular'],\n  },\n  {\n    key: 'bsky',\n    name: 'Bluesky',\n    host: 'bsky.app',\n    heat: 25232,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'threads',\n    name: 'Threads',\n    host: 'threads.net',\n    heat: 24696,\n    categories: ['social-media', 'popular'],\n  },\n  {\n    key: 'nytimes',\n    name: 'The New York Times',\n    host: 'nytimes.com',\n    heat: 8922,\n    categories: ['traditional-media', 'popular'],\n  },\n  {\n    key: 'reuters',\n    name: 'Reuters',\n    host: 'reuters.com',\n    heat: 5915,\n    categories: ['traditional-media', 'popular'],\n  },\n  {\n    key: 'bloomberg',\n    name: 'Bloomberg',\n    host: 'bloomberg.com',\n    heat: 5339,\n    categories: ['finance', 'popular'],\n  },\n  {\n    key: 'huggingface',\n    name: 'Hugging Face',\n    host: 'huggingface.co',\n    heat: 3219,\n    categories: ['programming', 'popular'],\n  },\n  {\n    key: 'spotify',\n    name: 'Spotify',\n    host: 'open.spotify.com',\n    heat: 2948,\n    categories: ['multimedia', 'popular'],\n  },\n  {\n    key: 'rsshub',\n    name: 'RSSHub',\n    host: 'docs.rsshub.app',\n    heat: 12237,\n    categories: ['program-update', 'popular'],\n  },\n]\n\nexport const TRUSTED_COMPANIES: TrustedCompany[] = [\n  { name: 'Alibaba', host: 'aliyun.com', users: 281 },\n  { name: 'ByteDance', host: 'bytedance.com', users: 107 },\n  { name: 'Mozilla', host: 'mozmail.com', users: 85 },\n  { name: 'Tencent', host: 'tencent.com', users: 18 },\n  { name: 'Microsoft', host: 'microsoft.com', users: 12 },\n  { name: 'Google', host: 'google.com', users: 9 },\n  { name: 'Baidu', host: 'baidu.com', users: 7 },\n  { name: 'Xiaomi', host: 'xiaomi.com', users: 7 },\n  { name: 'Huawei', host: 'huawei.com', users: 5 },\n  { name: 'NVIDIA', host: 'nvidia.com', users: 4 },\n]\n\nconst LANDING_METRICS_FALLBACK: LandingMetrics = {\n  feeds: 1267577,\n  entries: 300849952,\n}\n\nconst truncate = (value: string, maxLength: number) =>\n  value.length <= maxLength\n    ? value\n    : `${value.slice(0, maxLength - 1).trimEnd()}…`\n\nconst normalizeHost = (raw: string | null | undefined) => {\n  if (!raw) return null\n\n  try {\n    const url = raw.startsWith('http')\n      ? new URL(raw)\n      : new URL(`https://${raw}`)\n    return url.host.replace(/^www\\./, '')\n  } catch {\n    return null\n  }\n}\n\nconst normalizeDescription = (value: string | null | undefined) => {\n  const cleaned = (value || '')\n    .replace(/\\s+- Powered by RSSHub$/i, '')\n    .replaceAll(/\\s+/g, ' ')\n    .trim()\n\n  return cleaned\n    ? truncate(cleaned, 96)\n    : 'Fresh signal from a source that is trending inside production Folo timelines.'\n}\n\nconst fetchJson = async <T>(url: string) => {\n  const response = await fetch(url, {\n    headers: {\n      accept: 'application/json',\n    },\n  })\n\n  if (!response.ok) {\n    throw new Error(`Failed to fetch ${url}: ${response.status}`)\n  }\n\n  return (await response.json()) as T\n}\n\nconst getTrendingLanguage = (locale?: string) =>\n  locale === 'zh' ? 'cmn' : 'eng'\n\nexport const buildDiscoverSourcesFromIndex = (\n  result: RSSHubRoutesIndex,\n): DiscoverSource[] => {\n  const uniqueByHost = new Map<string, DiscoverSource>()\n\n  for (const [key, value] of Object.entries(result)) {\n    const host = normalizeHost(value.url) || value.url\n    if (!host) continue\n\n    const nextItem = {\n      key,\n      name: value.name,\n      host,\n      heat: value.heat ?? 0,\n      categories: value.categories ?? [],\n    }\n\n    const existing = uniqueByHost.get(host)\n    if (!existing || nextItem.heat > existing.heat) {\n      uniqueByHost.set(host, nextItem)\n    }\n  }\n\n  return Array.from(uniqueByHost.values()).sort(\n    (left, right) =>\n      right.heat - left.heat || left.name.localeCompare(right.name),\n  )\n}\n\nexport const getHeroTimelineItems = async (\n  locale?: string,\n): Promise<HeroTimelineItem[]> => {\n  if (isDevelopment) {\n    return HERO_FALLBACK\n  }\n\n  try {\n    const language = getTrendingLanguage(locale)\n    const result = await fetchJson<TrendingFeedsResponse>(\n      `${PRODUCTION_API_URL}/trending/feeds?range=30d&limit=40&language=${language}`,\n    )\n\n    const hostUsage = new Map<string, number>()\n    const items: HeroTimelineItem[] = []\n\n    for (const entry of result.data) {\n      const host = normalizeHost(entry.feed.siteUrl || entry.feed.url)\n      if (!host) continue\n\n      const currentCount = hostUsage.get(host) ?? 0\n      const maxCount = HERO_HOST_LIMITS[host] ?? 1\n      if (currentCount >= maxCount) continue\n\n      items.push({\n        title: truncate(entry.feed.title.trim(), 44),\n        description: normalizeDescription(entry.feed.description),\n        host,\n        href: entry.feed.siteUrl || entry.feed.url,\n        subscriptions: entry.analytics.subscriptionCount,\n      })\n\n      hostUsage.set(host, currentCount + 1)\n\n      if (items.length >= HERO_ITEM_LIMIT) {\n        break\n      }\n    }\n\n    return items.length >= 8 ? items : HERO_FALLBACK\n  } catch (error) {\n    console.error('Failed to load landing hero timeline items', error)\n    return HERO_FALLBACK\n  }\n}\n\nexport const getDiscoverSources = async (): Promise<DiscoverSource[]> => {\n  try {\n    const result = await fetchJson<RSSHubRoutesIndex>(\n      PRODUCTION_RSSHUB_ROUTES_URL,\n    )\n    const items = buildDiscoverSourcesFromIndex(result)\n\n    return items.length >= 8 ? items : DISCOVER_FALLBACK\n  } catch (error) {\n    console.error('Failed to load landing discover sources', error)\n    return DISCOVER_FALLBACK\n  }\n}\n\nexport const getLandingMetrics = async (): Promise<LandingMetrics> => {\n  if (isDevelopment) {\n    return LANDING_METRICS_FALLBACK\n  }\n\n  try {\n    const result = await fetchJson<LandingMetricsResponse>(\n      `${PRODUCTION_API_URL}/status/landing-metrics`,\n    )\n    return result.data\n  } catch (error) {\n    console.error('Failed to load landing metrics', error)\n    return LANDING_METRICS_FALLBACK\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/lib/noop.ts",
    "content": "export const noopArr = []\n\nexport const noopObj = {}\n\nexport const Noop = () => null\n"
  },
  {
    "path": "apps/landing/src/lib/platform.ts",
    "content": "import type { OS } from '~/constants/download'\n\nexport function detectPlatform(userAgent: string): OS | null {\n  if (userAgent.includes('iphone') || userAgent.includes('ipad')) {\n    return 'iOS'\n  } else if (userAgent.includes('android')) {\n    return 'Android'\n  } else if (userAgent.includes('mac')) {\n    return 'macOS'\n  } else if (userAgent.includes('win')) {\n    return 'Windows'\n  } else if (userAgent.includes('linux')) {\n    return 'Linux'\n  }\n\n  return null\n}\n"
  },
  {
    "path": "apps/landing/src/lib/pricing-data.ts",
    "content": "const PRICING_API_URL = 'https://api.follow.is/status/configs'\nconst isDevelopment = process.env.NODE_ENV !== 'production'\n\nexport type PricingPlanLimit = {\n  MAX_AI_TASKS: number\n  AI_CREDIT: number\n  AI_MODEL_SELECTION: 'none' | 'curated' | 'high_performance'\n  AI_BRING_YOUR_OWN_KEY: boolean\n  MAX_AI_ENTRY_SUMMARY_PER_DAY: number\n  MAX_AI_ENTRY_TRANSLATION_PER_DAY: number\n  MAX_AI_TEXT_TO_SPEECH_PER_DAY: number\n  MAX_SUBSCRIPTIONS: number\n  MAX_RSSHUB_SUBSCRIPTIONS: number\n  PRIVATE_SUBSCRIPTION: boolean\n  MAX_INBOXES: number\n  MAX_LISTS: number\n  MAX_ACTIONS: number\n  INTEGRATION_SUPPORTED: boolean\n  SECURE_IMAGE_PROXY: boolean\n  PRIORITY_SUPPORT: boolean | string[]\n  BOOSTS: string\n}\n\nexport type PricingPlan = {\n  planID?: string\n  name: string\n  priceInDollars: number\n  priceInDollarsInDiscount?: number\n  priceInDollarsAnnual: number\n  priceInDollarsInDiscountAnnual?: number\n  discountDescription?: string\n  upgradeButtonText?: string\n  isPopular?: boolean\n  isComingSoon?: boolean\n  tier: number\n  limit: PricingPlanLimit\n  role: 'free' | 'basic' | 'plus' | 'pro' | 'admin'\n}\n\ntype StatusConfigsResponse = {\n  code: number\n  data: {\n    PAYMENT_PLAN_LIST: PricingPlan[]\n  }\n}\n\nconst PRICING_FALLBACK: PricingPlan[] = [\n  {\n    name: 'Free',\n    priceInDollars: 0,\n    priceInDollarsAnnual: 0,\n    tier: 0,\n    role: 'free',\n    limit: {\n      MAX_AI_TASKS: 0,\n      AI_CREDIT: 0,\n      AI_MODEL_SELECTION: 'none',\n      AI_BRING_YOUR_OWN_KEY: false,\n      MAX_AI_ENTRY_SUMMARY_PER_DAY: 3,\n      MAX_AI_ENTRY_TRANSLATION_PER_DAY: 0,\n      MAX_AI_TEXT_TO_SPEECH_PER_DAY: 1,\n      MAX_SUBSCRIPTIONS: 150,\n      MAX_RSSHUB_SUBSCRIPTIONS: 30,\n      PRIVATE_SUBSCRIPTION: false,\n      MAX_INBOXES: 1,\n      MAX_LISTS: 1,\n      MAX_ACTIONS: 1,\n      INTEGRATION_SUPPORTED: false,\n      SECURE_IMAGE_PROXY: false,\n      PRIORITY_SUPPORT: false,\n      BOOSTS: '0',\n    },\n  },\n  {\n    planID: 'basic',\n    name: 'Basic',\n    priceInDollars: 4.99,\n    priceInDollarsAnnual: 49.99,\n    upgradeButtonText: 'Start Free Trial',\n    tier: 0.5,\n    role: 'basic',\n    limit: {\n      MAX_AI_TASKS: 0,\n      AI_CREDIT: 0,\n      AI_MODEL_SELECTION: 'none',\n      AI_BRING_YOUR_OWN_KEY: false,\n      MAX_AI_ENTRY_SUMMARY_PER_DAY: 30,\n      MAX_AI_ENTRY_TRANSLATION_PER_DAY: 300,\n      MAX_AI_TEXT_TO_SPEECH_PER_DAY: 1,\n      MAX_SUBSCRIPTIONS: 1000,\n      MAX_RSSHUB_SUBSCRIPTIONS: 200,\n      PRIVATE_SUBSCRIPTION: true,\n      MAX_INBOXES: 10,\n      MAX_LISTS: 5,\n      MAX_ACTIONS: 5,\n      INTEGRATION_SUPPORTED: true,\n      SECURE_IMAGE_PROXY: true,\n      PRIORITY_SUPPORT: ['Email'],\n      BOOSTS: '0',\n    },\n  },\n  {\n    planID: 'plus',\n    name: 'Plus',\n    priceInDollars: 9.99,\n    priceInDollarsAnnual: 99.99,\n    upgradeButtonText: 'Start Free Trial',\n    isPopular: true,\n    tier: 1,\n    role: 'plus',\n    limit: {\n      MAX_AI_TASKS: Number.MAX_SAFE_INTEGER,\n      AI_CREDIT: 5_000_000,\n      AI_MODEL_SELECTION: 'curated',\n      AI_BRING_YOUR_OWN_KEY: true,\n      MAX_AI_ENTRY_SUMMARY_PER_DAY: Number.MAX_SAFE_INTEGER,\n      MAX_AI_ENTRY_TRANSLATION_PER_DAY: Number.MAX_SAFE_INTEGER,\n      MAX_AI_TEXT_TO_SPEECH_PER_DAY: Number.MAX_SAFE_INTEGER,\n      MAX_SUBSCRIPTIONS: 2500,\n      MAX_RSSHUB_SUBSCRIPTIONS: 500,\n      PRIVATE_SUBSCRIPTION: true,\n      MAX_INBOXES: 20,\n      MAX_LISTS: 15,\n      MAX_ACTIONS: 10,\n      INTEGRATION_SUPPORTED: true,\n      SECURE_IMAGE_PROXY: true,\n      PRIORITY_SUPPORT: ['Email', 'Discord'],\n      BOOSTS: '×10',\n    },\n  },\n  {\n    planID: 'pro',\n    name: 'Pro',\n    priceInDollars: 99.99,\n    priceInDollarsAnnual: 999.99,\n    upgradeButtonText: 'Start Free Trial',\n    tier: 2,\n    role: 'pro',\n    limit: {\n      MAX_AI_TASKS: Number.MAX_SAFE_INTEGER,\n      AI_CREDIT: 50_000_000,\n      AI_MODEL_SELECTION: 'high_performance',\n      AI_BRING_YOUR_OWN_KEY: true,\n      MAX_AI_ENTRY_SUMMARY_PER_DAY: Number.MAX_SAFE_INTEGER,\n      MAX_AI_ENTRY_TRANSLATION_PER_DAY: Number.MAX_SAFE_INTEGER,\n      MAX_AI_TEXT_TO_SPEECH_PER_DAY: Number.MAX_SAFE_INTEGER,\n      MAX_SUBSCRIPTIONS: 25000,\n      MAX_RSSHUB_SUBSCRIPTIONS: 5000,\n      PRIVATE_SUBSCRIPTION: true,\n      MAX_INBOXES: 200,\n      MAX_LISTS: 100,\n      MAX_ACTIONS: 100,\n      INTEGRATION_SUPPORTED: true,\n      SECURE_IMAGE_PROXY: true,\n      PRIORITY_SUPPORT: ['Email', 'Discord'],\n      BOOSTS: '×100',\n    },\n  },\n]\n\nexport const FEATURE_ORDER: Array<keyof PricingPlanLimit> = [\n  'MAX_SUBSCRIPTIONS',\n  'MAX_LISTS',\n  'MAX_INBOXES',\n  'MAX_ACTIONS',\n  'MAX_AI_ENTRY_SUMMARY_PER_DAY',\n  'MAX_AI_ENTRY_TRANSLATION_PER_DAY',\n  'MAX_AI_TEXT_TO_SPEECH_PER_DAY',\n  'AI_MODEL_SELECTION',\n  'AI_BRING_YOUR_OWN_KEY',\n  'BOOSTS',\n  'PRIORITY_SUPPORT',\n  'PRIVATE_SUBSCRIPTION',\n  'MAX_RSSHUB_SUBSCRIPTIONS',\n  'SECURE_IMAGE_PROXY',\n  'INTEGRATION_SUPPORTED',\n  'AI_CREDIT',\n  'MAX_AI_TASKS',\n]\n\nexport const fetchPricingPlans = async (): Promise<PricingPlan[]> => {\n  if (isDevelopment) {\n    return PRICING_FALLBACK\n  }\n\n  try {\n    const controller = new AbortController()\n    const timeout = setTimeout(() => controller.abort(), 5000)\n    const response = await fetch(PRICING_API_URL, {\n      headers: { accept: 'application/json' },\n      signal: controller.signal,\n    })\n    clearTimeout(timeout)\n\n    if (!response.ok) {\n      throw new Error(`Failed to fetch pricing plans: ${response.status}`)\n    }\n\n    const json = (await response.json()) as StatusConfigsResponse\n    const plans = json.data?.PAYMENT_PLAN_LIST ?? []\n    return plans.length > 0 ? plans : PRICING_FALLBACK\n  } catch {\n    return PRICING_FALLBACK\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/lib/query-client.server.ts",
    "content": "import { QueryClient } from '@tanstack/react-query'\n\nconst sharedClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 1000 * 3,\n      gcTime: 1000 * 3,\n    },\n  },\n})\nexport const getQueryClient = () => sharedClient\n"
  },
  {
    "path": "apps/landing/src/lib/scroller.ts",
    "content": "'use client'\n\nimport { animateValue } from 'motion/react'\n\nimport { microDampingPreset } from '~/constants/spring'\n\n// TODO scroller lock\nexport const springScrollTo = (y: number) => {\n  const scrollTop =\n    // FIXME latest version framer will ignore keyframes value `0`\n    document.documentElement.scrollTop || document.body.scrollTop\n\n  const stopSpringScrollHandler = () => {\n    animation.stop()\n  }\n  const animation = animateValue({\n    keyframes: [scrollTop + 1, y],\n    autoplay: true,\n    ...microDampingPreset,\n    onPlay() {\n      window.addEventListener('wheel', stopSpringScrollHandler)\n      window.addEventListener('touchmove', stopSpringScrollHandler)\n    },\n\n    onUpdate(latest) {\n      if (latest <= 0) {\n        animation.stop()\n      }\n      window.scrollTo(0, latest)\n    },\n  })\n\n  animation.then(() => {\n    window.removeEventListener('wheel', stopSpringScrollHandler)\n    window.removeEventListener('touchmove', stopSpringScrollHandler)\n  })\n  return animation\n}\n\nexport const springScrollToTop = () => {\n  return springScrollTo(0)\n}\n\nexport const springScrollToElement = (element: HTMLElement, delta = 40) => {\n  const y = calculateElementTop(element)\n\n  const to = y + delta\n  return springScrollTo(to)\n}\n\nconst calculateElementTop = (el: HTMLElement) => {\n  let top = 0\n  while (el) {\n    top += el.offsetTop\n    el = el.offsetParent as HTMLElement\n  }\n  return top\n}\n"
  },
  {
    "path": "apps/landing/src/lib/sleep.ts",
    "content": "export const sleep = (ms: number) =>\n  new Promise((resolve) => setTimeout(resolve, ms))\n"
  },
  {
    "path": "apps/landing/src/lib/spring.ts",
    "content": "import type { Transition } from 'motion/react'\n\n/**\n * A smooth spring with a predefined duration and no bounce.\n */\nconst smoothPreset: Transition = {\n  type: 'spring',\n  duration: 0.4,\n  bounce: 0,\n}\n\n/**\n * A spring with a predefined duration and small amount of bounce that feels more snappy.\n */\nconst snappyPreset: Transition = {\n  type: 'spring',\n  duration: 0.4,\n  bounce: 0.15,\n}\n\n/**\n * A spring with a predefined duration and higher amount of bounce.\n */\nconst bouncyPreset: Transition = {\n  type: 'spring',\n  duration: 0.4,\n  bounce: 0.3,\n}\nclass SpringPresets {\n  smooth = smoothPreset\n  snappy = snappyPreset\n  bouncy = bouncyPreset\n}\nclass SpringStatic {\n  presets = new SpringPresets()\n\n  /**\n   * A smooth spring with a predefined duration and no bounce that can be tuned.\n   *\n   * @param duration The perceptual duration, which defines the pace of the spring.\n   * @param extraBounce How much additional bounce should be added to the base bounce of 0.\n   */\n  smooth(duration = 0.4, extraBounce = 0): Transition {\n    return {\n      type: 'spring',\n      duration,\n      bounce: extraBounce,\n    }\n  }\n\n  /**\n   * A spring with a predefined duration and small amount of bounce that feels more snappy.\n   */\n  snappy(duration = 0.4, extraBounce = 0): Transition {\n    return {\n      type: 'spring',\n      duration,\n      bounce: 0.15 + extraBounce,\n    }\n  }\n\n  /**\n   * A spring with a predefined duration and higher amount of bounce that can be tuned.\n   */\n  bouncy(duration = 0.4, extraBounce = 0): Transition {\n    return {\n      type: 'spring',\n      duration,\n      bounce: 0.3 + extraBounce,\n    }\n  }\n}\n\nconst SpringClass = new SpringStatic()\nexport { SpringClass as Spring }\n"
  },
  {
    "path": "apps/landing/src/lib/store.ts",
    "content": "import { createStore } from 'jotai/vanilla'\n\nexport const jotaiStore = createStore()\n"
  },
  {
    "path": "apps/landing/src/messages/en.json",
    "content": "{\n  \"metadata\": {\n    \"title\": \"Folo\",\n    \"description\": \"AI RSS Reader for deep, noise-free reading with contextual AI.\",\n    \"keywords\": \"Folo, AI RSS reader, RSS, summarize, translate, research, open source, content discovery, information browser\"\n  },\n  \"common\": {\n    \"actions\": {\n      \"getStarted\": \"Get Started\",\n      \"download\": \"Download\",\n      \"tryOnWeb\": \"Try on Web\",\n      \"viewOnTwitter\": \"View on Twitter\",\n      \"switchLanguage\": \"Switch language\"\n    },\n    \"error\": {\n      \"title\": \"Something went wrong!\",\n      \"action\": \"Try again\"\n    }\n  },\n  \"landing\": {\n    \"hero\": {\n      \"eyebrow\": \"AI RSS Reader · Clean Timeline\",\n      \"title\": \"<brand>Folo</brand> is the <highlight>AI RSS Reader</highlight>\",\n      \"bodyLine1\": \"The AI that reads the internet for you,\",\n      \"bodyLine2\": \"cutting through noise to surface the knowledge you actually care about.\",\n      \"humanTab\": \"I'm a Human\",\n      \"agentTab\": \"I'm an Agent\",\n      \"agentTitle\": \"Use Folo\",\n      \"agentBody\": \"Read <skill>https://api.folo.is/skill.md</skill> and follow the instructions to use Folo.\"\n    },\n    \"features\": {\n      \"headline\": \"Reading reimagined in the AI era\",\n      \"discover\": {\n        \"label\": \"Discover\",\n        \"titleStrong\": \"AI finds the best sources\",\n        \"titleRest\": \"across the open web.\"\n      },\n      \"twin\": {\n        \"label\": \"Digital Twin\",\n        \"titleStrong\": \"Everything you Folo trains your AI\",\n        \"titleRest\": \"to think like you. It remembers, adapts, and evolves with every update.\"\n      },\n      \"vibe\": {\n        \"label\": \"Vibe Read\",\n        \"titleStrong\": \"Let AI read it all.\",\n        \"titleRest\": \"Keep only the signal.\"\n      }\n    },\n    \"trustedBy\": {\n      \"eyebrow\": \"Trusted by teams at\",\n      \"headline\": \"Teams already using Folo.\",\n      \"body\": \"We sampled production work-email registrations to find the companies that have already started reading with Folo.\"\n    },\n    \"metrics\": {\n      \"eyebrow\": \"Production Snapshot\",\n      \"headline\": \"At a glance\",\n      \"body\": \"Live production scale from Folo.\",\n      \"footnote\": \"Production snapshot. Approximate.\",\n      \"cards\": {\n        \"feeds\": {\n          \"label\": \"Feeds\",\n          \"detail\": \"Sources across blogs, research, newsletters, social profiles, and more.\"\n        },\n        \"entries\": {\n          \"label\": \"Entries\",\n          \"detail\": \"A huge reading graph that AI can search, summarize, and connect for you.\"\n        },\n        \"readers\": {\n          \"label\": \"Readers\",\n          \"detail\": \"People already using Folo to keep up without drowning in noise.\"\n        },\n        \"follows\": {\n          \"label\": \"Follows\",\n          \"detail\": \"Subscriptions flowing through production timelines right now.\"\n        }\n      }\n    },\n    \"builtOpen\": {\n      \"title\": \"Built Open\",\n      \"body\": \"Open source, verifiable, and easy to inspect.\",\n      \"note\": \"Code public. PRs welcome.\"\n    },\n    \"repoStats\": {\n      \"githubStars\": \"GitHub Stars\",\n      \"license\": \"License\",\n      \"licenseValue\": \"GPL-3.0\",\n      \"repository\": \"Repository\",\n      \"repoDescriptionMobile\": \"Code public. PRs welcome.\",\n      \"repoAriaLabel\": \"Open Folo repository on GitHub\"\n    },\n    \"socialProof\": {\n      \"eyebrow\": \"Loved by The Curious\",\n      \"headline\": \"Folo is how the curious read in the AI era.\"\n    }\n  },\n  \"download\": {\n    \"metadata\": {\n      \"title\": \"Download Folo\",\n      \"description\": \"Download Folo for Desktop or use it on the Web. Available on macOS, Windows, and Linux.\"\n    },\n    \"hero\": {\n      \"title\": \"Download <brand>Folo</brand>\",\n      \"subtitle\": \"Get the AI RSS Reader on Desktop, or open it instantly on the Web.\"\n    },\n    \"platforms\": {\n      \"recommended\": \"Recommended for your device\",\n      \"allPlatforms\": \"Available on\",\n      \"moreDownloads\": \"More downloads\",\n      \"lessDownloads\": \"Show less\",\n      \"iOS\": {\n        \"label\": \"iOS\",\n        \"store\": \"App Store\"\n      },\n      \"Android\": {\n        \"label\": \"Android\",\n        \"store\": \"Google Play\"\n      },\n      \"macOS\": {\n        \"label\": \"macOS\",\n        \"store\": \"Mac App Store\"\n      },\n      \"Windows\": {\n        \"label\": \"Windows\",\n        \"store\": \"Microsoft Store\"\n      },\n      \"Linux\": {\n        \"label\": \"Linux\",\n        \"store\": \"GitHub\"\n      },\n      \"web\": {\n        \"title\": \"Web Version\",\n        \"subtitle\": \"No installation required\"\n      },\n      \"footer\": \"By downloading, you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>.\"\n    }\n  },\n  \"pricing\": {\n    \"metadata\": {\n      \"title\": \"Pricing\",\n      \"description\": \"Choose the Folo plan that fits your reading workflow.\"\n    },\n    \"eyebrow\": \"Pricing\",\n    \"headline\": \"Plans for every reading workflow\",\n    \"body\": \"Compare limits, AI capabilities, and workflow scale before you choose.\",\n    \"monthly\": \"Monthly\",\n    \"yearly\": \"Yearly\",\n    \"save\": \"Save {percent}%\",\n    \"month\": \"month\",\n    \"billedYearly\": \"Billed yearly at {total}\",\n    \"mostPopular\": \"Most Popular\",\n    \"getStarted\": \"Get Started\",\n    \"table\": {\n      \"features\": \"Features\"\n    },\n    \"descriptions\": {\n      \"free\": \"For getting started with a calm reading workflow.\",\n      \"basic\": \"For solo readers who want more subscriptions and more AI.\",\n      \"plus\": \"For power users who rely on AI every day.\",\n      \"pro\": \"For heavy workflows, large libraries, and maximum limits.\",\n      \"admin\": \"For heavy workflows, large libraries, and maximum limits.\"\n    },\n    \"features\": {\n      \"MAX_SUBSCRIPTIONS\": \"feed subscriptions\",\n      \"MAX_LISTS\": \"lists\",\n      \"MAX_INBOXES\": \"inboxes\",\n      \"MAX_ACTIONS\": \"actions\",\n      \"MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"AI summaries / day\",\n      \"MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"AI translations / day\",\n      \"MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"text-to-speech / day\",\n      \"AI_MODEL_SELECTION\": \"AI model selection\",\n      \"AI_BRING_YOUR_OWN_KEY\": \"bring your own key\",\n      \"BOOSTS\": \"boosts\",\n      \"PRIORITY_SUPPORT\": \"priority support\",\n      \"PRIVATE_SUBSCRIPTION\": \"private subscriptions\",\n      \"MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub subscriptions\",\n      \"SECURE_IMAGE_PROXY\": \"secure image proxy\",\n      \"INTEGRATION_SUPPORTED\": \"integrations\",\n      \"AI_CREDIT\": \"AI credits\",\n      \"MAX_AI_TASKS\": \"AI tasks\"\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/messages/jp.json",
    "content": "{\n  \"metadata\": {\n    \"title\": \"Folo\",\n    \"description\": \"AI RSS Reader。文脈理解でノイズのない深い読書体験を。\",\n    \"keywords\": \"Folo, AI RSS リーダー, RSS, 要約, 翻訳, リサーチ, オープンソース, コンテンツ発見, インフォメーションブラウザ\"\n  },\n  \"common\": {\n    \"actions\": {\n      \"getStarted\": \"今すぐ体験\",\n      \"download\": \"ダウンロード\",\n      \"tryOnWeb\": \"Web で試す\",\n      \"viewOnTwitter\": \"X で見る\",\n      \"switchLanguage\": \"言語を切り替える\"\n    },\n    \"error\": {\n      \"title\": \"問題が発生しました！\",\n      \"action\": \"再試行\"\n    }\n  },\n  \"landing\": {\n    \"hero\": {\n      \"eyebrow\": \"AI RSS Reader · クリーンタイムライン\",\n      \"title\": \"<brand>Folo</brand> はあなたの <highlight>AI RSS Reader</highlight>\",\n      \"bodyLine1\": \"インターネットの情報を AI が代わりに読み、\",\n      \"bodyLine2\": \"雑音を取り除いて、本当に必要な知識だけを届けます。\",\n      \"humanTab\": \"I'm a Human\",\n      \"agentTab\": \"I'm an Agent\",\n      \"agentTitle\": \"Use Folo\",\n      \"agentBody\": \"Read <skill>https://api.folo.is/skill.md</skill> and follow the instructions to use Folo.\"\n    },\n    \"features\": {\n      \"headline\": \"AI 時代のための新しい読書体験\",\n      \"discover\": {\n        \"label\": \"ディスカバリー\",\n        \"titleStrong\": \"AI が最高の情報源を見つけ出す\",\n        \"titleRest\": \"オープンウェブ全体から。\"\n      },\n      \"twin\": {\n        \"label\": \"デジタルツイン\",\n        \"titleStrong\": \"Folo でのすべてがあなたの AI を育てる\",\n        \"titleRest\": \"記憶し、理解し、アップデートのたびにあなたと共に進化します。\"\n      },\n      \"vibe\": {\n        \"label\": \"バイブリード\",\n        \"titleStrong\": \"AI にすべて任せて読んでもらおう。\",\n        \"titleRest\": \"必要なシグナルだけ残す。\"\n      }\n    },\n    \"trustedBy\": {\n      \"eyebrow\": \"導入が進むチーム\",\n      \"headline\": \"すでに Folo を使うチームがあります。\",\n      \"body\": \"本番環境の業務用メール登録サンプルをもとに、Folo を使い始めている企業を抽出しました。\"\n    },\n    \"metrics\": {\n      \"eyebrow\": \"本番スナップショット\",\n      \"headline\": \"At a glance\",\n      \"body\": \"Folo の本番環境の規模です。\",\n      \"footnote\": \"本番スナップショット。概算値です。\",\n      \"cards\": {\n        \"feeds\": {\n          \"label\": \"Feeds\",\n          \"detail\": \"ブログ、研究、ニュースレター、ソーシャルなど幅広いソースをまとめて追えます。\"\n        },\n        \"entries\": {\n          \"label\": \"Entries\",\n          \"detail\": \"AI が検索・要約・文脈化できるだけの十分に大きな読書グラフです。\"\n        },\n        \"readers\": {\n          \"label\": \"Readers\",\n          \"detail\": \"ノイズに埋もれず情報を追うために、すでに Folo を使っている人たちです。\"\n        },\n        \"follows\": {\n          \"label\": \"Follows\",\n          \"detail\": \"いま本番タイムラインを流れている購読関係の総量です。\"\n        }\n      }\n    },\n    \"builtOpen\": {\n      \"title\": \"オープンに構築\",\n      \"body\": \"オープンソースで、見ればわかる。\",\n      \"note\": \"コード公開。PR歓迎。\"\n    },\n    \"repoStats\": {\n      \"githubStars\": \"GitHub スター\",\n      \"license\": \"ライセンス\",\n      \"licenseValue\": \"GPL-3.0\",\n      \"repository\": \"リポジトリ\",\n      \"repoDescriptionMobile\": \"コード公開。PR歓迎。\",\n      \"repoAriaLabel\": \"GitHub で Folo リポジトリを開く\"\n    },\n    \"socialProof\": {\n      \"eyebrow\": \"好奇心を持つ人に愛されています\",\n      \"headline\": \"AI 時代に好奇心旺盛な人が読むのは Folo です。\"\n    }\n  },\n  \"download\": {\n    \"metadata\": {\n      \"title\": \"Folo をダウンロード\",\n      \"description\": \"デスクトップ版をダウンロードするか、Web で今すぐ利用できます。macOS・Windows・Linux に対応。\"\n    },\n    \"hero\": {\n      \"title\": \"<brand>Folo</brand> をダウンロード\",\n      \"subtitle\": \"AI RSS Reader をデスクトップで使うか、Web ですぐに開けます。\"\n    },\n    \"platforms\": {\n      \"recommended\": \"このデバイスにおすすめ\",\n      \"allPlatforms\": \"その他の対応環境\",\n      \"moreDownloads\": \"その他のダウンロード\",\n      \"lessDownloads\": \"折りたたむ\",\n      \"iOS\": {\n        \"label\": \"iOS\",\n        \"store\": \"App Store\"\n      },\n      \"Android\": {\n        \"label\": \"Android\",\n        \"store\": \"Google Play\"\n      },\n      \"macOS\": {\n        \"label\": \"macOS\",\n        \"store\": \"Mac App Store\"\n      },\n      \"Windows\": {\n        \"label\": \"Windows\",\n        \"store\": \"Microsoft Store\"\n      },\n      \"Linux\": {\n        \"label\": \"Linux\",\n        \"store\": \"GitHub\"\n      },\n      \"web\": {\n        \"title\": \"Web 版\",\n        \"subtitle\": \"インストール不要ですぐに利用可能\"\n      },\n      \"footer\": \"ダウンロードすると、<terms>利用規約</terms>と<privacy>プライバシーポリシー</privacy>に同意したものとみなされます。\"\n    }\n  },\n  \"pricing\": {\n    \"metadata\": {\n      \"title\": \"Pricing\",\n      \"description\": \"Folo の読書ワークフローに合うプランを選べます。\"\n    },\n    \"eyebrow\": \"Pricing\",\n    \"headline\": \"読書ワークフローに合わせたプラン\",\n    \"body\": \"制限、AI 機能、運用規模を比較して選べます。\",\n    \"monthly\": \"月払い\",\n    \"yearly\": \"年払い\",\n    \"save\": \"{percent}% お得\",\n    \"month\": \"月\",\n    \"billedYearly\": \"年額 {total} で請求\",\n    \"mostPopular\": \"Most Popular\",\n    \"getStarted\": \"始める\",\n    \"table\": {\n      \"features\": \"機能\"\n    },\n    \"descriptions\": {\n      \"free\": \"落ち着いた読書体験を始めるための無料プラン。\",\n      \"basic\": \"購読数と AI 利用量を増やしたい個人ユーザー向け。\",\n      \"plus\": \"毎日 AI を活用するパワーユーザー向け。\",\n      \"pro\": \"大規模ライブラリと重いワークフロー向けの上位プラン。\",\n      \"admin\": \"大規模ライブラリと重いワークフロー向けの上位プラン。\"\n    },\n    \"features\": {\n      \"MAX_SUBSCRIPTIONS\": \"feed subscriptions\",\n      \"MAX_LISTS\": \"lists\",\n      \"MAX_INBOXES\": \"inboxes\",\n      \"MAX_ACTIONS\": \"actions\",\n      \"MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"AI summaries / day\",\n      \"MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"AI translations / day\",\n      \"MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"text-to-speech / day\",\n      \"AI_MODEL_SELECTION\": \"AI model selection\",\n      \"AI_BRING_YOUR_OWN_KEY\": \"bring your own key\",\n      \"BOOSTS\": \"boosts\",\n      \"PRIORITY_SUPPORT\": \"priority support\",\n      \"PRIVATE_SUBSCRIPTION\": \"private subscriptions\",\n      \"MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub subscriptions\",\n      \"SECURE_IMAGE_PROXY\": \"secure image proxy\",\n      \"INTEGRATION_SUPPORTED\": \"integrations\",\n      \"AI_CREDIT\": \"AI credits\",\n      \"MAX_AI_TASKS\": \"AI tasks\"\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/messages/zh.json",
    "content": "{\n  \"metadata\": {\n    \"title\": \"Folo\",\n    \"description\": \"AI RSS Reader，具备上下文 AI，带来深度、无噪的阅读体验。\",\n    \"keywords\": \"Folo, AI RSS 阅读器, RSS, 摘要, 翻译, 研究, 开源, 内容发现, 信息浏览器\"\n  },\n  \"common\": {\n    \"actions\": {\n      \"getStarted\": \"立即体验\",\n      \"download\": \"下载\",\n      \"tryOnWeb\": \"网页版试用\",\n      \"viewOnTwitter\": \"在 X 上查看\",\n      \"switchLanguage\": \"切换语言\"\n    },\n    \"error\": {\n      \"title\": \"出错了！\",\n      \"action\": \"重试\"\n    }\n  },\n  \"landing\": {\n    \"hero\": {\n      \"eyebrow\": \"AI RSS Reader · 洁净时间线\",\n      \"title\": \"<brand>Folo</brand> 是你的 <highlight>AI RSS Reader</highlight>\",\n      \"bodyLine1\": \"这款 AI 为你读遍全网，\",\n      \"bodyLine2\": \"穿透噪音，只呈现你真正关心的洞见。\",\n      \"humanTab\": \"I'm a Human\",\n      \"agentTab\": \"I'm an Agent\",\n      \"agentTitle\": \"Use Folo\",\n      \"agentBody\": \"Read <skill>https://api.folo.is/skill.md</skill> and follow the instructions to use Folo.\"\n    },\n    \"features\": {\n      \"headline\": \"在 AI 时代重新定义阅读\",\n      \"discover\": {\n        \"label\": \"发现\",\n        \"titleStrong\": \"AI 从海量信息中筛选最佳来源\",\n        \"titleRest\": \"覆盖整个开放网络。\"\n      },\n      \"twin\": {\n        \"label\": \"数字分身\",\n        \"titleStrong\": \"你在 Folo 的一切都在训练你的 AI\",\n        \"titleRest\": \"它会记住、理解，并随每次更新与你共同进化。\"\n      },\n      \"vibe\": {\n        \"label\": \"氛围阅读\",\n        \"titleStrong\": \"让 AI 读完所有内容。\",\n        \"titleRest\": \"只留下真正的信号。\"\n      }\n    },\n    \"trustedBy\": {\n      \"eyebrow\": \"已被这些团队采用\",\n      \"headline\": \"已经有团队在用 Folo。\",\n      \"body\": \"我们基于生产环境中的工作邮箱注册样本，整理出了这些已经开始使用 Folo 的公司。\"\n    },\n    \"metrics\": {\n      \"eyebrow\": \"生产环境快照\",\n      \"headline\": \"一眼看懂\",\n      \"body\": \"Folo 当前生产环境的数据规模。\",\n      \"footnote\": \"生产快照，近似值。\",\n      \"cards\": {\n        \"feeds\": {\n          \"label\": \"Feeds\",\n          \"detail\": \"覆盖博客、研究、Newsletter、社交账号以及更多开放网页来源。\"\n        },\n        \"entries\": {\n          \"label\": \"Entries\",\n          \"detail\": \"足够大的阅读图谱，能让 AI 做搜索、摘要与上下文关联。\"\n        },\n        \"readers\": {\n          \"label\": \"Readers\",\n          \"detail\": \"已经在用 Folo 追踪信息、同时避开噪音的人。\"\n        },\n        \"follows\": {\n          \"label\": \"Follows\",\n          \"detail\": \"此刻正在生产时间线里流转的订阅关系。\"\n        }\n      }\n    },\n    \"builtOpen\": {\n      \"title\": \"开放共建\",\n      \"body\": \"开源、透明，而且容易验证。\",\n      \"note\": \"代码公开，欢迎 PR。\"\n    },\n    \"repoStats\": {\n      \"githubStars\": \"GitHub 星标\",\n      \"license\": \"许可证\",\n      \"licenseValue\": \"GPL-3.0\",\n      \"repository\": \"代码仓库\",\n      \"repoDescriptionMobile\": \"代码公开，欢迎 PR。\",\n      \"repoAriaLabel\": \"在 GitHub 打开 Folo 仓库\"\n    },\n    \"socialProof\": {\n      \"eyebrow\": \"好奇者热爱 Folo\",\n      \"headline\": \"在 AI 时代，好奇的人用 Folo 阅读。\"\n    }\n  },\n  \"download\": {\n    \"metadata\": {\n      \"title\": \"下载 Folo\",\n      \"description\": \"下载 Folo 桌面端或直接在 Web 上体验。支持 macOS、Windows 和 Linux。\"\n    },\n    \"hero\": {\n      \"title\": \"下载 <brand>Folo</brand>\",\n      \"subtitle\": \"在桌面端使用 AI RSS Reader，或直接在 Web 上打开。\"\n    },\n    \"platforms\": {\n      \"recommended\": \"为你的设备推荐\",\n      \"allPlatforms\": \"还可下载于\",\n      \"moreDownloads\": \"更多下载\",\n      \"lessDownloads\": \"收起\",\n      \"iOS\": {\n        \"label\": \"iOS\",\n        \"store\": \"App Store\"\n      },\n      \"Android\": {\n        \"label\": \"Android\",\n        \"store\": \"Google Play\"\n      },\n      \"macOS\": {\n        \"label\": \"macOS\",\n        \"store\": \"Mac App Store\"\n      },\n      \"Windows\": {\n        \"label\": \"Windows\",\n        \"store\": \"Microsoft Store\"\n      },\n      \"Linux\": {\n        \"label\": \"Linux\",\n        \"store\": \"GitHub\"\n      },\n      \"web\": {\n        \"title\": \"网页版\",\n        \"subtitle\": \"无需安装，随时开启阅读\"\n      },\n      \"footer\": \"下载即表示你同意我们的 <terms>服务条款</terms> 和 <privacy>隐私政策</privacy>。\"\n    }\n  },\n  \"pricing\": {\n    \"metadata\": {\n      \"title\": \"价格\",\n      \"description\": \"选择适合你阅读工作流的 Folo 计划。\"\n    },\n    \"eyebrow\": \"Pricing\",\n    \"headline\": \"适合不同阅读工作流的计划\",\n    \"body\": \"先比较额度、AI 能力和工作流规模，再决定使用哪一档。\",\n    \"monthly\": \"月付\",\n    \"yearly\": \"年付\",\n    \"save\": \"省 {percent}%\",\n    \"month\": \"月\",\n    \"billedYearly\": \"按年计费 {total}\",\n    \"mostPopular\": \"最受欢迎\",\n    \"getStarted\": \"开始使用\",\n    \"table\": {\n      \"features\": \"功能\"\n    },\n    \"descriptions\": {\n      \"free\": \"适合开始使用，保持安静清爽的阅读体验。\",\n      \"basic\": \"适合希望提升订阅量和 AI 使用额度的个人读者。\",\n      \"plus\": \"适合每天依赖 AI 阅读和处理信息的重度用户。\",\n      \"pro\": \"适合大型资料库、重度工作流和最高额度需求。\",\n      \"admin\": \"适合大型资料库、重度工作流和最高额度需求。\"\n    },\n    \"features\": {\n      \"MAX_SUBSCRIPTIONS\": \"feed 订阅数\",\n      \"MAX_LISTS\": \"列表\",\n      \"MAX_INBOXES\": \"收件箱\",\n      \"MAX_ACTIONS\": \"自动动作\",\n      \"MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"AI 摘要 / 天\",\n      \"MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"AI 翻译 / 天\",\n      \"MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"语音朗读 / 天\",\n      \"AI_MODEL_SELECTION\": \"AI 模型选择\",\n      \"AI_BRING_YOUR_OWN_KEY\": \"自带 API Key\",\n      \"BOOSTS\": \"加速次数\",\n      \"PRIORITY_SUPPORT\": \"优先支持\",\n      \"PRIVATE_SUBSCRIPTION\": \"私密订阅\",\n      \"MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub 订阅数\",\n      \"SECURE_IMAGE_PROXY\": \"安全图片代理\",\n      \"INTEGRATION_SUPPORTED\": \"集成能力\",\n      \"AI_CREDIT\": \"AI Credits\",\n      \"MAX_AI_TASKS\": \"AI 任务\"\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/debug-provider.tsx",
    "content": "'use client'\n\nimport { ReactQueryDevtools } from '@tanstack/react-query-devtools'\nimport type { PropsWithChildren, ReactElement } from 'react'\nimport { Suspense } from 'react'\n\nexport const DebugProvider = ({\n  children,\n}: PropsWithChildren): ReactElement => {\n  return (\n    <>\n      <Suspense>\n        <div data-hide-print>\n          <ReactQueryDevtools buttonPosition=\"bottom-left\" />\n        </div>\n      </Suspense>\n      {children}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/event-provider.tsx",
    "content": "'use client'\n\nimport { throttle } from 'es-toolkit'\nimport { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'\nimport { useEffect } from 'react'\n\nimport { setIsPrintMode } from '~/atoms/css-media'\nimport { viewportAtom } from '~/atoms/viewport'\nimport { jotaiStore } from '~/lib/store'\n\nexport const EventProvider: Component = ({ children }) => {\n  useIsomorphicLayoutEffect(() => {\n    const readViewport = throttle(() => {\n      const { innerWidth: w, innerHeight: h } = window\n      const sm = w >= 640\n      const md = w >= 768\n      const lg = w >= 1024\n      const xl = w >= 1280\n      const _2xl = w >= 1536\n      jotaiStore.set(viewportAtom, {\n        sm,\n        md,\n        lg,\n        xl,\n        '2xl': _2xl,\n        h,\n        w,\n      })\n    }, 16)\n\n    readViewport()\n\n    window.addEventListener('resize', readViewport)\n    return () => {\n      window.removeEventListener('resize', readViewport)\n    }\n  }, [])\n\n  useEffect(() => {\n    const getMediaType = <T extends { matches: boolean }>(e: T) => {\n      setIsPrintMode(!e.matches)\n    }\n\n    getMediaType(window.matchMedia('screen'))\n\n    const callback = (e: MediaQueryListEvent): void => {\n      getMediaType(e)\n    }\n    try {\n      window.matchMedia('screen').addEventListener('change', callback)\n      // eslint-disable-next-line no-empty\n    } catch {}\n\n    return () => {\n      window.matchMedia('screen').removeEventListener('change', callback)\n    }\n  }, [])\n\n  return <>{children}</>\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/framer-lazy-feature.ts",
    "content": "export { domMax as default } from 'motion/react'\n"
  },
  {
    "path": "apps/landing/src/providers/root/index.tsx",
    "content": "'use client'\n\nimport { LazyMotion } from 'motion/react'\nimport { ThemeProvider } from 'next-themes'\nimport type { JSX, PropsWithChildren } from 'react'\nimport { Toaster } from 'sonner'\n\nimport { ModalContainer } from '~/components/ui/modal'\n\nimport { ProviderComposer } from '../../components/common/ProviderComposer'\nimport { DebugProvider } from './debug-provider'\nimport { EventProvider } from './event-provider'\nimport { JotaiStoreProvider } from './jotai-provider'\nimport { PageScrollInfoProvider } from './page-scroll-info-provider'\nimport { ReactQueryProvider } from './react-query-provider'\n\nconst loadFeatures = () =>\n  import('./framer-lazy-feature').then((res) => res.default)\nconst contexts: JSX.Element[] = [\n  <ThemeProvider key=\"themeProvider\" />,\n  <ReactQueryProvider key=\"reactQueryProvider\" />,\n  <JotaiStoreProvider key=\"jotaiStoreProvider\" />,\n\n  <LazyMotion features={loadFeatures} strict key=\"framer\" />,\n]\nexport function Providers({ children }: PropsWithChildren) {\n  return (\n    <>\n      <ProviderComposer contexts={contexts}>\n        {children}\n\n        <EventProvider key=\"viewportProvider\" />\n        <PageScrollInfoProvider key=\"PageScrollInfoProvider\" />\n        <DebugProvider key=\"debugProvider\" />\n        <Toaster key=\"toaster\" />\n        <ModalContainer />\n      </ProviderComposer>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/jotai-provider.tsx",
    "content": "import { Provider } from 'jotai'\n\nimport { jotaiStore } from '~/lib/store'\n\nexport const JotaiStoreProvider: Component = ({ children }) => {\n  return <Provider store={jotaiStore}>{children}</Provider>\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/page-scroll-info-provider.tsx",
    "content": "'use client'\n\nimport { throttle } from 'es-toolkit'\nimport { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'\nimport { atom, useAtomValue, useSetAtom } from 'jotai'\nimport type { FC, PropsWithChildren } from 'react'\nimport { startTransition, useMemo, useRef } from 'react'\n\nimport { setIsInteractive } from '~/atoms/is-interactive'\nimport { createAtomSelector } from '~/lib/jotai'\n\nconst pageScrollLocationAtom = atom(0)\nconst pageScrollDirectionAtom = atom<'up' | 'down' | null>(null)\n\nexport const PageScrollInfoProvider: FC<PropsWithChildren> = ({ children }) => {\n  return (\n    <>\n      <ScrollDetector />\n      {children}\n    </>\n  )\n}\n\nconst ScrollDetector = () => {\n  const setPageScrollLocation = useSetAtom(pageScrollLocationAtom)\n  const setPageScrollDirection = useSetAtom(pageScrollDirectionAtom)\n  const prevScrollY = useRef(0)\n  const setIsInteractiveOnceRef = useRef(false)\n\n  // const lastTime = useRef(0)\n  // const setScrollSpeed = useSetAtom(pageScrollSpeedAtom)\n\n  useIsomorphicLayoutEffect(() => {\n    const scrollHandler = throttle(() => {\n      if (!setIsInteractiveOnceRef.current) {\n        setIsInteractive(true)\n        setIsInteractiveOnceRef.current = true\n      }\n      const currentTop = document.documentElement.scrollTop\n\n      setPageScrollDirection(\n        prevScrollY.current - currentTop > 0 ? 'up' : 'down',\n      )\n      prevScrollY.current = currentTop\n      startTransition(() => {\n        setPageScrollLocation(prevScrollY.current)\n      })\n    }, 16)\n    window.addEventListener('scroll', scrollHandler)\n\n    scrollHandler()\n\n    return () => {\n      window.removeEventListener('scroll', scrollHandler)\n    }\n  }, [])\n\n  return null\n}\n\nconst usePageScrollLocation = () => useAtomValue(pageScrollLocationAtom)\nconst usePageScrollDirection = () => useAtomValue(pageScrollDirectionAtom)\n\nconst usePageScrollLocationSelector = createAtomSelector(pageScrollLocationAtom)\nconst usePageScrollDirectionSelector = createAtomSelector(\n  pageScrollDirectionAtom,\n)\n\nconst useIsScrollUpAndPageIsOver = (threshold: number) => {\n  return useAtomValue(\n    useMemo(\n      () =>\n        atom((get) => {\n          const scrollLocation = get(pageScrollLocationAtom)\n          const scrollDirection = get(pageScrollDirectionAtom)\n          return scrollLocation > threshold && scrollDirection === 'up'\n        }),\n      [threshold],\n    ),\n  )\n}\nexport {\n  useIsScrollUpAndPageIsOver,\n  usePageScrollDirection,\n  usePageScrollDirectionSelector,\n  usePageScrollLocation,\n  usePageScrollLocationSelector,\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/react-query-provider.tsx",
    "content": "'use client'\n\nimport { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'\nimport { QueryClient } from '@tanstack/react-query'\nimport type { PersistQueryClientOptions } from '@tanstack/react-query-persist-client'\nimport { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'\nimport { createStore, del, get, set } from 'idb-keyval'\nimport type { PropsWithChildren } from 'react'\n\nimport { isServerSide } from '~/lib/env'\n\nconst dbStore = isServerSide ? undefined : createStore('react-query', 'queries')\n\nconst asyncStoragePersister = createAsyncStoragePersister({\n  storage: {\n    getItem: async (key) => {\n      const value = await get(key, dbStore)\n      return value\n    },\n    setItem: async (key, value) => {\n      await set(key, value, dbStore)\n    },\n    removeItem: async (key) => {\n      await del(key, dbStore)\n    },\n  },\n})\n\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 1000 * 60 * 5, // 5 minutes\n      refetchOnWindowFocus: false,\n      refetchIntervalInBackground: false,\n    },\n  },\n})\n\nconst persistOptions: Omit<PersistQueryClientOptions, 'queryClient'> = {\n  persister: asyncStoragePersister,\n  maxAge: 1000 * 60 * 60 * 24 * 7, // 1 week\n  dehydrateOptions: {\n    shouldDehydrateQuery: (query) => {\n      const queryIsReadyForPersistance = query.state.status === 'success'\n\n      if (query.meta?.persist === false) return false\n\n      if (queryIsReadyForPersistance) {\n        return (\n          !((query.state?.data as any)?.pages?.length > 1) ||\n          (!!query.state.data && !(query.state.data as any).pages)\n        )\n      } else {\n        return false\n      }\n    },\n  },\n}\nexport const ReactQueryProvider = ({ children }: PropsWithChildren) => {\n  return (\n    <PersistQueryClientProvider\n      client={queryClient}\n      persistOptions={persistOptions}\n    >\n      {children}\n    </PersistQueryClientProvider>\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/providers/root/sonner.tsx",
    "content": "import { Toaster as Sonner } from 'sonner'\n\nimport { useIsDark } from '~/hooks/common/use-is-dark'\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>\n\nexport const Toaster = ({ ...props }: ToasterProps) => {\n  const theme = useIsDark() ? 'dark' : 'light'\n\n  return (\n    <Sonner\n      theme={theme}\n      richColors\n      expand\n      closeButton\n      duration={3500}\n      offset=\"16px\"\n      className=\"toaster group\"\n      toastOptions={{\n        classNames: {\n          // Card shell - using material colors for glass morphism effect\n          toast:\n            'group pointer-events-auto flex gap-3 rounded-xl border border-border bg-material-opaque backdrop-blur supports-[backdrop-filter]:bg-material-medium shadow-lg shadow-black/5 ring-1 ring-border',\n          // Title & description - using semantic text colors\n          title: 'text-text font-medium',\n          description: 'text-text-secondary text-sm leading-relaxed',\n          // Icon & close button - using accent and text colors\n          icon: 'text-accent size-4',\n          closeButton:\n            'text-text-tertiary hover:text-text transition-opacity duration-200',\n          // Action buttons - using primary and fill colors\n          actionButton:\n            'rounded-md bg-accent text-background px-2.5 py-1 text-xs font-medium hover:bg-accent/90',\n          cancelButton:\n            'rounded-md border border-border bg-fill px-2.5 py-1 text-xs font-medium text-text hover:bg-fill-secondary',\n        },\n      }}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/landing/src/providers/shared/LayoutRightSideProvider.tsx",
    "content": "'use client'\n\nimport { atom, useAtomValue, useSetAtom } from 'jotai'\nimport * as React from 'react'\nimport { useLayoutEffect } from 'react'\nimport { createPortal } from 'react-dom'\n\nimport { useIsClient } from '~/hooks/common/use-is-client'\n\nconst rightSideElementAtom = atom<null | HTMLDivElement>(null)\nexport const LayoutRightSideProvider: Component = ({ className }) => {\n  const setElement = useSetAtom(rightSideElementAtom)\n  const divRef = React.useRef<HTMLDivElement>(null)\n  useLayoutEffect(() => {\n    setElement(divRef.current)\n    return () => {\n      // GC\n      setElement(null)\n    }\n  }, [])\n\n  return (\n    <div\n      ref={divRef}\n      className={className}\n      // data-testid=\"LayoutRightSideProvider\"\n    />\n  )\n}\n\nexport const LayoutRightSidePortal: Component = ({ children }) => {\n  const rightSideElement = useAtomValue(rightSideElementAtom)\n\n  const isClient = useIsClient()\n  if (!isClient) return null\n\n  if (!rightSideElement) return null\n\n  return createPortal(children, rightSideElement)\n}\n"
  },
  {
    "path": "apps/landing/src/providers/shared/WrappedElementProvider.tsx",
    "content": "'use client'\n\nimport { createContextState } from 'foxact/create-context-state'\nimport { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'\nimport { memo, useEffect, useRef } from 'react'\n\nimport { ProviderComposer } from '~/components/common/ProviderComposer'\nimport { useStateToRef } from '~/hooks/common/use-state-ref'\nimport { clsxm } from '~/lib/helper'\n\nimport { usePageScrollDirection } from '../root/page-scroll-info-provider'\n\nconst [\n  WrappedElementProviderInternal,\n  useWrappedElement,\n  useSetWrappedElement,\n] = createContextState<HTMLDivElement | null>(undefined as any)\n\nconst [\n  ElementSizeProviderInternal,\n  useWrappedElementSize,\n  useSetWrappedElementSize,\n] = createContextState({\n  h: 0,\n  w: 0,\n})\n\nconst [\n  ElementPositsionProviderInternal,\n  useWrappedElementPositsion,\n  useSetElementPositsion,\n] = createContextState({\n  x: 0,\n  y: 0,\n})\n\nconst [\n  IsEOArticleElementProviderInternal,\n  useIsEoFWrappedElement,\n  useSetIsEOArticleElement,\n] = createContextState<boolean>(false)\n\nconst Providers = [\n  <WrappedElementProviderInternal key=\"ArticleElementProviderInternal\" />,\n  <ElementSizeProviderInternal key=\"ElementSizeProviderInternal\" />,\n  <ElementPositsionProviderInternal key=\"ElementPositsionProviderInternal\" />,\n  <IsEOArticleElementProviderInternal key=\"IsEOArticleElementProviderInternal\" />,\n]\nconst WrappedElementProvider: Component = ({ children, className }) => {\n  return (\n    <ProviderComposer contexts={Providers}>\n      <ArticleElementResizeObserver />\n      <Content className={className}>{children}</Content>\n    </ProviderComposer>\n  )\n}\nconst ArticleElementResizeObserver = () => {\n  const setSize = useSetWrappedElementSize()\n  const setPos = useSetElementPositsion()\n  const $article = useWrappedElement()\n  useIsomorphicLayoutEffect(() => {\n    if (!$article) return\n    const { height, width, x, y } = $article.getBoundingClientRect()\n    setSize({ h: height, w: width })\n    setPos({ x, y })\n\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[0]\n      const { height, width, x, y } = entry.contentRect\n      setSize({ h: height, w: width })\n      setPos({ x, y })\n    })\n    observer.observe($article)\n    return () => {\n      observer.unobserve($article)\n      observer.disconnect()\n    }\n  }, [$article])\n\n  return null\n}\n\nconst Content: Component = memo(({ children, className }) => {\n  const setElement = useSetWrappedElement()\n\n  return (\n    <div className={clsxm('relative', className)} ref={setElement}>\n      {children}\n      <EOADetector />\n    </div>\n  )\n})\n\nContent.displayName = 'ArticleElementProviderContent'\n\nconst EOADetector: Component = () => {\n  const dir = usePageScrollDirection()\n  const getDir = useStateToRef(dir)\n  const setter = useSetIsEOArticleElement()\n  const ref = useRef<HTMLDivElement>(null)\n  useEffect(() => {\n    if (!ref.current) return\n    const $el = ref.current\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[0]\n        // if (yhRef.current < ) return\n        if (!entry.isIntersecting && getDir.current === 'down') {\n          return\n        }\n\n        setter(entry.isIntersecting)\n      },\n      {\n        rootMargin: '0px 0px 0px 0px',\n      },\n    )\n\n    observer.observe($el)\n    return () => {\n      observer.unobserve($el)\n      observer.disconnect()\n    }\n  }, [])\n\n  return <div ref={ref} />\n}\n\nexport {\n  useIsEoFWrappedElement,\n  useSetWrappedElement,\n  useWrappedElement,\n  useWrappedElementPositsion,\n  useWrappedElementSize,\n  WrappedElementProvider,\n}\n"
  },
  {
    "path": "apps/landing/src/proxy.ts",
    "content": "import { NextResponse } from 'next/server'\n\nimport { defaultLocale, locales } from '~/i18n/routing'\n\nconst localeSet = new Set(locales)\nconst rscSuffix = '.rsc'\nconst bypassPrefixes = ['/_next', '/_vinext', '/api']\nconst bypassExactPaths = new Set([\n  '/apple-app-site-association',\n  '/.well-known/apple-app-site-association',\n  '/discover-sources',\n])\n\nconst getLogicalPathname = (pathname: string) => {\n  if (!pathname.endsWith(rscSuffix)) {\n    return pathname\n  }\n\n  const logicalPathname = pathname.slice(0, -rscSuffix.length)\n  return logicalPathname === '' ? '/' : logicalPathname\n}\n\nexport function proxy(request: Request) {\n  const url = new URL(request.url)\n  const rawPathname = url.pathname\n  const pathname = getLogicalPathname(rawPathname)\n\n  if (bypassPrefixes.some((prefix) => pathname.startsWith(prefix))) {\n    return NextResponse.next()\n  }\n\n  if (bypassExactPaths.has(pathname)) {\n    return NextResponse.next()\n  }\n\n  const hasFileExtension = /\\.[^/]+$/.test(rawPathname)\n  if (hasFileExtension && !rawPathname.endsWith(rscSuffix)) {\n    return NextResponse.next()\n  }\n\n  const firstSegment = pathname.split('/').find(Boolean)\n  if (firstSegment && localeSet.has(firstSegment as (typeof locales)[number])) {\n    return NextResponse.next()\n  }\n\n  const rewritePath =\n    pathname === '/' ? `/${defaultLocale}` : `/${defaultLocale}${pathname}`\n  const rewriteUrl = new URL(rewritePath, request.url)\n  rewriteUrl.search = url.search\n\n  return NextResponse.rewrite(rewriteUrl)\n}\n\nexport const middleware = proxy\nexport default proxy\n\nexport const config = {\n  matcher: ['/:path*'],\n}\n"
  },
  {
    "path": "apps/landing/src/styles/globals.css",
    "content": "@import 'tailwindcss';\n@import 'tailwindcss-safe-area';\n@import './pastel-theme-oklch.css';\n\n@plugin \"@tailwindcss/typography\";\n@plugin '@egoist/tailwindcss-icons';\n@plugin \"tailwind-scrollbar\";\n@plugin 'tailwindcss-animate';\n\n@source \"../**/*.{js,jsx,ts,tsx}\";\n@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));\n\n@theme {\n  --color-accent-foreground: #fff;\n}\n\n/* Brand accent override */\n:root,\n[data-theme] {\n  --color-accent: #ff5c00;\n}\n\n[data-theme='light'] {\n  --radius: 0.5rem;\n  --border: 20 5.9% 90%;\n}\n\n[data-theme='dark'] {\n  --radius: 0.5rem;\n  --border: 0 0% 22.1%;\n}\n\n[data-hand-cursor='true'] {\n  --cursor-button: pointer;\n  --cursor-select: text;\n  --cursor-checkbox: pointer;\n  --cursor-link: pointer;\n  --cursor-menu: pointer;\n  --cursor-radio: pointer;\n  --cursor-switch: pointer;\n  --cursor-card: pointer;\n}\n\n:root {\n  --cursor-button: default;\n  --cursor-select: text;\n  --cursor-checkbox: default;\n  --cursor-link: pointer;\n  --cursor-menu: default;\n  --cursor-radio: default;\n  --cursor-switch: default;\n  --cursor-card: default;\n\n  /* Vercel-style design tokens - balanced radius */\n  --radius: 0.5rem;\n  /* Pastel provides semantic colors (background, text, accent, border, etc).\n     Keep only non-Pastel variables here. */\n\n  /* Shadcn compatibility vars mapped to Pastel tokens */\n  --background: var(--color-background);\n  --foreground: var(--color-text);\n  --card: var(--color-material-opaque);\n  --card-foreground: var(--color-text);\n  --popover: var(--color-material-medium);\n  --popover-foreground: var(--color-text);\n  --primary: var(--color-primary);\n  --primary-foreground: var(--color-white);\n  --secondary: var(--color-fill-secondary);\n  --secondary-foreground: var(--color-text);\n  --muted: var(--color-fill-tertiary);\n  --muted-foreground: var(--color-text-tertiary);\n  --accent: var(--color-accent);\n  --accent-foreground: var(--color-white);\n  --destructive: var(--color-red);\n  --border: var(--color-border);\n  --input: var(--color-border-secondary);\n  --ring: var(--color-primary);\n\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.145 0 0);\n  --sidebar-primary: oklch(0.205 0 0);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.97 0 0);\n  --sidebar-accent-foreground: oklch(0.205 0 0);\n  --sidebar-border: oklch(0.922 0 0);\n  --sidebar-ring: oklch(0.708 0 0);\n}\n\n/* Warm, Folo-branded surface system overrides */\n:root,\n[data-theme='light'] {\n  /* Background family */\n  --color-background: oklch(0.994 0.002 48);\n  --color-background-secondary: oklch(0.988 0.0028 48);\n  --color-background-tertiary: oklch(0.982 0.0036 48);\n  --color-background-quaternary: oklch(0.974 0.0044 48);\n  --color-background-quinary: oklch(0.966 0.0052 48);\n  --color-background-light: oklch(0.997 0.0016 48);\n  --color-background-dark: oklch(0.26 0.01 45);\n  --color-background-secondary-light: oklch(0.99 0.0028 48);\n  --color-background-secondary-dark: oklch(0.29 0.011 45);\n  --color-background-tertiary-light: oklch(0.984 0.0036 48);\n  --color-background-tertiary-dark: oklch(0.32 0.012 45);\n  --color-background-quaternary-light: oklch(0.976 0.0044 48);\n  --color-background-quaternary-dark: oklch(0.35 0.013 45);\n  --color-background-quinary-light: oklch(0.968 0.0052 48);\n  --color-background-quinary-dark: oklch(0.38 0.014 45);\n\n  /* Fill family */\n  --color-fill: oklch(0.962 0.004 48);\n  --color-fill-secondary: oklch(0.948 0.0054 48);\n  --color-fill-tertiary: oklch(0.934 0.0068 48);\n  --color-fill-quaternary: oklch(0.92 0.0082 48);\n  --color-fill-light: oklch(0.962 0.004 48);\n  --color-fill-dark: oklch(0.34 0.012 45);\n  --color-fill-secondary-light: oklch(0.948 0.0054 48);\n  --color-fill-secondary-dark: oklch(0.37 0.013 45);\n  --color-fill-tertiary-light: oklch(0.934 0.0068 48);\n  --color-fill-tertiary-dark: oklch(0.4 0.014 45);\n  --color-fill-quaternary-light: oklch(0.92 0.0082 48);\n  --color-fill-quaternary-dark: oklch(0.43 0.015 45);\n\n  /* Material family */\n  --color-material-ultra-thick: oklch(0.97 0.004 48 / 0.92);\n  --color-material-thick: oklch(0.966 0.004 48 / 0.85);\n  --color-material-medium: oklch(0.96 0.0055 48 / 0.7);\n  --color-material-thin: oklch(0.954 0.0055 48 / 0.6);\n  --color-material-ultra-thin: oklch(0.948 0.0066 48 / 0.48);\n  --color-material-opaque: oklch(0.952 0.0065 48);\n  --color-material-ultra-thick-light: oklch(0.97 0.004 48 / 0.92);\n  --color-material-ultra-thick-dark: oklch(0.27 0.011 45 / 0.92);\n  --color-material-thick-light: oklch(0.966 0.004 48 / 0.85);\n  --color-material-thick-dark: oklch(0.26 0.011 45 / 0.85);\n  --color-material-medium-light: oklch(0.96 0.0055 48 / 0.7);\n  --color-material-medium-dark: oklch(0.28 0.012 45 / 0.7);\n  --color-material-thin-light: oklch(0.954 0.0055 48 / 0.6);\n  --color-material-thin-dark: oklch(0.27 0.012 45 / 0.6);\n  --color-material-ultra-thin-light: oklch(0.948 0.0066 48 / 0.48);\n  --color-material-ultra-thin-dark: oklch(0.26 0.012 45 / 0.48);\n  --color-material-opaque-light: oklch(0.952 0.0065 48);\n  --color-material-opaque-dark: oklch(0.295 0.012 45);\n}\n\n[data-theme='dark'] {\n  /* Background family */\n  --color-background: oklch(0.2 0.008 45);\n  --color-background-secondary: oklch(0.24 0.0075 45);\n  --color-background-tertiary: oklch(0.28 0.007 45);\n  --color-background-quaternary: oklch(0.32 0.0065 45);\n  --color-background-quinary: oklch(0.36 0.006 45);\n  --color-background-light: oklch(0.25 0.0078 45);\n  --color-background-dark: oklch(0.15 0.009 45);\n  --color-background-secondary-light: oklch(0.28 0.007 45);\n  --color-background-secondary-dark: oklch(0.19 0.0086 45);\n  --color-background-tertiary-light: oklch(0.32 0.0065 45);\n  --color-background-tertiary-dark: oklch(0.23 0.008 45);\n  --color-background-quaternary-light: oklch(0.36 0.006 45);\n  --color-background-quaternary-dark: oklch(0.27 0.0075 45);\n  --color-background-quinary-light: oklch(0.4 0.0055 45);\n  --color-background-quinary-dark: oklch(0.31 0.007 45);\n\n  /* Fill family */\n  --color-fill: oklch(0.23 0.0078 45);\n  --color-fill-secondary: oklch(0.27 0.0073 45);\n  --color-fill-tertiary: oklch(0.31 0.0068 45);\n  --color-fill-quaternary: oklch(0.35 0.0063 45);\n  --color-fill-light: oklch(0.27 0.0073 45);\n  --color-fill-dark: oklch(0.18 0.0088 45);\n  --color-fill-secondary-light: oklch(0.31 0.0068 45);\n  --color-fill-secondary-dark: oklch(0.22 0.0082 45);\n  --color-fill-tertiary-light: oklch(0.35 0.0063 45);\n  --color-fill-tertiary-dark: oklch(0.26 0.0078 45);\n  --color-fill-quaternary-light: oklch(0.39 0.0058 45);\n  --color-fill-quaternary-dark: oklch(0.3 0.0073 45);\n\n  /* Material family */\n  --color-material-ultra-thick: oklch(0.27 0.0075 45 / 0.9);\n  --color-material-thick: oklch(0.25 0.0075 45 / 0.82);\n  --color-material-medium: oklch(0.23 0.007 45 / 0.7);\n  --color-material-thin: oklch(0.21 0.0065 45 / 0.58);\n  --color-material-ultra-thin: oklch(0.19 0.006 45 / 0.48);\n  --color-material-opaque: oklch(0.238 0.0066 45);\n  --color-material-ultra-thick-light: oklch(0.29 0.008 45 / 0.9);\n  --color-material-ultra-thick-dark: oklch(0.16 0.0086 45 / 0.9);\n  --color-material-thick-light: oklch(0.27 0.0075 45 / 0.82);\n  --color-material-thick-dark: oklch(0.14 0.008 45 / 0.82);\n  --color-material-medium-light: oklch(0.25 0.007 45 / 0.7);\n  --color-material-medium-dark: oklch(0.12 0.008 45 / 0.7);\n  --color-material-thin-light: oklch(0.23 0.0065 45 / 0.58);\n  --color-material-thin-dark: oklch(0.1 0.0076 45 / 0.58);\n  --color-material-ultra-thin-light: oklch(0.21 0.006 45 / 0.48);\n  --color-material-ultra-thin-dark: oklch(0.09 0.0072 45 / 0.48);\n  --color-material-opaque-light: oklch(0.258 0.0066 45);\n  --color-material-opaque-dark: oklch(0.135 0.0076 45);\n}\n\n:root,\nbody {\n  @apply text-text;\n  @apply font-sans;\n  @apply text-base leading-normal;\n  @apply antialiased;\n  @apply selection:bg-accent/20 selection:text-text;\n\n  @apply bg-accent-10/5;\n}\n\nhtml {\n  /* make app like native app in mobile */\n  -webkit-tap-highlight-color: transparent;\n  /*  for firefox */\n  scrollbar-width: thin;\n}\n/* Theme configuration */\n@theme {\n  --color-accent: #ff5c00;\n  /* Container */\n  --container-padding: 2rem;\n  --container-max-width-2xl: 1400px;\n\n  /* Custom cursors */\n  --cursor-button: var(--cursor-button);\n  --cursor-select: var(--cursor-select);\n  --cursor-checkbox: var(--cursor-checkbox);\n  --cursor-link: var(--cursor-link);\n  --cursor-menu: var(--cursor-menu);\n  --cursor-radio: var(--cursor-radio);\n  --cursor-switch: var(--cursor-switch);\n  --cursor-card: var(--cursor-card);\n\n  /* Blur */\n  --blur-background: 70px;\n\n  /* Box shadow */\n  --box-shadow-context-menu:\n    rgba(0, 0, 0, 0.067) 0px 3px 8px, rgba(0, 0, 0, 0.067) 0px 2px 5px,\n    rgba(0, 0, 0, 0.067) 0px 1px 1px;\n\n  /* Font */\n  --text-large-title: 1.625rem;\n  --text-large-title--line-height: 2rem;\n\n  --text-title1: 1.375rem;\n  --text-title1--line-height: 1.625rem;\n\n  --text-title2: 1.0625rem;\n  --text-title2--line-height: 1.375rem;\n\n  --text-title3: 0.9375rem;\n  --text-title3--line-height: 1.25rem;\n\n  --text-headline: 0.8125rem;\n  --text-headline--line-height: 1rem;\n\n  --text-body: 0.8125rem;\n  --text-body--line-height: 1rem;\n\n  --text-callout: 0.75rem;\n  --text-callout--line-height: 0.9375rem;\n\n  --text-subheadline: 0.6875rem;\n  --text-subheadline--line-height: 0.875rem;\n\n  --text-footnote: 0.625rem;\n  --text-footnote--line-height: 0.8125rem;\n\n  --text-caption: 0.625rem;\n  --text-caption--line-height: 0.8125rem;\n\n  /* Font families */\n  --font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;\n  --font-serif:\n    'Noto Serif CJK SC', 'Noto Serif SC', var(--font-serif),\n    'Source Han Serif SC', 'Source Han Serif', source-han-serif-sc, SongTi SC,\n    SimSum, 'Hiragino Sans GB', system-ui, -apple-system, Segoe UI, Roboto,\n    Helvetica, 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif;\n  --font-mono:\n    'OperatorMonoSSmLig Nerd Font', 'Cascadia Code PL',\n    'FantasqueSansMono Nerd Font', 'operator mono', JetBrainsMono,\n    'Fira code Retina', 'Fira code', Consolas, Monaco, 'Hannotate SC',\n    monospace, -apple-system;\n\n  /* Custom screens */\n  --screen-light-mode: (prefers-color-scheme: light);\n  --screen-dark-mode: (prefers-color-scheme: dark);\n\n  /* Width and max-width */\n  --width-screen: 100vw;\n  --max-width-screen: 100vw;\n\n  /* Height and max-height */\n  --height-screen: 100vh;\n  --max-height-screen: 100vh;\n\n  --color-primary: var(--color-accent);\n  --color-primary-light: var(--color-accent-light);\n  --color-primary-dark: var(--color-accent-dark);\n\n  --color-accent-10: color-mix(\n    in srgb,\n    var(--color-accent) 10%,\n    var(--color-background)\n  );\n  --color-accent-20: color-mix(\n    in srgb,\n    var(--color-accent) 20%,\n    var(--color-background)\n  );\n  --color-accent-30: color-mix(\n    in srgb,\n    var(--color-accent) 30%,\n    var(--color-background)\n  );\n  --color-accent-40: color-mix(\n    in srgb,\n    var(--color-accent) 40%,\n    var(--color-background)\n  );\n  --color-accent-50: color-mix(\n    in srgb,\n    var(--color-accent) 50%,\n    var(--color-background)\n  );\n  --color-accent-60: color-mix(\n    in srgb,\n    var(--color-accent) 60%,\n    var(--color-background)\n  );\n  --color-accent-70: color-mix(\n    in srgb,\n    var(--color-accent) 70%,\n    var(--color-background)\n  );\n  --color-accent-80: color-mix(\n    in srgb,\n    var(--color-accent) 80%,\n    var(--color-background)\n  );\n}\n\n@layer theme {\n  #root {\n    --color-primary: var(--color-accent);\n    --color-primary-light: var(--color-accent-light);\n    --color-primary-dark: var(--color-accent-dark);\n  }\n}\n\n@layer base {\n  .container {\n    margin-left: auto;\n    margin-right: auto;\n    padding: 1rem;\n  }\n  @media (min-width: 640px) {\n    .container {\n      padding: var(--container-padding);\n    }\n  }\n  @media (min-width: 1536px) {\n    .container {\n      max-width: var(--container-max-width-2xl);\n    }\n  }\n}\n\nhtml {\n  @apply font-sans;\n}\n\nhtml body {\n  @apply max-w-screen overflow-x-hidden;\n}\n\n*:not(input):not(textarea):not([contenteditable='true']):focus-visible {\n  outline: 0 !important;\n}\n\nbody {\n  font-feature-settings:\n    'rlig' 1,\n    'calt' 1;\n}\n\n@theme inline {\n  /* Vercel-style balanced radius - clean but not too sharp */\n  --radius-sm: 0.25rem;\n  --radius-md: 0.375rem;\n  --radius-lg: 0.5rem;\n  --radius-xl: 0.75rem;\n  /* Use Pastel palette tokens; provide minimal shims for existing classes */\n  --color-ring: var(--color-accent);\n  --color-foreground: var(--color-text);\n  --color-muted-foreground: var(--color-text-secondary);\n}\n\n@layer theme {\n  :root {\n    @variant dark {\n      --chart-1: oklch(0.488 0.243 264.376);\n      --chart-2: oklch(0.696 0.17 162.48);\n      --chart-3: oklch(0.769 0.188 70.08);\n      --chart-4: oklch(0.627 0.265 303.9);\n      --chart-5: oklch(0.645 0.246 16.439);\n\n      --sidebar: oklch(0.205 0 0);\n      --sidebar-foreground: oklch(0.985 0 0);\n      --sidebar-primary: oklch(0.488 0.243 264.376);\n      --sidebar-primary-foreground: oklch(0.985 0 0);\n      --sidebar-accent: oklch(0.269 0 0);\n      --sidebar-accent-foreground: oklch(0.985 0 0);\n      --sidebar-border: oklch(1 0 0 / 10%);\n      --sidebar-ring: oklch(0.556 0 0);\n    }\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\nhtml {\n  scrollbar-width: thin;\n  scrollbar-color: var(--color-material-medium) transparent;\n}\n\nhtml *::-webkit-scrollbar-thumb {\n  background-color: transparent;\n  border: 6px solid var(--color-fill-quaternary);\n  @apply rounded-xl;\n}\n\nhtml *::-webkit-scrollbar-thumb:hover {\n  border-color: var(--color-fill-secondary);\n}\n\nhtml *::-webkit-scrollbar {\n  width: 6px !important;\n  height: 6px !important;\n  background: transparent;\n}\n\nhtml *::-webkit-scrollbar-thumb {\n  background: var(--color-material-medium);\n}\n\nhtml *::-webkit-scrollbar-thumb:hover {\n  background: var(--color-material-thick);\n}\n\nhtml *::-webkit-scrollbar-thumb:active {\n  background: var(--color-material-ultra-thick);\n}\n\nhtml *::-webkit-scrollbar-corner {\n  background: var(--color-background-secondary);\n}\n\n/* Accordion animations */\n@layer utilities {\n  @keyframes accordion-down {\n    from {\n      height: 0;\n    }\n    to {\n      height: var(--radix-accordion-content-height);\n    }\n  }\n\n  @keyframes accordion-up {\n    from {\n      height: var(--radix-accordion-content-height);\n    }\n    to {\n      height: 0;\n    }\n  }\n\n  .animate-accordion-down {\n    animation: accordion-down 0.2s ease-out;\n  }\n\n  .animate-accordion-up {\n    animation: accordion-up 0.2s ease-out;\n  }\n\n  .animate-mask-left-to-right {\n    animation: mask-left-to-right var(--animation-duration, 0.5s) ease-in-out\n      forwards;\n  }\n\n  @keyframes mask-left-to-right {\n    0% {\n      mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 150% 0 /\n        400% no-repeat;\n      opacity: 0.2;\n    }\n    100% {\n      mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 0 / 400%\n        no-repeat;\n      opacity: 1;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/styles/pastel-theme-oklch.css",
    "content": "/* This file is auto-generated by Pastel Palette for Tailwind v4 */\n\n/* Light mode colors (default) */\n@theme {\n  --color-blue: oklch(0.65 0.18 237);\n  --color-pink: oklch(0.68 0.22 350);\n  --color-purple: oklch(0.65 0.2 280);\n  --color-green: oklch(0.67 0.15 155);\n  --color-orange: oklch(0.68 0.15 60);\n  --color-yellow: oklch(0.9 0.19 100);\n  --color-sky: oklch(0.7 0.14 210);\n  --color-red: oklch(0.65 0.22 27);\n  --color-brown: oklch(0.62 0.12 45);\n  --color-gray: oklch(0.65 0 0);\n  --color-neutral: oklch(0.6 0 0);\n  --color-black: oklch(0.2 0 0);\n  --color-white: oklch(0.99 0.005 200);\n  --color-teal: oklch(0.66 0.16 180);\n  --color-cyan: oklch(0.7 0.15 195);\n  --color-indigo: oklch(0.58 0.2 260);\n  --color-violet: oklch(0.62 0.22 300);\n  --color-lime: oklch(0.75 0.16 125);\n  --color-emerald: oklch(0.64 0.16 160);\n  --color-amber: oklch(0.75 0.13 85);\n  --color-rose: oklch(0.63 0.21 15);\n  --color-slate: oklch(0.55 0.015 240);\n  --color-zinc: oklch(0.58 0.01 240);\n  --color-gray1: oklch(0.9 0 0);\n  --color-gray2: oklch(0.85 0 0);\n  --color-gray3: oklch(0.8 0 0);\n  --color-gray4: oklch(0.75 0 0);\n  --color-gray5: oklch(0.7 0 0);\n  --color-gray6: oklch(0.65 0 0);\n  --color-gray7: oklch(0.6 0 0);\n  --color-gray8: oklch(0.55 0 0);\n  --color-gray9: oklch(0.5 0 0);\n  --color-gray10: oklch(0.45 0 0);\n  --color-text: oklch(0.15 0.0049 230);\n  --color-text-secondary: oklch(0.45 0.0049 230);\n  --color-text-tertiary: oklch(0.55 0.0049 230);\n  --color-text-quaternary: oklch(0.7 0.0049 230);\n  --color-placeholder-text: oklch(0.8 0.0049 226);\n  --color-border: oklch(0.92 0.0049 234);\n  --color-border-secondary: oklch(0.94 0.0049 234);\n  --color-separator: oklch(0.88 0.0049 236);\n  --color-link: oklch(0.7 0.16 250);\n  --color-disabled-control: oklch(0.94 0.0049 226);\n  --color-disabled-text: oklch(0.9 0.0049 226);\n  --color-background: oklch(0.99 0.0049 230);\n  --color-background-secondary: oklch(0.98 0.0049 230);\n  --color-background-tertiary: oklch(0.97 0.0049 230);\n  --color-background-quaternary: oklch(0.96 0.0049 230);\n  --color-background-quinary: oklch(0.949 0.0049 230);\n  --color-fill: oklch(0.96 0.0049 228);\n  --color-fill-secondary: oklch(0.94 0.0049 228);\n  --color-fill-tertiary: oklch(0.92 0.0049 228);\n  --color-fill-quaternary: oklch(0.9 0.0049 228);\n  --color-material-ultra-thick: oklch(0.96 0.0049 232 / 0.93);\n  --color-material-thick: oklch(0.96 0.0049 232 / 0.85);\n  --color-material-medium: oklch(0.96 0.0049 232 / 0.65);\n  --color-material-thin: oklch(0.96 0.0049 232 / 0.6);\n  --color-material-ultra-thin: oklch(0.96 0.0049 232 / 0.45);\n  --color-material-opaque: oklch(0.96 0.0049 232);\n  --color-accent: oklch(0.65 0.18 238);\n  --color-primary: oklch(0.55 0.2 249);\n  --color-secondary: oklch(0.77 0.14 171);\n}\n\n/* All color variants with suffixes */\n@theme {\n  --color-blue-light: oklch(0.65 0.18 237);\n  --color-blue-dark: oklch(0.7 0.16 237);\n  --color-blue-hc: oklch(0.45 0.25 237);\n  --color-blue-kawaii: oklch(0.75 0.12 237);\n  --color-pink-light: oklch(0.68 0.22 350);\n  --color-pink-dark: oklch(0.73 0.2 350);\n  --color-pink-hc: oklch(0.5 0.3 350);\n  --color-pink-kawaii: oklch(0.78 0.16 350);\n  --color-purple-light: oklch(0.65 0.2 280);\n  --color-purple-dark: oklch(0.7 0.18 280);\n  --color-purple-hc: oklch(0.45 0.28 280);\n  --color-purple-kawaii: oklch(0.75 0.14 280);\n  --color-green-light: oklch(0.67 0.15 155);\n  --color-green-dark: oklch(0.72 0.16 155);\n  --color-green-hc: oklch(0.5 0.2 155);\n  --color-green-kawaii: oklch(0.76 0.12 155);\n  --color-orange-light: oklch(0.68 0.15 60);\n  --color-orange-dark: oklch(0.73 0.16 60);\n  --color-orange-hc: oklch(0.55 0.18 60);\n  --color-orange-kawaii: oklch(0.77 0.12 60);\n  --color-yellow-light: oklch(0.9 0.19 100);\n  --color-yellow-dark: oklch(0.85 0.18 100);\n  --color-yellow-hc: oklch(0.65 0.15 100);\n  --color-yellow-kawaii: oklch(0.84 0.1 100);\n  --color-sky-light: oklch(0.7 0.14 210);\n  --color-sky-dark: oklch(0.75 0.13 210);\n  --color-sky-hc: oklch(0.5 0.2 210);\n  --color-sky-kawaii: oklch(0.78 0.11 210);\n  --color-red-light: oklch(0.65 0.22 27);\n  --color-red-dark: oklch(0.7 0.21 27);\n  --color-red-hc: oklch(0.5 0.28 20);\n  --color-red-kawaii: oklch(0.75 0.14 20);\n  --color-brown-light: oklch(0.62 0.12 45);\n  --color-brown-dark: oklch(0.67 0.12 45);\n  --color-brown-hc: oklch(0.45 0.15 45);\n  --color-brown-kawaii: oklch(0.74 0.1 45);\n  --color-gray-light: oklch(0.65 0 0);\n  --color-gray-dark: oklch(0.7 0 0);\n  --color-gray-hc: oklch(0.3 0 0);\n  --color-gray-kawaii: oklch(0.8 0 0);\n  --color-neutral-light: oklch(0.6 0 0);\n  --color-neutral-dark: oklch(0.652 0 0);\n  --color-neutral-hc: oklch(0.25 0 0);\n  --color-neutral-kawaii: oklch(0.76 0 0);\n  --color-black-light: oklch(0.2 0 0);\n  --color-black-dark: oklch(0.25 0 0);\n  --color-black-hc: oklch(0 0 0);\n  --color-black-kawaii: oklch(0.3 0 0);\n  --color-white-light: oklch(0.99 0.005 200);\n  --color-white-dark: oklch(0.97 0 0);\n  --color-white-hc: oklch(1 0 0);\n  --color-white-kawaii: oklch(0.999 0 0);\n  --color-teal-light: oklch(0.66 0.16 180);\n  --color-teal-dark: oklch(0.71 0.15 180);\n  --color-teal-hc: oklch(0.45 0.25 180);\n  --color-teal-kawaii: oklch(0.78 0.1 180);\n  --color-cyan-light: oklch(0.7 0.15 195);\n  --color-cyan-dark: oklch(0.75 0.14 195);\n  --color-cyan-hc: oklch(0.5 0.22 195);\n  --color-cyan-kawaii: oklch(0.8 0.09 195);\n  --color-indigo-light: oklch(0.58 0.2 260);\n  --color-indigo-dark: oklch(0.65 0.18 260);\n  --color-indigo-hc: oklch(0.4 0.28 260);\n  --color-indigo-kawaii: oklch(0.75 0.11 260);\n  --color-violet-light: oklch(0.62 0.22 300);\n  --color-violet-dark: oklch(0.68 0.2 300);\n  --color-violet-hc: oklch(0.42 0.3 300);\n  --color-violet-kawaii: oklch(0.77 0.12 300);\n  --color-lime-light: oklch(0.75 0.16 125);\n  --color-lime-dark: oklch(0.78 0.17 125);\n  --color-lime-hc: oklch(0.6 0.22 125);\n  --color-lime-kawaii: oklch(0.82 0.08 125);\n  --color-emerald-light: oklch(0.64 0.16 160);\n  --color-emerald-dark: oklch(0.69 0.15 160);\n  --color-emerald-hc: oklch(0.48 0.22 160);\n  --color-emerald-kawaii: oklch(0.78 0.1 160);\n  --color-amber-light: oklch(0.75 0.13 85);\n  --color-amber-dark: oklch(0.78 0.14 85);\n  --color-amber-hc: oklch(0.62 0.18 85);\n  --color-amber-kawaii: oklch(0.82 0.08 85);\n  --color-rose-light: oklch(0.63 0.21 15);\n  --color-rose-dark: oklch(0.68 0.19 15);\n  --color-rose-hc: oklch(0.48 0.3 15);\n  --color-rose-kawaii: oklch(0.78 0.13 15);\n  --color-slate-light: oklch(0.55 0.015 240);\n  --color-slate-dark: oklch(0.6 0.015 240);\n  --color-slate-hc: oklch(0.35 0.02 240);\n  --color-slate-kawaii: oklch(0.76 0.01 240);\n  --color-zinc-light: oklch(0.58 0.01 240);\n  --color-zinc-dark: oklch(0.63 0.01 240);\n  --color-zinc-hc: oklch(0.38 0.01 240);\n  --color-zinc-kawaii: oklch(0.78 0.005 240);\n  --color-gray1-light: oklch(0.9 0 0);\n  --color-gray1-dark: oklch(0.2 0 0);\n  --color-gray1-hc: oklch(0.9 0 0);\n  --color-gray1-kawaii: oklch(0.9 0 0);\n  --color-gray2-light: oklch(0.85 0 0);\n  --color-gray2-dark: oklch(0.25 0 0);\n  --color-gray2-hc: oklch(0.85 0 0);\n  --color-gray2-kawaii: oklch(0.85 0 0);\n  --color-gray3-light: oklch(0.8 0 0);\n  --color-gray3-dark: oklch(0.3 0 0);\n  --color-gray3-hc: oklch(0.8 0 0);\n  --color-gray3-kawaii: oklch(0.8 0 0);\n  --color-gray4-light: oklch(0.75 0 0);\n  --color-gray4-dark: oklch(0.35 0 0);\n  --color-gray4-hc: oklch(0.75 0 0);\n  --color-gray4-kawaii: oklch(0.75 0 0);\n  --color-gray5-light: oklch(0.7 0 0);\n  --color-gray5-dark: oklch(0.4 0 0);\n  --color-gray5-hc: oklch(0.7 0 0);\n  --color-gray5-kawaii: oklch(0.7 0 0);\n  --color-gray6-light: oklch(0.65 0 0);\n  --color-gray6-dark: oklch(0.451 0 0);\n  --color-gray6-hc: oklch(0.65 0 0);\n  --color-gray6-kawaii: oklch(0.65 0 0);\n  --color-gray7-light: oklch(0.6 0 0);\n  --color-gray7-dark: oklch(0.501 0 0);\n  --color-gray7-hc: oklch(0.6 0 0);\n  --color-gray7-kawaii: oklch(0.6 0 0);\n  --color-gray8-light: oklch(0.55 0 0);\n  --color-gray8-dark: oklch(0.551 0 0);\n  --color-gray8-hc: oklch(0.55 0 0);\n  --color-gray8-kawaii: oklch(0.55 0 0);\n  --color-gray9-light: oklch(0.5 0 0);\n  --color-gray9-dark: oklch(0.601 0 0);\n  --color-gray9-hc: oklch(0.5 0 0);\n  --color-gray9-kawaii: oklch(0.5 0 0);\n  --color-gray10-light: oklch(0.45 0 0);\n  --color-gray10-dark: oklch(0.651 0 0);\n  --color-gray10-hc: oklch(0.45 0 0);\n  --color-gray10-kawaii: oklch(0.45 0 0);\n  --color-text-light: oklch(0.15 0.0049 230);\n  --color-text-dark: oklch(0.95 0.0049 230);\n  --color-text-secondary-light: oklch(0.45 0.0049 230);\n  --color-text-secondary-dark: oklch(0.75 0.0049 230);\n  --color-text-tertiary-light: oklch(0.55 0.0049 230);\n  --color-text-tertiary-dark: oklch(0.65 0.0049 230);\n  --color-text-quaternary-light: oklch(0.7 0.0049 230);\n  --color-text-quaternary-dark: oklch(0.451 0.0049 230);\n  --color-placeholder-text-light: oklch(0.8 0.0049 226);\n  --color-placeholder-text-dark: oklch(0.6 0.0049 226);\n  --color-border-light: oklch(0.92 0.0049 234);\n  --color-border-dark: oklch(0.35 0.0049 234);\n  --color-border-secondary-light: oklch(0.94 0.0049 234);\n  --color-border-secondary-dark: oklch(0.3 0.0049 234);\n  --color-separator-light: oklch(0.88 0.0049 236);\n  --color-separator-dark: oklch(0.33 0.0049 236);\n  --color-link-light: oklch(0.7 0.16 250);\n  --color-link-dark: oklch(0.78 0.14 250);\n  --color-disabled-control-light: oklch(0.94 0.0049 226);\n  --color-disabled-control-dark: oklch(0.27 0.0049 226);\n  --color-disabled-text-light: oklch(0.9 0.0049 226);\n  --color-disabled-text-dark: oklch(0.55 0.0049 226);\n  --color-background-light: oklch(0.99 0.0049 230);\n  --color-background-dark: oklch(0.22 0.0049 230);\n  --color-background-secondary-light: oklch(0.98 0.0049 230);\n  --color-background-secondary-dark: oklch(0.26 0.0049 230);\n  --color-background-tertiary-light: oklch(0.97 0.0049 230);\n  --color-background-tertiary-dark: oklch(0.3 0.0049 230);\n  --color-background-quaternary-light: oklch(0.96 0.0049 230);\n  --color-background-quaternary-dark: oklch(0.34 0.0049 230);\n  --color-background-quinary-light: oklch(0.949 0.0049 230);\n  --color-background-quinary-dark: oklch(0.38 0.0049 230);\n  --color-fill-light: oklch(0.96 0.0049 228);\n  --color-fill-dark: oklch(0.3 0.0049 228);\n  --color-fill-secondary-light: oklch(0.94 0.0049 228);\n  --color-fill-secondary-dark: oklch(0.35 0.0049 228);\n  --color-fill-tertiary-light: oklch(0.92 0.0049 228);\n  --color-fill-tertiary-dark: oklch(0.4 0.0049 228);\n  --color-fill-quaternary-light: oklch(0.9 0.0049 228);\n  --color-fill-quaternary-dark: oklch(0.45 0.0049 228);\n  --color-material-ultra-thick-light: oklch(0.96 0.0049 232 / 0.93);\n  --color-material-ultra-thick-dark: oklch(0.2 0.0049 232 / 0.93);\n  --color-material-thick-light: oklch(0.96 0.0049 232 / 0.85);\n  --color-material-thick-dark: oklch(0.2 0.0049 232 / 0.85);\n  --color-material-medium-light: oklch(0.96 0.0049 232 / 0.65);\n  --color-material-medium-dark: oklch(0.2 0.0049 232 / 0.8);\n  --color-material-thin-light: oklch(0.96 0.0049 232 / 0.6);\n  --color-material-thin-dark: oklch(0.2 0.0049 232 / 0.6);\n  --color-material-ultra-thin-light: oklch(0.96 0.0049 232 / 0.45);\n  --color-material-ultra-thin-dark: oklch(0.2 0.0049 232 / 0.45);\n  --color-material-opaque-light: oklch(0.96 0.0049 232);\n  --color-material-opaque-dark: oklch(0.2 0.0049 232);\n  --color-accent-light: oklch(0.65 0.18 238);\n  --color-accent-dark: oklch(0.7 0.16 237);\n  --color-primary-light: oklch(0.55 0.2 249);\n  --color-primary-dark: oklch(0.75 0.17 250);\n  --color-secondary-light: oklch(0.77 0.14 171);\n  --color-secondary-dark: oklch(0.8 0.14 170);\n}\n\n@layer theme {\n  :root {\n    /* Dark mode overrides */\n    @variant dark {\n      --color-blue: oklch(0.7 0.16 237);\n      --color-pink: oklch(0.73 0.2 350);\n      --color-purple: oklch(0.7 0.18 280);\n      --color-green: oklch(0.72 0.16 155);\n      --color-orange: oklch(0.73 0.16 60);\n      --color-yellow: oklch(0.85 0.18 100);\n      --color-sky: oklch(0.75 0.13 210);\n      --color-red: oklch(0.7 0.21 27);\n      --color-brown: oklch(0.67 0.12 45);\n      --color-gray: oklch(0.7 0 0);\n      --color-neutral: oklch(0.652 0 0);\n      --color-black: oklch(0.25 0 0);\n      --color-white: oklch(0.97 0 0);\n      --color-teal: oklch(0.71 0.15 180);\n      --color-cyan: oklch(0.75 0.14 195);\n      --color-indigo: oklch(0.65 0.18 260);\n      --color-violet: oklch(0.68 0.2 300);\n      --color-lime: oklch(0.78 0.17 125);\n      --color-emerald: oklch(0.69 0.15 160);\n      --color-amber: oklch(0.78 0.14 85);\n      --color-rose: oklch(0.68 0.19 15);\n      --color-slate: oklch(0.6 0.015 240);\n      --color-zinc: oklch(0.63 0.01 240);\n      --color-gray1: oklch(0.2 0 0);\n      --color-gray2: oklch(0.25 0 0);\n      --color-gray3: oklch(0.3 0 0);\n      --color-gray4: oklch(0.35 0 0);\n      --color-gray5: oklch(0.4 0 0);\n      --color-gray6: oklch(0.451 0 0);\n      --color-gray7: oklch(0.501 0 0);\n      --color-gray8: oklch(0.551 0 0);\n      --color-gray9: oklch(0.601 0 0);\n      --color-gray10: oklch(0.651 0 0);\n      --color-text: oklch(0.95 0.0049 230);\n      --color-text-secondary: oklch(0.75 0.0049 230);\n      --color-text-tertiary: oklch(0.65 0.0049 230);\n      --color-text-quaternary: oklch(0.451 0.0049 230);\n      --color-placeholder-text: oklch(0.6 0.0049 226);\n      --color-border: oklch(0.35 0.0049 234);\n      --color-border-secondary: oklch(0.3 0.0049 234);\n      --color-separator: oklch(0.33 0.0049 236);\n      --color-link: oklch(0.78 0.14 250);\n      --color-disabled-control: oklch(0.27 0.0049 226);\n      --color-disabled-text: oklch(0.55 0.0049 226);\n      --color-background: oklch(0.22 0.0049 230);\n      --color-background-secondary: oklch(0.26 0.0049 230);\n      --color-background-tertiary: oklch(0.3 0.0049 230);\n      --color-background-quaternary: oklch(0.34 0.0049 230);\n      --color-background-quinary: oklch(0.38 0.0049 230);\n      --color-fill: oklch(0.3 0.0049 228);\n      --color-fill-secondary: oklch(0.35 0.0049 228);\n      --color-fill-tertiary: oklch(0.4 0.0049 228);\n      --color-fill-quaternary: oklch(0.45 0.0049 228);\n      --color-material-ultra-thick: oklch(0.2 0.0049 232 / 0.93);\n      --color-material-thick: oklch(0.2 0.0049 232 / 0.85);\n      --color-material-medium: oklch(0.2 0.0049 232 / 0.8);\n      --color-material-thin: oklch(0.2 0.0049 232 / 0.6);\n      --color-material-ultra-thin: oklch(0.2 0.0049 232 / 0.45);\n      --color-material-opaque: oklch(0.2 0.0049 232);\n      --color-accent: oklch(0.7 0.16 237);\n      --color-primary: oklch(0.75 0.17 250);\n      --color-secondary: oklch(0.8 0.14 170);\n    }\n  }\n}\n\n@layer theme {\n  [data-contrast='low'],\n  [data-contrast='low'] * {\n    /* Kawaii color overrides */\n    --color-blue: oklch(0.75 0.12 237);\n    --color-pink: oklch(0.78 0.16 350);\n    --color-purple: oklch(0.75 0.14 280);\n    --color-green: oklch(0.76 0.12 155);\n    --color-orange: oklch(0.77 0.12 60);\n    --color-yellow: oklch(0.84 0.1 100);\n    --color-sky: oklch(0.78 0.11 210);\n    --color-red: oklch(0.75 0.14 20);\n    --color-brown: oklch(0.74 0.1 45);\n    --color-gray: oklch(0.8 0 0);\n    --color-neutral: oklch(0.76 0 0);\n    --color-black: oklch(0.3 0 0);\n    --color-white: oklch(0.999 0 0);\n    --color-teal: oklch(0.78 0.1 180);\n    --color-cyan: oklch(0.8 0.09 195);\n    --color-indigo: oklch(0.75 0.11 260);\n    --color-violet: oklch(0.77 0.12 300);\n    --color-lime: oklch(0.82 0.08 125);\n    --color-emerald: oklch(0.78 0.1 160);\n    --color-amber: oklch(0.82 0.08 85);\n    --color-rose: oklch(0.78 0.13 15);\n    --color-slate: oklch(0.76 0.01 240);\n    --color-zinc: oklch(0.78 0.005 240);\n    --color-gray1: oklch(0.9 0 0);\n    --color-gray2: oklch(0.85 0 0);\n    --color-gray3: oklch(0.8 0 0);\n    --color-gray4: oklch(0.75 0 0);\n    --color-gray5: oklch(0.7 0 0);\n    --color-gray6: oklch(0.65 0 0);\n    --color-gray7: oklch(0.6 0 0);\n    --color-gray8: oklch(0.55 0 0);\n    --color-gray9: oklch(0.5 0 0);\n    --color-gray10: oklch(0.45 0 0);\n    --color-text: oklch(0.15 0.02 320);\n    --color-text-secondary: oklch(0.3 0.02 320);\n    --color-text-tertiary: oklch(0.5 0.01 320);\n    --color-text-quaternary: oklch(0.75 0.01 320);\n    --color-placeholder-text: oklch(0.65 0.02 320);\n    --color-border: oklch(0.92 0.015 330);\n    --color-border-secondary: oklch(0.95 0.008 330);\n    --color-separator: oklch(0.94 0.008 330);\n    --color-link: oklch(0.86 0.0617 256.24);\n    --color-disabled-control: oklch(0.92 0.0049 338.82);\n    --color-disabled-text: oklch(0.7 0.0049 338.82);\n    --color-background: oklch(0.986 0 358.73967248753775);\n    --color-background-secondary: oklch(0.978 0.004 358.73967248753775);\n    --color-background-tertiary: oklch(0.97 0.005 358.73967248753775);\n    --color-background-quaternary: oklch(0.962 0.006 358.73967248753775);\n    --color-background-quinary: oklch(0.954 0.01 358.73967248753775);\n    --color-fill: oklch(0.964 0.0049 338.82);\n    --color-fill-secondary: oklch(0.952 0.0049 338.82);\n    --color-fill-tertiary: oklch(0.94 0.0049 338.82);\n    --color-fill-quaternary: oklch(0.928 0.0049 338.82);\n    --color-material-ultra-thick: oklch(0.956 0.0049 338.82 / 0.93);\n    --color-material-thick: oklch(0.952 0.0049 338.82 / 0.85);\n    --color-material-medium: oklch(0.948 0.0049 338.82 / 0.65);\n    --color-material-thin: oklch(0.944 0.0049 338.82 / 0.6);\n    --color-material-ultra-thin: oklch(0.94 0.0049 338.82 / 0.45);\n    --color-material-opaque: oklch(0.936 0.0049 338.82);\n    --color-accent: oklch(0.71 0.14 237);\n    --color-primary: oklch(0.68 0.14 237);\n    --color-secondary: oklch(0.7486 0.1168 187.91);\n\n    /* Kawaii dark mode overrides */\n    @variant dark {\n      --color-blue: oklch(0.65 0.14 237);\n      --color-pink: oklch(0.7 0.18 350);\n      --color-purple: oklch(0.67 0.16 280);\n      --color-green: oklch(0.68 0.14 155);\n      --color-orange: oklch(0.69 0.14 60);\n      --color-yellow: oklch(0.73 0.12 100);\n      --color-sky: oklch(0.7 0.13 210);\n      --color-red: oklch(0.67 0.16 20);\n      --color-brown: oklch(0.65 0.12 45);\n      --color-gray: oklch(0.7 0 0);\n      --color-neutral: oklch(0.65 0 0);\n      --color-black: oklch(0.5 0 0);\n      --color-white: oklch(0.95 0 0);\n      --color-teal: oklch(0.72 0.12 180);\n      --color-cyan: oklch(0.74 0.11 195);\n      --color-indigo: oklch(0.69 0.13 260);\n      --color-violet: oklch(0.71 0.14 300);\n      --color-lime: oklch(0.75 0.1 125);\n      --color-emerald: oklch(0.72 0.12 160);\n      --color-amber: oklch(0.76 0.1 85);\n      --color-rose: oklch(0.72 0.15 15);\n      --color-slate: oklch(0.7 0.01 240);\n      --color-zinc: oklch(0.71 0.005 240);\n      --color-gray1: oklch(0.2 0 0);\n      --color-gray2: oklch(0.25 0 0);\n      --color-gray3: oklch(0.3 0 0);\n      --color-gray4: oklch(0.35 0 0);\n      --color-gray5: oklch(0.4 0 0);\n      --color-gray6: oklch(0.451 0 0);\n      --color-gray7: oklch(0.501 0 0);\n      --color-gray8: oklch(0.551 0 0);\n      --color-gray9: oklch(0.601 0 0);\n      --color-gray10: oklch(0.651 0 0);\n      --color-text: oklch(0.95 0.01 320);\n      --color-text-secondary: oklch(0.85 0.01 320);\n      --color-text-tertiary: oklch(0.7 0.01 320);\n      --color-text-quaternary: oklch(0.55 0.01 320);\n      --color-placeholder-text: oklch(0.6 0.01 320);\n      --color-border: oklch(0.3 0.0049 338.82);\n      --color-border-secondary: oklch(0.25 0.0049 338.82);\n      --color-separator: oklch(0.28 0.0049 338.82);\n      --color-link: oklch(0.8959 0.0524530753637823 250.67881278919134);\n      --color-disabled-control: oklch(0.22 0.0049 338.82);\n      --color-disabled-text: oklch(0.5 0.0049 338.82);\n      --color-background: oklch(0.241 0.0049 338.82);\n      --color-background-secondary: oklch(0.253 0.0049 338.82);\n      --color-background-tertiary: oklch(0.265 0.0049 338.82);\n      --color-background-quaternary: oklch(0.277 0.0049 338.82);\n      --color-background-quinary: oklch(0.289 0.0049 338.82);\n      --color-fill: oklch(0.255 0.0049 338.82);\n      --color-fill-secondary: oklch(0.275 0.0049 338.82);\n      --color-fill-tertiary: oklch(0.295 0.0049 338.82);\n      --color-fill-quaternary: oklch(0.315 0.0049 338.82);\n      --color-material-ultra-thick: oklch(0.156 0.0049 338.82 / 0.93);\n      --color-material-thick: oklch(0.152 0.0049 338.82 / 0.85);\n      --color-material-medium: oklch(0.148 0.0049 338.82 / 0.8);\n      --color-material-thin: oklch(0.144 0.0049 338.82 / 0.6);\n      --color-material-ultra-thin: oklch(0.14 0.0049 338.82 / 0.45);\n      --color-material-opaque: oklch(0.136 0.0049 338.82);\n      --color-accent: oklch(0.67 0.14 237);\n      --color-primary: oklch(0.7 0.14 237);\n      --color-secondary: oklch(0.77 0.1168 187.91);\n    }\n  }\n}\n\n@layer theme {\n  [data-contrast='high'],\n  [data-contrast='high'] * {\n    /* High contrast color overrides */\n    --color-blue: oklch(0.45 0.25 237);\n    --color-pink: oklch(0.5 0.3 350);\n    --color-purple: oklch(0.45 0.28 280);\n    --color-green: oklch(0.5 0.2 155);\n    --color-orange: oklch(0.55 0.18 60);\n    --color-yellow: oklch(0.65 0.15 100);\n    --color-sky: oklch(0.5 0.2 210);\n    --color-red: oklch(0.5 0.28 20);\n    --color-brown: oklch(0.45 0.15 45);\n    --color-gray: oklch(0.3 0 0);\n    --color-neutral: oklch(0.25 0 0);\n    --color-black: oklch(0 0 0);\n    --color-white: oklch(1 0 0);\n    --color-teal: oklch(0.45 0.25 180);\n    --color-cyan: oklch(0.5 0.22 195);\n    --color-indigo: oklch(0.4 0.28 260);\n    --color-violet: oklch(0.42 0.3 300);\n    --color-lime: oklch(0.6 0.22 125);\n    --color-emerald: oklch(0.48 0.22 160);\n    --color-amber: oklch(0.62 0.18 85);\n    --color-rose: oklch(0.48 0.3 15);\n    --color-slate: oklch(0.35 0.02 240);\n    --color-zinc: oklch(0.38 0.01 240);\n    --color-gray1: oklch(0.9 0 0);\n    --color-gray2: oklch(0.85 0 0);\n    --color-gray3: oklch(0.8 0 0);\n    --color-gray4: oklch(0.75 0 0);\n    --color-gray5: oklch(0.7 0 0);\n    --color-gray6: oklch(0.65 0 0);\n    --color-gray7: oklch(0.6 0 0);\n    --color-gray8: oklch(0.55 0 0);\n    --color-gray9: oklch(0.5 0 0);\n    --color-gray10: oklch(0.45 0 0);\n    --color-text: oklch(0.12 0.02 200);\n    --color-text-secondary: oklch(0.25 0.02 200);\n    --color-text-tertiary: oklch(0.4 0.02 200);\n    --color-text-quaternary: oklch(0.55 0.015 200);\n    --color-placeholder-text: oklch(0.5 0.015 200);\n    --color-border: oklch(0.8 0.02 200);\n    --color-border-secondary: oklch(0.85 0.01 200);\n    --color-separator: oklch(0.83 0.01 200);\n    --color-link: oklch(0.35 0.3 200);\n    --color-disabled-control: oklch(0.7 0.01 200);\n    --color-disabled-text: oklch(0.6 0.01 200);\n    --color-background: oklch(1 0.005 200);\n    --color-background-secondary: oklch(0.985 0.003 200);\n    --color-background-tertiary: oklch(0.97 0.005 200);\n    --color-background-quaternary: oklch(0.955 0.005 200);\n    --color-background-quinary: oklch(0.94 0.005 200);\n    --color-fill: oklch(0.66 0.005 200);\n    --color-fill-secondary: oklch(0.52 0.01 200);\n    --color-fill-tertiary: oklch(0.38 0.01 200);\n    --color-fill-quaternary: oklch(0.24 0.005 200);\n    --color-material-ultra-thick: oklch(0.98 0 0 / 0.95);\n    --color-material-thick: oklch(0.96 0 0 / 0.88);\n    --color-material-medium: oklch(0.94 0 0 / 0.7);\n    --color-material-thin: oklch(0.92 0 0 / 0.65);\n    --color-material-ultra-thin: oklch(0.9 0 0 / 0.5);\n    --color-material-opaque: oklch(0.949 0 0);\n    --color-accent: oklch(0.45 0.25 238);\n    --color-primary: oklch(0.4 0.28 261);\n    --color-secondary: oklch(0.5 0.2 156);\n\n    /* High contrast dark mode overrides */\n    @variant dark {\n      --color-blue: oklch(0.75 0.2 237);\n      --color-pink: oklch(0.78 0.25 350);\n      --color-purple: oklch(0.75 0.22 280);\n      --color-green: oklch(0.77 0.18 155);\n      --color-orange: oklch(0.78 0.2 60);\n      --color-yellow: oklch(0.82 0.16 100);\n      --color-sky: oklch(0.8 0.16 210);\n      --color-red: oklch(0.75 0.24 20);\n      --color-brown: oklch(0.72 0.14 45);\n      --color-gray: oklch(0.85 0 0);\n      --color-neutral: oklch(0.9 0 0);\n      --color-black: oklch(0.2 0 0);\n      --color-white: oklch(0.95 0 0);\n      --color-teal: oklch(0.78 0.2 180);\n      --color-cyan: oklch(0.8 0.18 195);\n      --color-indigo: oklch(0.75 0.22 260);\n      --color-violet: oklch(0.78 0.24 300);\n      --color-lime: oklch(0.82 0.2 125);\n      --color-emerald: oklch(0.78 0.18 160);\n      --color-amber: oklch(0.83 0.17 85);\n      --color-rose: oklch(0.78 0.22 15);\n      --color-slate: oklch(0.82 0.02 240);\n      --color-zinc: oklch(0.8 0.01 240);\n      --color-gray1: oklch(0.2 0 0);\n      --color-gray2: oklch(0.25 0 0);\n      --color-gray3: oklch(0.3 0 0);\n      --color-gray4: oklch(0.35 0 0);\n      --color-gray5: oklch(0.4 0 0);\n      --color-gray6: oklch(0.451 0 0);\n      --color-gray7: oklch(0.501 0 0);\n      --color-gray8: oklch(0.551 0 0);\n      --color-gray9: oklch(0.601 0 0);\n      --color-gray10: oklch(0.651 0 0);\n      --color-text: oklch(0.98 0.005 200);\n      --color-text-secondary: oklch(0.93 0.01 200);\n      --color-text-tertiary: oklch(0.85 0.015 200);\n      --color-text-quaternary: oklch(0.75 0.01 200);\n      --color-placeholder-text: oklch(0.8 0.01 200);\n      --color-border: oklch(0.251 0.02 200);\n      --color-border-secondary: oklch(0.2 0.01 200);\n      --color-separator: oklch(0.23 0.01 200);\n      --color-link: oklch(0.85 0.2 200);\n      --color-disabled-control: oklch(0.4 0.01 200);\n      --color-disabled-text: oklch(0.5 0.01 200);\n      --color-background: oklch(0.08 0.005 200);\n      --color-background-secondary: oklch(0.095 0.008 200);\n      --color-background-tertiary: oklch(0.11 0.008 200);\n      --color-background-quaternary: oklch(0.125 0.008 200);\n      --color-background-quinary: oklch(0.14 0.008 200);\n      --color-fill: oklch(0.62 0.005 200);\n      --color-fill-secondary: oklch(0.7 0.005 200);\n      --color-fill-tertiary: oklch(0.78 0.005 200);\n      --color-fill-quaternary: oklch(0.86 0.005 200);\n      --color-material-ultra-thick: oklch(0.08 0 0 / 0.95);\n      --color-material-thick: oklch(0.12 0 0 / 0.88);\n      --color-material-medium: oklch(0.16 0 0 / 0.82);\n      --color-material-thin: oklch(0.18 0 0 / 0.65);\n      --color-material-ultra-thin: oklch(0.2 0 0 / 0.5);\n      --color-material-opaque: oklch(0.15 0 0);\n      --color-accent: oklch(0.75 0.2 236);\n      --color-primary: oklch(0.75 0.22 259);\n      --color-secondary: oklch(0.77 0.18 154);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"baseUrl\": \".\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"~/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\"node_modules\", \"packages/markdown\", \"./storybook\"]\n}\n"
  },
  {
    "path": "apps/landing/vite.config.ts",
    "content": "import { fileURLToPath } from 'node:url'\n\nimport { cloudflare } from '@cloudflare/vite-plugin'\nimport vinext from 'vinext'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n  plugins: [\n    vinext(),\n    cloudflare({\n      viteEnvironment: {\n        name: 'rsc',\n        childEnvironments: ['ssr'],\n      },\n    }),\n  ],\n  optimizeDeps: {\n    exclude: [\n      'next-intl',\n      'next-intl/navigation',\n      'next-intl/routing',\n      'use-intl',\n    ],\n    include: [\n      'react',\n      'react-dom',\n      'react/jsx-runtime',\n      'react/jsx-dev-runtime',\n    ],\n  },\n  resolve: {\n    alias: {\n      'next-intl/config': fileURLToPath(\n        new URL('src/i18n/request.ts', import.meta.url),\n      ),\n    },\n    dedupe: ['react', 'react-dom'],\n  },\n})\n"
  },
  {
    "path": "apps/landing/worker/index.js",
    "content": "import handler from 'vinext/server/app-router-entry'\nimport { handleImageOptimization } from 'vinext/server/image-optimization'\n\nexport default {\n  async fetch(request, env) {\n    const url = new URL(request.url)\n\n    if (url.pathname === '/_vinext/image') {\n      return handleImageOptimization(request, {\n        fetchAsset: (path) =>\n          env.ASSETS.fetch(new Request(new URL(path, request.url))),\n        transformImage: async (body, { width, format, quality }) => {\n          const result = await env.IMAGES.input(body)\n            .transform(width > 0 ? { width } : {})\n            .output({ format, quality })\n          return result.response()\n        },\n      })\n    }\n\n    return handler.fetch(request)\n  },\n}\n"
  },
  {
    "path": "apps/landing/wrangler.jsonc",
    "content": "{\n  \"$schema\": \"./node_modules/wrangler/config-schema.json\",\n  \"name\": \"landing-vinext\",\n  \"main\": \"./worker/index.js\",\n  \"compatibility_date\": \"2026-02-01\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"account_id\": \"1f1d1678a2413a54c944b3081bab5c84\",\n  \"assets\": {\n    \"directory\": \"./dist/client\",\n    \"binding\": \"ASSETS\",\n    \"not_found_handling\": \"none\",\n  },\n  \"images\": {\n    \"binding\": \"IMAGES\",\n  },\n  \"routes\": [\n    {\n      \"pattern\": \"folo.is/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n  ],\n  \"env\": {\n    \"dev\": {\n      \"name\": \"landing-next-dev\",\n      \"routes\": [\n        {\n          \"pattern\": \"landing.dev.folo.is/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n      ],\n    },\n  },\n}\n"
  },
  {
    "path": "apps/mobile/.env.example",
    "content": "EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN=\nSENTRY_AUTH_TOKEN=\n"
  },
  {
    "path": "apps/mobile/.gitignore",
    "content": "# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files\n\n# dependencies\nnode_modules/\n\n# Expo\n.expo/\ndist/\nweb-build/\nexpo-env.d.ts\n\n# Native\n*.orig.*\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n\n# Metro\n.metro-health-check*\n\n# debug\nnpm-debug.*\nyarn-debug.*\nyarn-error.*\n\n# macOS\n.DS_Store\n*.pem\n!code-signing/certificate.pem\n\n# local env files\n.env*.local\n\n# typescript\n*.tsbuildinfo\n\napp-example\n\n\n\nios/Pods\nandroid\n!e2e/flows/android/\n!e2e/flows/android/**\nPodfile.lock\n\nbuildServer.json\n\n.env.local\n"
  },
  {
    "path": "apps/mobile/.watchmanconfig",
    "content": "{\n  \"ignore_dirs\": [\"node_modules\", \"dist\", \".git\", \"ios\", \"android\", \"native\"]\n}\n"
  },
  {
    "path": "apps/mobile/AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides specific guidance for developing the React Native mobile application.\n\n## Architecture\n\n- **React Native** with Expo for cross-platform mobile development\n- **Native modules** in `native/` for platform-specific functionality\n- **Web views** in `web-app/` for HTML rendering within the mobile app\n- **Shared components** with the desktop app through `packages/internal/`\n\n## Development Commands\n\n```bash\n# Start Expo development server\npnpm run dev\n\n# Start with specific platform\npnpm run ios\npnpm run android\n\n# Clean rebuild (useful for native module changes)\npnpm expo prebuild --clean\n```\n\n## UIKit Colors for React Native Components\n\nFor react native components (`apps/mobile/**/*`), use Apple UIKit color system with Tailwind classes. **Important**: Always use the correct Tailwind prefix for each color category:\n\n### System Colors\n\n- Background: `system-background`, `secondary-system-background`, `tertiary-system-background`\n- Grouped: `system-grouped-background`, `secondary-system-grouped-background`, `tertiary-system-grouped-background`\n- Labels: `label`, `secondary-label`, `tertiary-label`, `quaternary-label`\n- Fills: `system-fill`, `secondary-system-fill`, `tertiary-system-fill`, `quaternary-system-fill`\n- Separators: `separator`, `opaque-separator`, `non-opaque-separator`\n\n### Semantic Colors\n\n- Core: `red`, `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`\n- Grays: `gray`, `gray2`, `gray3`, `gray4`, `gray5`, `gray6`\n- Interactive: `link`, `placeholder-text`\n\nThese colors automatically adapt to light/dark mode following Apple's design system. Remember to use the appropriate prefix (`text-`, `bg-`, `border-`) based on the CSS property you're styling.\n"
  },
  {
    "path": "apps/mobile/README.md",
    "content": "# Folo Mobile App 📱\n\nThis is the mobile client for [Folo](https://app.folo.is), built with [Expo](https://expo.dev). Folo organizes content into one timeline, keeping you updated on what matters, noise-free.\n\n## Features\n\n- Customized information hub for content discovery\n- AI-powered features like translation and summary\n- Dynamic content support (articles, videos, images, audio)\n- $POWER integration for creator economy\n- Cross-platform support (iOS & Android [WIP])\n- Modern UI design with native feel\n\n## Getting Started\n\n1. Enable Corepack (if not already enabled)\n\n   ```bash\n   corepack enable\n   ```\n\n2. Install dependencies\n\n   ```bash\n   pnpm install\n   ```\n\n3. Start the development server\n   ```bash\n   pnpm run dev\n   ```\n\nYou can run the app on:\n\n- iOS Simulator (`pnpm run ios`)\n- iOS Device (`pnpm run ios:device`)\n- Android Emulator (`pnpm run android`)\n- Development build for full native feature testing\n\n## Project Structure\n\n```\nsrc/\n├── modules/         # Feature-specific modules\n│   ├── discover/    # Discovery feed features\n│   └── ...\n├── screens/         # App screens using file-based routing\n└── ...\n```\n\n## Development\n\n- Built with Expo SDK [>=52]\n- Uses [Expo Router](https://docs.expo.dev/router/introduction/) for navigation\n- Styling with [NativeWind](https://www.nativewind.dev/)\n- State management with Jotai and Zustand\n- API integration with Tanstack Query\n- Full TypeScript support\n\n## Useful Resources\n\n- [Expo Documentation](https://docs.expo.dev/)\n- [NativeWind Documentation](https://www.nativewind.dev/)\n- [Main Project Repository](https://github.com/RSSNext/Follow)\n\n## Need Help?\n\n- Join our [Discord](https://discord.gg/AwWcAQ7euc)\n- Follow us on [Twitter](https://x.com/folo_is)\n- Contact the mobile development team\n\n## License\n\nThis project is licensed under the GNU Affero General Public License version 3. See the main project repository for full license details.\n"
  },
  {
    "path": "apps/mobile/app.config.ts",
    "content": "import type { ConfigContext, ExpoConfig } from \"expo/config\"\nimport { resolve } from \"pathe\"\n\nimport PKG from \"./package.json\"\n\n// const roundedIconPath = resolve(__dirname, \"../../resources/icon.png\")\nconst iconPathMap = {\n  production: resolve(__dirname, \"./assets/icon.png\"),\n  development: resolve(__dirname, \"./assets/icon-dev.png\"),\n  \"ios-simulator\": resolve(__dirname, \"./assets/icon-dev.png\"),\n  preview: resolve(__dirname, \"./assets/icon-staging.png\"),\n} as Record<string, string>\nconst iconPath = iconPathMap[process.env.PROFILE || \"production\"] || iconPathMap.production\n\nconst adaptiveIconPath = resolve(__dirname, \"./assets/adaptive-icon.png\")\nconst splashIconPath = resolve(__dirname, \"./assets/splash-icon.png\")\n\nconst isDev = process.env.NODE_ENV === \"development\"\n\nexport default ({ config }: ConfigContext): ExpoConfig => {\n  const result: ExpoConfig = {\n    ...config,\n\n    extra: {\n      eas: {\n        projectId: \"a6335b14-fb84-45aa-ba80-6f6ab8926920\",\n      },\n      e2eEnvProfile: process.env.EXPO_PUBLIC_E2E_ENV_PROFILE ?? null,\n      e2eLanguage: process.env.EXPO_PUBLIC_E2E_LANGUAGE ?? null,\n    },\n    owner: \"follow\",\n    // disable expo updates for now, https://github.com/expo/expo/issues/29630\n    // updates: {\n    //   url: \"https://folo-custom-expo-updates.vercel.app/api/manifest\",\n    //   codeSigningCertificate: \"./code-signing/certificate.pem\",\n    //   codeSigningMetadata: {\n    //     keyid: \"main\",\n    //     alg: \"rsa-v1_5-sha256\",\n    //   },\n    // },\n    runtimeVersion: isDev ? \"0.0.0-dev\" : PKG.version,\n\n    name: \"Folo\",\n    slug: \"follow\",\n    version: PKG.version,\n    orientation: \"portrait\" as const,\n    icon: iconPath,\n    scheme: [\"follow\", \"folo\"],\n    userInterfaceStyle: \"automatic\" as const,\n    newArchEnabled: true,\n    ios: {\n      supportsTablet: true,\n      bundleIdentifier: \"is.follow\",\n      usesAppleSignIn: true,\n      infoPlist: {\n        LSApplicationCategoryType: \"public.app-category.news\",\n        ITSAppUsesNonExemptEncryption: false,\n        UIBackgroundModes: [\"audio\"],\n        LSApplicationQueriesSchemes: [\"bilibili\", \"youtube\"],\n        CFBundleAllowMixedLocalizations: true,\n        // apps/mobile/src/@types/constants.ts currentSupportedLanguages\n        CFBundleLocalizations: [\"en\", \"ja\", \"zh-CN\", \"zh-TW\", \"fr-FR\"],\n        CFBundleDevelopmentRegion: \"en\",\n      },\n      googleServicesFile: \"./build/GoogleService-Info.plist\",\n    },\n    android: {\n      package: \"is.follow\",\n      // Suppress warning about EDGE_TO_EDGE_PLUGIN\n      // Learn more: https://expo.dev/blog/edge-to-edge-display-now-streamlined-for-android\n      edgeToEdgeEnabled: true,\n      adaptiveIcon: {\n        foregroundImage: adaptiveIconPath,\n        monochromeImage: adaptiveIconPath,\n        backgroundColor: \"#FF5C00\",\n      },\n      googleServicesFile: \"./build/google-services.json\",\n    },\n    androidStatusBar: {\n      translucent: true,\n    },\n    // web: {\n    //   bundler: \"metro\",\n    //   output: \"static\",\n    //   favicon: iconPath,\n    // },\n    plugins: [\n      [\n        \"expo-document-picker\",\n        {\n          iCloudContainerEnvironment: \"Production\",\n        },\n      ],\n      \"expo-localization\",\n      [\n        \"expo-splash-screen\",\n        {\n          android: {\n            image: splashIconPath,\n            imageWidth: 200,\n          },\n        },\n      ],\n      [\n        \"expo-build-properties\",\n        {\n          ios: {\n            useFrameworks: \"static\",\n          },\n        },\n      ],\n      \"expo-sqlite\",\n      [\n        \"expo-media-library\",\n        {\n          photosPermission: \"Allow $(PRODUCT_NAME) to access your photos.\",\n          savePhotosPermission: \"Allow $(PRODUCT_NAME) to save photos.\",\n          isAccessMediaLocationEnabled: true,\n        },\n      ],\n      \"expo-apple-authentication\",\n      \"expo-web-browser\",\n      [\n        \"expo-video\",\n        {\n          supportsBackgroundPlayback: true,\n          supportsPictureInPicture: true,\n        },\n      ],\n      [\n        require(\"./plugins/with-follow-assets.js\"),\n        {\n          // Add asset directory paths, the plugin copies the files in the given paths to the app bundle folder named Assets\n          assetsPath: resolve(__dirname, \"..\", \"..\", \"out\", \"rn-web\"),\n        },\n      ],\n\n      require(\"./plugins/with-gradle-jvm-heap-size-increase.js\"),\n      require(\"./plugins/with-android-jdk-21.js\"),\n      require(\"./plugins/with-android-manifest-plugin.js\"),\n      \"expo-secure-store\",\n      \"@react-native-firebase/app\",\n      [\n        \"expo-image-picker\",\n        {\n          photosPermission: \"Allow $(PRODUCT_NAME) to access your photos.\",\n        },\n      ],\n      [\n        \"expo-notifications\",\n        {\n          enableBackgroundRemoteNotifications: true,\n        },\n      ],\n      \"expo-background-task\",\n    ],\n  }\n\n  if (process.env.PROFILE !== \"production\") {\n    result.plugins ||= []\n    result.plugins.push(require(\"./plugins/android-trust-user-certs.js\"))\n  }\n\n  return result\n}\n"
  },
  {
    "path": "apps/mobile/babel.config.js",
    "content": "module.exports = function (api) {\n  api.cache(true)\n  return {\n    presets: [\n      [\"babel-preset-expo\", { jsxImportSource: \"nativewind\", unstable_transformImportMeta: true }],\n      \"nativewind/babel\",\n    ],\n    plugins: [[\"inline-import\", { extensions: [\".sql\"] }], \"react-native-worklets/plugin\"],\n  }\n}\n"
  },
  {
    "path": "apps/mobile/build/GoogleService-Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>API_KEY</key>\n\t<string>AIzaSyBl6-TXvw5F0CX1r-935w0x_xPbVr0SSbQ</string>\n\t<key>GCM_SENDER_ID</key>\n\t<string>992336953943</string>\n\t<key>PLIST_VERSION</key>\n\t<string>1</string>\n\t<key>BUNDLE_ID</key>\n\t<string>is.follow</string>\n\t<key>PROJECT_ID</key>\n\t<string>diygod-folo</string>\n\t<key>STORAGE_BUCKET</key>\n\t<string>diygod-folo.firebasestorage.app</string>\n\t<key>IS_ADS_ENABLED</key>\n\t<false></false>\n\t<key>IS_ANALYTICS_ENABLED</key>\n\t<false></false>\n\t<key>IS_APPINVITE_ENABLED</key>\n\t<true></true>\n\t<key>IS_GCM_ENABLED</key>\n\t<true></true>\n\t<key>IS_SIGNIN_ENABLED</key>\n\t<true></true>\n\t<key>GOOGLE_APP_ID</key>\n\t<string>1:992336953943:ios:75a436f716af190dc11912</string>\n</dict>\n</plist>"
  },
  {
    "path": "apps/mobile/build/google-services.json",
    "content": "{\n  \"project_info\": {\n    \"project_number\": \"992336953943\",\n    \"project_id\": \"diygod-folo\",\n    \"storage_bucket\": \"diygod-folo.firebasestorage.app\"\n  },\n  \"client\": [\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"1:992336953943:android:7e45e76b1a8986e1c11912\",\n        \"android_client_info\": {\n          \"package_name\": \"is.follow\"\n        }\n      },\n      \"oauth_client\": [\n        {\n          \"client_id\": \"992336953943-3rja1g44ep4tmsmv84mkaa6itv9ejrsi.apps.googleusercontent.com\",\n          \"client_type\": 3\n        }\n      ],\n      \"api_key\": [\n        {\n          \"current_key\": \"AIzaSyC7X-OdrxXWogeQwVDIFNkZzy81TINa7Fs\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": [\n            {\n              \"client_id\": \"992336953943-3rja1g44ep4tmsmv84mkaa6itv9ejrsi.apps.googleusercontent.com\",\n              \"client_type\": 3\n            }\n          ]\n        }\n      }\n    }\n  ],\n  \"configuration_version\": \"1\"\n}\n"
  },
  {
    "path": "apps/mobile/bump.config.ts",
    "content": "/* eslint-disable no-template-curly-in-string */\nimport { defineConfig } from \"nbump\"\n\nexport default defineConfig({\n  leading: [\n    \"git pull --rebase\",\n    \"tsx scripts/apply-changelog.ts ${NEW_VERSION}\",\n    \"git add changelog\",\n    \"pnpm eslint --fix package.json\",\n    \"pnpm prettier --ignore-unknown --write package.json\",\n    \"git add package.json\",\n  ],\n  trailing: [\n    \"plutil -replace CFBundleShortVersionString -string ${NEW_VERSION} ios/Folo/Info.plist\",\n    \"CURRENT_BUILD=$(plutil -extract CFBundleVersion raw ios/Folo/Info.plist) && plutil -replace CFBundleVersion -string $((CURRENT_BUILD + 1)) ios/Folo/Info.plist\",\n    \"git add ios/Folo/Info.plist\",\n    \"git checkout -b release/mobile/${NEW_VERSION}\",\n  ],\n  finally: [\n    \"git push origin release/mobile/${NEW_VERSION}\",\n    \"gh pr create --title 'release(mobile): Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base mobile-main --head release/mobile/${NEW_VERSION}\",\n  ],\n  push: false,\n  commitMessage: \"release(mobile): release v${NEW_VERSION}\",\n  tagPrefix: \"mobile@\",\n  tag: false,\n  changelog: false,\n  allowedBranches: [\"dev\"],\n})\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.3.md",
    "content": "# What's new in v0.1.3\n\n## New Features\n\n- New global settings for AI summary and translation (#3294)\n- Integrate AI translation support (#3294)\n- Introduce an invite code input field along with a helpful prompt (359093d)\n\n## Improvements\n\n- Navigate back to the previous page with a simple swipe, eliminating the need to start from the far left (81fc94c)\n- Streamline the app architecture and enhance overall navigation (359093d)\n- Boost image sharing capabilities by adding URL support (1cd67cd)\n- Ensure compatibility with older iOS versions for a smoother experience (62e8382)\n- Update the MGC Icon to v1.36 (#3310)\n- Adjust the email section margin in the edit-email screen for better layout (2558fe6)\n\n## Bug Fixes\n\n- Fix the issue where the image preview could not be dismissed if an image load error occurred (626e54d)\n- Resolve header title overflow problems (0bee60c)\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.4.md",
    "content": "# What's New in v0.1.4\n\n## New Features\n\n- Added support for i18n in 20 languages (#3331)\n- Enabled viewing and generating invitation codes (92904be)\n- Introduced a \"Mark Above as Read\" button at the bottom of the entry list (d0a8f6b)\n- Implemented long press functionality to mark entries as unread (1ceeed5)\n\n## Improvements\n\n- AI summaries can now be copied (59b8e91)\n- Optimized the display for empty entry lists (d237ad1)\n- Added a loading indicator for entry list (d5b579f)\n- Improved layout for picture, video, and notification entries (da7bfef)\n- Display a background color while images are loading (0013837)\n- Automatically scrolls the entry list to the top when refetching (9de4425) (9d64dbb)\n- Refined the display logic of the top view selector (4ea69b9) (24834f5)\n- Streamlined the entry header design (97da078)\n- Integrated a more advanced AI model for enhanced performance\n- Excluded entries without images from the picture view for a streamlined browsing experience\n\n## Bug Fixes\n\n- Resolved a race condition that occurred during frequent horizontal scrolling of the timeline\n- Displayed title and translation in the grid footer (#3351)\n- Fixed an issue preventing HTTP images from loading (0013837)\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.5.md",
    "content": "# What's New in v0.1.5\n\n## Shiny new things\n\n- Added configurable notifications for new entries (#3424)\n- Introduced unread count badges (#3424)\n- Integrated document links into the actions page (7e553db)\n- Enabled marking feeds or categories as read (53b2136)\n- Provided an option to display detailed unread counts (#3453)\n\n## Improvements\n\n- Enhanced AI summary styles (87aa062)\n- Now displays an error message when a feed request fails (#3455)\n- Refined the timeline list separator style (b1c356f)\n- Concealed the share button when not applicable to a feed (#3424)\n- Upgraded toast styles (83e7764)\n- Set AI summary to be enabled by default (8da8a71)\n- Achieved significant performance improvements (23eaf86)\n\n## No longer broken\n\n- Resolved issues preventing sharing of feed URLs (#3424)\n- Corrected the subscription list to accurately distinguish categories with identical names across different views (d70f082)\n- Eliminated duplicated menu options (f24f21a)\n- Adjusted entry text positions to avoid occasional misplacement (17dd1e7)\n- Prevented view selector blinking when pressed (d66d49c)\n- Fixed a crash caused by loading specific images (7b20633)\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.6.md",
    "content": "# What's New in v0.1.6\n\n## Shiny new things\n\n- Server‑side readability with AI summaries and instant translation (#3498)\n- Discover page completely overhauled for a vibrant, streamlined experience (cce400c)\n- Added openLinksInExternalApp setting to let you choose where links open (61c099b)\n\n## Improvements\n\n- React re‑renders cut by 10×, delivering lightning‑fast, buttery‑smooth interactions (22964c7, a758e43)\n- Added action icons and refreshed share & AI icons for a cleaner interface (ece2637, 3bc6585)\n\n## No longer broken\n\n- Fixed an issue that prevented video preview images from loading (bcf752b)\n- Resolved the iOS swipe‑back gesture conflict with React Native’s Pressable, restoring silky‑smooth navigation\n- Corrected a bug where some images failed to display in the entry body (4654dc9)\n- Fixed an issue causing entry timestamps to display incorrectly in the timeline (4ee5944)\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.7.md",
    "content": "# What's New in v0.1.7\n\n## Shiny new things\n\n- Added status action that allows you to send notifications or trigger webhooks for starred entries (a4ce91d)\n- Enabled video playback in the Social Media view (#3512)\n\n## Improvements\n\n- Improved layout and styling consistency (017d986)\n- Redesigned the search module on the Discover page\n\n## No longer broken\n\n- Hid the native floating tab bar on iPad (769df4c)\n- Fixed an issue where image BlurHash thumbnails were rendered at an overly small size (bf8e3b0)\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.8.md",
    "content": "# What's New in v0.1.8\n\n## Shiny new things\n\n- Choose whether translations appear on their own or side-by-side with the original text (#3568)\n- “Mark above / below as read” context menu (#3570)\n- Automation actions: auto start entries that match your rules (#3625)\n- Invite-code gates removed – no invite code needed anymore (cee647c)\n\n## Improvements\n\n- AI summaries are now selectable, so you can copy text (0ffde43)\n- Feeds from the same site are automatically grouped (c418dea)\n- Private subscriptions now show a icon for quick identification (9d39711)\n- Removed long-unmaintained language packs (f48697c)\n- Faster search performance\n- Readability now auto-activates for blank content (a1a9521)\n- Upgraded to React 19 and React Native 0.79 (#3661)\n- Dozens of visual polish tweaks across the app\n\n## No longer broken\n\n- Fixed AI Summary blocking scroll (41484b5)\n- Unread badge count now stays accurate (26a3d83)\n- Corrected AI Summary height (8bd186a)\n- Social accounts can again be unlinked (5114155)\n"
  },
  {
    "path": "apps/mobile/changelog/0.1.9.md",
    "content": "# What's New in v0.1.9\n\n## Shiny new things\n\n- New option: hide subscriptions that have no unread entries. Enable it via Settings → General → Subscriptions/Hide Read. (1b88d72)\n- Trending Feeds now appear on the Discover page and during onboarding. (fc7b37d)\n\n## Improvements\n\n- Clearer error messages for RSSHub feed issues. (ed95fb6b)\n- AI summary is now enabled by default. (f329ae9)\n- Displays a fallback icon if a feed icon fails to load. (93f19c4)\n- Streamlined login and sign-up flow. (e392e59)\n\n## No longer broken\n\n- Tapping the avatar now navigates correctly. (5a9af9f)\n- Entries without images now display properly in the video view. (4119b9a)\n- Fixed an issue where some feeds didn’t appear in search results. (74f7d52)\n- Unread indicators now show correctly in both image and video views. (2244dc7)\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.0.md",
    "content": "# What's New in v0.2.0\n\n## Shiny new things\n\n- Video view now displays the video’s total duration (2234b4b)\n- Added the option to hide private subscriptions in the Timeline: `Settings → General → Subscriptions → Hide Private` (#3773)\n- Introduced pull-up to next for effortless, continuous reading (#3760)\n\n## Improvements\n\n- Discover page now remembers your language preference (43d07e4)\n- Added compatibility for both `folo` and `follow` URI schemes (4fa171b)\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.1.md",
    "content": "# What's New in v0.2.1\n\n## Shiny new things\n\n- Video playback is now supported in both the entry list and entry view (#3798 #3816)\n- AI summaries can be rendered in Markdown (bf7193d)\n\n## Improvements\n\n- Gradually rolling out an experimental unified local database for mobile and desktop (#3809)\n- Added a “Mark as read” option to the long-press menu in lists (7c4b896)\n- RSSHub routes list now shows popularity scores and sorts by them (9c3457a)\n- Polished the RSSHub route detail page and added “Top Feeds” (6a51c8f)\n- Feed-subscription page now displays feed analytics (2c27d91)\n- Trending list now shows subscriber counts (bdb6802)\n- AI will skip summarising extra-short articles to avoid trivial summaries (63388b4)\n\n## No longer broken\n\n- AI-summary component now correctly spans full width (2612f9)\n- Fixed icon-colour issues in dark mode (c7be63b)\n- Fixed misaligned icons (75c9f80)\n- Fixed occasional transparency on the bottom navigation bar (#3867)\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.10.md",
    "content": "# What's New in v0.2.10\n\n## Shiny new things\n\n## Improvements\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.2.md",
    "content": "# What's New in v0.2.2\n\n## Shiny New Things\n\n- Copy AI summaries with one tap\n- Use video duration as an Action condition\n\n## Improvements\n\n- Top indicator now fades smoothly when switching views (fa78bad)\n- Gradually rolling out an experimental unified local database for mobile and desktop (#3897 #3902)\n- Improved image preview\n- Refined Android dropdown-menu styling\n- Fixed star/unstar status not syncing across devices (fbd0b3)\n\n## No Longer Broken\n\n- Fixed login failures caused by corrupted cache in some cases (2bbe512)\n- Fixed inability to follow sources in preview mode (2c04919)\n- Fixed profile-picture upload not working\n\n## Thanks\n\nSpecial thanks to volunteer contributors @kovsu @huanfe1 @cscnk52 @Olexandr88 @0-o0 @kingsword09 @ericyzhu for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.3.md",
    "content": "# What's New in v0.2.3\n\n## Shiny new things\n\n- Tap an image to open it in a zoomable lightbox (#3995)\n- Added Android adaptive-icon support (#4058)\n\n## Improvements\n\n- Smoother selection menus on Android (#4010)\n- Faster unread-indicator animation when switching views (86d75b9)\n- Extra vertical spacing in Images and Videos views (66591e4)\n- Introduced an imperative modal on Android (#4041)\n- Quicker copying of AI summaries (63d3df5)\n- Videos no longer auto-play (79c9b05)\n\n## No longer broken\n\n- Fixed a crash caused by certain image blurhashes (ef40845)\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ericyzhu @cscnk52 @kovsu @Fatpandac @dai @LitoMore @kira-offgrid @AkaShark @RtYkk for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.4.md",
    "content": "# What's New in v0.2.4\n\n## Shiny new things\n\n- Implemented pull-up navigation on Android(#4228)\n- Added smart default view type detection in the follow feed screen(71408ea)\n- Support custom text size, go to Setting - Appearance to adjust the text size(38553b)\n- Preview trending feed's entries before subscribing.\n- Hide From Timeline option for subscriptions to make your timeline cleaner.\n\n## Improvements\n\n- Clearer loading indicators for entry list. (4ebff4d)\n- Better splash icon for Android.\n- Notification support for inbox.\n\n## No longer broken\n\n- Pull‑to‑refresh no longer incorrectly marks unread items as read. (e7c8380)\n- Fixed navigation direction when clicking view switch button on iOS(ad7c5dd)\n- Fixed audio playback issues on Android(#4188)\n- Fixed occasional crashes on Android(#4233)\n- Fixed can not open videos in the external app.\n- Fixed custom feed title not being respected.\n- Feeds with invalid site URLs being hidden in the subscription list.\n- List with unread entries being hidden when Hide Read option is enabled.\n- Inbox can not receive text only emails like Gmail Forward verification.\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.5.md",
    "content": "# What's New in v0.2.5\n\n## Shiny new things\n\n- Add option for independent content font size and presets\n\n## Improvements\n\n## No longer broken\n\n- Address the issue of being unable to follow the feed in specific scenarios\n- Fixed the issue where entries opened from notifications could not be loaded\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.6.md",
    "content": "# What's New in v0.2.6\n\nFixed several bugs, improved stability.\n"
  },
  {
    "path": "apps/mobile/changelog/0.2.8.md",
    "content": "# What's New in v0.2.8\n\n## Shiny new things\n\n## Improvements\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/0.3.0.md",
    "content": "# What's New in v0.3.0\n\n## Shiny new things\n\n- Liquid glass tab bar for iOS 26\n- Enhanced blur effects with Expo Glass Effect\n- Dynamic tab bar height adjustment with NativeTabBarHolder\n- Migrated error tracking to PostHog\n- Supported French localization\n\n## Improvements\n\n- Upgraded to Expo SDK 54 for better performance and compatibility\n- Rebalanced typography sizes for improved readability\n- Migrated swipe interactions to ReanimatedSwipeable for smoother experience\n- Simplified animation configurations in dialog and player controls\n\n## No longer broken\n\n- Reduced timeline indicator jank on iOS\n- Stabilized timeline initial load and scroll position\n- Fixed Crashlytics crash issues affecting 101+ users\n- Fixed iOS SQLite startup crash\n- Aligned header height with safe area inset\n- Fixed full mobile UI consistency and stability issues\n- Hidden payment UI on non-APK builds\n- Fixed safe area insets for iOS 26 tab bar\n"
  },
  {
    "path": "apps/mobile/changelog/0.4.0.md",
    "content": "# What's New in v0.4.0\n\n## Shiny new things\n\n- Added Apple subscriptions on iOS\n- Supported anonymous timeline reading without signing in\n- Refined upgrade prompts to match desktop subscription flows\n- Added in-app review prompts\n- Supported French localization\n\n## Improvements\n\n- Polished settings and Discover UI across iOS and Android\n- Improved subscription and plan management surfaces\n- Hardened authentication flows and expanded end-to-end coverage\n- Aligned translation gating with actual user roles\n\n## No longer broken\n\n- Fixed discover category loading and iOS auth bootstrap issues\n- Fixed iOS subscription upgrade actions\n- Fixed Android modal header overlap\n- Stabilized sign-in persistence across mobile auth flows\n\n## Thanks\n\nSpecial thanks to volunteer contributors for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/next.md",
    "content": "# What's New in vNEXT_VERSION\n\n## Shiny new things\n\n## Improvements\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/changelog/next.template.md",
    "content": "# What's New in vNEXT_VERSION\n\n## Shiny new things\n\n## Improvements\n\n## No longer broken\n\n## Thanks\n\nSpecial thanks to volunteer contributors @ for their valuable contributions\n"
  },
  {
    "path": "apps/mobile/e2e/README.md",
    "content": "# Mobile E2E\n\n## Requirements\n\n- Install Maestro CLI.\n- Android: install the app on a booted emulator.\n- iOS: provide a standalone simulator app bundle via `MAESTRO_IOS_APP_PATH`, or place a local `build-*.tar.gz` from `eas build --local --platform ios --profile e2e-ios-simulator` in `apps/mobile`.\n- Export `E2E_EMAIL` if you want to reuse a fixed account. Otherwise the runner script creates a unique address.\n\n## Commands\n\n```bash\npnpm run e2e:doctor\npnpm run e2e:android\npnpm run e2e:ios\npnpm run e2e:ios:bootstrap\n```\n\n## iOS Notes\n\n- `pnpm run e2e:ios` runs two real journeys in sequence:\n  - `auth.yaml`: register -> sign out -> log in\n  - `content.yaml`: ensure onboarding feed unfollowed -> follow -> timeline/read-unread -> unfollow\n- The iOS runner resets the simulator, disables password autofill prompts, installs the provided app bundle, then executes Maestro.\n\n## Prod iOS auth bootstrap\n\nWhen non-auth iOS self-tests need a signed-in simulator quickly, bootstrap auth through the standard iOS runner mode:\n\n```bash\npnpm run e2e:ios:bootstrap\n```\n\nThis mode uses the auth bootstrap helper on iOS when `EXPO_PUBLIC_E2E_ENV_PROFILE=prod` or `EXPO_PUBLIC_E2E_ENV_PROFILE=local`, and falls back to the normal iOS registration flow for other environments.\n\nOptional environment variables:\n\n- `E2E_EMAIL`\n- `E2E_PASSWORD`\n- `MAESTRO_IOS_DEVICE_ID`\n- `E2E_API_URL`\n- `E2E_CALLBACK_URL`\n- `E2E_BUNDLE_ID`\n\nThe bootstrap script signs in against prod using the mobile fallback token header, writes the auth cookie into the simulator's `ExpoSQLiteStorage` fallback store, and relaunches the app.\n\n## Environment\n\n- `E2E_EMAIL`\n- `E2E_PASSWORD`\n- `MAESTRO_DEBUG_OUTPUT`\n- `MAESTRO_IOS_APP_PATH`\n- `EXPO_PUBLIC_E2E_ENV_PROFILE`\n- `EXPO_PUBLIC_E2E_LANGUAGE`\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/auth.yaml",
    "content": "appId: is.follow\nname: iOS auth journey\n---\n- runFlow:\n    file: ./register.yaml\n- runFlow:\n    file: ./sign-out.yaml\n- runFlow:\n    file: ./login.yaml\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/content.yaml",
    "content": "appId: is.follow\nname: iOS content journey\n---\n- runFlow:\n    file: ./ensure-onboarding-unfollowed.yaml\n- runFlow:\n    file: ./follow-onboarding.yaml\n- runFlow:\n    file: ./timeline-entry.yaml\n- runFlow:\n    file: ./unfollow-onboarding.yaml\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/core.yaml",
    "content": "appId: is.follow\nname: iOS core journey\n---\n- launchApp:\n    clearState: true\n- runFlow:\n    file: ./register.yaml\n- runFlow:\n    file: ./sign-out.yaml\n- runFlow:\n    file: ./login.yaml\n- runFlow:\n    file: ./ensure-onboarding-unfollowed.yaml\n- runFlow:\n    file: ./follow-onboarding.yaml\n- runFlow:\n    file: ./timeline-entry.yaml\n- runFlow:\n    file: ./unfollow-onboarding.yaml\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/dismiss-overlays.yaml",
    "content": "appId: is.follow\n---\n- runFlow:\n    when:\n      visible: Close image\n    commands:\n      - tapOn:\n          point: \"5%,10%\"\n      - waitForAnimationToEnd\n- runFlow:\n    when:\n      visible: Move to Category\n    commands:\n      - tapOn:\n          point: \"50%,18%\"\n      - waitForAnimationToEnd\n- runFlow:\n    when:\n      visible: Mark all as read\n    commands:\n      - tapOn:\n          point: \"50%,18%\"\n      - waitForAnimationToEnd\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/ensure-onboarding-unfollowed.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    text: Discover\n- tapOn:\n    id: discover-search-input\n- eraseText\n- inputText: folo://onboarding\n- pressKey: Enter\n- extendedWaitUntil:\n    visible: Welcome to Folo\n    timeout: 20000\n- tapOn:\n    id: discover-feed-follow-action\n- runFlow:\n    when:\n      visible:\n        id: follow-unfollow\n    commands:\n      - tapOn:\n          id: follow-unfollow\n      - extendedWaitUntil:\n          visible: Unsubscribe?\n          timeout: 10000\n      - tapOn:\n          point: \"275,497\"\n- runFlow:\n    when:\n      visible:\n        id: follow-submit\n    commands:\n      - tapOn:\n          id: navigation-back\n- runFlow:\n    when:\n      visible:\n        id: navigation-back\n    commands:\n      - tapOn:\n          id: navigation-back\n- extendedWaitUntil:\n    visible: Discover\n    timeout: 20000\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/follow-onboarding.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    text: Discover\n- tapOn:\n    id: discover-search-input\n- eraseText\n- inputText: folo://onboarding\n- pressKey: Enter\n- extendedWaitUntil:\n    visible: Welcome to Folo\n    timeout: 20000\n- tapOn:\n    id: discover-feed-follow-action\n- runFlow:\n    when:\n      visible:\n        id: follow-submit\n    commands:\n      - tapOn:\n          id: follow-submit\n- runFlow:\n    when:\n      visible:\n        id: follow-unfollow\n    commands:\n      - tapOn:\n          id: navigation-back\n- tapOn:\n    text: Subscriptions\n- extendedWaitUntil:\n    visible: Welcome to Folo\n    timeout: 20000\n- assertVisible: Welcome to Folo\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/login.yaml",
    "content": "appId: is.follow\n---\n- assertVisible:\n    id: login-screen\n- waitForAnimationToEnd:\n    timeout: 15000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-credential\n    timeout: 60000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-google\n    timeout: 60000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-github\n    timeout: 60000\n- runFlow:\n    when:\n      visible: Already have an account? Sign in\n    commands:\n      - tapOn:\n          id: auth-toggle-mode\n      - waitForAnimationToEnd\n- extendedWaitUntil:\n    visible:\n      id: login-provider-credential\n    timeout: 60000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-google\n    timeout: 60000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-github\n    timeout: 60000\n- tapOn:\n    id: login-provider-credential\n- extendedWaitUntil:\n    visible:\n      id: login-email-input\n    timeout: 15000\n- tapOn:\n    id: login-email-input\n- inputText: ${E2E_EMAIL}\n- tapOn:\n    id: login-password-input\n- eraseText\n- inputText: ${E2E_PASSWORD}\n- pressKey: Enter\n- extendedWaitUntil:\n    notVisible:\n      id: login-screen\n    timeout: 30000\n- assertNotVisible:\n    id: login-screen\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/register.yaml",
    "content": "appId: is.follow\n---\n- runFlow: ../shared/register.yaml\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/sign-out.yaml",
    "content": "appId: is.follow\n---\n- waitForAnimationToEnd:\n    timeout: 15000\n- runFlow:\n    file: ./dismiss-overlays.yaml\n- runFlow:\n    when:\n      visible:\n        id: navigation-back\n    commands:\n      - tapOn:\n          id: navigation-back\n      - waitForAnimationToEnd\n- runFlow:\n    when:\n      visible:\n        id: navigation-back\n    commands:\n      - tapOn:\n          id: navigation-back\n      - waitForAnimationToEnd\n- runFlow:\n    when:\n      visible: Settings\n    commands:\n      - tapOn:\n          text: Settings\n- runFlow:\n    when:\n      notVisible: Settings\n    commands:\n      - tapOn:\n          point: \"89%,86%\"\n- scrollUntilVisible:\n    element:\n      id: settings-sign-out\n    direction: DOWN\n    timeout: 10000\n    centerElement: false\n- tapOn:\n    id: settings-sign-out\n- extendedWaitUntil:\n    visible: Confirm sign out\n    timeout: 10000\n- tapOn:\n    text: Sign out\n- extendedWaitUntil:\n    visible:\n      id: login-screen\n    timeout: 30000\n- assertVisible:\n    id: login-screen\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/timeline-entry.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    text: Home\n- tapOn:\n    id: timeline-view-videos\n- tapOn:\n    id: timeline-view-articles\n- extendedWaitUntil:\n    visible:\n      id: timeline-entry-first\n    timeout: 20000\n- tapOn:\n    id: timeline-entry-first\n    point: \"50%,50%\"\n    retryTapIfNoChange: true\n- extendedWaitUntil:\n    notVisible:\n      id: timeline-entry-first\n    timeout: 20000\n- extendedWaitUntil:\n    visible:\n      id: navigation-back\n    timeout: 20000\n- tapOn:\n    id: navigation-back\n- extendedWaitUntil:\n    visible:\n      id: timeline-entry-first\n    timeout: 20000\n- longPressOn:\n    id: timeline-entry-first\n- runFlow:\n    when:\n      visible: Mark as Read\n    commands:\n      - tapOn: Mark as Read\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Unread\n      - tapOn: Mark as Unread\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Read\n- runFlow:\n    when:\n      visible: Mark as Unread\n    commands:\n      - tapOn: Mark as Unread\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Read\n      - tapOn: Mark as Read\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Unread\n\n- runFlow:\n    when:\n      visible: Mark as Read\n    commands:\n      - tapOn:\n          point: \"200,120\"\n- runFlow:\n    when:\n      visible: Mark as Unread\n    commands:\n      - tapOn:\n          point: \"200,120\"\n- runFlow:\n    when:\n      visible:\n        id: navigation-back\n    commands:\n      - tapOn:\n          id: navigation-back\n- extendedWaitUntil:\n    visible:\n      id: timeline-view-articles\n    timeout: 20000\n"
  },
  {
    "path": "apps/mobile/e2e/flows/ios/unfollow-onboarding.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    text: Discover\n- tapOn:\n    id: discover-search-input\n- eraseText\n- inputText: folo://onboarding\n- pressKey: Enter\n- extendedWaitUntil:\n    visible: Welcome to Folo\n    timeout: 20000\n- tapOn:\n    id: discover-feed-follow-action\n- extendedWaitUntil:\n    visible:\n      id: follow-unfollow\n    timeout: 20000\n- tapOn:\n    id: follow-unfollow\n- extendedWaitUntil:\n    visible: Unsubscribe?\n    timeout: 10000\n- tapOn:\n    point: \"275,497\"\n- runFlow:\n    when:\n      visible:\n        id: navigation-back\n    commands:\n      - tapOn:\n          id: navigation-back\n- extendedWaitUntil:\n    visible: Discover\n    timeout: 20000\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/core.yaml",
    "content": "appId: is.follow\n---\n- runFlow: register.yaml\n- runFlow: sign-out.yaml\n- runFlow: login.yaml\n- runFlow: ensure-onboarding-unfollowed.yaml\n- runFlow: follow-onboarding.yaml\n- runFlow: timeline-entry.yaml\n- runFlow: unfollow-onboarding.yaml\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/dismiss-ios-system-modal.yaml",
    "content": "appId: is.follow\n---\n- runFlow:\n    when:\n      visible:\n        text: Sign in to your Apple Account\n    commands:\n      - tapOn:\n          text: Close\n- runFlow:\n    when:\n      visible:\n        text: Siri, Dictation & Privacy\n    commands:\n      - tapOn:\n          point: \"8%,8%\"\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/ensure-onboarding-unfollowed.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    id: tab-SubscriptionsTabScreen\n- runFlow:\n    when:\n      visible:\n        id: subscription-feed-url-folo-onboarding\n    commands:\n      - longPressOn:\n          id: subscription-feed-url-folo-onboarding\n      - extendedWaitUntil:\n          visible: Unfollow\n          timeout: 10000\n      - tapOn: Unfollow\n      - tapOn:\n          text: Unfollow\n      - extendedWaitUntil:\n          notVisible:\n            id: subscription-feed-url-folo-onboarding\n          timeout: 20000\n- assertVisible:\n    id: tab-IndexTabScreen\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/follow-onboarding.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    id: tab-DiscoverTabScreen\n- tapOn:\n    id: discover-search-input\n- inputText: folo://onboarding\n- pressKey: Enter\n- extendedWaitUntil:\n    visible: Welcome to Folo\n    timeout: 20000\n- tapOn:\n    id: discover-feed-follow-action\n- extendedWaitUntil:\n    visible:\n      id: follow-submit\n    timeout: 20000\n- tapOn:\n    id: follow-submit\n- tapOn:\n    id: tab-SubscriptionsTabScreen\n- extendedWaitUntil:\n    visible: Welcome to Folo\n    timeout: 20000\n- assertVisible: Welcome to Folo\n- assertVisible:\n    id: tab-IndexTabScreen\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/login.yaml",
    "content": "appId: is.follow\n---\n- assertVisible:\n    id: login-screen\n- tapOn:\n    id: auth-toggle-mode\n- extendedWaitUntil:\n    visible: Don't have an account? Sign up\n    timeout: 10000\n- tapOn:\n    id: login-provider-credential\n- extendedWaitUntil:\n    visible:\n      id: login-email-input\n    timeout: 15000\n- tapOn:\n    id: login-email-input\n- inputText: ${E2E_EMAIL}\n- tapOn:\n    id: login-password-input\n- eraseText\n- inputText: ${E2E_PASSWORD}\n- pressKey: Enter\n- extendedWaitUntil:\n    notVisible:\n      id: login-screen\n    timeout: 30000\n- assertNotVisible:\n    id: login-screen\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/open-auth.yaml",
    "content": "appId: is.follow\n---\n- waitForAnimationToEnd:\n    timeout: 15000\n- runFlow:\n    file: dismiss-ios-system-modal.yaml\n- runFlow:\n    when:\n      visible:\n        id: no-login-timeline\n    commands:\n      - tapOn:\n          id: no-login-timeline\n- runFlow:\n    when:\n      visible:\n        id: home-avatar-trigger\n    commands:\n      - tapOn:\n          id: home-avatar-trigger\n- extendedWaitUntil:\n    visible:\n      id: login-screen\n    timeout: 30000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-credential\n    timeout: 60000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-google\n    timeout: 60000\n- extendedWaitUntil:\n    visible:\n      id: login-provider-github\n    timeout: 60000\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/register.yaml",
    "content": "appId: is.follow\n---\n- runFlow: open-auth.yaml\n- tapOn:\n    id: login-provider-credential\n- extendedWaitUntil:\n    visible:\n      id: register-email-input\n    timeout: 15000\n- tapOn:\n    id: register-email-input\n- inputText: ${E2E_EMAIL}\n- tapOn:\n    id: register-password-input\n- eraseText\n- inputText: ${E2E_PASSWORD}\n- tapOn:\n    id: register-confirm-password-input\n- eraseText\n- inputText: ${E2E_PASSWORD}\n- pressKey: Enter\n- runFlow:\n    when:\n      visible:\n        id: onboarding-next\n    commands:\n      - tapOn:\n          id: onboarding-next\n      - tapOn:\n          id: onboarding-next\n      - tapOn:\n          id: onboarding-next\n      - tapOn:\n          id: onboarding-next\n- extendedWaitUntil:\n    visible:\n      id: timeline-view-articles\n    timeout: 30000\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/sign-out.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    id: tab-SettingsTabScreen\n- scrollUntilVisible:\n    element:\n      text: Sign Out\n    direction: DOWN\n    timeout: 10000\n    centerElement: false\n- tapOn:\n    text: Sign Out\n- tapOn:\n    text: Sign out\n- extendedWaitUntil:\n    visible:\n      id: login-screen\n    timeout: 30000\n- assertVisible:\n    id: login-screen\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/timeline-entry.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    id: tab-IndexTabScreen\n- tapOn:\n    id: timeline-view-videos\n- tapOn:\n    id: timeline-view-articles\n- assertVisible:\n    id: timeline-entry-first\n- tapOn:\n    id: timeline-entry-first\n- extendedWaitUntil:\n    visible:\n      id: navigation-back\n    timeout: 20000\n- tapOn:\n    id: navigation-back\n- extendedWaitUntil:\n    visible:\n      id: timeline-entry-first\n    timeout: 20000\n- longPressOn:\n    id: timeline-entry-first\n- runFlow:\n    when:\n      visible: Mark as Read\n    commands:\n      - tapOn: Mark as Read\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Unread\n      - tapOn: Mark as Unread\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Read\n- runFlow:\n    when:\n      visible: Mark as Unread\n    commands:\n      - tapOn: Mark as Unread\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Read\n      - tapOn: Mark as Read\n      - longPressOn:\n          id: timeline-entry-first\n      - assertVisible: Mark as Unread\n"
  },
  {
    "path": "apps/mobile/e2e/flows/shared/unfollow-onboarding.yaml",
    "content": "appId: is.follow\n---\n- tapOn:\n    id: tab-SubscriptionsTabScreen\n- extendedWaitUntil:\n    visible:\n      id: subscription-feed-url-folo-onboarding\n    timeout: 20000\n- longPressOn:\n    id: subscription-feed-url-folo-onboarding\n- extendedWaitUntil:\n    visible: Unfollow\n    timeout: 10000\n- tapOn: Unfollow\n- tapOn:\n    text: Unfollow\n- extendedWaitUntil:\n    notVisible:\n      id: subscription-feed-url-folo-onboarding\n    timeout: 20000\n- assertNotVisible:\n    id: subscription-feed-url-folo-onboarding\n- assertVisible:\n    id: tab-IndexTabScreen\n"
  },
  {
    "path": "apps/mobile/e2e/run-maestro.sh",
    "content": "#!/usr/bin/env sh\nset -eu\n\nplatform=\"${1:?platform is required}\"\nmode=\"${2:-full}\"\ndebug_output=\"${MAESTRO_DEBUG_OUTPUT:-e2e/artifacts/${platform}}\"\nrun_suffix=\"$(date +%s)-$$\"\n\nmkdir -p \"${debug_output}\"\n\n: \"${E2E_PASSWORD:=Password123!}\"\n: \"${E2E_EMAIL:=folo-e2e-${platform}-${run_suffix}@example.com}\"\n\nexport E2E_EMAIL\nexport E2E_PASSWORD\n\nresolve_ios_device() {\n  if [ -n \"${MAESTRO_IOS_DEVICE_ID:-}\" ]; then\n    printf '%s' \"${MAESTRO_IOS_DEVICE_ID}\"\n    return\n  fi\n\n  xcrun simctl list devices booted | awk -F '[()]' '/Booted/ && $2 ~ /^[A-F0-9-]+$/ { print $2; exit }'\n}\n\nresolve_android_device() {\n  if [ -n \"${MAESTRO_ANDROID_DEVICE_ID:-}\" ]; then\n    printf '%s' \"${MAESTRO_ANDROID_DEVICE_ID}\"\n    return\n  fi\n\n  adb devices | awk '$2 == \"device\" && $1 != \"List\" { print $1; exit }'\n}\n\nextract_ios_app_from_tar() {\n  tar_path=\"$1\"\n  dest_dir=\"${2:?destination is required}\"\n  mkdir -p \"${dest_dir}\"\n  tar -xzf \"${tar_path}\" -C \"${dest_dir}\"\n  find \"${dest_dir}\" -maxdepth 3 -name 'Folo.app' | head -n1\n}\n\nresolve_ios_app_path() {\n  device_id=\"$1\"\n  if [ -n \"${MAESTRO_IOS_APP_PATH:-}\" ]; then\n    if [ -d \"${MAESTRO_IOS_APP_PATH}\" ]; then\n      printf '%s' \"${MAESTRO_IOS_APP_PATH}\"\n      return\n    fi\n\n    if [ -f \"${MAESTRO_IOS_APP_PATH}\" ] && echo \"${MAESTRO_IOS_APP_PATH}\" | grep -q '\\.tar\\.gz$'; then\n      extract_dir=\"$(mktemp -d /tmp/folo-ios-app-XXXXXX)\"\n      extract_ios_app_from_tar \"${MAESTRO_IOS_APP_PATH}\" \"${extract_dir}\"\n      return\n    fi\n  fi\n\n  latest_tar=\"$(find . -maxdepth 1 -name 'build-*.tar.gz' | sort | tail -n1)\"\n  if [ -n \"${latest_tar}\" ]; then\n    extract_dir=\"$(mktemp -d /tmp/folo-ios-app-XXXXXX)\"\n    extract_ios_app_from_tar \"${latest_tar}\" \"${extract_dir}\"\n    return\n  fi\n\n  existing_path=\"$(find \"$HOME/Library/Developer/Xcode/DerivedData\" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1)\"\n  if [ -n \"${existing_path}\" ]; then\n    printf '%s' \"${existing_path}\"\n    return\n  fi\n\n  build_ios_simulator_app \"${device_id}\"\n}\n\nwait_for_android_ready() {\n  device_id=\"$1\"\n\n  for _ in $(seq 1 90); do\n    boot_completed=$(adb -s \"${device_id}\" shell getprop sys.boot_completed 2>/dev/null | awk 'NF { print $1; exit }')\n    if [ \"$boot_completed\" = \"1\" ] && adb -s \"${device_id}\" shell cmd package list packages >/dev/null 2>&1; then\n      sleep 20\n      return\n    fi\n    sleep 2\n  done\n\n  echo \"Android device ${device_id} did not finish booting in time.\" >&2\n  exit 1\n}\n\nprepare_ios_simulator() {\n  device_id=\"$1\"\n  xcrun simctl shutdown \"${device_id}\" >/dev/null 2>&1 || true\n  xcrun simctl erase \"${device_id}\" >/dev/null 2>&1 || true\n  xcrun simctl boot \"${device_id}\" >/dev/null 2>&1 || true\n  xcrun simctl bootstatus \"${device_id}\" -b >/dev/null 2>&1 || true\n  xcrun simctl shutdown \"${device_id}\" >/dev/null 2>&1 || true\n\n  simulator_data=\"$HOME/Library/Developer/CoreSimulator/Devices/${device_id}/data\"\n  for rel in \\\n    Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles/UserSettings.plist \\\n    Library/UserConfigurationProfiles/EffectiveUserSettings.plist \\\n    Library/UserConfigurationProfiles/PublicInfo/PublicEffectiveUserSettings.plist\n  do\n    file=\"${simulator_data}/${rel}\"\n    if [ -f \"${file}\" ]; then\n      /usr/libexec/PlistBuddy -c 'Add :restrictedBool dict' \"${file}\" 2>/dev/null || true\n      /usr/libexec/PlistBuddy -c 'Add :restrictedBool:allowPasswordAutoFill dict' \"${file}\" 2>/dev/null || true\n      /usr/libexec/PlistBuddy -c 'Set :restrictedBool:allowPasswordAutoFill:value false' \"${file}\" 2>/dev/null \\\n        || /usr/libexec/PlistBuddy -c 'Add :restrictedBool:allowPasswordAutoFill:value bool false' \"${file}\" >/dev/null 2>&1 || true\n    fi\n  done\n\n  xcrun simctl boot \"${device_id}\" >/dev/null 2>&1 || true\n  xcrun simctl bootstatus \"${device_id}\" -b >/dev/null 2>&1 || true\n}\n\nappend_ios_arch_args() {\n  arch=\"$(uname -m)\"\n  if [ \"${arch}\" = \"arm64\" ]; then\n    printf '%s\\n' \"ONLY_ACTIVE_ARCH=YES\" \"ARCHS=arm64\"\n  fi\n}\n\nbuild_ios_simulator_app() {\n  device_id=\"$1\"\n  (\n    cd ios\n    pod install\n    set -- $(append_ios_arch_args)\n    PROFILE=e2e-ios-simulator \\\n    EXPO_PUBLIC_E2E_ENV_PROFILE=\"${EXPO_PUBLIC_E2E_ENV_PROFILE:-}\" \\\n    EXPO_PUBLIC_E2E_LANGUAGE=\"${EXPO_PUBLIC_E2E_LANGUAGE:-en}\" \\\n    xcodebuild -workspace Folo.xcworkspace \\\n      -scheme Folo \\\n      -configuration Release \\\n      -sdk iphonesimulator \\\n      -destination \"id=${device_id}\" \\\n      build \\\n      \"$@\"\n  )\n\n  find \"$HOME/Library/Developer/Xcode/DerivedData\" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1\n}\n\nrun_ios_bootstrap_auth() {\n  device_id=\"$1\"\n\n  case \"${EXPO_PUBLIC_E2E_ENV_PROFILE:-prod}\" in\n    prod|local)\n      pnpm run e2e:bootstrap:ios:prod-auth -- --udid \"${device_id}\"\n      ;;\n    *)\n      maestro test --format junit --platform ios --device \"${device_id}\" --debug-output \"${debug_output}/bootstrap-auth\" \\\n        -e E2E_EMAIL=\"${E2E_EMAIL}\" -e E2E_PASSWORD=\"${E2E_PASSWORD}\" e2e/flows/ios/register.yaml\n      ;;\n  esac\n}\n\ncase \"${platform}\" in\n  ios)\n    device_id=\"$(resolve_ios_device)\"\n    if [ -z \"${device_id}\" ]; then\n      echo \"No booted iOS simulator found. Set MAESTRO_IOS_DEVICE_ID or boot a simulator first.\" >&2\n      exit 1\n    fi\n\n    app_path=\"$(resolve_ios_app_path \"${device_id}\")\"\n    if [ -z \"${app_path}\" ] || [ ! -d \"${app_path}\" ]; then\n      echo \"Unable to resolve a built iOS .app bundle. Set MAESTRO_IOS_APP_PATH or place a build-*.tar.gz in apps/mobile.\" >&2\n      exit 1\n    fi\n\n    prepare_ios_simulator \"${device_id}\"\n    xcrun simctl install \"${device_id}\" \"${app_path}\" >/dev/null 2>&1 || true\n    xcrun simctl launch \"${device_id}\" is.follow >/dev/null 2>&1 || true\n\n    case \"${mode}\" in\n      full)\n        maestro test --format junit --platform ios --device \"${device_id}\" --debug-output \"${debug_output}/auth\" \\\n          -e E2E_EMAIL=\"${E2E_EMAIL}\" -e E2E_PASSWORD=\"${E2E_PASSWORD}\" e2e/flows/ios/auth.yaml\n        ;;\n      bootstrap-auth)\n        run_ios_bootstrap_auth \"${device_id}\"\n        ;;\n      *)\n        echo \"Unsupported iOS runner mode: ${mode}\" >&2\n        exit 1\n        ;;\n    esac\n\n    ;;\n  android)\n    flow_target=\"e2e/flows/${platform}/core.yaml\"\n    device_id=\"$(resolve_android_device)\"\n    if [ -z \"${device_id}\" ]; then\n      echo \"No booted Android emulator found. Set MAESTRO_ANDROID_DEVICE_ID or boot an emulator first.\" >&2\n      exit 1\n    fi\n    wait_for_android_ready \"${device_id}\"\n    adb -s \"${device_id}\" shell pm clear is.follow >/dev/null 2>&1 || true\n    adb -s \"${device_id}\" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true\n    maestro test --format junit --platform android --device \"${device_id}\" --debug-output \"${debug_output}\" \\\n      -e E2E_EMAIL=\"${E2E_EMAIL}\" -e E2E_PASSWORD=\"${E2E_PASSWORD}\" \"${flow_target}\"\n    ;;\n  *)\n    echo \"Unsupported platform: ${platform}\" >&2\n    exit 1\n    ;;\nesac\n"
  },
  {
    "path": "apps/mobile/eas.json",
    "content": "{\n  \"cli\": {\n    \"version\": \">= 15.0.10\",\n    \"appVersionSource\": \"remote\"\n  },\n  \"build\": {\n    \"development\": {\n      \"developmentClient\": true,\n      \"distribution\": \"internal\",\n      \"channel\": \"development\",\n      \"env\": {\n        \"PROFILE\": \"development\"\n      }\n    },\n    \"ios-simulator\": {\n      \"extends\": \"development\",\n      \"ios\": {\n        \"simulator\": true\n      },\n      \"env\": {\n        \"PROFILE\": \"ios-simulator\"\n      }\n    },\n    \"preview\": {\n      \"distribution\": \"internal\",\n      \"channel\": \"preview\",\n      \"env\": {\n        \"PROFILE\": \"preview\"\n      }\n    },\n    \"e2e-android\": {\n      \"extends\": \"preview\",\n      \"env\": {\n        \"PROFILE\": \"e2e-android\",\n        \"EXPO_PUBLIC_E2E_ENV_PROFILE\": \"prod\",\n        \"EXPO_PUBLIC_E2E_LANGUAGE\": \"en\"\n      }\n    },\n    \"e2e-ios-simulator\": {\n      \"extends\": \"preview\",\n      \"ios\": {\n        \"simulator\": true\n      },\n      \"env\": {\n        \"PROFILE\": \"e2e-ios-simulator\",\n        \"EXPO_PUBLIC_E2E_ENV_PROFILE\": \"prod\",\n        \"EXPO_PUBLIC_E2E_LANGUAGE\": \"en\"\n      }\n    },\n    \"production\": {\n      \"autoIncrement\": true,\n      \"channel\": \"production\",\n      \"env\": {\n        \"PROFILE\": \"production\"\n      }\n    }\n  },\n  \"submit\": {\n    \"production\": {\n      \"ios\": {\n        \"appleId\": \"diygod@rss3.io\",\n        \"ascAppId\": \"6739802604\",\n        \"appleTeamId\": \"492J8Q67PF\"\n      },\n      \"android\": {\n        \"releaseStatus\": \"draft\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/global.d.ts",
    "content": "import type { DOMProps } from \"expo/dom\"\nimport type { FC } from \"react\"\nimport type WebView from \"react-native-webview\"\n\ndeclare global {\n  export type WebComponent<P = object> = FC<P & { dom?: DOMProps } & React.RefAttributes<WebView>>\n}\nexport {}\n"
  },
  {
    "path": "apps/mobile/ios/.gitignore",
    "content": "# OSX\n#\n.DS_Store\n\n# Xcode\n#\nbuild/\n*.pbxuser\n!default.pbxuser\n*.mode1v3\n!default.mode1v3\n*.mode2v3\n!default.mode2v3\n*.perspectivev3\n!default.perspectivev3\nxcuserdata\n*.xccheckout\n*.moved-aside\nDerivedData\n*.hmap\n*.ipa\n*.xcuserstate\nproject.xcworkspace\n.xcode.env.local\n\n# Bundle artifacts\n*.jsbundle\n\n# CocoaPods\n/Pods/\n"
  },
  {
    "path": "apps/mobile/ios/.xcode.env",
    "content": "# This `.xcode.env` file is versioned and is used to source the environment\n# used when running script phases inside Xcode.\n# To customize your local environment, you can create an `.xcode.env.local`\n# file that is not versioned.\n\n# NODE_BINARY variable contains the PATH to the node executable.\n#\n# Customize the NODE_BINARY variable here.\n# For example, to use nvm with brew, add the following line\n# . \"$(brew --prefix nvm)/nvm.sh\" --no-use\nexport NODE_BINARY=$(command -v node)\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/black_board_2_cute_fi.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"black_board_2_cute_fi.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/black_board_2_cute_re.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"black_board_2_cute_re.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/home_5_cute_fi.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"home_5_cute_fi.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/home_5_cute_re.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"home_5_cute_re.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/search_3_cute_fi.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"search_3_cute_fi.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/search_3_cute_re.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"search_3_cute_re.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/settings_1_cute_fi.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"settings_1_cute_fi.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Assets.xcassets/settings_1_cute_re.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"settings_1_cute_re.pdf\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"template-rendering-intent\" : \"template\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Folo/AppDelegate.swift",
    "content": "import Expo\nimport FirebaseCore\nimport RNFBAppCheck\nimport React\nimport ReactAppDependencyProvider\n\n@UIApplicationMain\npublic class AppDelegate: ExpoAppDelegate {\n  var window: UIWindow?\n\n  var reactNativeDelegate: ExpoReactNativeFactoryDelegate?\n  var reactNativeFactory: RCTReactNativeFactory?\n\n  public override func application(\n    _ application: UIApplication,\n    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil\n  ) -> Bool {\n    let delegate = ReactNativeDelegate()\n    let factory = ExpoReactNativeFactory(delegate: delegate)\n    delegate.dependencyProvider = RCTAppDependencyProvider()\n\n    reactNativeDelegate = delegate\n    reactNativeFactory = factory\n    bindReactNativeFactory(factory)\n\n    RNFBAppCheckModule.sharedInstance()\n    FirebaseApp.configure()\n\n    setAppAppearance()\n\n    #if os(iOS) || os(tvOS)\n      window = UIWindow(frame: UIScreen.main.bounds)\n      factory.startReactNative(\n        withModuleName: \"main\",\n        in: window,\n        launchOptions: launchOptions)\n    #endif\n\n    return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n  }\n\n  // Linking API\n  public override func application(\n    _ app: UIApplication,\n    open url: URL,\n    options: [UIApplication.OpenURLOptionsKey: Any] = [:]\n  ) -> Bool {\n    return super.application(app, open: url, options: options)\n      || RCTLinkingManager.application(app, open: url, options: options)\n  }\n\n  // Universal Links\n  public override func application(\n    _ application: UIApplication,\n    continue userActivity: NSUserActivity,\n    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void\n  ) -> Bool {\n    let result = RCTLinkingManager.application(\n      application, continue: userActivity, restorationHandler: restorationHandler)\n    return super.application(\n      application, continue: userActivity, restorationHandler: restorationHandler) || result\n  }\n}\n\nclass ReactNativeDelegate: ExpoReactNativeFactoryDelegate {\n  // Extension point for config-plugins\n\n  override func sourceURL(for bridge: RCTBridge) -> URL? {\n    // needed to return the correct URL for expo-dev-client.\n    bridge.bundleURL ?? bundleURL()\n  }\n\n  override func bundleURL() -> URL? {\n    #if DEBUG\n      return RCTBundleURLProvider.sharedSettings().jsBundleURL(\n        forBundleRoot: \".expo/.virtual-metro-entry\")\n    #else\n      return Bundle.main.url(forResource: \"main\", withExtension: \"jsbundle\")\n    #endif\n  }\n}\n\nprivate func setAppAppearance() {\n  let tintColor = UIColor(red: 255.0 / 255.0, green: 92.0 / 255.0, blue: 0.0 / 255.0, alpha: 1.0)\n  UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = tintColor\n\n  UIButton.appearance().tintColor = tintColor\n  UISwitch.appearance().onTintColor = tintColor\n}\n"
  },
  {
    "path": "apps/mobile/ios/Folo/Folo-Bridging-Header.h",
    "content": "//\n// Use this file to import your target's public headers that you would like to expose to Swift.\n//\n"
  },
  {
    "path": "apps/mobile/ios/Folo/Folo.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>aps-environment</key>\n    <string>development</string>\n    <key>com.apple.developer.applesignin</key>\n    <array>\n      <string>Default</string>\n    </array>\n  </dict>\n</plist>"
  },
  {
    "path": "apps/mobile/ios/Folo/Images.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\": [\n    {\n      \"filename\": \"App-Icon-1024x1024@1x.png\",\n      \"idiom\": \"universal\",\n      \"platform\": \"ios\",\n      \"size\": \"1024x1024\"\n    }\n  ],\n  \"info\": {\n    \"version\": 1,\n    \"author\": \"expo\"\n  }\n}"
  },
  {
    "path": "apps/mobile/ios/Folo/Images.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"version\" : 1,\n    \"author\" : \"expo\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Folo/Images.xcassets/SplashScreenBackground.colorset/Contents.json",
    "content": "{\n  \"colors\": [\n    {\n      \"color\": {\n        \"components\": {\n          \"alpha\": \"1.000\",\n          \"blue\": \"1.00000000000000\",\n          \"green\": \"1.00000000000000\",\n          \"red\": \"1.00000000000000\"\n        },\n        \"color-space\": \"srgb\"\n      },\n      \"idiom\": \"universal\"\n    }\n  ],\n  \"info\": {\n    \"version\": 1,\n    \"author\": \"expo\"\n  }\n}"
  },
  {
    "path": "apps/mobile/ios/Folo/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>BGTaskSchedulerPermittedIdentifiers</key>\n\t<array>\n\t\t<string>com.expo.modules.backgroundtask.processing</string>\n\t</array>\n\t<key>CADisableMinimumFrameDurationOnPhone</key>\n\t<true/>\n\t<key>CFBundleAllowMixedLocalizations</key>\n\t<true/>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>en</string>\n\t<key>CFBundleDisplayName</key>\n\t<string>Folo</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleLocalizations</key>\n\t<array>\n\t\t<string>en</string>\n\t\t<string>ja</string>\n\t\t<string>zh-CN</string>\n\t\t<string>zh-TW</string>\n\t\t<string>fr-FR</string>\n\t</array>\n\t<key>CFBundleName</key>\n\t<string>$(PRODUCT_NAME)</string>\n\t<key>CFBundlePackageType</key>\n\t<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>0.4.0</string>\n\t<key>CFBundleSignature</key>\n\t<string>????</string>\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>follow</string>\n\t\t\t\t<string>folo</string>\n\t\t\t\t<string>is.follow</string>\n\t\t\t</array>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>exp+follow</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>CFBundleVersion</key>\n\t<string>139</string>\n\t<key>ITSAppUsesNonExemptEncryption</key>\n\t<false/>\n\t<key>LSApplicationCategoryType</key>\n\t<string>public.app-category.news</string>\n\t<key>LSApplicationQueriesSchemes</key>\n\t<array>\n\t\t<string>bilibili</string>\n\t\t<string>youtube</string>\n\t</array>\n\t<key>LSMinimumSystemVersion</key>\n\t<string>12.0</string>\n\t<key>LSRequiresIPhoneOS</key>\n\t<true/>\n\t<key>NSAppTransportSecurity</key>\n\t<dict>\n\t\t<key>NSAllowsArbitraryLoads</key>\n\t\t<false/>\n\t\t<key>NSAllowsLocalNetworking</key>\n\t\t<true/>\n\t</dict>\n\t<key>NSCameraUsageDescription</key>\n\t<string>Allow $(PRODUCT_NAME) to access your camera</string>\n\t<key>NSFaceIDUsageDescription</key>\n\t<string>Allow $(PRODUCT_NAME) to access your Face ID biometric data.</string>\n\t<key>NSMicrophoneUsageDescription</key>\n\t<string>Allow $(PRODUCT_NAME) to access your microphone</string>\n\t<key>NSPhotoLibraryAddUsageDescription</key>\n\t<string>Allow $(PRODUCT_NAME) to save photos.</string>\n\t<key>NSPhotoLibraryUsageDescription</key>\n\t<string>Allow $(PRODUCT_NAME) to access your photos.</string>\n\t<key>RCTNewArchEnabled</key>\n\t<true/>\n\t<key>UIBackgroundModes</key>\n\t<array>\n\t\t<string>audio</string>\n\t\t<string>fetch</string>\n\t\t<string>processing</string>\n\t\t<string>remote-notification</string>\n\t</array>\n\t<key>UILaunchStoryboardName</key>\n\t<string>SplashScreen</string>\n\t<key>UIRequiredDeviceCapabilities</key>\n\t<array>\n\t\t<string>arm64</string>\n\t</array>\n\t<key>UIRequiresFullScreen</key>\n\t<false/>\n\t<key>UIStatusBarStyle</key>\n\t<string>UIStatusBarStyleDefault</string>\n\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t<string>UIInterfaceOrientationPortraitUpsideDown</string>\n\t</array>\n\t<key>UISupportedInterfaceOrientations~ipad</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t<string>UIInterfaceOrientationPortraitUpsideDown</string>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>\n\t<key>UIUserInterfaceStyle</key>\n\t<string>Automatic</string>\n\t<key>UIViewControllerBasedStatusBarAppearance</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apps/mobile/ios/Folo/PrivacyInfo.xcprivacy",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>NSPrivacyAccessedAPITypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>NSPrivacyAccessedAPIType</key>\n\t\t\t<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>\n\t\t\t<key>NSPrivacyAccessedAPITypeReasons</key>\n\t\t\t<array>\n\t\t\t\t<string>C617.1</string>\n\t\t\t\t<string>0A2A.1</string>\n\t\t\t\t<string>3B52.1</string>\n\t\t\t</array>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>NSPrivacyAccessedAPIType</key>\n\t\t\t<string>NSPrivacyAccessedAPICategoryUserDefaults</string>\n\t\t\t<key>NSPrivacyAccessedAPITypeReasons</key>\n\t\t\t<array>\n\t\t\t\t<string>CA92.1</string>\n\t\t\t\t<string>1C8F.1</string>\n\t\t\t\t<string>C56D.1</string>\n\t\t\t</array>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>NSPrivacyAccessedAPIType</key>\n\t\t\t<string>NSPrivacyAccessedAPICategorySystemBootTime</string>\n\t\t\t<key>NSPrivacyAccessedAPITypeReasons</key>\n\t\t\t<array>\n\t\t\t\t<string>35F9.1</string>\n\t\t\t</array>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>NSPrivacyAccessedAPIType</key>\n\t\t\t<string>NSPrivacyAccessedAPICategoryDiskSpace</string>\n\t\t\t<key>NSPrivacyAccessedAPITypeReasons</key>\n\t\t\t<array>\n\t\t\t\t<string>E174.1</string>\n\t\t\t\t<string>85F4.1</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>NSPrivacyCollectedDataTypes</key>\n\t<array/>\n\t<key>NSPrivacyTracking</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apps/mobile/ios/Folo/SplashScreen.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"32700.99.1234\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" launchScreen=\"YES\" useTraitCollections=\"YES\" useSafeAreas=\"YES\" colorMatched=\"YES\" initialViewController=\"EXPO-VIEWCONTROLLER-1\">\n    <device id=\"retina6_12\" orientation=\"portrait\" appearance=\"light\"/>\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"22685\"/>\n        <capability name=\"Named colors\" minToolsVersion=\"9.0\"/>\n        <capability name=\"Safe area layout guides\" minToolsVersion=\"9.0\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <scenes>\n        <scene sceneID=\"EXPO-SCENE-1\">\n            <objects>\n                <viewController storyboardIdentifier=\"SplashScreenViewController\" id=\"EXPO-VIEWCONTROLLER-1\" sceneMemberID=\"viewController\">\n                    <view key=\"view\" userInteractionEnabled=\"NO\" contentMode=\"scaleToFill\" insetsLayoutMarginsFromSafeArea=\"NO\" id=\"EXPO-ContainerView\" userLabel=\"ContainerView\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"393\" height=\"852\"/>\n                        <autoresizingMask key=\"autoresizingMask\" flexibleMaxX=\"YES\" flexibleMaxY=\"YES\"/>\n                        <subviews/>\n                        <viewLayoutGuide key=\"safeArea\" id=\"Rmq-lb-GrQ\"/>\n                        <constraints>\n                            <constraint firstItem=\"EXPO-SplashScreen\" firstAttribute=\"centerY\" secondItem=\"EXPO-ContainerView\" secondAttribute=\"centerY\" id=\"0VC-Wk-OaO\"/>\n                            <constraint firstItem=\"EXPO-SplashScreen\" firstAttribute=\"centerX\" secondItem=\"EXPO-ContainerView\" secondAttribute=\"centerX\" id=\"zR4-NK-mVN\"/>\n                        </constraints>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"EXPO-PLACEHOLDER-1\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n            <point key=\"canvasLocation\" x=\"0.0\" y=\"0.0\"/>\n        </scene>\n    </scenes>\n    <resources>\n        <image name=\"SplashScreenLogo\" width=\"100\" height=\"90.333335876464844\"/>\n    </resources>\n</document>"
  },
  {
    "path": "apps/mobile/ios/Folo/Supporting/Expo.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>EXUpdatesCheckOnLaunch</key>\n    <string>ALWAYS</string>\n    <key>EXUpdatesEnabled</key>\n    <false/>\n    <key>EXUpdatesLaunchWaitMs</key>\n    <integer>0</integer>\n    <key>EXUpdatesRuntimeVersion</key>\n    <string>0.3.0</string>\n  </dict>\n</plist>"
  },
  {
    "path": "apps/mobile/ios/Folo - Follow everything.storekit",
    "content": "{\n  \"appPolicies\": {\n    \"eula\": \"\",\n    \"policies\": [\n      {\n        \"locale\": \"en_US\",\n        \"policyText\": \"\",\n        \"policyURL\": \"\"\n      }\n    ]\n  },\n  \"identifier\": \"33F52C22\",\n  \"nonRenewingSubscriptions\": [],\n  \"products\": [\n    {\n      \"displayPrice\": \"1.0\",\n      \"familyShareable\": false,\n      \"internalID\": \"6747998552\",\n      \"localizations\": [\n        {\n          \"description\": \"Pro Preview\",\n          \"displayName\": \"Pro Preview\",\n          \"locale\": \"en_US\"\n        }\n      ],\n      \"productID\": \"is.follow.propreview\",\n      \"referenceName\": \"Pro Preview\",\n      \"type\": \"NonConsumable\"\n    }\n  ],\n  \"settings\": {\n    \"_applicationInternalID\": \"6739802604\",\n    \"_developerTeamID\": \"492J8Q67PF\",\n    \"_failTransactionsEnabled\": false,\n    \"_lastSynchronizedDate\": 773246215.317704,\n    \"_locale\": \"en_US\",\n    \"_storefront\": \"USA\",\n    \"_storeKitErrors\": [\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Load Products\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Purchase\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Verification\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"App Store Sync\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Subscription Status\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"App Transaction\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Manage Subscriptions Sheet\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Refund Request Sheet\"\n      },\n      {\n        \"current\": null,\n        \"enabled\": false,\n        \"name\": \"Offer Code Redeem Sheet\"\n      }\n    ]\n  },\n  \"subscriptionGroups\": [\n    {\n      \"id\": \"21822756\",\n      \"name\": \"MembershipGroup\",\n      \"subscriptions\": [\n        {\n          \"adHocOffers\": [],\n          \"displayPrice\": \"4.99\",\n          \"familyShareable\": false,\n          \"groupNumber\": 6,\n          \"internalID\": \"6756944937\",\n          \"introductoryOffer\": {\n            \"displayPrice\": \"0\",\n            \"internalID\": \"intro-basic-monthly\",\n            \"paymentMode\": \"freeTrial\",\n            \"subscriptionPeriod\": \"P1W\"\n          },\n          \"localizations\": [\n            {\n              \"description\": \"More feeds without AI features.\",\n              \"displayName\": \"Folo Basic (Monthly)\",\n              \"locale\": \"en_US\"\n            }\n          ],\n          \"productID\": \"is.follow.basic.monthly\",\n          \"recurringSubscriptionPeriod\": \"P1M\",\n          \"referenceName\": \"Basic Monthly\",\n          \"type\": \"RecurringSubscription\"\n        },\n        {\n          \"adHocOffers\": [],\n          \"displayPrice\": \"49.99\",\n          \"familyShareable\": false,\n          \"groupNumber\": 5,\n          \"internalID\": \"6756944880\",\n          \"introductoryOffer\": {\n            \"displayPrice\": \"0\",\n            \"internalID\": \"intro-basic-yearly\",\n            \"paymentMode\": \"freeTrial\",\n            \"subscriptionPeriod\": \"P1W\"\n          },\n          \"localizations\": [\n            {\n              \"description\": \"More feeds without AI features.\",\n              \"displayName\": \"Folo Basic (Annual)\",\n              \"locale\": \"en_US\"\n            }\n          ],\n          \"productID\": \"is.follow.basic.yearly\",\n          \"recurringSubscriptionPeriod\": \"P1Y\",\n          \"referenceName\": \"Basic Yearly\",\n          \"type\": \"RecurringSubscription\"\n        },\n        {\n          \"adHocOffers\": [],\n          \"displayPrice\": \"9.99\",\n          \"familyShareable\": false,\n          \"groupNumber\": 4,\n          \"internalID\": \"6754834004\",\n          \"introductoryOffer\": {\n            \"displayPrice\": \"0\",\n            \"internalID\": \"intro-plus-monthly\",\n            \"paymentMode\": \"freeTrial\",\n            \"subscriptionPeriod\": \"P1W\"\n          },\n          \"localizations\": [\n            {\n              \"description\": \"Unlock AI features and more feeds.\",\n              \"displayName\": \"Folo Plus (Monthly)\",\n              \"locale\": \"en_US\"\n            }\n          ],\n          \"productID\": \"is.follow.plus.monthly\",\n          \"recurringSubscriptionPeriod\": \"P1M\",\n          \"referenceName\": \"Plus Monthly\",\n          \"type\": \"RecurringSubscription\"\n        },\n        {\n          \"adHocOffers\": [],\n          \"displayPrice\": \"99.99\",\n          \"familyShareable\": false,\n          \"groupNumber\": 3,\n          \"internalID\": \"6754834111\",\n          \"introductoryOffer\": {\n            \"displayPrice\": \"0\",\n            \"internalID\": \"intro-plus-yearly\",\n            \"paymentMode\": \"freeTrial\",\n            \"subscriptionPeriod\": \"P1W\"\n          },\n          \"localizations\": [\n            {\n              \"description\": \"Unlock AI features and more feeds.\",\n              \"displayName\": \"Folo Plus (Annual)\",\n              \"locale\": \"en_US\"\n            }\n          ],\n          \"productID\": \"is.follow.plus.yearly\",\n          \"recurringSubscriptionPeriod\": \"P1Y\",\n          \"referenceName\": \"Plus Yearly\",\n          \"type\": \"RecurringSubscription\"\n        },\n        {\n          \"adHocOffers\": [],\n          \"displayPrice\": \"99.99\",\n          \"familyShareable\": false,\n          \"groupNumber\": 2,\n          \"internalID\": \"6754833948\",\n          \"introductoryOffer\": {\n            \"displayPrice\": \"0\",\n            \"internalID\": \"intro-pro-monthly\",\n            \"paymentMode\": \"freeTrial\",\n            \"subscriptionPeriod\": \"P1W\"\n          },\n          \"localizations\": [\n            {\n              \"description\": \"Full access to the best of Folo.\",\n              \"displayName\": \"Folo Pro (Monthly)\",\n              \"locale\": \"en_US\"\n            }\n          ],\n          \"productID\": \"is.follow.pro.monthly\",\n          \"recurringSubscriptionPeriod\": \"P1M\",\n          \"referenceName\": \"Pro Monthly\",\n          \"type\": \"RecurringSubscription\"\n        },\n        {\n          \"adHocOffers\": [],\n          \"displayPrice\": \"999.99\",\n          \"familyShareable\": false,\n          \"groupNumber\": 1,\n          \"internalID\": \"6754834107\",\n          \"introductoryOffer\": {\n            \"displayPrice\": \"0\",\n            \"internalID\": \"intro-pro-yearly\",\n            \"paymentMode\": \"freeTrial\",\n            \"subscriptionPeriod\": \"P1W\"\n          },\n          \"localizations\": [\n            {\n              \"description\": \"Full access to the best of Folo.\",\n              \"displayName\": \"Folo Pro (Annual)\",\n              \"locale\": \"en_US\"\n            }\n          ],\n          \"productID\": \"is.follow.pro.yearly\",\n          \"recurringSubscriptionPeriod\": \"P1Y\",\n          \"referenceName\": \"Pro Yearly\",\n          \"type\": \"RecurringSubscription\"\n        }\n      ]\n    }\n  ],\n  \"version\": {\n    \"major\": 4,\n    \"minor\": 0\n  }\n}\n"
  },
  {
    "path": "apps/mobile/ios/Folo.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 54;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t08804409FAE316B2F4286290 /* Pods_Folo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C9A37CAFE9FFED2FCC141E3 /* Pods_Folo.framework */; };\n\t\t1274C3CE4776411EBE7CEAE8 /* rn-web in Resources */ = {isa = PBXBuildFile; fileRef = B69598E8ACBE445EA3C3DCEE /* rn-web */; };\n\t\t13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };\n\t\t1FF55C5B2E818E0C000C84BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1FF55C5A2E818E0C000C84BF /* Assets.xcassets */; };\n\t\t21D47042A21541638A8813FF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 771F66ABFAD64B3D89ABAB8A /* GoogleService-Info.plist */; };\n\t\t3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };\n\t\t948BDAD26FE9D66B8735BBD0 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CEF90D1FA529DB2CC2AE941 /* ExpoModulesProvider.swift */; };\n\t\tA6C8B6012E13C44000D28090 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6C8B6002E13C44000D28090 /* StoreKit.framework */; };\n\t\tA6D4133120E003E1031C2B48 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BB62D7D685AF319E0A030204 /* PrivacyInfo.xcprivacy */; };\n\t\tBB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };\n\t\tF11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXFileReference section */\n\t\t13B07F961A680F5B00A75B9A /* Folo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Folo.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Folo/Images.xcassets; sourceTree = \"<group>\"; };\n\t\t13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Folo/Info.plist; sourceTree = \"<group>\"; };\n\t\t1CEF90D1FA529DB2CC2AE941 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = \"Pods/Target Support Files/Pods-Folo/ExpoModulesProvider.swift\"; sourceTree = \"<group>\"; };\n\t\t1FF55C5A2E818E0C000C84BF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t2C9A37CAFE9FFED2FCC141E3 /* Pods_Folo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Folo.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t5D1548572A564A709411B1D2 /* Pods-Folo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Folo.release.xcconfig\"; path = \"Target Support Files/Pods-Folo/Pods-Folo.release.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t771F66ABFAD64B3D89ABAB8A /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = \"GoogleService-Info.plist\"; path = \"Folo/GoogleService-Info.plist\"; sourceTree = \"<group>\"; };\n\t\t84F18C4CB344992039541AAC /* Pods-Folo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Folo.debug.xcconfig\"; path = \"Target Support Files/Pods-Folo/Pods-Folo.debug.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tA652EE6C2E16CCFF00371A74 /* Folo - Follow everything.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = \"Folo - Follow everything.storekit\"; sourceTree = \"<group>\"; };\n\t\tA6C8B6002E13C44000D28090 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };\n\t\tAA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Folo/SplashScreen.storyboard; sourceTree = \"<group>\"; };\n\t\tB69598E8ACBE445EA3C3DCEE /* rn-web */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = \"rn-web\"; path = \"../../../out/rn-web\"; sourceTree = \"<group>\"; };\n\t\tBB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = \"<group>\"; };\n\t\tBB62D7D685AF319E0A030204 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Folo/PrivacyInfo.xcprivacy; sourceTree = \"<group>\"; };\n\t\tED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };\n\t\tF11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Folo/AppDelegate.swift; sourceTree = \"<group>\"; };\n\t\tF11748442D0722820044C1D9 /* Folo-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = \"Folo-Bridging-Header.h\"; path = \"Folo/Folo-Bridging-Header.h\"; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t13B07F8C1A680F5B00A75B9A /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t08804409FAE316B2F4286290 /* Pods_Folo.framework in Frameworks */,\n\t\t\t\tA6C8B6012E13C44000D28090 /* StoreKit.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t13B07FAE1A68108700A75B9A /* Folo */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t1FF55C5A2E818E0C000C84BF /* Assets.xcassets */,\n\t\t\t\tF11748412D0307B40044C1D9 /* AppDelegate.swift */,\n\t\t\t\tF11748442D0722820044C1D9 /* Folo-Bridging-Header.h */,\n\t\t\t\tBB2F792B24A3F905000567C9 /* Supporting */,\n\t\t\t\t13B07FB51A68108700A75B9A /* Images.xcassets */,\n\t\t\t\t13B07FB61A68108700A75B9A /* Info.plist */,\n\t\t\t\tAA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,\n\t\t\t\t771F66ABFAD64B3D89ABAB8A /* GoogleService-Info.plist */,\n\t\t\t\tBB62D7D685AF319E0A030204 /* PrivacyInfo.xcprivacy */,\n\t\t\t);\n\t\t\tname = Folo;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t2329C4DD95ED4964546416B9 /* Folo */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t1CEF90D1FA529DB2CC2AE941 /* ExpoModulesProvider.swift */,\n\t\t\t);\n\t\t\tname = Folo;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t2D16E6871FA4F8E400B85C8A /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA6C8B6002E13C44000D28090 /* StoreKit.framework */,\n\t\t\t\tED297162215061F000B7C4FE /* JavaScriptCore.framework */,\n\t\t\t\t2C9A37CAFE9FFED2FCC141E3 /* Pods_Folo.framework */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t486BB19B76483417F230E142 /* Pods */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t84F18C4CB344992039541AAC /* Pods-Folo.debug.xcconfig */,\n\t\t\t\t5D1548572A564A709411B1D2 /* Pods-Folo.release.xcconfig */,\n\t\t\t);\n\t\t\tpath = Pods;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t5A694C761377C5AE81D5B04E /* ExpoModulesProviders */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t2329C4DD95ED4964546416B9 /* Folo */,\n\t\t\t);\n\t\t\tname = ExpoModulesProviders;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t62F56456C9AC4A5EA82DF7AB /* Assets */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tB69598E8ACBE445EA3C3DCEE /* rn-web */,\n\t\t\t);\n\t\t\tname = Assets;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t832341AE1AAA6A7D00B99B32 /* Libraries */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t);\n\t\t\tname = Libraries;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t83CBB9F61A601CBA00E9B192 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA652EE6C2E16CCFF00371A74 /* Folo - Follow everything.storekit */,\n\t\t\t\t13B07FAE1A68108700A75B9A /* Folo */,\n\t\t\t\t832341AE1AAA6A7D00B99B32 /* Libraries */,\n\t\t\t\t83CBBA001A601CBA00E9B192 /* Products */,\n\t\t\t\t2D16E6871FA4F8E400B85C8A /* Frameworks */,\n\t\t\t\t62F56456C9AC4A5EA82DF7AB /* Assets */,\n\t\t\t\t486BB19B76483417F230E142 /* Pods */,\n\t\t\t\t5A694C761377C5AE81D5B04E /* ExpoModulesProviders */,\n\t\t\t);\n\t\t\tindentWidth = 2;\n\t\t\tsourceTree = \"<group>\";\n\t\t\ttabWidth = 2;\n\t\t\tusesTabs = 0;\n\t\t};\n\t\t83CBBA001A601CBA00E9B192 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t13B07F961A680F5B00A75B9A /* Folo.app */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tBB2F792B24A3F905000567C9 /* Supporting */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tBB2F792C24A3F905000567C9 /* Expo.plist */,\n\t\t\t);\n\t\t\tname = Supporting;\n\t\t\tpath = Folo/Supporting;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t13B07F861A680F5B00A75B9A /* Folo */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget \"Folo\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,\n\t\t\t\tE2A317A7E9D9C06F986E7E62 /* [Expo] Configure project */,\n\t\t\t\t13B07F871A680F5B00A75B9A /* Sources */,\n\t\t\t\t13B07F8C1A680F5B00A75B9A /* Frameworks */,\n\t\t\t\t13B07F8E1A680F5B00A75B9A /* Resources */,\n\t\t\t\t00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,\n\t\t\t\t800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,\n\t\t\t\t6E4ADCBDA5B760296BAE78FA /* [CP] Embed Pods Frameworks */,\n\t\t\t\t8F2D99FDEA6998C9D98BD7E0 /* [CP-User] [RNFB] Core Configuration */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = Folo;\n\t\t\tproductName = Folo;\n\t\t\tproductReference = 13B07F961A680F5B00A75B9A /* Folo.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t83CBB9F71A601CBA00E9B192 /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tLastUpgradeCheck = 1130;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t13B07F861A680F5B00A75B9A = {\n\t\t\t\t\t\tLastSwiftMigration = 1250;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject \"Folo\" */;\n\t\t\tcompatibilityVersion = \"Xcode 3.2\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 83CBB9F61A601CBA00E9B192;\n\t\t\tproductRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t13B07F861A680F5B00A75B9A /* Folo */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t13B07F8E1A680F5B00A75B9A /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tBB2F792D24A3F905000567C9 /* Expo.plist in Resources */,\n\t\t\t\t13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,\n\t\t\t\t3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,\n\t\t\t\t21D47042A21541638A8813FF /* GoogleService-Info.plist in Resources */,\n\t\t\t\t1FF55C5B2E818E0C000C84BF /* Assets.xcassets in Resources */,\n\t\t\t\t1274C3CE4776411EBE7CEAE8 /* rn-web in Resources */,\n\t\t\t\tA6D4133120E003E1031C2B48 /* PrivacyInfo.xcprivacy in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\t00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t);\n\t\t\tname = \"Bundle React Native code and images\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"if [[ -f \\\"$PODS_ROOT/../.xcode.env\\\" ]]; then\\n  source \\\"$PODS_ROOT/../.xcode.env\\\"\\nfi\\nif [[ -f \\\"$PODS_ROOT/../.xcode.env.local\\\" ]]; then\\n  source \\\"$PODS_ROOT/../.xcode.env.local\\\"\\nfi\\n\\n# The project root by default is one level up from the ios directory\\nexport PROJECT_ROOT=\\\"$PROJECT_DIR\\\"/..\\n\\nif [[ \\\"$CONFIGURATION\\\" = *Debug* ]]; then\\n  export SKIP_BUNDLING=1\\nfi\\nif [[ -z \\\"$ENTRY_FILE\\\" ]]; then\\n  # Set the entry JS file using the bundler's entry resolution.\\n  export ENTRY_FILE=\\\"$(\\\"$NODE_BINARY\\\" -e \\\"require('expo/scripts/resolveAppEntry')\\\" \\\"$PROJECT_ROOT\\\" ios absolute | tail -n 1)\\\"\\nfi\\n\\nif [[ -z \\\"$CLI_PATH\\\" ]]; then\\n  # Use Expo CLI\\n  export CLI_PATH=\\\"$(\\\"$NODE_BINARY\\\" --print \\\"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\\\")\\\"\\nfi\\nif [[ -z \\\"$BUNDLE_COMMAND\\\" ]]; then\\n  # Default Expo CLI command for bundling\\n  export BUNDLE_COMMAND=\\\"export:embed\\\"\\nfi\\n\\n# Source .xcode.env.updates if it exists to allow\\n# SKIP_BUNDLING to be unset if needed\\nif [[ -f \\\"$PODS_ROOT/../.xcode.env.updates\\\" ]]; then\\n  source \\\"$PODS_ROOT/../.xcode.env.updates\\\"\\nfi\\n# Source local changes to allow overrides\\n# if needed\\nif [[ -f \\\"$PODS_ROOT/../.xcode.env.local\\\" ]]; then\\n  source \\\"$PODS_ROOT/../.xcode.env.local\\\"\\nfi\\n\\n/bin/sh `\\\"$NODE_BINARY\\\" --print \\\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\\\"`\\n\\n\";\n\t\t};\n\t\t08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\",\n\t\t\t\t\"${PODS_ROOT}/Manifest.lock\",\n\t\t\t);\n\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(DERIVED_FILE_DIR)/Pods-Folo-checkManifestLockResult.txt\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"diff \\\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\\\" \\\"${PODS_ROOT}/Manifest.lock\\\" > /dev/null\\nif [ $? != 0 ] ; then\\n    # print error to STDERR\\n    echo \\\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\\" >&2\\n    exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\\"SUCCESS\\\" > \\\"${SCRIPT_OUTPUT_FILE_0}\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\t6E4ADCBDA5B760296BAE78FA /* [CP] Embed Pods Frameworks */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Folo/Pods-Folo-frameworks.sh\",\n\t\t\t\t\"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes\",\n\t\t\t);\n\t\t\tname = \"[CP] Embed Pods Frameworks\";\n\t\t\toutputPaths = (\n\t\t\t\t\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"${PODS_ROOT}/Target Support Files/Pods-Folo/Pods-Folo-frameworks.sh\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\t800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Folo/Pods-Folo-resources.sh\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseMessaging/FirebaseMessaging_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FollowNative/js.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/FollowNative/FollowNative.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/GoogleDataTransport/GoogleDataTransport_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/Mute/Mute.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit_Privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle\",\n\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle\",\n\t\t\t);\n\t\t\tname = \"[CP] Copy Pods Resources\";\n\t\t\toutputPaths = (\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseMessaging_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/js.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FollowNative.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleDataTransport_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Mute.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SnapKit_Privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle\",\n\t\t\t\t\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"${PODS_ROOT}/Target Support Files/Pods-Folo/Pods-Folo-resources.sh\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\t8F2D99FDEA6998C9D98BD7E0 /* [CP-User] [RNFB] Core Configuration */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)\",\n\t\t\t);\n\t\t\tname = \"[CP-User] [RNFB] Core Configuration\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"#!/usr/bin/env bash\\n#\\n# Copyright (c) 2016-present Invertase Limited & Contributors\\n#\\n# Licensed under the Apache License, Version 2.0 (the \\\"License\\\");\\n# you may not use this library except in compliance with the License.\\n# You may obtain a copy of the License at\\n#\\n#   http://www.apache.org/licenses/LICENSE-2.0\\n#\\n# Unless required by applicable law or agreed to in writing, software\\n# distributed under the License is distributed on an \\\"AS IS\\\" BASIS,\\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\\n# See the License for the specific language governing permissions and\\n# limitations under the License.\\n#\\n\\n##########################################################################\\n##########################################################################\\n#\\n#  NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\\n#\\n#  This file is installed as an Xcode build script in the project file\\n#  by cocoapods, and you will not see your changes until you pod install\\n#\\n##########################################################################\\n##########################################################################\\n\\nset -e\\n\\n_MAX_LOOKUPS=2;\\n_SEARCH_RESULT=''\\n_RN_ROOT_EXISTS=''\\n_CURRENT_LOOKUPS=1\\n_JSON_ROOT=\\\"'react-native'\\\"\\n_JSON_FILE_NAME='firebase.json'\\n_JSON_OUTPUT_BASE64='e30=' # { }\\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\\n_TARGET_PLIST=\\\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\\\"\\n_DSYM_PLIST=\\\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\\\"\\n\\n# plist arrays\\n_PLIST_ENTRY_KEYS=()\\n_PLIST_ENTRY_TYPES=()\\n_PLIST_ENTRY_VALUES=()\\n\\nfunction setPlistValue {\\n  echo \\\"info:      setting plist entry '$1' of type '$2' in file '$4'\\\"\\n  ${_PLIST_BUDDY} -c \\\"Add :$1 $2 '$3'\\\" $4 || echo \\\"info:      '$1' already exists\\\"\\n}\\n\\nfunction getFirebaseJsonKeyValue () {\\n  if [[ ${_RN_ROOT_EXISTS} ]]; then\\n    ruby -Ku -e \\\"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\\\"\\n  else\\n    echo \\\"\\\"\\n  fi;\\n}\\n\\nfunction jsonBoolToYesNo () {\\n  if [[ $1 == \\\"false\\\" ]]; then\\n    echo \\\"NO\\\"\\n  elif [[ $1 == \\\"true\\\" ]]; then\\n    echo \\\"YES\\\"\\n  else echo \\\"NO\\\"\\n  fi\\n}\\n\\necho \\\"info: -> RNFB build script started\\\"\\necho \\\"info: 1) Locating ${_JSON_FILE_NAME} file:\\\"\\n\\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\\n  _CURRENT_SEARCH_DIR=$(pwd)\\nfi;\\n\\nwhile true; do\\n  _CURRENT_SEARCH_DIR=$(dirname \\\"$_CURRENT_SEARCH_DIR\\\")\\n  if [[ \\\"$_CURRENT_SEARCH_DIR\\\" == \\\"/\\\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\\n  echo \\\"info:      ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\\\"\\n  _SEARCH_RESULT=$(find \\\"$_CURRENT_SEARCH_DIR\\\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\\n  if [[ ${_SEARCH_RESULT} ]]; then\\n    echo \\\"info:      ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\\\"\\n    break;\\n  fi;\\n  _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\\ndone\\n\\nif [[ ${_SEARCH_RESULT} ]]; then\\n  _JSON_OUTPUT_RAW=$(cat \\\"${_SEARCH_RESULT}\\\")\\n  _RN_ROOT_EXISTS=$(ruby -Ku -e \\\"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\\\" || echo '')\\n\\n  if [[ ${_RN_ROOT_EXISTS} ]]; then\\n    if ! python3 --version >/dev/null 2>&1; then echo \\\"python3 not found, firebase.json file processing error.\\\" && exit 1; fi\\n    _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\\\"'${_SEARCH_RESULT}'\\\"', '\\\"'rb'\\\"').read())['${_JSON_ROOT}']), '\\\"'utf-8'\\\"')).decode())' || echo \\\"e30=\\\")\\n  fi\\n\\n  _PLIST_ENTRY_KEYS+=(\\\"firebase_json_raw\\\")\\n  _PLIST_ENTRY_TYPES+=(\\\"string\\\")\\n  _PLIST_ENTRY_VALUES+=(\\\"$_JSON_OUTPUT_BASE64\\\")\\n\\n  # config.app_data_collection_default_enabled\\n  _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"app_data_collection_default_enabled\\\")\\n  if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FirebaseDataCollectionDefaultEnabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_APP_DATA_COLLECTION_ENABLED\\\")\\\")\\n  fi\\n\\n  # config.analytics_auto_collection_enabled\\n  _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_auto_collection_enabled\\\")\\n  if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_AUTO_COLLECTION\\\")\\\")\\n  fi\\n\\n  # config.analytics_collection_deactivated\\n  _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_collection_deactivated\\\")\\n  if [[ $_ANALYTICS_DEACTIVATED ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_DEACTIVATED\\\")\\\")\\n  fi\\n\\n  # config.analytics_idfv_collection_enabled\\n  _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_idfv_collection_enabled\\\")\\n  if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_IDFV_COLLECTION\\\")\\\")\\n  fi\\n\\n  # config.analytics_default_allow_analytics_storage\\n  _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_default_allow_analytics_storage\\\")\\n  if [[ $_ANALYTICS_STORAGE ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_STORAGE\\\")\\\")\\n  fi\\n\\n  # config.analytics_default_allow_ad_storage\\n  _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_default_allow_ad_storage\\\")\\n  if [[ $_ANALYTICS_AD_STORAGE ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_AD_STORAGE\\\")\\\")\\n  fi\\n\\n  # config.analytics_default_allow_ad_user_data\\n  _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_default_allow_ad_user_data\\\")\\n  if [[ $_ANALYTICS_AD_USER_DATA ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_AD_USER_DATA\\\")\\\")\\n  fi\\n\\n  # config.analytics_default_allow_ad_personalization_signals\\n  _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"analytics_default_allow_ad_personalization_signals\\\")\\n  if [[ $_ANALYTICS_PERSONALIZATION ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_PERSONALIZATION\\\")\\\")\\n  fi\\n\\n  # config.analytics_registration_with_ad_network_enabled\\n  _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"google_analytics_registration_with_ad_network_enabled\\\")\\n  if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\\\")\\\")\\n  fi\\n\\n  # config.google_analytics_automatic_screen_reporting_enabled\\n  _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"google_analytics_automatic_screen_reporting_enabled\\\")\\n  if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FirebaseAutomaticScreenReportingEnabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_ANALYTICS_AUTO_SCREEN_REPORTING\\\")\\\")\\n  fi\\n\\n  # config.perf_auto_collection_enabled\\n  _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"perf_auto_collection_enabled\\\")\\n  if [[ $_PERF_AUTO_COLLECTION ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"firebase_performance_collection_enabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_PERF_AUTO_COLLECTION\\\")\\\")\\n  fi\\n\\n  # config.perf_collection_deactivated\\n  _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"perf_collection_deactivated\\\")\\n  if [[ $_PERF_DEACTIVATED ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"firebase_performance_collection_deactivated\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_PERF_DEACTIVATED\\\")\\\")\\n  fi\\n\\n  # config.messaging_auto_init_enabled\\n  _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"messaging_auto_init_enabled\\\")\\n  if [[ $_MESSAGING_AUTO_INIT ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FirebaseMessagingAutoInitEnabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_MESSAGING_AUTO_INIT\\\")\\\")\\n  fi\\n\\n  # config.in_app_messaging_auto_colllection_enabled\\n  _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"in_app_messaging_auto_collection_enabled\\\")\\n  if [[ $_FIAM_AUTO_INIT ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_FIAM_AUTO_INIT\\\")\\\")\\n  fi\\n\\n  # config.app_check_token_auto_refresh\\n  _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"app_check_token_auto_refresh\\\")\\n  if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\\n    _PLIST_ENTRY_KEYS+=(\\\"FirebaseAppCheckTokenAutoRefreshEnabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"$(jsonBoolToYesNo \\\"$_APP_CHECK_TOKEN_AUTO_REFRESH\\\")\\\")\\n  fi\\n\\n  # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\\n  _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \\\"$_JSON_OUTPUT_RAW\\\" \\\"crashlytics_disable_auto_disabler\\\")\\n  if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \\\"true\\\" ]]; then\\n    echo \\\"Disabled Crashlytics auto disabler.\\\" # do nothing\\n  else\\n    _PLIST_ENTRY_KEYS+=(\\\"FirebaseCrashlyticsCollectionEnabled\\\")\\n    _PLIST_ENTRY_TYPES+=(\\\"bool\\\")\\n    _PLIST_ENTRY_VALUES+=(\\\"NO\\\")\\n  fi\\nelse\\n  _PLIST_ENTRY_KEYS+=(\\\"firebase_json_raw\\\")\\n  _PLIST_ENTRY_TYPES+=(\\\"string\\\")\\n  _PLIST_ENTRY_VALUES+=(\\\"$_JSON_OUTPUT_BASE64\\\")\\n  echo \\\"warning:   A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\\\"\\nfi;\\n\\necho \\\"info: 2) Injecting Info.plist entries: \\\"\\n\\n# Log out the keys we're adding\\nfor i in \\\"${!_PLIST_ENTRY_KEYS[@]}\\\"; do\\n  echo \\\"    ->  $i) ${_PLIST_ENTRY_KEYS[$i]}\\\" \\\"${_PLIST_ENTRY_TYPES[$i]}\\\" \\\"${_PLIST_ENTRY_VALUES[$i]}\\\"\\ndone\\n\\nfor plist in \\\"${_TARGET_PLIST}\\\" \\\"${_DSYM_PLIST}\\\" ; do\\n  if [[ -f \\\"${plist}\\\" ]]; then\\n\\n    # paths with spaces break the call to setPlistValue. temporarily modify\\n    # the shell internal field separator variable (IFS), which normally\\n    # includes spaces, to consist only of line breaks\\n    oldifs=$IFS\\n    IFS=\\\"\\n\\\"\\n\\n    for i in \\\"${!_PLIST_ENTRY_KEYS[@]}\\\"; do\\n      setPlistValue \\\"${_PLIST_ENTRY_KEYS[$i]}\\\" \\\"${_PLIST_ENTRY_TYPES[$i]}\\\" \\\"${_PLIST_ENTRY_VALUES[$i]}\\\" \\\"${plist}\\\"\\n    done\\n\\n    # restore the original internal field separator value\\n    IFS=$oldifs\\n  else\\n    echo \\\"warning:   A Info.plist build output file was not found (${plist})\\\"\\n  fi\\ndone\\n\\necho \\\"info: <- RNFB build script finished\\\"\\n\";\n\t\t};\n\t\tE2A317A7E9D9C06F986E7E62 /* [Expo] Configure project */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"$(SRCROOT)/.xcode.env\",\n\t\t\t\t\"$(SRCROOT)/.xcode.env.local\",\n\t\t\t\t\"$(SRCROOT)/Folo/Folo.entitlements\",\n\t\t\t\t\"$(SRCROOT)/Pods/Target Support Files/Pods-Folo/expo-configure-project.sh\",\n\t\t\t);\n\t\t\tname = \"[Expo] Configure project\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(SRCROOT)/Pods/Target Support Files/Pods-Folo/ExpoModulesProvider.swift\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"# This script configures Expo modules and generates the modules provider file.\\nbash -l -c \\\"./Pods/Target\\\\ Support\\\\ Files/Pods-Folo/expo-configure-project.sh\\\"\\n\";\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t13B07F871A680F5B00A75B9A /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tF11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,\n\t\t\t\t948BDAD26FE9D66B8735BBD0 /* ExpoModulesProvider.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin XCBuildConfiguration section */\n\t\t13B07F941A680F5B00A75B9A /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 84F18C4CB344992039541AAC /* Pods-Folo.debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Folo/Folo.entitlements;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = 492J8Q67PF;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"FB_SONARKIT_ENABLED=1\",\n\t\t\t\t);\n\t\t\t\tINFOPLIST_FILE = Folo/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tOTHER_LDFLAGS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"-ObjC\",\n\t\t\t\t\t\"-lc++\",\n\t\t\t\t);\n\t\t\t\tOTHER_SWIFT_FLAGS = \"$(inherited) -D EXPO_CONFIGURATION_DEBUG\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = is.follow;\n\t\t\t\tPRODUCT_NAME = Folo;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Folo/Folo-Bridging-Header.h\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t13B07F951A680F5B00A75B9A /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 5D1548572A564A709411B1D2 /* Pods-Folo.release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Folo/Folo.entitlements;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = 492J8Q67PF;\n\t\t\t\tINFOPLIST_FILE = Folo/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tOTHER_LDFLAGS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"-ObjC\",\n\t\t\t\t\t\"-lc++\",\n\t\t\t\t);\n\t\t\t\tOTHER_SWIFT_FLAGS = \"$(inherited) -D EXPO_CONFIGURATION_RELEASE\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = is.follow;\n\t\t\t\tPRODUCT_NAME = Folo;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Folo/Folo-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t83CBBA201A601CBA00E9B192 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"c++20\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_SYMBOLS_PRIVATE_EXTERN = NO;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tHEADER_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios\",\n\t\t\t\t);\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t/usr/lib/swift,\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = \"$(SDKROOT)/usr/lib/swift\\\"$(inherited)\\\"\";\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tOTHER_LDFLAGS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\" \",\n\t\t\t\t);\n\t\t\t\tREACT_NATIVE_PATH = \"${PODS_ROOT}/../../../../node_modules/react-native\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = \"$(inherited) DEBUG\";\n\t\t\t\tUSE_HERMES = true;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t83CBBA211A601CBA00E9B192 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"c++20\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = YES;\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tHEADER_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers\",\n\t\t\t\t\t\"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios\",\n\t\t\t\t);\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t/usr/lib/swift,\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = \"$(SDKROOT)/usr/lib/swift\\\"$(inherited)\\\"\";\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tOTHER_LDFLAGS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\" \",\n\t\t\t\t);\n\t\t\t\tREACT_NATIVE_PATH = \"${PODS_ROOT}/../../../../node_modules/react-native\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tUSE_HERMES = true;\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget \"Folo\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t13B07F941A680F5B00A75B9A /* Debug */,\n\t\t\t\t13B07F951A680F5B00A75B9A /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject \"Folo\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t83CBBA201A601CBA00E9B192 /* Debug */,\n\t\t\t\t83CBBA211A601CBA00E9B192 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;\n}\n"
  },
  {
    "path": "apps/mobile/ios/Folo.xcodeproj/xcshareddata/xcschemes/Folo.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1130\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"13B07F861A680F5B00A75B9A\"\n               BuildableName = \"Folo.app\"\n               BlueprintName = \"Folo\"\n               ReferencedContainer = \"container:Folo.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <Testables>\n         <TestableReference\n            skipped = \"NO\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"00E356ED1AD99517003FC87E\"\n               BuildableName = \"FoloTests.xctest\"\n               BlueprintName = \"FoloTests\"\n               ReferencedContainer = \"container:Folo.xcodeproj\">\n            </BuildableReference>\n         </TestableReference>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"13B07F861A680F5B00A75B9A\"\n            BuildableName = \"Folo.app\"\n            BlueprintName = \"Folo\"\n            ReferencedContainer = \"container:Folo.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n      <StoreKitConfigurationFileReference\n         identifier = \"../Folo - Follow everything.storekit\">\n      </StoreKitConfigurationFileReference>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"13B07F861A680F5B00A75B9A\"\n            BuildableName = \"Folo.app\"\n            BlueprintName = \"Folo\"\n            ReferencedContainer = \"container:Folo.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "apps/mobile/ios/Folo.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"group:Folo.xcodeproj\">\n   </FileRef>\n   <FileRef\n      location = \"group:Pods/Pods.xcodeproj\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "apps/mobile/ios/Podfile",
    "content": "require File.join(File.dirname(`node --print \"require.resolve('expo/package.json')\"`), \"scripts/autolinking\")\nrequire File.join(File.dirname(`node --print \"require.resolve('react-native/package.json')\"`), \"scripts/react_native_pods\")\n\nrequire 'json'\npodfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}\n\nENV['RCT_NEW_ARCH_ENABLED'] = '0' if podfile_properties['newArchEnabled'] == 'false'\nENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']\n\nplatform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'\ninstall! 'cocoapods',\n  :deterministic_uuids => false\n\nprepare_react_native_project!\n\ndef generate_react_native_codegen!(ios_root)\n  app_root = File.expand_path('..', ios_root)\n  react_native_root = File.dirname(`node --print \"require.resolve('react-native/package.json', { paths: ['#{ios_root}'] })\"`.strip)\n  react_native_codegen_cli = File.join(react_native_root, 'scripts', 'generate-codegen-artifacts.js')\n\n  Pod::UI.puts 'Generating React Native codegen artifacts for iOS...'\n\n  codegen_output_root = File.join(ios_root, 'build', 'generated', 'ios')\n\n  system(\n    'node',\n    react_native_codegen_cli,\n    '--path',\n    app_root,\n    '--targetPlatform',\n    'ios',\n    '--outputPath',\n    codegen_output_root,\n  ) || raise('React Native iOS codegen generation failed')\nend\n\ntarget 'Folo' do\n  use_expo_modules!\n\n  if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'\n    config_command = ['node', '-e', \"process.argv=['', '', 'config'];require('@react-native-community/cli').run()\"];\n  else\n    expo_modules_autolinking_root = File.dirname(`node --print \"require.resolve('expo-modules-autolinking/package.json', { paths: ['#{__dir__}'] })\"`)\n    expo_modules_autolinking_cli = File.join(expo_modules_autolinking_root, 'bin/expo-modules-autolinking.js')\n    config_command = [\n      'node',\n      expo_modules_autolinking_cli,\n      'react-native-config',\n      '--json',\n      '--platform',\n      'ios'\n    ]\n  end\n\n  generate_react_native_codegen!(__dir__)\n\n  config = use_native_modules!(config_command)\n\n  use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']\n  use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']\n\n  use_react_native!(\n    :path => config[:reactNativePath],\n    :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',\n    # An absolute path to your application root.\n    :app_path => \"#{Pod::Config.instance.installation_root}/..\",\n    :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',\n  )\n\n  post_install do |installer|\n    react_native_post_install(\n      installer,\n      config[:reactNativePath],\n      :mac_catalyst_enabled => false,\n      :ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',\n    )\n\n    # This is necessary for Xcode 14, because it signs resource bundles by default\n    # when building for devices.\n    installer.target_installation_results.pod_target_installation_results\n      .each do |pod_name, target_installation_result|\n      target_installation_result.resource_bundle_targets.each do |resource_bundle_target|\n        resource_bundle_target.build_configurations.each do |config|\n          config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "apps/mobile/ios/Podfile.properties.json",
    "content": "{\n  \"expo.jsEngine\": \"hermes\",\n  \"EX_DEV_CLIENT_NETWORK_INSPECTOR\": \"true\",\n  \"newArchEnabled\": \"true\",\n  \"ios.useFrameworks\": \"static\",\n  \"apple.privacyManifestAggregationEnabled\": \"true\"\n}\n"
  },
  {
    "path": "apps/mobile/metro.config.js",
    "content": "const { getDefaultConfig } = require(\"expo/metro-config\")\nconst { withNativeWind } = require(\"nativewind/metro\")\nconst path = require(\"pathe\")\nconst { wrapWithReanimatedMetroConfig } = require(\"react-native-reanimated/metro-config\")\n\nconst config = getDefaultConfig(__dirname, { isCSSEnabled: true })\nconst workspaceRoot = path.resolve(__dirname, \"../..\")\nconfig.resolver.sourceExts.push(\"sql\")\n\nconfig.transformer.getTransformOptions = async () => ({\n  transform: {\n    experimentalImportSupport: false,\n    inlineRequires: true,\n  },\n})\n\nconfig.resolver.nodeModulesPaths = [\n  path.resolve(__dirname, \"./node_modules\"),\n  path.resolve(workspaceRoot, \"node_modules\"),\n]\n\nconfig.resolver.extraNodeModules = {\n  ...config.resolver.extraNodeModules,\n  \"@locales\": path.resolve(__dirname, \"../../locales\"),\n}\n\nconfig.watchFolders = Array.from(\n  new Set([...config.watchFolders, workspaceRoot, path.resolve(workspaceRoot, \"locales\")]),\n)\n\nconfig.resolver.resolveRequest = (context, moduleName, platform) => {\n  const result = context.resolveRequest(context, moduleName, platform)\n  if (result.type === \"sourceFile\") {\n    const lastDotIndex = result.filePath.lastIndexOf(\".\")\n    const mobilePath = `${result.filePath.slice(0, lastDotIndex)}.rn${result.filePath.slice(lastDotIndex)}`\n    const file = context.fileSystemLookup(mobilePath)\n    if (file.exists) {\n      return {\n        ...result,\n        filePath: mobilePath,\n      }\n    } else {\n      return result\n    }\n  }\n  return result\n}\n\nmodule.exports = wrapWithReanimatedMetroConfig(\n  withNativeWind(config, { input: \"./src/global.css\" }),\n)\n"
  },
  {
    "path": "apps/mobile/native/.eslintrc.js",
    "content": "module.exports = {\n  root: true,\n  extends: [\"universe/native\", \"universe/web\"],\n  ignorePatterns: [\"build\"],\n}\n"
  },
  {
    "path": "apps/mobile/native/.gitignore",
    "content": "# OSX\n#\n.DS_Store\n\n# VSCode\n.vscode/\njsconfig.json\n\n# Xcode\n#\nbuild/\n*.pbxuser\n!default.pbxuser\n*.mode1v3\n!default.mode1v3\n*.mode2v3\n!default.mode2v3\n*.perspectivev3\n!default.perspectivev3\nxcuserdata\n*.xccheckout\n*.moved-aside\nDerivedData\n*.hmap\n*.ipa\n*.xcuserstate\nproject.xcworkspace\n\n# Android/IJ\n#\n.classpath\n.cxx\n.gradle\n.idea\n.project\n.settings\nlocal.properties\nandroid.iml\nandroid/app/libs\nandroid/keystores/debug.keystore\n\n# Cocoapods\n#\nexample/ios/Pods\n\n# Ruby\nexample/vendor/\n\n# node.js\n#\nnode_modules/\nnpm-debug.log\nyarn-debug.log\nyarn-error.log\n\n# Expo\n.expo/*\n"
  },
  {
    "path": "apps/mobile/native/.npmignore",
    "content": "# Exclude all top-level hidden directories by convention\n/.*/\n\n# Exclude tarballs generated by `npm pack`\n/*.tgz\n\n__mocks__\n__tests__\n\n/babel.config.js\n/android/src/androidTest/\n/android/src/test/\n/android/build/\n/example/\n"
  },
  {
    "path": "apps/mobile/native/README.md",
    "content": "# follow-native\n\nNope\n\n# API documentation\n\n- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/follow-native/)\n- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/follow-native/)\n\n# Installation in managed Expo projects\n\nFor [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects &mdash; it is likely to be included in an upcoming Expo SDK release.\n\n# Installation in bare React Native projects\n\nFor bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.\n\n### Add the package to your npm dependencies\n\n```\nnpm install follow-native\n```\n\n### Configure for Android\n\n### Configure for iOS\n\nRun `npx pod-install` after installing the npm package.\n\n# Contributing\n\nContributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).\n"
  },
  {
    "path": "apps/mobile/native/expo-module.config.json",
    "content": "{\n  \"platforms\": [\"apple\", \"android\"],\n  \"apple\": {\n    \"modules\": [\n      \"SharedWebViewModule\",\n      \"HelperModule\",\n      \"ToasterModule\",\n      \"AppleIntelligenceGlowEffectModule\",\n      \"TabBarModule\",\n      \"TabScreenModule\",\n      \"TabBarPortalModule\",\n      \"EnhancePagerViewModule\",\n      \"EnhancePageViewModule\",\n      \"ItemPressableModule\",\n      \"TabBarBottomAccessoryModule\",\n      \"StoreKitTestHelperModule\"\n    ]\n  },\n  \"android\": {\n    \"modules\": [\n      \"expo.modules.follownative.tabbar.TabBarModule\",\n      \"expo.modules.follownative.tabbar.TabScreenModule\",\n      \"expo.modules.follownative.tabbar.TabBarPortalModule\",\n      \"expo.modules.follownative.itempressable.ItemPressableModule\"\n    ]\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Controllers/ModalWebViewController.swift",
    "content": "import SnapKit\nimport UIKit\nimport WebKit\n\nclass ModalWebViewController: UIViewController {\n    private let webView: WKWebView\n    private let url: URL\n\n    init(url: URL) {\n        self.url = url\n\n        let configuration = WKWebViewConfiguration()\n\n        self.webView = WKWebView(frame: .zero, configuration: configuration)\n        super.init(nibName: nil, bundle: nil)\n\n        webView.navigationDelegate = self\n    }\n\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) has not been implemented\")\n    }\n\n    override func viewDidLoad() {\n      super.viewDidLoad()\n      if let navVC = self.navigationController  {\n        let bar = navVC.navigationBar\n       self.webView.scrollView.contentInset.top = bar.frame.height - view.safeAreaInsets.top\n      }\n\n        setupNavigationBar()\n        setupWebView()\n        loadContent()\n        setupInteractivePopGesture()\n\n    }\n\n    private func setupInteractivePopGesture() {\n        navigationController?.interactivePopGestureRecognizer?.delegate = self\n        navigationController?.interactivePopGestureRecognizer?.isEnabled = true\n    }\n\n    private func setupNavigationBar() {\n        let closeButton = UIBarButtonItem(\n            barButtonSystemItem: .close,\n            target: self,\n            action: #selector(closeModal)\n        )\n\n        let safariButton = UIBarButtonItem(\n            image: UIImage(systemName: \"safari\"),\n            style: .plain,\n            target: self,\n            action: #selector(openInSafari)\n        )\n        safariButton.tintColor = Utils.accentColor\n\n        navigationItem.leftBarButtonItem = closeButton\n        navigationItem.rightBarButtonItem = safariButton\n\n        let appearance = UINavigationBarAppearance()\n        appearance.configureWithDefaultBackground()\n        navigationController?.navigationBar.standardAppearance = appearance\n        navigationController?.navigationBar.scrollEdgeAppearance = appearance\n    }\n\n    private func setupWebView() {\n        view.addSubview(webView)\n        view.backgroundColor = .systemBackground\n        webView.snp.makeConstraints { make in\n          make.edges.equalToSuperview()\n        }\n    }\n\n    private func loadContent() {\n        let request = URLRequest(url: url)\n        webView.load(request)\n    }\n\n    @objc private func closeModal() {\n        dismiss(animated: true)\n    }\n\n    @objc private func openInSafari() {\n        UIApplication.shared.open(url)\n    }\n}\n\n// MARK: - WKNavigationDelegate\nextension ModalWebViewController: WKNavigationDelegate {\n    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {\n        navigationItem.title = webView.title\n    }\n}\n\n// MARK: - UIGestureRecognizerDelegate\nextension ModalWebViewController: UIGestureRecognizerDelegate {\n    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {\n        return true\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Controllers/RNSViewController.swift",
    "content": "//\n//  RNSViewController.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/27.\n//\n\nimport UIKit\n\nclass RNSViewController: UIViewController {\n  @objc public let screenView: UIView? = nil\n  \n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Controllers/WebViewController.swift",
    "content": "//\n//  WebViewController.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/27.\n//\n\nimport Foundation\nimport SnapKit\nimport UIKit\nimport WebKit\n\nclass WebViewController: RNSViewController {\n  private let webView: WKWebView\n  private let url: URL\n\n  init(url: URL) {\n    self.url = url\n\n    let configuration = WKWebViewConfiguration()\n\n    self.webView = WKWebView(frame: .zero, configuration: configuration)\n    super.init(nibName: nil, bundle: nil)\n\n    webView.navigationDelegate = self\n  }\n\n  required init?(coder: NSCoder) {\n    fatalError(\"init(coder:) has not been implemented\")\n  }\n\n  override func viewDidLoad() {\n    super.viewDidLoad()\n\n    setupWebView()\n    setupToolbar()\n    loadContent()\n    setupInteractivePopGesture()\n  }\n\n  private func setupInteractivePopGesture() {\n    navigationController?.interactivePopGestureRecognizer?.delegate = self\n    navigationController?.interactivePopGestureRecognizer?.isEnabled = true\n  }\n\n  private func setupWebView() {\n    view.addSubview(webView)\n    view.backgroundColor = .systemBackground\n    webView.snp.makeConstraints { make in\n      make.top.equalTo(view.safeAreaLayoutGuide)\n      make.left.right.bottom.equalToSuperview()\n    }\n  }\n\n  private func setupToolbar() {\n\n    navigationController?.isToolbarHidden = false\n\n    let backButton = UIBarButtonItem(\n      image: UIImage(systemName: \"chevron.backward\"), style: .plain, target: self,\n      action: #selector(goBack))\n    backButton.tintColor = Utils.accentColor\n\n    let forwardButton = UIBarButtonItem(\n      image: UIImage(systemName: \"chevron.forward\"), style: .plain, target: self,\n      action: #selector(goForward))\n    forwardButton.tintColor = Utils.accentColor\n\n    let refreshButton = UIBarButtonItem(\n      barButtonSystemItem: .refresh, target: self, action: #selector(refreshPage))\n    refreshButton.tintColor = Utils.accentColor\n\n    let safariButton = UIBarButtonItem(\n      image: UIImage(systemName: \"safari\"), style: .plain, target: self,\n      action: #selector(openInSafari))\n    safariButton.tintColor = Utils.accentColor\n\n    let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)\n\n    toolbarItems = [\n      flexSpace, backButton, flexSpace, forwardButton, flexSpace, refreshButton, flexSpace,\n      safariButton, flexSpace,\n    ]\n  }\n\n  private func loadContent() {\n    let request = URLRequest(url: url)\n    webView.load(request)\n  }\n\n  @objc private func openInSafari() {\n    UIApplication.shared.open(url)\n  }\n\n  @objc private func goBack() {\n    if webView.canGoBack {\n      webView.goBack()\n    }\n  }\n\n  @objc private func goForward() {\n    if webView.canGoForward {\n      webView.goForward()\n    }\n  }\n\n  @objc private func refreshPage() {\n    webView.reload()\n  }\n}\n\n// MARK: - WKNavigationDelegate\nextension WebViewController: WKNavigationDelegate {\n  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {\n    navigationItem.title = webView.title\n\n    updateToolbarButtonsState()\n  }\n\n  func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {\n\n    updateToolbarButtonsState()\n  }\n\n  private func updateToolbarButtonsState() {\n\n    if let items = toolbarItems {\n\n      if let backButton = items[1] as? UIBarButtonItem {\n        backButton.isEnabled = webView.canGoBack\n      }\n\n      if let forwardButton = items[3] as? UIBarButtonItem {\n        forwardButton.isEnabled = webView.canGoForward\n      }\n    }\n  }\n}\n\n// MARK: - UIGestureRecognizerDelegate\nextension WebViewController: UIGestureRecognizerDelegate {\n  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {\n    return true\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Extensions/UIColor+Hex.swift",
    "content": "//\n//  UIColor+Hex.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/6.\n//\n\nimport UIKit\n\nextension UIColor {\n    func toHex() -> String {\n        var red: CGFloat = 0\n        var green: CGFloat = 0\n        var blue: CGFloat = 0\n        var alpha: CGFloat = 0\n\n        guard self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {\n            assertionFailure(\"Failed to get RGBA components from UIColor\")\n            return \"#000000\"\n        }\n\n        // Clamp components to [0.0, 1.0]\n        red = max(0, min(1, red))\n        green = max(0, min(1, green))\n        blue = max(0, min(1, blue))\n        alpha = max(0, min(1, alpha))\n\n        if alpha == 1 {\n            // RGB\n            return String(\n                format: \"#%02lX%02lX%02lX\",\n                Int(round(red * 255)),\n                Int(round(green * 255)),\n                Int(round(blue * 255))\n            )\n        } else {\n            // RGBA\n            return String(\n                format: \"#%02lX%02lX%02lX%02lX\",\n                Int(round(red * 255)),\n                Int(round(green * 255)),\n                Int(round(blue * 255)),\n                Int(round(alpha * 255))\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Extensions/UIImage+asActivityItemSource.swift",
    "content": "import CoreGraphics\nimport LinkPresentation\nimport UIKit\n\nextension UIImage {\n  private final class UIImageActivityItemSource: NSObject, UIActivityItemSource {\n    var image = UIImage()\n    var title: String?\n    var url: URL?\n\n    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController)\n      -> Any\n    {\n      UIImage()\n    }\n\n    func activityViewController(\n      _ activityViewController: UIActivityViewController,\n      itemForActivityType activityType: UIActivity.ActivityType?\n    ) -> Any? {\n      image\n    }\n\n    func activityViewControllerLinkMetadata(_: UIActivityViewController) -> LPLinkMetadata? {\n      let metadata = LPLinkMetadata()\n\n      if let url = url {\n        metadata.originalURL = url\n        metadata.url = url\n      }\n\n      if let title = title {\n        metadata.title = title\n      }\n\n      // This makes it so that the activity controller shows a thumbnail. It wouldn't if you just pass a plain UIImage to it.\n      metadata.imageProvider = NSItemProvider(object: image)\n\n      return metadata\n    }\n  }\n\n  /**\n  Makes the image a source for an `UIActivityViewController`.\n\n  The benefit of using this instead of passing the image directly is that with this one the activity controller shows a thumbnail and you can also set title and URL.\n  */\n  func asActivityItemSource(\n    title: String? = nil,\n    url: URL? = nil\n  ) -> UIActivityItemSource {\n    let itemSource = UIImageActivityItemSource()\n    itemSource.image = self\n    let pixelWidth = Int(self.size.width * self.scale)\n    let pixelHeight = Int(self.size.height * self.scale)\n\n    itemSource.title = title ?? \"\\(pixelHeight)x\\(pixelWidth)\"\n    itemSource.url = url\n    return itemSource\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Extensions/UIImage.swift",
    "content": "//\n//  UIImage.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/12.\n//\n\nimport UIKit\n\nextension UIImage {\n    func withAlpha(_ alpha: CGFloat) -> UIImage {\n        return UIGraphicsImageRenderer(size: size).image { _ in\n            draw(at: .zero, blendMode: .normal, alpha: alpha)\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Extensions/UIWindow.swift",
    "content": "//\n//  UIWindow.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/27.\n//\n\nimport UIKit\n\nextension UIWindow {\n\n  static func findViewController<T: UIViewController>(ofType type: T.Type) -> T? {\n    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\n      let window = windowScene.windows.first\n    else {\n      return nil\n    }\n\n    if let rootVC = window.rootViewController {\n      return findViewControllerInHierarchy(rootVC, ofType: type)\n    }\n    return nil\n  }\n\n  static private func findViewControllerInHierarchy<T: UIViewController>(\n    _ viewController: UIViewController, ofType type: T.Type\n  ) -> T? {\n\n    if let targetVC = viewController as? T {\n      return targetVC\n    }\n\n    if let navController = viewController as? UINavigationController {\n      if let visibleVC = navController.visibleViewController {\n        return findViewControllerInHierarchy(visibleVC, ofType: type)\n      }\n    }\n\n    if let tabController = viewController as? UITabBarController {\n      if let selectedVC = tabController.selectedViewController {\n        return findViewControllerInHierarchy(selectedVC, ofType: type)\n      }\n    }\n\n    for childVC in viewController.children {\n      if let foundVC = findViewControllerInHierarchy(childVC, ofType: type) {\n        return foundVC\n      }\n    }\n\n    if let presentedVC = viewController.presentedViewController {\n      return findViewControllerInHierarchy(presentedVC, ofType: type)\n    }\n\n    return nil\n  }\n\n  public static func findRNSNavigationController() -> UINavigationController? {\n\n    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\n      let window = windowScene.windows.first\n    else {\n      return nil\n    }\n\n    let rootViewController = window.rootViewController\n\n    if let navController = rootViewController as? UINavigationController,\n      NSStringFromClass(type(of: navController)).contains(\"RNSNavigationController\")\n    {\n      return navController\n    }\n\n    return findRNSNavigationControllerInChildren(of: rootViewController)\n  }\n\n  private static func findRNSNavigationControllerInChildren(of viewController: UIViewController?)\n    -> UINavigationController?\n  {\n    guard let viewController = viewController else {\n      return nil\n    }\n\n    if let presentedVC = viewController.presentedViewController {\n      if let navController = presentedVC as? UINavigationController,\n        NSStringFromClass(type(of: navController)).contains(\"RNSNavigationController\")\n      {\n        return navController\n      }\n\n      if let result = findRNSNavigationControllerInChildren(of: presentedVC) {\n        return result\n      }\n    }\n\n    for childVC in viewController.children {\n      if let navController = childVC as? UINavigationController,\n        NSStringFromClass(type(of: navController)).contains(\"RNSNavigationController\")\n      {\n        return navController\n      }\n\n      if let result = findRNSNavigationControllerInChildren(of: childVC) {\n        return result\n      }\n    }\n\n    return nil\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/FollowNative.podspec",
    "content": "require 'json'\nnew_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'\n\npackage = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))\n\nPod::Spec.new do |s|\n  s.name           = 'FollowNative'\n  s.version        = package['version']\n  s.summary        = package['description']\n  s.description    = package['description']\n  s.license        = package['license']\n  s.author         = package['author']\n  s.homepage       = package['homepage']\n  s.platforms      = {\n    :ios => '15.1',\n    :tvos => '15.1'\n  }\n  s.swift_version  = '5.4'\n  s.source         = { git: 'https://github.com/RSSNext/follow' }\n  s.static_framework = true\n\n  s.dependency 'ExpoModulesCore'\n  s.dependency 'SnapKit', '~> 5.7.0'\n  s.dependency 'SDWebImage', '~> 5.0'\n  s.dependency \"ToastViewSwift\", \"~> 2.1.3\"\n  # Swift/Objective-C compatibility\n  s.pod_target_xcconfig = {\n    'DEFINES_MODULE' => 'YES',\n    'OTHER_SWIFT_FLAGS' => \"$(inherited) #{new_arch_enabled ? '-DRCT_NEW_ARCH_ENABLED' : ''}\",\n  }\n\n  s.source_files = \"**/*.{h,m,mm,swift,hpp,cpp}\"\n\n  s.resource_bundles = {\n    'js' => ['Modules/SharedWebView/Injected/**/*'],\n    'FollowNative' => ['Media.xcassets'],\n  }\n\nend\n"
  },
  {
    "path": "apps/mobile/native/ios/Models/ProfileData.swift",
    "content": "import Foundation\nimport ExpoModulesCore\n\nstruct ProfileData: Codable {\n  var lists: [ProfileList]\n  var feeds: [ProfileFeed]\n  var groupedFeeds: [String: [ProfileFeed]]\n\n  static var mockData: ProfileData {\n    let decoder = JSONDecoder()\n    guard let url = Bundle.main.url(forResource: \"Profile\", withExtension: \"json\"),\n      let data = try? Data(contentsOf: url),\n      let profileData = try? decoder.decode(ProfileData.self, from: data)\n    else {\n      // Return empty data if decoding fails\n      return ProfileData(lists: [], feeds: [], groupedFeeds: [:])\n    }\n    return profileData\n  }\n}\n\nstruct ProfileList: Codable, Identifiable {\n  var id: String\n  var title: String\n  var image: String?\n  var description: String?\n  var view: FeedViewType\n  var customTitle: String?\n}\n\nstruct ProfileFeed: Codable, Identifiable {\n  var id: String\n  var title: String\n  var image: String?\n  var description: String?\n  var siteUrl: String\n  var url: String\n  var view: FeedViewType\n  var customTitle: String?\n}\n\nenum FeedViewType: Int, Codable {\n  case Article = 0\n  case SocialMedia = 1\n  case Image = 2\n  case Video = 3\n  case Audio = 4\n  case Notification = 5\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Models/UserData.swift",
    "content": "//\n//  UserData.swift\n//  SwiftUIDemo\n//\n//  Created by Innei on 2025/3/24.\n//\n\nimport Foundation\n\nstruct UserData: Codable {\n  var id: String\n  var name: String\n  var email: String\n  var emailVerified: Bool\n  var image: String\n  var createdAt: String\n  var updatedAt: String\n  var twoFactorEnabled: Bool\n  var isAnonymous: Bool?\n  var handle: String\n\n  static var mockData: UserData {\n      \n    let decoder = JSONDecoder()\n    guard let url = Bundle.main.url(forResource: \"User\", withExtension: \"json\"),\n      let data = try? Data(contentsOf: url),\n      let profileData = try? decoder.decode(UserData.self, from: data)\n    else {\n      // Return empty data if decoding fails\n      return UserData(\n        id: \"\", name: \"\", email: \"\", emailVerified: false, image: \"\", createdAt: \"\", updatedAt: \"\",\n        twoFactorEnabled: false, isAnonymous: false, handle: \"\")\n    }\n    return profileData\n  }\n\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/AppleIntelligenceGlowEffect/AppleIntelligenceGlowEffectModule.swift",
    "content": "//\n//  AppleIntelligenceGlowEffectModule.swift\n//  Pods\n//\n//  Created by Innei on 2025/2/24.\n//\n\nimport ExpoModulesCore\n\npublic class AppleIntelligenceGlowEffectModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"AppleIntelligenceGlowEffect\")\n\n     \n    Function(\"show\") {\n      DispatchQueue.main.async {\n        _ = showIntelligenceEffect()\n      }\n    }\n\n    Function(\"hide\") {\n      DispatchQueue.main.async {\n        hideIntelligenceEffect()\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/AppleIntelligenceGlowEffect/IntelligenceAnimationController.swift",
    "content": "import Foundation\nimport SwiftUI\nimport UIKit\n\n/// A private class to manage the presentation of the animation in a separate window.\nprivate class IntelligenceAnimationPresenter {\n  static let shared = IntelligenceAnimationPresenter()\n  private var window: UIWindow?\n\n  private init() {}\n\n  func show() -> () -> Void {\n    guard self.window == nil else {\n      // If window already exists, just return a closure to hide it.\n      return { self.hide() }\n    }\n\n    // Find the active window scene to display the new window.\n    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {\n      print(\"Could not find a UIWindowScene.\")\n      return {}\n    }\n\n    let animationView = IntelligenceAnimationView()\n    // Use UIHostingController to embed the SwiftUI view.\n    let hostingController = UIHostingController(rootView: animationView)\n    hostingController.view.backgroundColor = .clear\n\n    // Create and configure the new window.\n    let newWindow = UIWindow(windowScene: windowScene)\n    newWindow.rootViewController = hostingController\n    // Set a high window level to appear on top.\n    newWindow.windowLevel = .normal + 1\n    newWindow.backgroundColor = .clear\n    newWindow.isUserInteractionEnabled = false  // Let touches pass through.\n    newWindow.makeKeyAndVisible()\n\n    // Animate the appearance\n    newWindow.alpha = 0\n    UIView.animate(withDuration: 0.5) {\n      newWindow.alpha = 1\n    }\n\n    self.window = newWindow\n\n    return { self.hide() }\n  }\n\n  func hide() {\n    guard let window = self.window else { return }\n\n    UIView.animate(\n      withDuration: 0.5,\n      animations: {\n        window.alpha = 0\n      }\n    ) { _ in\n      // Clean up the window after the animation.\n      window.isHidden = true\n      self.window = nil\n    }\n  }\n}\n\n// MARK: - Public API\n\n@MainActor\n@discardableResult\npublic func showIntelligenceEffect() -> () -> Void {\n  return IntelligenceAnimationPresenter.shared.show()\n}\n\n@MainActor\npublic func hideIntelligenceEffect() {\n  IntelligenceAnimationPresenter.shared.hide()\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/AppleIntelligenceGlowEffect/IntelligenceAnimationView.swift",
    "content": "import SwiftUI\n\nstruct IntelligenceAnimationView: View {\n  @State private var rotation: Double = 0\n  @State private var shimmerPosition: CGFloat = -1.0\n\n  private let gradientColors: [Color] = [\n    .pink,\n    .purple,\n    .blue,\n    .cyan,\n    .green,\n    .yellow,\n    .orange,\n    .red,\n    .pink,\n  ]\n\n  var body: some View {\n    let baseBorder = ContainerRelativeShape()\n      .stroke(\n        AngularGradient(\n          gradient: Gradient(colors: gradientColors),\n          center: .center,\n          angle: .degrees(rotation)\n        ),\n        style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round)\n      )\n\n    let shimmer = LinearGradient(\n      gradient: Gradient(colors: [\n        Color.clear,\n        Color.white.opacity(0.8),\n        Color.clear,\n      ]),\n      startPoint: .init(x: shimmerPosition, y: 0.5),\n      endPoint: .init(x: shimmerPosition + 0.5, y: 0.5)\n    )\n\n    ZStack {\n      baseBorder\n        .overlay(\n          baseBorder\n            .brightness(0.3)\n            .mask(shimmer)\n        )\n        .blur(radius: 10)\n        .opacity(0.8)\n        .scaleEffect(1.02)  // A bit larger than the screen to avoid clipping\n        .transition(.scale(scale: 1.2).combined(with: .opacity))\n        .onAppear {\n          // Start rotation animation\n          withAnimation(.linear(duration: 8).repeatForever(autoreverses: false)) {\n            rotation = 360\n          }\n\n          // Start shimmer animation\n          withAnimation(\n            .linear(duration: 3)\n              .repeatForever(autoreverses: false)\n              .delay(1)\n          ) {\n            shimmerPosition = 1.5\n          }\n        }\n    }\n    .ignoresSafeArea()\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/Helper/Helper+Image.swift",
    "content": "//\n//  Helper+Image.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/7.\n//\n\nimport QuickLook\nimport SDWebImage\nimport UIKit\n\nprivate let previewContentDirectory = URL(fileURLWithPath: NSTemporaryDirectory())\n  .appendingPathComponent(Bundle.main.bundleIdentifier!)\n  .appendingPathComponent(\"image-preview\")\n\nclass PreviewControllerController: QLPreviewController, QLPreviewControllerDataSource,\n  QLPreviewControllerDelegate\n{\n  private var imageDataArray: [Data] = []\n  private var initialIndex: Int = 0\n\n  func numberOfPreviewItems(in controller: QLPreviewController) -> Int {\n    return imageDataArray.count\n  }\n\n  func prepareImages(_ images: [Data], initialIndex: Int = 0) {\n    self.imageDataArray = images\n    self.initialIndex = initialIndex\n  }\n\n  func prepareImages(imageUrls: [String], initialIndex: Int = 0) {\n    self.initialIndex = initialIndex\n    self.imageDataArray = []\n\n    // Create a dispatch group to wait for all downloads\n    let group = DispatchGroup()\n\n    for url in imageUrls {\n      group.enter()\n\n      // Use the same caching mechanism as FollowImageURLSchemeHandler\n      let originalURLString = url.removingPercentEncoding ?? url\n      let cacheKey = originalURLString\n      let imageCache = SDImageCache.shared\n\n      // Check if image is already cached\n      if let cachedData = imageCache.diskImageData(forKey: cacheKey) {\n        self.imageDataArray.append(cachedData)\n        group.leave()\n      } else {\n        // Download the image if not cached\n        SDWebImageManager.shared.loadImage(\n          with: URL(string: url),\n          options: [],\n          progress: nil\n        ) { (image, data, error, _, _, _) in\n          if let imageData = data {\n            // Store in cache using the same key as FollowImageURLSchemeHandler\n            imageCache.storeImageData(toDisk: imageData, forKey: cacheKey)\n            self.imageDataArray.append(imageData)\n          } else if let image = image, let data = image.sd_imageData() {\n            // Store in cache using the same key as FollowImageURLSchemeHandler\n            imageCache.storeImageData(toDisk: data, forKey: cacheKey)\n            self.imageDataArray.append(data)\n          }\n          group.leave()\n        }\n      }\n    }\n\n    // Wait for all downloads to complete\n    group.notify(queue: .main) {\n      // Refresh the view if it's already loaded\n      if self.isViewLoaded {\n        self.reloadData()\n      }\n    }\n  }\n\n  func previewController(_ controller: QLPreviewController, previewItemAt index: Int)\n    -> QLPreviewItem\n  {\n    let imageData = imageDataArray[index]\n    guard let image = UIImage(data: imageData) else {\n      return previewContentDirectory as QLPreviewItem\n    }\n    try? FileManager.default.createDirectory(\n      at: previewContentDirectory, withIntermediateDirectories: true)\n\n    let tempLocation =\n      previewContentDirectory\n      .appendingPathComponent(UUID().uuidString)\n      .appendingPathExtension(image.sd_imageFormat.possiblePathExtension)\n\n    try? imageData.write(to: tempLocation)\n    return tempLocation as QLPreviewItem\n  }\n\n  override func viewDidLoad() {\n    super.viewDidLoad()\n    delegate = self\n    dataSource = self\n    view.tintColor = Utils.accentColor\n\n    // Set initial preview index\n    if initialIndex < imageDataArray.count {\n      self.currentPreviewItemIndex = initialIndex\n    }\n\n    // Add save button to navigation bar\n    let saveButton = UIBarButtonItem(\n      image: UIImage(systemName: \"square.and.arrow.down\"),\n      style: .plain,\n      target: self,\n      action: #selector(saveCurrentImage)\n    )\n    navigationItem.leftBarButtonItem = saveButton\n  }\n\n  @objc private func saveCurrentImage() {\n    let currentIndex = currentPreviewItemIndex\n    guard currentIndex < imageDataArray.count else { return }\n\n    let imageData = imageDataArray[currentIndex]\n    guard let image = UIImage(data: imageData) else { return }\n\n    UIImageWriteToSavedPhotosAlbum(\n      image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil)\n  }\n\n  @objc private func image(\n    _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer\n  ) {\n    if let error = error {\n      print(\"Error saving image: \\(error.localizedDescription)\")\n    } else {\n      print(\"Image saved successfully\")\n    }\n  }\n\n  private func cleanupTempFiles() {\n    try? FileManager.default.removeItem(at: previewContentDirectory)\n  }\n\n  func previewControllerDidDismiss(_ controller: QLPreviewController) {\n    cleanupTempFiles()\n  }\n\n  deinit {\n    self.cleanupTempFiles()\n  }\n\n}\n\nclass ImagePreview: NSObject {\n  public static func quickLookImage(_ images: [Data], index: Int = 0) {\n    guard let rootViewController = Utils.getRootVC() else {\n      return\n    }\n\n    if images.count == 0 {\n      print(\"no preview data\")\n      return\n    }\n\n    let previewController = PreviewControllerController()\n    previewController.prepareImages(images, initialIndex: index)\n\n    rootViewController.present(previewController, animated: true)\n  }\n\n  public static func quickLookImageUrls(_ imageUrls: [String], index: Int = 0) {\n    guard let rootViewController = Utils.getRootVC() else {\n      return\n    }\n\n    if imageUrls.count == 0 {\n      print(\"no preview urls\")\n      return\n    }\n\n    let previewController = PreviewControllerController()\n    previewController.prepareImages(imageUrls: imageUrls, initialIndex: index)\n\n    rootViewController.present(previewController, animated: true)\n  }\n}\n\nextension SDImageFormat {\n  var possiblePathExtension: String {\n    switch self {\n    case .undefined: \"\"\n    case .JPEG: \"jpg\"\n    case .PNG: \"png\"\n    case .GIF: \"gif\"\n    case .TIFF: \"tiff\"\n    case .webP: \"webp\"\n    case .HEIC: \"heic\"\n    case .HEIF: \"heif\"\n    case .PDF: \"pdf\"\n    case .SVG: \"svg\"\n    default: \"png\"\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/Helper/HelperModule.swift",
    "content": "//\n//  HelperModule.swift\n//  Pods\n//\n//  Created by Innei on 2025/2/7.\n//\nimport ExpoModulesCore\nimport UIKit\n\npublic class HelperModule: Module {\n  public func definition() -> ExpoModulesCore.ModuleDefinition {\n    Name(\"Helper\")\n\n    AsyncFunction(\"openLink\") { (urlString: String, promise: Promise) in\n      guard let url = URL(string: urlString) else {\n        return\n      }\n      DispatchQueue.main.async {\n        guard let rootVC = Utils.getRootVC() else { return }\n\n        let onDismiss = {\n          promise.resolve([\"type\": \"dismiss\"])\n        }\n\n        WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss)\n      }\n    }\n\n    Function(\"scrollToTop\") { (reactTag: Int) in\n      DispatchQueue.main.async { [weak self] in\n        guard let bridge = self?.appContext?.reactBridge else { return }\n\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {\n          let scrollView = self?.findUIScrollView(view: sourceView)\n          guard let scrollView = scrollView else {\n            return\n          }\n          scrollView.scrollToTopIfPossible(animated: true)\n        }\n      }\n    }\n\n    AsyncFunction(\"isScrollToEnd\") { (reactTag: Int, promise: Promise) in\n      DispatchQueue.main.async { [weak self] in\n        guard let bridge = self?.appContext?.reactBridge else { return }\n\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {\n          let scrollView = self?.findUIScrollView(view: sourceView)\n          guard let scrollView = scrollView else {\n            return\n          }\n          let contentHeight = scrollView.contentSize.height\n          let scrollViewHeight = scrollView.bounds.size.height\n          let contentOffsetY = scrollView.contentOffset.y\n\n          let bottomOffset = contentHeight - scrollViewHeight\n\n          promise.resolve(contentOffsetY >= bottomOffset - 1.0)\n        }\n      }\n    }\n\n    Function(\"saveImageByHandle\") { (reactTag: Int) in\n      DispatchQueue.main.async { [weak self] in\n        guard let bridge = self?.appContext?.reactBridge else { return }\n\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {\n          let imageView = self?.findUIImageView(view: sourceView)\n          guard let imageView = imageView else {\n            return\n          }\n          guard let image = imageView.image else { return }\n          UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil)\n          Toast.show(options: .init(type: .success, title: \"Saved to photos\"))\n        }\n      }\n    }\n\n    Function(\"shareImageByHandle\") { (reactTag: Int, url: String?) in\n      DispatchQueue.main.async { [weak self] in\n        guard let bridge = self?.appContext?.reactBridge else { return }\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {\n          let imageView = self?.findUIImageView(view: sourceView)\n          guard let imageView = imageView else {\n            return\n          }\n          guard let image = imageView.image else { return }\n          let activityViewController = UIActivityViewController(\n            activityItems: [\n              image.asActivityItemSource(\n                url: try? URL(string: url ?? \"\")\n              )\n            ], applicationActivities: nil)\n          activityViewController.popoverPresentationController?.sourceView = sourceView\n          activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds\n          activityViewController.popoverPresentationController?.permittedArrowDirections = .any\n          activityViewController.popoverPresentationController?.permittedArrowDirections = .any\n\n          Utils.getRootVC()?.present(activityViewController, animated: true)\n        }\n      }\n    }\n\n    AsyncFunction(\"getBase64FromImageViewByHandle\") { (reactTag: Int, promise: Promise) in\n      DispatchQueue.main.async { [weak self] in\n        guard let bridge = self?.appContext?.reactBridge else { return }\n\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {\n          let imageView = self?.findUIImageView(view: sourceView)\n          guard let imageView = imageView else {\n            promise.reject(\n              NSError(\n                domain: \"HelperModule\", code: 0,\n                userInfo: [NSLocalizedDescriptionKey: \"Image view not found\"]))\n            return\n          }\n          let base64 = self?.getBase64FromImageView(imageView: imageView)\n          promise.resolve([\"base64\": base64])\n        }\n      }\n    }\n\n    Function(\"copyImageByHandle\") { (reactTag: Int) in\n      DispatchQueue.main.async { [weak self] in\n        guard let bridge = self?.appContext?.reactBridge else { return }\n\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {\n          let imageView = self?.findUIImageView(view: sourceView)\n          guard let imageView = imageView else {\n            return\n          }\n          guard let image = imageView.image else { return }\n          guard let imageData = image.pngData() else { return }\n          UIPasteboard.general.setData(imageData, forPasteboardType: \"public.png\")\n\n          Toast.show(options: .init(type: .success, title: \"Image copied to clipboard\"))\n        }\n      }\n    }\n  }\n\n  func getBase64FromImageView(imageView: UIImageView) -> String? {\n    guard let image = imageView.image else { return nil }\n    guard let imageData = image.pngData() else { return nil }\n\n    let base64String = imageData.base64EncodedString(options: .lineLength64Characters)\n    return base64String\n  }\n\n  @objc func image(\n    _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?\n  ) {\n    if let error = error {\n      Toast.show(options: .init(type: .error, title: \"Save image failed\"))\n    } else {\n      Toast.show(options: .init(type: .success, title: \"Saved to photos\"))\n    }\n  }\n\n  private func findUIScrollView(view: UIView?) -> UIScrollView? {\n    return findUIViewOfType(view: view)\n  }\n\n  private func findUIImageView(view: UIView?) -> UIImageView? {\n    return findUIViewOfType(view: view)\n  }\n\n  private func findUIViewOfType<T: UIView>(view: UIView?) -> T? {\n    guard let view = view else {\n      return nil\n    }\n    if let view = view as? T {\n      return view\n    }\n\n    let subviews = view.subviews\n    for subview in subviews {\n      if let targetView = findUIViewOfType(view: subview) as T? {\n        return targetView\n      }\n    }\n    return nil\n  }\n}\n\nextension UIScrollView {\n  func scrollToTopIfPossible(animated: Bool) {\n    let encodedSelector = \"X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=\"  // \"_scrollToTopIfPossible:\"\n\n    if let decodedData = Data(base64Encoded: encodedSelector),\n      let decodedString = String(data: decodedData, encoding: .utf8)\n    {\n      let selector = NSSelectorFromString(decodedString)\n      if responds(to: selector) {\n        perform(selector, with: animated)\n      } else {\n        print(\"UIScrollView does not respond to decoded method\")\n        setContentOffset(.zero, animated: animated)\n      }\n    } else {\n      setContentOffset(.zero, animated: animated)\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/ItemPressable/ItemPressableModule.swift",
    "content": "//\n//  ItemPressableModule.swift\n//  Pods\n//\n//  Created by Innei on 2025/4/15.\n//\n\nimport ExpoModulesCore\nimport UIKit\n\npublic class ItemPressableModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"ItemPressable\")\n\n    View(ItemPressableView.self) {\n      OnViewDidUpdateProps { view in\n        view.initizlize()\n      }\n\n      Prop(\"touchHighlight\") { (view: ItemPressableView, value: Bool) in\n        view.setTouchHighlight(value)\n      }\n\n      Events(\"onItemPress\")\n    }\n  }\n}\n\nclass ItemPressableView: ExpoView {\n  private var onItemPress: EventDispatcher!\n  private var touchHighlight = true\n  required init(appContext: AppContext? = nil) {\n    self.onItemPress = EventDispatcher()\n    super.init(appContext: appContext)\n  }\n\n  func setTouchHighlight(_ value: Bool) {\n    touchHighlight = value\n  }\n\n  func initizlize() {\n    let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))\n    tapGesture.delegate = self\n    gestureRecognizers = [tapGesture]\n  }\n\n  @objc func handleTap() {\n    let bgColor = layer.backgroundColor\n    if touchHighlight {\n      layer.backgroundColor = UIColor.systemGray5.cgColor\n\n      UIView.animate(withDuration: 0.2, delay: 0.1, options: [.curveEaseOut]) {\n        self.layer.backgroundColor = bgColor\n      }\n    }\n    onItemPress()\n  }\n}\n\nextension ItemPressableView: UIGestureRecognizerDelegate {}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/PagerView/EnhancePageViewModule.swift",
    "content": "//\n//  EnhancePageViewModule.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/31.\n//\n\nimport ExpoModulesCore\n\npublic class EnhancePageViewModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"EnhancePageView\")\n    \n    View(EnhancePageView.self) {\n    }\n  }\n}\n\n\nclass EnhancePageView: ExpoView {\n  \n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/PagerView/EnhancePagerController.swift",
    "content": "//\n//  PagerView.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/30.\n//\n\nimport SnapKit\nimport UIKit\n\nenum PagerDirection: String {\n  case left\n  case right\n  case none\n}\n\nenum PagerState: String {\n  case idle\n  case dragging\n  case scrolling\n}\n\nprivate class PagerViewController: UIViewController {\n  private var pageIndex = 0\n  private var pageView: UIView?\n  private var contentView: UIView?\n\n  convenience init(index: Int, view: UIView) {\n    self.init()\n    pageIndex = index\n    contentView = view\n  }\n\n  override func loadView() {\n    pageView = UIView()\n    view = pageView\n  }\n\n  override func viewDidLoad() {\n    super.viewDidLoad()\n    if let contentView = contentView, let pageView = pageView {\n      contentView.removeFromSuperview() // Ensure it's not in another view hierarchy\n      pageView.addSubview(contentView)\n      contentView.snp.makeConstraints { make in\n        make.edges.equalToSuperview()\n      }\n    }\n  }\n\n  public func getPageIndex() -> Int { pageIndex }\n  public func setPageIndex(index: Int) { pageIndex = index }\n}\n\nclass EnhancePagerController: UIPageViewController, UIScrollViewDelegate {\n  private var pageControllers = [PagerViewController]()\n  private var currentPageIndex: Int = 0 {\n    willSet {\n      if newValue != currentPageIndex {\n        onPageIndexChange?(newValue)\n      }\n    }\n  }\n\n  // Events\n  var onPageIndexChange: ((Int) -> Void)?\n  var onScroll: ((CGFloat, PagerDirection, Int) -> Void)?\n  var onScrollEnd: ((Int) -> Void)?\n  var onScrollStart: ((Int) -> Void)?\n  var onPageWillAppear: ((Int) -> Void)?\n\n  var isTransitioning: Bool = false\n  var isDragging: Bool = false\n\n  public var scrollView: UIScrollView?\n\n  convenience init(\n    pageViews: [UIView], initialPageIndex: Int = 0,\n    transitionStyle: UIPageViewController.TransitionStyle = .scroll,\n    options: [UIPageViewController.OptionsKey: Any]? = nil\n  ) {\n    self.init(\n      transitionStyle: transitionStyle, navigationOrientation: .horizontal, options: options)\n    pageControllers = pageViews.enumerated().map { index, view in\n      PagerViewController(index: index, view: view)\n    }\n    currentPageIndex = initialPageIndex\n  }\n\n  override func viewDidLoad() {\n    super.viewDidLoad()\n    dataSource = self\n    delegate = self\n\n    let hasPageController = pageControllers.indices.contains(currentPageIndex)\n\n    if hasPageController {\n      let vc = pageControllers[currentPageIndex]\n      setViewControllers([vc], direction: .forward, animated: false)\n      vc.didMove(toParent: self)\n    } else {\n      setViewControllers([UIViewController()], direction: .forward, animated: false)\n    }\n\n    for subview in view.subviews {\n      if let scrollView = subview as? UIScrollView {\n        scrollView.delegate = self\n        scrollView.delaysContentTouches = false\n        self.scrollView = scrollView\n      }\n    }\n  }\n\n  var startOffset: CGFloat = 0\n\n  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {\n    startOffset = scrollView.contentOffset.x\n    isDragging = true\n    onScrollStart?(currentPageIndex)\n  }\n\n  public func scrollViewDidScroll(_ scrollView: UIScrollView) {\n    var direction: PagerDirection = .none\n\n    if startOffset < scrollView.contentOffset.x {\n      direction = .right\n    } else if startOffset > scrollView.contentOffset.x {\n      direction = .left\n    }\n\n    guard view.frame.width > 0 else { return }\n    let positionFromStartOfCurrentPage = abs(startOffset - scrollView.contentOffset.x)\n    let percent = positionFromStartOfCurrentPage / view.frame.width\n    let position = currentPageIndex\n\n    if direction != .none || percent > 0 {\n      onScroll?(percent, direction, position)\n    }\n  }\n\n  public func scrollViewDidEndDragging(\n    _ scrollView: UIScrollView, willDecelerate decelerate: Bool\n  ) {\n    if !decelerate {\n      isDragging = false\n      onScrollEnd?(currentPageIndex)\n    }\n  }\n\n  public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {\n    isDragging = false\n    onScrollEnd?(currentPageIndex)\n  }\n}\n\n// Add this extension to implement UIPageViewControllerDataSource\nextension EnhancePagerController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {\n  func pageViewController(\n    _ pageViewController: UIPageViewController,\n    viewControllerBefore viewController: UIViewController\n  ) -> UIViewController? {\n    if let pagerController = pageViewController.viewControllers?.first as? PagerViewController {\n      let index = pagerController.getPageIndex()\n      if index > 0 {\n        onPageWillAppear?(index - 1)\n        return pageControllers[index - 1]\n      }\n    }\n\n    return nil\n  }\n\n  func pageViewController(\n    _ pageViewController: UIPageViewController,\n    viewControllerAfter viewController: UIViewController\n  ) -> UIViewController? {\n    if let pagerController = pageViewController.viewControllers?.first as? PagerViewController {\n      let index = pagerController.getPageIndex()\n      if index < pageControllers.count - 1 {\n        onPageWillAppear?(index + 1)\n        return pageControllers[index + 1]\n      }\n    }\n\n    return nil\n  }\n\n  func pageViewController(\n    _ pageViewController: UIPageViewController,\n    didFinishAnimating finished: Bool,\n    previousViewControllers: [UIViewController],\n    transitionCompleted completed: Bool\n  ) {\n    if completed {\n      isTransitioning = false\n      if let currentVC = pageViewController.viewControllers?.first as? PagerViewController,\n         let newIndex = pageControllers.firstIndex(of: currentVC)\n      {\n        currentPageIndex = newIndex\n      }\n    }\n  }\n\n  func pageViewController(\n    _ pageViewController: UIPageViewController,\n    willTransitionTo pendingViewControllers: [UIViewController]\n  ) {\n    isTransitioning = true\n  }\n}\n\n// MARK: Add this extension to implement insert and remove page view\n\nextension EnhancePagerController {\n  public func insertPageView(view: UIView, animated: Bool = false) {\n    let index = pageControllers.count\n    let vc = PagerViewController(index: index, view: view)\n\n    let hasPageControllerBefore = pageControllers.count > 0\n\n    pageControllers.append(vc)\n\n    if !hasPageControllerBefore {\n      setViewControllers([vc], direction: .forward, animated: animated)\n    }\n  }\n\n  public func removePageView(at index: Int) {\n    pageControllers.remove(at: index)\n\n    if currentPageIndex == index {\n      if !pageControllers.isEmpty {\n        let newIndex = min(index, pageControllers.count - 1)\n        let newVC = pageControllers[newIndex]\n        setViewControllers([newVC], direction: .forward, animated: true)\n        currentPageIndex = newIndex\n      } else {\n        //        let emptyVC = UIViewController()\n        //        self.setViewControllers([emptyVC], direction: .forward, animated: false)\n        currentPageIndex = 0\n      }\n    } else if currentPageIndex > index {\n      currentPageIndex -= 1\n    }\n\n    for (i, pagerVC) in pageControllers.enumerated() {\n      pagerVC.setPageIndex(index: i)\n    }\n  }\n}\n\n// MARK: PagerController utils\n\nextension EnhancePagerController {\n  public func setCurrentPage(index: Int) {\n    if pageControllers.indices.contains(index) {\n      let vc = pageControllers[index]\n      let direction: UIPageViewController.NavigationDirection = index > currentPageIndex ? .forward : .reverse\n      setViewControllers([vc], direction: direction, animated: true)\n      currentPageIndex = index\n    }\n  }\n\n  public func getCurrentPageIndex() -> Int {\n    return currentPageIndex\n  }\n\n  public func getState() -> PagerState {\n    if isDragging {\n      return .dragging\n    } else if isTransitioning {\n      return .scrolling\n    } else {\n      return .idle\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/PagerView/EnhancePagerViewModule.swift",
    "content": "//\n//  EnhancePagerModule.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/30.\n//\n\nimport ExpoModulesCore\n\npublic class EnhancePagerViewModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"EnhancePagerView\")\n\n    View(EnhancePagerView.self) {\n      OnViewDidUpdateProps { view in\n        view.initialize()\n      }\n\n      Prop(\"page\") { (view: EnhancePagerView, index: Int) in\n        view.page = index\n      }\n\n      Prop(\"initialPageIndex\") { (view: EnhancePagerView, index: Int) in\n        view.initialPageIndex = index\n      }\n\n      Prop(\"transitionStyle\") { (view: EnhancePagerView, style: TransitionStyle?) in\n        guard let style = style else { return }\n        view.transitionStyle = style\n      }\n\n      AsyncFunction(\"setPage\") { (view: EnhancePagerView, index: Int) in\n        view.pageController?.setCurrentPage(index: index)\n      }\n\n      AsyncFunction(\"getCurrentPage\") { view in\n        view.pageController?.getCurrentPageIndex()\n      }\n\n      AsyncFunction(\"getState\") { view -> String? in\n        if let pageController = view.pageController {\n          return pageController.getState().rawValue\n        }\n        return nil\n      }\n\n      Events(\"onPageChange\")\n      Events(\"onScroll\")\n      Events(\"onScrollBegin\")\n      Events(\"onScrollEnd\")\n      Events(\"onPageWillAppear\")\n    }\n  }\n}\n\nenum TransitionStyle: String, Enumerable {\n  case scroll\n  case pageCurl\n  func toUIPageViewControllerTransitionStyle() -> UIPageViewController.TransitionStyle {\n    switch self {\n    case .scroll:\n      return .scroll\n    case .pageCurl:\n      return .pageCurl\n    }\n  }\n}\n\nprivate class EnhancePagerView: ExpoView, UIGestureRecognizerDelegate {\n  fileprivate var pageController: EnhancePagerController?\n  private var isInitialized = false\n\n  private let onScroll = EventDispatcher()\n  private let onScrollBegin = EventDispatcher()\n  private let onScrollEnd = EventDispatcher()\n  private let onPageChange = EventDispatcher()\n  private let onPageWillAppear = EventDispatcher()\n\n  private var pageViews: [UIView] = []\n\n  required init(appContext: AppContext? = nil) {\n    super.init(appContext: appContext)\n  }\n\n  override func insertSubview(_ view: UIView, at index: Int) {\n    pageViews.insert(view, at: index)\n    pageController?.insertPageView(view: view)\n  }\n\n  func willRemoveSubview(_ subview: UIView, at index: Int) {\n    pageViews.remove(at: index)\n    pageController?.removePageView(at: index)\n  }\n\n  // Props\n  var page: Int = 0 {\n    willSet {\n      pageController?.setCurrentPage(index: newValue)\n    }\n  }\n\n  var initialPageIndex: Int = 0\n\n  var pageGap = 20\n  var transitionStyle: TransitionStyle = .scroll\n  var panGestureRecognizer: UIGestureRecognizer?\n  func initialize() {\n    if isInitialized {\n      return\n    }\n    isInitialized = true\n\n    let panGestureRecognizer = UIPanGestureRecognizer()\n    panGestureRecognizer.delegate = self\n    self.panGestureRecognizer = panGestureRecognizer\n    addGestureRecognizer(panGestureRecognizer)\n\n    pageController = EnhancePagerController(\n      pageViews: pageViews, initialPageIndex: initialPageIndex,\n      transitionStyle: transitionStyle.toUIPageViewControllerTransitionStyle(),\n      options: [\n        .interPageSpacing: pageGap\n      ])\n    guard let pageController = pageController else { return }\n    addSubview(pageController.view)\n    pageController.view.snp.makeConstraints { make in\n      make.edges.equalToSuperview()\n    }\n\n    pageController.onPageIndexChange = { [weak self] index in\n      self?.onPageChange([\"index\": index])\n    }\n    pageController.onScrollStart = { [weak self] index in\n      self?.onScrollBegin([\"index\": index])\n    }\n    pageController.onScroll = { [weak self] percent, direction, position in\n      self?.onScroll([\"percent\": percent, \"direction\": direction.rawValue, \"position\": position])\n    }\n    pageController.onScrollEnd = { [weak self] index in\n      self?.onScrollEnd([\"index\": index])\n    }\n    pageController.onPageWillAppear = { [weak self] index in\n      self?.onPageWillAppear([\"index\": index])\n    }\n  }\n\n  #if RCT_NEW_ARCH_ENABLED\n    override func mountChildComponentView(_ childComponentView: UIView, index: Int) {\n      if childComponentView is EnhancePageView {\n        insertSubview(childComponentView, at: index)\n      }\n    }\n\n    override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {\n      if childComponentView is EnhancePageView {\n        willRemoveSubview(childComponentView, at: index)\n      }\n    }\n  #endif\n\n  func gestureRecognizer(\n    _ gestureRecognizer: UIGestureRecognizer,\n    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer\n  ) -> Bool {\n    guard let pageController = pageController else { return false }\n    let currentIndex = pageController.getCurrentPageIndex()\n    guard let scrollView = pageController.scrollView else { return false }\n    if gestureRecognizer == panGestureRecognizer,\n       NSStringFromClass(type(of: otherGestureRecognizer)) == \"RNSPanGestureRecognizer\"\n    {\n      if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer {\n        let velocity = panGestureRecognizer.velocity(in: self)\n        let isLTR = true\n        let isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0)\n\n        if currentIndex == 0 && isBackGesture {\n          scrollView.panGestureRecognizer.isEnabled = false\n        }\n      }\n      return true\n    }\n\n    return false\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift",
    "content": "//\n//  FOWebView.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/7.\n//\n\n@preconcurrency import WebKit\n\nclass FOWebView: WKWebView {\n  private func setupView() {\n    scrollView.isScrollEnabled = false\n    scrollView.bounces = false\n    scrollView.contentInsetAdjustmentBehavior = .never\n\n    isOpaque = false\n    backgroundColor = UIColor.clear\n    scrollView.backgroundColor = UIColor.clear\n    tintColor = Utils.accentColor\n\n    if #available(iOS 16.4, *) {\n      isInspectable = true\n    }\n  }\n\n  private var state: WebViewState!\n\n  init(frame: CGRect, state: WebViewState) {\n    let configuration = FOWKWebViewConfiguration()\n\n    super.init(frame: frame, configuration: configuration)\n    configuration.userContentController.add(self, name: \"message\")\n    self.state = state\n\n    setupView()\n    navigationDelegate = self\n    uiDelegate = self\n  }\n\n  @available(*, unavailable)\n  required init?(coder: NSCoder) {\n    fatalError(\"init(coder:) has not been implemented\")\n  }\n}\n\nprivate class FOWKWebViewConfiguration: WKWebViewConfiguration {\n  private static let sharedProcessPool = WKProcessPool()\n  override init() {\n    super.init()\n    let configuration = self\n\n    // Share process pool and default data store across instances for faster warm-up\n    configuration.processPool = FOWKWebViewConfiguration.sharedProcessPool\n    configuration.websiteDataStore = .default()\n\n    let hexAccentColor = Utils.accentColor.toHex()\n    let css = \"\"\"\n        :root { overflow: hidden !important; overflow-behavior: none !important; }\n        body {\n            margin: 0 !important;\n            overflow-y: visible !important;\n            position: absolute !important;\n            width: 100% !important;\n            max-width: 100% !important;\n            height: auto !important;\n            -webkit-overflow-scrolling: touch !important;\n        }\n        #root,\n        article {\n            width: 100% !important;\n            max-width: 100% !important;\n            margin-left: 0 !important;\n            margin-right: 0 !important;\n            box-sizing: border-box !important;\n        }\n        article > p,\n        article > div,\n        article > ul,\n        article > ol,\n        article > pre,\n        article > table,\n        article > blockquote,\n        article > h1,\n        article > h2,\n        article > h3,\n        article > h4,\n        article > h5,\n        article > h6,\n        article > figure {\n            width: 100% !important;\n            max-width: 100% !important;\n            margin-left: 0 !important;\n            margin-right: 0 !important;\n            box-sizing: border-box !important;\n        }\n        article figure {\n            margin-top: 0 !important;\n            margin-bottom: 1rem !important;\n        }\n        article button[data-image-width],\n        article figure img,\n        article > img {\n            margin-left: auto !important;\n            margin-right: auto !important;\n        }\n        ::selection {\n            background-color: \\(hexAccentColor) !important;\n        }\n    \"\"\"\n\n    _ = WKUserScript(\n      source: \"\"\"\n          var style = document.createElement('style');\n          style.textContent = '\\(css)';\n          document.head.appendChild(style);\n      \"\"\",\n      injectionTime: .atDocumentStart,\n      forMainFrameOnly: true\n    )\n\n    let atStartScripts = WKWebView.loadInjectedJs(forResource: \"at_start\")\n\n    if let jsString = atStartScripts {\n      let script = WKUserScript(\n        source: jsString,\n        injectionTime: .atDocumentStart,\n        forMainFrameOnly: true\n      )\n      configuration.userContentController.addUserScript(script)\n    }\n\n    configuration.preferences.setValue(true, forKey: \"allowFileAccessFromFileURLs\")\n\n    let atEndScripts = WKWebView.loadInjectedJs(forResource: \"at_end\")\n    guard let jsString = atEndScripts else {\n      print(\"Failed to load injected js\")\n      return\n    }\n    let script2 = WKUserScript(\n      source: jsString,\n      injectionTime: .atDocumentEnd,\n      forMainFrameOnly: true\n    )\n\n    let schemeHandler = FollowImageURLSchemeHandler()\n    configuration.setURLSchemeHandler(\n      schemeHandler, forURLScheme: FollowImageURLSchemeHandler.rewriteScheme\n    )\n\n    // Only rewrite <img>.src to custom scheme; keep XHR/fetch unchanged to avoid breaking CORS/auth\n    let customImageScript = WKUserScript(\n      source: \"\"\"\n      (function() {\n          const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src');\n          if (originalImageSrc && originalImageSrc.set) {\n            Object.defineProperty(Image.prototype, 'src', {\n                set: function(url) {\n                    try {\n                      const modifiedUrl = (typeof url === 'string') ? url.replace(/^https?:/, '\\(FollowImageURLSchemeHandler.rewriteScheme):') : url;\n                      originalImageSrc.set.call(this, modifiedUrl);\n                    } catch (e) {\n                      originalImageSrc.set.call(this, url);\n                    }\n                }\n            });\n          }\n      })();\n      \"\"\",\n      injectionTime: .atDocumentStart,\n      forMainFrameOnly: false\n    )\n    configuration.userContentController.addUserScript(customImageScript)\n\n    configuration.userContentController.addUserScript(script2)\n  }\n\n  @available(*, unavailable)\n  required init?(coder: NSCoder) {\n    fatalError(\"init(coder:) has not been implemented\")\n  }\n}\n\nprivate extension WKWebView {\n  static func loadInjectedJs(forResource: String) -> String? {\n    if let bundleURL = Bundle(for: WebViewView.self).url(\n      forResource: \"js\", withExtension: \"bundle\"\n    ),\n      let resourceBundle = Bundle(url: bundleURL)\n    {\n      if let jsPath = resourceBundle.path(forResource: forResource, ofType: \"js\") {\n        do {\n          let initJsContent = try String(contentsOfFile: jsPath, encoding: .utf8)\n\n          return initJsContent\n        } catch {\n          print(\"Error reading JS file:\", error)\n        }\n      }\n    }\n    return nil\n  }\n}\n\nextension FOWebView: WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate {\n  func userContentController(\n    _ userContentController: WKUserContentController, didReceive message: WKScriptMessage\n  ) {\n    debugPrint(\"message\", message.body)\n    if message.name == \"message\" {\n      let body = message.body\n\n      if let jsonString = body as? String, let decode = jsonString.data(using: .utf8) {\n        let data = try? JSONDecoder().decode(BridgeDataBasePayload.self, from: decode)\n        guard let data = data else { return }\n\n        switch data.type {\n        case \"ready\":\n          // Notify manager that the bridge is ready and flush queued scripts\n          DispatchQueue.main.async {\n            WebViewManager.markReadyAndFlush()\n          }\n        case \"setContentHeight\":\n          let data = try? JSONDecoder().decode(\n            SetContentHeightPayload.self, from: decode\n          )\n          guard let data = data else { return }\n\n          DispatchQueue.main.async {\n            self.state.contentHeight = data.payload\n          }\n\n        case \"measure\":\n          measureWebView(self)\n\n        case \"previewImage\":\n          let data = try? JSONDecoder().decode(\n            PreviewImagePayload.self, from: decode\n          )\n\n          guard let data = data else { return }\n          DispatchQueue.main.async {\n            let urls = data.payload.imageUrls\n            let index = data.payload.index ?? 0\n            if !urls.isEmpty {\n              self.state.previewImages(urls: urls, index: index)\n            }\n          }\n        case \"audio:seekTo\":\n            let data = try? JSONDecoder().decode(\n                AudioSeekPayload.self, from: decode\n            )\n            guard let data = data else { return }\n            DispatchQueue.main.async {\n                self.state.seekAudio(time: data.payload.time)\n            }\n\n        default:\n          break\n        }\n      }\n    }\n  }\n\n  private func measureWebView(_ webView: WKWebView) {\n    let jsCode = \"document.querySelector('#root').scrollHeight\"\n    webView.evaluateJavaScript(jsCode) { height, _ in\n      if let height = height as? CGFloat, height > 0 {\n        DispatchQueue.main.async {\n          self.state.contentHeight = height\n          debugPrint(\"measure\", height)\n        }\n      }\n    }\n  }\n\n  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {\n    measureWebView(webView)\n\n    Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in\n      self?.measureWebView(webView)\n    }\n  }\n\n  func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {\n    // Attempt a graceful recovery via the manager\n    DispatchQueue.main.async {\n      WebViewManager.handleProcessTerminated()\n    }\n  }\n\n  func webView(\n    _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,\n    for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures\n  ) -> WKWebView? {\n    if let url = navigationAction.request.url,\n       let viewController = Utils.getRootVC()\n    {\n      WebViewManager.presentModalWebView(url: url, from: viewController)\n    }\n    return nil\n  }\n\n  func webView(\n    _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,\n    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void\n  ) {\n    if navigationAction.targetFrame == nil {\n      if let url = navigationAction.request.url,\n         let viewController = Utils.getRootVC()\n      {\n        WebViewManager.presentModalWebView(url: url, from: viewController)\n        decisionHandler(.cancel)\n        return\n      }\n    }\n    decisionHandler(.allow)\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/FollowImageURLSchemeHandler.swift",
    "content": "//\n//  CustomURLSchemeHandler.swift\n//  Pods\n//\n//  Created by Innei on 2025/2/7.\n//\n\nimport Foundation\nimport SDWebImage\nimport WebKit\n\nclass FollowImageURLSchemeHandler: NSObject, WKURLSchemeHandler {\n    static let rewriteScheme = \"follow-image\"\n    private var activeTasks: [String: URLSessionDataTask] = [:]\n    private var stoppedTasks: Set<String> = []\n\n    private let imageCache = SDImageCache.shared\n\n    private func getCommonHeaders(forImageData data: Data? = nil) -> [String: String] {\n        var headers = [\n            \"Access-Control-Allow-Origin\": \"*\",\n            \"Access-Control-Allow-Methods\": \"GET, POST, PUT, DELETE, OPTIONS\",\n            \"Access-Control-Allow-Headers\": \"*\",\n\n        ]\n\n        if let imageData = data {\n            if imageData.starts(with: [0xFF, 0xD8, 0xFF]) {\n                headers[\"Content-Type\"] = \"image/jpeg\"\n            } else if imageData.starts(with: [0x89, 0x50, 0x4E, 0x47]) {\n                headers[\"Content-Type\"] = \"image/png\"\n            } else if imageData.starts(with: [0x47, 0x49, 0x46]) {\n                headers[\"Content-Type\"] = \"image/gif\"\n            } else if imageData.starts(with: [0x52, 0x49, 0x46, 0x46]) {\n                headers[\"Content-Type\"] = \"image/webp\"\n            } else {\n                headers[\"Content-Type\"] = \"application/octet-stream\"\n            }\n        }\n\n        return headers\n    }\n\n    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {\n        guard let url = urlSchemeTask.request.url,\n            let originalURLString = url.absoluteString.replacingOccurrences(\n                of: FollowImageURLSchemeHandler.rewriteScheme, with: \"https\"\n            ).removingPercentEncoding,\n            let originalURL = URL(string: originalURLString)\n        else {\n            urlSchemeTask.didFailWithError(NSError(domain: \"\", code: -1))\n            return\n        }\n\n        let cacheKey = originalURL.absoluteString\n        if let cachedData = imageCache.diskImageData(forKey: cacheKey) {\n            let response = HTTPURLResponse(\n                url: originalURL,\n                statusCode: 200,\n                httpVersion: \"HTTP/1.1\",\n                headerFields: getCommonHeaders(forImageData: cachedData)\n            )!\n\n            urlSchemeTask.didReceive(response)\n            urlSchemeTask.didReceive(cachedData)\n            urlSchemeTask.didFinish()\n            return\n        }\n\n        var request = URLRequest(url: originalURL)\n\n        request.httpMethod = urlSchemeTask.request.httpMethod\n        request.httpBody = urlSchemeTask.request.httpBody\n\n        // setting headers\n        var headers = urlSchemeTask.request.allHTTPHeaderFields ?? [:]\n        if let urlComponents = URLComponents(url: originalURL, resolvingAgainstBaseURL: false),\n            let scheme = urlComponents.scheme,\n            let host = urlComponents.host\n        {\n            let origin = \"\\(scheme)://\\(host)\\(urlComponents.port.map { \":\\($0)\" } ?? \"\")\"\n            headers[\"Referer\"] = origin\n            headers[\"Origin\"] = origin\n            headers[\"User-Agent\"] =\n                \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n\n        }\n        request.allHTTPHeaderFields = headers\n\n        let taskID = urlSchemeTask.description\n\n        let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in\n            guard let self = self else { return }\n\n            DispatchQueue.main.async {\n\n                guard !self.stoppedTasks.contains(taskID) else { return }\n\n                if let error = error {\n                    urlSchemeTask.didFailWithError(error)\n                    self.activeTasks.removeValue(forKey: taskID)\n                    return\n                }\n\n                if let response = response as? HTTPURLResponse, let data = data {\n                    guard !self.stoppedTasks.contains(taskID) else { return }\n\n                    // Cache the image data\n                    self.imageCache.storeImageData(toDisk: data, forKey: cacheKey)\n\n                    var newHeaders = response.allHeaderFields as? [String: String] ?? [:]\n\n                    for (key, value) in self.getCommonHeaders(forImageData: data) {\n                        newHeaders[key] = value\n                    }\n\n                    let modifiedResponse = HTTPURLResponse(\n                        url: response.url!,\n                        statusCode: response.statusCode,\n                        httpVersion: \"HTTP/1.1\",\n                        headerFields: newHeaders\n                    )!\n\n                    urlSchemeTask.didReceive(modifiedResponse)\n                    urlSchemeTask.didReceive(data)\n                    urlSchemeTask.didFinish()\n\n                    self.activeTasks.removeValue(forKey: taskID)\n                }\n            }\n        }\n\n        activeTasks[taskID] = task\n        task.resume()\n    }\n\n    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {\n        let taskID = urlSchemeTask.description\n        if let task = activeTasks[taskID] {\n            stoppedTasks.insert(taskID)\n            task.cancel()\n            activeTasks.removeValue(forKey: taskID)\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/Injected/at_end.js",
    "content": "//\n//  at_end.js\n//  Pods\n//\n//  Created by Innei on 2025/2/6.\n//\n\n;(() => {\n  const root = document.querySelector(\"#root\")\n  let ticking = false\n  const handleHeight = () => {\n    if (ticking) return\n    ticking = true\n    setTimeout(() => {\n      try {\n        window.webkit.messageHandlers.message.postMessage(\n          JSON.stringify({\n            type: \"setContentHeight\",\n            payload: root?.scrollHeight || document.documentElement.scrollHeight,\n          }),\n        )\n      } finally {\n        ticking = false\n      }\n    }, 16)\n  }\n  window.addEventListener(\"load\", handleHeight)\n  const observer = new ResizeObserver(handleHeight)\n\n  setTimeout(() => {\n    handleHeight()\n  }, 1000)\n  observer.observe(root)\n\n  // Fallback: ensure readiness is signaled at end if not yet sent\n  if (!window.__FO_WEBVIEW_READY__) {\n    try {\n      window.__FO_WEBVIEW_READY__ = true\n      window.webkit.messageHandlers.message.postMessage(JSON.stringify({ type: \"ready\" }))\n    } catch {\n      /* empty */\n    }\n  }\n})()\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js",
    "content": "//\n//  at_start.js\n//  Pods\n//\n//  Created by Innei on 2025/2/6.\n//\n;(() => {\n  window.__RN__ = true\n\n  function send(data) {\n    window.webkit.messageHandlers.message.postMessage?.(JSON.stringify(data))\n  }\n\n  window.bridge = {\n    measure: () => {\n      send({\n        type: \"measure\",\n      })\n    },\n    setContentHeight: (height) => {\n      send({\n        type: \"setContentHeight\",\n        payload: height,\n      })\n    },\n    previewImage: (data) => {\n      send({\n        type: \"previewImage\",\n        payload: {\n          imageUrls: data.imageUrls,\n          index: data.index || 0,\n        },\n      })\n    },\n    seekAudio: (time) => {\n      send({\n        type: \"audio:seekTo\",\n        payload: {\n          time,\n        },\n      })\n    },\n  }\n\n  // Signal readiness once DOM is interactive/loaded (guard to send once)\n  if (!window.__FO_WEBVIEW_READY__) {\n    let sent = false\n    const sendReady = () => {\n      if (sent) return\n      sent = true\n      window.__FO_WEBVIEW_READY__ = true\n      try {\n        send({ type: \"ready\" })\n      } catch {\n        /* empty */\n      }\n    }\n    document.addEventListener(\"DOMContentLoaded\", sendReady)\n    window.addEventListener(\"load\", sendReady)\n  }\n})()\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/SharedWebView+BridgeData.swift",
    "content": "//\n//  SharedWebView+BridgeData.swift\n//  Pods\n//\n//  Created by Innei on 2025/2/7.\n//\nimport Foundation\nimport UIKit\n\nprivate protocol BasePayload {\n  var type: String { get }\n}\n\nstruct SetContentHeightPayload: Codable, BasePayload {\n  var type: String\n  var payload: CGFloat\n}\n\nstruct BridgeDataBasePayload: Codable {\n  var type: String\n}\n\nstruct PreviewImagePayloadProps: Codable {\n  var imageUrls: [String]\n  var index: Int = 0\n}\n\nstruct PreviewImagePayload: Codable, BasePayload {\n  var type: String\n  var payload: PreviewImagePayloadProps\n}\n\nstruct AudioSeekPayloadProps: Codable {\n    let time: Double\n}\n\nstruct AudioSeekPayload: Codable, BasePayload {\n    var type: String\n    var payload: AudioSeekPayloadProps\n}"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift",
    "content": "import Combine\nimport ExpoModulesCore\nimport SnapKit\nimport SwiftUI\nimport WebKit\n\nclass WebViewView: ExpoView {\n    private var cancellable: AnyCancellable?\n    private var lastReportedHeight: CGFloat = 0\n\n    required init(appContext: AppContext? = nil) {\n        super.init(appContext: appContext)\n\n        clipsToBounds = true\n        cancellable = WebViewManager.state.$contentHeight\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] _ in\n                self?.setNeedsLayout()\n            }\n    }\n   \n    deinit {\n        cancellable?.cancel()\n    }\n\n    private let onContentHeightChange = ExpoModulesCore.EventDispatcher()\n\n    override func layoutSubviews() {\n        super.layoutSubviews()\n        let rect = CGRect(\n            x: 0,\n            y: 0,\n            width: bounds.width,\n            height: WebViewManager.state.contentHeight\n        )\n        WebViewManager.updateFrame(rect)\n        if abs(lastReportedHeight - rect.height) > 0.5 {\n            lastReportedHeight = rect.height\n            onContentHeightChange([\"height\": Float(rect.height)])\n        }\n\n    }\n\n    override func didMoveToWindow() {\n        super.didMoveToWindow()\n        if window != nil {\n            WebViewManager.attach(to: self)\n        } else {\n            WebViewManager.detach(from: self)\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift",
    "content": "//\n//  SharedWebViewModule.swift\n//\n//  Created by Innei on 2025/1/29.\n//\n\nimport Combine\nimport ExpoModulesCore\nimport WebKit\n\nlet onContentHeightChanged = \"onContentHeightChanged\"\nlet onImagePreview = \"onImagePreview\"\nlet onSeekAudio = \"onSeekAudio\"\n\npublic class SharedWebViewModule: Module {\n  private var cancellables = Set<AnyCancellable>()\n\n  public static var sharedWebView: WKWebView? {\n    WebViewManager.shared\n  }\n\n  public func definition() -> ModuleDefinition {\n    Name(\"FOSharedWebView\")\n\n    Function(\"load\") { (urlString: String) in\n      WebViewManager.load(urlString)\n    }\n\n    Function(\"evaluateJavaScript\") { (js: String) in\n      // Prefer typed setters or dispatch to record state; avoid parsing free-form JS\n      WebViewManager.evaluateJavaScript(js)\n    }\n\n    // Centralized dispatch to JS layer (native -> JS)\n    Function(\"dispatch\") { (type: String, payload: String?) in\n      let payloadExpr: String\n      if let payload = payload {\n        // payload is JSON string; pass via JSON.parse to avoid double-escaping\n        payloadExpr = \"JSON.parse(\\(self.jsonString(payload)))\"\n      } else {\n        payloadExpr = \"null\"\n      }\n      let js =\n        \"(function(){ if (window.__FO_BRIDGE__ && typeof window.__FO_BRIDGE__.dispatch === 'function') { window.__FO_BRIDGE__.dispatch(\\(self.jsonString(type)), \\(payloadExpr)); } })()\"\n      WebViewManager.recordJSON(key: type, payload: payload)\n      WebViewManager.evaluateJavaScript(js)\n    }\n\n    View(WebViewView.self) {\n      Events(\"onContentHeightChange\")\n      Events(\"onSeekAudio\")\n\n      Prop(\"url\") { (_: UIView, urlString: String) in\n        WebViewManager.load(urlString)\n      }\n    }\n\n    Events(onContentHeightChanged)\n    Events(onImagePreview)\n    Events(onSeekAudio)\n\n    OnCreate {\n      WebViewManager.initializeLifecycleObservers()\n    }\n\n    OnDestroy {\n      WebViewManager.cleanupLifecycleObservers()\n    }\n    OnStartObserving {\n      // Monitor content height changes\n      WebViewManager.state.$contentHeight\n        .receive(on: DispatchQueue.main)\n        .sink { [weak self] height in\n          self?.sendEvent(onContentHeightChanged, [\"height\": height])\n        }\n        .store(in: &self.cancellables)\n\n      // Monitor image preview events\n      WebViewManager.state.$imagePreviewEvent\n        .receive(on: DispatchQueue.main)\n        .compactMap { $0 }  // Filter out nil values\n        .sink { [weak self] event in\n          self?.sendEvent(onImagePreview, [\"imageUrls\": event.imageUrls, \"index\": event.index])\n        }\n        .store(in: &self.cancellables)\n\n      WebViewManager.state.$audioSeekEvent\n        .receive(on: DispatchQueue.main)\n        .compactMap { $0 }\n        .sink { [weak self] event in\n          self?.sendEvent(onSeekAudio, [\"time\": event.time])\n        }\n        .store(in: &self.cancellables)\n    }\n\n    OnStopObserving {\n      self.cancellables.forEach { $0.cancel() }\n      self.cancellables.removeAll()\n    }\n\n    // Debug helpers\n    Function(\"getDebugState\") { () -> [String: Any] in\n      WebViewManager.debugState()\n    }\n    Function(\"destroyForDebug\") {\n      WebViewManager.destroyForDebug()\n    }\n    Function(\"reloadLastURL\") {\n      WebViewManager.reloadLastURL()\n    }\n    Function(\"flushQueue\") {\n      WebViewManager.flushForDebug()\n    }\n  }\n\n  private func getLocalHTML(from fileURL: String) -> URL? {\n    if let url = URL(string: fileURL), url.scheme == \"file\" {\n      let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences(\n        of: \"file://\", with: \"\"\n      )\n      let fileName = url.lastPathComponent\n      let fileExtension = url.pathExtension\n\n      if let fileURL = Bundle.main\n        .url(\n          forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)),\n          withExtension: fileExtension,\n          subdirectory: directoryPath\n        )\n      {\n        return fileURL\n      } else {\n        return nil\n      }\n    } else {\n      debugPrint(\"Invalidate url\")\n      return nil\n    }\n  }\n\n  // Removed JS string parsing helpers; state is captured by typed setters or dispatch/setStateJSON\n\n  fileprivate func jsonString(_ value: String) -> String {\n    // Encode as JSON string literal\n    let data = try? JSONSerialization.data(withJSONObject: [\"v\": value], options: [])\n    if let data = data, let s = String(data: data, encoding: .utf8) {\n      if let range = s.range(of: \":\") {\n        return String(s[range.upperBound..<s.index(before: s.endIndex)])\n      }\n\n      let escaped = value.replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\").replacingOccurrences(\n        of: \"\\\"\", with: \"\\\\\\\"\")\n      return \"\\\"\\(escaped)\\\"\"\n    }\n    return \"\"\n  }\n\n  // recordDispatchState removed: dynamic state is recorded directly via WebViewManager.recordJSON\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift",
    "content": "//\n//  WebViewManager.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/1/31.\n//\nimport Combine\nimport ExpoModulesCore\nimport SafariServices\nimport SwiftUI\nimport UIKit\n@preconcurrency import WebKit\n\n// Add protocol for handling link clicks\nprotocol WebViewLinkDelegate: AnyObject {\n  func webView(_ webView: WKWebView, shouldOpenURL url: URL)\n}\n\nenum WebViewManager {\n  // Public observable state\n  static var state = WebViewState()\n\n  // Shared instance and lifecycle\n  private(set) static var shared: WKWebView?\n  private static weak var currentHost: UIView?\n  private static var observersAdded = false\n  private static var destroyWorkItem: DispatchWorkItem?\n\n  // Readiness and pending scripts\n  private static var isReady = false\n  private static var pendingJavaScripts: [String] = []\n\n  // Last known URL and replayable state (dynamic key-value, like JS object)\n  private static var lastURL: String?\n  private static var lastState: [String: Any] = [:]\n\n  // MARK: - Public API\n\n  public static func initializeLifecycleObservers() {\n    guard !observersAdded else { return }\n    observersAdded = true\n\n    NotificationCenter.default.addObserver(\n      forName: UIApplication.didEnterBackgroundNotification,\n      object: nil,\n      queue: .main\n    ) { _ in\n      didEnterBackground()\n    }\n\n    NotificationCenter.default.addObserver(\n      forName: UIApplication.willEnterForegroundNotification,\n      object: nil,\n      queue: .main\n    ) { _ in\n      willEnterForeground()\n    }\n  }\n\n  public static func cleanupLifecycleObservers() {\n    guard observersAdded else { return }\n    observersAdded = false\n\n    // Remove notification observers\n    NotificationCenter.default.removeObserver(\n      self,\n      name: UIApplication.didEnterBackgroundNotification,\n      object: nil\n    )\n\n    NotificationCenter.default.removeObserver(\n      self,\n      name: UIApplication.willEnterForegroundNotification,\n      object: nil\n    )\n\n    // Cancel any pending destroy work item\n    destroyWorkItem?.cancel()\n    destroyWorkItem = nil\n  }\n\n  public static func evaluateJavaScript(_ js: String) {\n    DispatchQueue.main.async {\n      // Queue until ready/loading completed\n      guard let webView = shared, webView.url != nil, !webView.isLoading, isReady else {\n        pendingJavaScripts.append(js)\n        return\n      }\n      webView.evaluateJavaScript(js)\n    }\n  }\n\n  public static func load(_ urlString: String) {\n    DispatchQueue.main.async {\n      lastURL = urlString\n      let webView = getOrCreate()\n      guard let url = URL(string: urlString) else { return }\n      if webView.url == url && !webView.isLoading { return }\n      isReady = false\n      if url.scheme == \"file\" {\n        if let localHtml = resolveLocalHTML(from: urlString) {\n          webView.loadFileURL(\n            localHtml, allowingReadAccessTo: localHtml.deletingLastPathComponent())\n          debugPrint(\"load local html: \\(localHtml.absoluteString)\")\n        } else {\n          debugPrint(\"Invalid local html url: \\(urlString)\")\n        }\n      } else {\n        webView.load(URLRequest(url: url))\n        debugPrint(\"load remote html: \\(url.absoluteString)\")\n      }\n    }\n  }\n\n  public static func attach(to host: UIView) {\n    DispatchQueue.main.async {\n      let webView = getOrCreate()\n\n      // Move from previous host if needed\n      if webView.superview !== host {\n        webView.removeFromSuperview()\n        host.addSubview(webView)\n      }\n      currentHost = host\n    }\n  }\n\n  public static func detach(from host: UIView) {\n    DispatchQueue.main.async {\n      guard currentHost === host else { return }\n      currentHost = nil\n      // Do not remove subview immediately; releasing is handled by background logic\n    }\n  }\n\n  public static func updateFrame(_ rect: CGRect) {\n    DispatchQueue.main.async {\n      guard let webView = shared else { return }\n      webView.frame = rect\n      webView.scrollView.frame = rect\n    }\n  }\n\n  public static func markReadyAndFlush() {\n    DispatchQueue.main.async {\n      isReady = true\n      flushPendingScripts()\n    }\n  }\n\n  public static func handleProcessTerminated() {\n    DispatchQueue.main.async {\n      // Try to reload last URL and replay state\n      isReady = false\n      if let urlString = lastURL {\n        load(urlString)\n      }\n      replayState()\n    }\n  }\n\n  // Generic state recorders\n  public static func record(key: String, value: Any) {\n    lastState[key] = value\n  }\n  public static func recordJSON(key: String, payload: String?) {\n    guard let payload else {\n      lastState[key] = NSNull()\n      return\n    }\n    if let data = payload.data(using: .utf8),\n      let obj = try? JSONSerialization.jsonObject(with: data)\n    {\n      lastState[key] = obj\n    } else {\n      // Store raw string if not valid JSON\n      lastState[key] = payload\n    }\n  }\n\n  // MARK: - Internals\n\n  @discardableResult\n  static func getOrCreate() -> WKWebView {\n    if let webView = shared { return webView }\n    let webView = FOWebView(frame: .zero, state: state)\n    shared = webView\n    return webView\n  }\n\n  private static func flushPendingScripts() {\n    guard let webView = shared, webView.url != nil, !webView.isLoading, isReady else { return }\n    let scripts = pendingJavaScripts\n    pendingJavaScripts.removeAll()\n    for js in scripts {\n      webView.evaluateJavaScript(js)\n    }\n  }\n\n  private static func replayState() {\n    guard let json = buildStateJSON() else { return }\n    let call =\n      \"(function(){ if (window.__FO_BRIDGE__ && typeof window.__FO_BRIDGE__.applyState === 'function') { window.__FO_BRIDGE__.applyState(JSON.parse(\\(jsonString(json)))); } })()\"\n    pendingJavaScripts.append(call)\n    flushPendingScripts()\n  }\n\n  private static func buildStateJSON() -> String? {\n    guard JSONSerialization.isValidJSONObject(lastState) else {\n      // Try to sanitize by converting non-JSON types to string\n      var sanitized: [String: Any] = [:]\n      for (k, v) in lastState {\n        if JSONSerialization.isValidJSONObject([k: v]) {\n          sanitized[k] = v\n        } else {\n          sanitized[k] = String(describing: v)\n        }\n      }\n      if let data = try? JSONSerialization.data(withJSONObject: sanitized, options: []) {\n        return String(data: data, encoding: .utf8)\n      }\n      return nil\n    }\n    if let data = try? JSONSerialization.data(withJSONObject: lastState, options: []) {\n      return String(data: data, encoding: .utf8)\n    }\n    return nil\n  }\n\n  private static func jsonString(_ value: String) -> String {\n    // Wrap as JSON string literal\n    let data = try? JSONSerialization.data(withJSONObject: [\"v\": value], options: [])\n    if let data = data, let s = String(data: data, encoding: .utf8) {\n      // {\"v\":\"...\"}\n      if let range = s.range(of: \":\") {\n        return String(s[range.upperBound..<s.index(before: s.endIndex)])\n      }\n    }\n    // Fallback naive escaping\n    let escaped = value.replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\").replacingOccurrences(\n      of: \"\\\"\", with: \"\\\\\\\"\")\n    return \"\\\"\\(escaped)\\\"\"\n  }\n\n  private static func didEnterBackground() {\n    // If not attached to a host, schedule destroy to save memory\n    guard currentHost == nil else { return }\n    destroyWorkItem?.cancel()\n    let work = DispatchWorkItem { destroyWebView() }\n    destroyWorkItem = work\n    DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)\n  }\n\n  private static func willEnterForeground() {\n    destroyWorkItem?.cancel()\n    // Prewarm by recreating and loading lastURL\n    if shared == nil {\n      _ = getOrCreate()\n      if let urlString = lastURL {\n        load(urlString)\n      }\n      replayState()\n    }\n  }\n\n  private static func destroyWebView() {\n    guard let webView = shared else { return }\n    isReady = false\n    pendingJavaScripts.removeAll()\n\n    // Detach and cleanup on main thread\n    if Thread.isMainThread {\n      cleanup(webView)\n    } else {\n      DispatchQueue.main.sync { cleanup(webView) }\n    }\n\n    shared = nil\n  }\n\n  private static func cleanup(_ webView: WKWebView) {\n    webView.stopLoading()\n    webView.navigationDelegate = nil\n    webView.uiDelegate = nil\n    if let fo = webView as? FOWebView {\n      fo.configuration.userContentController.removeScriptMessageHandler(forName: \"message\")\n    }\n    webView.removeFromSuperview()\n\n    debugPrint(\"destroy webview: \\(debugState())\")\n  }\n\n  // MARK: - Debug helpers\n  public static func debugState() -> [String: Any] {\n    var dict: [String: Any] = [:]\n    dict[\"hasWebView\"] = shared != nil\n    dict[\"hasHost\"] = currentHost != nil\n    dict[\"ready\"] = isReady\n    dict[\"pending\"] = pendingJavaScripts.count\n    dict[\"lastURL\"] = lastURL ?? NSNull()\n    dict[\"contentHeight\"] = Double(state.contentHeight)\n    dict[\"keys\"] = Array(lastState.keys)\n    return dict\n  }\n\n  public static func destroyForDebug() {\n    destroyWebView()\n  }\n\n  public static func reloadLastURL() {\n    if let url = lastURL { load(url) }\n  }\n\n  public static func flushForDebug() {\n    flushPendingScripts()\n  }\n\n  private static func resolveLocalHTML(from fileURL: String) -> URL? {\n    // Map RN-provided file:// path to actual bundle resource url\n    if let url = URL(string: fileURL), url.scheme == \"file\" {\n      let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences(\n        of: \"file://\", with: \"\")\n      let fileName = url.lastPathComponent\n      let fileExtension = url.pathExtension\n      if let fileURL = Bundle.main.url(\n        forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)),\n        withExtension: fileExtension,\n        subdirectory: directoryPath\n      ) {\n        return fileURL\n      } else {\n        return nil\n      }\n    }\n    return nil\n  }\n\n  // Existing method preserved\n  static func presentModalWebView(\n    url: URL, from viewController: UIViewController, onDismiss: (() -> Void)? = nil\n  ) {\n    let safariVC = SafariViewController(url: url)\n    safariVC.view.tintColor = Utils.accentColor\n    safariVC.preferredControlTintColor = Utils.accentColor\n\n    if let onDismiss = onDismiss { safariVC.setOnDismiss(onDismiss) }\n    viewController.present(safariVC, animated: true)\n  }\n}\n\n// SwiftUI wrapper for the shared WKWebView (debug/internals)\nstruct SharedWebViewUI: UIViewRepresentable {\n  func makeUIView(context: Context) -> WKWebView {\n    return WebViewManager.getOrCreate()\n  }\n\n  func updateUIView(_ uiView: WKWebView, context: Context) {\n  }\n}\n\nextension WebViewManager {\n  static var swiftUIView: some View {\n    SharedWebViewUI()\n      .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n}\n\nprivate class SafariViewController: SFSafariViewController {\n  private var onDismiss: (() -> Void)?\n\n  public func setOnDismiss(_ onDismiss: @escaping () -> Void) {\n    self.onDismiss = onDismiss\n  }\n\n  override func viewDidDisappear(_ animated: Bool) {\n    super.viewDidDisappear(animated)\n    onDismiss?()\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift",
    "content": "//\n//  WebViewState.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/1/31.\n//\n\nimport Combine\nimport UIKit\n\nstruct ImagePreviewEvent {\n  let imageUrls: [String]\n  let index: Int\n}\n\nstruct AudioSeekEvent {\n  let time: Double\n}\n\nfinal class WebViewState: ObservableObject {\n  @Published public var contentHeight: CGFloat\n  @Published public var imagePreviewEvent: ImagePreviewEvent?\n  @Published public var audioSeekEvent: AudioSeekEvent?\n\n  public init() {\n    if Thread.isMainThread {\n      self.contentHeight = UIScreen.main.bounds.height\n    } else {\n      var height: CGFloat = 0\n      DispatchQueue.main.sync {\n        height = UIScreen.main.bounds.height\n      }\n      self.contentHeight = height\n    }\n  }\n\n  public func previewImages(urls: [String], index: Int) {\n    imagePreviewEvent = ImagePreviewEvent(imageUrls: urls, index: index)\n  }\n\n  public func seekAudio(time: Double) {\n    audioSeekEvent = AudioSeekEvent(time: time)\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/StoreKitTestHelper/StoreKitTestHelperModule.swift",
    "content": "import ExpoModulesCore\nimport Foundation\n#if canImport(StoreKitTest)\nimport StoreKitTest\n#endif\n\npublic class StoreKitTestHelperModule: Module {\n  #if canImport(StoreKitTest)\n    private static var session: SKTestSession?\n  #endif\n\n  public func definition() -> ModuleDefinition {\n    Name(\"StoreKitTestHelper\")\n\n    AsyncFunction(\"prepareLocalSubscriptions\") { () -> [String: Any] in\n      #if canImport(StoreKitTest)\n        let moduleFileURL = URL(fileURLWithPath: #filePath)\n        let appRootURL = moduleFileURL\n          .deletingLastPathComponent()\n          .deletingLastPathComponent()\n          .deletingLastPathComponent()\n          .deletingLastPathComponent()\n        let storeKitFileURL = appRootURL\n          .appendingPathComponent(\"ios\")\n          .appendingPathComponent(\"Folo - Follow everything.storekit\")\n\n        let session = try SKTestSession(contentsOf: storeKitFileURL)\n        session.disableDialogs = true\n        session.askToBuyEnabled = false\n        session.locale = Locale(identifier: \"en_US\")\n        session.storefront = \"SGP\"\n        session.resetToDefaultState()\n        session.clearTransactions()\n        Self.session = session\n\n        return [\n          \"enabled\": true,\n          \"path\": storeKitFileURL.path,\n        ]\n      #else\n        return [\n          \"enabled\": false,\n        ]\n      #endif\n    }\n\n    AsyncFunction(\"buyProduct\") { (productId: String) async throws -> [String: Any] in\n      #if canImport(StoreKitTest)\n        guard let session = Self.session else {\n          throw NSError(domain: \"StoreKitTestHelper\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"SKTestSession not prepared\"])\n        }\n        let transaction = try await session.buyProduct(identifier: productId)\n        return [\n          \"success\": true,\n          \"productId\": productId,\n          \"jwsRepresentation\": transaction.jwsRepresentation,\n        ]\n      #else\n        return [\n          \"success\": false,\n          \"productId\": productId,\n        ]\n      #endif\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/TabBar/TabBarBottomAccessoryModule.swift",
    "content": "//\n//  TabBarBottomAccessoryModule.swift\n//  FollowNative\n//\n//  Created by Innei on 2025-09-25\n//\n\nimport ExpoModulesCore\nimport UIKit\n\npublic class TabBarBottomAccessoryModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"TabBarBottomAccessory\")\n\n    View(TabBarBottomAccessoryView.self) {\n\n    }\n  }\n}\n\nclass TabBarBottomAccessoryView: ExpoView {\n  private weak var attachedRoot: TabBarRootView?\n\n  required init(appContext: AppContext? = nil) {\n    super.init(appContext: appContext)\n  }\n\n  deinit {\n    detachFromRoot()\n  }\n\n  override func didMoveToWindow() {\n    super.didMoveToWindow()\n    attachToNearestTabBarRoot()\n  }\n\n  override func willMove(toWindow newWindow: UIWindow?) {\n    if newWindow == nil {\n      detachFromRoot()\n    }\n    super.willMove(toWindow: newWindow)\n  }\n\n  #if RCT_NEW_ARCH_ENABLED\n\n    override func mountChildComponentView(_ childComponentView: UIView, index: Int) {\n      attachToNearestTabBarRoot()\n    }\n\n    override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {\n      detachFromRoot()\n\n    }\n  #endif\n\n  func attachToNearestTabBarRoot() {\n    if #available(iOS 26, *) {\n      guard window != nil else { return }\n\n      CustomTabbarController.tabBarController.bottomAccessory = .init(contentView: self)\n    }\n\n  }\n\n  func detachFromRoot() {\n    if #available(iOS 26, *) {\n      guard window != nil else { return }\n      CustomTabbarController.tabBarController.bottomAccessory = nil\n\n    }\n  }\n\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/TabBar/TabBarModule.swift",
    "content": "//\n//  TabBarModule.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/16.\n//\n\nimport ExpoModulesCore\n\npublic class TabBarModule: Module {\n\n  public func definition() -> ModuleDefinition {\n    Name(\"TabBarRoot\")\n\n    Function(\"switchTab\") { [weak self] (reactTag: Int, index: Int) in\n      DispatchQueue.main.async {\n        guard let bridge = self?.appContext?.reactBridge else {\n          return\n        }\n\n        if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag))\n          as! TabBarRootView?\n        {\n          sourceView.switchToTab(index: index)\n        }\n      }\n    }\n\n    View(TabBarRootView.self) {\n      Prop(\"selectedIndex\") { (view, index: Int) in\n        view.switchToTab(index: index)\n      }\n\n      Events(\"onTabIndexChange\", \"onTabItemPress\")\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/TabBar/TabBarPortalModule.swift",
    "content": "//\n//  TabBarPortalModule.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/17.\n//\n\nimport ExpoModulesCore\nimport UIKit\n\npublic class TabBarPortalModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"TabBarPortal\")\n\n    View(TabBarPortalView.self) {}\n  }\n}\n\nclass TabBarPortalView: ExpoView {\n\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift",
    "content": "//\n//  TabBarRootView.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/16.\n//\n\nimport ExpoModulesCore\nimport Foundation\nimport SnapKit\nimport UIKit\n\n@MainActor\nenum CustomTabbarController {\n  static var tabBarController = {\n    let tabBarController = UITabBarController()\n    let isPad = UIDevice.current.userInterfaceIdiom == .pad\n    if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .pad {\n      tabBarController.tabBar.isTranslucent = false\n      tabBarController.tabBar.barStyle = .default\n\n    }\n\n    tabBarController.tabBar.isHidden = true\n    if #available(iOS 18.0, *) {\n      tabBarController.isTabBarHidden = true\n    }\n\n    if #available(iOS 26.0, *), !isPad {\n      tabBarController.isTabBarHidden = false\n      tabBarController.tabBarMinimizeBehavior = .onScrollDown\n\n    }\n\n    tabBarController.tabBar.tintColor = Utils.accentColor\n\n    return tabBarController\n  }()\n}\n\nclass TabBarRootView: ExpoView {\n  private var tabBarController = CustomTabbarController.tabBarController\n\n  private let vc = UIViewController()\n  private var tabViewControllers: [UIViewController] = []\n  private var bottomAccessoryView: UIView?\n\n  private let onTabIndexChange = EventDispatcher()\n  private let onTabItemPress = EventDispatcher()\n\n\n\n  required init(appContext: AppContext? = nil) {\n    super.init(appContext: appContext)\n\n    tabBarController.delegate = self\n    addSubview(vc.view)\n    vc.addChild(tabBarController)\n    vc.view!.snp.makeConstraints { make in\n      make.edges.equalToSuperview()\n    }\n    vc.view.addSubview(tabBarController.view)\n    tabBarController.didMove(toParent: vc)\n  }\n\n  #if RCT_NEW_ARCH_ENABLED\n\n    override func mountChildComponentView(_ childComponentView: UIView, index: Int) {\n      insertSubview(childComponentView, at: index)\n    }\n\n    override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {\n      willRemoveSubview(childComponentView)\n      //      gestureRecognizers?.removeAll()\n    }\n  #endif\n\n  public func switchToTab(index: Int) {\n    let beforeIndex = tabBarController.selectedIndex\n    if beforeIndex == index {\n      return\n    }\n\n    if tabViewControllers.count < beforeIndex || tabViewControllers.count < index {\n      return\n    }\n    if let fromView = tabViewControllers[beforeIndex].view,\n      let toView = tabViewControllers[index].view\n    {\n      if fromView != toView {\n        UIView.transition(\n          from: fromView,\n          to: toView,\n          duration: 0.1,\n          options: [.transitionCrossDissolve, .preferredFramesPerSecond60],\n          completion: nil\n        )\n      }\n    }\n\n    tabBarController.selectedIndex = index\n    if let selectedViewController = tabBarController.selectedViewController {\n      tabBarController(tabBarController, didSelect: selectedViewController)\n    }\n  }\n\n  override func insertSubview(_ subview: UIView, at atIndex: Int) {\n    if let tabScreenView = subview as? TabScreenView {\n      let screenVC = UIViewController()\n      screenVC.view = tabScreenView\n      tabScreenView.ownerViewController = screenVC\n      // Apply current title if already provided from React side\n      if let currentTitle = tabScreenView.title {\n        screenVC.tabBarItem.title = currentTitle\n      }\n      if let icon = tabScreenView.icon {\n        screenVC.tabBarItem.image = .init(UIImage(named: icon)!)\n      }\n      if let activeIcon = tabScreenView.activeIcon {\n        screenVC.tabBarItem.selectedImage = .init(UIImage(named: activeIcon)!)\n      }\n      tabViewControllers.append(screenVC)\n      tabBarController.viewControllers = tabViewControllers\n      tabBarController.didMove(toParent: vc)\n    }\n\n    if let tabBarPortalView = subview as? TabBarPortalView {\n      let tabBarView = tabBarController.view!\n      tabBarView.addSubview(tabBarPortalView)\n    }\n\n  }\n\n  override func willRemoveSubview(_ subview: UIView) {\n    if let tabScreenView = subview as? TabScreenView {\n      tabViewControllers.removeAll { viewController in\n        viewController.view == tabScreenView\n      }\n\n      tabBarController.viewControllers = tabViewControllers\n      tabBarController.didMove(toParent: vc)\n    }\n\n  }\n}\n\n// MARK: - UITabBarControllerDelegate\n\nextension TabBarRootView: UITabBarControllerDelegate {\n  func tabBarController(\n    _ tabBarController: UITabBarController, shouldSelect viewController: UIViewController\n  ) -> Bool {\n    if let index = tabViewControllers.firstIndex(of: viewController) {\n      onTabItemPress([\"index\": index, \"currentIndex\": tabBarController.selectedIndex])\n    }\n    return true\n  }\n  func tabBarController(\n    _ tabBarController: UITabBarController, didSelect viewController: UIViewController\n  ) {\n    onTabIndexChange([\n      \"index\": tabBarController.selectedIndex\n    ])\n  }\n\n  // func tabBarController(\n  //   _ tabBarController: UITabBarController,\n  //   animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController\n  // ) -> (any UIViewControllerAnimatedTransitioning)? {\n\n  //   return nil\n  // }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/TabBar/TabScreenModule.swift",
    "content": "//\n//  TabScreenModule.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/16.\n//\n\nimport ExpoModulesCore\n\npublic class TabScreenModule: Module {\n\n  public func definition() -> ModuleDefinition {\n    Name(\"TabScreen\")\n    View(TabScreenView.self) {\n      Prop(\"title\") { (view, title: String?) in\n        view.title = title\n      }\n      Prop(\"icon\") { (view, icon: String?) in\n        view.icon = icon\n      }\n      Prop(\"activeIcon\") { (view, activeIcon: String?) in\n        view.activeIcon = activeIcon\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/TabBar/TabScreenView.swift",
    "content": "//\n//  TabScreenView.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/16.\n//\n\nimport ExpoModulesCore\nimport UIKit\n\nclass TabScreenView: ExpoView {\n  weak var ownerViewController: UIViewController?\n\n  var icon: String?\n  var activeIcon: String?\n  var title: String?\n\n  required init(appContext: AppContext? = nil) {\n    super.init(appContext: appContext)\n  }\n\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/Toaster/Toast.swift",
    "content": "//\n//  Toast.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/4/9.\n//\n\nimport Foundation\nimport ToastViewSwift\n\npublic enum Toast {\n    enum ToastType: String {\n        case error\n        case info\n        case warn\n        case success\n    }\n\n    enum Position: String {\n        case top\n        case center\n        case bottom\n    }\n\n    struct ToastOptions {\n        var message: String?\n        var type: ToastType = .info\n        var duration: Double = 1.5\n        var title: String\n        var position: Position?\n    }\n\n    static func show(options: ToastOptions) {\n        DispatchQueue.main.async {\n            let config: ToastConfiguration = .init(\n                direction: options.position?.position() ?? .top,\n                dismissBy: [.time(time: TimeInterval(options.duration))]\n            )\n            ToastViewSwift.Toast.default(image: options.type.image(), title: options.title, subtitle: options.message, config: config)\n                .show(haptic: options.type.haptic())\n        }\n    }\n}\n\nextension Toast.Position {\n    func position() -> ToastViewSwift.Toast.Direction {\n        switch self {\n        case .top:\n            return .top\n        case .center:\n            return .center\n        case .bottom:\n            return .bottom\n        }\n    }\n}\n\nextension Toast.ToastType {\n    func image() -> UIImage {\n        switch self {\n        case .error: UIImage(systemName: \"xmark.circle.fill\")!.withTintColor(.systemRed)\n        case .warn: UIImage(systemName: \"exclamationmark.triangle.fill\")!.withTintColor(.systemOrange)\n        case .info: UIImage(systemName: \"info.circle.fill\")!.withTintColor(.systemBlue)\n        case .success: UIImage(systemName: \"checkmark.circle.fill\")!.withTintColor(.systemGreen)\n        }\n    }\n\n    func haptic() -> UINotificationFeedbackGenerator.FeedbackType {\n        switch self {\n        case .error: .error\n        case .info: .success\n        case .warn: .warning\n        case .success: .success\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Modules/Toaster/ToasterModule.swift",
    "content": "//\n//  ToasterModule.swift\n//\n//  Created by Innei on 2025/2/21.\n//\n\nimport ExpoModulesCore\n\npublic class ToasterModule: Module {\n    struct ToastOptions: Record {\n        @Field\n        var message: String?\n        @Field\n        var type: ToastType = .info\n        @Field\n        var duration: Double = 1.5\n        @Field\n        var title: String\n        @Field\n        var position: Position?\n    }\n\n    enum ToastType: String, Enumerable {\n        case error\n        case info\n        case warn\n        case success\n\n        func toToastType() -> Toast.ToastType {\n            switch self {\n            case .error:\n                return .error\n            case .info:\n                return .info\n            case .warn:\n                return .warn\n            case .success:\n                return .success\n            }\n        }\n    }\n\n    enum Position: String, Enumerable {\n        case top\n        case center\n        case bottom\n\n        func toPostion() -> Toast.Position {\n            switch self {\n            case .top:\n                return .top\n            case .center:\n                return .center\n            case .bottom:\n                return .bottom\n            }\n        }\n    }\n\n    public func definition() -> ModuleDefinition {\n        Name(\"Toaster\")\n\n        Function(\"toast\") { (value: ToastOptions) in\n            Toast.show(options: Toast.ToastOptions(message: value.message, type: value.type.toToastType(), duration: value.duration, title: value.title, position: value.position?.toPostion() ?? .top))\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageCarouselViewController.swift",
    "content": "import UIKit\n\npublic protocol ImageDataSource: AnyObject {\n    func numberOfImages() -> Int\n    func imageItem(at index: Int) -> ImageItem\n}\n\npublic class ImageCarouselViewController: UIPageViewController,\n    ImageViewerTransitionViewControllerConvertible,\n    UIPageViewControllerDelegate\n{\n\n    unowned var initialSourceView: UIImageView?\n    var sourceView: UIImageView? {\n        guard let vc = viewControllers?.first as? ImageViewerController else {\n            return nil\n        }\n        return initialIndex == vc.index ? initialSourceView : nil\n    }\n\n    var targetView: UIImageView? {\n        guard let vc = viewControllers?.first as? ImageViewerController else {\n            return nil\n        }\n        return vc.imageView\n    }\n\n    weak var imageDatasource: ImageDataSource?\n    let imageLoader: ImageLoader\n\n    var initialIndex = 0\n\n    var imageContentMode: UIView.ContentMode = .scaleAspectFill\n    var options: [ImageViewerOption] = []\n\n    private var onRightNavBarTapped: ((Int) -> Void)?\n    private var onPreview: ((Int) -> Void)?\n    private var onClosePreview: (() -> Void)?\n    private var onIndexChange: ((Int) -> Void)?\n\n    private(set) lazy var navBar: UINavigationBar = {\n        let navbar = UINavigationBar(frame: .zero)\n        navbar.backgroundColor = .clear\n        navbar.isTranslucent = true\n        navbar.setBackgroundImage(UIImage(), for: .default)\n        navbar.shadowImage = UIImage()\n        return navbar\n    }()\n\n    private(set) lazy var backgroundView: UIView? = {\n        let v = UIView()\n        v.backgroundColor = .black\n        v.alpha = 1.0\n        return v\n    }()\n\n    private(set) lazy var navItem = UINavigationItem()\n\n    private(set) lazy var pageIndicator: UIPageControl = {\n        let pageControl = UIPageControl()\n        pageControl.translatesAutoresizingMaskIntoConstraints = false\n        pageControl.currentPageIndicatorTintColor = .white\n        pageControl.pageIndicatorTintColor = .gray\n        pageControl.isUserInteractionEnabled = false\n        return pageControl\n    }()\n\n    private let imageViewerPresentationDelegate: ImageViewerTransitionPresentationManager\n\n    public init(\n        sourceView: UIImageView,\n        imageDataSource: ImageDataSource?,\n        imageLoader: ImageLoader,\n        options: [ImageViewerOption] = [],\n        initialIndex: Int = 0\n    ) {\n        self.initialSourceView = sourceView\n        self.initialIndex = initialIndex\n        self.options = options\n        self.imageDatasource = imageDataSource\n        self.imageLoader = imageLoader\n        let pageOptions = [UIPageViewController.OptionsKey.interPageSpacing: 20]\n\n        var _imageContentMode = imageContentMode\n        options.forEach {\n            switch $0 {\n            case .contentMode(let contentMode):\n                _imageContentMode = contentMode\n            default:\n                break\n            }\n        }\n        imageContentMode = _imageContentMode\n\n        self.imageViewerPresentationDelegate = ImageViewerTransitionPresentationManager(\n            imageContentMode: imageContentMode)\n        super.init(\n            transitionStyle: .scroll,\n            navigationOrientation: .horizontal,\n            options: pageOptions)\n        self.view.overrideUserInterfaceStyle = .dark\n        transitioningDelegate = imageViewerPresentationDelegate\n        modalPresentationStyle = .custom\n        modalPresentationCapturesStatusBarAppearance = true\n    }\n\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) has not been implemented\")\n    }\n\n    private func addNavBar() {\n        let closeBarButton = UIBarButtonItem(\n            barButtonSystemItem: .close,\n            target: self,\n            action: #selector(dismiss(_:)))\n\n        let saveAction = UIAction(\n            title: \"Save to Photos\",\n            image: UIImage(systemName: \"square.and.arrow.down\")\n        ) { [weak self] _ in\n            self?.saveImageToPhotos()\n        }\n\n        let copyAction = UIAction(\n            title: \"Copy\",\n            image: UIImage(systemName: \"doc.on.doc\")\n        ) { [weak self] _ in\n            self?.copyImageToClipboard()\n        }\n\n        let shareAction = UIAction(\n            title: \"Share\",\n            image: UIImage(systemName: \"square.and.arrow.up\")\n        ) { [weak self] _ in\n            self?.shareImage()\n        }\n\n        let menu = UIMenu(title: \"\", children: [saveAction, copyAction, shareAction])\n\n        let optionsButton = UIBarButtonItem(\n            image: UIImage(systemName: \"ellipsis\")?.withAlpha(0.9).withTintColor(\n                .white, renderingMode: .alwaysOriginal\n            ),\n            primaryAction: nil,\n            menu: menu\n        )\n\n        navItem.leftBarButtonItem = closeBarButton\n        navItem.rightBarButtonItem = optionsButton\n//        navBar.alpha = 0.0\n        navBar.items = [navItem]\n\n        navBar.insert(to: view)\n    }\n\n    private func saveImageToPhotos() {\n        if let vc = viewControllers?.first as? ImageCarouselViewControllerProtocol,\n            !vc.isLoadError()\n        {\n\n            vc.saveImageToPhotos()\n\n        }\n\n    }\n    private func copyImageToClipboard() {\n        if let vc = viewControllers?.first as? ImageCarouselViewControllerProtocol,\n            !vc.isLoadError()\n        {\n            vc.copyImageToClipboard()\n        }\n    }\n    private func shareImage() {\n        if let vc = viewControllers?.first as? ImageCarouselViewControllerProtocol,\n            !vc.isLoadError()\n        {\n            vc.shareImage()\n        }\n    }\n\n    private func addBackgroundView() {\n        guard let backgroundView = backgroundView else { return }\n        view.addSubview(backgroundView)\n        backgroundView.bindFrameToSuperview()\n        view.sendSubviewToBack(backgroundView)\n    }\n\n    private func addPageIndicator() {\n        if imageDatasource?.numberOfImages() == 1 { return }\n        view.addSubview(pageIndicator)\n        NSLayoutConstraint.activate([\n            pageIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),\n            pageIndicator.bottomAnchor.constraint(\n                equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),\n        ])\n\n        if let imageDatasource = imageDatasource {\n            pageIndicator.numberOfPages = imageDatasource.numberOfImages()\n            pageIndicator.currentPage = initialIndex\n        }\n    }\n\n    public func setRightNavItem(item: UIBarButtonItem) {\n        navItem.rightBarButtonItem = item\n    }\n\n    private func applyOptions() {\n\n        options.forEach {\n            switch $0 {\n\n            case .contentMode(let contentMode):\n                self.imageContentMode = contentMode\n            case .onPreview(let callback):\n                self.onPreview = callback\n            case .onClosePreview(let callback):\n                self.onClosePreview = callback\n            case .onIndexChange(let callback):\n                self.onIndexChange = callback\n            }\n        }\n    }\n\n    override public func viewDidLoad() {\n        super.viewDidLoad()\n\n        addBackgroundView()\n        addNavBar()\n        addPageIndicator()\n        applyOptions()\n\n        dataSource = self\n        delegate = self\n\n        if let imageDatasource = imageDatasource {\n            let initialVC: ImageViewerController = .init(\n                index: initialIndex,\n                imageItem: imageDatasource.imageItem(at: initialIndex),\n                imageLoader: imageLoader,\n                sourceView: initialSourceView\n            )\n            setViewControllers([initialVC], direction: .forward, animated: true)\n            onPreview?(initialIndex)\n        }\n    }\n\n    override public func viewDidAppear(_ animated: Bool) {\n        super.viewDidAppear(animated)\n        if let vc = viewControllers?.first as? ImageViewerController {\n            onPreview?(vc.index)\n        }\n    }\n\n    @objc\n    private func dismiss(_ sender: UIBarButtonItem) {\n        onClosePreview?()\n        self.dismiss(animated: true, completion: nil)\n    }\n\n    deinit {\n        initialSourceView?.alpha = 1.0\n        onClosePreview?()\n    }\n\n}\n\n// MARK: - UIPageViewControllerDelegate\nextension ImageCarouselViewController {\n    public func pageViewController(\n        _ pageViewController: UIPageViewController,\n        didFinishAnimating finished: Bool,\n        previousViewControllers: [UIViewController],\n        transitionCompleted completed: Bool\n    ) {\n        if completed,\n            let currentVC = pageViewController.viewControllers?.first as? ImageViewerController\n        {\n            pageIndicator.currentPage = currentVC.index\n            onIndexChange?(currentVC.index)\n        }\n    }\n}\n\nextension ImageCarouselViewController: UIPageViewControllerDataSource {\n    public func pageViewController(\n        _ pageViewController: UIPageViewController,\n        viewControllerBefore viewController: UIViewController\n    ) -> UIViewController? {\n\n        guard let vc = viewController as? ImageViewerController else { return nil }\n        guard let imageDatasource = imageDatasource else { return nil }\n        guard vc.index > 0 else { return nil }\n\n        let newIndex = vc.index - 1\n        return ImageViewerController.init(\n            index: newIndex,\n            imageItem: imageDatasource.imageItem(at: newIndex),\n            imageLoader: vc.imageLoader\n        )\n    }\n\n    public func pageViewController(\n        _ pageViewController: UIPageViewController,\n        viewControllerAfter viewController: UIViewController\n    ) -> UIViewController? {\n\n        guard let vc = viewController as? ImageViewerController else { return nil }\n        guard let imageDatasource = imageDatasource else { return nil }\n        guard vc.index <= (imageDatasource.numberOfImages() - 2) else { return nil }\n\n        let newIndex = vc.index + 1\n        return ImageViewerController.init(\n            index: newIndex,\n            imageItem: imageDatasource.imageItem(at: newIndex),\n            imageLoader: vc.imageLoader)\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageCarouselViewControllerProtocol.swift",
    "content": "//\n//  ImageCarouselViewControllerProtocol.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/3/12.\n//\n\nimport UIKit\n\nprotocol ImageCarouselViewControllerProtocol {\n  func saveImageToPhotos()\n  func copyImageToClipboard()\n  func shareImage()\n\n  func isLoadError() -> Bool\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageItem.swift",
    "content": "import UIKit\n\npublic enum ImageItem {\n    case image(UIImage?)\n    case url(URL, placeholder: UIImage?)\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageLoader.swift",
    "content": "import Foundation\nimport SDWebImage\n\npublic protocol ImageLoader {\n  func loadImage(\n    _ url: URL, placeholder: UIImage?, imageView: UIImageView,\n    completion: @escaping (UIImage?) -> Void, onError: @escaping (Error) -> Void)\n}\nstruct SDWebImageLoader: ImageLoader {\n  func loadImage(\n    _ url: URL, placeholder: UIImage?, imageView: UIImageView,\n    completion: @escaping (UIImage?) -> Void, onError: @escaping (Error) -> Void\n  ) {\n    guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else {\n      return\n    }\n    urlComponents.scheme = \"https\"\n    guard let httpsURL = urlComponents.url else { return }\n\n    imageView.sd_setImage(\n      with: httpsURL,\n      placeholderImage: placeholder,\n      options: [],\n      progress: nil\n    ) { (img, err, type, url) in\n      DispatchQueue.main.async {\n        completion(img)\n      }\n\n      if let error = err {\n        print(\"Error: \\(error.localizedDescription)\")\n        if let nsError = error as NSError? {\n          print(\"Error Code: \\(nsError.code), Domain: \\(nsError.domain)\")\n        }\n        DispatchQueue.main.async {\n          onError(error)\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageViewerController.swift",
    "content": "import UIKit\n\nclass ImageViewerController: UIViewController,\n    UIGestureRecognizerDelegate\n{\n    var imageView: UIImageView = UIImageView(frame: .zero)\n    let imageLoader: ImageLoader\n    private var activityIndicator: UIActivityIndicatorView!\n\n    var backgroundView: UIView? {\n        guard let _parent = parent as? ImageCarouselViewController\n        else { return nil }\n        return _parent.backgroundView\n    }\n\n    var index: Int = 0\n    var imageItem: ImageItem!\n\n    var navBar: UINavigationBar? {\n        guard let _parent = parent as? ImageCarouselViewController\n        else { return nil }\n        return _parent.navBar\n    }\n\n    // MARK: Layout Constraints\n\n    private var top: NSLayoutConstraint!\n    private var leading: NSLayoutConstraint!\n    private var trailing: NSLayoutConstraint!\n    private var bottom: NSLayoutConstraint!\n\n    private var scrollView: UIScrollView!\n\n    private var lastLocation: CGPoint = .zero\n    private var isAnimating: Bool = false\n    private var maxZoomScale: CGFloat = 1.0\n\n    private var sourceView: UIImageView?\n\n    private var _error: Error?\n\n    init(\n        index: Int,\n        imageItem: ImageItem,\n        imageLoader: ImageLoader,\n        sourceView: UIImageView? = nil\n    ) {\n        self.index = index\n        self.imageItem = imageItem\n        self.imageLoader = imageLoader\n        self.sourceView = sourceView\n        super.init(nibName: nil, bundle: nil)\n    }\n\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) has not been implemented\")\n    }\n\n    override func loadView() {\n        let view = UIView()\n\n        view.backgroundColor = .clear\n        self.view = view\n\n        scrollView = UIScrollView()\n        scrollView.delegate = self\n        scrollView.showsVerticalScrollIndicator = false\n        scrollView.showsHorizontalScrollIndicator = false\n        scrollView.contentInsetAdjustmentBehavior = .never\n\n        view.addSubview(scrollView)\n        scrollView.bindFrameToSuperview()\n        scrollView.backgroundColor = .clear\n        scrollView.addSubview(imageView)\n\n        activityIndicator = UIActivityIndicatorView(style: .large)\n        activityIndicator.color = .white\n        activityIndicator.hidesWhenStopped = true\n        scrollView.addSubview(activityIndicator)\n        activityIndicator.translatesAutoresizingMaskIntoConstraints = false\n        NSLayoutConstraint.activate([\n            activityIndicator.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),\n            activityIndicator.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),\n        ])\n\n        imageView.translatesAutoresizingMaskIntoConstraints = false\n        top = imageView.topAnchor.constraint(equalTo: scrollView.topAnchor)\n        leading = imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)\n        trailing = scrollView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor)\n        bottom = scrollView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor)\n\n        top.isActive = true\n        leading.isActive = true\n        trailing.isActive = true\n        bottom.isActive = true\n    }\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n\n        switch imageItem {\n        case let .image(img):\n            imageView.image = img\n            imageView.layoutIfNeeded()\n        case let .url(url, placeholder):\n            activityIndicator.startAnimating()\n            if let sourceView = sourceView {\n                imageView.image = sourceView.image\n            }\n\n            if imageView.image == nil {\n                imageView.image = UIImage(systemName: \"photo\")?.withTintColor(\n                    .gray, renderingMode: .alwaysOriginal)\n                imageView.frame = .init(origin: .zero, size: .init(width: 100, height: 100))\n                layout()\n            }\n            layout()\n            imageLoader.loadImage(url, placeholder: placeholder, imageView: imageView) {\n                [weak self] _ in\n                self?.layout()\n                self?.activityIndicator.stopAnimating()\n            } onError: { [weak self] error in\n                self?.imageView.image = UIImage(systemName: \"exclamationmark.triangle.fill\")?\n                    .withTintColor(.red, renderingMode: .alwaysOriginal)\n                self?.imageView.frame = .init(origin: .zero, size: .init(width: 30, height: 30))\n                self?.layout()\n\n                Toast.show(\n                    options: .init(\n                        message: error.localizedDescription,\n                        type: .error,\n                        title: \"Image Load Error\"\n                    ))\n\n                self?.activityIndicator.stopAnimating()\n                self?._error = error\n            }\n        default:\n            break\n        }\n\n        addGestureRecognizers()\n    }\n\n    override func viewDidAppear(_ animated: Bool) {\n        super.viewDidAppear(animated)\n        navBar?.alpha = 1.0\n    }\n\n    override func viewDidDisappear(_ animated: Bool) {\n        super.viewDidDisappear(animated)\n        navBar?.alpha = 0.0\n    }\n\n    override func viewWillLayoutSubviews() {\n        super.viewWillLayoutSubviews()\n        layout()\n    }\n\n    private func layout() {\n        updateConstraintsForSize(view.bounds.size)\n        updateMinMaxZoomScaleForSize(view.bounds.size)\n    }\n\n    // MARK: Add Gesture Recognizers\n\n    func addGestureRecognizers() {\n        let panGesture = UIPanGestureRecognizer(\n            target: self, action: #selector(didPan(_:)))\n        panGesture.cancelsTouchesInView = false\n        panGesture.delegate = self\n        scrollView.addGestureRecognizer(panGesture)\n\n        let pinchRecognizer = UITapGestureRecognizer(\n            target: self, action: #selector(didPinch(_:)))\n        pinchRecognizer.numberOfTapsRequired = 1\n        pinchRecognizer.numberOfTouchesRequired = 2\n        scrollView.addGestureRecognizer(pinchRecognizer)\n\n        let singleTapGesture = UITapGestureRecognizer(\n            target: self, action: #selector(didSingleTap(_:)))\n        singleTapGesture.numberOfTapsRequired = 1\n        singleTapGesture.numberOfTouchesRequired = 1\n        scrollView.addGestureRecognizer(singleTapGesture)\n\n        let doubleTapRecognizer = UITapGestureRecognizer(\n            target: self, action: #selector(didDoubleTap(_:)))\n        doubleTapRecognizer.numberOfTapsRequired = 2\n        doubleTapRecognizer.numberOfTouchesRequired = 1\n        scrollView.addGestureRecognizer(doubleTapRecognizer)\n\n        singleTapGesture.require(toFail: doubleTapRecognizer)\n    }\n\n    @objc\n    func didPan(_ gestureRecognizer: UIPanGestureRecognizer) {\n        guard\n            isAnimating == false,\n            scrollView.zoomScale == scrollView.minimumZoomScale\n        else { return }\n\n        let container: UIView! = imageView\n        if gestureRecognizer.state == .began {\n            lastLocation = container.center\n        }\n\n        if gestureRecognizer.state != .cancelled {\n            let translation: CGPoint =\n                gestureRecognizer\n                .translation(in: view)\n            container.center = CGPoint(\n                x: lastLocation.x + translation.x,\n                y: lastLocation.y + translation.y)\n        }\n\n        let diffY = view.center.y - container.center.y\n        backgroundView?.alpha = 1.0 - abs(diffY / view.center.y)\n        if gestureRecognizer.state == .ended {\n            if abs(diffY) > 60 {\n                dismiss(animated: true)\n            } else {\n                executeCancelAnimation()\n            }\n        }\n    }\n\n    @objc\n    func didPinch(_ recognizer: UITapGestureRecognizer) {\n        var newZoomScale = scrollView.zoomScale / 1.5\n        newZoomScale = max(newZoomScale, scrollView.minimumZoomScale)\n        scrollView.setZoomScale(newZoomScale, animated: true)\n    }\n\n    @objc\n    func didSingleTap(_ recognizer: UITapGestureRecognizer) {\n        let currentNavAlpha = navBar?.alpha ?? 0.0\n        UIView.animate(withDuration: 0.235) {\n            self.navBar?.alpha = currentNavAlpha > 0.5 ? 0.0 : 1.0\n        }\n    }\n\n    @objc\n    func didDoubleTap(_ recognizer: UITapGestureRecognizer) {\n        let pointInView = recognizer.location(in: imageView)\n        zoomInOrOut(at: pointInView)\n    }\n\n    @objc func image(\n        _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer\n    ) {\n        if let error = error {\n            Toast.show(\n                options: .init(\n                    message: error.localizedDescription,\n                    type: .error,\n                    title: \"Save Error\"\n                ))\n        } else {\n            Toast.show(\n                options: .init(\n                    message: \"Saved\",\n                    type: .success,\n                    title: \"Save Success\"\n                ))\n        }\n    }\n\n    func gestureRecognizerShouldBegin(\n        _ gestureRecognizer: UIGestureRecognizer\n    ) -> Bool {\n        guard scrollView.zoomScale == scrollView.minimumZoomScale,\n            let panGesture = gestureRecognizer as? UIPanGestureRecognizer\n        else { return false }\n\n        let velocity = panGesture.velocity(in: scrollView)\n        return abs(velocity.y) > abs(velocity.x)\n    }\n}\n\n// MARK: Adjusting the dimensions\n\nextension ImageViewerController {\n    func updateMinMaxZoomScaleForSize(_ size: CGSize) {\n        let targetSize = imageView.bounds.size\n        if targetSize.width == 0 || targetSize.height == 0 {\n            return\n        }\n\n        let minScale = min(\n            size.width / targetSize.width,\n            size.height / targetSize.height)\n        let maxScale = max(\n            (size.width + 1.0) / targetSize.width,\n            (size.height + 1.0) / targetSize.height)\n\n        scrollView.minimumZoomScale = minScale\n        scrollView.zoomScale = minScale\n        maxZoomScale = maxScale\n        scrollView.maximumZoomScale = maxZoomScale * 1.1\n    }\n\n    func zoomInOrOut(at point: CGPoint) {\n        let newZoomScale =\n            scrollView.zoomScale == scrollView.minimumZoomScale\n            ? maxZoomScale : scrollView.minimumZoomScale\n        let size = scrollView.bounds.size\n        let w = size.width / newZoomScale\n        let h = size.height / newZoomScale\n        let x = point.x - (w * 0.5)\n        let y = point.y - (h * 0.5)\n        let rect = CGRect(x: x, y: y, width: w, height: h)\n        scrollView.zoom(to: rect, animated: true)\n    }\n\n    func updateConstraintsForSize(_ size: CGSize) {\n        let yOffset = max(0, (size.height - imageView.frame.height) / 2)\n        top.constant = yOffset\n        bottom.constant = yOffset\n\n        let xOffset = max(0, (size.width - imageView.frame.width) / 2)\n        leading.constant = xOffset\n        trailing.constant = xOffset\n        view.layoutIfNeeded()\n    }\n}\n\n// MARK: Animation Related stuff\n\nextension ImageViewerController {\n    private func executeCancelAnimation() {\n        isAnimating = true\n        UIView.animate(\n            withDuration: 0.237,\n            animations: {\n                self.imageView.center = self.view.center\n                self.backgroundView?.alpha = 1.0\n            }\n        ) { [weak self] _ in\n            self?.isAnimating = false\n        }\n    }\n}\n\nextension ImageViewerController: UIScrollViewDelegate {\n    func viewForZooming(in scrollView: UIScrollView) -> UIView? {\n        return imageView\n    }\n\n    func scrollViewDidZoom(_ scrollView: UIScrollView) {\n        updateConstraintsForSize(view.bounds.size)\n    }\n}\n\nextension ImageViewerController: ImageCarouselViewControllerProtocol {\n    public func isLoadError() -> Bool {\n        guard _error != nil else { return false }\n        return true\n    }\n\n    public func saveImageToPhotos() {\n        guard let image = imageView.image else { return }\n\n        UIImageWriteToSavedPhotosAlbum(\n            image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil)\n    }\n\n    public func copyImageToClipboard() {\n        guard let image = imageView.image else { return }\n\n        UIPasteboard.general.image = image\n\n        Toast.show(\n            options: .init(\n                message: \"Copied\",\n                type: .success,\n                title: \"Copy Success\"\n            ))\n    }\n\n    public func shareImage() {\n        guard let image = imageView.image else { return }\n\n        let activityViewController = UIActivityViewController(\n            activityItems: [image],\n            applicationActivities: nil)\n\n        // For iPad support\n        if let popoverController = activityViewController.popoverPresentationController {\n            popoverController.sourceView = imageView\n            popoverController.sourceRect = CGRect(\n                x: imageView.bounds.midX, y: imageView.bounds.midY, width: 0, height: 0)\n            popoverController.permittedArrowDirections = []\n        }\n\n        present(activityViewController, animated: true)\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageViewerOption.swift",
    "content": "import UIKit\n\npublic enum ImageViewerOption {\n\n    case contentMode(UIView.ContentMode)\n    case onPreview((Int) -> Void)\n    case onClosePreview(() -> Void)\n    case onIndexChange((Int) -> Void)\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageViewerTransitionPresentationManager.swift",
    "content": "//\n//  ImageViewerTransitionPresentationManager.swift\n//  ImageViewer.swift\n//\n//  Created by Michael Henry Pantaleon on 2020/08/19.\n//\n\nimport Foundation\nimport UIKit\n\nprotocol ImageViewerTransitionViewControllerConvertible {\n\n    // The source view\n    var sourceView: UIImageView? { get }\n\n    // The final view\n    var targetView: UIImageView? { get }\n}\n\nfinal class ImageViewerTransitionPresentationAnimator: NSObject {\n\n    let isPresenting: Bool\n    let imageContentMode: UIView.ContentMode\n\n    var observation: NSKeyValueObservation?\n\n    init(isPresenting: Bool, imageContentMode: UIView.ContentMode) {\n        self.isPresenting = isPresenting\n        self.imageContentMode = imageContentMode\n        super.init()\n    }\n}\n\n// MARK: - UIViewControllerAnimatedTransitioning\nextension ImageViewerTransitionPresentationAnimator: UIViewControllerAnimatedTransitioning {\n\n    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?)\n        -> TimeInterval\n    {\n        return 0.3\n    }\n\n    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {\n        let key: UITransitionContextViewControllerKey = isPresenting ? .to : .from\n        guard let controller = transitionContext.viewController(forKey: key)\n        else { return }\n\n        let animationDuration = transitionDuration(using: transitionContext)\n\n        if isPresenting {\n            presentAnimation(\n                transitionView: transitionContext.containerView,\n                controller: controller,\n                duration: animationDuration\n            ) { finished in\n                transitionContext.completeTransition(finished)\n            }\n\n        } else {\n            dismissAnimation(\n                transitionView: transitionContext.containerView,\n                controller: controller,\n                duration: animationDuration\n            ) { finished in\n                transitionContext.completeTransition(finished)\n            }\n        }\n    }\n\n    private func createDummyImageView(frame: CGRect, image: UIImage? = nil)\n        -> UIImageView\n    {\n        let dummyImageView: UIImageView = UIImageView(frame: frame)\n        dummyImageView.clipsToBounds = true\n        dummyImageView.contentMode = imageContentMode\n        dummyImageView.alpha = 1.0\n        dummyImageView.image = image\n        dummyImageView.isUserInteractionEnabled = false\n        return dummyImageView\n    }\n\n    private func presentAnimation(\n        transitionView: UIView,\n        controller: UIViewController,\n        duration: TimeInterval,\n        completed: @escaping ((Bool) -> Void)\n    ) {\n\n        guard\n            let transitionVC = controller as? ImageViewerTransitionViewControllerConvertible,\n            let sourceView = transitionVC.sourceView\n        else { return }\n\n        sourceView.alpha = 0.0\n        controller.view.alpha = 0.0\n\n        transitionView.addSubview(controller.view)\n        transitionVC.targetView?.alpha = 0.0\n        transitionVC.targetView?.tintColor = sourceView.tintColor\n\n        var dummyImageView: UIImageView?\n        if sourceView.image != nil {\n            dummyImageView = createDummyImageView(\n                frame: sourceView.frameRelativeToWindow(),\n                image: sourceView.image)\n            dummyImageView!.contentMode = .scaleAspectFit\n            dummyImageView!.tintColor = sourceView.tintColor\n\n            transitionView.addSubview(dummyImageView!)\n        }\n        UIView.animate(\n            withDuration: duration,\n            animations: {\n                dummyImageView?.frame = UIScreen.main.bounds\n                controller.view.alpha = 1.0\n            }\n        ) { [weak self] finished in\n\n            self?.observation = transitionVC.targetView?.observe(\n                \\.image, options: [.new, .initial]\n            ) { img, change in\n                if img.image != nil {\n                    transitionVC.targetView?.alpha = 1.0\n                    dummyImageView?.removeFromSuperview()\n                    completed(finished)\n                }\n            }\n        }\n    }\n\n    private func dismissAnimation(\n        transitionView: UIView,\n        controller: UIViewController,\n        duration: TimeInterval,\n        completed: @escaping ((Bool) -> Void)\n    ) {\n\n        guard\n            let transitionVC = controller as? ImageViewerTransitionViewControllerConvertible\n        else { return }\n\n        let sourceView = transitionVC.sourceView\n        let targetView = transitionVC.targetView\n\n        let dummyImageView = createDummyImageView(\n            frame: targetView?.frameRelativeToWindow() ?? UIScreen.main.bounds,\n            image: targetView?.image)\n        dummyImageView.tintColor = sourceView?.tintColor\n        transitionView.addSubview(dummyImageView)\n        targetView?.isHidden = true\n\n        controller.view.alpha = 1.0\n        UIView.animate(\n            withDuration: duration,\n            animations: {\n                if let sourceView = sourceView {\n                    // return to original position\n                    dummyImageView.frame = sourceView.frameRelativeToWindow()\n                } else {\n                    // just disappear\n                    dummyImageView.alpha = 0.0\n                }\n                controller.view.alpha = 0.0\n            }\n        ) { finished in\n            sourceView?.alpha = 1.0\n            controller.view.removeFromSuperview()\n            completed(finished)\n        }\n    }\n}\n\nfinal class ImageViewerTransitionPresentationController: UIPresentationController {\n\n    override var frameOfPresentedViewInContainerView: CGRect {\n        var frame: CGRect = .zero\n        frame.size = size(\n            forChildContentContainer: presentedViewController,\n            withParentContainerSize: containerView!.bounds.size)\n        return frame\n    }\n\n    override func containerViewWillLayoutSubviews() {\n        presentedView?.frame = frameOfPresentedViewInContainerView\n    }\n}\n\nfinal class ImageViewerTransitionPresentationManager: NSObject {\n    private let imageContentMode: UIView.ContentMode\n\n    public init(imageContentMode: UIView.ContentMode) {\n        self.imageContentMode = imageContentMode\n    }\n\n}\n\n// MARK: - UIViewControllerTransitioningDelegate\nextension ImageViewerTransitionPresentationManager: UIViewControllerTransitioningDelegate {\n    func presentationController(\n        forPresented presented: UIViewController,\n        presenting: UIViewController?,\n        source: UIViewController\n    ) -> UIPresentationController? {\n        let presentationController = ImageViewerTransitionPresentationController(\n            presentedViewController: presented,\n            presenting: presenting)\n        return presentationController\n    }\n\n    func animationController(\n        forPresented presented: UIViewController,\n        presenting: UIViewController,\n        source: UIViewController\n    ) -> UIViewControllerAnimatedTransitioning? {\n\n        return ImageViewerTransitionPresentationAnimator(\n            isPresenting: true, imageContentMode: imageContentMode)\n    }\n\n    func animationController(\n        forDismissed dismissed: UIViewController\n    ) -> UIViewControllerAnimatedTransitioning? {\n        return ImageViewerTransitionPresentationAnimator(\n            isPresenting: false, imageContentMode: imageContentMode)\n    }\n}\n\n// MARK: - UIAdaptivePresentationControllerDelegate\nextension ImageViewerTransitionPresentationManager: UIAdaptivePresentationControllerDelegate {\n\n    func adaptivePresentationStyle(\n        for controller: UIPresentationController,\n        traitCollection: UITraitCollection\n    ) -> UIModalPresentationStyle {\n        return .none\n    }\n\n    func presentationController(\n        _ controller: UIPresentationController,\n        viewControllerForAdaptivePresentationStyle style: UIModalPresentationStyle\n    ) -> UIViewController? {\n        return nil\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/ImageViewer_swift.h",
    "content": "#import <Foundation/Foundation.h>\n\n//! Project version number for ImageViewer_swift.\nFOUNDATION_EXPORT double ImageViewer_swiftVersionNumber;\n\n//! Project version string for ImageViewer_swift.\nFOUNDATION_EXPORT const unsigned char ImageViewer_swiftVersionString[];\n\n// In this header, you should import all the public headers of your framework using statements like #import <ImageViewer_swift/PublicHeader.h>\n\n\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/LISENCE",
    "content": "ImageViewer.swift\n\nMIT\n\nCopyright (c) 2013 Michael Henry Pantaleon (http://www.iamkel.net). All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/SimpleImageDatasource.swift",
    "content": "class SimpleImageDatasource:ImageDataSource {\n    \n    private(set) var imageItems:[ImageItem]\n    \n    init(imageItems: [ImageItem]) {\n        self.imageItems = imageItems\n    }\n    \n    func numberOfImages() -> Int {\n        return imageItems.count\n    }\n    \n    func imageItem(at index: Int) -> ImageItem {\n        return imageItems[index]\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/UIImageView_Extensions.swift",
    "content": "import UIKit\n\nextension UIImageView {\n    \n    // Data holder tap recognizer\n    private class TapWithDataRecognizer:UITapGestureRecognizer {\n        weak var from:UIViewController?\n        var imageDatasource:ImageDataSource?\n        var imageLoader:ImageLoader?\n        var initialIndex:Int = 0\n        var options:[ImageViewerOption] = []\n    }\n    \n    private var vc:UIViewController? {\n        guard let rootVC = UIApplication.shared.keyWindow?.rootViewController\n            else { return nil }\n        return rootVC.presentedViewController != nil ? rootVC.presentedViewController : rootVC\n    }\n    \n    public func setupImageViewer(\n        options:[ImageViewerOption] = [],\n        from:UIViewController? = nil,\n        imageLoader:ImageLoader? = nil) {\n        setup(\n            datasource: SimpleImageDatasource(imageItems: [.image(image)]),\n            options: options,\n            from: from,\n            imageLoader: imageLoader)\n    }\n\n    public func setupImageViewer(\n        url:URL,\n        initialIndex:Int = 0,\n        placeholder: UIImage? = nil,\n        options:[ImageViewerOption] = [],\n        from:UIViewController? = nil,\n        imageLoader:ImageLoader? = nil) {\n        \n        let datasource = SimpleImageDatasource(\n            imageItems: [url].compactMap {\n                ImageItem.url($0, placeholder: placeholder)\n        })\n        setup(\n            datasource: datasource,\n            initialIndex: initialIndex,\n            options: options,\n            from: from,\n            imageLoader: imageLoader)\n    }\n    \n    public func setupImageViewer(\n        images:[UIImage],\n        initialIndex:Int = 0,\n        options:[ImageViewerOption] = [],\n        from:UIViewController? = nil,\n        imageLoader:ImageLoader? = nil) {\n        \n        let datasource = SimpleImageDatasource(\n            imageItems: images.compactMap {\n                ImageItem.image($0)\n        })\n        setup(\n            datasource: datasource,\n            initialIndex: initialIndex,\n            options: options,\n            from: from,\n            imageLoader: imageLoader)\n    }\n\n    public func setupImageViewer(\n        urls:[URL],\n        initialIndex:Int = 0,\n        options:[ImageViewerOption] = [],\n        placeholder: UIImage? = nil,\n        from:UIViewController? = nil,\n        imageLoader:ImageLoader? = nil) {\n        \n        let datasource = SimpleImageDatasource(\n            imageItems: urls.compactMap {\n                ImageItem.url($0, placeholder: placeholder)\n        })\n        setup(\n            datasource: datasource,\n            initialIndex: initialIndex,\n            options: options,\n            from: from,\n            imageLoader: imageLoader)\n    }\n    \n    public func setupImageViewer(\n        datasource:ImageDataSource,\n        initialIndex:Int = 0,\n        options:[ImageViewerOption] = [],\n        from:UIViewController? = nil,\n        imageLoader:ImageLoader? = nil) {\n        \n        setup(\n            datasource: datasource,\n            initialIndex: initialIndex,\n            options: options,\n            from: from,\n            imageLoader: imageLoader)\n    }\n    \n    private func setup(\n        datasource:ImageDataSource?,\n        initialIndex:Int = 0,\n        options:[ImageViewerOption] = [],\n        from: UIViewController? = nil,\n        imageLoader:ImageLoader? = nil) {\n        \n        var _tapRecognizer:TapWithDataRecognizer?\n        gestureRecognizers?.forEach {\n            if let _tr = $0 as? TapWithDataRecognizer {\n                // if found, just use existing\n                _tapRecognizer = _tr\n            }\n        }\n        \n        isUserInteractionEnabled = true\n        \n        var imageContentMode: UIView.ContentMode = .scaleAspectFill\n        options.forEach {\n            switch $0 {\n            case .contentMode(let contentMode):\n                imageContentMode = contentMode\n            default:\n                break\n            }\n        }\n        contentMode = imageContentMode\n        \n        clipsToBounds = true\n        \n        if _tapRecognizer == nil {\n            _tapRecognizer = TapWithDataRecognizer(\n                target: self, action: #selector(showImageViewer(_:)))\n            _tapRecognizer!.numberOfTouchesRequired = 1\n            _tapRecognizer!.numberOfTapsRequired = 1\n        }\n        // Pass the Data\n        _tapRecognizer!.imageDatasource = datasource\n        _tapRecognizer!.imageLoader = imageLoader\n        _tapRecognizer!.initialIndex = initialIndex\n        _tapRecognizer!.options = options\n        _tapRecognizer!.from = from\n        addGestureRecognizer(_tapRecognizer!)\n    }\n    \n    @objc\n    private func showImageViewer(_ sender:TapWithDataRecognizer) {\n        guard let sourceView = sender.view as? UIImageView else { return }\n        let imageCarousel = ImageCarouselViewController.init(\n            sourceView: sourceView,\n            imageDataSource: sender.imageDatasource,\n            imageLoader: sender.imageLoader ?? SDWebImageLoader(),\n            options: sender.options,\n            initialIndex: sender.initialIndex)\n        let presentFromVC = sender.from ?? vc\n        presentFromVC?.present(imageCarousel, animated: true)\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/UINavigationBar_Extensions.swift",
    "content": "import UIKit\n\nextension UINavigationBar {\n    \n    func insert(to view: UIView) {\n        view.addSubview(self)\n        translatesAutoresizingMaskIntoConstraints = false\n        leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true\n        rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true\n        topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)\n          .isActive = true\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/ImageViewer_swift/UIView_Extensions.swift",
    "content": "import UIKit\n\nextension UIView {\n    func bindFrameToSuperview(top:CGFloat = 0, leading: CGFloat = 0, trailing:CGFloat = 0, bottom:CGFloat = 0) {\n        guard let superview = self.superview else { return }\n        self.translatesAutoresizingMaskIntoConstraints = false\n        self.topAnchor.constraint(equalTo: superview.topAnchor, constant: top).isActive = true\n        self.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: leading).isActive = true\n        superview.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: trailing).isActive = true\n        superview.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: bottom).isActive = true\n    }\n    \n    func bindFrameToSuperview(margin:CGFloat) {\n        bindFrameToSuperview(top: margin, leading: margin, trailing: margin, bottom: margin)\n    }\n    \n    func frameRelativeToWindow() -> CGRect {\n        return convert(bounds, to: nil)\n    }\n}\n"
  },
  {
    "path": "apps/mobile/native/ios/Packages/SPIndicator/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Ivan Vorobei\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "apps/mobile/native/ios/Utils/Utils.swift",
    "content": "//\n//  Utils.swift\n//  FollowNative\n//\n//  Created by Innei on 2025/2/7.\n//\n\nimport Foundation\nimport UIKit\n\nprivate class Noop {}\n\nenum Utils {\n  static let bundle = Bundle(for: Noop.self)\n  static let accentColor = UIColor(cgColor: .init(red: 255 / 255, green: 92 / 255, blue: 0, alpha: 1))\n  \n  static func getRootVC() -> UIViewController? {\n    if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\n       let window = scene.windows.first,\n       let rootVC = window.rootViewController {\n      return rootVC\n    }\n    return nil\n  }\n}\n"
  },
  {
    "path": "apps/mobile/native/package.json",
    "content": "{\n  \"name\": \"follow-native\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Nope\",\n  \"author\": \"Innei <tukon479@gmail.com> (https://github.com/Innei)\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/Innei/follow-native#readme\",\n  \"bugs\": {\n    \"url\": \"https://github.com/Innei/follow-native/issues\"\n  },\n  \"keywords\": [\n    \"react-native\",\n    \"expo\",\n    \"follow-native\",\n    \"FollowNative\"\n  ],\n  \"scripts\": {\n    \"open:android\": \"open -a \\\"Android Studio\\\" example/android\",\n    \"open:ios\": \"xed example/ios\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/nativewind-env.d.ts",
    "content": "/// <reference types=\"nativewind/types\" />\n\n// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.\n"
  },
  {
    "path": "apps/mobile/package.json",
    "content": "{\n  \"name\": \"@follow/mobile\",\n  \"version\": \"0.4.0\",\n  \"private\": true,\n  \"main\": \"src/main.tsx\",\n  \"scripts\": {\n    \"android\": \"expo run:android\",\n    \"bump\": \"vv\",\n    \"dev\": \"npm run start\",\n    \"e2e:android\": \"sh ./e2e/run-maestro.sh android\",\n    \"e2e:bootstrap:ios:auth\": \"tsx scripts/e2e-prod-ios-auth-bootstrap.ts\",\n    \"e2e:bootstrap:ios:prod-auth\": \"pnpm run e2e:bootstrap:ios:auth\",\n    \"e2e:doctor\": \"sh -c 'for f in e2e/flows/shared/*.yaml e2e/flows/android/*.yaml e2e/flows/ios/*.yaml; do maestro check-syntax $f; done'\",\n    \"e2e:ios\": \"sh ./e2e/run-maestro.sh ios\",\n    \"e2e:ios:bootstrap\": \"sh ./e2e/run-maestro.sh ios bootstrap-auth\",\n    \"eas-build-post-install\": \"rm -rf $TMPDIR/metro-cache\",\n    \"eas-build-pre-install\": \"command -v pod >/dev/null 2>&1 && pod repo update || echo 'CocoaPods not found, skipping pod repo update'\",\n    \"ios\": \"expo run:ios\",\n    \"ios:device\": \"expo run:ios --device\",\n    \"start\": \"expo start --dev-client\",\n    \"start:clean\": \"expo start --dev-client --clear\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"update\": \"tsx scripts/expo-update.ts\",\n    \"web\": \"expo start --web\"\n  },\n  \"dependencies\": {\n    \"@better-auth/expo\": \"1.5.5\",\n    \"@expo/metro-runtime\": \"6.1.2\",\n    \"@expo/react-native-action-sheet\": \"4.1.1\",\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/components\": \"workspace:*\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/database\": \"workspace:*\",\n    \"@follow/hooks\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/store\": \"workspace:*\",\n    \"@follow/tracker\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"@gorhom/portal\": \"1.0.14\",\n    \"@hookform/resolvers\": \"5.2.2\",\n    \"@react-native-community/image-editor\": \"4.3.0\",\n    \"@react-native-firebase/analytics\": \"22.2.1\",\n    \"@react-native-firebase/app\": \"22.2.1\",\n    \"@react-native-firebase/app-check\": \"22.2.1\",\n    \"@react-native-firebase/messaging\": \"22.2.1\",\n    \"@react-native-masked-view/masked-view\": \"0.3.2\",\n    \"@react-native-menu/menu\": \"2.0.0\",\n    \"@react-native-picker/picker\": \"2.11.1\",\n    \"@shopify/flash-list\": \"2.0.2\",\n    \"@tanstack/query-sync-storage-persister\": \"5.90.22\",\n    \"@tanstack/react-query\": \"5.90.21\",\n    \"@tanstack/react-query-persist-client\": \"5.90.22\",\n    \"@types/qrcode\": \"1.5.6\",\n    \"better-auth\": \"1.5.5\",\n    \"camelcase-keys\": \"10.0.2\",\n    \"dayjs\": \"1.11.19\",\n    \"dnum\": \"2.17.0\",\n    \"es-toolkit\": \"1.44.0\",\n    \"expo\": \"54.0.33\",\n    \"expo-apple-authentication\": \"8.0.8\",\n    \"expo-application\": \"7.0.8\",\n    \"expo-background-task\": \"1.0.10\",\n    \"expo-blur\": \"15.0.8\",\n    \"expo-build-properties\": \"1.0.10\",\n    \"expo-clipboard\": \"8.0.8\",\n    \"expo-constants\": \"18.0.13\",\n    \"expo-dev-client\": \"6.0.20\",\n    \"expo-device\": \"8.0.10\",\n    \"expo-document-picker\": \"14.0.8\",\n    \"expo-file-system\": \"19.0.21\",\n    \"expo-glass-effect\": \"0.1.9\",\n    \"expo-haptics\": \"15.0.8\",\n    \"expo-iap\": \"2.5.0\",\n    \"expo-image\": \"3.0.11\",\n    \"expo-image-manipulator\": \"14.0.8\",\n    \"expo-image-picker\": \"~17.0.10\",\n    \"expo-linear-gradient\": \"15.0.8\",\n    \"expo-linking\": \"8.0.11\",\n    \"expo-localization\": \"17.0.8\",\n    \"expo-media-library\": \"18.2.1\",\n    \"expo-notifications\": \"0.32.16\",\n    \"expo-secure-store\": \"15.0.8\",\n    \"expo-sharing\": \"14.0.8\",\n    \"expo-splash-screen\": \"31.0.13\",\n    \"expo-sqlite\": \"16.0.10\",\n    \"expo-status-bar\": \"3.0.9\",\n    \"expo-store-review\": \"~9.0.9\",\n    \"expo-symbols\": \"1.0.8\",\n    \"expo-system-ui\": \"6.0.9\",\n    \"expo-task-manager\": \"14.0.9\",\n    \"expo-updates\": \"29.0.16\",\n    \"expo-video\": \"3.0.16\",\n    \"expo-web-browser\": \"15.0.10\",\n    \"franc-min\": \"6.2.0\",\n    \"i18next\": \"25.8.6\",\n    \"jotai\": \"2.17.1\",\n    \"lru-cache\": \"11.2.6\",\n    \"nanoid\": \"5.1.6\",\n    \"nativewind\": \"4.2.1\",\n    \"ofetch\": \"1.5.1\",\n    \"posthog-react-native\": \"4.34.0\",\n    \"qrcode\": \"1.5.4\",\n    \"react\": \"19.0.0\",\n    \"react-dom\": \"19.0.0\",\n    \"react-error-boundary\": \"6.1.0\",\n    \"react-hook-form\": \"7.71.1\",\n    \"react-i18next\": \"16.5.4\",\n    \"react-native\": \"0.81.5\",\n    \"react-native-awesome-slider\": \"2.9.0\",\n    \"react-native-bouncy-checkbox\": \"4.1.4\",\n    \"react-native-color-matrix-image-filters\": \"8.0.2\",\n    \"react-native-device-info\": \"15.0.1\",\n    \"react-native-edge-to-edge\": \"1.7.0\",\n    \"react-native-gesture-handler\": \"2.28.0\",\n    \"react-native-image-colors\": \"2.5.1\",\n    \"react-native-ios-context-menu\": \"3.2.1\",\n    \"react-native-ios-utilities\": \"5.2.0\",\n    \"react-native-keyboard-controller\": \"1.18.5\",\n    \"react-native-otp-entry\": \"1.8.5\",\n    \"react-native-pager-view\": \"6.9.1\",\n    \"react-native-reanimated\": \"4.1.6\",\n    \"react-native-root-siblings\": \"5.0.1\",\n    \"react-native-safe-area-context\": \"5.6.2\",\n    \"react-native-screens\": \"4.16.0\",\n    \"react-native-sheet-transitions\": \"0.1.2\",\n    \"react-native-svg\": \"15.12.1\",\n    \"react-native-track-player\": \"4.1.1\",\n    \"react-native-uikit-colors\": \"0.6.2\",\n    \"react-native-volume-manager\": \"2.0.8\",\n    \"react-native-web\": \"0.21.2\",\n    \"react-native-webview\": \"13.15.0\",\n    \"react-native-worklets\": \"0.5.1\",\n    \"shiki\": \"3.22.0\",\n    \"tailwindcss\": \"3.4.17\",\n    \"title-case\": \"4.3.2\",\n    \"use-sync-external-store\": \"1.6.0\",\n    \"usehooks-ts\": \"3.1.1\",\n    \"zeego\": \"3.0.6\",\n    \"zod\": \"3.25.76\",\n    \"zustand\": \"5.0.11\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"7.29.0\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@types/react\": \"19.1.17\",\n    \"babel-plugin-inline-import\": \"3.0.0\",\n    \"eas-cli\": \"16.26.0\",\n    \"expo-drizzle-studio-plugin\": \"0.2.1\",\n    \"nbump\": \"2.1.8\",\n    \"postcss\": \"8.5.6\",\n    \"sf-symbols-typescript\": \"2.2.0\",\n    \"typescript\": \"catalog:\"\n  },\n  \"appName\": \"Folo\",\n  \"expo\": {\n    \"autolinking\": {\n      \"nativeModulesDir\": \"./native\"\n    },\n    \"doctor\": {\n      \"appConfigFieldsNotSyncedCheck\": {\n        \"enabled\": false\n      },\n      \"reactNativeDirectoryCheck\": {\n        \"enabled\": true,\n        \"exclude\": [\n          \"react-native-track-player\",\n          \"follow-native\",\n          \"react-native-color-matrix-image-filters\"\n        ],\n        \"listUnknownPackages\": false\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/plugins/android-trust-user-certs.js",
    "content": "const { AndroidConfig, withAndroidManifest } = require(\"@expo/config-plugins\")\nconst { Paths } = require(\"@expo/config-plugins/build/android\")\nconst path = require(\"pathe\")\nconst fs = require(\"node:fs\")\n\nconst fsPromises = fs.promises\n\nconst { getMainApplicationOrThrow } = AndroidConfig.Manifest\n\nconst withTrustLocalCerts = (config) => {\n  return withAndroidManifest(config, async (config) => {\n    config.modResults = await setCustomConfigAsync(config, config.modResults)\n    return config\n  })\n}\n\nasync function setCustomConfigAsync(config, androidManifest) {\n  const src_file_pat = path.join(__dirname, \"network_security_config.xml\")\n  const res_file_path = path.join(\n    await Paths.getResourceFolderAsync(config.modRequest.projectRoot),\n    \"xml\",\n    \"network_security_config.xml\",\n  )\n\n  const res_dir = path.resolve(res_file_path, \"..\")\n\n  if (!fs.existsSync(res_dir)) {\n    await fsPromises.mkdir(res_dir)\n  }\n\n  await fsPromises.copyFile(src_file_pat, res_file_path)\n\n  const mainApplication = getMainApplicationOrThrow(androidManifest)\n  mainApplication.$[\"android:networkSecurityConfig\"] = \"@xml/network_security_config\"\n\n  return androidManifest\n}\n\nmodule.exports = withTrustLocalCerts\n"
  },
  {
    "path": "apps/mobile/plugins/network_security_config.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<network-security-config>\n  <base-config cleartextTrafficPermitted=\"true\">\n    <trust-anchors>\n      <certificates src=\"system\" />\n      <certificates src=\"user\" />\n    </trust-anchors>\n  </base-config>\n</network-security-config>"
  },
  {
    "path": "apps/mobile/plugins/with-android-jdk-21.js",
    "content": "const { withGradleProperties } = require(\"@expo/config-plugins\")\n\nfunction setGradleProperty(config, key, value) {\n  return withGradleProperties(config, (config) => {\n    const keyIndex = config.modResults.findIndex(\n      (item) => item.type === \"property\" && item.key === key,\n    )\n\n    const nextItem = {\n      type: \"property\",\n      key,\n      value,\n    }\n\n    if (keyIndex === -1) {\n      config.modResults.push(nextItem)\n    } else {\n      config.modResults.splice(keyIndex, 1, nextItem)\n    }\n\n    return config\n  })\n}\n\nmodule.exports = function withAndroidJdk21(config) {\n  let nextConfig = setGradleProperty(config, \"react.internal.disableJavaVersionAlignment\", \"true\")\n  nextConfig = setGradleProperty(nextConfig, \"kotlin.jvm.target.validation.mode\", \"warning\")\n  return nextConfig\n}\n"
  },
  {
    "path": "apps/mobile/plugins/with-android-manifest-plugin.js",
    "content": "const { withAndroidManifest } = require(\"expo/config-plugins\")\n\n// Ported from https://github.com/bluesky-social/social-app/blob/a5e25a7a16cdcde64628e942c073a119bc1d7a1e/plugins/withAndroidManifestPlugin.js\nmodule.exports = function withAndroidManifestPlugin(appConfig) {\n  return withAndroidManifest(appConfig, (decoratedAppConfig) => {\n    try {\n      decoratedAppConfig.modResults.manifest.application[0].$[\"android:largeHeap\"] = \"true\"\n    } catch (e) {\n      console.error(`withAndroidManifestPlugin failed`, e)\n    }\n    return decoratedAppConfig\n  })\n}\n"
  },
  {
    "path": "apps/mobile/plugins/with-follow-app-delegate.js",
    "content": "const { withAppDelegate } = require(\"@expo/config-plugins\")\nconst { mergeContents } = require(\"@expo/config-plugins/build/utils/generateCode\")\n\nconst withFollowAppDelegate = (config) => {\n  return withAppDelegate(config, async (config) => {\n    let newContents = config.modResults.contents\n\n    newContents = mergeContents({\n      src: newContents,\n      anchor: \"// You can add your custom initial props in the dictionary below.\",\n      newSrc: `\n  UIColor* tintColor = [UIColor colorWithRed:255.0/255.0 green:92.0/255.0 blue:0.0/255.0 alpha:1.0];\n  [UIView appearanceWhenContainedInInstancesOfClasses:@[[UIAlertController class]]].tintColor = tintColor;\n  self.window.tintColor = tintColor;\n\n  [[UIButton appearance] setTintColor:tintColor];\n  [[UISwitch appearance] setOnTintColor:tintColor];\n  [[UIView appearance] setTintColor:tintColor];\n`,\n      offset: 3,\n      tag: \"custom tint color\",\n      comment: \"  //\",\n    }).contents\n\n    config.modResults.contents = newContents\n\n    return config\n  })\n}\n\nmodule.exports = withFollowAppDelegate\n"
  },
  {
    "path": "apps/mobile/plugins/with-follow-assets.js",
    "content": "/*\n * If you add an asset you need to run `npx expo prebuild`\n * If you rename or delete an asset you need to run `npx expo prebuild --clean` to delete them in your android and ios folder as well.\n *\n * This plugin is inspired by the following plugins:\n * - [expo-custom-assets](https://github.com/Malaa-tech/expo-custom-assets)\n * - [spacedrive](https://github.com/spacedriveapp/spacedrive/blob/main/apps/mobile/scripts/withRiveAssets.js)\n */\n\nconst { withDangerousMod, withXcodeProject, IOSConfig } = require(\"@expo/config-plugins\")\nconst fs = require(\"node:fs\")\nconst path = require(\"pathe\")\nconst { execSync } = require(\"node:child_process\")\n\nconst IOS_GROUP_NAME = \"Assets\"\n\nconst isAssetReady = (assetsPath) => {\n  return fs.existsSync(assetsPath)\n}\n\nconst withFollowAssets = (config, props) => {\n  if (!isAssetReady(props.assetsPath)) {\n    // Build the web renderer directly to avoid workspace filter resolution issues on EAS workers.\n    const webAppDir = path.resolve(__dirname, \"..\", \"web-app\")\n    const cmd = `pnpm --dir ${webAppDir} build --outDir ${path.resolve(props.assetsPath, \"html-renderer\")}`\n    console.error(`Assets source directory not found! Running \\`${cmd}\\` to generate assets.`)\n    execSync(cmd, { stdio: [\"ignore\", \"ignore\", \"inherit\"] })\n  }\n  if (!isAssetReady(props.assetsPath)) {\n    throw new Error(\n      `Assets source directory not found! Please make sure the build is successful. path: ${\n        props.assetsPath\n      }`,\n    )\n  }\n  const configAfterAndroid = addAndroidResources(config, props)\n  const configAfterIos = addIOSResources(configAfterAndroid, props)\n  return configAfterIos\n}\n\n// Code inspired by https://github.com/rive-app/rive-react-native/issues/185#issuecomment-1593396573\nfunction addAndroidResources(config, { assetsPath }) {\n  return withDangerousMod(config, [\n    \"android\",\n    async (config) => {\n      // Get the path to the Android project directory\n      const { projectRoot } = config.modRequest\n\n      // Get the path to the Android resources directory\n      const resDir = path.join(projectRoot, \"android\", \"app\", \"src\", \"main\")\n\n      // Create the 'assets' directory if it doesn't exist\n      const rawDir = path.join(resDir, \"assets\")\n      fs.mkdirSync(rawDir, { recursive: true })\n\n      // Retrieve all files in the assets directory\n      // const assetFiles = fs.readdirSync(assetSourcePath)\n\n      // Move asset file to the resources 'raw' directory\n      fs.cpSync(assetsPath, rawDir, { recursive: true })\n\n      // Move each asset file to the resources 'raw' directory\n      // for (const assetFile of assetFiles) {\n      //   const srcAssetPath = path.join(assetSourcePath, assetFile)\n      //   const destAssetPath = path.join(rawDir, assetFile)\n      //   fs.copyFileSync(srcAssetPath, destAssetPath)\n      // }\n\n      // Access the resources directory by using `file:///android_asset/FILENAME`\n      return config\n    },\n  ])\n}\n\n// Code inspired by https://github.com/expo/expo/blob/61f8cf8d4b3cf5f8bf61f346476ebdb4aff40545/packages/expo-font/plugin/src/withFontsIos.ts\nfunction addIOSResources(config, { assetsPath }) {\n  return withXcodeProject(config, async (config) => {\n    const project = config.modResults\n    const { platformProjectRoot } = config.modRequest\n\n    // Create Assets group in project\n    IOSConfig.XcodeUtils.ensureGroupRecursively(project, IOS_GROUP_NAME)\n\n    // Add assets to group\n    addIOSResourceFile(project, platformProjectRoot, [assetsPath])\n\n    return config\n  })\n\n  function addIOSResourceFile(project, platformRoot, assetFilesPaths) {\n    for (const assetFile of assetFilesPaths) {\n      const riveFilePath = path.relative(platformRoot, assetFile)\n      IOSConfig.XcodeUtils.addResourceFileToGroup({\n        filepath: riveFilePath,\n        groupName: IOS_GROUP_NAME,\n        project,\n        isBuildFile: true,\n        verbose: true,\n      })\n    }\n  }\n}\n\nmodule.exports = withFollowAssets\n"
  },
  {
    "path": "apps/mobile/plugins/with-gradle-jvm-heap-size-increase.js",
    "content": "const { withGradleProperties } = require(\"expo/config-plugins\")\n\n// Fix android build failed randomly\n// Ported from https://github.com/bluesky-social/social-app/blob/main/plugins/withGradleJVMHeapSizeIncrease.js\n// Licensed under the MIT License\n// See also https://github.com/expo/expo/issues/30413\n\nfunction setGradlePropertiesValue(config, key, value) {\n  return withGradleProperties(config, (exportedConfig) => {\n    const keyIdx = exportedConfig.modResults.findIndex(\n      (item) => item.type === \"property\" && item.key === key,\n    )\n    if (keyIdx !== -1) {\n      exportedConfig.modResults.splice(keyIdx, 1, {\n        type: \"property\",\n        key,\n        value,\n      })\n    } else {\n      exportedConfig.modResults.push({\n        type: \"property\",\n        key,\n        value,\n      })\n    }\n\n    return exportedConfig\n  })\n}\n\nmodule.exports = function withGradleJVMHeapSizeIncrease(config) {\n  const newConfig = setGradlePropertiesValue(\n    config,\n    \"org.gradle.jvmargs\",\n    \"-Xmx4096m -XX:MaxMetaspaceSize=1024m\", //Set data of your choice\n  )\n\n  return newConfig\n}\n"
  },
  {
    "path": "apps/mobile/postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {\n      config: \"./tailwind.dom.config.ts\",\n    },\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "apps/mobile/scripts/apply-changelog.ts",
    "content": "import { copyFileSync, readFileSync, renameSync, writeFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { dirname, join } from \"pathe\"\n\n// @ts-ignore\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst changelogDir = join(__dirname, \"..\", \"changelog\")\n\nconst nextFile = join(changelogDir, \"next.md\")\n\nconst new_version = process.argv[2]\n\nconst majorMinorPatch = new_version!.split(\"-\")[0]\n\nconst nextContent = readFileSync(nextFile, \"utf-8\")\nwriteFileSync(nextFile, nextContent.replaceAll(\"NEXT_VERSION\", majorMinorPatch!))\n\n// Rename the next.md to the new version\nrenameSync(nextFile, join(changelogDir, `${majorMinorPatch}.md`))\n// Replace the NEXT_VERSION in the next.md file with the new version\n\n// Create the new next.md file\n\ncopyFileSync(join(changelogDir, \"next.template.md\"), join(changelogDir, \"next.md\"))\n"
  },
  {
    "path": "apps/mobile/scripts/e2e-prod-ios-auth-bootstrap.ts",
    "content": "import { execFileSync } from \"node:child_process\"\nimport { existsSync } from \"node:fs\"\nimport process from \"node:process\"\n\nimport { join } from \"pathe\"\n\ntype AuthError = {\n  code?: number\n  message?: string\n  status?: number\n  statusText?: string\n}\n\ntype AuthResponse = {\n  data: {\n    token?: string | null\n  } | null\n  error: AuthError | null\n}\n\ntype CookieValue = {\n  expires: string | null\n  value: string\n}\n\ntype CookieMap = Record<string, CookieValue>\n\nconst DEFAULT_BUNDLE_ID = \"is.follow\"\nconst DEFAULT_PASSWORD = \"Password123!\"\nconst COOKIE_STORAGE_KEY = \"follow_secure_store_fallback:follow_auth_cookie\"\nconst SESSION_TOKEN_STORAGE_KEY = \"follow_secure_store_fallback:__Secure-better-auth.session_token\"\nconst SESSION_COOKIE_KEYS = [\n  \"__Secure-better-auth.session_token\",\n  \"better-auth.session_token\",\n] as const\nconst envProfileDefaults = {\n  local: {\n    apiUrl: \"http://127.0.0.1:3000\",\n    callbackUrl: \"http://localhost:2233/login\",\n  },\n  prod: {\n    apiUrl: \"https://api.folo.is\",\n    callbackUrl: \"https://app.folo.is/login\",\n  },\n} as const\n\nconst getArgValue = (name: string) => {\n  const index = process.argv.indexOf(name)\n  if (index === -1) {\n    return null\n  }\n\n  return process.argv[index + 1] ?? null\n}\n\nconst run = (command: string, args: string[]) =>\n  execFileSync(command, args, {\n    encoding: \"utf8\",\n    stdio: [\"ignore\", \"pipe\", \"pipe\"],\n  }).trim()\n\nconst tryRun = (command: string, args: string[]) => {\n  try {\n    return run(command, args)\n  } catch {\n    return null\n  }\n}\n\nconst resolveBootedIOSDevice = () => {\n  const output = tryRun(\"xcrun\", [\"simctl\", \"list\", \"devices\", \"booted\"])\n  if (!output) {\n    return null\n  }\n\n  const match = output.match(/\\(([A-F0-9-]{36})\\) \\(Booted\\)/)\n  return match?.[1] ?? null\n}\n\nconst escapeSql = (value: string) => value.replaceAll(\"'\", \"''\")\n\nconst splitSetCookieHeader = (header: string) => {\n  const parts: string[] = []\n  let buffer = \"\"\n  let index = 0\n\n  while (index < header.length) {\n    const char = header[index]\n\n    if (char === \",\") {\n      const recent = buffer.toLowerCase()\n      const hasExpires = recent.includes(\"expires=\")\n      const hasGmt = /gmt/i.test(recent)\n\n      if (hasExpires && !hasGmt) {\n        buffer += char\n        index += 1\n        continue\n      }\n\n      if (buffer.trim()) {\n        parts.push(buffer.trim())\n        buffer = \"\"\n      }\n\n      index += 1\n      if (header[index] === \" \") {\n        index += 1\n      }\n      continue\n    }\n\n    buffer += char\n    index += 1\n  }\n\n  if (buffer.trim()) {\n    parts.push(buffer.trim())\n  }\n\n  return parts\n}\n\nconst toCookieMap = (setCookieHeader: string): CookieMap => {\n  const cookies = splitSetCookieHeader(setCookieHeader)\n  const now = Date.now()\n  const cookieMap: CookieMap = {}\n\n  for (const cookie of cookies) {\n    const parts = cookie.split(\";\").map((part) => part.trim())\n    const [nameValue, ...attributes] = parts\n    if (!nameValue) {\n      continue\n    }\n\n    const [name, ...valueParts] = nameValue.split(\"=\")\n    if (!name) {\n      continue\n    }\n\n    const value = valueParts.join(\"=\")\n    let expires: string | null = null\n\n    for (const attribute of attributes) {\n      const [rawAttrName, ...rawAttrValueParts] = attribute.split(\"=\")\n      const attrName = rawAttrName?.toLowerCase()\n      const attrValue = rawAttrValueParts.join(\"=\")\n\n      if (attrName === \"max-age\") {\n        const maxAge = Number(attrValue)\n        if (!Number.isNaN(maxAge)) {\n          expires = new Date(now + maxAge * 1000).toISOString()\n        }\n      }\n\n      if (!expires && attrName === \"expires\") {\n        const parsed = new Date(attrValue)\n        if (!Number.isNaN(parsed.getTime())) {\n          expires = parsed.toISOString()\n        }\n      }\n    }\n\n    cookieMap[name] = {\n      value,\n      expires,\n    }\n  }\n\n  return cookieMap\n}\n\nconst parseJson = async <T>(response: Response): Promise<T | null> => {\n  const text = await response.text()\n  if (!text) {\n    return null\n  }\n\n  return JSON.parse(text) as T\n}\n\nconst requestAuth = async ({\n  apiUrl,\n  body,\n  clientId,\n  path,\n  sessionId,\n}: {\n  apiUrl: string\n  body: Record<string, unknown>\n  clientId: string\n  path: string\n  sessionId: string\n}) => {\n  const response = await fetch(new URL(path, apiUrl), {\n    method: \"POST\",\n    headers: {\n      \"content-type\": \"application/json\",\n      \"x-client-id\": clientId,\n      \"x-session-id\": sessionId,\n      \"x-token\": \"ac:fallback\",\n    },\n    body: JSON.stringify(body),\n  })\n\n  const json = await parseJson<AuthResponse>(response)\n\n  return {\n    response,\n    json,\n    setCookie: response.headers.get(\"set-cookie\"),\n  }\n}\n\nconst signIn = (input: {\n  apiUrl: string\n  clientId: string\n  email: string\n  password: string\n  sessionId: string\n}) =>\n  requestAuth({\n    apiUrl: input.apiUrl,\n    path: \"/better-auth/sign-in/email\",\n    clientId: input.clientId,\n    sessionId: input.sessionId,\n    body: {\n      email: input.email,\n      password: input.password,\n      rememberMe: true,\n    },\n  })\n\nconst signUp = (input: {\n  apiUrl: string\n  callbackUrl: string\n  clientId: string\n  email: string\n  password: string\n  sessionId: string\n}) =>\n  requestAuth({\n    apiUrl: input.apiUrl,\n    path: \"/better-auth/sign-up/email\",\n    clientId: input.clientId,\n    sessionId: input.sessionId,\n    body: {\n      email: input.email,\n      password: input.password,\n      name: input.email.split(\"@\")[0] ?? input.email,\n      callbackURL: input.callbackUrl,\n    },\n  })\n\nconst assertAuthSuccess = ({\n  action,\n  response,\n  result,\n}: {\n  action: string\n  response: Response\n  result: AuthResponse | null\n}) => {\n  if (response.ok && !result?.error) {\n    return\n  }\n\n  const message =\n    result?.error?.message || `${action} failed with ${response.status} ${response.statusText}`\n  throw new Error(message)\n}\n\nconst upsertStorageValue = (dbPath: string, key: string, value: string) => {\n  const sql = `INSERT OR REPLACE INTO storage(key, value) VALUES('${escapeSql(key)}', '${escapeSql(value)}');`\n  run(\"sqlite3\", [dbPath, sql])\n}\n\nconst main = async () => {\n  const envProfile = process.env.EXPO_PUBLIC_E2E_ENV_PROFILE === \"local\" ? \"local\" : \"prod\"\n  const envDefaults = envProfileDefaults[envProfile]\n  const apiUrl = process.env.E2E_API_URL ?? envDefaults.apiUrl\n  const bundleId = process.env.E2E_BUNDLE_ID ?? DEFAULT_BUNDLE_ID\n  const callbackUrl = process.env.E2E_CALLBACK_URL ?? envDefaults.callbackUrl\n  const email = process.env.E2E_EMAIL ?? `folo-self-test-ios-${envProfile}-${Date.now()}@gmail.com`\n  const password = process.env.E2E_PASSWORD ?? DEFAULT_PASSWORD\n  const deviceId =\n    getArgValue(\"--udid\") ??\n    process.env.MAESTRO_IOS_DEVICE_ID ??\n    process.env.IOS_UDID ??\n    resolveBootedIOSDevice()\n\n  if (!deviceId) {\n    throw new Error(\"Missing iOS simulator UDID. Pass --udid or set MAESTRO_IOS_DEVICE_ID.\")\n  }\n\n  const clientId = process.env.E2E_CLIENT_ID ?? `codex-e2e-${Date.now()}`\n  const sessionId = process.env.E2E_SESSION_ID ?? `codex-e2e-${Date.now()}`\n\n  let signInResult = await signIn({\n    apiUrl,\n    clientId,\n    email,\n    password,\n    sessionId,\n  })\n\n  if (!signInResult.response.ok || signInResult.json?.error || !signInResult.setCookie) {\n    const signUpResult = await signUp({\n      apiUrl,\n      callbackUrl,\n      clientId,\n      email,\n      password,\n      sessionId,\n    })\n\n    if (!signUpResult.response.ok || signUpResult.json?.error) {\n      const signInMessage = signInResult.json?.error?.message\n      const signUpMessage = signUpResult.json?.error?.message\n      const isExistingAccount = signUpMessage?.toLowerCase().includes(\"exist\")\n\n      if (!isExistingAccount) {\n        throw new Error(\n          signUpMessage || signInMessage || `sign up failed with ${signUpResult.response.status}`,\n        )\n      }\n    }\n\n    signInResult = await signIn({\n      apiUrl,\n      clientId,\n      email,\n      password,\n      sessionId,\n    })\n  }\n\n  assertAuthSuccess({\n    action: \"sign in\",\n    response: signInResult.response,\n    result: signInResult.json,\n  })\n\n  if (!signInResult.setCookie) {\n    throw new Error(\"Missing set-cookie header from sign in response.\")\n  }\n\n  const cookieMap = toCookieMap(signInResult.setCookie)\n  const sessionToken = SESSION_COOKIE_KEYS.map((key) => cookieMap[key]?.value).find(Boolean)\n  if (!sessionToken) {\n    throw new Error(\"Missing session cookie after sign in.\")\n  }\n\n  const appContainer = run(\"xcrun\", [\"simctl\", \"get_app_container\", deviceId, bundleId, \"data\"])\n  const storageDbPath = join(appContainer, \"Documents\", \"SQLite\", \"ExpoSQLiteStorage\")\n\n  if (!existsSync(storageDbPath)) {\n    throw new Error(\n      `ExpoSQLiteStorage not found at ${storageDbPath}. Install and launch the app first.`,\n    )\n  }\n\n  upsertStorageValue(storageDbPath, COOKIE_STORAGE_KEY, JSON.stringify(cookieMap))\n  upsertStorageValue(storageDbPath, SESSION_TOKEN_STORAGE_KEY, sessionToken)\n\n  tryRun(\"xcrun\", [\"simctl\", \"terminate\", deviceId, bundleId])\n\n  run(\"xcrun\", [\"simctl\", \"launch\", deviceId, bundleId])\n\n  process.stdout.write(\n    `${JSON.stringify(\n      {\n        apiUrl,\n        bundleId,\n        deviceId,\n        email,\n        password,\n        storageDbPath,\n      },\n      null,\n      2,\n    )}\\n`,\n  )\n}\n\nmain().catch((error) => {\n  console.error(error instanceof Error ? error.message : String(error))\n  process.exitCode = 1\n})\n"
  },
  {
    "path": "apps/mobile/scripts/expo-update.ts",
    "content": "import { spawn } from \"node:child_process\"\nimport fs from \"node:fs\"\nimport fsp from \"node:fs/promises\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { getConfig } from \"@expo/config\"\nimport { dirname, join } from \"pathe\"\n\nimport { version } from \"../package.json\"\n\nasync function spawnAsync(command: string, args: string[]) {\n  return new Promise((resolve, reject) => {\n    const child = spawn(command, args, { stdio: \"inherit\", shell: true })\n    child.on(\"close\", (code) => {\n      if (code === 0) {\n        resolve(void 0)\n      } else {\n        reject(new Error(`Command failed with exit code ${code}`))\n      }\n    })\n  })\n}\n\nasync function main() {\n  await spawnAsync(\"expo\", [\"export\", \"-p\", \"ios\", \"-p\", \"android\"])\n\n  const __dirname = dirname(fileURLToPath(import.meta.url))\n  const projectDir = join(__dirname, \"..\")\n\n  const expoUpdatesServerRoot = join(projectDir, \"../\", \"../\", \"../\", \"expo-updates-server\")\n  const timestamp = Date.now()\n  const updatesFolder = join(expoUpdatesServerRoot, \"updates\", version, `${timestamp}`)\n\n  if (!fs.existsSync(updatesFolder)) {\n    fs.mkdirSync(updatesFolder, { recursive: true })\n  }\n\n  // cp dist to expo-updates-server\n  const distFolder = join(projectDir, \"dist\")\n  await fsp.cp(distFolder, updatesFolder, { recursive: true })\n\n  const { exp } = getConfig(projectDir, {\n    skipSDKVersionRequirement: true,\n    isPublicConfig: true,\n  })\n\n  const expoConfig = JSON.stringify(exp, null, 2)\n  const expoConfigPath = join(updatesFolder, \"expoConfig.json\")\n  fs.writeFileSync(expoConfigPath, expoConfig)\n}\n\nmain()\n"
  },
  {
    "path": "apps/mobile/shim-env.d.ts",
    "content": "/// <reference types=\"nativewind/types\" />\n\ndeclare namespace NodeJS {\n  export type ProcessEnv = Record<string, string | undefined>\n}\n"
  },
  {
    "path": "apps/mobile/src/@types/constants.ts",
    "content": "const langs = [\"en\", \"ja\", \"zh-CN\", \"zh-TW\", \"fr-FR\"] as const\nexport const currentSupportedLanguages = langs as readonly string[]\nexport type MobileSupportedLanguages = (typeof langs)[number]\n\nexport const ns = [\"default\", \"common\", \"lang\", \"errors\", \"settings\"] as const\nexport const defaultNS = \"default\" as const\n\nexport const dayjsLocaleImportMap = {\n  en: [\"en\", () => import(\"dayjs/locale/en\")],\n  [\"zh-CN\"]: [\"zh-cn\", () => import(\"dayjs/locale/zh-cn\")],\n  [\"ja\"]: [\"ja\", () => import(\"dayjs/locale/ja\")],\n  [\"zh-TW\"]: [\"zh-tw\", () => import(\"dayjs/locale/zh-tw\")],\n  [\"fr-FR\"]: [\"fr\", () => import(\"dayjs/locale/fr\")],\n} as const\n"
  },
  {
    "path": "apps/mobile/src/@types/default-resource.ts",
    "content": "import common_en from \"@locales/common/en.json\"\nimport common_frFR from \"@locales/common/fr-FR.json\"\nimport common_ja from \"@locales/common/ja.json\"\nimport common_zhCN from \"@locales/common/zh-CN.json\"\nimport common_zhTW from \"@locales/common/zh-TW.json\"\nimport errors_en from \"@locales/errors/en.json\"\nimport errors_frFR from \"@locales/errors/fr-FR.json\"\nimport errors_ja from \"@locales/errors/ja.json\"\nimport errors_zhCN from \"@locales/errors/zh-CN.json\"\nimport errors_zhTW from \"@locales/errors/zh-TW.json\"\nimport lang_en from \"@locales/lang/en.json\"\nimport lang_frFR from \"@locales/lang/fr-FR.json\"\nimport lang_ja from \"@locales/lang/ja.json\"\nimport lang_zhCN from \"@locales/lang/zh-CN.json\"\nimport lang_zhTW from \"@locales/lang/zh-TW.json\"\nimport en from \"@locales/mobile/default/en.json\"\nimport frFR from \"@locales/mobile/default/fr-FR.json\"\nimport ja from \"@locales/mobile/default/ja.json\"\nimport zhCN from \"@locales/mobile/default/zh-CN.json\"\nimport zhTW from \"@locales/mobile/default/zh-TW.json\"\nimport settings_en from \"@locales/settings/en.json\"\nimport settings_frFR from \"@locales/settings/fr-FR.json\"\nimport settings_ja from \"@locales/settings/ja.json\"\nimport settings_zhCN from \"@locales/settings/zh-CN.json\"\nimport settings_zhTW from \"@locales/settings/zh-TW.json\"\n\nimport type { MobileSupportedLanguages, ns } from \"./constants\"\n\n// @keep-sorted\nexport const defaultResources = {\n  \"fr-FR\": {\n    common: common_frFR,\n    default: frFR,\n    errors: errors_frFR,\n    lang: lang_frFR,\n    settings: settings_frFR,\n  },\n  // @keep-sorted\n  \"zh-CN\": {\n    common: common_zhCN,\n    default: zhCN,\n    errors: errors_zhCN,\n    lang: lang_zhCN,\n    settings: settings_zhCN,\n  },\n  // @keep-sorted\n  \"zh-TW\": {\n    common: common_zhTW,\n    default: zhTW,\n    errors: errors_zhTW,\n    lang: lang_zhTW,\n    settings: settings_zhTW,\n  },\n  // @keep-sorted\n  en: {\n    common: common_en,\n    default: en,\n    errors: errors_en,\n    lang: lang_en,\n    settings: settings_en,\n  },\n  // @keep-sorted\n  ja: {\n    common: common_ja,\n    default: ja,\n    errors: errors_ja,\n    lang: lang_ja,\n    settings: settings_ja,\n  },\n} satisfies Record<\n  MobileSupportedLanguages,\n  Partial<Record<(typeof ns)[number], Record<string, string>>>\n>\n"
  },
  {
    "path": "apps/mobile/src/@types/i18next.d.ts",
    "content": "import type { defaultNS, ns } from \"./constants\"\nimport type { defaultResources as resources } from \"./default-resource\"\n\ndeclare module \"i18next\" {\n  interface CustomTypeOptions {\n    // ns: [\"app\", \"common\", \"external\", \"lang\", \"settings\", \"shortcuts\"]\n    ns: typeof ns\n    resources: (typeof resources)[\"en\"]\n    defaultNS: typeof defaultNS\n    // if you see an error like: \"Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz\"\n    // set returnNull to false (and also in the i18next init options)\n    // returnNull: false;\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/App.tsx",
    "content": "import { usePrefetchActions } from \"@follow/store/action/hooks\"\nimport { usePrefetchSessionUser } from \"@follow/store/user/hooks\"\nimport { StatusBar } from \"expo-status-bar\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { View } from \"react-native\"\nimport Animated, { interpolate, useAnimatedStyle } from \"react-native-reanimated\"\nimport { RootSiblingParent } from \"react-native-root-siblings\"\nimport { useSheet } from \"react-native-sheet-transitions\"\n\nimport { useBackHandler } from \"./hooks/useBackHandler\"\nimport { useIntentHandler } from \"./hooks/useIntentHandler\"\nimport { useMessaging, useUpdateMessagingToken } from \"./hooks/useMessaging\"\nimport { useOnboarding } from \"./hooks/useOnboarding\"\nimport { useUnreadCountBadge } from \"./hooks/useUnreadCountBadge\"\nimport { DebugButton, EnvProfileIndicator } from \"./modules/debug\"\nimport { ReviewPromptProvider } from \"./modules/review-prompt/provider\"\n\nexport function App({ children }: { children: React.ReactNode }) {\n  return (\n    <>\n      <StatusBar translucent animated style=\"auto\" />\n      <View className=\"flex-1 bg-system-background\">\n        <SideEffect />\n\n        <ScaleableWrapper>\n          <RootSiblingParent>{children}</RootSiblingParent>\n        </ScaleableWrapper>\n\n        {__DEV__ && <DebugButton />}\n\n        <EnvProfileIndicator />\n      </View>\n    </>\n  )\n}\n\nconst ScaleableWrapper: FC<PropsWithChildren> = ({ children }) => {\n  const { scale } = useSheet()\n\n  const style = useAnimatedStyle(() => ({\n    borderRadius: interpolate(scale.value, [0.8, 0.99, 1], [0, 50, 0]),\n    transform: [\n      {\n        scale: scale.value,\n      },\n    ],\n  }))\n  return (\n    <Animated.View className=\"flex-1 overflow-hidden\" style={style}>\n      {children}\n    </Animated.View>\n  )\n}\n\nconst SideEffect = () => {\n  usePrefetchSessionUser()\n  useUnreadCountBadge()\n  useBackHandler()\n  useIntentHandler()\n  useOnboarding()\n\n  // prefetch actions to detect if the user has any actions contains notifications\n  usePrefetchActions()\n  useUpdateMessagingToken()\n  useMessaging()\n  return <ReviewPromptProvider />\n}\n"
  },
  {
    "path": "apps/mobile/src/atoms/app.ts",
    "content": "import type { DeviceType } from \"expo-device\"\nimport { atom } from \"jotai\"\n\nexport const loadingVisibleAtom = atom(false)\n\nexport const loadingAtom = atom<{\n  finish?: null | (() => any)\n  cancel?: null | (() => any)\n  error?: null | ((err: any) => any)\n  done?: null | ((r: unknown) => any)\n  thenable: null | Promise<any>\n}>({\n  finish: null,\n  cancel: null,\n  error: null,\n  done: null,\n  thenable: null,\n})\n/**\n * Do not use directly\n */\nexport const appAtoms = {\n  deviceType: atom<DeviceType | null>(null),\n}\n"
  },
  {
    "path": "apps/mobile/src/atoms/hooks/useDeviceType.ts",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { useAtomValue } from \"jotai\"\n\nimport { appAtoms } from \"../app\"\n\nexport const useDeviceType = () => {\n  const deviceType = useAtomValue(appAtoms.deviceType)\n  return deviceType\n}\n\nexport const getDeviceType = () => {\n  return jotaiStore.get(appAtoms.deviceType)\n}\n"
  },
  {
    "path": "apps/mobile/src/atoms/server-configs.ts",
    "content": "import { createAtomHooks } from \"@follow/utils/jotai\"\nimport type { StatusConfigs } from \"@follow-app/client-sdk\"\nimport { atom } from \"jotai\"\n\nimport { isPaymentFeatureEnabled } from \"@/src/lib/payment\"\n\nexport const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks(\n  atom<Nullable<StatusConfigs>>(null),\n)\n\nexport const useIsPaymentEnabled = () => {\n  const serverConfigs = useServerConfigs()\n  return isPaymentFeatureEnabled(serverConfigs?.PAYMENT_ENABLED)\n}\n\nexport const getIsPaymentEnabled = () => {\n  const serverConfigs = getServerConfigs()\n  return isPaymentFeatureEnabled(serverConfigs?.PAYMENT_ENABLED)\n}\n"
  },
  {
    "path": "apps/mobile/src/atoms/settings/data.ts",
    "content": "import type { DataSettings } from \"@/src/interfaces/settings/data\"\n\nimport { createSettingAtom } from \"./internal/helper\"\n\nexport const createDefaultSettings = (): DataSettings => ({\n  sendAnonymousData: true,\n})\n\nexport const {\n  useSettingKey: useDataSettingKey,\n  useSettingSelector: useDataSettingSelector,\n  useSettingKeys: useDataSettingKeys,\n  setSetting: setDataSetting,\n  clearSettings: clearDataSettings,\n  initializeDefaultSettings: initializeDefaultDataSettings,\n  getSettings: getDataSettings,\n  useSettingValue: useDataSettingValue,\n} = createSettingAtom(\"data\", createDefaultSettings)\n"
  },
  {
    "path": "apps/mobile/src/atoms/settings/general.ts",
    "content": "import { defaultGeneralSettings } from \"@follow/shared/settings/defaults\"\nimport type { GeneralSettings } from \"@follow/shared/settings/interface\"\nimport type { FetchEntriesPropsSettings } from \"@follow/store/entry/types\"\nimport type { SupportedLanguages } from \"@follow-app/client-sdk\"\nimport { useMemo } from \"react\"\n\nimport { getDeviceLanguage } from \"@/src/lib/i18n\"\n\nimport { createSettingAtom } from \"./internal/helper\"\n\nconst createDefaultSettings = (): GeneralSettings => {\n  const deviceLanguage = getDeviceLanguage()\n  return {\n    ...defaultGeneralSettings,\n    language: deviceLanguage,\n  }\n}\n\nexport const {\n  useSettingKey: useGeneralSettingKey,\n  useSettingSelector: useGeneralSettingSelector,\n  useSettingKeys: useGeneralSettingKeys,\n  setSetting: setGeneralSetting,\n  clearSettings: clearGeneralSettings,\n  initializeDefaultSettings: initializeDefaultGeneralSettings,\n  getSettings: getGeneralSettings,\n  useSettingValue: useGeneralSettingValue,\n\n  settingAtom: __generalSettingAtom,\n} = createSettingAtom(\"general\", createDefaultSettings)\n\nexport const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [\n  \"sendAnonymousData\",\n  \"language\",\n  \"appLaunchOnStartup\",\n  \"voice\",\n]\n\nexport function useActionLanguage() {\n  const actionLanguage = useGeneralSettingSelector((s) => s.actionLanguage)\n  const language = useGeneralSettingSelector((s) => s.language)\n  return (actionLanguage === \"default\" ? language : actionLanguage) as SupportedLanguages\n}\n\nexport function getActionLanguage() {\n  const { actionLanguage, language } = getGeneralSettings()\n  return (actionLanguage === \"default\" ? language : actionLanguage) as SupportedLanguages\n}\n\nexport function useHideAllReadSubscriptions() {\n  const hideAllReadSubscriptions = useGeneralSettingKey(\"hideAllReadSubscriptions\")\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  return hideAllReadSubscriptions && unreadOnly\n}\n\nexport function getHideAllReadSubscriptions() {\n  const { hideAllReadSubscriptions, unreadOnly } = getGeneralSettings()\n  return hideAllReadSubscriptions && unreadOnly\n}\n\nexport function useFetchEntriesSettings(): FetchEntriesPropsSettings {\n  const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(\n    \"hidePrivateSubscriptionsInTimeline\",\n  )\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  return useMemo(\n    () => ({\n      hidePrivateSubscriptionsInTimeline,\n      unreadOnly,\n    }),\n    [hidePrivateSubscriptionsInTimeline, unreadOnly],\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/atoms/settings/internal/helper.ts",
    "content": "import { useRefValue } from \"@follow/hooks\"\nimport { createAtomHooks } from \"@follow/utils\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport type { SetStateAction, WritableAtom } from \"jotai\"\nimport { atom as jotaiAtom, useAtomValue } from \"jotai\"\nimport { atomWithStorage, selectAtom } from \"jotai/utils\"\nimport { useMemo } from \"react\"\nimport { shallow } from \"zustand/shallow\"\n\nimport { JotaiPersistSyncStorage } from \"@/src/lib/jotai\"\n\nconst getStorageNS = (settingKey: string) => `follow-rn-${settingKey}`\ntype Nullable<T> = T | null | undefined\n\nexport const createSettingAtom = <T extends object>(\n  settingKey: string,\n  createDefaultSettings: () => T,\n) => {\n  const atom = atomWithStorage(\n    getStorageNS(settingKey),\n    createDefaultSettings(),\n    JotaiPersistSyncStorage,\n    {\n      getOnInit: true,\n    },\n  ) as WritableAtom<T, [SetStateAction<T>], void>\n\n  const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom)\n\n  const initializeDefaultSettings = () => {\n    const currentSettings = getSettings()\n    const defaultSettings = createDefaultSettings()\n    if (typeof currentSettings !== \"object\") setSettings(defaultSettings)\n    const newSettings = { ...defaultSettings, ...currentSettings }\n    setSettings(newSettings)\n  }\n\n  const selectAtomCacheMap = {} as Record<keyof ReturnType<typeof getSettings>, any>\n\n  const noopAtom = jotaiAtom(null)\n\n  const useMaybeSettingKey = <T extends keyof ReturnType<typeof getSettings>>(key: Nullable<T>) => {\n    // @ts-expect-error\n    let selectedAtom: Record<keyof T, any>[T] | null = null\n    if (key) {\n      selectedAtom = selectAtomCacheMap[key]\n      if (!selectedAtom) {\n        selectedAtom = selectAtom(atom, (s) => s[key])\n        selectAtomCacheMap[key] = selectedAtom\n      }\n    } else {\n      selectedAtom = noopAtom\n    }\n\n    return useAtomValue(selectedAtom) as ReturnType<typeof getSettings>[T]\n  }\n\n  const useSettingKey = <T extends keyof ReturnType<typeof getSettings>>(key: T) => {\n    return useMaybeSettingKey(key) as ReturnType<typeof getSettings>[T]\n  }\n\n  function useSettingKeys<\n    T extends keyof ReturnType<typeof getSettings>,\n    K1 extends T,\n    K2 extends T,\n    K3 extends T,\n    K4 extends T,\n    K5 extends T,\n    K6 extends T,\n    K7 extends T,\n    K8 extends T,\n    K9 extends T,\n    K10 extends T,\n  >(keys: [K1, K2?, K3?, K4?, K5?, K6?, K7?, K8?, K9?, K10?]) {\n    return [\n      useMaybeSettingKey(keys[0]),\n      useMaybeSettingKey(keys[1]),\n      useMaybeSettingKey(keys[2]),\n      useMaybeSettingKey(keys[3]),\n      useMaybeSettingKey(keys[4]),\n      useMaybeSettingKey(keys[5]),\n      useMaybeSettingKey(keys[6]),\n      useMaybeSettingKey(keys[7]),\n      useMaybeSettingKey(keys[8]),\n      useMaybeSettingKey(keys[9]),\n    ] as [\n      ReturnType<typeof getSettings>[K1],\n      ReturnType<typeof getSettings>[K2],\n      ReturnType<typeof getSettings>[K3],\n      ReturnType<typeof getSettings>[K4],\n      ReturnType<typeof getSettings>[K5],\n      ReturnType<typeof getSettings>[K6],\n      ReturnType<typeof getSettings>[K7],\n      ReturnType<typeof getSettings>[K8],\n      ReturnType<typeof getSettings>[K9],\n      ReturnType<typeof getSettings>[K10],\n    ]\n  }\n\n  const useSettingSelector = <\n    T extends keyof ReturnType<typeof getSettings>,\n    S extends ReturnType<typeof getSettings>,\n    R = S[T],\n  >(\n    selector: (s: S) => R,\n  ): R => {\n    const stableSelector = useRefValue(selector)\n\n    return useAtomValue(\n      // @ts-expect-error\n      useMemo(() => selectAtom(atom, stableSelector.current, shallow), [stableSelector]),\n    )\n  }\n\n  const setSetting = <K extends keyof ReturnType<typeof getSettings>>(\n    key: K,\n    value: ReturnType<typeof getSettings>[K],\n  ) => {\n    const updated = Date.now()\n\n    EventBus.dispatch(\"SETTING_CHANGE_EVENT\", {\n      key: settingKey as \"general\" | \"ui\",\n      payload: {\n        [key]: value,\n      },\n    })\n    setSettings({\n      ...getSettings(),\n      [key]: value,\n\n      updated,\n    })\n  }\n\n  const clearSettings = () => {\n    setSettings(createDefaultSettings())\n  }\n\n  Object.defineProperty(useSettingValue, \"select\", {\n    value: useSettingSelector,\n  })\n\n  return {\n    useSettingKey,\n    useSettingSelector,\n    setSetting,\n    clearSettings,\n    initializeDefaultSettings,\n\n    useSettingValue,\n    useSettingKeys,\n    getSettings,\n\n    settingAtom: atom,\n  } as {\n    useSettingKey: typeof useSettingKey\n    useSettingSelector: typeof useSettingSelector\n    setSetting: typeof setSetting\n    clearSettings: typeof clearSettings\n    initializeDefaultSettings: typeof initializeDefaultSettings\n    useSettingValue: typeof useSettingValue & {\n      select: <T extends keyof ReturnType<() => T>>(key: T) => Awaited<T[T]>\n    }\n    useSettingKeys: typeof useSettingKeys\n    getSettings: typeof getSettings\n    settingAtom: typeof atom\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/atoms/settings/ui.ts",
    "content": "import { defaultUISettings } from \"@follow/shared/settings/defaults\"\nimport type { UISettings as BaseUISettings } from \"@follow/shared/settings/interface\"\n\nimport { getDeviceLanguage } from \"@/src/lib/i18n\"\n\nimport { createSettingAtom } from \"./internal/helper\"\n\nexport interface UISettings extends BaseUISettings {\n  fontScale: number\n  useSystemFontScaling: boolean\n  useDifferentFontSizeForContent: boolean\n  mobileContentFontSize: number\n}\nexport const createDefaultSettings = (): UISettings => ({\n  ...defaultUISettings,\n  discoverLanguage: getDeviceLanguage().startsWith(\"zh\") ? \"all\" : \"eng\",\n\n  fontScale: 1,\n  useSystemFontScaling: true,\n  useDifferentFontSizeForContent: false,\n  mobileContentFontSize: 16,\n})\n\nexport const {\n  useSettingKey: useUISettingKey,\n  useSettingSelector: useUISettingSelector,\n  useSettingKeys: useUISettingKeys,\n  setSetting: setUISetting,\n  clearSettings: clearUISettings,\n  initializeDefaultSettings: initializeDefaultUISettings,\n  getSettings: getUISettings,\n  useSettingValue: useUISettingValue,\n  settingAtom: __uiSettingAtom,\n} = createSettingAtom(\"ui\", createDefaultSettings)\n\nexport const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [\n  \"uiFontFamily\",\n  \"readerFontFamily\",\n  \"opaqueSidebar\",\n  \"fontScale\",\n  \"useSystemFontScaling\",\n  // \"customCSS\",\n]\n"
  },
  {
    "path": "apps/mobile/src/components/common/AnimatedComponents.tsx",
    "content": "import { Animated, FlatList, Pressable, ScrollView } from \"react-native\"\nimport Reanimated from \"react-native-reanimated\"\n\nimport { Image } from \"@/src/components/ui/image/Image\"\n\nexport const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView)\nexport const AnimatedFlatList = Animated.createAnimatedComponent(FlatList)\nexport const AnimatedPressable = Animated.createAnimatedComponent(Pressable)\n\nexport const ReAnimateImage = Reanimated.createAnimatedComponent(Image)\nexport const ReAnimatedPressable = Reanimated.createAnimatedComponent(Pressable)\nexport const ReAnimatedScrollView = Reanimated.createAnimatedComponent(ScrollView)\n"
  },
  {
    "path": "apps/mobile/src/components/common/Balance.tsx",
    "content": "import { cn, toScientificNotation } from \"@follow/utils\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const Balance = ({\n  children,\n  className,\n  precision = 2,\n}: {\n  /** The token balance in wei. */\n  children: bigint\n  className?: string\n  precision?: number\n}) => {\n  const n = BigInt(children || 0n)\n  const formatted = format(n, {\n    digits: precision,\n    trailingZeros: true,\n  })\n  return <Text className={cn(\"tabular-nums\", className)}>{formatted}</Text>\n}\nfunction format(\n  value: bigint,\n  options:\n    | number\n    | {\n        digits?: number\n        compact?: boolean\n        trailingZeros?: boolean\n        locale?: string\n        decimalsRounding?: \"ROUND_HALF\" | \"ROUND_UP\" | \"ROUND_DOWN\"\n        signDisplay?: \"auto\" | \"always\" | \"exceptZero\" | \"negative\" | \"never\"\n      } = {},\n): string {\n  // Normalize options\n  const opts =\n    typeof options === \"number\"\n      ? {\n          digits: options,\n        }\n      : options\n  const {\n    digits,\n    compact = false,\n    trailingZeros = false,\n    locale = \"en-US\",\n    signDisplay = \"auto\",\n  } = opts\n\n  // Convert bigint to number for formatting\n  const num = Number(value) / 1e18 // Assuming 18 decimals (wei to ether conversion)\n\n  if (compact) {\n    return new Intl.NumberFormat(locale, {\n      notation: \"compact\",\n      maximumFractionDigits: digits,\n      minimumFractionDigits: trailingZeros ? digits : 0,\n      signDisplay,\n    }).format(num)\n  }\n\n  // For very large or small numbers, use scientific notation\n  if (Math.abs(num) > 1e9 || (Math.abs(num) < 1e-9 && num !== 0)) {\n    return toScientificNotation([value, 18], 10)\n  }\n  return new Intl.NumberFormat(locale, {\n    maximumFractionDigits: digits,\n    minimumFractionDigits: trailingZeros ? digits : 0,\n    signDisplay,\n    useGrouping: true,\n  }).format(num)\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/BlurEffect.tsx",
    "content": "import { GlassView } from \"expo-glass-effect\"\nimport { StyleSheet } from \"react-native\"\n\nimport { ThemedBlurView } from \"@/src/components/common/ThemedBlurView\"\nimport { isIos26 } from \"@/src/lib/platform\"\n\nexport const BlurEffect = () => {\n  if (isIos26) {\n    return <GlassView style={styles.fill} glassEffectStyle=\"regular\" />\n  }\n  return <ThemedBlurView style={styles.fill} />\n}\n\nconst styles = StyleSheet.create({\n  fill: {\n    ...StyleSheet.absoluteFillObject,\n    overflow: \"hidden\",\n    backgroundColor: \"transparent\",\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/common/CopyButton.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { useRef } from \"react\"\nimport { Pressable } from \"react-native\"\nimport Animated, {\n  interpolateColor,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withTiming,\n} from \"react-native-reanimated\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { CheckFilledIcon } from \"@/src/icons/check_filled\"\nimport { Copy2CuteReIcon } from \"@/src/icons/copy_2_cute_re\"\nimport { useColor } from \"@/src/theme/colors\"\n\ntype Size = \"sm\" | \"md\" | \"tiny\"\ninterface CopyButtonProps {\n  onCopy: () => void\n  className?: string\n  size?: Size\n}\n\nconst sizeClassNames = {\n  tiny: \"size-6\",\n  sm: \"size-8\",\n  md: \"size-10\",\n}\n\nconst sizeIconSize = {\n  tiny: 14,\n  sm: 18,\n  md: 20,\n}\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable)\nexport const CopyButton = ({ onCopy, className, size = \"sm\" }: CopyButtonProps) => {\n  const initialIconScale = useSharedValue(1)\n  const pressedIconScale = useSharedValue(0)\n  const initialStyle = useAnimatedStyle(() => ({\n    transform: [{ scale: initialIconScale.value }],\n  }))\n\n  const initialBgColor = useColor(\"gray3\")\n  const pressedBgColor = useColor(\"green\")\n\n  const pressedStyle = useAnimatedStyle(() => ({\n    position: \"absolute\",\n    transform: [{ scale: pressedIconScale.value }],\n  }))\n\n  const wrapperStyle = useAnimatedStyle(() => ({\n    backgroundColor: interpolateColor(\n      pressedIconScale.value,\n      [0, 1],\n      [initialBgColor, pressedBgColor],\n    ),\n  }))\n\n  const animatedProgressingRef = useRef(false)\n  const handlePress = useEventCallback(() => {\n    onCopy()\n\n    if (animatedProgressingRef.current) return\n    animatedProgressingRef.current = true\n    initialIconScale.value = withTiming(0, { duration: 100 }, () => {\n      pressedIconScale.value = withTiming(1, { duration: 100 }, () => {\n        pressedIconScale.value = withDelay(\n          1000,\n          withTiming(0, { duration: 100 }, () => {\n            initialIconScale.value = withTiming(1, { duration: 100 })\n          }),\n        )\n      })\n    })\n\n    setTimeout(\n      () => {\n        animatedProgressingRef.current = false\n      },\n      100 + 100 + 1000 + 100 + 100,\n    )\n  })\n  return (\n    <AnimatedPressable\n      hitSlop={10}\n      style={wrapperStyle}\n      className={cn(\n        \"items-center justify-center rounded-lg bg-gray-4\",\n        sizeClassNames[size],\n        className,\n      )}\n      onPress={handlePress}\n    >\n      <Animated.View style={initialStyle}>\n        <Copy2CuteReIcon color=\"#fff\" height={sizeIconSize[size]} width={sizeIconSize[size]} />\n      </Animated.View>\n      <Animated.View style={pressedStyle}>\n        <CheckFilledIcon color=\"#fff\" height={sizeIconSize[size]} width={sizeIconSize[size]} />\n      </Animated.View>\n    </AnimatedPressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/ErrorBoundary.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport type { FC } from \"react\"\nimport { createElement, useEffect } from \"react\"\nimport { ErrorBoundary as ReactErrorBoundary } from \"react-error-boundary\"\nimport { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const ErrorBoundary = ({\n  children,\n  fallbackRender,\n}: {\n  children: React.ReactNode\n  fallbackRender: FC<{\n    error: Error\n    resetError: () => void\n  }>\n}) => {\n  return (\n    <ReactErrorBoundary\n      fallbackRender={useTypeScriptHappyCallback(\n        ({ error: rawError, resetErrorBoundary }) => {\n          const error = rawError instanceof Error ? rawError : new Error(String(rawError))\n          return (\n            <>\n              {typeof fallbackRender === \"function\"\n                ? createElement(fallbackRender, {\n                    error,\n                    resetError: resetErrorBoundary,\n                  })\n                : defaultFallbackRender({\n                    error,\n                  })}\n              <ErrorReport error={error} />\n            </>\n          )\n        },\n        [fallbackRender],\n      )}\n    >\n      {children}\n    </ReactErrorBoundary>\n  )\n}\nconst defaultFallbackRender = ({ error }: { error: Error }) => {\n  return (\n    <View className=\"flex-1 items-center justify-center\">\n      <Text className=\"text-label\">{error.message}</Text>\n    </View>\n  )\n}\nconst ErrorReport = ({ error }: { error: Error }) => {\n  useEffect(() => {\n    console.error(error)\n    void tracker.manager.captureException(error, {\n      source: \"mobile_error_boundary\",\n    })\n  }, [error])\n  return null\n}\nexport const withErrorBoundary = <P extends object>(\n  Component: React.ComponentType<P>,\n  fallbackRender: (props: { error: Error; resetError: () => void }) => React.ReactNode,\n) => {\n  const WithErrorBoundaryComponent = (props: P) => {\n    return (\n      <ErrorBoundary fallbackRender={fallbackRender}>\n        <Component {...props} />\n      </ErrorBoundary>\n    )\n  }\n  WithErrorBoundaryComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || \"Component\"})`\n  return WithErrorBoundaryComponent\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/FullWindowOverlay.ios.tsx",
    "content": "export { FullWindowOverlay } from \"react-native-screens\"\n"
  },
  {
    "path": "apps/mobile/src/components/common/FullWindowOverlay.tsx",
    "content": "export { Fragment as FullWindowOverlay } from \"react\"\n"
  },
  {
    "path": "apps/mobile/src/components/common/Link.tsx",
    "content": "import type { PropsWithChildren } from \"react\"\nimport type { TextProps } from \"react-native\"\nimport { Linking } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const Link = ({\n  href,\n  children,\n  ...props\n}: PropsWithChildren<\n  {\n    href: string\n  } & TextProps\n>) => {\n  return (\n    <Text\n      className=\"text-accent\"\n      onPress={async () => {\n        const canOpen = await Linking.canOpenURL(href)\n        if (canOpen) {\n          Linking.openURL(href)\n        }\n      }}\n      {...props}\n    >\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/NoLoginInfo.tsx",
    "content": "import { Pressable, View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { destination } from \"@/src/lib/navigation/biz/Destination\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { Logo } from \"../ui/logo\"\n\nexport function NoLoginInfo({ target }: { target: \"timeline\" | \"subscriptions\" }) {\n  const readableContainerStyle = useReadableContainerStyle(420)\n  return (\n    <Pressable\n      testID={`no-login-${target}`}\n      className=\"flex-1 items-center justify-center gap-3\"\n      onPress={() => destination.Login()}\n    >\n      <View className=\"items-center gap-3 px-6\" style={readableContainerStyle}>\n        <Logo width={40} height={40} color={accentColor} />\n        <Text className=\"text-center text-xl text-secondary-label\">\n          {`Sign in to see your ${target}`}\n        </Text>\n      </View>\n    </Pressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/RefreshControl.tsx",
    "content": "import * as React from \"react\"\nimport { StyleSheet } from \"react-native\"\nimport Animated, {\n  cancelAnimation,\n  Easing,\n  useAnimatedStyle,\n  useSharedValue,\n  withRepeat,\n  withTiming,\n} from \"react-native-reanimated\"\nimport Svg, { Circle } from \"react-native-svg\"\n\ninterface CustomRefreshControlProps {\n  refreshing: boolean\n  pullProgress: number\n}\n\nconst SIZE = 24\nconst STROKE_WIDTH = 2\nconst RADIUS = (SIZE - STROKE_WIDTH) / 2\nconst CIRCUMFERENCE = 2 * Math.PI * RADIUS\n\nexport function CustomRefreshControl({ refreshing, pullProgress }: CustomRefreshControlProps) {\n  const rotation = useSharedValue(0)\n\n  React.useEffect(() => {\n    if (refreshing) {\n      rotation.value = withRepeat(\n        withTiming(360, {\n          duration: 1000,\n          easing: Easing.linear,\n        }),\n        -1, // infinite repeats\n        false, // no reverse\n      )\n    } else {\n      cancelAnimation(rotation)\n      rotation.value = 0\n    }\n  }, [refreshing, rotation])\n\n  const animatedStyle = useAnimatedStyle(() => {\n    return {\n      transform: [{ rotate: `${rotation.value}deg` }],\n    }\n  })\n\n  const strokeDashoffset = CIRCUMFERENCE * (1 - Math.max(Math.min(pullProgress - 0.2, 1), 0))\n\n  const opacityStyle = useAnimatedStyle(() => {\n    return {\n      opacity: pullProgress > 0 ? 1 : 0,\n    }\n  })\n\n  return (\n    <Animated.View style={[styles.container, opacityStyle]}>\n      <Animated.View style={[styles.circleContainer, animatedStyle]}>\n        <Svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>\n          <Circle\n            cx={SIZE / 2}\n            cy={SIZE / 2}\n            r={RADIUS}\n            fill=\"none\"\n            stroke=\"rgba(161, 161, 170, 0.7)\"\n            strokeWidth={STROKE_WIDTH}\n            strokeDasharray={CIRCUMFERENCE}\n            strokeDashoffset={refreshing ? 20 : strokeDashoffset}\n            strokeLinecap=\"round\"\n          />\n        </Svg>\n      </Animated.View>\n    </Animated.View>\n  )\n}\n\nconst styles = StyleSheet.create({\n  container: {\n    position: \"absolute\",\n    left: 0,\n    right: 0,\n    alignItems: \"center\",\n    height: 60,\n    justifyContent: \"center\",\n    transform: [{ translateY: -60 }],\n  },\n  circleContainer: {\n    width: SIZE,\n    height: SIZE,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/common/RotateableLoading.tsx",
    "content": "import type { FC } from \"react\"\nimport { useEffect } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useSharedValue,\n  withRepeat,\n  withTiming,\n} from \"react-native-reanimated\"\n\nimport { Loading3CuteReIcon } from \"@/src/icons/loading_3_cute_re\"\nimport { useColor } from \"@/src/theme/colors\"\n\nexport interface RotateableLoadingProps {\n  className?: string\n  style?: StyleProp<ViewStyle>\n  size?: number\n  color?: string\n}\nexport const RotateableLoading: FC<RotateableLoadingProps> = ({\n  size = 36,\n  color,\n  className,\n  style,\n}) => {\n  const label = useColor(\"label\")\n  const iconColor = color ?? label\n  const rotate = useSharedValue(0)\n  useEffect(() => {\n    rotate.value = withRepeat(\n      withTiming(360, { duration: 1000, easing: Easing.linear }),\n      Infinity,\n      false,\n    )\n    return () => {\n      rotate.value = 0\n    }\n  }, [rotate])\n\n  const rotateStyle = useAnimatedStyle(() => ({\n    display: \"flex\",\n    justifyContent: \"center\",\n    alignItems: \"center\",\n    transform: [{ rotate: `${rotate.value}deg` }],\n  }))\n\n  return (\n    <Animated.View className={className} style={[rotateStyle, style]}>\n      <Loading3CuteReIcon height={size} width={size} color={iconColor} />\n    </Animated.View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/SubmitButton.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useEffect } from \"react\"\nimport type { PressableProps } from \"react-native\"\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { PlatformActivityIndicator } from \"../ui/loading/PlatformActivityIndicator\"\nimport { ReAnimatedPressable } from \"./AnimatedComponents\"\n\nexport function SubmitButton({\n  isLoading,\n  title,\n  ...props\n}: PressableProps & { isLoading?: boolean; title: string }) {\n  const disabled = props.disabled || isLoading\n  const disableColor = useColor(\"gray6\")\n  const disabledTextColor = useColor(\"gray2\")\n\n  const disabledValue = useSharedValue(1)\n  useEffect(() => {\n    cancelAnimation(disabledValue)\n    disabledValue.value = withTiming(disabled ? 1 : 0)\n  }, [disabled, disabledValue])\n\n  const buttonStyle = useAnimatedStyle(() => ({\n    backgroundColor: interpolateColor(disabledValue.value, [1, 0], [disableColor, accentColor]),\n  }))\n\n  const textStyle = useAnimatedStyle(() => ({\n    color: interpolateColor(disabledValue.value, [1, 0], [disabledTextColor, \"white\"]),\n  }))\n\n  return (\n    <ReAnimatedPressable\n      {...props}\n      disabled={disabled}\n      style={[buttonStyle, props.style]}\n      className={cn(\n        \"flex h-[48] flex-row items-center justify-center rounded-2xl\",\n        props.className,\n      )}\n    >\n      {isLoading ? (\n        <PlatformActivityIndicator color=\"white\" />\n      ) : (\n        <Animated.Text className=\"text-center text-xl font-semibold\" style={textStyle}>\n          {title}\n        </Animated.Text>\n      )}\n    </ReAnimatedPressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/common/SwipeableItem.tsx",
    "content": "import { impactAsync, ImpactFeedbackStyle } from \"expo-haptics\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport * as React from \"react\"\nimport { StyleSheet, View } from \"react-native\"\nimport { RectButton } from \"react-native-gesture-handler\"\nimport type { SwipeableMethods } from \"react-native-gesture-handler/ReanimatedSwipeable\"\nimport ReanimatedSwipeable from \"react-native-gesture-handler/ReanimatedSwipeable\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, {\n  interpolate,\n  runOnJS,\n  useAnimatedReaction,\n  useAnimatedStyle,\n} from \"react-native-reanimated\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\ninterface Action {\n  label: string\n  icon?: React.ReactNode\n  backgroundColor?: string\n  onPress?: () => void\n  color?: string\n}\ninterface SwipeableItemProps {\n  children: React.ReactNode\n  leftActions?: Action[]\n  rightActions?: Action[]\n  disabled?: boolean\n  swipeRightToCallAction?: boolean\n}\nconst getActionKey = (side: \"left\" | \"right\", action: Action) => {\n  return `${side}-${action.label}-${action.backgroundColor ?? \"\"}-${action.color ?? \"\"}`\n}\nconst styles = StyleSheet.create({\n  absoluteFill: {\n    ...StyleSheet.absoluteFillObject,\n  },\n  actionContainer: {\n    flex: 1,\n    justifyContent: \"center\",\n    alignItems: \"center\",\n  },\n  animatedContainer: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n  },\n  actionsWrapper: {\n    flexDirection: \"row\",\n  },\n  actionText: {\n    color: \"#fff\",\n  },\n})\nconst rectButtonWidth = 74\nexport const SwipeableItem: React.FC<SwipeableItemProps> = ({\n  children,\n  leftActions,\n  rightActions,\n  disabled,\n  swipeRightToCallAction,\n}) => {\n  const itemRef = React.useRef<SwipeableMethods | null>(null)\n  const endDragCallerRef = React.useRef<() => void>(() => {})\n  const renderLeftActions = (progress: SharedValue<number>) => {\n    const width = leftActions?.length ? leftActions.length * rectButtonWidth : rectButtonWidth\n    return (\n      <>\n        <View\n          style={[\n            styles.absoluteFill,\n            {\n              backgroundColor: leftActions?.[0]?.backgroundColor ?? \"#fff\",\n            },\n          ]}\n        />\n        <Animated.View\n          style={[\n            styles.actionsWrapper,\n            {\n              width,\n            },\n          ]}\n        >\n          {leftActions?.map((action, index) => (\n            <LeftActionItem\n              key={getActionKey(\"left\", action)}\n              index={index}\n              action={action}\n              progress={progress}\n              length={leftActions?.length ?? 1}\n            />\n          ))}\n        </Animated.View>\n      </>\n    )\n  }\n  const renderRightActions = (progress: SharedValue<number>, translation: SharedValue<number>) => {\n    const width = rightActions?.length ? rightActions.length * rectButtonWidth : rectButtonWidth\n    return (\n      <>\n        <View\n          style={[\n            styles.absoluteFill,\n            {\n              backgroundColor: rightActions?.at(-1)?.backgroundColor ?? \"#fff\",\n            },\n          ]}\n        />\n        <Animated.View\n          style={[\n            styles.actionsWrapper,\n            {\n              width,\n            },\n          ]}\n        >\n          {rightActions?.map((action, index) => {\n            return (\n              <RightRectButton\n                endDragCallerRef={endDragCallerRef}\n                key={getActionKey(\"right\", action)}\n                index={index}\n                action={action}\n                length={rightActions?.length ?? 1}\n                progress={progress}\n                translation={translation}\n                swipeRightToCallAction={\n                  swipeRightToCallAction && index === rightActions?.length - 1\n                }\n              />\n            )\n          })}\n        </Animated.View>\n      </>\n    )\n  }\n  const id = React.useId()\n  const { swipeableOpenedId } = React.use(SwipeableGroupContext)\n  const setAtom = useSetAtom(swipeableOpenedId)\n  const isOpened = useAtomValue(\n    React.useMemo(\n      () => selectAtom(swipeableOpenedId, (value) => value === id),\n      [id, swipeableOpenedId],\n    ),\n  )\n  React.useEffect(() => {\n    if (!isOpened) {\n      itemRef.current?.close()\n    }\n  }, [isOpened])\n  return (\n    <ReanimatedSwipeable\n      ref={itemRef}\n      enabled={!disabled}\n      friction={1}\n      onSwipeableWillOpen={() => {\n        setAtom(id)\n        if (swipeRightToCallAction && endDragCallerRef.current) {\n          endDragCallerRef.current()\n          endDragCallerRef.current = () => {}\n        }\n      }}\n      leftThreshold={37}\n      rightThreshold={37}\n      enableTrackpadTwoFingerGesture\n      renderLeftActions={leftActions?.length ? renderLeftActions : undefined}\n      renderRightActions={rightActions?.length ? renderRightActions : undefined}\n      overshootLeft={leftActions?.length ? leftActions?.length >= 1 : undefined}\n      overshootRight={rightActions?.length ? rightActions?.length >= 1 : undefined}\n      overshootFriction={swipeRightToCallAction ? 1 : 10}\n    >\n      {children}\n    </ReanimatedSwipeable>\n  )\n}\nconst SwipeableGroupContext = React.createContext({\n  swipeableOpenedId: atom(\"\"),\n})\nexport const SwipeableGroupProvider = ({ children }: { children: React.ReactNode }) => {\n  const ctx = React.useMemo(\n    () => ({\n      swipeableOpenedId: atom(\"\"),\n    }),\n    [],\n  )\n  return <SwipeableGroupContext value={ctx}>{children}</SwipeableGroupContext>\n}\n\nconst LeftActionItem = React.memo(\n  ({\n    index,\n    action,\n    progress,\n    length = 1,\n  }: {\n    index: number\n    action: Action\n    progress: SharedValue<number>\n    length: number\n  }) => {\n    const animStyle = useAnimatedStyle(() => ({\n      transform: [\n        {\n          translateX: interpolate(progress.value, [0, 1], [-rectButtonWidth * length, 0]),\n        },\n      ],\n    }))\n    return (\n      <Animated.View\n        style={[\n          styles.animatedContainer,\n          {\n            width: rectButtonWidth,\n            left: index * rectButtonWidth,\n          },\n          animStyle,\n        ]}\n      >\n        <RectButton\n          style={[\n            styles.actionContainer,\n            {\n              backgroundColor: action.backgroundColor ?? \"#fff\",\n            },\n          ]}\n          onPress={action.onPress}\n        >\n          {action.icon}\n          <Text\n            style={[\n              styles.actionText,\n              {\n                color: action.color ?? \"#fff\",\n              },\n            ]}\n          >\n            {action.label}\n          </Text>\n        </RectButton>\n      </Animated.View>\n    )\n  },\n)\n\nconst rightActionThreshold = -100\nconst RightRectButton = React.memo(\n  ({\n    index,\n    action,\n    length = 1,\n    progress,\n    translation,\n    swipeRightToCallAction,\n    endDragCallerRef,\n  }: {\n    progress: SharedValue<number>\n    translation: SharedValue<number>\n    index: number\n    action: Action\n    length: number\n    swipeRightToCallAction?: boolean\n    endDragCallerRef: React.MutableRefObject<() => void>\n  }) => {\n    const hapticOnce = React.useRef(false)\n\n    const setEndDragCaller = React.useCallback(\n      (shouldCall: boolean) => {\n        if (shouldCall) {\n          if (!hapticOnce.current) {\n            hapticOnce.current = true\n            impactAsync(ImpactFeedbackStyle.Light)\n            endDragCallerRef.current = () => {\n              action.onPress?.()\n            }\n          }\n        } else {\n          hapticOnce.current = false\n          endDragCallerRef.current = () => {}\n        }\n      },\n      [action, endDragCallerRef],\n    )\n\n    useAnimatedReaction(\n      () => translation.value,\n      (value) => {\n        if (!swipeRightToCallAction) return\n        runOnJS(setEndDragCaller)(value <= rightActionThreshold)\n      },\n      [swipeRightToCallAction, setEndDragCaller],\n    )\n\n    const animStyle = useAnimatedStyle(() => ({\n      transform: [\n        {\n          translateX: interpolate(progress.value, [0, 1, 1.2], [rectButtonWidth * length, 0, -40]),\n        },\n      ],\n    }))\n\n    const textAnimStyle = useAnimatedStyle(() => ({\n      transform: [\n        {\n          translateX: interpolate(progress.value, [0, 1, 1.2], [0, 0, 10]),\n        },\n      ],\n    }))\n\n    return (\n      <Animated.View\n        style={[\n          styles.animatedContainer,\n          {\n            width: rectButtonWidth,\n            left: index * rectButtonWidth,\n          },\n          animStyle,\n        ]}\n      >\n        <RectButton\n          style={[\n            styles.actionContainer,\n            {\n              backgroundColor: action.backgroundColor ?? \"#fff\",\n            },\n          ]}\n          onPress={action.onPress}\n        >\n          {action.icon}\n          <Animated.Text\n            style={[\n              styles.actionText,\n              {\n                color: action.color ?? \"#fff\",\n              },\n              textAnimStyle,\n            ]}\n          >\n            {action.label}\n          </Animated.Text>\n        </RectButton>\n      </Animated.View>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/mobile/src/components/common/ThemedBlurView.tsx",
    "content": "import type { BlurViewProps } from \"expo-blur\"\nimport { BlurView } from \"expo-blur\"\nimport { GlassView } from \"expo-glass-effect\"\nimport { useColorScheme } from \"nativewind\"\nimport { Platform, StyleSheet, View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { isIos26 } from \"@/src/lib/platform\"\n\n/**\n * @android In Android, the BlurView is experimental and not fully supported,\n * so we use a normal View with a background color with **100% opacity**.\n * However, if the `experimentalBlurMethod` prop is explicitly provided,\n * we'll use the BlurView even on Android,\n */\nexport const ThemedBlurView = ({\n  ref,\n  tint,\n\n  tintColor,\n  useGlass,\n  ...rest\n}: BlurViewProps & {\n  ref?: React.Ref<BlurView | null>\n  useGlass?: boolean\n  /**\n   * The tint color of the glass view, only works when `useGlass` is true\n   */\n  tintColor?: string\n}) => {\n  const { colorScheme } = useColorScheme()\n\n  const background = useColor(\"systemBackground\")\n\n  const useBlurView = Platform.OS === \"ios\" || \"experimentalBlurMethod\" in rest\n\n  if (isIos26 && useGlass) {\n    return <GlassView style={rest.style} glassEffectStyle=\"regular\" tintColor={tintColor} />\n  }\n  return useBlurView ? (\n    <>\n      <BlurView\n        ref={ref}\n        intensity={100}\n        tint={colorScheme === \"light\" ? \"systemChromeMaterialLight\" : \"systemChromeMaterialDark\"}\n        {...rest}\n      />\n      {tintColor && (\n        <View style={[StyleSheet.absoluteFillObject, { backgroundColor: tintColor }]} />\n      )}\n    </>\n  ) : (\n    <View\n      ref={ref as any}\n      {...rest}\n      style={StyleSheet.flatten([\n        rest.style,\n        {\n          backgroundColor: tintColor ?? background,\n          opacity: 1,\n        },\n      ])}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/errors/GlobalErrorScreen.tsx",
    "content": "import { applicationName } from \"expo-application\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\ninterface GlobalErrorScreenProps {\n  error?: Error\n  resetError?: () => void\n}\nexport const GlobalErrorScreen: React.FC<GlobalErrorScreenProps> = ({ error, resetError }) => {\n  const { t } = useTranslation(\"common\")\n  return (\n    <View className=\"flex-1 bg-system-background\">\n      <View className=\"flex-1 items-center justify-center px-6\">\n        <Text className=\"mb-6 text-[64px]\">😕</Text>\n        <Text className=\"mb-3 text-center text-xl font-semibold text-label\">\n          {t(\"error_screen.crashed\", { appName: applicationName ?? \"App\" })}\n        </Text>\n        <Text className=\"mb-8 text-center text-lg text-secondary-label\">\n          {error?.message || t(\"error_screen.unexpected\")}\n        </Text>\n        {resetError && (\n          <Pressable className=\"min-w-[160px] rounded-xl bg-accent px-6 py-3\" onPress={resetError}>\n            <Text className=\"text-center text-[17px] font-semibold text-white\">{t(\"retry\")}</Text>\n          </Pressable>\n        )}\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/errors/ListErrorView.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { AlertCuteFiIcon } from \"@/src/icons/alert_cute_fi\"\n\nimport { MonoText } from \"../ui/typography/MonoText\"\n\nexport const ListErrorView = ({ error, resetError }: { error: Error; resetError: () => void }) => {\n  const { t } = useTranslation(\"common\")\n  const red = useColor(\"red\")\n  return (\n    <View className=\"flex-1 items-center justify-center p-4\">\n      <View className=\"w-full max-w-[300px] items-center rounded-2xl bg-secondary-system-grouped-background p-6\">\n        <View className=\"mb-4 items-center justify-center rounded-3xl bg-quaternary-system-fill p-3\">\n          <AlertCuteFiIcon color={red} height={48} width={48} />\n        </View>\n        <Text className=\"mb-2 text-center text-lg font-semibold text-label\">\n          {t(\"error_screen.list_unable_to_load\")}\n        </Text>\n        <Text className=\"mb-2 text-center text-base text-secondary-label\">\n          {t(\"error_screen.list_try_later\")}\n        </Text>\n        <MonoText className=\"mb-4 text-center text-base text-secondary-label\">\n          {error.message || t(\"error_screen.unknown\")}\n        </MonoText>\n\n        <Pressable className=\"w-full rounded-xl bg-accent px-6 py-3\" onPress={resetError}>\n          <Text className=\"text-center text-base font-semibold text-white\">{t(\"retry\")}</Text>\n        </Pressable>\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/errors/ScreenErrorScreen.tsx",
    "content": "import * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useCanBack, useNavigation } from \"@/src/lib/navigation/hooks\"\n\ninterface ScreenErrorScreenProps {\n  error?: Error\n  resetError?: () => void\n}\nexport const ScreenErrorScreen: React.FC<ScreenErrorScreenProps> = ({ error }) => {\n  const { t } = useTranslation(\"common\")\n  const navigation = useNavigation()\n  const canGoBack = useCanBack()\n  return (\n    <View className=\"flex-1 bg-system-background\">\n      <View className=\"flex-1 items-center justify-center px-6\">\n        <Text className=\"mb-6 text-[64px]\">😕</Text>\n        <Text className=\"mb-3 text-center text-xl font-semibold text-label\">\n          {t(\"error_screen.page_failed\")}\n        </Text>\n        <Text className=\"mb-8 text-center text-lg text-secondary-label\">\n          {error?.message || t(\"error_screen.unexpected\")}\n        </Text>\n\n        <View className=\"flex-row gap-4\">\n          {canGoBack && (\n            <Pressable\n              className=\"min-w-[160px] rounded-xl bg-accent px-6 py-3\"\n              onPress={() => navigation.back()}\n            >\n              <Text className=\"text-center text-[17px] font-semibold text-white\">\n                {t(\"words.back\")}\n              </Text>\n            </Pressable>\n          )}\n        </View>\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/icons/OouiUserAnonymous.tsx",
    "content": "import * as React from \"react\"\nimport type { SvgProps } from \"react-native-svg\"\nimport Svg, { Path } from \"react-native-svg\"\n\nexport function OouiUserAnonymous(props: SvgProps & { size?: number; color?: string }) {\n  const { size = 24, color = \"currentColor\", ...rest } = props\n  return (\n    <Svg width={size} height={size} viewBox=\"0 0 24 24\" {...rest}>\n      <Path\n        fill={color}\n        d=\"M6 2.4 4.8 9.6 0 12s2.4 1.2 12 1.2S24 12 24 12l-4.8-2.4L18 2.4Zm1.2 12A3.6 3.6 0 0 0 3.6 18a3.6 3.6 0 0 0 3.6 3.6 3.6 3.6 0 0 0 3.6-3.6h2.4a3.6 3.6 0 0 0 3.6 3.6 3.6 3.6 0 0 0 3.6-3.6 3.6 3.6 0 0 0-3.6-3.6 3.6 3.6 0 0 0-3.384 2.4h-2.822A3.6 3.6 0 0 0 7.2 14.4\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/icons/PhUsersBold.tsx",
    "content": "import * as React from \"react\"\nimport type { SvgProps } from \"react-native-svg\"\nimport Svg, { Path } from \"react-native-svg\"\n\nexport function PhUsersBold(props: SvgProps & { size?: number; color?: string }) {\n  const { size = 24, color = \"currentColor\", ...rest } = props\n  return (\n    <Svg width={size} height={size} viewBox=\"0 0 24 24\" {...rest}>\n      <Path\n        fill={color}\n        d=\"M11.734 14.924a6.048 6.048 0 1 0-7.782 0A9.5 9.5 0 0 0 .22 17.948a1.134 1.134 0 0 0 1.828 1.342 7.182 7.182 0 0 1 11.59 0 1.134 1.134 0 0 0 1.83-1.342 9.5 9.5 0 0 0-3.734-3.024M4.063 10.3a3.78 3.78 0 1 1 3.78 3.78 3.78 3.78 0 0 1-3.78-3.78m19.476 9.23a1.134 1.134 0 0 1-1.585-.243 7.21 7.21 0 0 0-5.795-2.939 1.134 1.134 0 0 1 0-2.268 3.78 3.78 0 1 0-.973-7.434 1.134 1.134 0 1 1-.583-2.191 6.048 6.048 0 0 1 5.447 10.47 9.5 9.5 0 0 1 3.732 3.024 1.134 1.134 0 0 1-.243 1.581\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/contexts/ModalScrollViewContext.ts",
    "content": "import { createContext, use } from \"react\"\nimport type { ScrollView } from \"react-native\"\nimport type { AnimatedRef, SharedValue } from \"react-native-reanimated\"\n\ninterface ModalScrollViewContextType {\n  scrollViewRef: AnimatedRef<ScrollView>\n  animatedY: SharedValue<number>\n}\nexport const ModalScrollViewContext = createContext<ModalScrollViewContextType>(null!)\n\nexport const useModalScrollViewContext = () => {\n  const context = use(ModalScrollViewContext)\n  if (!context) {\n    throw new Error(\"ModalScrollViewContext not found\")\n  }\n  return context\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/header/FakeNativeHeaderTitle.tsx",
    "content": "import type { StyleProp, TextProps, TextStyle } from \"react-native\"\nimport { Animated, StyleSheet } from \"react-native\"\n\ntype Props = Omit<TextProps, \"style\"> & {\n  tintColor?: string\n  children?: string\n  style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>\n}\n\nexport function FakeNativeHeaderTitle({ style, ...rest }: Props) {\n  return (\n    <Animated.Text\n      accessibilityRole=\"header\"\n      aria-level=\"1\"\n      numberOfLines={1}\n      className={\"font-semibold text-label\"}\n      {...rest}\n      style={[styles.title, style]}\n    />\n  )\n}\n\nconst styles = StyleSheet.create({\n  title: {\n    fontSize: 17,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/header/HeaderElements.tsx",
    "content": "import { cn, withOpacity } from \"@follow/utils\"\nimport { useTranslation } from \"react-i18next\"\nimport { Text, View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { CheckLineIcon } from \"@/src/icons/check_line\"\nimport { useCanBack, useCanDismiss } from \"@/src/lib/navigation/hooks\"\nimport { StackScreenHeaderPortal } from \"@/src/lib/navigation/StackScreenHeaderPortal\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { PlatformActivityIndicator } from \"../../ui/loading/PlatformActivityIndicator\"\nimport { DefaultHeaderBackButton, UINavigationHeaderActionButton } from \"./NavigationHeader\"\n\nexport interface ModalHeaderSubmitButtonProps {\n  isValid: boolean\n  onPress: () => void\n  isLoading?: boolean\n  testID?: string\n}\nexport const HeaderSubmitButton = ({\n  isValid,\n  onPress,\n  isLoading,\n  testID,\n}: ModalHeaderSubmitButtonProps) => {\n  const label = useColor(\"label\")\n  return (\n    <UINavigationHeaderActionButton\n      onPress={onPress}\n      disabled={!isValid || isLoading}\n      testID={testID}\n    >\n      {isLoading ? (\n        <PlatformActivityIndicator size=\"small\" color={withOpacity(label, 0.5)} />\n      ) : (\n        <CheckLineIcon height={20} width={20} color={isValid ? label : withOpacity(label, 0.5)} />\n      )}\n    </UINavigationHeaderActionButton>\n  )\n}\nexport const HeaderSubmitTextButton = ({\n  isValid,\n  onPress,\n  isLoading,\n  label,\n  testID,\n}: ModalHeaderSubmitButtonProps & {\n  label?: string\n}) => {\n  const { t } = useTranslation(\"common\")\n  const labelColor = useColor(\"label\")\n  return (\n    <UINavigationHeaderActionButton\n      onPress={onPress}\n      disabled={!isValid || isLoading}\n      testID={testID}\n    >\n      {isLoading && (\n        <View className=\"absolute inset-y-0 right-2 items-center justify-center\">\n          <PlatformActivityIndicator size=\"small\" color={withOpacity(labelColor, 0.5)} />\n        </View>\n      )}\n      <Text\n        allowFontScaling={false}\n        className={cn(\n          \"text-[16px] font-bold text-accent\",\n          !isValid && \"text-secondary-label\",\n          isLoading && \"opacity-0\",\n        )}\n      >\n        {label ?? t(\"words.submit\")}\n      </Text>\n    </UINavigationHeaderActionButton>\n  )\n}\nexport const HeaderCloseOnly = () => {\n  const insets = useSafeAreaInsets()\n  const canDismiss = useCanDismiss()\n  const canBack = useCanBack()\n  return (\n    <StackScreenHeaderPortal>\n      <UINavigationHeaderActionButton\n        testID=\"auth-back\"\n        className=\"absolute\"\n        style={{\n          top: insets.top,\n          left: insets.left,\n        }}\n      >\n        <DefaultHeaderBackButton canGoBack={canBack} canDismiss={canDismiss} />\n      </UINavigationHeaderActionButton>\n    </StackScreenHeaderPortal>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/header/NavigationHeader.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport type { FC, PropsWithChildren, ReactNode } from \"react\"\nimport {\n  createElement,\n  use,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport type { LayoutChangeEvent, StyleProp, ViewStyle } from \"react-native\"\nimport { Alert, Pressable, StyleSheet, View } from \"react-native\"\nimport type { AnimatedProps } from \"react-native-reanimated\"\nimport Animated, {\n  useAnimatedReaction,\n  useAnimatedRef,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\"\nimport type { DefaultStyle } from \"react-native-reanimated/lib/typescript/hook/commonTypes\"\nimport { useSafeAreaFrame, useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport type { ViewProps } from \"react-native-svg/lib/typescript/fabric/utils\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { CloseCuteReIcon } from \"@/src/icons/close_cute_re\"\nimport { MingcuteLeftLineIcon } from \"@/src/icons/mingcute_left_line\"\nimport {\n  useCanBack,\n  useCanDismiss,\n  useIsTopRouteInGroup,\n  useNavigation,\n  useScreenIsInSheetModal,\n} from \"@/src/lib/navigation/hooks\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\n\nimport { ThemedBlurView } from \"../../common/ThemedBlurView\"\nimport { PlatformActivityIndicator } from \"../../ui/loading/PlatformActivityIndicator\"\nimport { getNavigationHeaderLayout } from \"../utils\"\nimport { SetNavigationHeaderHeightContext } from \"../views/NavigationHeaderContext\"\nimport { FakeNativeHeaderTitle } from \"./FakeNativeHeaderTitle\"\n\ninterface NavigationHeaderButtonProps {\n  canGoBack: boolean\n  canDismiss: boolean\n  modal?: boolean\n  promptBeforeLeave?: boolean\n}\nexport interface NavigationHeaderRawProps {\n  headerLeft?: FC<NavigationHeaderButtonProps>\n  headerRight?: FC<NavigationHeaderButtonProps>\n  headerTitle?: FC<React.ComponentProps<typeof FakeNativeHeaderTitle>> | ReactNode\n  headerTitleAbsolute?: boolean\n\n  title?: string\n\n  modal?: boolean\n  hideableBottom?: ReactNode\n  hideableBottomHeight?: number\n}\n\nconst HideableThreshold = 20\nconst useHideableBottom = (\n  enable: boolean,\n  originalDefaultHeaderHeight: number,\n  hideableBottomHeight?: number,\n) => {\n  const lastScrollY = useRef(0)\n\n  const largeDefaultHeaderHeightRef = useRef(\n    originalDefaultHeaderHeight + (hideableBottomHeight || 0),\n  )\n  const largeHeaderHeight = useSharedValue(largeDefaultHeaderHeightRef.current)\n  const [hideableBottomRef, setHideableBottomRef] = useState<View | undefined>()\n\n  useEffect(() => {\n    hideableBottomRef?.measure((x, y, width, height) => {\n      const largeHeight = height + originalDefaultHeaderHeight\n      largeDefaultHeaderHeightRef.current = largeHeight\n      largeHeaderHeight.value = largeHeight\n    })\n  }, [hideableBottomRef, largeHeaderHeight, originalDefaultHeaderHeight])\n\n  const { reAnimatedScrollY } = use(ScreenItemContext)!\n  useAnimatedReaction(\n    () => reAnimatedScrollY.value,\n    (value) => {\n      if (!enable) {\n        return\n      }\n\n      const largeDefaultHeaderHeight = largeDefaultHeaderHeightRef.current\n\n      if (value <= 100) {\n        largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight)\n      } else if (value > lastScrollY.current + HideableThreshold) {\n        largeHeaderHeight.value = withTiming(originalDefaultHeaderHeight)\n      } else if (value < lastScrollY.current - HideableThreshold) {\n        largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight)\n      }\n      lastScrollY.current = value\n    },\n  )\n\n  useEffect(() => {\n    EventBus.subscribe(\"SELECT_TIMELINE\", () => {\n      largeHeaderHeight.value = withTiming(largeDefaultHeaderHeightRef.current)\n    })\n  }, [largeHeaderHeight])\n\n  const layoutHeightOnceRef = useRef(false)\n  const onLayout = useCallback(\n    (e: LayoutChangeEvent) => {\n      if (typeof hideableBottomHeight === \"number\") {\n        return\n      }\n      const { height } = e.nativeEvent.layout\n\n      if (!height) return\n      if (layoutHeightOnceRef.current) {\n        return\n      }\n\n      layoutHeightOnceRef.current = true\n\n      largeDefaultHeaderHeightRef.current = height + originalDefaultHeaderHeight\n      largeHeaderHeight.value = largeDefaultHeaderHeightRef.current\n    },\n    [hideableBottomHeight, largeHeaderHeight, originalDefaultHeaderHeight],\n  )\n  return {\n    hideableBottomRef,\n    setHideableBottomRef,\n    largeHeaderHeight,\n    largeDefaultHeaderHeightRef,\n    onLayout,\n  }\n}\nexport interface InternalNavigationHeaderProps\n  extends Omit<AnimatedProps<ViewProps>, \"children\">, PropsWithChildren {\n  headerLeft?:\n    | FC<{\n        canGoBack: boolean\n      }>\n    | ReactNode\n  promptBeforeLeave?: boolean\n  headerRight?:\n    | FC<{\n        canGoBack: boolean\n      }>\n    | ReactNode\n  title?: string\n\n  hideableBottom?: ReactNode\n  hideableBottomHeight?: number\n  headerTitleAbsolute?: boolean\n  headerTitle?: FC<React.ComponentProps<typeof FakeNativeHeaderTitle>> | ReactNode\n  isLoading?: boolean\n}\n\nconst blurThreshold = 0\nconst titlebarPaddingHorizontal = 8\nconst titleMarginHorizontal = 16\nexport const InternalNavigationHeader = ({\n  style,\n  children,\n  headerLeft,\n  headerRight,\n  title,\n  headerTitle: customHeaderTitle,\n\n  hideableBottom,\n  hideableBottomHeight,\n  headerTitleAbsolute,\n\n  promptBeforeLeave,\n  isLoading,\n  ...rest\n}: InternalNavigationHeaderProps) => {\n  const insets = useSafeAreaInsets()\n  const frame = useSafeAreaFrame()\n\n  const sheetModal = useScreenIsInSheetModal()\n  const { headerHeight: defaultHeight, headerTopInset } = useMemo(\n    () =>\n      getNavigationHeaderLayout({\n        landscape: frame.width > frame.height,\n        sheetModal,\n        topInset: insets.top,\n      }),\n    [frame, insets.top, sheetModal],\n  )\n\n  const border = useColor(\"opaqueSeparator\")\n  const opacityAnimated = useSharedValue(0)\n  const { reAnimatedScrollY } = use(ScreenItemContext)!\n\n  const setHeaderHeight = use(SetNavigationHeaderHeightContext)\n\n  useAnimatedReaction(\n    () => reAnimatedScrollY.value,\n    (value) => {\n      opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10))\n    },\n  )\n\n  const canBack = useCanBack()\n  const canDismiss = useCanDismiss()\n\n  useEffect(() => {\n    const { value } = reAnimatedScrollY\n    opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10))\n  }, [opacityAnimated, reAnimatedScrollY])\n\n  const blurStyle = useAnimatedStyle(() => ({\n    opacity: opacityAnimated.value,\n    ...StyleSheet.absoluteFillObject,\n    borderBottomWidth: StyleSheet.hairlineWidth,\n    borderBottomColor: border,\n  }))\n\n  const { setHideableBottomRef, largeHeaderHeight, onLayout } = useHideableBottom(\n    !!hideableBottom,\n    defaultHeight,\n    hideableBottomHeight,\n  )\n\n  const rootTitleBarStyle = useAnimatedStyle(() => {\n    const styles = {\n      paddingTop: headerTopInset,\n      position: \"relative\",\n      overflow: \"hidden\",\n    } satisfies DefaultStyle\n    if (hideableBottom) {\n      ;(styles as DefaultStyle).height = largeHeaderHeight.value\n    }\n\n    return styles\n  })\n\n  const HeaderLeft = headerLeft ?? DefaultHeaderBackButton\n\n  const renderTitle = customHeaderTitle ?? FakeNativeHeaderTitle\n  const headerTitle =\n    typeof renderTitle !== \"function\"\n      ? renderTitle\n      : createElement(renderTitle, {\n          children: title,\n        })\n  const RightButton = headerRight ?? (Noop as FC<NavigationHeaderButtonProps>)\n\n  const animatedRef = useAnimatedRef<Animated.View>()\n  useLayoutEffect(() => {\n    animatedRef.current?.measure((x, y, width, height) => {\n      setHeaderHeight?.(height)\n    })\n  }, [animatedRef, setHeaderHeight])\n\n  const leftRef = useRef<View>(null)\n  const [leftWidth, setLeftWidth] = useState(0)\n  useLayoutEffect(() => {\n    leftRef.current?.measure((x, y, width) => {\n      setLeftWidth(width)\n    })\n  }, [leftRef])\n\n  const rightRef = useRef<View>(null)\n  const [rightWidth, setRightWidth] = useState(0)\n  useLayoutEffect(() => {\n    rightRef.current?.measure((x, y, width) => {\n      setRightWidth(width)\n    })\n  }, [rightRef])\n\n  return (\n    <Animated.View\n      ref={animatedRef}\n      pointerEvents={\"box-none\"}\n      {...rest}\n      style={[rootTitleBarStyle, style]}\n      onLayout={useCallback(\n        (e: LayoutChangeEvent) => {\n          setHeaderHeight?.(e.nativeEvent.layout.height)\n        },\n        [setHeaderHeight],\n      )}\n    >\n      <Animated.View style={blurStyle} pointerEvents={\"none\"}>\n        <ThemedBlurView className=\"flex-1\" />\n      </Animated.View>\n\n      {/* Grid */}\n\n      <View\n        className=\"relative flex-row items-center\"\n        style={{\n          marginLeft: insets.left,\n          marginRight: insets.right,\n          height: defaultHeight - headerTopInset,\n          paddingHorizontal: titlebarPaddingHorizontal,\n        }}\n        pointerEvents={\"box-none\"}\n      >\n        {/* Left */}\n        <View\n          ref={leftRef}\n          className=\"flex min-w-6 shrink-0 flex-row items-center justify-start\"\n          pointerEvents={\"box-none\"}\n        >\n          {typeof HeaderLeft === \"function\" ? (\n            <HeaderLeft\n              canGoBack={canBack}\n              canDismiss={canDismiss}\n              modal={sheetModal}\n              promptBeforeLeave={promptBeforeLeave}\n            />\n          ) : (\n            HeaderLeft\n          )}\n        </View>\n        {/* Center */}\n        <Animated.View\n          className=\"flex min-w-0 flex-1 shrink flex-row items-center justify-center truncate\"\n          pointerEvents={\"box-none\"}\n          style={{\n            marginHorizontal: titleMarginHorizontal,\n          }}\n        >\n          {headerTitleAbsolute ? (\n            <View />\n          ) : (\n            <View className=\"flex-row\">\n              <View className=\"shrink\" style={{ width: rightWidth }} />\n              {headerTitle}\n\n              {/* Only show loading indicator when headerTitle is not absolute */}\n              <View>\n                {!headerTitleAbsolute && isLoading && (\n                  <View\n                    className=\"absolute right-0 z-10\"\n                    style={{ transform: [{ translateX: \"100%\" }, { scale: 0.74 }] }}\n                  >\n                    <PlatformActivityIndicator size=\"small\" />\n                  </View>\n                )}\n              </View>\n              <View className=\"shrink\" style={{ width: leftWidth }} />\n            </View>\n          )}\n        </Animated.View>\n\n        {/* Right */}\n        <View\n          ref={rightRef}\n          className=\"flex min-w-6 shrink-0 flex-row items-center justify-end\"\n          pointerEvents={\"box-none\"}\n        >\n          {typeof RightButton === \"function\" ? (\n            <RightButton canGoBack={canBack} canDismiss={canDismiss} modal={sheetModal} />\n          ) : (\n            RightButton\n          )}\n        </View>\n        <View\n          className=\"absolute inset-0 flex-row items-center justify-center\"\n          pointerEvents={\"box-none\"}\n        >\n          {headerTitleAbsolute && headerTitle}\n        </View>\n      </View>\n\n      {!!hideableBottom && (\n        <View ref={setHideableBottomRef as any} onLayout={onLayout}>\n          {hideableBottom}\n        </View>\n      )}\n    </Animated.View>\n  )\n}\n\nexport const DefaultHeaderBackButton = ({\n  canGoBack,\n  canDismiss,\n  promptBeforeLeave,\n}: NavigationHeaderButtonProps) => {\n  const label = useColor(\"label\")\n  const navigation = useNavigation()\n\n  const isTopRouteInGroup = useIsTopRouteInGroup()\n\n  const showCloseIcon = canDismiss && isTopRouteInGroup\n\n  if (!canGoBack && !canDismiss) return null\n  return (\n    <UINavigationHeaderActionButton\n      testID=\"navigation-back\"\n      onPress={() => {\n        const leave = () => {\n          if (canGoBack) {\n            navigation.back()\n          } else if (canDismiss) {\n            navigation.dismiss()\n          }\n        }\n\n        if (promptBeforeLeave) {\n          Alert.alert(\"Are you sure you want to exit?\", \"You have unsaved changes.\", [\n            {\n              text: \"Cancel\",\n              style: \"cancel\",\n            },\n            {\n              text: \"Exit\",\n              onPress: () => {\n                leave()\n              },\n            },\n          ])\n        } else {\n          leave()\n        }\n      }}\n    >\n      {!showCloseIcon ? (\n        <MingcuteLeftLineIcon height={20} width={20} color={label} />\n      ) : (\n        <CloseCuteReIcon height={20} width={20} color={label} />\n      )}\n    </UINavigationHeaderActionButton>\n  )\n}\n\nexport const UINavigationHeaderActionButton = ({\n  children,\n  onPress,\n  disabled,\n  className,\n  style,\n  testID,\n}: {\n  children: ReactNode\n  onPress?: () => void\n  disabled?: boolean\n  className?: string\n  style?: StyleProp<ViewStyle>\n  testID?: string\n}) => {\n  return (\n    <Pressable\n      hitSlop={5}\n      testID={testID}\n      className={cn(\"p-2\", className)}\n      onPress={onPress}\n      disabled={disabled}\n      style={style}\n    >\n      {children}\n    </Pressable>\n  )\n}\nconst Noop = () => null\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/header/hooks.ts",
    "content": "import { use } from \"react\"\n\nimport { NavigationHeaderHeightContext } from \"../views/NavigationHeaderContext\"\n\nexport const useNavigationHeaderHeight = () => {\n  const headerHeight = use(NavigationHeaderHeightContext)\n  if (!headerHeight) {\n    throw new Error(\"NavigationHeaderHeightContext is not found\")\n  }\n  return headerHeight\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/BottomTabHeightProvider.tsx",
    "content": "import { useState } from \"react\"\n\nimport { BottomTabBarHeightContext } from \"@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext\"\n\nimport { SetBottomTabBarHeightContext } from \"./contexts/BottomTabBarHeightContext\"\n\nexport const BottomTabHeightProvider = ({ children }: { children: React.ReactNode }) => {\n  const [height, setHeight] = useState(0)\n\n  return (\n    <BottomTabBarHeightContext value={height}>\n      <SetBottomTabBarHeightContext value={setHeight}>{children}</SetBottomTabBarHeightContext>\n    </BottomTabBarHeightContext>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/BottomTabProvider.tsx",
    "content": "import { atom } from \"jotai\"\nimport { useMemo, useState } from \"react\"\nimport { useSharedValue } from \"react-native-reanimated\"\n\nimport type { BottomTabContextType } from \"@/src/lib/navigation/bottom-tab/BottomTabContext\"\nimport { BottomTabContext } from \"@/src/lib/navigation/bottom-tab/BottomTabContext\"\nimport type { ResolvedTabScreenProps } from \"@/src/lib/navigation/bottom-tab/types\"\n\nimport { BottomTabHeightProvider } from \"./BottomTabHeightProvider\"\nimport { BottomTabBarBackgroundContext } from \"./contexts/BottomTabBarBackgroundContext\"\nimport {\n  BottomTabBarVisibleContext,\n  SetBottomTabBarVisibleContext,\n} from \"./contexts/BottomTabBarVisibleContext\"\n\nexport const BottomTabProvider = ({\n  children,\n  initialTabIndex = 0,\n}: {\n  children: React.ReactNode\n  initialTabIndex?: number\n}) => {\n  const opacity = useSharedValue(1)\n  const [tabBarVisible, setTabBarVisible] = useState(true)\n\n  const [tabIndexAtom] = useState(() => atom(initialTabIndex))\n\n  const ctxValue = useMemo<BottomTabContextType>(\n    () => ({\n      currentIndexAtom: tabIndexAtom,\n      loadedableIndexAtom: atom(new Set<number>()),\n      tabScreensAtom: atom<ResolvedTabScreenProps[]>([]),\n      tabHeightAtom: atom(0),\n    }),\n    [tabIndexAtom],\n  )\n\n  return (\n    <BottomTabContext value={ctxValue}>\n      <BottomTabBarBackgroundContext value={useMemo(() => ({ opacity }), [opacity])}>\n        <SetBottomTabBarVisibleContext value={setTabBarVisible}>\n          <BottomTabBarVisibleContext value={tabBarVisible}>\n            <BottomTabHeightProvider>{children}</BottomTabHeightProvider>\n          </BottomTabBarVisibleContext>\n        </SetBottomTabBarVisibleContext>\n      </BottomTabBarBackgroundContext>\n    </BottomTabContext>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/BottomTabs.tsx",
    "content": "import { use, useRef } from \"react\"\n\nimport { BottomTabBarBackgroundContext } from \"./contexts/BottomTabBarBackgroundContext\"\nimport { useNavigationScrollToTop } from \"./hooks\"\nimport { Tabbar } from \"./Tabbar\"\n\nexport const BottomTabs = () => {\n  const currentIndex = useRef<number | undefined>(undefined)\n  const scrollToTop = useNavigationScrollToTop()\n  const { opacity } = use(BottomTabBarBackgroundContext)\n  return (\n    <Tabbar\n      onPress={(index) => {\n        opacity.value = 1\n\n        if (currentIndex.current === index) {\n          scrollToTop()\n          return\n        }\n\n        currentIndex.current = index\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/ReactNativeTab.ios.tsx",
    "content": "import { Portal } from \"@gorhom/portal\"\nimport { use, useLayoutEffect } from \"react\"\nimport { Platform, View } from \"react-native\"\nimport DeviceInfo from \"react-native-device-info\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { BottomTabContext } from \"@/src/lib/navigation/bottom-tab/BottomTabContext\"\nimport { TabBarPortal } from \"@/src/lib/navigation/bottom-tab/TabBarPortal\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { NavigationInstanceContext } from \"@/src/lib/navigation/NavigationInstanceContext\"\nimport { isIos26 } from \"@/src/lib/platform\"\nimport { GlassPlayerTabBar } from \"@/src/modules/player/GlassPlayerTabBar\"\n\nimport { BottomTabs } from \"./BottomTabs\"\nimport { SetBottomTabBarHeightContext } from \"./contexts/BottomTabBarHeightContext\"\n\nconst isIpad = Platform.OS === \"ios\" && DeviceInfo.isTablet()\n\nexport const ReactNativeTab = () => {\n  if (isIos26 && !isIpad) {\n    return <NativeTabBarHolder />\n  }\n  return (\n    <TabBarPortal>\n      <BottomTabs />\n    </TabBarPortal>\n  )\n}\n\nconst NativeTabBarHolder = () => {\n  const setHeight = use(SetBottomTabBarHeightContext)\n  const insets = useSafeAreaInsets()\n\n  const tabBarHeight = 68 + insets.bottom\n  useLayoutEffect(() => {\n    //https://developer.apple.com/design/human-interface-guidelines/tab-bars\n    setHeight(tabBarHeight)\n  }, [insets.bottom, setHeight, tabBarHeight])\n\n  const bottomTabContext = use(BottomTabContext)\n  const navigation = useNavigation()\n  return (\n    <Portal>\n      <BottomTabContext value={bottomTabContext}>\n        <NavigationInstanceContext value={navigation}>\n          <View className=\"absolute inset-x-0 bottom-[68px] mb-4 bg-red\">\n            <GlassPlayerTabBar className=\"absolute inset-x-0 bottom-0\" />\n          </View>\n        </NavigationInstanceContext>\n      </BottomTabContext>\n    </Portal>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/ReactNativeTab.tsx",
    "content": "import { TabBarPortal } from \"@/src/lib/navigation/bottom-tab/TabBarPortal\"\n\nimport { BottomTabs } from \"./BottomTabs\"\n\nexport const ReactNativeTab = () => {\n  return (\n    <TabBarPortal>\n      <BottomTabs />\n    </TabBarPortal>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/Tabbar.tsx",
    "content": "import { useAtom, useAtomValue } from \"jotai\"\nimport type { FC } from \"react\"\nimport { memo, use, useCallback, useEffect, useMemo } from \"react\"\nimport type { StyleProp, TextStyle } from \"react-native\"\nimport { Platform, Pressable, StyleSheet, Text, View } from \"react-native\"\nimport Animated, {\n  cancelAnimation,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { SetBottomTabBarHeightContext } from \"@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext\"\nimport { gentleSpringPreset, quickSpringPreset, softSpringPreset } from \"@/src/constants/spring\"\nimport { BottomTabContext } from \"@/src/lib/navigation/bottom-tab/BottomTabContext\"\nimport type { ResolvedTabScreenProps, TabbarIconProps } from \"@/src/lib/navigation/bottom-tab/types\"\nimport { isAndroid } from \"@/src/lib/platform\"\nimport { PlayerTabBar } from \"@/src/modules/player/PlayerTabBar\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { ThemedBlurView } from \"../../common/ThemedBlurView\"\nimport { Grid } from \"../../ui/grid\"\nimport { BottomTabBarBackgroundContext } from \"./contexts/BottomTabBarBackgroundContext\"\nimport { BottomTabBarVisibleContext } from \"./contexts/BottomTabBarVisibleContext\"\n\nexport const Tabbar: FC<{\n  onPress?: (index: number) => void\n}> = ({ onPress: onPressProp }) => {\n  const setTabBarHeight = use(SetBottomTabBarHeightContext)\n  const insets = useSafeAreaInsets()\n  const tabBarVisible = use(BottomTabBarVisibleContext)\n  const tabContext = use(BottomTabContext)\n  const tabScreens = useAtomValue(tabContext.tabScreensAtom)\n  const [selectedIndex, setSelectedIndex] = useAtom(tabContext.currentIndexAtom)\n  const translateY = useSharedValue(0)\n  useEffect(() => {\n    cancelAnimation(translateY)\n    translateY.value = withSpring(\n      tabBarVisible ? 0 : 100,\n      !tabBarVisible ? quickSpringPreset : softSpringPreset,\n    )\n  }, [tabBarVisible, translateY])\n  const placeholderTabScreens = useMemo<ResolvedTabScreenProps[]>(() => {\n    return [\n      {\n        tabScreenIndex: 0,\n        title: \"\",\n        icon: () => <View className=\"size-5\" />,\n\n        identifier: \"placeholder\",\n      },\n    ]\n  }, [])\n  const renderTabScreens = tabScreens.length > 0 ? tabScreens : placeholderTabScreens\n  const onPress = useEventCallback((index: number) => {\n    setSelectedIndex(index)\n    onPressProp?.(index)\n  })\n  return (\n    <Animated.View\n      pointerEvents={tabScreens.length > 0 ? \"auto\" : \"none\"}\n      accessibilityRole=\"tablist\"\n      className=\"absolute inset-x-0 bottom-0\"\n      style={{\n        paddingBottom: Math.max(insets.bottom, 8),\n        transform: [\n          {\n            translateY,\n          },\n        ],\n      }}\n      onLayout={(e) => {\n        const tabBarHeight = e.nativeEvent.layout.height + (isAndroid ? insets.bottom : 0)\n        setTabBarHeight(tabBarHeight)\n      }}\n    >\n      <TabBarBackground />\n\n      <PlayerTabBar />\n      <Grid columns={renderTabScreens.length} gap={10} className=\"mt-[7]\">\n        {renderTabScreens.map((route, index) => {\n          const focused = index === selectedIndex\n          const label = route.title ?? \"\"\n          return (\n            <MemoedTabItem\n              key={route.tabScreenIndex}\n              focused={focused}\n              identifier={route.identifier ?? String(route.tabScreenIndex)}\n              index={index}\n              label={label}\n              renderIcon={route.icon}\n              onPress={onPress}\n            />\n          )\n        })}\n      </Grid>\n    </Animated.View>\n  )\n}\nconst MemoedTabItem: FC<{\n  focused: boolean\n  identifier: string\n  index: number\n  label: string\n  renderIcon?: (options: TabbarIconProps) => React.ReactNode\n  onPress: (index: number) => void\n}> = memo(\n  ({ focused, identifier, index, label, renderIcon: renderIconFn, onPress: onPressProp }) => {\n    const inactiveTintColor = \"#999\"\n    const onPress = () => {\n      onPressProp?.(index)\n    }\n    const accessibilityLabel =\n      typeof label === \"string\" && Platform.OS === \"ios\" ? `${label}, tab` : undefined\n    const renderIcon = useCallback(\n      ({ focused }: { focused: boolean }) => {\n        const iconSize = ICON_SIZE_ROUND\n        return (\n          <TabIcon\n            focused={focused}\n            iconSize={iconSize}\n            inactiveTintColor={inactiveTintColor}\n            renderIcon={renderIconFn || noop}\n          />\n        )\n      },\n      [renderIconFn],\n    )\n    const renderLabel = useCallback(\n      ({ focused }: { focused: boolean }) => {\n        return (\n          <TextLabel\n            focused={focused}\n            accessibilityLabel={accessibilityLabel}\n            label={label}\n            inactiveTintColor={inactiveTintColor}\n            style={styles.labelBeneath}\n          />\n        )\n      },\n      [label, accessibilityLabel, inactiveTintColor],\n    )\n    return (\n      <TabItem\n        focused={focused}\n        testID={`tab-${identifier}`}\n        onPress={onPress}\n        originalRenderIcon={renderIcon}\n        originalRenderLabel={renderLabel}\n        accessibilityLabel={accessibilityLabel}\n      />\n    )\n  },\n)\nconst TextLabel = (props: {\n  focused: boolean\n  accessibilityLabel: string | undefined\n  label: string\n  inactiveTintColor: string\n  style: StyleProp<TextStyle>\n}) => {\n  const { focused, accessibilityLabel, label, inactiveTintColor, style } = props\n  return (\n    <Text\n      numberOfLines={1}\n      accessibilityLabel={accessibilityLabel}\n      style={StyleSheet.flatten([\n        style,\n        {\n          color: focused ? accentColor : inactiveTintColor,\n        },\n      ])}\n      allowFontScaling\n    >\n      {label}\n    </Text>\n  )\n}\nconst TabIcon = ({\n  focused,\n  iconSize,\n  inactiveTintColor,\n  renderIcon,\n}: {\n  focused: boolean\n  iconSize: number\n  inactiveTintColor: string\n  renderIcon: (options: TabbarIconProps) => React.ReactNode\n}) => {\n  const activeOpacity = focused ? 1 : 0\n  const inactiveOpacity = focused ? 0 : 1\n  return (\n    <View style={styles.wrapperUikit}>\n      {focused && (\n        <Animated.View\n          style={[\n            styles.icon,\n            {\n              opacity: activeOpacity,\n            },\n          ]}\n        >\n          {renderIcon({\n            focused: true,\n            size: iconSize,\n            color: accentColor,\n          })}\n        </Animated.View>\n      )}\n      {!focused && (\n        <Animated.View\n          style={[\n            styles.icon,\n            {\n              opacity: inactiveOpacity,\n            },\n          ]}\n        >\n          {renderIcon({\n            focused: false,\n            size: iconSize,\n            color: inactiveTintColor,\n          })}\n        </Animated.View>\n      )}\n    </View>\n  )\n}\n\n// @copy node_modules/@react-navigation/bottom-tabs/src/views/TabBarIcon.tsx\nconst ICON_SIZE_WIDE = 31\nconst ICON_SIZE_TALL = 28\nconst ICON_SIZE_ROUND = 25\nconst styles = StyleSheet.create({\n  labelBeneath: {\n    fontSize: 10,\n  },\n  blurEffect: {\n    ...StyleSheet.absoluteFillObject,\n    overflow: \"hidden\",\n    backgroundColor: \"transparent\",\n  },\n  icon: {\n    // We render the icon twice at the same position on top of each other:\n    // active and inactive one, so we can fade between them:\n    // Cover the whole iconContainer:\n    position: \"absolute\",\n    alignSelf: \"center\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    height: \"100%\",\n    width: \"100%\",\n  },\n  wrapperUikit: {\n    width: ICON_SIZE_WIDE,\n    height: ICON_SIZE_TALL,\n  },\n})\nconst TabBarBackground = () => {\n  const { opacity } = use(BottomTabBarBackgroundContext)\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    ...styles.blurEffect,\n  }))\n  const separatorStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n  }))\n  return (\n    <View style={styles.blurEffect}>\n      <Animated.View style={[styles.blurEffect, animatedStyle]}>\n        <ThemedBlurView style={styles.blurEffect} />\n      </Animated.View>\n      <Animated.View\n        className=\"absolute top-0 w-full bg-opaque-separator/50\"\n        style={[\n          separatorStyle,\n          {\n            height: StyleSheet.hairlineWidth,\n          },\n        ]}\n      />\n    </View>\n  )\n}\nconst TabItem = memo(\n  ({\n    focused,\n    onPress,\n    originalRenderIcon,\n    originalRenderLabel,\n    accessibilityLabel,\n    testID,\n  }: {\n    focused: boolean\n    onPress: () => void\n    originalRenderIcon: (scene: { focused: boolean }) => React.ReactNode\n    originalRenderLabel: (scene: { focused: boolean }) => React.ReactNode\n    accessibilityLabel?: string\n    testID?: string\n  }) => {\n    const pressed = useSharedValue(0)\n    const animatedStyle = useAnimatedStyle(() => {\n      return {\n        transform: [\n          {\n            scale: 1 - 0.15 * pressed.value,\n          },\n        ],\n      }\n    })\n    const scene = {\n      focused,\n    }\n    return (\n      <Pressable\n        testID={testID}\n        onPress={() => {\n          onPress()\n          cancelAnimation(pressed)\n          pressed.value = withSpring(0, quickSpringPreset)\n        }}\n        onPressIn={() => {\n          pressed.value = withSpring(1, gentleSpringPreset)\n        }}\n        onPressOut={() => {\n          cancelAnimation(pressed)\n          pressed.value = withSpring(0, quickSpringPreset)\n        }}\n        className=\"flex-1 flex-col items-center justify-center\"\n        accessibilityLabel={accessibilityLabel}\n        accessibilityRole=\"button\"\n        accessibilityState={{\n          selected: focused,\n        }}\n      >\n        <Animated.View style={animatedStyle}>{originalRenderIcon(scene)}</Animated.View>\n        {originalRenderLabel(scene)}\n      </Pressable>\n    )\n  },\n)\nconst noop = () => null\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/contexts/BottomTabBarBackgroundContext.tsx",
    "content": "import { createContext } from \"react\"\nimport type { SharedValue } from \"react-native-reanimated\"\n\ninterface TabBarBackgroundContextType {\n  opacity: SharedValue<number>\n}\n\nexport const BottomTabBarBackgroundContext = createContext<TabBarBackgroundContextType>({\n  opacity: null!,\n})\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext.tsx",
    "content": "import type { Dispatch, SetStateAction } from \"react\"\nimport { createContext } from \"react\"\n\nexport const BottomTabBarHeightContext = createContext(0)\nexport const SetBottomTabBarHeightContext = createContext<Dispatch<SetStateAction<number>>>(null!)\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/contexts/BottomTabBarVisibleContext.tsx",
    "content": "import type { Dispatch, SetStateAction } from \"react\"\nimport { createContext } from \"react\"\n\nexport const SetBottomTabBarVisibleContext = createContext<Dispatch<SetStateAction<boolean>>>(\n  () => {},\n)\n\nexport const BottomTabBarVisibleContext = createContext<boolean>(true)\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/tabbar/hooks.ts",
    "content": "import type { RefObject } from \"react\"\nimport { use, useCallback, useEffect, useLayoutEffect, useRef } from \"react\"\nimport type { FlatList, ScrollView } from \"react-native\"\nimport { findNodeHandle, Platform } from \"react-native\"\n\nimport { performNativeScrollToTop } from \"@/src/lib/native\"\nimport {\n  SetAttachNavigationScrollViewContext,\n  useAttachNavigationScrollView,\n} from \"@/src/lib/navigation/AttachNavigationScrollViewContext\"\nimport { useScreenIsAppeared, useTabScreenIsFocused } from \"@/src/lib/navigation/bottom-tab/hooks\"\n\nimport { BottomTabBarBackgroundContext } from \"./contexts/BottomTabBarBackgroundContext\"\nimport { BottomTabBarHeightContext } from \"./contexts/BottomTabBarHeightContext\"\n\nexport const useBottomTabBarHeight = () => {\n  const height = use(BottomTabBarHeightContext)\n  return height\n}\n\nexport const useNavigationScrollToTop = (\n  overrideScrollerRef?: React.RefObject<ScrollView> | React.RefObject<FlatList<any>> | null,\n) => {\n  const attachNavigationScrollViewRef = useAttachNavigationScrollView()\n  return useCallback(() => {\n    const $scroller = overrideScrollerRef?.current ?? attachNavigationScrollViewRef?.current\n    if (!$scroller) return\n\n    if (Platform.OS === \"ios\") {\n      const reactTag = findNodeHandle($scroller)\n      if (reactTag) {\n        performNativeScrollToTop(reactTag)\n        return\n      }\n    }\n\n    if (\"scrollTo\" in $scroller) {\n      void ($scroller as ScrollView).scrollTo({\n        y: 0,\n        animated: true,\n      })\n    } else if (\"scrollToIndex\" in $scroller) {\n      void ($scroller as FlatList<any>).scrollToIndex({\n        index: 0,\n        animated: true,\n      })\n    } else if (\"scrollToOffset\" in $scroller) {\n      void ($scroller as FlatList<any>).scrollToOffset({\n        offset: 0,\n        animated: true,\n      })\n    }\n    return\n  }, [attachNavigationScrollViewRef, overrideScrollerRef])\n}\n\nexport const useRegisterNavigationScrollView = <T = unknown>(active = true) => {\n  const scrollViewRef = useRef<T>(null)\n  const tabScreenIsFocused = useScreenIsAppeared()\n  const setAttachNavigationScrollViewRef = use(SetAttachNavigationScrollViewContext)\n\n  useEffect(() => {\n    if (!active) return\n    if (!setAttachNavigationScrollViewRef) return\n    if (!tabScreenIsFocused) return\n    if (\n      scrollViewRef.current &&\n      typeof scrollViewRef.current === \"object\" &&\n      \"checkScrollToBottom\" in scrollViewRef.current &&\n      typeof scrollViewRef.current.checkScrollToBottom === \"function\"\n    ) {\n      scrollViewRef.current.checkScrollToBottom()\n    }\n\n    setAttachNavigationScrollViewRef(scrollViewRef as unknown as RefObject<ScrollView>)\n  }, [setAttachNavigationScrollViewRef, scrollViewRef, active, tabScreenIsFocused])\n  return scrollViewRef\n}\n\nexport const useResetTabOpacityWhenFocused = () => {\n  const { opacity } = use(BottomTabBarBackgroundContext)\n  const tabScreenIsFocus = useTabScreenIsFocused()\n  useLayoutEffect(() => {\n    if (tabScreenIsFocus) {\n      opacity.value = 1\n    }\n  }, [tabScreenIsFocus, opacity])\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/utils/index.tsx",
    "content": "import { Platform } from \"react-native\"\n\nimport { isIOS } from \"@/src/lib/platform\"\n\n/**\n * @description In order to make android header height same as ios, we need to custom this function.\n * @copyright copy from @react-navigation/elements/src/Header/getDefaultHeaderHeight.tsx\n */\n\nexport function getDefaultHeaderHeight({\n  landscape,\n  modalPresentation,\n  topInset,\n}: {\n  landscape: boolean\n  modalPresentation: boolean\n  topInset: number\n}): number {\n  let headerHeight\n\n  // On models with Dynamic Island the status bar height is smaller than the safe area top inset.\n  const hasDynamicIsland = isIOS && topInset > 50\n\n  if (Platform.OS === \"ios\") {\n    if (Platform.isPad || Platform.isTV) {\n      if (modalPresentation) {\n        headerHeight = 56\n      } else {\n        headerHeight = 50\n      }\n    } else {\n      if (modalPresentation && !landscape) {\n        headerHeight = 56\n      } else {\n        headerHeight = hasDynamicIsland ? 50 : 44\n      }\n    }\n  } else {\n    headerHeight = 64\n  }\n\n  return headerHeight + (modalPresentation ? 0 : topInset)\n}\n\nexport function getNavigationHeaderLayout({\n  landscape,\n  sheetModal,\n  topInset,\n}: {\n  landscape: boolean\n  sheetModal: boolean\n  topInset: number\n}) {\n  const effectiveModalPresentation = isIOS && sheetModal\n  const headerTopInset = effectiveModalPresentation ? 0 : topInset\n\n  return {\n    headerTopInset,\n    effectiveModalPresentation,\n    headerHeight: getDefaultHeaderHeight({\n      landscape,\n      modalPresentation: effectiveModalPresentation,\n      topInset: headerTopInset,\n    }),\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/views/NavigationHeaderContext.tsx",
    "content": "import type { Dispatch, SetStateAction } from \"react\"\nimport { createContext } from \"react\"\n\nexport const NavigationHeaderHeightContext = createContext<number>(null!)\nexport const SetNavigationHeaderHeightContext = createContext<Dispatch<SetStateAction<number>>>(\n  null!,\n)\n"
  },
  {
    "path": "apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { useSetAtom, useStore } from \"jotai\"\nimport type { PropsWithChildren } from \"react\"\nimport { use, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\"\nimport type { ScrollView, ScrollViewProps, StyleProp, ViewStyle } from \"react-native\"\nimport { findNodeHandle, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport { runOnJS, useAnimatedScrollHandler } from \"react-native-reanimated\"\nimport type { ReanimatedScrollEvent } from \"react-native-reanimated/lib/typescript/hook/commonTypes\"\nimport { useSafeAreaFrame, useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { useBottomTabBarHeight } from \"@/src/components/layouts/tabbar/hooks\"\nimport { isScrollToEnd } from \"@/src/lib/native\"\nimport { useInTabScreen } from \"@/src/lib/navigation/bottom-tab/hooks\"\nimport { useScreenIsInSheetModal } from \"@/src/lib/navigation/hooks\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\n\nimport { ReAnimatedScrollView } from \"../../common/AnimatedComponents\"\nimport type { InternalNavigationHeaderProps } from \"../header/NavigationHeader\"\nimport { InternalNavigationHeader } from \"../header/NavigationHeader\"\nimport { BottomTabBarBackgroundContext } from \"../tabbar/contexts/BottomTabBarBackgroundContext\"\nimport { getNavigationHeaderLayout } from \"../utils\"\nimport {\n  NavigationHeaderHeightContext,\n  SetNavigationHeaderHeightContext,\n} from \"./NavigationHeaderContext\"\n\ntype SafeNavigationScrollViewProps = Omit<ScrollViewProps, \"onScroll\"> & {\n  onScroll?: (e: ReanimatedScrollEvent) => void\n\n  // For scroll view content adjustment behavior\n  withTopInset?: boolean\n  withBottomInset?: boolean\n\n  // to sharedValue\n  reanimatedScrollY?: SharedValue<number>\n\n  contentViewStyle?: StyleProp<ViewStyle>\n  contentViewClassName?: string\n  contentContainerMaxWidth?: number\n\n  Header?: React.ReactNode\n  ScrollViewBottom?: React.ReactNode\n} & PropsWithChildren\n\nexport interface ForwardedSafeNavigationScrollView extends ScrollView {\n  checkScrollToBottom: () => void\n}\nexport const SafeNavigationScrollView = ({\n  ref: forwardedRef,\n  children,\n  onScroll,\n  withBottomInset = false,\n  withTopInset = false,\n  reanimatedScrollY,\n  contentViewClassName,\n  contentViewStyle,\n  contentContainerMaxWidth,\n  Header,\n  ScrollViewBottom,\n  ...props\n}: SafeNavigationScrollViewProps & { ref?: React.Ref<ScrollView | null> }) => {\n  const insets = useSafeAreaInsets()\n  const tabBarHeight = useBottomTabBarHeight()\n\n  const frame = useSafeAreaFrame()\n  const resolvedContentContainerWidth = contentContainerMaxWidth\n    ? Math.min(frame.width, contentContainerMaxWidth)\n    : undefined\n  const sheetModal = useScreenIsInSheetModal()\n  const [headerHeight, setHeaderHeight] = useState(\n    () =>\n      getNavigationHeaderLayout({\n        landscape: frame.width > frame.height,\n        sheetModal,\n        topInset: insets.top,\n      }).headerHeight,\n  )\n  const screenCtxValue = use(ScreenItemContext)\n\n  const ref = useRef<ScrollView>(null)\n  useImperativeHandle(forwardedRef, () => {\n    return Object.assign({}, ref.current!, {\n      checkScrollToBottom,\n    })\n  })\n  const { opacity } = use(BottomTabBarBackgroundContext)\n\n  const inTabScreen = useInTabScreen()\n\n  function checkScrollToBottom() {\n    if (!inTabScreen) {\n      return\n    }\n    const handle = findNodeHandle(ref.current!)\n    if (!handle) {\n      return\n    }\n\n    isScrollToEnd(handle).then((isEnd) => {\n      opacity.value = isEnd ? 0 : 1\n    })\n  }\n  const scrollHandler = useAnimatedScrollHandler({\n    onScroll: (event) => {\n      if (reanimatedScrollY) {\n        reanimatedScrollY.value = event.contentOffset.y\n      }\n      if (onScroll) {\n        runOnJS(onScroll)(event)\n      }\n      runOnJS(checkScrollToBottom)()\n      screenCtxValue.reAnimatedScrollY.value = event.contentOffset.y\n    },\n  })\n\n  return (\n    <NavigationHeaderHeightContext value={headerHeight}>\n      <SetNavigationHeaderHeightContext value={setHeaderHeight}>\n        {Header}\n        <ReAnimatedScrollView\n          ref={ref}\n          onScroll={scrollHandler}\n          onContentSizeChange={useTypeScriptHappyCallback(\n            (w, h) => {\n              screenCtxValue.scrollViewContentHeight.value = h\n              checkScrollToBottom()\n            },\n            [screenCtxValue.scrollViewContentHeight],\n          )}\n          onLayout={useTypeScriptHappyCallback(\n            (e) => {\n              screenCtxValue.scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight\n              checkScrollToBottom()\n            },\n            [screenCtxValue.scrollViewHeight, headerHeight],\n          )}\n          automaticallyAdjustContentInsets={false}\n          automaticallyAdjustsScrollIndicatorInsets={false}\n          scrollIndicatorInsets={{\n            top: headerHeight,\n            bottom: tabBarHeight,\n          }}\n          {...props}\n        >\n          <View style={{ height: headerHeight - (withTopInset ? insets.top : 0) }} />\n          <View\n            style={[\n              contentContainerMaxWidth\n                ? {\n                    width: resolvedContentContainerWidth,\n                    alignSelf: \"center\",\n                  }\n                : undefined,\n              contentViewStyle,\n            ]}\n            className={cn(\"flex-1\", contentViewClassName)}\n          >\n            {children}\n          </View>\n          {ScrollViewBottom}\n          <View style={{ height: tabBarHeight - (withBottomInset ? insets.bottom : 0) }} />\n        </ReAnimatedScrollView>\n      </SetNavigationHeaderHeightContext>\n    </NavigationHeaderHeightContext>\n  )\n}\n\nexport const NavigationBlurEffectHeaderView = ({\n  headerHideableBottom,\n  headerHideableBottomHeight,\n  headerTitleAbsolute,\n  ...props\n}: InternalNavigationHeaderProps & {\n  blurThreshold?: number\n  headerHideableBottom?: () => React.ReactNode\n  headerHideableBottomHeight?: number\n  headerTitleAbsolute?: boolean\n}) => {\n  const hideableBottom = headerHideableBottom?.()\n  return (\n    <View className=\"absolute inset-x-0 top-0 z-[99]\">\n      <InternalNavigationHeader\n        title={props.title}\n        headerRight={props.headerRight}\n        headerLeft={props.headerLeft}\n        hideableBottom={hideableBottom}\n        hideableBottomHeight={headerHideableBottomHeight}\n        headerTitleAbsolute={headerTitleAbsolute}\n        headerTitle={props.headerTitle}\n        promptBeforeLeave={props.promptBeforeLeave}\n        isLoading={props.isLoading}\n      />\n    </View>\n  )\n}\n\n/**\n * @deprecated\n * please use `NavigationBlurEffectHeaderView` instead, pass `<NavigationBlurEffectHeaderView />` in `SafeNavigationScrollView`'s `Header` prop\n *\n * e.g. `<SafeNavigationScrollView Header={<NavigationBlurEffectHeaderView />} />`\n * @see NavigationBlurEffectHeaderView\n */\nexport const NavigationBlurEffectHeader = ({\n  headerHideableBottom,\n  headerHideableBottomHeight,\n  headerTitleAbsolute,\n  ...props\n}: InternalNavigationHeaderProps & {\n  blurThreshold?: number\n  headerHideableBottomHeight?: number\n  headerHideableBottom?: () => React.ReactNode\n  headerTitleAbsolute?: boolean\n}) => {\n  const setHeaderHeight = use(SetNavigationHeaderHeightContext)\n\n  const hideableBottom = headerHideableBottom?.()\n  const screenCtxValue = use(ScreenItemContext)\n\n  const setSlot = useSetAtom(screenCtxValue.Slot)\n  const store = useStore()\n  useLayoutEffect(() => {\n    setSlot({\n      ...store.get(screenCtxValue.Slot),\n      header: (\n        <SetNavigationHeaderHeightContext value={setHeaderHeight}>\n          <InternalNavigationHeader\n            title={props.title}\n            headerRight={props.headerRight}\n            headerLeft={props.headerLeft}\n            hideableBottom={hideableBottom}\n            hideableBottomHeight={headerHideableBottomHeight}\n            headerTitleAbsolute={headerTitleAbsolute}\n            headerTitle={props.headerTitle}\n            promptBeforeLeave={props.promptBeforeLeave}\n            isLoading={props.isLoading}\n          />\n        </SetNavigationHeaderHeightContext>\n      ),\n    })\n  }, [\n    screenCtxValue.Slot,\n    headerHideableBottomHeight,\n    headerTitleAbsolute,\n    hideableBottom,\n    props.headerLeft,\n    props.headerRight,\n    props.title,\n    setHeaderHeight,\n    setSlot,\n    store,\n    props.headerTitle,\n    props.promptBeforeLeave,\n    props.isLoading,\n  ])\n\n  return null\n}\n"
  },
  {
    "path": "apps/mobile/src/components/native/PagerView/index.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { cssInterop } from \"nativewind\"\nimport type { FC, ReactNode, Ref } from \"react\"\nimport { useImperativeHandle, useMemo, useRef, useState } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { StyleSheet } from \"react-native\"\n\nimport type { PagerRef } from \"./specs\"\nimport { EnhancePagerView, EnhancePageView } from \"./specs\"\n\ncssInterop(EnhancePagerView, {\n  className: \"style\",\n})\ninterface PagerViewProps {\n  pageContainerStyle?: ViewStyle\n  pageContainerClassName?: string\n  renderPage?: (index: number) => ReactNode\n  pageTotal: number\n  pageGap?: number\n  transitionStyle?: \"scroll\" | \"pageCurl\"\n  page?: number\n  onPageChange?: (index: number) => void\n  onScroll?: (percent: number, direction: \"left\" | \"right\" | \"none\", position: number) => void\n  onScrollBegin?: () => void\n  onScrollEnd?: (index: number) => void\n  onPageWillAppear?: (index: number) => void\n  containerStyle?: StyleProp<ViewStyle>\n  containerClassName?: string\n  initialPageIndex?: number\n  ref?: Ref<PagerRef>\n}\nexport const PagerView: FC<PagerViewProps> = ({\n  pageContainerStyle,\n  pageContainerClassName,\n  renderPage,\n  pageTotal,\n  pageGap,\n  transitionStyle,\n  containerStyle,\n  containerClassName,\n  page,\n  onPageChange,\n  onScroll,\n  onScrollBegin,\n  onScrollEnd,\n  onPageWillAppear,\n  initialPageIndex,\n  ref,\n}) => {\n  const [currentPage, setCurrentPage] = useState(page ?? 0)\n  const pageKeys = useMemo(\n    () => Array.from({ length: pageTotal }, (_, pageIndex) => `page-${pageIndex}`),\n    [pageTotal],\n  )\n\n  const nativeRef = useRef<PagerRef>(null)\n  useImperativeHandle(ref, () => ({\n    setPage: (index: number) => {\n      setCurrentPage(index)\n      nativeRef.current?.setPage(index)\n    },\n    getPage: () => currentPage,\n    getState: () => nativeRef.current?.getState() ?? \"idle\",\n  }))\n  return (\n    <EnhancePagerView\n      initialPageIndex={initialPageIndex}\n      transitionStyle={transitionStyle}\n      pageGap={pageGap}\n      onPageChange={(e) => {\n        setCurrentPage(e.nativeEvent.index)\n        onPageChange?.(e.nativeEvent.index)\n      }}\n      onScroll={(e) => {\n        onScroll?.(e.nativeEvent.percent, e.nativeEvent.direction, e.nativeEvent.position)\n      }}\n      onScrollBegin={() => {\n        onScrollBegin?.()\n      }}\n      onScrollEnd={(e) => {\n        onScrollEnd?.(e.nativeEvent.index)\n      }}\n      onPageWillAppear={(e) => {\n        onPageWillAppear?.(e.nativeEvent.index)\n      }}\n      className={cn(\"flex-1\", containerClassName)}\n      style={containerStyle}\n      ref={nativeRef}\n    >\n      {pageKeys.map((pageKey, pageIndex) => (\n        <EnhancePageView\n          key={pageKey}\n          className={cn(\"flex-1\", pageContainerClassName)}\n          style={[styles.pageContainer, pageContainerStyle]}\n        >\n          {renderPage?.(pageIndex)}\n        </EnhancePageView>\n      ))}\n    </EnhancePagerView>\n  )\n}\n\nconst styles = StyleSheet.create({\n  pageContainer: {\n    ...StyleSheet.absoluteFillObject,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/native/PagerView/specs.ts",
    "content": "import { requireNativeView } from \"expo\"\nimport type { NativeSyntheticEvent, ViewProps } from \"react-native\"\n\nexport const EnhancePagerView = requireNativeView<\n  ViewProps &\n    PagerProps & {\n      ref: React.RefObject<PagerRef | null>\n    }\n>(\"EnhancePagerView\")\nexport const EnhancePageView = requireNativeView(\"EnhancePageView\")\n\nexport interface PagerProps {\n  onPageChange?: (e: NativeSyntheticEvent<{ index: number }>) => void\n  onScroll?: (\n    e: NativeSyntheticEvent<{\n      percent: number\n      direction: \"left\" | \"right\" | \"none\"\n      position: number\n    }>,\n  ) => void\n  onScrollBegin?: () => void\n  onScrollEnd?: (e: NativeSyntheticEvent<{ index: number }>) => void\n  onPageWillAppear?: (e: NativeSyntheticEvent<{ index: number }>) => void\n  page?: number\n  pageGap?: number\n  transitionStyle?: \"scroll\" | \"pageCurl\"\n  initialPageIndex?: number\n}\nexport interface PagerRef {\n  setPage: (index: number) => void\n  getPage: () => number\n  getState: () => \"idle\" | \"dragging\" | \"scrolling\"\n}\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/DebugPanel.tsx",
    "content": "import { Portal } from \"@gorhom/portal\"\nimport * as React from \"react\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport { Pressable, Text, View } from \"react-native\"\n\nimport { BugCuteReIcon } from \"@/src/icons/bug_cute_re\"\nimport { CloseCuteReIcon } from \"@/src/icons/close_cute_re\"\n\nimport { htmlUrl } from \"./constants\"\nimport type { WebViewDebugState } from \"./index\"\nimport { SharedWebViewModule } from \"./index\"\n\ninterface DebugPanelProps {\n  mode: \"debug\" | \"normal\"\n  onModeToggle: () => void\n}\n\nconst SimpleButton = ({ label, onPress }: { label: string; onPress: () => void }) => (\n  <Pressable onPress={onPress} className=\"mr-2 rounded bg-gray-200 px-2 py-1 dark:bg-gray-700\">\n    <Text className=\"text-xs text-label\">{label}</Text>\n  </Pressable>\n)\n\nexport function DebugPanel({ mode, onModeToggle }: DebugPanelProps) {\n  const [visible, setVisible] = useState(false)\n  const [debugState, setDebugState] = useState<WebViewDebugState | null>(null)\n\n  // Get debug state periodically\n  useEffect(() => {\n    const id = setInterval(() => {\n      try {\n        const d = SharedWebViewModule.getDebugState?.()\n        if (d) setDebugState(d as WebViewDebugState)\n      } catch {\n        /* empty */\n      }\n    }, 1000)\n    return () => clearInterval(id)\n  }, [])\n\n  // Simple debug info\n  const debugInfo = useMemo(() => {\n    if (!debugState) return \"Loading...\"\n    const ready = debugState.ready && debugState.hasWebView\n    return `${ready ? \"✅\" : \"❌\"} WV:${debugState.hasWebView} H:${Math.round(debugState.contentHeight)}`\n  }, [debugState])\n\n  if (!__DEV__) return null\n\n  return (\n    <Portal>\n      {/* Debug Float Button */}\n      <View className=\"bottom-safe-offset-2 absolute left-4 flex-row gap-4\">\n        <Pressable\n          className=\"flex size-12 items-center justify-center rounded-full bg-orange-500\"\n          onPress={() => setVisible(true)}\n        >\n          <BugCuteReIcon color=\"#fff\" />\n        </Pressable>\n      </View>\n\n      {/* Debug Panel */}\n      {visible && (\n        <View className=\"absolute inset-x-4 bottom-20 rounded-lg bg-white p-3 shadow-lg dark:bg-gray-900\">\n          <View className=\"mb-3 flex-row items-center justify-between\">\n            <Text className=\"text-sm font-medium text-label\">WebView Debug</Text>\n            <Pressable onPress={() => setVisible(false)}>\n              <CloseCuteReIcon width={16} height={16} color=\"#8E8E93\" />\n            </Pressable>\n          </View>\n\n          <Text className=\"mb-3 font-mono text-xs text-gray-600 dark:text-gray-400\">\n            {debugInfo}\n          </Text>\n\n          <View className=\"flex-row flex-wrap gap-y-2\">\n            <SimpleButton label={`Mode: ${mode}`} onPress={onModeToggle} />\n            <SimpleButton label=\"Destroy\" onPress={() => SharedWebViewModule.destroyForDebug?.()} />\n            <SimpleButton label=\"Reload\" onPress={() => SharedWebViewModule.reloadLastURL?.()} />\n            <SimpleButton label=\"Prewarm\" onPress={() => SharedWebViewModule.load(htmlUrl)} />\n            <SimpleButton label=\"Flush\" onPress={() => SharedWebViewModule.flushQueue?.()} />\n          </View>\n        </View>\n      )}\n    </Portal>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/EntryContentWebView.tsx",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport { Portal } from \"@gorhom/portal\"\nimport { useAtom } from \"jotai\"\nimport * as React from \"react\"\nimport { useCallback, useEffect } from \"react\"\nimport { Dimensions, StyleSheet, View } from \"react-native\"\nimport { runOnJS, runOnUI } from \"react-native-reanimated\"\nimport TrackPlayer from \"react-native-track-player\"\n\nimport { player } from \"@/src/lib/player\"\n\nimport { useLightboxControls } from \"../../ui/lightbox/lightboxState\"\nimport { PlatformActivityIndicator } from \"../../ui/loading/PlatformActivityIndicator\"\nimport { sharedWebViewHeightAtom } from \"./atom\"\nimport { DebugPanel } from \"./DebugPanel\"\nimport { useWebViewEntry, useWebViewMode } from \"./hooks\"\nimport type { AudioSeekEvent } from \"./index\"\nimport { prepareEntryRenderWebView } from \"./index\"\nimport { NativeWebView } from \"./native-webview\"\nimport { WebViewManager } from \"./webview-manager\"\n\ntype EntryContentWebViewProps = {\n  entryId: string\n  noMedia?: boolean\n  showReadability?: boolean\n  showTranslation?: boolean\n}\n\n// Export for backward compatibility\n\nexport function EntryContentWebView(props: EntryContentWebViewProps) {\n  const [contentHeight, setContentHeight] = useAtom(sharedWebViewHeightAtom)\n  const { openLightbox } = useLightboxControls()\n\n  // Use custom hooks for WebView management\n  const { entryInWebview, isLoading } = useWebViewEntry(props)\n  const { handleModeSwitch, mode } = useWebViewMode()\n\n  const handleSeekAudio = useCallback(\n    async (e: AudioSeekEvent) => {\n      const activeTrack = await TrackPlayer.getActiveTrack()\n      const entryAudio = entryInWebview?.attachments?.find((attachment) =>\n        attachment.mime_type?.startsWith(\"audio/\"),\n      )\n      if (!entryAudio) {\n        console.warn(\"Failed to seek audio! No audio attachment found\")\n        return\n      }\n      if (activeTrack?.url !== entryAudio.url) {\n        await player.play({\n          url: entryAudio.url || \"\",\n          title: entryInWebview?.title || \"Unknown Title\",\n          artist: entryInWebview?.author || \"Unknown Artist\",\n        })\n      }\n      await player.seekTo(e.time)\n    },\n    [entryInWebview?.attachments, entryInWebview?.author, entryInWebview?.title],\n  )\n\n  // Handle audio seek events\n  useEffect(() => {\n    return EventBus.subscribe(\"SEEK_AUDIO\", (event) => {\n      handleSeekAudio(event)\n    })\n  }, [handleSeekAudio])\n\n  // Handle image preview events\n  useEffect(() => {\n    return EventBus.subscribe(\"PREVIEW_IMAGE\", (event) => {\n      const { imageUrls, index } = event\n\n      runOnUI(() => {\n        \"worklet\"\n        runOnJS(openLightbox)({\n          images: imageUrls.map((url: string) => ({\n            uri: url,\n            dimensions: null,\n            thumbUri: url,\n            thumbDimensions: null,\n            thumbRect: null,\n            type: \"image\",\n          })),\n          index,\n        })\n      })()\n    })\n  }, [openLightbox])\n\n  // Initialize WebView once\n  const onceRef = React.useRef(false)\n  if (!onceRef.current) {\n    onceRef.current = true\n    prepareEntryRenderWebView()\n  }\n\n  const handleModeToggle = React.useCallback(() => {\n    const nextMode = mode === \"debug\" ? \"normal\" : \"debug\"\n    handleModeSwitch(nextMode)\n  }, [mode, handleModeSwitch])\n\n  useEffect(() => {\n    // Reset the shared container height before the next entry content arrives.\n    setContentHeight(Dimensions.get(\"window\").height)\n  }, [props.entryId, props.showReadability, props.showTranslation, setContentHeight])\n\n  return (\n    <>\n      <View\n        key={`${mode}-${props.entryId}`}\n        style={{ width: \"100%\", height: contentHeight, transform: [{ translateY: 0 }] }}\n        onLayout={() => {\n          WebViewManager.setEntry(entryInWebview)\n        }}\n      >\n        <NativeWebView\n          style={styles.webView}\n          onContentHeightChange={(e) => {\n            setContentHeight(e.nativeEvent.height)\n          }}\n          onSeekAudio={handleSeekAudio}\n        />\n      </View>\n\n      <Portal>\n        {isLoading && (\n          <View className=\"absolute inset-0 items-center justify-center\">\n            <PlatformActivityIndicator />\n          </View>\n        )}\n      </Portal>\n      <DebugPanel mode={mode} onModeToggle={handleModeToggle} />\n    </>\n  )\n}\n\nconst styles = StyleSheet.create({\n  webView: {\n    width: \"100%\",\n    height: \"100%\",\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/atom.ts",
    "content": "import { atom } from \"jotai\"\nimport { Dimensions } from \"react-native\"\n\nexport const sharedWebViewHeightAtom = atom<number>(Dimensions.get(\"window\").height)\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/constants.ts",
    "content": "import { Image, Platform } from \"react-native\"\n\n// HTML renderer asset configuration\nconst HTML_RENDERER_ASSET = \"rn-web/html-renderer\"\nconst ANDROID_ASSET_BASE = \"file:///android_asset/html-renderer/index.html\"\n\nconst getAssetPath = () => {\n  try {\n    return Image.resolveAssetSource({\n      uri: HTML_RENDERER_ASSET,\n    }).uri\n  } catch (error) {\n    console.warn(\"Failed to resolve HTML renderer asset path:\", error)\n    return \"\"\n  }\n}\n\nexport const htmlUrl =\n  Platform.select({\n    ios: `file://${getAssetPath()}/index.html`,\n    android: ANDROID_ASSET_BASE,\n    default: \"\",\n  }) || \"\"\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/hooks.ts",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport { PixelRatio } from \"react-native\"\n\nimport { useActionLanguage } from \"@/src/atoms/settings/general\"\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\n\nimport { prepareEntryRenderWebView } from \"./index\"\nimport { WebViewManager } from \"./webview-manager\"\n\ninterface UseWebViewEntryOptions {\n  entryId: string\n  noMedia?: boolean\n  showReadability?: boolean\n  showTranslation?: boolean\n}\n\n/**\n * Hook to prepare the entry render WebView with proper cleanup and error handling\n * Ensures the WebView is only initialized once per component lifecycle\n */\nexport const usePrepareEntryRenderWebView = () => {\n  const initializedRef = useRef(false)\n\n  useEffect(() => {\n    if (initializedRef.current) return\n\n    try {\n      prepareEntryRenderWebView()\n      initializedRef.current = true\n    } catch (error) {\n      console.error(\"Failed to prepare entry render WebView:\", error)\n    }\n  }, [])\n\n  return initializedRef.current\n}\n\n/**\n * Custom hook to manage WebView entry content and settings\n * Handles all WebView JavaScript bridge calls in a centralized way\n */\nexport function useWebViewEntry({\n  entryId,\n  noMedia,\n  showReadability,\n  showTranslation,\n}: UseWebViewEntryOptions) {\n  const entry = useEntry(entryId, (state) => state)\n  const language = useActionLanguage()\n  const translation = useEntryTranslation({\n    entryId,\n    language,\n    enabled: showTranslation ?? false,\n  })\n\n  // UI Settings\n  const codeThemeLight = useUISettingKey(\"codeHighlightThemeLight\")\n  const codeThemeDark = useUISettingKey(\"codeHighlightThemeDark\")\n  const readerRenderInlineStyle = useUISettingKey(\"readerRenderInlineStyle\")\n  const useSystemFontScaling = useUISettingKey(\"useSystemFontScaling\")\n  const customFontScale = useUISettingKey(\"fontScale\")\n  const useDifferentFontSizeForContent = useUISettingKey(\"useDifferentFontSizeForContent\")\n  const mobileContentFontSize = useUISettingKey(\"mobileContentFontSize\")\n\n  useEffect(() => {\n    const fontScale = useSystemFontScaling ? PixelRatio.getFontScale() : customFontScale\n    WebViewManager.setRootFontSize(fontScale * 16)\n  }, [useSystemFontScaling, customFontScale])\n\n  // Handle content-specific font size\n  useEffect(() => {\n    if (useDifferentFontSizeForContent) {\n      WebViewManager.setRootFontSize(mobileContentFontSize)\n    } else {\n      // Reset to use the global font scaling\n      const fontScale = useSystemFontScaling ? PixelRatio.getFontScale() : customFontScale\n      WebViewManager.setRootFontSize(fontScale * 16)\n    }\n  }, [useDifferentFontSizeForContent, mobileContentFontSize, useSystemFontScaling, customFontScale])\n\n  // Prepare entry data for WebView\n  const entryInWebview = useMemo(() => {\n    if (!entry) return null\n\n    const entryContent = showReadability ? entry?.readabilityContent : entry?.content\n    const translatedContent = showReadability\n      ? translation?.readabilityContent\n      : translation?.content\n    const content = showTranslation ? translatedContent || entryContent : entryContent\n\n    return {\n      ...entry,\n      content,\n    }\n  }, [\n    entry,\n    showReadability,\n    showTranslation,\n    translation?.content,\n    translation?.readabilityContent,\n  ])\n\n  // Update WebView settings when dependencies change\n  useEffect(() => {\n    WebViewManager.setNoMedia(!!noMedia)\n  }, [noMedia])\n\n  useEffect(() => {\n    WebViewManager.setReaderRenderInlineStyle(readerRenderInlineStyle)\n  }, [readerRenderInlineStyle])\n\n  useEffect(() => {\n    WebViewManager.setCodeTheme(codeThemeLight, codeThemeDark)\n  }, [codeThemeLight, codeThemeDark])\n\n  useEffect(() => {\n    WebViewManager.setEntry(entryInWebview)\n  }, [entryInWebview])\n\n  return {\n    entry,\n    entryInWebview,\n    translation,\n    isLoading: showReadability ? !entry?.readabilityContent : !entry?.content,\n  }\n}\n\n/**\n * Hook for managing WebView mode switching (debug/normal)\n */\nexport function useWebViewMode() {\n  const [mode, setMode] = useState<\"normal\" | \"debug\">(\"normal\")\n  const handleModeSwitch = (mode: \"normal\" | \"debug\") => {\n    setMode(mode)\n    if (mode === \"debug\") {\n      WebViewManager.loadUrl(\"http://localhost:5173/\")\n    } else {\n      const { htmlUrl } = require(\"./constants\")\n      WebViewManager.loadUrl(htmlUrl)\n    }\n  }\n\n  return { handleModeSwitch, mode }\n}\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/index.android.ts",
    "content": "import { injectJavaScript } from \"./native-webview.android\"\n\nexport const SharedWebViewModule = {\n  load: () => {\n    console.warn(\"SharedWebViewModule.load is not implemented on Android\")\n  },\n  evaluateJavaScript: (js: string) => {\n    injectJavaScript(js)\n  },\n  dispatch: (type: string, payload?: string) => {\n    const payloadExpr = payload != null ? `JSON.parse(${JSON.stringify(payload)})` : \"null\"\n    const js = `;(function(){try{if(window.__FO_BRIDGE__&&typeof window.__FO_BRIDGE__.dispatch==='function'){window.__FO_BRIDGE__.dispatch(${JSON.stringify(\n      type,\n    )}, ${payloadExpr});}}catch(e){}})();`\n    injectJavaScript(js)\n  },\n}\n\nexport const prepareEntryRenderWebView = () => {}\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/index.ts",
    "content": "import { EventBus } from \"@follow/utils/event-bus\"\nimport type { NativeModule } from \"expo\"\nimport { requireNativeModule } from \"expo-modules-core\"\n\nimport { htmlUrl } from \"./constants\"\n\nexport interface ImagePreviewEvent {\n  imageUrls: string[]\n  index: number\n}\n\nexport interface AudioSeekEvent {\n  time: number\n}\n\ndeclare module \"@follow/utils/event-bus\" {\n  export interface CustomEvent {\n    PREVIEW_IMAGE: ImagePreviewEvent\n    SEEK_AUDIO: AudioSeekEvent\n  }\n}\n\ndeclare class ISharedWebViewModule extends NativeModule<{\n  onContentHeightChanged: ({ height }: { height: number }) => void\n  onImagePreview: (event: ImagePreviewEvent) => void\n  onSeekAudio?: (e: { time: number }) => void\n}> {\n  load(url: string): void\n  evaluateJavaScript(js: string): void\n  dispatch?(type: string, payload?: string): void\n\n  // Debug helpers (iOS only)\n  getDebugState?(): WebViewDebugState\n  destroyForDebug?(): void\n  reloadLastURL?(): void\n  flushQueue?(): void\n}\n\nexport const SharedWebViewModule = requireNativeModule<ISharedWebViewModule>(\"FOSharedWebView\")\n\n// Re-export all WebView utilities\nexport { usePrepareEntryRenderWebView, useWebViewEntry, useWebViewMode } from \"./hooks\"\nexport { WebViewManager } from \"./webview-manager\"\n\n// Dev/debug helpers (iOS implemented; Android no-op)\nexport type WebViewDebugState = {\n  hasWebView: boolean\n  hasHost: boolean\n  ready: boolean\n  pending: number\n  lastURL?: string | null\n  contentHeight: number\n}\n\nlet prepareOnce = false\n\n/**\n * Initializes the shared WebView module with proper error handling and event listeners\n * This function is idempotent and will only execute once per app lifecycle\n */\nexport const prepareEntryRenderWebView = () => {\n  if (prepareOnce) return\n\n  try {\n    prepareOnce = true\n\n    if (!htmlUrl) {\n      throw new Error(\"HTML URL is not available for WebView initialization\")\n    }\n\n    SharedWebViewModule.load(htmlUrl)\n\n    // Set up image preview event listener with error handling\n    SharedWebViewModule.addListener(\"onImagePreview\", (event: ImagePreviewEvent) => {\n      EventBus.dispatch(\"PREVIEW_IMAGE\", event)\n    })\n    SharedWebViewModule.addListener(\"onSeekAudio\", (event: AudioSeekEvent) => {\n      EventBus.dispatch(\"SEEK_AUDIO\", event)\n    })\n  } catch (error) {\n    console.error(\"Failed to prepare entry render WebView:\", error)\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/injected-js.ts",
    "content": "// Ported from apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js\n\nconst RNMessageHandlers = `\nif(!window.webkit) {\n  window.webkit = {\n    messageHandlers: {\n      message: ReactNativeWebView\n    },\n  }\n}\n`\n\nexport const atStart = `\n;(() => {\n  ${RNMessageHandlers}\n  window.__RN__ = true\n\n  function send(data) {\n    window.webkit.messageHandlers.message.postMessage?.(JSON.stringify(data))\n  }\n\n  window.bridge = {\n    measure: () => {\n      send({\n        type: \"measure\",\n      })\n    },\n    setContentHeight: (height) => {\n      send({\n        type: \"setContentHeight\",\n        payload: height,\n      })\n    },\n    previewImage: (data) => {\n      send({\n        type: \"previewImage\",\n        payload: {\n          imageUrls: data.imageUrls,\n          index: data.index || 0,\n        },\n      })\n    },\n    seekAudio: (time) => {\n      send({\n        type: \"audio:seekTo\",\n        payload: {\n          time,\n        },\n      })\n    },\n  }\n\n  // Signal readiness once DOM is interactive/loaded (guard to send once)\n  if (!window.__FO_WEBVIEW_READY__) {\n    let sent = false\n    const sendReady = () => {\n      if (sent) return\n      sent = true\n      window.__FO_WEBVIEW_READY__ = true\n      try {\n        send({ type: \"ready\" })\n      } catch {\n        /* empty */\n      }\n    }\n    document.addEventListener(\"DOMContentLoaded\", sendReady)\n    window.addEventListener(\"load\", sendReady)\n  }\n})()\n`\n\nexport const atEnd = `\n;(() => {\n  const root = document.querySelector(\"#root\")\n  let ticking = false\n  const handleHeight = () => {\n    if (ticking) return\n    ticking = true\n    setTimeout(() => {\n      try {\n        window.webkit.messageHandlers.message.postMessage(\n          JSON.stringify({\n            type: \"setContentHeight\",\n            payload: root?.scrollHeight || document.documentElement.scrollHeight,\n          }),\n        )\n      } finally {\n        ticking = false\n      }\n    }, 16)\n  }\n  window.addEventListener(\"load\", handleHeight)\n  const observer = new ResizeObserver(handleHeight)\n\n  setTimeout(() => {\n    handleHeight()\n  }, 1000)\n  observer.observe(root)\n\n  // Fallback: ensure readiness is signaled at end if not yet sent\n  if (!window.__FO_WEBVIEW_READY__) {\n    try {\n      window.__FO_WEBVIEW_READY__ = true\n      window.webkit.messageHandlers.message.postMessage(JSON.stringify({ type: \"ready\" }))\n    } catch {\n      /* empty */\n    }\n  }\n})()\n`\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/native-webview.android.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { jotaiStore } from \"@follow/utils\"\nimport { atom } from \"jotai\"\nimport type * as React from \"react\"\nimport type { RefObject } from \"react\"\nimport { useCallback, useRef } from \"react\"\nimport type { ViewProps } from \"react-native\"\nimport { runOnJS, runOnUI } from \"react-native-reanimated\"\nimport type { WebViewNavigation } from \"react-native-webview\"\nimport WebView from \"react-native-webview\"\n\nimport { openLink } from \"@/src/lib/native\"\n\nimport { useLightboxControls } from \"../../ui/lightbox/lightboxState\"\nimport { htmlUrl } from \"./constants\"\nimport { atEnd, atStart } from \"./injected-js\"\n\nconst webviewAtom = atom<WebView | null>(null)\n\nconst setWebview = (webview: WebView | null) => {\n  jotaiStore.set(webviewAtom, webview)\n}\n\nexport const injectJavaScript = (js: string) => {\n  const webview = jotaiStore.get(webviewAtom)\n  if (!webview) {\n    console.warn(\"WebView not ready, injecting JavaScript failed\", js)\n    return\n  }\n  return webview.injectJavaScript(js)\n}\n\nconst onLoadEnd = () => {\n  injectJavaScript(atEnd)\n}\n\nexport const NativeWebView: React.ComponentType<\n  ViewProps & {\n    onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void\n    onSeekAudio?: (e: { time: number }) => void\n    url?: string\n  }\n> = ({ onContentHeightChange, onSeekAudio }) => {\n  const webViewRef = useRef<WebView | null>(null)\n  const { onNavigationStateChange } = useWebViewNavigation({ webViewRef })\n  const { openLightbox } = useLightboxControls()\n\n  return (\n    <WebView\n      ref={(webview) => {\n        setWebview(webview)\n      }}\n      style={styles.webview}\n      containerStyle={styles.webviewContainer}\n      source={{ uri: htmlUrl }}\n      // Open chrome://inspect/#devices, or Development menu on Safari to debug the WebView.\n      // https://github.com/react-native-webview/react-native-webview/blob/master/docs/Debugging.md#debugging-webview-contents\n      webviewDebuggingEnabled={__DEV__}\n      sharedCookiesEnabled\n      originWhitelist={[\"*\"]}\n      allowUniversalAccessFromFileURLs\n      // startInLoadingState\n      allowsBackForwardNavigationGestures\n      allowsFullscreenVideo\n      injectedJavaScriptBeforeContentLoaded={atStart}\n      // setSupportMultipleWindows={false}\n      onOpenWindow={(e) => {\n        const { targetUrl } = e.nativeEvent\n        if (targetUrl) {\n          openLink(targetUrl)\n        }\n      }}\n      onNavigationStateChange={onNavigationStateChange}\n      onLoadEnd={onLoadEnd}\n      onMessage={useTypeScriptHappyCallback(\n        (e) => {\n          const message = e.nativeEvent.data\n          const parsed = JSON.parse(message)\n          switch (parsed.type) {\n            case \"setContentHeight\": {\n              onContentHeightChange?.({\n                nativeEvent: { height: parsed.payload },\n              })\n              return\n            }\n            case \"previewImage\": {\n              const { imageUrls, index } = parsed.payload\n              runOnUI(() => {\n                \"worklet\"\n                // const rect = measureHandle(aviHandle)\n                runOnJS(openLightbox)({\n                  images: (imageUrls as string[]).map((url: string) => ({\n                    uri: url,\n                    dimensions: null,\n                    thumbUri: url,\n                    thumbDimensions: null,\n                    thumbRect: null,\n                    type: \"image\",\n                  })),\n                  index,\n                })\n              })()\n              return\n            }\n            case \"audio:seekTo\": {\n              const { time } = parsed.payload\n              if (typeof time !== \"number\") {\n                console.warn(\"Failed to seek audio! Invalid time\", time)\n                return\n              }\n              onSeekAudio?.({ time })\n              break\n            }\n            // No default\n          }\n        },\n        [onContentHeightChange, onSeekAudio, openLightbox],\n      )}\n    />\n  )\n}\n\nconst useWebViewNavigation = ({ webViewRef }: { webViewRef: RefObject<WebView | null> }) => {\n  const onNavigationStateChange = useCallback(\n    (newNavState: WebViewNavigation) => {\n      const { url: urlStr } = newNavState\n      let url = null\n      try {\n        url = new URL(urlStr)\n      } catch (error) {\n        console.warn(\"Invalid URL\", urlStr, error)\n        return\n      }\n      if (!url) return\n      if (url.protocol === \"file:\") return\n      // if (allowHosts.has(url.host)) return\n      webViewRef.current?.stopLoading()\n      // const formattedUrl = transformVideoUrl({ url: urlStr })\n      if (urlStr) {\n        openLink(urlStr)\n        return\n      }\n      openLink(urlStr)\n    },\n    [webViewRef],\n  )\n\n  return { onNavigationStateChange }\n}\n\nconst styles = {\n  // https://github.com/react-native-webview/react-native-webview/issues/318#issuecomment-503979211\n  webview: { backgroundColor: \"transparent\" },\n  webviewContainer: { width: \"100%\", backgroundColor: \"transparent\" },\n} as const\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/native-webview.tsx",
    "content": "import { requireNativeView } from \"expo\"\nimport type * as React from \"react\"\nimport type { ViewProps } from \"react-native\"\n\nexport const NativeWebView: React.ComponentType<\n  ViewProps & {\n    onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void\n    onSeekAudio?: (e: { time: number }) => void\n    url?: string\n  }\n> = requireNativeView(\"FOSharedWebView\")\n"
  },
  {
    "path": "apps/mobile/src/components/native/webview/webview-manager.ts",
    "content": "import type { EntryModel } from \"@follow/store/entry/types\"\n\nimport { SharedWebViewModule } from \"./index\"\n\n/**\n * WebView JavaScript bridge manager\n * Provides a centralized way to execute JavaScript functions in the WebView\n */\nexport const WebViewManager = {\n  /**\n   * Set code highlighting themes for light and dark modes\n   */\n  setCodeTheme(light: string, dark: string): void {\n    SharedWebViewModule.dispatch?.(\"setCodeTheme\", JSON.stringify({ light, dark }))\n  },\n\n  /**\n   * Set the current entry data in WebView\n   */\n  setEntry(entry?: EntryModel | null): void {\n    if (!entry) return\n    const json = JSON.stringify(entry)\n    SharedWebViewModule.dispatch?.(\"setEntry\", json)\n  },\n\n  /**\n   * Set root font size for the WebView\n   */\n  setRootFontSize(size = 16): void {\n    SharedWebViewModule.dispatch?.(\"setRootFontSize\", JSON.stringify(size))\n  },\n\n  /**\n   * Toggle media display in WebView\n   */\n  setNoMedia(value: boolean): void {\n    SharedWebViewModule.dispatch?.(\"setNoMedia\", JSON.stringify(value))\n  },\n\n  /**\n   * Set reader render inline style preference\n   */\n  setReaderRenderInlineStyle(value: boolean): void {\n    SharedWebViewModule.dispatch?.(\"setReaderRenderInlineStyle\", JSON.stringify(value))\n  },\n\n  /**\n   * Execute custom JavaScript code in WebView\n   */\n  executeScript(script: string): void {\n    SharedWebViewModule.dispatch?.(\"executeScript\", JSON.stringify(script))\n  },\n\n  /**\n   * Load a URL in the WebView\n   */\n  loadUrl(url: string): void {\n    SharedWebViewModule.load(url)\n  },\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/accordion/AccordionItem.tsx",
    "content": "import type { StyleProp, ViewStyle } from \"react-native\"\nimport { StyleSheet, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, {\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\n\nexport function AccordionItem({\n  isExpanded,\n  children,\n  viewKey,\n  style,\n  wrapperStyle,\n  wrapperClassName,\n}: {\n  isExpanded: SharedValue<boolean>\n  children: React.ReactNode\n  viewKey: string\n  style?: StyleProp<ViewStyle>\n  wrapperStyle?: StyleProp<ViewStyle>\n  wrapperClassName?: string\n  duration?: number\n}) {\n  const height = useSharedValue(0)\n\n  const derivedHeight = useDerivedValue(() =>\n    withSpring(height.value * Number(isExpanded.value), {\n      damping: 30,\n      stiffness: 100,\n    }),\n  )\n  const bodyStyle = useAnimatedStyle(() => ({\n    height: derivedHeight.value,\n  }))\n\n  return (\n    <Animated.View key={`accordionItem_${viewKey}`} style={[styles.animatedView, bodyStyle, style]}>\n      <View\n        onLayout={(e) => {\n          height.value = e.nativeEvent.layout.height\n        }}\n        style={[styles.wrapper, wrapperStyle]}\n        className={wrapperClassName}\n      >\n        {children}\n      </View>\n    </Animated.View>\n  )\n}\n\nconst styles = StyleSheet.create({\n  wrapper: {\n    width: \"100%\",\n    position: \"absolute\",\n    display: \"flex\",\n    flex: 1,\n  },\n  animatedView: {\n    width: \"100%\",\n    overflow: \"hidden\",\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/action-bar/ActionBarItem.tsx",
    "content": "import { clsx } from \"@follow/utils\"\nimport { cloneElement } from \"react\"\nimport { Pressable } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\ninterface ActiionBarItemProps {\n  onPress: () => void\n  children: React.JSX.Element\n  label: string\n  disabled?: boolean\n  active?: boolean\n  iconColor?: string\n}\nexport const ActionBarItem = ({\n  onPress,\n  children,\n  label,\n  disabled,\n  active,\n  iconColor,\n}: ActiionBarItemProps) => {\n  const labelColor = useColor(\"label\")\n  return (\n    <Pressable\n      hitSlop={10}\n      onPress={onPress}\n      accessibilityLabel={label}\n      disabled={disabled}\n      className={clsx(\n        active && \"bg-system-fill\",\n        disabled && \"opacity-50\",\n        \"-mt-1.5 rounded-lg p-2\",\n      )}\n    >\n      {cloneElement(children, { color: iconColor || labelColor, height: 20, width: 20 })}\n    </Pressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/avatar/UserAvatar.tsx",
    "content": "import { UserRole } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { Image as ExpoImage } from \"expo-image\"\nimport { useCallback } from \"react\"\nimport { Pressable, Text, View } from \"react-native\"\nimport { measure, runOnJS, runOnUI, useAnimatedRef } from \"react-native-reanimated\"\n\nimport { PowerIcon } from \"@/src/icons/power\"\nimport { User4CuteFiIcon } from \"@/src/icons/user_4_cute_fi\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { Image } from \"../image/Image\"\nimport { useLightboxControls } from \"../lightbox/lightboxState\"\n\ninterface UserAvatarProps {\n  image?: string | null\n  size?: number\n  name?: string | null\n  className?: string\n  color?: string\n  preview?: boolean\n  role?: UserRole | null\n}\nexport const UserAvatar = ({\n  image,\n  size = 24,\n  name,\n  className,\n  color,\n  preview = true,\n  role,\n}: UserAvatarProps) => {\n  const { openLightbox } = useLightboxControls()\n  const aviRef = useAnimatedRef<ExpoImage>()\n  const onPreview = useCallback(() => {\n    runOnUI(() => {\n      \"worklet\"\n\n      if (!image) {\n        return\n      }\n      const rect = measure(aviRef)\n      runOnJS(openLightbox)({\n        images: [\n          {\n            uri: image,\n            thumbUri: image,\n            thumbDimensions: null,\n            thumbRect: rect,\n            dimensions: rect\n              ? {\n                  height: rect.height,\n                  width: rect.width,\n                }\n              : null,\n            type: \"image\",\n          },\n        ],\n        index: 0,\n      })\n    })()\n  }, [aviRef, image, openLightbox])\n  const avatarBadge =\n    role && role !== UserRole.Free && role !== UserRole.Trial ? (\n      <View\n        className=\"absolute bottom-0 right-0 rounded-full\"\n        style={{\n          width: size / 3,\n          height: size / 3,\n        }}\n      >\n        <PowerIcon color={accentColor} width={size / 3} height={size / 3} />\n      </View>\n    ) : null\n  if (!image) {\n    return (\n      <View\n        className={cn(\n          \"items-center justify-center rounded-full\",\n          name && \"bg-secondary-system-background\",\n          className,\n        )}\n        style={{\n          width: size,\n          height: size,\n        }}\n      >\n        {name ? (\n          <Text\n            allowFontScaling={false}\n            className=\"p-2 text-center uppercase text-secondary-label\"\n            style={{\n              fontSize: size / 3,\n            }}\n            adjustsFontSizeToFit\n          >\n            {name.slice(0, 2)}\n          </Text>\n        ) : (\n          <User4CuteFiIcon width={size} height={size} color={color} />\n        )}\n        {avatarBadge}\n      </View>\n    )\n  }\n  const imageContent = (\n    <View className=\"relative\">\n      <Image\n        ref={aviRef}\n        source={{\n          uri: image,\n        }}\n        className={cn(\"rounded-full\", className)}\n        style={{\n          width: size,\n          height: size,\n        }}\n        proxy={{\n          width: size,\n          height: size,\n        }}\n      />\n      {avatarBadge}\n    </View>\n  )\n  return preview ? <Pressable onPress={onPreview}>{imageContent}</Pressable> : imageContent\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/button/UIBarButton.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { PressableProps } from \"react-native\"\nimport { Pressable } from \"react-native\"\nimport Animated, { FadeIn, FadeOut } from \"react-native-reanimated\"\n\ninterface UIBarButtonProps extends PressableProps {\n  label: string\n\n  selected?: boolean\n\n  normalIcon: React.ReactNode\n  selectedIcon?: React.ReactNode\n\n  overlay?: boolean\n}\n\nexport const UIBarButton = ({\n  normalIcon,\n  selectedIcon,\n  onPress,\n  selected,\n  label,\n  overlay = true,\n\n  ...props\n}: UIBarButtonProps) => {\n  const hasDifferentIcon = !!selectedIcon\n  return (\n    <Pressable\n      className={cn(\n        \"relative flex size-10 items-center justify-center\",\n        props.disabled && \"opacity-50\",\n      )}\n      onPress={onPress}\n      aria-label={label}\n      {...props}\n    >\n      {selected && overlay && <ButtonOverlay />}\n\n      {!hasDifferentIcon ? (\n        normalIcon\n      ) : (\n        <IconTransition\n          icon1={normalIcon}\n          icon2={selectedIcon}\n          current={selected ? \"icon2\" : \"icon1\"}\n        />\n      )}\n    </Pressable>\n  )\n}\n\nconst fadeInAnimation = FadeIn.springify().damping(10).stiffness(100)\nconst fadeOutAnimation = FadeOut.springify().damping(10).stiffness(100)\n\nconst ButtonOverlay = () => {\n  return (\n    <Animated.View\n      entering={fadeInAnimation}\n      exiting={fadeOutAnimation}\n      className=\"absolute inset-0 rounded-lg bg-system-fill\"\n    />\n  )\n}\n\nconst IconTransition = ({\n  icon1,\n  icon2,\n  current,\n}: {\n  icon1: React.ReactNode\n  icon2: React.ReactNode\n  current: \"icon1\" | \"icon2\"\n}) => {\n  return (\n    <Animated.View entering={fadeInAnimation} exiting={fadeOutAnimation} key={current}>\n      {current === \"icon1\" ? icon1 : icon2}\n    </Animated.View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/carousel/MediaCarousel.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { MediaModel } from \"@follow/database/schemas/types\"\nimport type { ImageSource } from \"expo-image\"\nimport type { Ref } from \"react\"\nimport { useEffect, useState } from \"react\"\nimport { ScrollView, View } from \"react-native\"\nimport Animated, {\n  interpolateColor,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\n\nimport { EntryGridFooter } from \"@/src/modules/entry-content/EntryGridFooter\"\n\nimport { Image } from \"../image/Image\"\nimport { ImageContextMenu } from \"../image/ImageContextMenu\"\nimport { getAllSources } from \"../image/utils\"\nimport { NativePressable } from \"../pressable/NativePressable\"\nimport { VideoPlayer } from \"../video/VideoPlayer\"\n\nconst getMediaKey = (mediaItem: MediaModel) =>\n  mediaItem.url || mediaItem.preview_image_url || `${mediaItem.type}-media`\n\nexport const MediaCarousel = ({\n  ref,\n  entryId,\n  media,\n  onPreview,\n  aspectRatio,\n  view,\n}: {\n  ref?: Ref<View>\n  entryId: string\n  media: MediaModel[]\n  onPreview?: (index: number, placeholder: ImageSource | undefined) => void\n  aspectRatio: number\n  view: FeedViewType\n}) => {\n  const [containerWidth, setContainerWidth] = useState(0)\n  const containerHeight = Math.floor(containerWidth / aspectRatio)\n  const hasMany = media.length > 1\n\n  // const activeIndex = useSharedValue(0)\n  const [activeIndex, setActiveIndex] = useState(0)\n\n  return (\n    <View\n      onLayout={(e) => {\n        setContainerWidth(e.nativeEvent.layout.width)\n      }}\n    >\n      <View ref={ref} className=\"relative overflow-hidden rounded-md\">\n        <ScrollView\n          onMomentumScrollEnd={(e) => {\n            setActiveIndex(Math.round(e.nativeEvent.contentOffset.x / containerWidth))\n          }}\n          scrollEnabled={hasMany}\n          horizontal\n          showsHorizontalScrollIndicator={false}\n          pagingEnabled\n          className=\"flex-1\"\n          // We need to fixed the height of the container to prevent the carousel from resizing\n          // See https://github.com/Shopify/flash-list/issues/797\n          style={{ height: containerHeight }}\n        >\n          {media.map((m, index) => {\n            const imageUrl = m.type === \"video\" ? m.preview_image_url : m.url\n            if (!imageUrl) {\n              return null\n            }\n            const proxy = {\n              height: 400,\n            }\n            const ImageItem = (\n              <NativePressable\n                onPress={() => {\n                  const [placeholder] = getAllSources({ uri: imageUrl }, proxy)\n                  onPreview?.(index, { blurhash: m.blurhash, ...placeholder })\n                }}\n              >\n                <Image\n                  proxy={proxy}\n                  source={{ uri: imageUrl }}\n                  blurhash={m.blurhash}\n                  className=\"w-full\"\n                  aspectRatio={aspectRatio}\n                  placeholderContentFit=\"cover\"\n                />\n              </NativePressable>\n            )\n\n            if (m.type === \"photo\") {\n              return (\n                <View\n                  key={imageUrl}\n                  className=\"relative\"\n                  style={{ width: containerWidth, height: containerHeight }}\n                >\n                  <ImageContextMenu entryId={entryId} imageUrl={imageUrl} view={view}>\n                    {ImageItem}\n                  </ImageContextMenu>\n                </View>\n              )\n            } else if (m.type === \"video\") {\n              return (\n                <ImageContextMenu key={imageUrl} entryId={entryId} imageUrl={imageUrl} view={view}>\n                  <VideoPlayer\n                    source={m.url}\n                    height={containerHeight}\n                    width={containerWidth}\n                    placeholder={ImageItem}\n                    view={view}\n                  />\n                </ImageContextMenu>\n              )\n            } else {\n              return null\n            }\n          })}\n        </ScrollView>\n\n        {/* Indicators */}\n        {hasMany && (\n          <View className=\"absolute inset-x-0 bottom-0 flex-row items-center justify-center gap-1\">\n            {media.map((mediaItem, mediaIndex) => (\n              <Indicator\n                key={`indicator-${getMediaKey(mediaItem)}`}\n                index={mediaIndex}\n                activeIndex={activeIndex}\n              />\n            ))}\n          </View>\n        )}\n      </View>\n      <EntryGridFooter entryId={entryId} view={view!} />\n    </View>\n  )\n}\n\nconst Indicator = ({ index, activeIndex }: { index: number; activeIndex: number }) => {\n  const activeValue = useSharedValue(0)\n  useEffect(() => {\n    activeValue.value = withSpring(index === activeIndex ? 1 : 0)\n  }, [activeIndex, activeValue, index])\n  const animatedStyle = useAnimatedStyle(() => ({\n    backgroundColor: interpolateColor(\n      activeValue.value,\n      [0, 1],\n      [\"rgba(0, 0, 0, 0.5)\", \"rgba(255, 255, 255, 0.9)\"],\n    ),\n  }))\n  return <Animated.View className=\"h-1 flex-1 rounded-sm\" style={animatedStyle} />\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/context-menu/index.tsx",
    "content": "import { composeEventHandlers } from \"@follow/utils\"\nimport { Vibration } from \"react-native\"\nimport * as ZeegoContextMenu from \"zeego/context-menu\"\n\nimport { isAndroid } from \"@/src/lib/platform\"\n\nexport * as DropdownMenu from \"zeego/dropdown-menu\"\n\nconst handleContextMenuOpenWithVibration = (open: boolean) => {\n  if (!isAndroid) return\n  if (open) {\n    Vibration.vibrate(10)\n  }\n}\n\nconst ContextMenuRoot: typeof ZeegoContextMenu.Root = (props) => {\n  return (\n    <ZeegoContextMenu.Root\n      {...props}\n      onOpenChange={composeEventHandlers(props.onOpenChange, handleContextMenuOpenWithVibration)}\n    >\n      {/* Add your context menu items here */}\n    </ZeegoContextMenu.Root>\n  )\n}\n\nconst ContextMenu = {\n  ...ZeegoContextMenu,\n  Root: ContextMenuRoot,\n}\n\nexport { ContextMenu }\n"
  },
  {
    "path": "apps/mobile/src/components/ui/datetime/RelativeDateTime.tsx",
    "content": "import dayjs from \"dayjs\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { TextProps } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport { NativePressable } from \"../pressable/NativePressable\"\n\nconst formatTemplateString = \"lll\"\n\nconst formatTime = (\n  date: string | Date,\n  relativeBeforeDay?: number,\n  template = formatTemplateString,\n) => {\n  if (relativeBeforeDay && Math.abs(dayjs(date).diff(new Date(), \"d\")) > relativeBeforeDay) {\n    return dayjs(date).format(template)\n  }\n  return dayjs.duration(dayjs(date).diff(dayjs(), \"minute\"), \"minute\").humanize()\n}\n\nconst getUpdateInterval = (date: string | Date, relativeBeforeDay?: number) => {\n  if (!relativeBeforeDay) return null\n  const diffInSeconds = Math.abs(dayjs(date).diff(new Date(), \"second\"))\n  if (diffInSeconds <= 60) {\n    return 1000 // Update every second\n  }\n  const diffInMinutes = Math.abs(dayjs(date).diff(new Date(), \"minute\"))\n  if (diffInMinutes <= 60) {\n    return 60000 // Update every minute\n  }\n  const diffInHours = Math.abs(dayjs(date).diff(new Date(), \"hour\"))\n  if (diffInHours <= 24) {\n    return 3600000 // Update every hour\n  }\n  const diffInDays = Math.abs(dayjs(date).diff(new Date(), \"day\"))\n  if (diffInDays <= relativeBeforeDay) {\n    return 86400000 // Update every day\n  }\n  return null // No need to update\n}\n\ninterface RelativeDateTimeProps extends TextProps {\n  date: string | Date\n  displayAbsoluteTimeAfterDay?: number\n  dateFormatTemplate?: string\n  postfixText?: string\n}\n\nexport const RelativeDateTime = ({\n  date,\n  displayAbsoluteTimeAfterDay = Infinity,\n  dateFormatTemplate,\n  postfixText,\n  ...props\n}: RelativeDateTimeProps) => {\n  const { t } = useTranslation(\"common\")\n  const [relative, setRelative] = useState<string>(() =>\n    formatTime(date, displayAbsoluteTimeAfterDay, dateFormatTemplate),\n  )\n\n  const memoizedFormatTime = useMemo(() => {\n    return dayjs(date).format(dateFormatTemplate ?? formatTemplateString)\n  }, [date, dateFormatTemplate])\n  const [mode, setMode] = useState<\"relative\" | \"absolute\">(\"relative\")\n  useEffect(() => {\n    if (mode === \"absolute\") return\n    if (!displayAbsoluteTimeAfterDay) return\n    setRelative(formatTime(date, displayAbsoluteTimeAfterDay, dateFormatTemplate))\n    const interval = setInterval(\n      () => {\n        setRelative(formatTime(date, displayAbsoluteTimeAfterDay, dateFormatTemplate))\n      },\n      getUpdateInterval(date, displayAbsoluteTimeAfterDay) ?? 1000,\n    )\n    return () => clearInterval(interval)\n  }, [date, displayAbsoluteTimeAfterDay, dateFormatTemplate, mode])\n\n  return (\n    <NativePressable\n      hitSlop={10}\n      onPress={() => {\n        setMode((mode) => (mode === \"relative\" ? \"absolute\" : \"relative\"))\n      }}\n    >\n      <Text key={mode} {...props}>\n        {mode === \"relative\"\n          ? `${relative}${t(\"space\")}${postfixText ?? t(\"words.ago\")}`\n          : memoizedFormatTime}\n      </Text>\n    </NativePressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/FormProvider.tsx",
    "content": "import { createContext, use } from \"react\"\nimport type { FieldValues, UseFormReturn } from \"react-hook-form\"\n\nconst FormContext = createContext<UseFormReturn<any> | null>(null)\n\nexport function FormProvider<T extends FieldValues>(props: {\n  form: UseFormReturn<T>\n  children: React.ReactNode\n}) {\n  return <FormContext value={props.form}>{props.children}</FormContext>\n}\n\nexport function useFormContext<T extends FieldValues>() {\n  const context = use(FormContext)\n  if (!context) {\n    throw new Error(\"useFormContext must be used within a FormProvider\")\n  }\n  return context as UseFormReturn<T>\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/Label.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const FormLabel: FC<\n  PropsWithChildren<{\n    label: string\n    optional?: boolean\n    className?: string\n    style?: StyleProp<ViewStyle>\n  }>\n> = ({ label, optional, className, style }) => {\n  return (\n    <View className={cn(\"flex-row\", className)} style={style}>\n      <Text className=\"font-medium capitalize text-label\">{label}</Text>\n      {!optional && <Text className=\"ml-1 align-sub text-red\">*</Text>}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/PickerIos.tsx",
    "content": "/* eslint-disable @eslint-react/no-array-index-key */\nimport { cn } from \"@follow/utils\"\nimport { Portal } from \"@gorhom/portal\"\nimport { Picker } from \"@react-native-picker/picker\"\nimport { useMemo, useState } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { Pressable, View } from \"react-native\"\nimport Animated, { SlideOutDown } from \"react-native-reanimated\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { MingcuteDownLineIcon } from \"@/src/icons/mingcute_down_line\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { BlurEffect } from \"../../common/BlurEffect\"\n\ninterface PickerIosProps<T> {\n  options: {\n    label: string\n    value: T\n  }[]\n  value: T\n  onValueChange: (value: T) => void\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n}\nexport function PickerIos<T>({\n  options,\n  value,\n  onValueChange,\n  wrapperClassName,\n  wrapperStyle,\n}: PickerIosProps<T>) {\n  const [isOpen, setIsOpen] = useState(false)\n  const [currentValue, setCurrentValue] = useState(() => {\n    if (!value) {\n      return options[0]!.value\n    }\n    return value\n  })\n  const valueToLabelMap = useMemo(() => {\n    return options.reduce((acc, option) => {\n      acc.set(option.value, option.label)\n      return acc\n    }, new Map<T, string>())\n  }, [options])\n  const handleChangeValue = useEventCallback((value: T) => {\n    setCurrentValue(value)\n    onValueChange(value)\n  })\n  const systemFill = useColor(\"text\")\n  return (\n    <>\n      {/* Trigger */}\n      <Pressable onPress={() => setIsOpen(!isOpen)}>\n        <View\n          className={cn(\n            \"h-10 flex-row items-center rounded-lg border border-system-fill/80 bg-system-fill/30 pl-4 pr-2\",\n            wrapperClassName,\n          )}\n          style={wrapperStyle}\n        >\n          <Text className=\"text-text\">{valueToLabelMap.get(currentValue)}</Text>\n          <View className=\"ml-auto shrink-0\">\n            <MingcuteDownLineIcon color={systemFill} height={16} width={16} />\n          </View>\n        </View>\n      </Pressable>\n      {/* Picker */}\n      {isOpen && (\n        <Portal>\n          <Pressable\n            onPress={() => setIsOpen(false)}\n            className=\"absolute inset-0 flex flex-row items-end\"\n          >\n            <Animated.View className=\"relative flex-1\" exiting={SlideOutDown}>\n              <BlurEffect />\n              <Pressable onPress={(e) => e.stopPropagation()}>\n                <Picker selectedValue={currentValue} onValueChange={handleChangeValue}>\n                  {options.map((option, index) => (\n                    <Picker.Item key={index} label={option.label} value={option.value} />\n                  ))}\n                </Picker>\n              </Pressable>\n            </Animated.View>\n          </Pressable>\n        </Portal>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/Select.android.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { FlashList } from \"@shopify/flash-list\"\nimport { useCallback, useState } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { Pressable, StyleSheet, View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckFilledIcon } from \"@/src/icons/check_filled\"\nimport { MingcuteDownLineIcon } from \"@/src/icons/mingcute_down_line\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport { BottomModal } from \"../modal/BottomModal\"\nimport { FormLabel } from \"./Label\"\n\ninterface SelectProps<T> {\n  options: {\n    label: string\n    value: T\n    subLabel?: string\n  }[]\n  value: T\n  onValueChange: (value: T) => void\n  displayValue?: string\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n  label?: string\n  disabled?: boolean\n}\nexport function Select<T>({\n  options,\n  value,\n  onValueChange,\n  displayValue,\n  wrapperClassName,\n  wrapperStyle,\n  label,\n  disabled,\n}: SelectProps<T>) {\n  const grayColor = useColor(\"gray\")\n  const [isModalVisible, setModalVisible] = useState(false)\n  const selectedOption = options.find((opt) => opt.value === value)\n  const insets = useSafeAreaInsets()\n  const showOptions = useCallback(() => {\n    setModalVisible(true)\n  }, [])\n  const closeModal = useCallback(() => {\n    setModalVisible(false)\n  }, [])\n  const handleSelectOption = useCallback(\n    (optionValue: T) => {\n      onValueChange(optionValue)\n      closeModal()\n    },\n    [onValueChange, closeModal],\n  )\n  const renderOption = useCallback(\n    ({\n      item,\n    }: {\n      item: {\n        label: string\n        value: T\n        subLabel?: string\n      }\n    }) => {\n      const isSelected = value === item.value\n      return (\n        <>\n          <Pressable\n            onPress={() => handleSelectOption(item.value)}\n            className=\"flex-row items-center justify-between p-4\"\n            disabled={disabled}\n          >\n            <View className=\"flex-1\">\n              <Text\n                className={cn(\"text-base text-label\", isSelected ? \"font-semibold\" : \"font-normal\")}\n              >\n                {item.label}\n              </Text>\n              {item.subLabel ? (\n                <Text className=\"mt-0.5 text-xs text-secondary-label\">{item.subLabel}</Text>\n              ) : null}\n            </View>\n            {isSelected && (\n              <View className=\"ml-2\">\n                <CheckFilledIcon color={accentColor} height={20} width={20} />\n              </View>\n            )}\n          </Pressable>\n          <View\n            className={cn(\"mb-px ml-4 bg-opaque-separator/70\")}\n            style={{\n              height: StyleSheet.hairlineWidth,\n            }}\n          />\n        </>\n      )\n    },\n    [handleSelectOption, value, disabled],\n  )\n  const Trigger = (\n    <Pressable\n      className={cn(\n        \"min-w-24 flex-1 shrink flex-row items-center rounded-lg pl-3\",\n        disabled ? \"opacity-50\" : \"\",\n        wrapperClassName,\n      )}\n      hitSlop={30}\n      onPress={showOptions}\n      disabled={disabled}\n      style={wrapperStyle}\n    >\n      <Text\n        className={cn(\"flex-1 text-right font-semibold\", disabled ? \"text-gray\" : \"text-accent\")}\n        ellipsizeMode=\"tail\"\n        numberOfLines={1}\n      >\n        {displayValue || selectedOption?.label || \"Select\"}\n      </Text>\n      <View className=\"ml-auto shrink-0 pl-1\">\n        <MingcuteDownLineIcon color={disabled ? grayColor : accentColor} height={18} width={18} />\n      </View>\n    </Pressable>\n  )\n  const SelectModal = (\n    <BottomModal visible={isModalVisible} onClose={closeModal}>\n      <View className=\"border-b-hairline flex-row items-center justify-between border-opaque-separator p-4\">\n        <Text className=\"text-xl font-semibold text-label\">Select an option</Text>\n        <Pressable onPress={closeModal}>\n          <Text\n            style={{\n              color: accentColor,\n            }}\n            className=\"text-lg font-bold\"\n          >\n            Done\n          </Text>\n        </Pressable>\n      </View>\n\n      <FlashList\n        data={options}\n        renderItem={renderOption}\n        keyExtractor={(item) => String(item.value)}\n        className=\"grow\"\n        style={{\n          marginBottom: insets.bottom + 16,\n        }}\n      />\n    </BottomModal>\n  )\n  if (!label) {\n    return (\n      <>\n        {Trigger}\n        {SelectModal}\n      </>\n    )\n  }\n  return (\n    <>\n      <View className=\"flex-1 flex-row items-center justify-between\">\n        <FormLabel className=\"pl-2\" label={label} />\n        <View className=\"flex-1\">{Trigger}</View>\n      </View>\n      {SelectModal}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/Select.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\nimport * as DropdownMenu from \"zeego/dropdown-menu\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { MingcuteDownLineIcon } from \"@/src/icons/mingcute_down_line\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport { FormLabel } from \"./Label\"\n\ninterface SelectProps<T> {\n  options: {\n    label: string\n    value: T\n    subLabel?: string\n  }[]\n  value: T\n  onValueChange: (value: T) => void\n  displayValue?: string\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n  label?: string\n  disabled?: boolean\n}\nexport function Select<T>({\n  options,\n  value,\n  onValueChange,\n  displayValue,\n  wrapperClassName,\n  wrapperStyle,\n  label,\n  disabled,\n}: SelectProps<T>) {\n  const [currentValue, setCurrentValue] = useState(() => value)\n  useEffect(() => {\n    setCurrentValue(value)\n  }, [value])\n  const valueToLabelMap = useMemo(() => {\n    return options.reduce((acc, option) => {\n      acc.set(option.value, option.label)\n      return acc\n    }, new Map<T, string>())\n  }, [options])\n  const grayColor = useColor(\"gray\")\n  const Trigger = (\n    <DropdownMenu.Root>\n      <DropdownMenu.Trigger asChild disabled={disabled}>\n        <View\n          className={cn(\n            \"min-w-24 flex-1 shrink flex-row items-center rounded-lg pl-3\",\n            disabled && \"opacity-50\",\n            wrapperClassName,\n          )}\n          style={wrapperStyle}\n        >\n          <Text\n            className={cn(\"flex-1 text-right font-semibold text-accent\", disabled && \"text-gray\")}\n            ellipsizeMode=\"tail\"\n            numberOfLines={1}\n          >\n            {displayValue || valueToLabelMap.get(currentValue) || \"Select\"}\n          </Text>\n          <View className=\"ml-auto shrink-0 pl-1\">\n            <MingcuteDownLineIcon\n              color={disabled ? grayColor : accentColor}\n              height={18}\n              width={18}\n            />\n          </View>\n        </View>\n      </DropdownMenu.Trigger>\n\n      <DropdownMenu.Content>\n        {options.map((option) => {\n          const isSelected = currentValue === option.value\n          const handleSelect = () => {\n            setCurrentValue(option.value)\n            onValueChange(option.value)\n          }\n          return (\n            <DropdownMenu.CheckboxItem\n              key={option.label}\n              value={isSelected}\n              onSelect={handleSelect}\n            >\n              <DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>\n              {!!option.subLabel && (\n                <DropdownMenu.ItemSubtitle>{option.subLabel}</DropdownMenu.ItemSubtitle>\n              )}\n            </DropdownMenu.CheckboxItem>\n          )\n        })}\n      </DropdownMenu.Content>\n    </DropdownMenu.Root>\n  )\n  if (!label) {\n    return Trigger\n  }\n  return (\n    <View className=\"flex-1 flex-row items-center justify-between\">\n      <FormLabel className=\"pl-2\" label={label} />\n      <View className=\"flex-1\">{Trigger}</View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/Slider.tsx",
    "content": "import type { StyleProp, ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport type { SliderProps, SliderRef } from \"../slider/Slider\"\nimport { Slider } from \"../slider/Slider\"\nimport { FormLabel } from \"./Label\"\n\ninterface Props {\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n  label?: string\n  description?: string\n\n  /**\n   * Display the current value next to the label\n   */\n  showValue?: boolean\n\n  /**\n   * Custom formatter for the displayed value\n   */\n  valueFormatter?: (value: number) => string\n}\nexport const FormSlider = ({\n  ref,\n  wrapperClassName,\n  wrapperStyle,\n  label,\n  description,\n  showValue = false,\n  valueFormatter,\n  value = 0,\n  ...rest\n}: Props &\n  SliderProps & {\n    ref?: React.Ref<SliderRef | null>\n  }) => {\n  const SliderComponent = <Slider value={value} ref={ref} {...rest} />\n  const formatValue = (val: number) => {\n    if (valueFormatter) {\n      return valueFormatter(val)\n    }\n    return val.toFixed(2)\n  }\n  if (!label) {\n    return SliderComponent\n  }\n  return (\n    <View className={wrapperClassName} style={wrapperStyle}>\n      <View className=\"mb-3 flex-row items-center justify-between\">\n        <FormLabel label={label} optional />\n        {showValue && (\n          <Text className=\"text-sm font-medium text-secondary-label\">{formatValue(value)}</Text>\n        )}\n      </View>\n\n      {!!description && <Text className=\"mb-3 text-sm text-secondary-label\">{description}</Text>}\n\n      {SliderComponent}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/Switch.tsx",
    "content": "import type { StyleProp, ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport type { SwitchProps, SwitchRef } from \"../switch/Switch\"\nimport { Switch } from \"../switch/Switch\"\nimport { FormLabel } from \"./Label\"\n\ninterface Props {\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n  label?: string\n  description?: string\n  size?: \"sm\" | \"default\"\n}\nexport const FormSwitch = ({\n  ref,\n  wrapperClassName,\n  wrapperStyle,\n  label,\n  description,\n  size = \"default\",\n  ...rest\n}: Props &\n  SwitchProps & {\n    ref?: React.Ref<SwitchRef | null>\n  }) => {\n  const Trigger = <Switch size={size} ref={ref} {...rest} />\n  if (!label) {\n    return Trigger\n  }\n  return (\n    <View className={\"w-full flex-row\"}>\n      <View className=\"flex-1 gap-1\">\n        <FormLabel className=\"pl-1\" label={label} optional />\n        {!!description && (\n          <Text className=\"mb-1 pl-1 text-sm text-secondary-label\">{description}</Text>\n        )}\n      </View>\n      {Trigger}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/form/TextField.tsx",
    "content": "import { composeEventHandlers } from \"@follow/utils\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useEffect, useImperativeHandle, useRef, useState } from \"react\"\nimport type { StyleProp, TextInputProps, ViewStyle } from \"react-native\"\nimport { Pressable, StyleSheet, TextInput, View } from \"react-native\"\nimport Animated, { useAnimatedStyle, useSharedValue, withSpring } from \"react-native-reanimated\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { gentleSpringPreset } from \"@/src/constants/spring\"\nimport { CloseCircleFillIcon } from \"@/src/icons/close_circle_fill\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport { FormLabel } from \"./Label\"\n\ninterface BaseFieldProps {\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n  label?: string\n  description?: string\n  required?: boolean\n  inputPostfixElement?: React.ReactNode\n}\nconst BaseField = ({\n  ref,\n  className,\n  style,\n  wrapperClassName,\n  wrapperStyle,\n  label,\n  description,\n  required,\n  inputPostfixElement,\n  ...rest\n}: TextInputProps &\n  BaseFieldProps & {\n    ref?: React.Ref<TextInput | null>\n  }) => {\n  return (\n    <View className=\"w-full flex-1 gap-1\">\n      {!!label && <FormLabel className=\"pl-2.5\" label={label} optional={!required} />}\n      {!!description && (\n        <Text className=\"mb-1 pl-2.5 text-sm text-secondary-label\">{description}</Text>\n      )}\n      <View\n        className={cn(\n          \"relative h-10 flex-row items-center rounded-lg bg-tertiary-system-fill px-3\",\n          wrapperClassName,\n        )}\n        style={wrapperStyle}\n      >\n        <TextInput\n          selectionColor={accentColor}\n          ref={ref}\n          className={cn(\"w-full flex-1 p-0 text-label placeholder:text-secondary-label\", className)}\n          style={StyleSheet.flatten([styles.textField, style])}\n          {...rest}\n        />\n        {inputPostfixElement}\n      </View>\n    </View>\n  )\n}\nexport const TextField = ({\n  ref,\n  ...props\n}: TextInputProps &\n  BaseFieldProps & {\n    ref?: React.Ref<TextInput | null>\n  }) => <BaseField {...props} ref={ref} />\ninterface NumberFieldProps extends BaseFieldProps {\n  value?: number\n  onChangeNumber?: (value: number) => void\n  defaultValue?: number\n}\nexport const NumberField = ({\n  ref,\n  value,\n  onChangeNumber,\n  defaultValue,\n  ...rest\n}: Omit<TextInputProps, \"keyboardType\" | \"value\" | \"onChangeText\" | \"defaultValue\"> &\n  NumberFieldProps & {\n    ref?: React.Ref<TextInput | null>\n  }) => (\n  <BaseField\n    {...rest}\n    ref={ref}\n    keyboardType=\"number-pad\"\n    value={value?.toString()}\n    onChangeText={(text) => onChangeNumber?.(Math.min(Number(text), Number.MAX_SAFE_INTEGER))}\n    defaultValue={defaultValue?.toString()}\n  />\n)\nexport const PlainTextField = ({\n  ref,\n  ...props\n}: TextInputProps & {\n  ref?: React.Ref<TextInput | null>\n}) => {\n  const secondaryLabelColor = useColor(\"secondaryLabel\")\n  const [isFocused, setIsFocused] = useState(false)\n  const textInputRef = useRef<TextInput>(null)\n  useImperativeHandle(ref, () => textInputRef.current!)\n  const pressableButtonXSharedValue = useSharedValue(20)\n  const pressableButtonOpacity = useSharedValue(0)\n  const animatedStyle = useAnimatedStyle(() => {\n    return {\n      transform: [\n        {\n          translateX: pressableButtonXSharedValue.value,\n        },\n      ],\n      opacity: pressableButtonOpacity.value,\n      position: \"absolute\",\n      right: 0,\n    }\n  })\n  const pressableWidthSharedValue = useSharedValue(isFocused ? 20 : 0)\n  useEffect(() => {\n    if (isFocused) {\n      pressableButtonXSharedValue.value = withSpring(0, gentleSpringPreset)\n      pressableButtonOpacity.value = withSpring(1, gentleSpringPreset)\n      pressableWidthSharedValue.value = withSpring(20, gentleSpringPreset)\n    } else {\n      pressableButtonXSharedValue.value = withSpring(20, gentleSpringPreset)\n      pressableButtonOpacity.value = withSpring(0, gentleSpringPreset)\n      pressableWidthSharedValue.value = withSpring(0, gentleSpringPreset)\n    }\n  }, [isFocused, pressableButtonXSharedValue, pressableButtonOpacity, pressableWidthSharedValue])\n  return (\n    <View className=\"flex-1 flex-row items-center\">\n      <TextInput\n        {...props}\n        ref={textInputRef}\n        onChange={props.onChange}\n        onChangeText={props.onChangeText}\n        onFocus={composeEventHandlers(props.onFocus, () => setIsFocused(true))}\n        onBlur={composeEventHandlers(props.onBlur, () => setIsFocused(false))}\n        selectionColor={accentColor}\n        className={cn(\"w-full flex-1 text-label placeholder:text-secondary-label\", props.className)}\n      />\n\n      <Animated.View\n        className=\"ml-2 shrink-0\"\n        style={{\n          width: pressableWidthSharedValue,\n        }}\n      />\n      <Animated.View style={animatedStyle}>\n        <Pressable\n          onPress={() => {\n            textInputRef.current?.clear()\n            props.onChangeText?.(\"\")\n          }}\n        >\n          <CloseCircleFillIcon height={16} width={16} color={secondaryLabelColor} />\n        </Pressable>\n      </Animated.View>\n    </View>\n  )\n}\nconst styles = StyleSheet.create({\n  textField: {\n    fontSize: 16,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/grid/index.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { useMemo } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\n\ninterface GridProps {\n  columns: number\n  gap: number\n\n  style?: StyleProp<ViewStyle>\n  className?: string\n}\ntype GridCell = {\n  key: string\n  node: React.ReactNode\n}\n\ntype GridRow = {\n  key: string\n  cells: GridCell[]\n}\n\nconst createPlaceholderCell = (key: string): GridCell => ({\n  key,\n  node: <View className=\"flex-1 shrink-0\" />,\n})\n\nexport const Grid = ({\n  columns,\n  gap,\n  children,\n  style,\n  className,\n}: GridProps & PropsWithChildren) => {\n  if (columns < 1) {\n    throw new Error(\"Columns must be greater than 0\")\n  }\n  const rows = useMemo<GridRow[]>(() => {\n    const cells: GridCell[] = []\n    let fallbackCellIndex = 0\n\n    const appendChild = (child: React.ReactNode) => {\n      if (Array.isArray(child)) {\n        for (const childItem of child) {\n          appendChild(childItem)\n        }\n        return\n      }\n      if (child === null || child === undefined) return\n      const key =\n        React.isValidElement(child) && child.key != null\n          ? String(child.key)\n          : `cell-${fallbackCellIndex++}`\n      cells.push({\n        key,\n        node: child,\n      })\n    }\n\n    appendChild(children)\n\n    if (cells.length === 0) return []\n\n    const nextRows: GridRow[] = []\n    let placeholderIndex = 0\n    for (let start = 0; start < cells.length; start += columns) {\n      const rowCells = cells.slice(start, start + columns)\n      while (rowCells.length < columns) {\n        rowCells.push(createPlaceholderCell(`placeholder-${placeholderIndex++}`))\n      }\n      nextRows.push({\n        key: `row-${rowCells.map((cell) => cell.key).join(\"-\")}`,\n        cells: rowCells,\n      })\n    }\n    return nextRows\n  }, [children, columns])\n\n  return (\n    <View className={cn(\"w-full flex-1\", className)} style={[{ gap }, style]}>\n      {rows.map((row) => (\n        <View key={row.key} className=\"flex flex-row\" style={{ gap }}>\n          {row.cells.map((cell) => (\n            <View key={cell.key} className=\"flex-1 shrink-0\">\n              {cell.node}\n            </View>\n          ))}\n        </View>\n      ))}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/grouped/GroupedInsetListCardItemStyle.tsx",
    "content": "export enum GroupedInsetListCardItemStyle {\n  NavigationLink = \"NavigationLink\",\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/grouped/GroupedList.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { SymbolView } from \"expo-symbols\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { Fragment } from \"react\"\nimport type { PressableProps, ViewProps } from \"react-native\"\nimport { Pressable, StyleSheet, View } from \"react-native\"\nimport Animated, { FadeIn, FadeOut } from \"react-native-reanimated\"\nimport type { SFSymbol } from \"sf-symbols-typescript\"\nimport { titleCase } from \"title-case\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckFilledIcon } from \"@/src/icons/check_filled\"\nimport { MingcuteRightLine } from \"@/src/icons/mingcute_right_line\"\nimport { useIsTabletLayout } from \"@/src/lib/responsive\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport { PlatformActivityIndicator } from \"../loading/PlatformActivityIndicator\"\nimport {\n  GROUPED_ICON_TEXT_GAP,\n  GROUPED_LIST_ITEM_PADDING,\n  GROUPED_LIST_MARGIN,\n  GROUPED_SECTION_BOTTOM_MARGIN,\n  GROUPED_SECTION_TOP_MARGIN,\n} from \"./constants\"\nimport { GroupedInsetListCardItemStyle } from \"./GroupedInsetListCardItemStyle\"\n\nconst GROUPED_TABLET_MAX_WIDTH = 760\n\ninterface GroupedInsetListCardProps {\n  showSeparator?: boolean\n  SeparatorComponent?: FC\n  SeparatorElement?: React.ReactNode\n}\ninterface BaseCellClassNames {\n  className?: string\n  leftClassName?: string\n  rightClassName?: string\n}\nexport const GroupedOutlineDescription: FC<{\n  description: string\n}> = ({ description }) => {\n  return <Text className=\"mx-9 mt-2 text-xs text-secondary-label\">{description}</Text>\n}\nexport const GroupedInsetListCard: FC<\n  PropsWithChildren & ViewProps & GroupedInsetListCardProps\n> = ({\n  children,\n  className,\n  showSeparator = true,\n  SeparatorComponent,\n  SeparatorElement,\n  ...props\n}) => {\n  const nextChildren = React.useMemo(\n    () => React.Children.toArray(children).filter(Boolean),\n    [children],\n  )\n  const isTablet = useIsTabletLayout()\n  return (\n    <View\n      {...props}\n      style={[\n        isTablet && {\n          width: \"100%\",\n          maxWidth: GROUPED_TABLET_MAX_WIDTH,\n          alignSelf: \"center\",\n        },\n        {\n          marginHorizontal: GROUPED_LIST_MARGIN,\n        },\n        props.style,\n      ]}\n      className={cn(\n        \"flex flex-1 flex-col overflow-hidden rounded-[10px] bg-secondary-system-grouped-background\",\n        className,\n      )}\n    >\n      {showSeparator\n        ? nextChildren.map((child, index) => {\n            const isLast = index === nextChildren.length - 1\n            if (child === null) return null\n            const isNavigationLink =\n              React.isValidElement(child) &&\n              // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n              ((child.type as Function).name === GroupedInsetListNavigationLink.name ||\n                (child.type as any).itemStyle === GroupedInsetListCardItemStyle.NavigationLink)\n            const NextSeparatorComponent =\n              typeof SeparatorComponent === \"function\" ? <SeparatorComponent /> : undefined\n            const NextSeparatorElement = SeparatorElement\n              ? React.isValidElement(SeparatorElement)\n                ? SeparatorElement\n                : NextSeparatorComponent\n              : NextSeparatorComponent\n            return (\n              <Fragment key={typeof child === \"object\" && \"key\" in child ? child.key : index}>\n                {child}\n                {!isLast &&\n                  (NextSeparatorElement ?? (\n                    <View\n                      className={cn(\"bg-opaque-separator/70\", isNavigationLink ? \"ml-16\" : \"ml-4\")}\n                      style={{\n                        height: StyleSheet.hairlineWidth,\n                      }}\n                    />\n                  ))}\n              </Fragment>\n            )\n          })\n        : children}\n    </View>\n  )\n}\nexport const GroupedInsetListSectionHeader: FC<{\n  label: string\n  marginSize?: \"normal\" | \"small\"\n}> = ({ label, marginSize = \"normal\" }) => {\n  const isTablet = useIsTabletLayout()\n  return (\n    <View\n      style={{\n        width: isTablet ? \"100%\" : undefined,\n        maxWidth: isTablet ? GROUPED_TABLET_MAX_WIDTH : undefined,\n        alignSelf: isTablet ? \"center\" : undefined,\n        paddingHorizontal: GROUPED_LIST_ITEM_PADDING,\n        marginHorizontal: GROUPED_LIST_MARGIN,\n        marginTop:\n          marginSize === \"normal\" ? GROUPED_SECTION_TOP_MARGIN : GROUPED_SECTION_TOP_MARGIN / 2,\n        marginBottom: GROUPED_SECTION_BOTTOM_MARGIN,\n      }}\n    >\n      <Text className=\"text-sm text-secondary-label\" ellipsizeMode=\"tail\" numberOfLines={1}>\n        {label}\n      </Text>\n    </View>\n  )\n}\nexport const GroupedInsetListBaseCell: FC<\n  PropsWithChildren &\n    ViewProps & {\n      as?: FC<any>\n    }\n> = ({ children, as, ...props }) => {\n  const Component = as ?? View\n  return (\n    <Component\n      {...props}\n      className={cn(\"flex-row items-center justify-between py-4\", props.className)}\n      style={[\n        {\n          paddingHorizontal: GROUPED_LIST_ITEM_PADDING,\n        },\n        props.style,\n      ]}\n    >\n      {children}\n    </Component>\n  )\n}\nexport const GroupedInsetListNavigationLink: FC<\n  {\n    label: string\n    icon?: React.ReactNode\n    onPress: () => void\n    disabled?: boolean\n    postfix?: React.ReactNode\n    testID?: string\n  } & BaseCellClassNames\n> = ({\n  label,\n  icon,\n  onPress,\n  disabled,\n  className,\n  leftClassName,\n  rightClassName,\n  postfix,\n  testID,\n}) => {\n  const rightIconColor = useColor(\"tertiaryLabel\")\n  return (\n    <Pressable testID={testID} onPress={onPress} disabled={disabled} className={className}>\n      {({ pressed }) => (\n        <GroupedInsetListBaseCell\n          className={cn(pressed ? \"bg-system-fill\" : undefined, disabled && \"opacity-40\")}\n        >\n          <View className={cn(\"flex-1 flex-row items-center justify-between\", leftClassName)}>\n            <View className=\"flex-row items-center\">\n              {icon}\n              <Text className={\"text-sm text-label\"}>{label}</Text>\n            </View>\n            <View className={cn(\"-mr-2 ml-4 flex-row\", rightClassName)}>\n              {postfix}\n              <MingcuteRightLine height={18} width={18} color={rightIconColor} />\n            </View>\n          </View>\n        </GroupedInsetListBaseCell>\n      )}\n    </Pressable>\n  )\n}\nexport const GroupedInsetListNavigationLinkIcon: FC<\n  {\n    backgroundColor: string\n  } & PropsWithChildren\n> = ({ backgroundColor, children }) => {\n  return (\n    <View\n      className=\"items-center justify-center rounded-[5px] p-1\"\n      style={{\n        marginRight: GROUPED_ICON_TEXT_GAP,\n        backgroundColor,\n      }}\n    >\n      {children}\n    </View>\n  )\n}\nexport const GroupedInsetListCell: FC<\n  {\n    label: string\n    description?: string\n    children?: React.ReactNode\n    icon?: SFSymbol\n    onPress?: () => void\n  } & BaseCellClassNames\n> = ({ label, description, children, className, leftClassName, rightClassName, icon, onPress }) => {\n  return (\n    <GroupedInsetListBaseCell\n      className={cn(\"flex flex-1 bg-secondary-system-grouped-background\", className)}\n      as={onPress ? Pressable : undefined}\n      {...(onPress\n        ? {\n            onPress,\n          }\n        : {})}\n    >\n      <View className={cn(\"flex-1 gap-1\", leftClassName)}>\n        <View className=\"flex-row items-center gap-2\">\n          {!!icon && <SymbolView name={icon} size={20} tintColor=\"black\" />}\n          <Text className=\"text-sm text-label\">{titleCase(label)}</Text>\n        </View>\n        {!!description && (\n          <Text className=\"text-xs leading-tight text-secondary-label\">{description}</Text>\n        )}\n      </View>\n\n      <View className={cn(\"mb-auto ml-4 shrink-0\", rightClassName)}>{children}</View>\n    </GroupedInsetListBaseCell>\n  )\n}\nexport const GroupedInsetListActionCellRadio: FC<{\n  label: string\n  description?: string\n  onPress?: () => void\n  disabled?: boolean\n  selected?: boolean\n}> = ({ label, description, onPress, disabled, selected }) => {\n  return (\n    <Pressable onPress={onPress} disabled={disabled}>\n      {({ pressed }) => (\n        <GroupedInsetListBaseCell\n          className={cn(pressed ? \"bg-system-fill\" : undefined, disabled && \"opacity-40\")}\n        >\n          <View className=\"flex-1\">\n            <Text className=\"text-sm text-label\">{label}</Text>\n            {!!description && (\n              <Text className=\"text-xs leading-tight text-secondary-label\">{description}</Text>\n            )}\n          </View>\n\n          <View className=\"ml-4 size-[18px]\">\n            {selected && <CheckFilledIcon height={18} width={18} color={accentColor} />}\n          </View>\n        </GroupedInsetListBaseCell>\n      )}\n    </Pressable>\n  )\n}\nconst OverlayInterectionPressable = ({\n  children,\n  ...props\n}: PropsWithChildren & PressableProps) => {\n  return (\n    <Pressable {...props} className={cn(\"flex-1\", props.className)}>\n      {({ pressed }) => {\n        return (\n          <>\n            {/* Pressed Overlay Effect */}\n            {pressed && (\n              <Animated.View\n                className=\"absolute inset-0 bg-system-fill\"\n                entering={FadeIn.duration(100)}\n                exiting={FadeOut.duration(100)}\n              />\n            )}\n\n            {children}\n          </>\n        )\n      }}\n    </Pressable>\n  )\n}\nexport const GroupedInsetListActionCell: FC<{\n  label: string\n  description?: string\n  onPress?: () => void\n  disabled?: boolean\n  icon?: SFSymbol\n}> = ({ label, description, onPress, disabled, icon }) => {\n  const rightIconColor = useColor(\"tertiaryLabel\")\n  return (\n    <Pressable\n      onPress={onPress}\n      disabled={disabled}\n      className=\"bg-secondary-system-grouped-background\"\n    >\n      {({ pressed }) => (\n        <GroupedInsetListBaseCell\n          className={cn(pressed ? \"bg-system-fill\" : undefined, disabled && \"opacity-40\")}\n        >\n          <View className=\"flex-1\">\n            <View className=\"flex-row items-center gap-2\">\n              {!!icon && <SymbolView name={icon} size={20} tintColor=\"black\" />}\n              <Text className=\"text-sm text-label\">{label}</Text>\n            </View>\n            {!!description && (\n              <Text className=\"text-xs leading-tight text-secondary-label\">{description}</Text>\n            )}\n          </View>\n\n          <View className=\"-mr-2 ml-4\">\n            <MingcuteRightLine height={18} width={18} color={rightIconColor} />\n          </View>\n        </GroupedInsetListBaseCell>\n      )}\n    </Pressable>\n  )\n}\nexport const GroupedInsetButtonCell: FC<{\n  label: string\n  onPress?: () => void\n  disabled?: boolean\n  style?: \"destructive\" | \"primary\"\n}> = ({ label, onPress, disabled, style = \"primary\" }) => {\n  return (\n    <Pressable onPress={onPress} disabled={disabled}>\n      {({ pressed }) => (\n        <GroupedInsetListBaseCell\n          className={cn(pressed ? \"bg-system-fill\" : undefined, disabled && \"opacity-40\")}\n        >\n          <View className=\"flex-1 items-center justify-center\">\n            <Text className={`text-sm ${style === \"destructive\" ? \"text-red\" : \"text-label\"}`}>\n              {label}\n            </Text>\n          </View>\n        </GroupedInsetListBaseCell>\n      )}\n    </Pressable>\n  )\n}\nexport const GroupedInformationCell: FC<{\n  title: string\n  description?: string\n  icon?: React.ReactNode\n  iconBackgroundColor?: string\n  children?: React.ReactNode\n}> = ({ title, description, icon, iconBackgroundColor, children }) => {\n  return (\n    <GroupedInsetListBaseCell className=\"flex-1 flex-col items-center justify-center rounded-[16px] p-6\">\n      {!!icon && (\n        <View\n          className=\"mb-3 size-[64px] items-center justify-center rounded-xl p-1\"\n          style={{\n            backgroundColor: iconBackgroundColor,\n          }}\n        >\n          {icon}\n        </View>\n      )}\n      <Text className=\"text-2xl font-bold text-label\">{title}</Text>\n      {!!description && (\n        <Text className=\"mt-3 text-balance text-center text-sm leading-tight text-label\">\n          {description}\n        </Text>\n      )}\n      {children}\n    </GroupedInsetListBaseCell>\n  )\n}\nexport const GroupedPlainButtonCell: FC<\n  {\n    label: string\n    textClassName?: string\n  } & PressableProps\n> = ({ label, textClassName, ...props }) => {\n  return (\n    <GroupedInsetListBaseCell as={OverlayInterectionPressable} {...(props as any)}>\n      <Text className={cn(\"text-center text-sm text-accent\", textClassName)}>{label}</Text>\n    </GroupedInsetListBaseCell>\n  )\n}\nexport const GroupedInsetActivityIndicatorCell: FC = () => {\n  return (\n    <GroupedInsetListBaseCell className=\"flex-1 items-center justify-center py-4\">\n      <PlatformActivityIndicator />\n    </GroupedInsetListBaseCell>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/grouped/constants.ts",
    "content": "import { PixelRatio } from \"react-native\"\n\nconst pixelRatio = PixelRatio.get()\nexport const GROUPED_ICON_TEXT_GAP = 36 / pixelRatio\n\nexport const GROUPED_LIST_MARGIN = 48 / pixelRatio\n\nexport const GROUPED_LIST_ITEM_PADDING = 52 / pixelRatio\n\nexport const GROUPED_SECTION_TOP_MARGIN = 87 / pixelRatio\nexport const GROUPED_SECTION_BOTTOM_MARGIN = 36 / pixelRatio\n"
  },
  {
    "path": "apps/mobile/src/components/ui/icon/fallback-icon.tsx",
    "content": "import { getBackgroundGradient, isCJKChar } from \"@follow/utils\"\nimport { LinearGradient } from \"expo-linear-gradient\"\nimport { useMemo, useState } from \"react\"\nimport type { DimensionValue, StyleProp, TextStyle, ViewStyle } from \"react-native\"\nimport { StyleSheet, Text, View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { Image } from \"@/src/components/ui/image/Image\"\n\nexport const FallbackIcon = ({\n  title,\n  url,\n  size,\n  className,\n  style,\n  textClassName,\n  textStyle,\n  gray,\n}: {\n  title: string\n  url?: string\n  size: DimensionValue\n  className?: string\n  style?: StyleProp<ViewStyle>\n  textClassName?: string\n  textStyle?: StyleProp<TextStyle>\n  gray?: boolean\n}) => {\n  const colors = useMemo(() => getBackgroundGradient(title || url || \"\"), [title, url])\n  const sizeStyle = useMemo(\n    () => ({\n      width: size,\n      height: size,\n    }),\n    [size],\n  )\n  const [, , , bgAccent, bgAccentLight, bgAccentUltraLight] = colors\n  const renderedText = useMemo(() => {\n    const firstChar = title.at(0)\n    const isCJK = firstChar ? isCJKChar(firstChar) : false\n    return (\n      <Text\n        allowFontScaling={false}\n        style={StyleSheet.flatten([styles.text, textStyle])}\n        className={textClassName}\n      >\n        {isCJK ? title[0] : title.slice(0, 2)}\n      </Text>\n    )\n  }, [title, textStyle, textClassName])\n  const grayColor = useColor(\"gray2\")\n  return (\n    <LinearGradient\n      className={className}\n      colors={\n        gray ? [grayColor, grayColor, grayColor] : [bgAccent!, bgAccentLight!, bgAccentUltraLight!]\n      }\n      locations={[0, 0.99, 1]}\n      style={[sizeStyle, styles.container, style]}\n    >\n      {renderedText}\n    </LinearGradient>\n  )\n}\nexport const IconWithFallback = (props: {\n  url?: string | undefined | null\n  size: number\n  title?: string\n  className?: string\n  style?: StyleProp<ViewStyle>\n  textClassName?: string\n  textStyle?: StyleProp<TextStyle>\n}) => {\n  const { url, size, title = \"\", className, style, textClassName, textStyle } = props\n  const [hasError, setHasError] = useState(false)\n  if (!url || hasError) {\n    return (\n      <FallbackIcon\n        title={title}\n        size={size}\n        className={className}\n        style={style}\n        textClassName={textClassName}\n        textStyle={textStyle}\n      />\n    )\n  }\n  return (\n    <View className={className} style={style}>\n      <Image\n        source={{\n          uri: url,\n        }}\n        style={[\n          {\n            width: size,\n            height: size,\n          },\n        ]}\n        onError={() => setHasError(true)}\n      />\n    </View>\n  )\n}\nconst styles = StyleSheet.create({\n  container: {\n    display: \"flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  text: {\n    fontSize: 12,\n    color: \"#fff\",\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/icon/feed-icon.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { FeedSchema } from \"@follow/database/schemas/types\"\nimport { cn } from \"@follow/utils\"\nimport type { ReactNode } from \"react\"\nimport { useCallback, useMemo, useState } from \"react\"\n\nimport { getFeedIconSource } from \"@/src/lib/image\"\n\nimport type { ImageProps } from \"../image/Image\"\nimport { Image } from \"../image/Image\"\nimport { FallbackIcon } from \"./fallback-icon\"\n\nexport type FeedIconRequiredFeed = Pick<\n  FeedSchema,\n  \"ownerUserId\" | \"id\" | \"title\" | \"url\" | \"image\"\n> & {\n  type: FeedViewType\n  siteUrl?: string\n}\nexport type FeedIconFeed = FeedIconRequiredFeed | FeedSchema\n\ninterface FeedIconProps {\n  feed?: FeedIconFeed\n  fallbackUrl?: string\n  className?: string\n  size?: number\n  siteUrl?: string\n  /**\n   * Image loading error fallback to site icon\n   */\n  fallback?: boolean\n  fallbackElement?: ReactNode\n}\nexport function FeedIcon({\n  feed,\n  fallbackUrl,\n  className,\n  size = 20,\n  fallback,\n  fallbackElement,\n  siteUrl,\n  ...props\n}: FeedIconProps & ImageProps) {\n  const [isError, setIsError] = useState(false)\n  const src = useMemo(() => {\n    return getFeedIconSource(feed, siteUrl, fallback)\n  }, [fallback, feed, siteUrl])\n\n  const handleError = useCallback(() => setIsError(true), [])\n\n  if (!src || isError) {\n    return (\n      <FallbackIcon title={feed?.title ?? \"\"} size={size} className={cn(\"rounded\", className)} />\n    )\n  }\n  return (\n    <Image\n      proxy={{\n        width: size,\n        height: size,\n      }}\n      className={cn(\"rounded\", className)}\n      style={{ height: size, width: size }}\n      source={{ uri: src }}\n      onError={handleError}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/image/Image.tsx",
    "content": "import type {\n  ImageErrorEventData,\n  ImageLoadEventData,\n  ImageProps as ExpoImageProps,\n} from \"expo-image\"\nimport { Image as ExpoImage } from \"expo-image\"\nimport { useCallback, useMemo, useState } from \"react\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { getAllSources } from \"./utils\"\n\nexport type ImageProps = Omit<ExpoImageProps, \"source\"> & {\n  proxy?: {\n    width?: number\n    height?: number\n  }\n  source?: {\n    uri: string\n    headers?: Record<string, string>\n  }\n  blurhash?: string\n  aspectRatio?: number\n  hideOnError?: boolean\n}\n\nexport const Image = ({\n  ref,\n  proxy,\n  source,\n  blurhash,\n  aspectRatio,\n  hideOnError,\n  ...rest\n}: ImageProps & { ref?: React.Ref<ExpoImage | null> }) => {\n  const [safeSource, proxiesSafeSource] = useMemo(\n    () => getAllSources(source, proxy),\n    [source, proxy],\n  )\n\n  const [isFallback, setIsFallback] = useState(false)\n  const [isError, setIsError] = useState(false)\n  const onError = useCallback(\n    (e: ImageErrorEventData) => {\n      if (\n        isFallback ||\n        e.error === \"Downloaded image has 0 pixels\" ||\n        safeSource?.uri?.endsWith(\".svg\")\n      ) {\n        setIsError(true)\n        rest.onError?.(e)\n      } else {\n        setIsFallback(true)\n      }\n    },\n    [isFallback, rest, safeSource?.uri],\n  )\n\n  const [isLoading, setIsLoading] = useState(true)\n  const onLoad = useCallback(\n    (e: ImageLoadEventData) => {\n      setIsLoading(false)\n      rest.onLoad?.(e)\n    },\n    [rest],\n  )\n\n  const backgroundColor = useColor(\"secondarySystemBackground\")\n\n  if (!source?.uri) {\n    return null\n  }\n\n  if (hideOnError && isError) {\n    return null\n  }\n\n  return (\n    <ExpoImage\n      recyclingKey={source?.uri}\n      {...rest}\n      source={isFallback ? safeSource : proxiesSafeSource}\n      onError={onError}\n      onLoad={onLoad}\n      placeholder={{\n        blurhash,\n        ...(typeof rest.placeholder === \"object\" && { ...rest.placeholder }),\n      }}\n      placeholderContentFit=\"cover\"\n      style={[\n        {\n          aspectRatio,\n          ...(typeof rest.style === \"object\" && { ...rest.style }),\n          ...((isLoading || isError) && { backgroundColor }),\n        },\n        rest.style,\n      ]}\n      ref={ref}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/image/ImageContextMenu.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { collectionSyncService } from \"@follow/store/collection/store\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { requireNativeModule } from \"expo\"\nimport type { PropsWithChildren } from \"react\"\nimport { useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { View } from \"react-native\"\nimport { findNodeHandle, Pressable } from \"react-native\"\n\nimport { isIOS } from \"@/src/lib/platform\"\nimport { toast } from \"@/src/lib/toast\"\n\nimport { ContextMenu } from \"../context-menu\"\nimport { shareImage, useSaveImageToMediaLibrary } from \"./utils\"\n\ntype ImageContextMenuProps = PropsWithChildren<{\n  imageUrl?: string\n  entryId?: string\n  view?: FeedViewType\n}>\n\ninterface IOSNativeImageActions {\n  saveImageByHandle: (handle: number) => void\n  shareImageByHandle: (handle: number, url: string) => void\n  getBase64FromImageViewByHandle: (handle: number) => Promise<{ base64: string }>\n  copyImageByHandle: (handle: number) => void\n}\n\nconst getIOSNativeImageActions = () => {\n  return requireNativeModule<IOSNativeImageActions>(\"Helper\")\n}\n\nexport const ImageContextMenu = ({ imageUrl, entryId, children, view }: ImageContextMenuProps) => {\n  const { t } = useTranslation()\n  const isLoggedIn = useIsLoggedIn()\n  const entry = useEntry(entryId, (state) => ({\n    read: state.read,\n    feedId: state.feedId,\n  }))\n  const feedId = entry?.feedId\n\n  const isEntryStarred = useIsEntryStarred(entryId!)\n\n  const contextMenuTriggerRef = useRef<View>(null)\n  const saveImageToAlbum = useSaveImageToMediaLibrary()\n\n  if (!imageUrl || !entry) {\n    return children\n  }\n\n  return (\n    <ContextMenu.Root>\n      <ContextMenu.Trigger>\n        {/* Must wrap a NOT <View /> Component, because <View />'s handle can found native view in native. May be this is a react native bug */}\n        <Pressable ref={contextMenuTriggerRef}>{children}</Pressable>\n      </ContextMenu.Trigger>\n\n      <ContextMenu.Content>\n        {isLoggedIn && entryId && feedId && view !== undefined && (\n          <>\n            <ContextMenu.Item\n              key=\"MarkAsRead\"\n              onSelect={() => {\n                entry.read\n                  ? unreadSyncService.markEntryAsUnread(entryId)\n                  : unreadSyncService.markEntryAsRead(entryId)\n              }}\n            >\n              <ContextMenu.ItemTitle>\n                {entry.read ? t(\"operation.mark_as_unread\") : t(\"operation.mark_as_read\")}\n              </ContextMenu.ItemTitle>\n              <ContextMenu.ItemIcon\n                ios={{\n                  name: entry.read ? \"circle.fill\" : \"checkmark.circle\",\n                }}\n              />\n            </ContextMenu.Item>\n            <ContextMenu.Item\n              key=\"Star\"\n              onSelect={() => {\n                if (isEntryStarred) {\n                  collectionSyncService.unstarEntry({ entryId })\n                  toast.success(\"Unstarred\")\n                } else {\n                  collectionSyncService.starEntry({\n                    entryId,\n                    view,\n                  })\n                  toast.success(\"Starred\")\n                }\n              }}\n            >\n              <ContextMenu.ItemIcon\n                ios={{\n                  name: isEntryStarred ? \"star.slash\" : \"star\",\n                }}\n              />\n              <ContextMenu.ItemTitle>\n                {isEntryStarred ? t(\"operation.unstar\") : t(\"operation.star\")}\n              </ContextMenu.ItemTitle>\n            </ContextMenu.Item>\n          </>\n        )}\n\n        <ContextMenu.Item\n          key=\"Save\"\n          onSelect={async () => {\n            if (isIOS) {\n              const handle = findNodeHandle(contextMenuTriggerRef.current)\n\n              if (!handle) {\n                return\n              }\n              getIOSNativeImageActions().saveImageByHandle(handle)\n            } else {\n              saveImageToAlbum(imageUrl)\n            }\n          }}\n        >\n          <ContextMenu.ItemTitle>Save to Album</ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"square.and.arrow.down\",\n            }}\n          />\n        </ContextMenu.Item>\n        <ContextMenu.Item\n          key=\"Share\"\n          onSelect={async () => {\n            if (isIOS) {\n              const handle = findNodeHandle(contextMenuTriggerRef.current)\n\n              if (!handle) {\n                return\n              }\n              getIOSNativeImageActions().shareImageByHandle(handle, imageUrl)\n            } else {\n              shareImage({ uri: imageUrl })\n            }\n          }}\n        >\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"square.and.arrow.up\",\n            }}\n          />\n          <ContextMenu.ItemTitle>{t(\"operation.share\")}</ContextMenu.ItemTitle>\n        </ContextMenu.Item>\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/image/utils.ts",
    "content": "import { createBuildSafeHeaders } from \"@follow/utils/headers\"\nimport { IMAGE_PROXY_URL } from \"@follow/utils/img-proxy\"\nimport ImageEditor from \"@react-native-community/image-editor\"\nimport * as FileSystem from \"expo-file-system/legacy\"\nimport type { ImageProps, ImageSource } from \"expo-image\"\nimport { saveToLibraryAsync, usePermissions } from \"expo-media-library\"\nimport * as Sharing from \"expo-sharing\"\nimport { useCallback } from \"react\"\nimport { Image } from \"react-native\"\n\nimport { getImageProxyUrl } from \"@/src/lib/img-proxy\"\nimport { isAndroid, isNative } from \"@/src/lib/platform\"\nimport { proxyEnv } from \"@/src/lib/proxy-env\"\nimport { toast } from \"@/src/lib/toast\"\n\nconst buildSafeHeaders = createBuildSafeHeaders(proxyEnv.WEB_URL, [\n  IMAGE_PROXY_URL,\n  proxyEnv.API_URL,\n])\n\n// Type guard to check if source is an ImageSource with uri property\nfunction isImageSourceWithUri(\n  source: ImageProps[\"source\"],\n): source is ImageSource & { uri: string } {\n  return (\n    source !== null &&\n    typeof source === \"object\" &&\n    !Array.isArray(source) &&\n    \"uri\" in source &&\n    typeof source.uri === \"string\"\n  )\n}\n\nexport const getAllSources = (\n  source: ImageProps[\"source\"],\n  proxy?: {\n    width?: number\n    height?: number\n  },\n) => {\n  if (!isImageSourceWithUri(source)) {\n    console.warn(\"Invalid image source\", source)\n    return [undefined, undefined] as const\n  }\n\n  // Now TypeScript knows source has a uri property\n  source.uri = source.uri.replace(\"http://\", \"https://\")\n\n  const safeSource: ImageProps[\"source\"] = {\n    width: proxy?.width,\n    height: proxy?.height,\n    ...source,\n    headers: {\n      ...buildSafeHeaders({ url: source.uri }),\n      ...source.headers,\n    },\n  }\n\n  const proxiesSafeSource = (() => {\n    if (!proxy?.height && !proxy?.width) {\n      return safeSource\n    }\n\n    return {\n      width: proxy?.width,\n      height: proxy?.height,\n      ...safeSource,\n      uri: getImageProxyUrl({\n        url: source.uri,\n        width: proxy?.width ? proxy?.width * 3 : undefined,\n        height: proxy?.height ? proxy?.height * 3 : undefined,\n      }),\n    } satisfies ImageProps[\"source\"]\n  })()\n\n  return [safeSource, proxiesSafeSource] as const\n}\n\nconst getImageData = async (imageUrl: string) => {\n  let size = await Image.getSize(imageUrl)\n\n  // Workaround for Android where the size returned by Image.getSize is not accurate for remote images\n  // Learn more https://github.com/facebook/react-native/issues/33498\n  if (isAndroid) {\n    size = {\n      width: size.width * 10,\n      height: size.height * 10,\n    }\n  }\n\n  const croppedImage = await ImageEditor.cropImage(imageUrl, {\n    offset: {\n      x: 0,\n      y: 0,\n    },\n    size,\n    format: \"png\",\n    includeBase64: true,\n  })\n\n  return croppedImage\n}\n\nconst createTempFile = async (base64: string, filename: string) => {\n  if (!FileSystem.cacheDirectory) {\n    throw new Error(\"Cache directory is not available\")\n  }\n  const fileUri = await FileSystem.getInfoAsync(FileSystem.cacheDirectory)\n  const filePath = `${fileUri.uri}/${filename}`\n  await FileSystem.writeAsStringAsync(filePath, base64, {\n    encoding: FileSystem.EncodingType.Base64,\n  })\n\n  return {\n    filePath,\n    cleanup: () => FileSystem.deleteAsync(filePath),\n  }\n}\n\nexport function extractFilenameFromUrl(uri: string): string {\n  // Extract actual filename from URL, removing any query parameters\n  const urlWithoutParams = uri.split(/[?#]/).at(0)\n  const lastSegment = urlWithoutParams?.split(\"/\").pop() || \"image\"\n  return lastSegment\n}\n\nexport async function shareImage({ uri }: { uri: string }) {\n  if (!(await Sharing.isAvailableAsync())) {\n    // TODO might need to give an error to the user in this case -prf\n    return\n  }\n  const croppedImage = await getImageData(uri)\n  const filename = `${extractFilenameFromUrl(uri)}.png`\n\n  const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename)\n  await Sharing.shareAsync(filePath, {\n    mimeType: \"image/png\",\n    dialogTitle: \"Share Image\",\n  })\n\n  cleanup()\n}\n\nexport const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {\n  const croppedImage = await getImageData(uri)\n  const filename = `${extractFilenameFromUrl(uri)}.png`\n  const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename)\n  await saveToLibraryAsync(filePath)\n  cleanup()\n}\n\n/**\n * Same as `saveImageToMediaLibrary`, but also handles permissions and toasts\n *\n * Ported from https://github.com/bluesky-social/social-app/blob/a0ea634349fd7eac40d72dbd57339f1d6c53a117/src/lib/media/save-image.ts\n *\n * @example\n * ```ts\n * const saveImageToAlbum = useSaveImageToMediaLibrary()\n * ```\n */\nexport function useSaveImageToMediaLibrary() {\n  const [permissionResponse, requestPermission, getPermission] = usePermissions({\n    granularPermissions: [\"photo\"],\n  })\n  return useCallback(\n    async (uri: string) => {\n      if (!isNative) {\n        throw new Error(\"useSaveImageToMediaLibrary is native only\")\n      }\n\n      async function save() {\n        try {\n          await saveImageToMediaLibrary({ uri })\n          toast.success(\"Image saved to library\")\n        } catch (e) {\n          toast.error(`Failed to save image: ${String(e)}`)\n        }\n      }\n\n      const permission = permissionResponse ?? (await getPermission())\n\n      if (permission.granted) {\n        await save()\n      } else {\n        if (permission.canAskAgain) {\n          // request again once\n          const askAgain = await requestPermission()\n          if (askAgain.granted) {\n            await save()\n          } else {\n            // since we've been explicitly denied, show a toast.\n            toast.error(\n              `Images cannot be saved unless permission is granted to access your photo library.`,\n              {\n                duration: 5000,\n              },\n            )\n          }\n        } else {\n          toast.info(\n            `Permission to access your photo library was denied. Please enable it in your system settings.`,\n            {\n              duration: 5000,\n            },\n          )\n        }\n      }\n    },\n    [permissionResponse, requestPermission, getPermission],\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/@types/index.ts",
    "content": "/**\n * Copyright (c) JOB TODAY S.A. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport type { ImageProps } from \"expo-image\"\nimport type { TransformsStyle } from \"react-native\"\nimport type { MeasuredDimensions } from \"react-native-reanimated\"\n\nexport type Dimensions = {\n  width: number\n  height: number\n}\n\nexport type Position = {\n  x: number\n  y: number\n}\n\nexport type LightboxImageSource = {\n  uri: string\n  dimensions: Dimensions | null\n  thumbUri: ImageProps[\"placeholder\"]\n  thumbDimensions: Dimensions | null\n  thumbRect: MeasuredDimensions | null\n  alt?: string\n  type: \"image\" | \"circle-avi\" | \"rect-avi\"\n}\n\nexport type Transform = Exclude<TransformsStyle[\"transform\"], string | undefined>\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageDefaultHeader.tsx",
    "content": "/**\n * Copyright (c) JOB TODAY S.A. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\nimport type { ViewStyle } from \"react-native\"\nimport { Pressable, StyleSheet, View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { CloseCuteReIcon } from \"@/src/icons/close_cute_re\"\nimport { Download2CuteReIcon } from \"@/src/icons/download_2_cute_re\"\nimport { ShareForwardCuteReIcon } from \"@/src/icons/share_forward_cute_re\"\n\ntype Props = {\n  onRequestClose: () => void\n  onPressSave: (uri: string) => void\n  onPressShare: (uri: string) => void\n  currentImageUri?: string\n}\n\nconst ImageDefaultHeader = ({\n  onRequestClose,\n  onPressSave,\n  onPressShare,\n  currentImageUri,\n}: Props) => {\n  const insets = useSafeAreaInsets()\n\n  return (\n    <View\n      style={[\n        styles.root,\n        { marginTop: insets.top, marginLeft: insets.left, marginRight: insets.right },\n      ]}\n    >\n      {/* Left side - Close button */}\n      <View style={styles.leftActions}>\n        <Pressable\n          style={[styles.actionButton, styles.blurredBackground]}\n          onPress={onRequestClose}\n          hitSlop={16}\n          accessibilityRole=\"button\"\n          accessibilityLabel=\"Close image\"\n          accessibilityHint=\"Closes viewer for header image\"\n          onAccessibilityEscape={onRequestClose}\n        >\n          <CloseCuteReIcon color=\"#fff\" width={20} height={20} />\n        </Pressable>\n      </View>\n\n      {/* Right side - Save and Share buttons */}\n      <View style={styles.rightActions}>\n        {currentImageUri && (\n          <>\n            <Pressable\n              style={[styles.actionButton, styles.blurredBackground]}\n              onPress={() => onPressSave(currentImageUri)}\n              hitSlop={16}\n              accessibilityRole=\"button\"\n              accessibilityLabel=\"Save image\"\n              accessibilityHint=\"Saves image to photo library\"\n            >\n              <Download2CuteReIcon color=\"#fff\" width={20} height={20} />\n            </Pressable>\n\n            <Pressable\n              style={[styles.actionButton, styles.blurredBackground]}\n              onPress={() => onPressShare(currentImageUri)}\n              hitSlop={16}\n              accessibilityRole=\"button\"\n              accessibilityLabel=\"Share image\"\n              accessibilityHint=\"Shares image with other apps\"\n            >\n              <ShareForwardCuteReIcon color=\"#fff\" width={20} height={20} />\n            </Pressable>\n          </>\n        )}\n      </View>\n    </View>\n  )\n}\n\nconst styles = StyleSheet.create({\n  root: {\n    flexDirection: \"row\",\n    justifyContent: \"space-between\",\n    alignItems: \"flex-start\",\n    paddingHorizontal: 16,\n    paddingTop: 16,\n    pointerEvents: \"box-none\",\n  },\n  leftActions: {\n    flexDirection: \"row\",\n    alignItems: \"center\",\n  },\n  rightActions: {\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    gap: 12,\n  },\n  actionButton: {\n    width: 44,\n    height: 44,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    borderRadius: 22,\n    backgroundColor: \"#00000077\",\n  },\n  blurredBackground: {\n    backdropFilter: \"blur(10px)\",\n    WebkitBackdropFilter: \"blur(10px)\",\n  } as ViewStyle,\n})\n\nexport default ImageDefaultHeader\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx",
    "content": "import * as React from \"react\"\nimport { useState } from \"react\"\nimport { ActivityIndicator, StyleSheet } from \"react-native\"\nimport type { PanGesture } from \"react-native-gesture-handler\"\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, {\n  runOnJS,\n  useAnimatedReaction,\n  useAnimatedRef,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\n\nimport { Image as ProxyImage } from \"@/src/components/ui/image/Image\"\n\nimport type { Dimensions as ImageDimensions, LightboxImageSource, Transform } from \"../../@types\"\nimport type { TransformMatrix } from \"../../transforms\"\nimport {\n  applyRounding,\n  createTransform,\n  prependPan,\n  prependPinch,\n  prependTransform,\n  readTransform,\n} from \"../../transforms\"\n\nconst MIN_SCREEN_ZOOM = 2\nconst MAX_ORIGINAL_IMAGE_ZOOM = 2\n\nconst initialTransform = createTransform()\n\ntype Props = {\n  imageSrc: LightboxImageSource\n  onRequestClose: () => void\n  onTap: () => void\n  onZoom: (isZoomed: boolean) => void\n  onLoad: (dims: ImageDimensions) => void\n  isScrollViewBeingDragged: boolean\n  showControls: boolean\n  measureSafeArea: () => {\n    x: number\n    y: number\n    width: number\n    height: number\n  }\n  imageAspect: number | undefined\n  imageDimensions: ImageDimensions | undefined\n  dismissSwipePan: PanGesture\n  transforms: Readonly<\n    SharedValue<{\n      scaleAndMoveTransform: Transform\n      cropFrameTransform: Transform\n      cropContentTransform: Transform\n      isResting: boolean\n      isHidden: boolean\n    }>\n  >\n}\nconst ImageItem = ({\n  imageSrc,\n  onTap,\n  onZoom,\n  onLoad,\n  isScrollViewBeingDragged,\n  measureSafeArea,\n  imageAspect,\n  imageDimensions,\n  dismissSwipePan,\n  transforms,\n}: Props) => {\n  const [isScaled, setIsScaled] = useState(false)\n  const committedTransform = useSharedValue(initialTransform)\n  const panTranslation = useSharedValue({ x: 0, y: 0 })\n  const pinchOrigin = useSharedValue({ x: 0, y: 0 })\n  const pinchScale = useSharedValue(1)\n  const pinchTranslation = useSharedValue({ x: 0, y: 0 })\n  const containerRef = useAnimatedRef()\n\n  // Keep track of when we're entering or leaving scaled rendering.\n  // Note: DO NOT move any logic reading animated values outside this function.\n  useAnimatedReaction(\n    () => {\n      if (pinchScale.get() !== 1) {\n        // We're currently pinching.\n        return true\n      }\n      const [, , committedScale] = readTransform(committedTransform.get())\n      if (committedScale !== 1) {\n        // We started from a pinched in state.\n        return true\n      }\n      // We're at rest.\n      return false\n    },\n    (nextIsScaled, prevIsScaled) => {\n      if (nextIsScaled !== prevIsScaled) {\n        runOnJS(handleZoom)(nextIsScaled)\n      }\n    },\n  )\n\n  function handleZoom(nextIsScaled: boolean) {\n    setIsScaled(nextIsScaled)\n    onZoom(nextIsScaled)\n  }\n\n  // On Android, stock apps prevent going \"out of bounds\" on pan or pinch. You should \"bump\" into edges.\n  // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.\n  function getExtraTranslationToStayInBounds(\n    candidateTransform: TransformMatrix,\n    screenSize: { width: number; height: number },\n  ) {\n    \"worklet\"\n    if (!imageAspect) {\n      return [0, 0] as const\n    }\n    const [nextTranslateX, nextTranslateY, nextScale] = readTransform(candidateTransform)\n    const scaledDimensions = getScaledDimensions(imageAspect, nextScale, screenSize)\n    const clampedTranslateX = clampTranslation(\n      nextTranslateX,\n      scaledDimensions.width,\n      screenSize.width,\n    )\n    const clampedTranslateY = clampTranslation(\n      nextTranslateY,\n      scaledDimensions.height,\n      screenSize.height,\n    )\n    const dx = clampedTranslateX - nextTranslateX\n    const dy = clampedTranslateY - nextTranslateY\n    return [dx, dy] as const\n  }\n\n  const pinch = Gesture.Pinch()\n    .onStart((e) => {\n      \"worklet\"\n      const screenSize = measureSafeArea()\n      pinchOrigin.set({\n        x: e.focalX - screenSize.width / 2,\n        y: e.focalY - screenSize.height / 2,\n      })\n    })\n    .onChange((e) => {\n      \"worklet\"\n      const screenSize = measureSafeArea()\n      if (!imageDimensions) {\n        return\n      }\n      // Don't let the picture zoom in so close that it gets blurry.\n      // Also, like in stock Android apps, don't let the user zoom out further than 1:1.\n      const [, , committedScale] = readTransform(committedTransform.get())\n      const maxCommittedScale = Math.max(\n        MIN_SCREEN_ZOOM,\n        (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,\n      )\n      const minPinchScale = 1 / committedScale\n      const maxPinchScale = maxCommittedScale / committedScale\n      const nextPinchScale = Math.min(Math.max(minPinchScale, e.scale), maxPinchScale)\n      pinchScale.set(nextPinchScale)\n\n      // Zooming out close to the corner could push us out of bounds, which we don't want on Android.\n      // Calculate where we'll end up so we know how much to translate back to stay in bounds.\n      const t = createTransform()\n      prependPan(t, panTranslation.get())\n      prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get())\n      prependTransform(t, committedTransform.get())\n      const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)\n      if (dx !== 0 || dy !== 0) {\n        const pt = pinchTranslation.get()\n        pinchTranslation.set({\n          x: pt.x + dx,\n          y: pt.y + dy,\n        })\n      }\n    })\n    .onEnd(() => {\n      \"worklet\"\n      // Commit just the pinch.\n      const t = createTransform()\n      prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())\n      prependTransform(t, committedTransform.get())\n      applyRounding(t)\n      committedTransform.set(t)\n\n      // Reset just the pinch.\n      pinchScale.set(1)\n      pinchOrigin.set({ x: 0, y: 0 })\n      pinchTranslation.set({ x: 0, y: 0 })\n    })\n\n  const pan = Gesture.Pan()\n    .averageTouches(true)\n    // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:\n    .minPointers(isScaled ? 1 : 2)\n    .onChange((e) => {\n      \"worklet\"\n      const screenSize = measureSafeArea()\n      if (!imageDimensions) {\n        return\n      }\n\n      const nextPanTranslation = { x: e.translationX, y: e.translationY }\n      const t = createTransform()\n      prependPan(t, nextPanTranslation)\n      prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())\n      prependTransform(t, committedTransform.get())\n\n      // Prevent panning from going out of bounds.\n      const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)\n      nextPanTranslation.x += dx\n      nextPanTranslation.y += dy\n      panTranslation.set(nextPanTranslation)\n    })\n    .onEnd(() => {\n      \"worklet\"\n      // Commit just the pan.\n      const t = createTransform()\n      prependPan(t, panTranslation.get())\n      prependTransform(t, committedTransform.get())\n      applyRounding(t)\n      committedTransform.set(t)\n\n      // Reset just the pan.\n      panTranslation.set({ x: 0, y: 0 })\n    })\n\n  const singleTap = Gesture.Tap().onEnd(() => {\n    \"worklet\"\n    runOnJS(onTap)()\n  })\n\n  const doubleTap = Gesture.Tap()\n    .numberOfTaps(2)\n    .onEnd((e) => {\n      \"worklet\"\n      const screenSize = measureSafeArea()\n      if (!imageDimensions || !imageAspect) {\n        return\n      }\n      const [, , committedScale] = readTransform(committedTransform.get())\n      if (committedScale !== 1) {\n        // Go back to 1:1 using the identity vector.\n        const t = createTransform()\n        committedTransform.set(withClampedSpring(t))\n        return\n      }\n\n      // Try to zoom in so that we get rid of the black bars (whatever the orientation was).\n      const screenAspect = screenSize.width / screenSize.height\n      const candidateScale = Math.max(\n        imageAspect / screenAspect,\n        screenAspect / imageAspect,\n        MIN_SCREEN_ZOOM,\n      )\n      // But don't zoom in so close that the picture gets blurry.\n      const maxScale = Math.max(\n        MIN_SCREEN_ZOOM,\n        (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,\n      )\n      const scale = Math.min(candidateScale, maxScale)\n\n      // Calculate where we would be if the user pinched into the double tapped point.\n      // We won't use this transform directly because it may go out of bounds.\n      const candidateTransform = createTransform()\n      const origin = {\n        x: e.absoluteX - screenSize.width / 2,\n        y: e.absoluteY - screenSize.height / 2,\n      }\n      prependPinch(candidateTransform, scale, origin, { x: 0, y: 0 })\n\n      // Now we know how much we went out of bounds, so we can shoot correctly.\n      const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform, screenSize)\n      const finalTransform = createTransform()\n      prependPinch(finalTransform, scale, origin, { x: dx, y: dy })\n      committedTransform.set(withClampedSpring(finalTransform))\n    })\n\n  const composedGesture = isScrollViewBeingDragged\n    ? // If the parent is not at rest, provide a no-op gesture.\n      Gesture.Manual()\n    : Gesture.Exclusive(dismissSwipePan, Gesture.Simultaneous(pinch, pan), doubleTap, singleTap)\n\n  const containerStyle = useAnimatedStyle(() => {\n    const { scaleAndMoveTransform, isHidden } = transforms.get()\n    // Apply the active adjustments on top of the committed transform before the gestures.\n    // This is matrix multiplication, so operations are applied in the reverse order.\n    const t = createTransform()\n    prependPan(t, panTranslation.get())\n    prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())\n    prependTransform(t, committedTransform.get())\n    const [translateX, translateY, scale] = readTransform(t)\n    const manipulationTransform = [{ translateX }, { translateY }, { scale }]\n    const screenSize = measureSafeArea()\n    return {\n      opacity: isHidden ? 0 : 1,\n      transform: scaleAndMoveTransform.concat(manipulationTransform),\n      width: screenSize.width,\n      maxHeight: screenSize.height,\n      alignSelf: \"center\",\n      aspectRatio: imageAspect ?? 1 /* force onLoad */,\n    }\n  })\n\n  const imageCropStyle = useAnimatedStyle(() => {\n    const { cropFrameTransform } = transforms.get()\n    return {\n      flex: 1,\n      overflow: \"hidden\",\n      transform: cropFrameTransform,\n    }\n  })\n\n  const imageStyle = useAnimatedStyle(() => {\n    const { cropContentTransform } = transforms.get()\n    return {\n      flex: 1,\n      transform: cropContentTransform,\n      opacity: imageAspect === undefined ? 0 : 1,\n    }\n  })\n\n  const [showLoader, setShowLoader] = useState(false)\n  const [hasLoaded, setHasLoaded] = useState(false)\n  useAnimatedReaction(\n    () => {\n      return transforms.get().isResting && !hasLoaded\n    },\n    (show, prevShow) => {\n      if (!prevShow && show) {\n        runOnJS(setShowLoader)(true)\n      } else if (prevShow && !show) {\n        runOnJS(setShowLoader)(false)\n      }\n    },\n  )\n\n  const { type } = imageSrc\n  const borderRadius = type === \"circle-avi\" ? 1e5 : type === \"rect-avi\" ? 20 : 0\n\n  return (\n    <GestureDetector gesture={composedGesture}>\n      <Animated.View ref={containerRef} style={[styles.container]} renderToHardwareTextureAndroid>\n        <Animated.View style={containerStyle}>\n          {showLoader && <ActivityIndicator size=\"small\" color=\"#FFF\" style={styles.loading} />}\n          <Animated.View style={imageCropStyle}>\n            <Animated.View style={imageStyle}>\n              <ProxyImage\n                contentFit=\"contain\"\n                source={{ uri: imageSrc.uri }}\n                placeholderContentFit=\"contain\"\n                placeholder={imageSrc.thumbUri}\n                accessibilityLabel={imageSrc.alt}\n                onLoad={\n                  hasLoaded\n                    ? undefined\n                    : (e) => {\n                        setHasLoaded(true)\n                        onLoad({ width: e.source.width, height: e.source.height })\n                      }\n                }\n                style={{ flex: 1, borderRadius, backgroundColor: \"rgba(255, 255, 255, 0.1)\" }}\n                accessibilityHint=\"\"\n                accessibilityIgnoresInvertColors\n                cachePolicy=\"memory\"\n                priority=\"high\"\n              />\n            </Animated.View>\n          </Animated.View>\n        </Animated.View>\n      </Animated.View>\n    </GestureDetector>\n  )\n}\n\nconst styles = StyleSheet.create({\n  container: {\n    height: \"100%\",\n    overflow: \"hidden\",\n    justifyContent: \"center\",\n  },\n  loading: {\n    position: \"absolute\",\n    left: 0,\n    right: 0,\n    top: 0,\n    bottom: 0,\n    justifyContent: \"center\",\n  },\n})\n\nfunction getScaledDimensions(\n  imageAspect: number,\n  scale: number,\n  screenSize: { width: number; height: number },\n): ImageDimensions {\n  \"worklet\"\n  const screenAspect = screenSize.width / screenSize.height\n  const isLandscape = imageAspect > screenAspect\n  if (isLandscape) {\n    return {\n      width: scale * screenSize.width,\n      height: (scale * screenSize.width) / imageAspect,\n    }\n  } else {\n    return {\n      width: scale * screenSize.height * imageAspect,\n      height: scale * screenSize.height,\n    }\n  }\n}\n\nfunction clampTranslation(value: number, scaledSize: number, screenSize: number): number {\n  \"worklet\"\n  // Figure out how much the user should be allowed to pan, and constrain the translation.\n  const panDistance = Math.max(0, (scaledSize - screenSize) / 2)\n  const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)\n  return clampedValue\n}\n\nfunction withClampedSpring(value: any) {\n  \"worklet\"\n  return withSpring(value, { overshootClamping: true })\n}\n\nexport default React.memo(ImageItem)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx",
    "content": "/**\n * Copyright (c) JOB TODAY S.A. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport * as React from \"react\"\nimport { useState } from \"react\"\nimport { ActivityIndicator, StyleSheet } from \"react-native\"\nimport type { PanGesture } from \"react-native-gesture-handler\"\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, {\n  runOnJS,\n  useAnimatedProps,\n  useAnimatedReaction,\n  useAnimatedRef,\n  useAnimatedScrollHandler,\n  useAnimatedStyle,\n  useSharedValue,\n} from \"react-native-reanimated\"\nimport { useSafeAreaFrame } from \"react-native-safe-area-context\"\n\nimport { Image as ProxyImage } from \"@/src/components/ui/image/Image\"\n\nimport type { Dimensions as ImageDimensions, LightboxImageSource, Transform } from \"../../@types\"\n\nconst MAX_ORIGINAL_IMAGE_ZOOM = 2\nconst MIN_SCREEN_ZOOM = 2\n\ntype Props = {\n  imageSrc: LightboxImageSource\n  onRequestClose: () => void\n  onTap: () => void\n  onZoom: (scaled: boolean) => void\n  onLoad: (dims: ImageDimensions) => void\n  isScrollViewBeingDragged: boolean\n  showControls: boolean\n  measureSafeArea: () => {\n    x: number\n    y: number\n    width: number\n    height: number\n  }\n  imageAspect: number | undefined\n  imageDimensions: ImageDimensions | undefined\n  dismissSwipePan: PanGesture\n  transforms: Readonly<\n    SharedValue<{\n      scaleAndMoveTransform: Transform\n      cropFrameTransform: Transform\n      cropContentTransform: Transform\n      isResting: boolean\n      isHidden: boolean\n    }>\n  >\n}\n\nconst ImageItem = ({\n  imageSrc,\n  onTap,\n  onZoom,\n  onLoad,\n  showControls,\n  measureSafeArea,\n  imageAspect,\n  imageDimensions,\n  dismissSwipePan,\n  transforms,\n}: Props) => {\n  const scrollViewRef = useAnimatedRef<Animated.ScrollView>()\n  const [scaled, setScaled] = useState(false)\n  const isDragging = useSharedValue(false)\n  const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()\n  const maxZoomScale = Math.max(\n    MIN_SCREEN_ZOOM,\n    imageDimensions\n      ? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) * MAX_ORIGINAL_IMAGE_ZOOM\n      : 1,\n  )\n\n  const scrollHandler = useAnimatedScrollHandler({\n    onScroll(e) {\n      \"worklet\"\n      const nextIsScaled = e.zoomScale > 1\n      if (scaled !== nextIsScaled) {\n        runOnJS(handleZoom)(nextIsScaled)\n      }\n    },\n    onBeginDrag() {\n      \"worklet\"\n      isDragging.value = true\n    },\n    onEndDrag() {\n      \"worklet\"\n      isDragging.value = false\n    },\n  })\n\n  function handleZoom(nextIsScaled: boolean) {\n    onZoom(nextIsScaled)\n    setScaled(nextIsScaled)\n  }\n\n  function zoomTo(nextZoomRect: { x: number; y: number; width: number; height: number }) {\n    const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()\n    // @ts-ignore\n    scrollResponderRef?.scrollResponderZoomTo({\n      ...nextZoomRect, // This rect is in screen coordinates\n      animated: true,\n    })\n  }\n\n  const singleTap = Gesture.Tap().onEnd(() => {\n    \"worklet\"\n    runOnJS(onTap)()\n  })\n\n  const doubleTap = Gesture.Tap()\n    .numberOfTaps(2)\n    .onEnd((e) => {\n      \"worklet\"\n      const screenSize = measureSafeArea()\n      const { absoluteX, absoluteY } = e\n      let nextZoomRect = {\n        x: 0,\n        y: 0,\n        width: screenSize.width,\n        height: screenSize.height,\n      }\n      const willZoom = !scaled\n      if (willZoom) {\n        nextZoomRect = getZoomRectAfterDoubleTap(imageAspect, absoluteX, absoluteY, screenSize)\n      }\n      runOnJS(zoomTo)(nextZoomRect)\n    })\n\n  const composedGesture = Gesture.Exclusive(dismissSwipePan, doubleTap, singleTap)\n\n  const containerStyle = useAnimatedStyle(() => {\n    const { scaleAndMoveTransform, isHidden } = transforms.get()\n    return {\n      flex: 1,\n      transform: scaleAndMoveTransform,\n      opacity: isHidden ? 0 : 1,\n    }\n  })\n\n  const imageCropStyle = useAnimatedStyle(() => {\n    const screenSize = measureSafeArea()\n    const { cropFrameTransform } = transforms.get()\n    return {\n      overflow: \"hidden\",\n      transform: cropFrameTransform,\n      width: screenSize.width,\n      maxHeight: screenSize.height,\n      alignSelf: \"center\",\n      aspectRatio: imageAspect ?? 1 /* force onLoad */,\n      opacity: imageAspect === undefined ? 0 : 1,\n    }\n  })\n\n  const imageStyle = useAnimatedStyle(() => {\n    const { cropContentTransform } = transforms.get()\n    return {\n      transform: cropContentTransform,\n      width: \"100%\",\n      aspectRatio: imageAspect ?? 1 /* force onLoad */,\n      opacity: imageAspect === undefined ? 0 : 1,\n    }\n  })\n\n  const [showLoader, setShowLoader] = useState(false)\n  const [hasLoaded, setHasLoaded] = useState(false)\n  useAnimatedReaction(\n    () => {\n      return transforms.get().isResting && !hasLoaded\n    },\n    (show, prevShow) => {\n      if (!prevShow && show) {\n        runOnJS(setShowLoader)(true)\n      } else if (prevShow && !show) {\n        runOnJS(setShowLoader)(false)\n      }\n    },\n  )\n\n  const { type } = imageSrc\n  const borderRadius = type === \"circle-avi\" ? 1e5 : type === \"rect-avi\" ? 20 : 0\n\n  const scrollViewProps = useAnimatedProps(() => ({\n    // Don't allow bounce at 1:1 rest so it can be swiped away.\n    bounces: scaled || isDragging.value,\n  }))\n\n  return (\n    <GestureDetector gesture={composedGesture}>\n      {/* Wrap a extra view to prevent https://github.com/software-mansion/react-native-reanimated/issues/6829 */}\n      <Animated.View style={containerStyle}>\n        <Animated.ScrollView\n          // @ts-ignore Something's up with the types here\n          ref={scrollViewRef}\n          pinchGestureEnabled\n          showsHorizontalScrollIndicator={false}\n          showsVerticalScrollIndicator={false}\n          maximumZoomScale={maxZoomScale}\n          onScroll={scrollHandler}\n          // style={containerStyle}\n          contentContainerClassName=\"flex-1 items-center justify-center\"\n          animatedProps={scrollViewProps}\n          centerContent\n        >\n          {showLoader && <ActivityIndicator size=\"small\" color=\"#FFF\" style={styles.loading} />}\n          <Animated.View style={imageCropStyle}>\n            <Animated.View style={imageStyle}>\n              <ProxyImage\n                contentFit=\"contain\"\n                source={{ uri: imageSrc.uri }}\n                placeholderContentFit=\"contain\"\n                placeholder={imageSrc.thumbUri}\n                style={{ flex: 1, borderRadius, backgroundColor: \"rgba(255, 255, 255, 0.1)\" }}\n                accessibilityLabel={imageSrc.alt}\n                accessibilityHint=\"\"\n                enableLiveTextInteraction={showControls && !scaled}\n                accessibilityIgnoresInvertColors\n                priority=\"high\"\n                onLoad={\n                  hasLoaded\n                    ? undefined\n                    : (e) => {\n                        setHasLoaded(true)\n                        onLoad({ width: e.source.width, height: e.source.height })\n                      }\n                }\n              />\n            </Animated.View>\n          </Animated.View>\n        </Animated.ScrollView>\n      </Animated.View>\n    </GestureDetector>\n  )\n}\n\nconst styles = StyleSheet.create({\n  loading: {\n    position: \"absolute\",\n    top: 0,\n    left: 0,\n    right: 0,\n    bottom: 0,\n  },\n  image: {\n    flex: 1,\n  },\n})\n\nconst getZoomRectAfterDoubleTap = (\n  imageAspect: number | undefined,\n  touchX: number,\n  touchY: number,\n  screenSize: { width: number; height: number },\n): {\n  x: number\n  y: number\n  width: number\n  height: number\n} => {\n  \"worklet\"\n  if (!imageAspect) {\n    return {\n      x: 0,\n      y: 0,\n      width: screenSize.width,\n      height: screenSize.height,\n    }\n  }\n\n  // First, let's figure out how much we want to zoom in.\n  // We want to try to zoom in at least close enough to get rid of black bars.\n  const screenAspect = screenSize.width / screenSize.height\n  const zoom = Math.max(imageAspect / screenAspect, screenAspect / imageAspect, MIN_SCREEN_ZOOM)\n  // Unlike in the Android version, we don't constrain the *max* zoom level here.\n  // Instead, this is done in the ScrollView props so that it constraints pinch too.\n\n  // Next, we'll be calculating the rectangle to \"zoom into\" in screen coordinates.\n  // We already know the zoom level, so this gives us the rectangle size.\n  const rectWidth = screenSize.width / zoom\n  const rectHeight = screenSize.height / zoom\n\n  // Before we settle on the zoomed rect, figure out the safe area it has to be inside.\n  // We don't want to introduce new black bars or make existing black bars unbalanced.\n  let minX = 0\n  let minY = 0\n  let maxX = screenSize.width - rectWidth\n  let maxY = screenSize.height - rectHeight\n  if (imageAspect >= screenAspect) {\n    // The image has horizontal black bars. Exclude them from the safe area.\n    const renderedHeight = screenSize.width / imageAspect\n    const horizontalBarHeight = (screenSize.height - renderedHeight) / 2\n    minY += horizontalBarHeight\n    maxY -= horizontalBarHeight\n  } else {\n    // The image has vertical black bars. Exclude them from the safe area.\n    const renderedWidth = screenSize.height * imageAspect\n    const verticalBarWidth = (screenSize.width - renderedWidth) / 2\n    minX += verticalBarWidth\n    maxX -= verticalBarWidth\n  }\n\n  // Finally, we can position the rect according to its size and the safe area.\n  let rectX\n  if (maxX >= minX) {\n    // Content fills the screen horizontally so we have horizontal wiggle room.\n    // Try to keep the tapped point under the finger after zoom.\n    rectX = touchX - touchX / zoom\n    rectX = Math.min(rectX, maxX)\n    rectX = Math.max(rectX, minX)\n  } else {\n    // Keep the rect centered on the screen so that black bars are balanced.\n    rectX = screenSize.width / 2 - rectWidth / 2\n  }\n  let rectY\n  if (maxY >= minY) {\n    // Content fills the screen vertically so we have vertical wiggle room.\n    // Try to keep the tapped point under the finger after zoom.\n    rectY = touchY - touchY / zoom\n    rectY = Math.min(rectY, maxY)\n    rectY = Math.max(rectY, minY)\n  } else {\n    // Keep the rect centered on the screen so that black bars are balanced.\n    rectY = screenSize.height / 2 - rectHeight / 2\n  }\n\n  return {\n    x: rectX,\n    y: rectY,\n    height: rectHeight,\n    width: rectWidth,\n  }\n}\n\nexport default React.memo(ImageItem)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx",
    "content": "// default implementation fallback for web\n\nimport * as React from \"react\"\nimport { View } from \"react-native\"\nimport type { PanGesture } from \"react-native-gesture-handler\"\nimport type { SharedValue } from \"react-native-reanimated\"\n\nimport type {\n  Dimensions,\n  Dimensions as ImageDimensions,\n  LightboxImageSource,\n  Transform,\n} from \"../../@types\"\n\ntype Props = {\n  imageSrc: LightboxImageSource\n  onRequestClose: () => void\n  onTap: () => void\n  onZoom: (scaled: boolean) => void\n  onLoad: (dims: Dimensions) => void\n  isScrollViewBeingDragged: boolean\n  showControls: boolean\n  measureSafeArea: () => {\n    x: number\n    y: number\n    width: number\n    height: number\n  }\n  imageAspect: number | undefined\n  imageDimensions: ImageDimensions | undefined\n  dismissSwipePan: PanGesture\n  transforms: Readonly<\n    SharedValue<{\n      scaleAndMoveTransform: Transform\n      cropFrameTransform: Transform\n      cropContentTransform: Transform\n      isResting: boolean\n      isHidden: boolean\n    }>\n  >\n}\n\nconst ImageItem = (_props: Props) => {\n  return <View />\n}\n\nexport default React.memo(ImageItem)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/index.tsx",
    "content": "/**\n * Copyright (c) JOB TODAY S.A. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n// Original code copied and simplified from the link below as the codebase is currently not maintained:\n// https://github.com/jobtoday/react-native-image-viewing\n\n// import * as ScreenOrientation from \"expo-screen-orientation\"\nimport * as React from \"react\"\nimport { useCallback, useMemo, useState } from \"react\"\nimport { LayoutAnimation, PixelRatio, ScrollView, StyleSheet, View } from \"react-native\"\nimport { SystemBars } from \"react-native-edge-to-edge\"\nimport { Gesture } from \"react-native-gesture-handler\"\nimport PagerView from \"react-native-pager-view\"\nimport type { AnimatedRef, SharedValue, WithSpringConfig } from \"react-native-reanimated\"\nimport Animated, {\n  cancelAnimation,\n  interpolate,\n  measure,\n  runOnJS,\n  useAnimatedReaction,\n  useAnimatedRef,\n  useAnimatedStyle,\n  useDerivedValue,\n  useReducedMotion,\n  useSharedValue,\n  withDecay,\n  withSpring,\n} from \"react-native-reanimated\"\nimport { useSafeAreaFrame, useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { isIOS } from \"@/src/lib/platform\"\n\nimport type { Lightbox } from \"../lightboxState\"\nimport type { Dimensions, LightboxImageSource, Transform } from \"./@types\"\nimport ImageDefaultHeader from \"./components/ImageDefaultHeader\"\nimport ImageItem from \"./components/ImageItem/ImageItem\"\n\ntype Rect = {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\n// const { PORTRAIT_UP } = ScreenOrientation.OrientationLock\nconst PIXEL_RATIO = PixelRatio.get()\nconst SLOW_SPRING: WithSpringConfig = {\n  mass: isIOS ? 1.25 : 0.75,\n  damping: 300,\n  stiffness: 800,\n}\nconst FAST_SPRING: WithSpringConfig = {\n  mass: isIOS ? 1.25 : 0.75,\n  damping: 150,\n  stiffness: 900,\n}\nfunction canAnimate(lightbox: Lightbox): boolean {\n  return (\n    // !PlatformInfo.getIsReducedMotionEnabled() &&\n    lightbox.images.every((img) => img.thumbRect && (img.dimensions || img.thumbDimensions))\n  )\n}\nexport default function ImageViewRoot({\n  lightbox: nextLightbox,\n  onRequestClose,\n  onPressSave,\n  onPressShare,\n}: {\n  lightbox: Lightbox | null\n  onRequestClose: () => void\n  onPressSave: (uri: string) => void\n  onPressShare: (uri: string) => void\n}) {\n  \"use no memo\"\n\n  const ref = useAnimatedRef<View>()\n  const [activeLightbox, setActiveLightbox] = useState(nextLightbox)\n  const [orientation, setOrientation] = useState<\"portrait\" | \"landscape\">(\"portrait\")\n  const openProgress = useSharedValue(0)\n  if (!activeLightbox && nextLightbox) {\n    setActiveLightbox(nextLightbox)\n  }\n  const reduceMotion = useReducedMotion()\n  React.useEffect(() => {\n    if (!nextLightbox) {\n      return\n    }\n    const isAnimated = canAnimate(nextLightbox) && !reduceMotion\n\n    // https://github.com/software-mansion/react-native-reanimated/issues/6677\n    rAF_FIXED(() => {\n      openProgress.set(() => (isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1))\n    })\n    return () => {\n      // https://github.com/software-mansion/react-native-reanimated/issues/6677\n      rAF_FIXED(() => {\n        openProgress.set(() => (isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0))\n      })\n    }\n  }, [nextLightbox, openProgress, reduceMotion])\n  useAnimatedReaction(\n    () => openProgress.get() === 0,\n    (isGone, wasGone) => {\n      if (isGone && !wasGone) {\n        runOnJS(setActiveLightbox)(null)\n      }\n    },\n  )\n\n  // Delay the unlock until after we've finished the scale up animation.\n  // It's complicated to do the same for locking it back so we don't attempt that.\n  // useAnimatedReaction(\n  //   () => openProgress.get() === 1,\n  //   (isOpen, wasOpen) => {\n  //     if (isOpen && !wasOpen) {\n  //       runOnJS(ScreenOrientation.unlockAsync)()\n  //     } else if (!isOpen && wasOpen) {\n  //       // default is PORTRAIT_UP - set via config plugin in app.config.js -sfn\n  //       runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)\n  //     }\n  //   },\n  // )\n\n  const onFlyAway = React.useCallback(() => {\n    \"worklet\"\n\n    openProgress.set(0)\n    runOnJS(onRequestClose)()\n  }, [onRequestClose, openProgress])\n  return (\n    // Keep it always mounted to avoid flicker on the first frame.\n    <View\n      style={[styles.screen, !activeLightbox && styles.screenHidden]}\n      aria-modal\n      accessibilityViewIsModal\n      aria-hidden={!activeLightbox}\n    >\n      <Animated.View\n        ref={ref}\n        style={{\n          flex: 1,\n        }}\n        collapsable={false}\n        onLayout={(e) => {\n          const { layout } = e.nativeEvent\n          setOrientation(layout.height > layout.width ? \"portrait\" : \"landscape\")\n        }}\n      >\n        {activeLightbox && (\n          <ImageView\n            key={`${activeLightbox.id}-${orientation}`}\n            lightbox={activeLightbox}\n            orientation={orientation}\n            onRequestClose={onRequestClose}\n            onPressSave={onPressSave}\n            onPressShare={onPressShare}\n            onFlyAway={onFlyAway}\n            safeAreaRef={ref}\n            openProgress={openProgress}\n          />\n        )}\n      </Animated.View>\n    </View>\n  )\n}\nfunction ImageView({\n  lightbox,\n  orientation,\n  onRequestClose,\n  onPressSave,\n  onPressShare,\n  onFlyAway,\n  safeAreaRef,\n  openProgress,\n}: {\n  lightbox: Lightbox\n  orientation: \"portrait\" | \"landscape\"\n  onRequestClose: () => void\n  onPressSave: (uri: string) => void\n  onPressShare: (uri: string) => void\n  onFlyAway: () => void\n  safeAreaRef: AnimatedRef<View>\n  openProgress: SharedValue<number>\n}) {\n  const { images, index: initialImageIndex } = lightbox\n  const reduceMotion = useReducedMotion()\n  const isAnimated = useMemo(() => canAnimate(lightbox) && !reduceMotion, [lightbox, reduceMotion])\n  const [isScaled, setIsScaled] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const [imageIndex, setImageIndex] = useState(initialImageIndex)\n  const [showControls, setShowControls] = useState(true)\n  const [isAltExpanded, setAltExpanded] = React.useState(false)\n  const dismissSwipeTranslateY = useSharedValue(0)\n  const isFlyingAway = useSharedValue(false)\n\n  // Get current image URI for the header buttons\n  const currentImage = images[imageIndex]\n  const currentImageUri = currentImage?.uri\n  const containerStyle = useAnimatedStyle(() => {\n    if (openProgress.get() < 1) {\n      return {\n        pointerEvents: \"none\",\n        opacity: isAnimated ? 1 : 0,\n      }\n    }\n    if (isFlyingAway.get()) {\n      return {\n        pointerEvents: \"none\",\n        opacity: 1,\n      }\n    }\n    return {\n      pointerEvents: \"auto\",\n      opacity: 1,\n    }\n  })\n  const backdropStyle = useAnimatedStyle(() => {\n    const screenSize = measure(safeAreaRef)\n    let opacity = 1\n    const openProgressValue = openProgress.get()\n    if (openProgressValue < 1) {\n      opacity = Math.sqrt(openProgressValue)\n    } else if (screenSize && orientation === \"portrait\") {\n      const dragProgress = Math.min(\n        Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),\n        1,\n      )\n      opacity -= dragProgress\n    }\n    const factor = isIOS ? 100 : 50\n    return {\n      opacity: Math.round(opacity * factor) / factor,\n    }\n  })\n  const animatedHeaderStyle = useAnimatedStyle(() => {\n    const show = showControls && dismissSwipeTranslateY.get() === 0\n    return {\n      pointerEvents: show ? \"box-none\" : \"none\",\n      opacity: withClampedSpring(show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING),\n      transform: [\n        {\n          translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),\n        },\n      ],\n    }\n  })\n  const animatedFooterStyle = useAnimatedStyle(() => {\n    const show = showControls && dismissSwipeTranslateY.get() === 0\n    return {\n      flexGrow: 1,\n      pointerEvents: show ? \"box-none\" : \"none\",\n      opacity: withClampedSpring(show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING),\n      transform: [\n        {\n          translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),\n        },\n      ],\n    }\n  })\n  const onTap = useCallback(() => {\n    setShowControls((show) => !show)\n  }, [])\n  const onZoom = useCallback((nextIsScaled: boolean) => {\n    setIsScaled(nextIsScaled)\n    if (nextIsScaled) {\n      setShowControls(false)\n    }\n  }, [])\n  useAnimatedReaction(\n    () => {\n      const screenSize = measure(safeAreaRef)\n      return !screenSize || Math.abs(dismissSwipeTranslateY.get()) > screenSize.height\n    },\n    (isOut, wasOut) => {\n      if (isOut && !wasOut) {\n        // Stop the animation from blocking the screen forever.\n        cancelAnimation(dismissSwipeTranslateY)\n        onFlyAway()\n      }\n    },\n  )\n\n  // style system ui on android\n  // const t = useTheme()\n  // useEffect(() => {\n  //   setSystemUITheme(\"lightbox\", t)\n  //   return () => {\n  //     setSystemUITheme(\"theme\", t)\n  //   }\n  // }, [t])\n\n  return (\n    <Animated.View style={[styles.container, containerStyle]}>\n      <SystemBars\n        style={{\n          statusBar: \"light\",\n          navigationBar: \"light\",\n        }}\n        hidden={{\n          statusBar: isScaled || !showControls,\n          navigationBar: false,\n        }}\n      />\n      <Animated.View style={[styles.backdrop, backdropStyle]} renderToHardwareTextureAndroid />\n      <PagerView\n        scrollEnabled={!isScaled}\n        initialPage={initialImageIndex}\n        onPageSelected={(e) => {\n          setImageIndex(e.nativeEvent.position)\n          setIsScaled(false)\n        }}\n        onPageScrollStateChanged={(e) => {\n          setIsDragging(e.nativeEvent.pageScrollState !== \"idle\")\n        }}\n        overdrag={true}\n        style={styles.pager}\n      >\n        {images.map((imageSrc, i) => (\n          <View key={imageSrc.uri}>\n            <LightboxImage\n              onTap={onTap}\n              onZoom={onZoom}\n              imageSrc={imageSrc}\n              onRequestClose={onRequestClose}\n              isScrollViewBeingDragged={isDragging}\n              showControls={showControls}\n              safeAreaRef={safeAreaRef}\n              isScaled={isScaled}\n              isFlyingAway={isFlyingAway}\n              isActive={i === imageIndex}\n              dismissSwipeTranslateY={dismissSwipeTranslateY}\n              openProgress={openProgress}\n            />\n          </View>\n        ))}\n      </PagerView>\n      <View style={styles.controls}>\n        <Animated.View style={animatedHeaderStyle} renderToHardwareTextureAndroid>\n          <ImageDefaultHeader\n            onRequestClose={onRequestClose}\n            onPressSave={onPressSave}\n            onPressShare={onPressShare}\n            currentImageUri={currentImageUri}\n          />\n        </Animated.View>\n        <Animated.View style={animatedFooterStyle} renderToHardwareTextureAndroid={!isAltExpanded}>\n          <LightboxFooter\n            images={images}\n            index={imageIndex}\n            isAltExpanded={isAltExpanded}\n            toggleAltExpanded={() => setAltExpanded((e) => !e)}\n          />\n        </Animated.View>\n      </View>\n    </Animated.View>\n  )\n}\nfunction LightboxImage({\n  imageSrc,\n  onTap,\n  onZoom,\n  onRequestClose,\n  isScrollViewBeingDragged,\n  isScaled,\n  isFlyingAway,\n  isActive,\n  showControls,\n  safeAreaRef,\n  openProgress,\n  dismissSwipeTranslateY,\n}: {\n  imageSrc: LightboxImageSource\n  onRequestClose: () => void\n  onTap: () => void\n  onZoom: (scaled: boolean) => void\n  isScrollViewBeingDragged: boolean\n  isScaled: boolean\n  isActive: boolean\n  isFlyingAway: SharedValue<boolean>\n  showControls: boolean\n  safeAreaRef: AnimatedRef<View>\n  openProgress: SharedValue<number>\n  dismissSwipeTranslateY: SharedValue<number>\n}) {\n  const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null)\n  const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions\n  let imageAspect: number | undefined\n  if (dims) {\n    imageAspect = dims.width / dims.height\n    if (Number.isNaN(imageAspect)) {\n      imageAspect = undefined\n    }\n  }\n  const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()\n  const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()\n  const measureSafeArea = React.useCallback(() => {\n    \"worklet\"\n\n    let safeArea: Rect | null = measure(safeAreaRef)\n    if (!safeArea) {\n      if (_WORKLET) {\n        console.error(\"Expected to always be able to measure safe area.\")\n      }\n      const frame = safeFrameDelayedForJSThreadOnly\n      const insets = safeInsetsDelayedForJSThreadOnly\n      safeArea = {\n        x: frame.x + insets.left,\n        y: frame.y + insets.top,\n        width: frame.width - insets.left - insets.right,\n        height: frame.height - insets.top - insets.bottom,\n      }\n    }\n    return safeArea\n  }, [safeFrameDelayedForJSThreadOnly, safeInsetsDelayedForJSThreadOnly, safeAreaRef])\n  const { thumbRect } = imageSrc\n  const transforms = useDerivedValue(() => {\n    \"worklet\"\n\n    const safeArea = measureSafeArea()\n    const openProgressValue = openProgress.get()\n    const dismissTranslateY = isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0\n    if (openProgressValue === 0 && isFlyingAway.get()) {\n      return {\n        isHidden: true,\n        isResting: false,\n        scaleAndMoveTransform: [],\n        cropFrameTransform: [],\n        cropContentTransform: [],\n      }\n    }\n    if (isActive && thumbRect && imageAspect && openProgressValue < 1) {\n      return interpolateTransform(openProgressValue, thumbRect, safeArea, imageAspect)\n    }\n    return {\n      isHidden: false,\n      isResting: dismissTranslateY === 0,\n      scaleAndMoveTransform: [\n        {\n          translateY: dismissTranslateY,\n        },\n      ],\n      cropFrameTransform: [],\n      cropContentTransform: [],\n    }\n  })\n  const dismissSwipePan = Gesture.Pan()\n    .enabled(isActive && !isScaled)\n    .activeOffsetY([-10, 10])\n    .failOffsetX([-10, 10])\n    .maxPointers(1)\n    .onUpdate((e) => {\n      \"worklet\"\n\n      if (openProgress.get() !== 1 || isFlyingAway.get()) {\n        return\n      }\n      dismissSwipeTranslateY.set(e.translationY)\n    })\n    .onEnd((e) => {\n      \"worklet\"\n\n      if (openProgress.get() !== 1 || isFlyingAway.get()) {\n        return\n      }\n      if (Math.abs(e.velocityY) > 200) {\n        isFlyingAway.set(true)\n        if (dismissSwipeTranslateY.get() === 0) {\n          // HACK: If the initial value is 0, withDecay() animation doesn't start.\n          // This is a bug in Reanimated, but for now we'll work around it like this.\n          dismissSwipeTranslateY.set(1)\n        }\n        dismissSwipeTranslateY.set(() => {\n          \"worklet\"\n\n          return withDecay({\n            velocity: e.velocityY,\n            velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),\n            // Speed up if it's too slow.\n            deceleration: 1, // Danger! This relies on the reaction below stopping it.\n          })\n        })\n      } else {\n        dismissSwipeTranslateY.set(() => {\n          \"worklet\"\n\n          return withSpring(0, {\n            stiffness: 700,\n            damping: 50,\n          })\n        })\n      }\n    })\n  return (\n    <ImageItem\n      imageSrc={imageSrc}\n      onTap={onTap}\n      onZoom={onZoom}\n      onRequestClose={onRequestClose}\n      onLoad={setFetchedDims}\n      isScrollViewBeingDragged={isScrollViewBeingDragged}\n      showControls={showControls}\n      measureSafeArea={measureSafeArea}\n      imageAspect={imageAspect}\n      imageDimensions={dims ?? undefined}\n      dismissSwipePan={dismissSwipePan}\n      transforms={transforms}\n    />\n  )\n}\nfunction LightboxFooter({\n  images,\n  index,\n  isAltExpanded,\n  toggleAltExpanded,\n}: {\n  images: LightboxImageSource[]\n  index: number\n  isAltExpanded: boolean\n  toggleAltExpanded: () => void\n}) {\n  const insets = useSafeAreaInsets()\n  const image = images.at(index)\n  const altText = image?.alt\n  const isMomentumScrolling = React.useRef(false)\n\n  // If there's no alt text, don't render the footer\n  if (!altText) {\n    return null\n  }\n  return (\n    <ScrollView\n      style={styles.footerScrollView}\n      scrollEnabled={isAltExpanded}\n      onMomentumScrollBegin={() => {\n        isMomentumScrolling.current = true\n      }}\n      onMomentumScrollEnd={() => {\n        isMomentumScrolling.current = false\n      }}\n      contentContainerStyle={{\n        paddingVertical: 12,\n        paddingHorizontal: 24,\n      }}\n    >\n      <View\n        style={{\n          marginBottom: insets.bottom,\n        }}\n      >\n        <View accessibilityRole=\"button\" style={styles.footerText}>\n          <Text\n            className=\"text-gray-3\"\n            numberOfLines={isAltExpanded ? undefined : 3}\n            selectable\n            onPress={() => {\n              if (isMomentumScrolling.current) {\n                return\n              }\n              LayoutAnimation.configureNext({\n                duration: 450,\n                update: {\n                  type: \"spring\",\n                  springDamping: 1,\n                },\n              })\n              toggleAltExpanded()\n            }}\n            onLongPress={() => {}}\n          >\n            {altText}\n          </Text>\n        </View>\n      </View>\n    </ScrollView>\n  )\n}\nconst styles = StyleSheet.create({\n  screen: {\n    position: \"absolute\",\n    top: 0,\n    left: 0,\n    bottom: 0,\n    right: 0,\n  },\n  screenHidden: {\n    opacity: 0,\n    pointerEvents: \"none\",\n  },\n  container: {\n    flex: 1,\n  },\n  backdrop: {\n    backgroundColor: \"#000\",\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    left: 0,\n    right: 0,\n  },\n  controls: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    left: 0,\n    right: 0,\n    gap: 20,\n    zIndex: 1,\n    pointerEvents: \"box-none\",\n  },\n  pager: {\n    flex: 1,\n  },\n  header: {\n    position: \"absolute\",\n    width: \"100%\",\n    top: 0,\n    pointerEvents: \"box-none\",\n  },\n  footer: {\n    position: \"absolute\",\n    width: \"100%\",\n    maxHeight: \"100%\",\n    bottom: 0,\n  },\n  footerScrollView: {\n    backgroundColor: \"#000d\",\n    flex: 1,\n    position: \"absolute\",\n    bottom: 0,\n    width: \"100%\",\n    maxHeight: \"100%\",\n  },\n  footerText: {\n    paddingBottom: isIOS ? 20 : 16,\n  },\n})\nfunction interpolatePx(px: number, inputRange: readonly number[], outputRange: readonly number[]) {\n  \"worklet\"\n\n  const value = interpolate(px, inputRange, outputRange)\n  return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO\n}\nfunction interpolateTransform(\n  progress: number,\n  thumbnailDims: {\n    pageX: number\n    width: number\n    pageY: number\n    height: number\n  },\n  safeArea: {\n    width: number\n    height: number\n    x: number\n    y: number\n  },\n  imageAspect: number,\n): {\n  scaleAndMoveTransform: Transform\n  cropFrameTransform: Transform\n  cropContentTransform: Transform\n  isResting: boolean\n  isHidden: boolean\n} {\n  \"worklet\"\n\n  const thumbAspect = thumbnailDims.width / thumbnailDims.height\n  let uncroppedInitialWidth\n  let uncroppedInitialHeight\n  if (imageAspect > thumbAspect) {\n    uncroppedInitialWidth = thumbnailDims.height * imageAspect\n    uncroppedInitialHeight = thumbnailDims.height\n  } else {\n    uncroppedInitialWidth = thumbnailDims.width\n    uncroppedInitialHeight = thumbnailDims.width / imageAspect\n  }\n  const safeAreaAspect = safeArea.width / safeArea.height\n  let finalWidth\n  let finalHeight\n  if (safeAreaAspect > imageAspect) {\n    finalWidth = safeArea.height * imageAspect\n    finalHeight = safeArea.height\n  } else {\n    finalWidth = safeArea.width\n    finalHeight = safeArea.width / imageAspect\n  }\n  const initialScale = Math.min(\n    uncroppedInitialWidth / finalWidth,\n    uncroppedInitialHeight / finalHeight,\n  )\n  const croppedFinalWidth = thumbnailDims.width / initialScale\n  const croppedFinalHeight = thumbnailDims.height / initialScale\n  const screenCenterX = safeArea.width / 2\n  const screenCenterY = safeArea.height / 2\n  const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x\n  const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y\n  const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2\n  const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2\n  const initialTranslateX = thumbnailCenterX - screenCenterX\n  const initialTranslateY = thumbnailCenterY - screenCenterY\n  const scale = interpolate(progress, [0, 1], [initialScale, 1])\n  const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])\n  const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])\n  const cropScaleX = interpolate(progress, [0, 1], [croppedFinalWidth / finalWidth, 1])\n  const cropScaleY = interpolate(progress, [0, 1], [croppedFinalHeight / finalHeight, 1])\n  return {\n    isHidden: false,\n    isResting: progress === 1,\n    scaleAndMoveTransform: [\n      {\n        translateX,\n      },\n      {\n        translateY,\n      },\n      {\n        scale,\n      },\n    ],\n    cropFrameTransform: [\n      {\n        scaleX: cropScaleX,\n      },\n      {\n        scaleY: cropScaleY,\n      },\n    ],\n    cropContentTransform: [\n      {\n        scaleX: 1 / cropScaleX,\n      },\n      {\n        scaleY: 1 / cropScaleY,\n      },\n    ],\n  }\n}\nfunction withClampedSpring(value: any, config: WithSpringConfig) {\n  \"worklet\"\n\n  return withSpring(value, {\n    ...config,\n    overshootClamping: true,\n  })\n}\n\n// We have to do this because we can't trust RN's rAF to fire in order.\n// https://github.com/facebook/react-native/issues/48005\nlet isFrameScheduled = false\nlet pendingFrameCallbacks: Array<() => void> = []\nfunction rAF_FIXED(callback: () => void) {\n  pendingFrameCallbacks.push(callback)\n  if (!isFrameScheduled) {\n    isFrameScheduled = true\n    requestAnimationFrame(() => {\n      const callbacks = pendingFrameCallbacks.slice()\n      isFrameScheduled = false\n      pendingFrameCallbacks = []\n      let hasError = false\n      let error\n      for (const callback_ of callbacks) {\n        try {\n          callback_()\n        } catch (e) {\n          hasError = true\n          error = e\n        }\n      }\n      if (hasError) {\n        throw error\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/ImageViewing/transforms.ts",
    "content": "import type { Position } from \"./@types\"\n\nexport type TransformMatrix = [\n  number,\n  number,\n  number,\n  number,\n  number,\n  number,\n  number,\n  number,\n  number,\n]\n\n// These are affine transforms. See explanation of every cell here:\n// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg\n\nexport function createTransform(): TransformMatrix {\n  \"worklet\"\n  return [1, 0, 0, 0, 1, 0, 0, 0, 1]\n}\n\nexport function applyRounding(t: TransformMatrix) {\n  \"worklet\"\n  t[2] = Math.round(t[2])\n  t[5] = Math.round(t[5])\n  // For example: 0.985, 0.99, 0.995, then 1:\n  t[0] = Math.round(t[0] * 200) / 200\n  t[4] = Math.round(t[0] * 200) / 200\n}\n\n// We're using a limited subset (always scaling and translating while keeping aspect ratio) so\n// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.\n\n// All write operations are applied in-place to avoid unnecessary allocations.\n\nexport function readTransform(t: TransformMatrix): [number, number, number] {\n  \"worklet\"\n  const scale = t[0]\n  const translateX = t[2]\n  const translateY = t[5]\n  return [translateX, translateY, scale]\n}\n\nexport function prependTranslate(t: TransformMatrix, x: number, y: number) {\n  \"worklet\"\n  t[2] += t[0] * x + t[1] * y\n  t[5] += t[3] * x + t[4] * y\n}\n\nexport function prependScale(t: TransformMatrix, value: number) {\n  \"worklet\"\n  t[0] *= value\n  t[1] *= value\n  t[3] *= value\n  t[4] *= value\n}\n\nexport function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {\n  \"worklet\"\n  // In-place matrix multiplication.\n  const a00 = ta[0],\n    a01 = ta[1],\n    a02 = ta[2]\n  const a10 = ta[3],\n    a11 = ta[4],\n    a12 = ta[5]\n  const a20 = ta[6],\n    a21 = ta[7],\n    a22 = ta[8]\n  ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]\n  ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]\n  ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]\n  ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]\n  ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]\n  ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]\n  ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]\n  ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]\n  ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]\n}\n\nexport function prependPan(t: TransformMatrix, translation: Position) {\n  \"worklet\"\n  prependTranslate(t, translation.x, translation.y)\n}\n\nexport function prependPinch(\n  t: TransformMatrix,\n  scale: number,\n  origin: Position,\n  translation: Position,\n) {\n  \"worklet\"\n  prependTranslate(t, translation.x, translation.y)\n  prependTranslate(t, origin.x, origin.y)\n  prependScale(t, scale)\n  prependTranslate(t, -origin.x, -origin.y)\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/Lightbox.tsx",
    "content": "import { useCallback } from \"react\"\n\nimport { shareImage, useSaveImageToMediaLibrary } from \"../image/utils\"\nimport ImageView from \"./ImageViewing\"\nimport { useLightbox, useLightboxControls } from \"./lightboxState\"\n\nexport function Lightbox() {\n  const { activeLightbox } = useLightbox()\n  const { closeLightbox } = useLightboxControls()\n\n  const onClose = useCallback(() => {\n    closeLightbox()\n  }, [closeLightbox])\n\n  const saveImageToAlbum = useSaveImageToMediaLibrary()\n\n  return (\n    <ImageView\n      lightbox={activeLightbox}\n      onRequestClose={onClose}\n      onPressSave={saveImageToAlbum}\n      onPressShare={(uri) => {\n        shareImage({ uri })\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/lightbox/lightboxState.tsx",
    "content": "import { nanoid } from \"nanoid/non-secure\"\nimport type { PropsWithChildren } from \"react\"\nimport { createContext, use, useCallback, useMemo, useState } from \"react\"\n\nimport type { LightboxImageSource } from \"./ImageViewing/@types\"\n\nexport type Lightbox = {\n  id: string\n  images: LightboxImageSource[]\n  index: number\n}\n\nconst LightboxContext = createContext<{\n  activeLightbox: Lightbox | null\n}>({\n  activeLightbox: null,\n})\n\nconst LightboxControlContext = createContext<{\n  openLightbox: (lightbox: Omit<Lightbox, \"id\">) => void\n  closeLightbox: () => boolean\n}>({\n  openLightbox: () => {\n    console.error(\"LightboxControlContext: openLightbox called without provider\")\n  },\n  closeLightbox: () => {\n    console.error(\"LightboxControlContext: closeLightbox called without provider\")\n    return false\n  },\n})\n\nexport function LightboxStateProvider({ children }: PropsWithChildren) {\n  const [activeLightbox, setActiveLightbox] = useState<Lightbox | null>(null)\n\n  const openLightbox = useCallback((lightbox: Omit<Lightbox, \"id\">) => {\n    setActiveLightbox((prevLightbox) => {\n      if (prevLightbox) {\n        // Ignore duplicate open requests. If it's already open,\n        // the user has to explicitly close the previous one first.\n        return prevLightbox\n      } else {\n        return { ...lightbox, id: nanoid() }\n      }\n    })\n  }, [])\n\n  const closeLightbox = useCallback(() => {\n    const wasActive = !!activeLightbox\n    setActiveLightbox(null)\n    return wasActive\n  }, [activeLightbox])\n\n  const state = useMemo(\n    () => ({\n      activeLightbox,\n    }),\n    [activeLightbox],\n  )\n\n  const methods = useMemo(\n    () => ({\n      openLightbox,\n      closeLightbox,\n    }),\n    [openLightbox, closeLightbox],\n  )\n\n  return (\n    <LightboxContext value={state}>\n      <LightboxControlContext value={methods}>{children}</LightboxControlContext>\n    </LightboxContext>\n  )\n}\n\nexport function useLightbox() {\n  return use(LightboxContext)\n}\n\nexport function useLightboxControls() {\n  return use(LightboxControlContext)\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/loading/PlatformActivityIndicator.tsx",
    "content": "import type { FC } from \"react\"\nimport type { ActivityIndicatorProps } from \"react-native\"\nimport { ActivityIndicator, Platform } from \"react-native\"\n\nimport { RotateableLoading } from \"../../common/RotateableLoading\"\n\nexport const PlatformActivityIndicator: FC<\n  Omit<ActivityIndicatorProps, \"color\"> & {\n    color?: string\n  }\n> = (props) => {\n  return Platform.OS === \"ios\" ? (\n    <ActivityIndicator {...props} />\n  ) : (\n    <RotateableLoading\n      className={props.className}\n      style={props.style}\n      color={props.color}\n      size={props.size === \"small\" ? 20 : 36}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/logo/index.tsx",
    "content": "import type { StyleProp, ViewStyle } from \"react-native\"\nimport type { SvgProps } from \"react-native-svg\"\nimport Svg, { Path } from \"react-native-svg\"\n\nimport { accentColor } from \"@/src/theme/colors\"\n\nexport const Logo: React.FC<{ color?: string } & SvgProps> = ({ color = accentColor, ...rest }) => {\n  return (\n    <Svg viewBox=\"0 0 24 24\" {...rest}>\n      <Path\n        fill={color}\n        d=\"M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z\"\n      />\n      <Path\n        fill=\"#fff\"\n        d=\"M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z\"\n      />\n    </Svg>\n  )\n}\n\nexport const FollowIcon: React.FC<{ color: string; style?: StyleProp<ViewStyle> }> = ({\n  color,\n  style,\n}) => (\n  <Svg viewBox=\"0 0 24 24\" style={style}>\n    <Path\n      fill={color}\n      d=\"M20.791.455H6.136a3.207 3.207 0 0 0-3.21 3.206 3.207 3.207 0 0 0 3.21 3.206H20.79A3.207 3.207 0 0 0 24 3.66 3.21 3.21 0 0 0 20.791.455M12.977 8.79H3.209A3.207 3.207 0 0 0 0 11.997a3.207 3.207 0 0 0 3.209 3.205h9.768a3.207 3.207 0 0 0 3.209-3.205 3.207 3.207 0 0 0-3.21-3.207m.945 11.55a3.207 3.207 0 0 0-3.21-3.207 3.207 3.207 0 0 0-3.208 3.206 3.207 3.207 0 0 0 3.209 3.206 3.207 3.207 0 0 0 3.209-3.206\"\n    />\n  </Svg>\n)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/modal/BottomModal.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { ReactNode } from \"react\"\nimport { useCallback, useEffect, useState } from \"react\"\nimport { KeyboardAvoidingView, Modal, Pressable, View } from \"react-native\"\nimport Animated, {\n  Easing,\n  runOnJS,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\"\n\nexport interface BottomModalProps {\n  // ref?: Ref<{ close: () => void }>\n  /**\n   * Whether the modal is visible\n   */\n  visible: boolean\n\n  /**\n   * Function to call when the modal should be closed (backdrop press or programmatically)\n   */\n  onClose?: () => void\n\n  /**\n   * Content to render inside the modal\n   */\n  children: ReactNode\n\n  /**\n   * Modal container class name\n   */\n  className?: string\n\n  /**\n   * Duration for the open animation in milliseconds\n   * @default 300\n   */\n  openDuration?: number\n\n  /**\n   * Duration for the close animation in milliseconds\n   * @default 250\n   */\n  closeDuration?: number\n\n  /**\n   * Allow closing the modal by tapping on the backdrop\n   * @default true\n   */\n  closeOnBackdropPress?: boolean\n\n  /**\n   * Backdrop opacity when fully visible\n   * @default 0.5\n   */\n  backdropOpacity?: number\n}\n\n/**\n * An animated modal that slides up from the bottom of the screen with a fading backdrop.\n * Great for bottom sheets, pickers, and other content that should appear from the bottom.\n */\nexport function BottomModal({\n  visible,\n  onClose,\n  children,\n  className = \"max-h-[40%]\",\n  openDuration = 300,\n  closeDuration = 250,\n  closeOnBackdropPress = true,\n  backdropOpacity = 0.3,\n}: BottomModalProps) {\n  const [internalVisible, setInternalVisible] = useState(false)\n  const backdropAnim = useSharedValue(0)\n  const contentTranslateY = useSharedValue(300)\n\n  const backdropStyle = useAnimatedStyle(() => ({\n    opacity: backdropAnim.value,\n  }))\n\n  const modalContentStyle = useAnimatedStyle(() => ({\n    transform: [{ translateY: contentTranslateY.value }],\n  }))\n\n  // useImperativeHandle(ref, () => ({\n  //   close: () => {\n  //     if (internalVisible) {\n  //       hideModal()\n  //     }\n  //   },\n  //   open: () => {\n  //     if (!internalVisible) {\n  //       showModal()\n  //     }\n  //   },\n  // }))\n\n  const showModal = useCallback(() => {\n    setInternalVisible(true)\n    backdropAnim.value = withTiming(backdropOpacity, {\n      duration: openDuration,\n      easing: Easing.out(Easing.cubic),\n    })\n\n    contentTranslateY.value = withTiming(0, {\n      duration: openDuration,\n      easing: Easing.out(Easing.cubic),\n    })\n  }, [backdropAnim, contentTranslateY, backdropOpacity, openDuration])\n\n  const hideModal = useCallback(() => {\n    backdropAnim.value = withTiming(0, {\n      duration: closeDuration,\n      easing: Easing.in(Easing.cubic),\n    })\n\n    contentTranslateY.value = withTiming(\n      300,\n      {\n        duration: closeDuration,\n        easing: Easing.in(Easing.cubic),\n      },\n      () => {\n        runOnJS(setInternalVisible)(false)\n        if (onClose) {\n          runOnJS(onClose)()\n        }\n      },\n    )\n  }, [backdropAnim, contentTranslateY, closeDuration, onClose])\n\n  const handleBackdropPress = useCallback(() => {\n    if (closeOnBackdropPress) {\n      hideModal()\n    }\n  }, [closeOnBackdropPress, hideModal])\n\n  // Start animations when visibility changes\n  useEffect(() => {\n    if (visible === internalVisible) {\n      return // No change, do nothing\n    }\n    if (visible) {\n      showModal()\n    } else {\n      hideModal()\n    }\n  }, [visible, showModal, hideModal, internalVisible])\n\n  if (!internalVisible) {\n    return null\n  }\n\n  return (\n    // Wrap in a View to avoid rendering issues with Modal on Android\n    <View>\n      <Modal\n        visible={internalVisible}\n        transparent={true}\n        animationType=\"none\"\n        onRequestClose={hideModal}\n        statusBarTranslucent\n        navigationBarTranslucent\n      >\n        <KeyboardAvoidingView className=\"flex-1\" behavior=\"padding\">\n          <Animated.View className=\"absolute inset-0 bg-black\" style={backdropStyle}>\n            <Pressable\n              className=\"flex-1\"\n              onPress={handleBackdropPress}\n              android_ripple={{ color: \"white\" }}\n            />\n          </Animated.View>\n\n          <Animated.View\n            className={cn(\n              \"mt-auto flex-1 overflow-hidden rounded-t-2xl bg-system-background\",\n              className,\n            )}\n            style={modalContentStyle}\n          >\n            {children}\n          </Animated.View>\n        </KeyboardAvoidingView>\n      </Modal>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/modal/imperative-modal/index.tsx",
    "content": "export * from \"./modal\"\nexport * as ModalTemplate from \"./templates\"\n"
  },
  {
    "path": "apps/mobile/src/components/ui/modal/imperative-modal/modal.tsx",
    "content": "import { nanoid } from \"nanoid/non-secure\"\nimport type { ReactNode } from \"react\"\nimport { cloneElement, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\nimport RootSiblings from \"react-native-root-siblings\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport type { BottomModalProps } from \"../BottomModal\"\nimport { BottomModal } from \"../BottomModal\"\nimport { Header, HeaderText, Input } from \"./templates\"\n\nexport type Modal = {\n  id: string\n  content: ReactNode\n} & Omit<BottomModalProps, \"visible\" | \"children\">\nexport type ModalInput = Omit<Modal, \"id\" | \"closeOnBackdropPress\"> & {\n  /**\n   * Optional: Will be auto-generated with nanoid if not provided\n   */\n  id?: string\n  /**\n   * Default is true\n   */\n  closeOnBackdropPress?: false\n  /**\n   * Select template for the modal.\n   *\n   * @default 'plain'\n   */\n  // type?: \"plain\" // | 'select' | 'confirm' | 'input' | 'custom'\n  abortController?: AbortController\n}\nexport const openModal = (modal: ModalInput) => {\n  const promise = Promise.withResolvers<void>()\n  const abortController = modal.abortController || new AbortController()\n  const node = (\n    <BottomModal\n      id={modal.id || nanoid()}\n      visible={true}\n      {...modal}\n      onClose={() => {\n        abortController.abort()\n        siblings.destroy()\n        modal.onClose?.()\n        promise.resolve()\n      }}\n    >\n      {modal.content}\n    </BottomModal>\n  )\n  const siblings = new RootSiblings(node)\n  abortController.signal.addEventListener(\"abort\", () => {\n    const newNode = cloneElement(node, {\n      visible: false,\n    })\n    siblings.update(newNode)\n  })\n  return promise.promise\n}\nconst PromptModal = ({\n  defaultValue,\n  title,\n  placeholder,\n  onSave,\n  abortController,\n}: {\n  defaultValue?: string\n  title?: string\n  placeholder?: string\n  onSave: (newCategory: string) => void\n  abortController: AbortController\n}) => {\n  const { t } = useTranslation()\n  const [text, setText] = useState(defaultValue ?? \"\")\n  return (\n    <View className=\"flex-1\">\n      <Header\n        renderLeft={() => (\n          <HeaderSubmitTextButton\n            label={t(\"words.cancel\", {\n              ns: \"common\",\n            })}\n            isValid={true}\n            onPress={() => {\n              abortController.abort()\n            }}\n          />\n        )}\n      >\n        <HeaderText>{title}</HeaderText>\n      </Header>\n      <View className=\"flex flex-1 gap-4 p-4\">\n        <Input\n          className=\"box-border w-full\"\n          value={text}\n          onChangeText={setText}\n          placeholder={placeholder}\n        />\n        <Pressable\n          className=\"w-full rounded-xl bg-accent px-6 py-3\"\n          disabled={text.trim().length === 0}\n          onPress={() => {\n            onSave(text)\n            abortController.abort()\n          }}\n        >\n          <Text className=\"text-center text-base font-semibold text-white\">\n            {t(\"words.save\", {\n              ns: \"common\",\n            })}\n          </Text>\n        </Pressable>\n      </View>\n    </View>\n  )\n}\nexport const modalPrompt = (\n  title: string,\n  message: string,\n  callback: (text: string) => void,\n  type?: undefined,\n  defaultValue?: string,\n) => {\n  const abortController = new AbortController()\n  openModal({\n    abortController,\n    closeOnBackdropPress: false,\n    content: (\n      <PromptModal\n        title={title}\n        defaultValue={defaultValue}\n        placeholder={message}\n        abortController={abortController}\n        onSave={callback}\n      />\n    ),\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/modal/imperative-modal/templates.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type {\n  LayoutChangeEvent,\n  StyleProp,\n  TextInputProps,\n  TextStyle,\n  ViewStyle,\n} from \"react-native\"\nimport { TextInput, View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport function Header({\n  renderLeft,\n  renderRight,\n  children,\n  className,\n  style,\n  onLayout,\n}: {\n  renderLeft?: () => React.ReactNode\n  renderRight?: () => React.ReactNode\n  children?: React.ReactNode\n  className?: string\n  style?: StyleProp<ViewStyle>\n  onLayout?: (event: LayoutChangeEvent) => void\n}) {\n  return (\n    <View\n      className={cn(\n        \"relative min-h-[50px] w-full flex-row items-center justify-center rounded-t-md border-b border-non-opaque-separator py-2\",\n        className,\n      )}\n      style={style}\n      onLayout={onLayout}\n    >\n      {renderLeft && <View className=\"absolute left-1\">{renderLeft()}</View>}\n      {children}\n      {renderRight && <View className=\"absolute right-1\">{renderRight()}</View>}\n    </View>\n  )\n}\nexport function HeaderText({\n  children,\n  style,\n}: {\n  children?: React.ReactNode\n  style?: StyleProp<TextStyle>\n}) {\n  return (\n    <Text className=\"text-center text-lg font-bold\" style={style}>\n      {children}\n    </Text>\n  )\n}\nexport function Input({\n  value,\n  onChangeText,\n  placeholder,\n  className,\n  style,\n  wrapperClassName,\n  wrapperStyle,\n  ...rest\n}: {\n  value: string\n  onChangeText: (text: string) => void\n  placeholder?: string\n  className?: string\n  style?: StyleProp<TextStyle>\n  wrapperClassName?: string\n  wrapperStyle?: StyleProp<ViewStyle>\n} & TextInputProps) {\n  return (\n    <View\n      className={cn(\n        \"relative h-10 flex-row items-center rounded-lg bg-tertiary-system-fill px-3\",\n        wrapperClassName,\n      )}\n      style={wrapperStyle}\n    >\n      <TextInput\n        className={cn(\"w-full flex-1 p-0 text-label\", className)}\n        clearButtonMode=\"always\"\n        style={style}\n        value={value}\n        onChangeText={onChangeText}\n        placeholder={placeholder}\n        {...rest}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/overlay/Overlay.tsx",
    "content": "import type { FC } from \"react\"\nimport { FadeIn, FadeOut } from \"react-native-reanimated\"\n\nimport { ReAnimatedPressable } from \"../../common/AnimatedComponents\"\n\nexport const Overlay: FC<{ onPress: () => void }> = ({ onPress }) => {\n  return (\n    <ReAnimatedPressable\n      entering={FadeIn}\n      exiting={FadeOut}\n      className={\"absolute inset-0 bg-black/50\"}\n      onPress={onPress}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/IosItemPressable.ios.tsx",
    "content": "import { requireNativeView } from \"expo\"\nimport { cssInterop } from \"nativewind\"\nimport type { ViewProps } from \"react-native\"\n\nconst NativeItemPressable = requireNativeView<\n  ViewProps & {\n    onItemPress?: () => any\n    touchHighlight?: boolean\n  }\n>(\"ItemPressable\")\ncssInterop(NativeItemPressable, {\n  className: \"style\",\n})\nexport { NativeItemPressable }\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/IosItemPressable.tsx",
    "content": "import type { FC } from \"react\"\nimport type { ViewProps } from \"react-native\"\n\nconst NativeItemPressable: FC<\n  ViewProps & {\n    onItemPress?: () => any\n    touchHighlight?: boolean\n  }\n> = () => {\n  throw new Error(\"NativeItemPressable is not supported on iOS\")\n}\nexport { NativeItemPressable }\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/ItemPressable.ios.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { FC } from \"react\"\nimport { memo } from \"react\"\nimport type { ViewProps } from \"react-native\"\n\nimport { ItemPressableStyle } from \"./enum\"\nimport { NativeItemPressable } from \"./IosItemPressable\"\n\nexport interface ItemPressableProps extends ViewProps {\n  itemStyle?: ItemPressableStyle\n  touchHighlight?: boolean\n  onPress?: () => any\n}\n\nexport const ItemPressable: FC<ItemPressableProps> = memo(\n  ({ children, itemStyle = ItemPressableStyle.Grouped, className, ...props }) => {\n    const isUnStyled = itemStyle === ItemPressableStyle.UnStyled\n    return (\n      <NativeItemPressable\n        {...props}\n        className={\n          isUnStyled\n            ? className\n            : cn(\n                \"relative overflow-hidden\",\n\n                itemStyle === ItemPressableStyle.Plain\n                  ? \"bg-system-background\"\n                  : \"bg-secondary-system-grouped-background\",\n                className,\n              )\n        }\n        touchHighlight={props.touchHighlight ?? true}\n        onItemPress={() => {\n          props.onPress?.()\n        }}\n      >\n        {children}\n      </NativeItemPressable>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/ItemPressable.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { cn, composeEventHandlers } from \"@follow/utils\"\nimport type { FC } from \"react\"\nimport { Fragment, memo } from \"react\"\nimport type { PressableProps } from \"react-native\"\nimport { StyleSheet } from \"react-native\"\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  useAnimatedStyle,\n  useSharedValue,\n  withSequence,\n  withSpring,\n} from \"react-native-reanimated\"\n\nimport { gentleSpringPreset } from \"@/src/constants/spring\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { ReAnimatedPressable } from \"../../common/AnimatedComponents\"\nimport { ItemPressableStyle } from \"./enum\"\n\nexport interface ItemPressableProps extends PressableProps {\n  itemStyle?: ItemPressableStyle\n  touchHighlight?: boolean\n}\n\nexport const ItemPressable: FC<ItemPressableProps> = memo(\n  ({ children, itemStyle = ItemPressableStyle.Grouped, touchHighlight = true, ...props }) => {\n    const secondarySystemGroupedBackground = useColor(\"secondarySystemGroupedBackground\")\n    const plainBackground = useColor(\"systemBackground\")\n\n    const itemNormalColor =\n      itemStyle === ItemPressableStyle.Plain ? plainBackground : secondarySystemGroupedBackground\n\n    const systemFill = useColor(\"systemFill\")\n    const pressed = useSharedValue(0)\n\n    const colorStyle = useAnimatedStyle(() => {\n      return {\n        backgroundColor: interpolateColor(pressed.value, [0, 1], [itemNormalColor, systemFill]),\n      }\n    })\n\n    const isUnStyled = itemStyle === ItemPressableStyle.UnStyled\n\n    return (\n      <ReAnimatedPressable\n        {...props}\n        onPress={composeEventHandlers(props.onPress, () => {\n          cancelAnimation(pressed)\n          pressed.value = withSequence(\n            withSpring(1, { duration: 0.2 }),\n            withSpring(0, gentleSpringPreset),\n          )\n        })}\n        // This is a workaround to prevent context menu crash when release too quickly\n        // https://github.com/nandorojo/zeego/issues/61\n        onLongPress={composeEventHandlers(props.onLongPress, () => {})}\n        delayLongPress={props.delayLongPress ?? 100}\n        className={cn(\"relative overflow-hidden\", props.className)}\n        style={StyleSheet.flatten([\n          props.style,\n          !isUnStyled && { backgroundColor: itemNormalColor },\n        ])}\n      >\n        {useTypeScriptHappyCallback(\n          (props) => {\n            return (\n              <Fragment>\n                {touchHighlight && (\n                  <Animated.View className=\"absolute inset-0\" style={colorStyle} />\n                )}\n                {typeof children === \"function\" ? children(props) : children}\n              </Fragment>\n            )\n          },\n          [children, colorStyle],\n        )}\n      </ReAnimatedPressable>\n    )\n  },\n)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/NativePressable.ios.tsx",
    "content": "import { NativeItemPressable } from \"./IosItemPressable\"\nimport type { NativePressableProps } from \"./NativePressable.types\"\n\nexport const NativePressable = ({ children, onPress, ...props }: NativePressableProps) => {\n  return (\n    <NativeItemPressable touchHighlight={false} onItemPress={onPress} {...props}>\n      {children}\n    </NativeItemPressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/NativePressable.tsx",
    "content": "import { Pressable } from \"react-native\"\n\nimport type { NativePressableProps } from \"./NativePressable.types\"\n\n/**\n * In order to resolve the conflict between the gesture handling of the native view and the RCTSurfaceGestureHandler in React Native.\n *\n */\nexport const NativePressable = ({ children, ...props }: NativePressableProps) => {\n  return <Pressable {...props}>{children}</Pressable>\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/NativePressable.types.tsx",
    "content": "import type { ViewProps } from \"react-native\"\n\nexport interface NativePressableProps extends ViewProps {\n  onPress?: () => any\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/pressable/enum.ts",
    "content": "export enum ItemPressableStyle {\n  UnStyled,\n  Plain,\n  Grouped,\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/LICENSE",
    "content": "react-native-qrcode-styled\n\nMIT License\n\nCopyright (c) 2022 Daniel Tokkozhin\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/QRCode.tsx",
    "content": "import type { Ref } from \"react\"\nimport { useEffect, useMemo } from \"react\"\nimport type { SvgProps } from \"react-native-svg\"\nimport { Defs, G, Svg } from \"react-native-svg\"\n\nimport type { QRCodeMessage, QRCodeOptions } from \"./adapter\"\nimport { EYES_POSITIONS, INNER_EYE_SIZE_IN_BITS, OUTER_EYE_SIZE_IN_BITS } from \"./constants\"\nimport SVGPieces, { DEFAULT_PIECE_SIZE } from \"./SVGPieces\"\nimport { SVGRadialGradient } from \"./SVGRadialGradient\"\nimport type {\n  AllEyesOptions,\n  BitMatrix,\n  EyeOptions,\n  EyePosition,\n  GradientOrigin,\n  PieceOptions,\n  RenderCustomPieceItem,\n} from \"./types\"\nimport useQRCodeData from \"./useQRCodeData\"\n\nfunction transformEyeOptionsToCommonPattern(\n  options?: EyeOptions | AllEyesOptions,\n): AllEyesOptions | undefined {\n  if (!options) {\n    return undefined\n  }\n\n  if (Object.keys(options).find((key) => EYES_POSITIONS.includes(key))) {\n    return options as AllEyesOptions\n  }\n\n  return Object.fromEntries(EYES_POSITIONS.map((position) => [position, options]))\n}\n\n// x, y - indexes in matrix\nexport function getPieceSquarePathData(x: number, y: number, size: number): string {\n  const _x = x * size\n  const _y = y * size\n\n  return `\n    M${_x} ${_y}\n    ${_x + size} ${_y}\n    ${_x + size} ${_y + size}\n    ${_x} ${_y + size}\n    z\n  `\n}\n\nexport interface SVGQRCodeStyledProps\n  extends QRCodeOptions, PieceOptions, Omit<SvgProps, \"children\"> {\n  data?: QRCodeMessage\n  onChangeSize?: (size: number) => void\n  pieceLiquidRadius?: number\n  outerEyesOptions?: EyeOptions | AllEyesOptions\n  innerEyesOptions?: EyeOptions | AllEyesOptions\n  renderCustomPieceItem?: RenderCustomPieceItem\n  isPiecesGlued?: boolean\n  padding?: number\n  ref?: Ref<Svg | null>\n  children?: (pieceSize: number, bitMatrix: BitMatrix) => SvgProps[\"children\"]\n}\n\nexport function QRCode(\n  {\n    data = \"\",\n    onChangeSize,\n    pieceSize = DEFAULT_PIECE_SIZE,\n    pieceScale,\n    pieceRotation,\n    pieceCornerType = \"rounded\",\n    pieceBorderRadius = 0,\n    pieceStroke,\n    pieceStrokeWidth,\n    pieceLiquidRadius,\n    isPiecesGlued = false,\n    outerEyesOptions,\n    innerEyesOptions,\n    renderCustomPieceItem,\n    padding,\n    color = \"black\",\n    gradient,\n\n    version,\n    maskPattern,\n    toSJISFunc,\n    errorCorrectionLevel = \"M\",\n    children,\n\n    ref,\n    ...props\n  }: SVGQRCodeStyledProps,\n  // ref?: ForwardedRef<Svg> | null,\n) {\n  const qrCodeOptions = useMemo(\n    () => ({\n      version,\n      errorCorrectionLevel,\n      maskPattern,\n      toSJISFunc,\n    }),\n    [errorCorrectionLevel, maskPattern, toSJISFunc, version],\n  )\n  const { qrCodeSize, bitMatrix } = useQRCodeData(data, qrCodeOptions)\n  const svgSize = pieceSize * qrCodeSize\n\n  useEffect(() => {\n    onChangeSize?.(qrCodeSize)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [qrCodeSize])\n\n  const transformedOuterEyesOptions = transformEyeOptionsToCommonPattern(outerEyesOptions)\n  const transformedInnerEyesOptions = transformEyeOptionsToCommonPattern(innerEyesOptions)\n\n  const _props = { ...props }\n  if (padding) {\n    const _size = svgSize + 2 * padding\n    _props.width = _size\n    _props.height = _size\n    _props.viewBox = `-${padding} -${padding} ${_size} ${_size}`\n  }\n\n  const startGradientOuterEyeCoords: Record<EyePosition, GradientOrigin> = {\n    topLeft: [0, 0],\n    topRight: [svgSize - pieceSize * OUTER_EYE_SIZE_IN_BITS, 0],\n    bottomLeft: [0, svgSize - pieceSize * OUTER_EYE_SIZE_IN_BITS],\n  }\n\n  const startGradientInnerEyeCoords: Record<EyePosition, GradientOrigin> = {\n    topLeft: [2 * pieceSize, 2 * pieceSize],\n    topRight: [svgSize - pieceSize * INNER_EYE_SIZE_IN_BITS + 2 * pieceSize, 2 * pieceSize],\n    bottomLeft: [2 * pieceSize, svgSize - pieceSize * OUTER_EYE_SIZE_IN_BITS + 2 * pieceSize],\n  }\n\n  const renderPieces = () => (\n    <SVGPieces\n      bitMatrix={bitMatrix}\n      isPiecesGlued={isPiecesGlued}\n      pieceLiquidRadius={pieceLiquidRadius}\n      pieceBorderRadius={pieceBorderRadius}\n      pieceCornerType={pieceCornerType}\n      pieceRotation={pieceRotation}\n      pieceScale={pieceScale}\n      pieceSize={pieceSize}\n      pieceStroke={pieceStroke}\n      pieceStrokeWidth={pieceStrokeWidth}\n      outerEyesOptions={transformedOuterEyesOptions}\n      innerEyesOptions={transformedInnerEyesOptions}\n      renderCustomPieceItem={renderCustomPieceItem}\n    />\n  )\n\n  return (\n    <Svg ref={ref} width={svgSize} height={svgSize} {..._props}>\n      {(!!gradient || !!transformedOuterEyesOptions || !!transformedInnerEyesOptions) && (\n        <Defs>\n          {!!gradient && <SVGRadialGradient id=\"gradient\" size={svgSize} {...gradient} />}\n\n          {!!transformedOuterEyesOptions &&\n            Object.keys(transformedOuterEyesOptions).map((key) => {\n              return (\n                <SVGRadialGradient\n                  id={`${key}CornerSquareGradient`}\n                  key={`${key}CornerSquareGradient`}\n                  size={pieceSize * OUTER_EYE_SIZE_IN_BITS}\n                  origin={startGradientOuterEyeCoords[key as EyePosition]}\n                  {...transformedOuterEyesOptions?.[key as EyePosition]?.gradient}\n                />\n              )\n            })}\n\n          {!!transformedInnerEyesOptions &&\n            Object.keys(transformedInnerEyesOptions).map((key) => {\n              return (\n                <SVGRadialGradient\n                  id={`${key}CornerDotGradient`}\n                  key={`${key}CornerDotGradient`}\n                  size={pieceSize * INNER_EYE_SIZE_IN_BITS}\n                  origin={startGradientInnerEyeCoords[key as EyePosition]}\n                  {...transformedInnerEyesOptions?.[key as EyePosition]?.gradient}\n                />\n              )\n            })}\n        </Defs>\n      )}\n\n      <G fill={gradient ? \"url(#gradient)\" : color}>{renderPieces()}</G>\n\n      {children?.(pieceSize, bitMatrix)}\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/SVGPieces.tsx",
    "content": "import * as React from \"react\"\nimport { memo } from \"react\"\nimport { Path } from \"react-native-svg\"\n\nimport { INNER_EYE_SIZE_IN_BITS, OUTER_EYE_SIZE_IN_BITS } from \"./constants\"\nimport {\n  getInnerEyePathData,\n  getOuterEyePathData,\n  getPieceLiquidPathData,\n  getPieceRoundedSquarePathData,\n  getPieceSquarePathData,\n  getRoundedInnerEyePathData,\n  getRoundedOuterEyePathData,\n  isCoordsOfInnerEyes,\n  isCoordsOfOuterEyes,\n  isLiquidPieceInEyes,\n  transformBorderRadiusToArray,\n} from \"./helper\"\nimport type {\n  AllEyesOptions,\n  BitMatrix,\n  BorderRadius,\n  EyePosition,\n  LogoArea,\n  PieceOptions,\n  RenderCustomPieceItem,\n} from \"./types\"\n\nexport const DEFAULT_PIECE_SIZE = 5\n\ninterface SVGPiecesProps extends PieceOptions {\n  bitMatrix: BitMatrix\n  pieceLiquidRadius?: number\n  pieceBorderRadius?: BorderRadius\n  outerEyesOptions?: AllEyesOptions\n  innerEyesOptions?: AllEyesOptions\n  isPiecesGlued?: boolean\n  renderCustomPieceItem?: RenderCustomPieceItem\n  logoArea?: LogoArea\n}\n\nfunction SVGPiecesImpl({\n  bitMatrix,\n  pieceLiquidRadius = 0,\n  pieceBorderRadius,\n  pieceSize = DEFAULT_PIECE_SIZE,\n  pieceCornerType,\n  pieceScale,\n  pieceRotation,\n  pieceStroke,\n  pieceStrokeWidth,\n  outerEyesOptions,\n  innerEyesOptions,\n  isPiecesGlued = false,\n  renderCustomPieceItem,\n  logoArea,\n}: SVGPiecesProps) {\n  if (!bitMatrix || !bitMatrix[0]) {\n    return null\n  }\n\n  const qrSize = bitMatrix.length * pieceSize\n  const svgPiecesMatrix: React.ReactElement[] = []\n\n  if (renderCustomPieceItem) {\n    for (let y = 0; y < bitMatrix.length; y++) {\n      for (let x = 0; x < bitMatrix.length; x++) {\n        const PieceElement = renderCustomPieceItem({ x, y, pieceSize, qrSize, bitMatrix })\n\n        if (PieceElement) {\n          svgPiecesMatrix.push(PieceElement)\n        }\n      }\n    }\n\n    return <>{svgPiecesMatrix}</>\n  }\n\n  const transformedPieceBorderRadius = transformBorderRadiusToArray(pieceBorderRadius)\n\n  for (let y = 0; y < bitMatrix.length; y++) {\n    for (let x = 0; x < bitMatrix.length; x++) {\n      // Not showing any shapes overlapping with logo if QR has it\n      if (logoArea) {\n        const _x = x * pieceSize\n        const _y = y * pieceSize\n        if (\n          logoArea.x < _x + pieceSize &&\n          logoArea.x + logoArea.width > _x &&\n          logoArea.y < _y + pieceSize &&\n          logoArea.y + logoArea.height > _y\n        ) {\n          continue\n        }\n      }\n\n      if (bitMatrix[y]?.[x] === 1) {\n        const origin = `\n          ${x * pieceSize + pieceSize / 2},\n          ${y * pieceSize + pieceSize / 2}`\n\n        let d = getPieceSquarePathData(x, y, pieceSize)\n\n        if (transformedPieceBorderRadius) {\n          d = getPieceRoundedSquarePathData({\n            x,\n            y,\n            size: pieceSize,\n            cornerType: pieceCornerType,\n            borderRadius: transformedPieceBorderRadius,\n            isGlued: isPiecesGlued,\n            isLiquid: !!pieceLiquidRadius,\n            bitMatrix,\n          })\n        }\n\n        const PathComponent = (\n          <Path\n            scale={pieceScale}\n            rotation={pieceRotation}\n            origin={origin}\n            stroke={pieceStroke}\n            strokeWidth={pieceStrokeWidth}\n            key={`${x}_${y}`}\n            d={d}\n          />\n        )\n\n        // not showing pieces if cornerSquaresOptions | cornerDotsOptions exist\n        if (outerEyesOptions || innerEyesOptions) {\n          if (\n            (outerEyesOptions &&\n              !innerEyesOptions &&\n              !isCoordsOfOuterEyes(x, y, bitMatrix.length)) ||\n            (!outerEyesOptions &&\n              innerEyesOptions &&\n              !isCoordsOfInnerEyes(x, y, bitMatrix.length)) ||\n            (innerEyesOptions &&\n              !isCoordsOfInnerEyes(x, y, bitMatrix.length) &&\n              outerEyesOptions &&\n              !isCoordsOfOuterEyes(x, y, bitMatrix.length))\n          ) {\n            svgPiecesMatrix.push(PathComponent)\n          }\n        } else {\n          svgPiecesMatrix.push(PathComponent)\n        }\n      } else {\n        // adding liquid between bits in empty places\n        if (\n          pieceLiquidRadius &&\n          ((outerEyesOptions && !isLiquidPieceInEyes(x, y, bitMatrix.length)) || !outerEyesOptions)\n        ) {\n          const d = getPieceLiquidPathData(x, y, pieceSize, pieceLiquidRadius)\n          const origin = `\n            ${x * pieceSize + pieceSize / 2},\n            ${y * pieceSize + pieceSize / 2}`\n\n          if (bitMatrix[y]?.[x - 1] === 1 && bitMatrix[y - 1]?.[x] === 1) {\n            svgPiecesMatrix.push(<Path key={`${x}_${y}_topLeft`} d={d} />)\n          }\n\n          if (bitMatrix[y]?.[x - 1] === 1 && bitMatrix[y + 1]?.[x] === 1) {\n            svgPiecesMatrix.push(\n              <Path rotation={-90} origin={origin} key={`${x}_${y}_topRight`} d={d} />,\n            )\n          }\n\n          if (bitMatrix[y]?.[x + 1] === 1 && bitMatrix[y - 1]?.[x] === 1) {\n            svgPiecesMatrix.push(\n              <Path rotation={90} origin={origin} key={`${x}_${y}_bottomRight`} d={d} />,\n            )\n          }\n\n          if (bitMatrix[y]?.[x + 1] === 1 && bitMatrix[y + 1]?.[x] === 1) {\n            svgPiecesMatrix.push(\n              <Path rotation={180} origin={origin} key={`${x}_${y}_bottomLeft`} d={d} />,\n            )\n          }\n        }\n      }\n    }\n  }\n\n  // adding custom corner squares if options is exist\n  if (outerEyesOptions) {\n    const listPositions: EyePosition[] = [\"topLeft\", \"topRight\", \"bottomLeft\"]\n    const origins = {\n      topLeft: `${(pieceSize * OUTER_EYE_SIZE_IN_BITS) / 2}, ${\n        (pieceSize * OUTER_EYE_SIZE_IN_BITS) / 2\n      }`,\n      topRight: `${qrSize - (pieceSize * OUTER_EYE_SIZE_IN_BITS) / 2}, ${\n        (pieceSize * OUTER_EYE_SIZE_IN_BITS) / 2\n      }`,\n      bottomLeft: `${(pieceSize * OUTER_EYE_SIZE_IN_BITS) / 2}, ${\n        qrSize - (pieceSize * OUTER_EYE_SIZE_IN_BITS) / 2\n      }`,\n    }\n\n    listPositions.forEach((position) => {\n      let d = getOuterEyePathData(position, pieceSize, qrSize)\n\n      if (Object.keys(outerEyesOptions).includes(position)) {\n        const transformedOuterEyeBorderRadius = transformBorderRadiusToArray(\n          outerEyesOptions[position]?.borderRadius,\n        )\n\n        if (transformedOuterEyeBorderRadius) {\n          d = getRoundedOuterEyePathData(\n            position,\n            transformedOuterEyeBorderRadius,\n            pieceSize,\n            bitMatrix.length * pieceSize,\n          )\n        }\n      }\n\n      svgPiecesMatrix.push(\n        <Path\n          fill={\n            outerEyesOptions?.[position]?.gradient\n              ? `url(#${position}OuterEyeGradient)`\n              : outerEyesOptions?.[position]?.color || undefined\n          }\n          fillRule=\"evenodd\"\n          stroke={outerEyesOptions?.[position]?.stroke}\n          strokeWidth={outerEyesOptions?.[position]?.strokeWidth}\n          scale={outerEyesOptions?.[position]?.scale}\n          rotation={outerEyesOptions?.[position]?.rotation}\n          origin={origins[position]}\n          key={`${position}OuterEye`}\n          d={d}\n        />,\n      )\n    })\n  }\n\n  // adding custom corner dots if options is exist\n  if (innerEyesOptions) {\n    const listPositions: EyePosition[] = [\"topLeft\", \"topRight\", \"bottomLeft\"]\n    const origins = {\n      topLeft: `${pieceSize * 2 + (pieceSize * INNER_EYE_SIZE_IN_BITS) / 2}, ${\n        pieceSize * 2 + (pieceSize * INNER_EYE_SIZE_IN_BITS) / 2\n      }`,\n      topRight: `${qrSize - pieceSize * 2 - (pieceSize * INNER_EYE_SIZE_IN_BITS) / 2}, ${\n        pieceSize * 2 + (pieceSize * INNER_EYE_SIZE_IN_BITS) / 2\n      }`,\n      bottomLeft: `${pieceSize * 2 + (pieceSize * INNER_EYE_SIZE_IN_BITS) / 2}, ${\n        qrSize - pieceSize * 2 - (pieceSize * INNER_EYE_SIZE_IN_BITS) / 2\n      }`,\n    }\n\n    listPositions.forEach((position) => {\n      let d = getInnerEyePathData(position, pieceSize, bitMatrix.length * pieceSize)\n\n      if (Object.keys(innerEyesOptions).includes(position)) {\n        const transformedInnerEyeBorderRadius = transformBorderRadiusToArray(\n          innerEyesOptions[position]?.borderRadius,\n        )\n\n        if (transformedInnerEyeBorderRadius) {\n          d = getRoundedInnerEyePathData(\n            position,\n            transformedInnerEyeBorderRadius,\n            pieceSize,\n            bitMatrix.length * pieceSize,\n          )\n        }\n      }\n\n      svgPiecesMatrix.push(\n        <Path\n          fill={\n            innerEyesOptions?.[position]?.gradient\n              ? `url(#${position}InnerEyeGradient)`\n              : innerEyesOptions?.[position]?.color || undefined\n          }\n          stroke={innerEyesOptions?.[position]?.stroke}\n          strokeWidth={innerEyesOptions?.[position]?.strokeWidth}\n          scale={innerEyesOptions?.[position]?.scale}\n          rotation={innerEyesOptions?.[position]?.rotation}\n          origin={origins[position]}\n          key={`${position}InnerEye`}\n          d={d}\n        />,\n      )\n    })\n  }\n\n  return <>{svgPiecesMatrix}</>\n}\n\nexport default memo(SVGPiecesImpl)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/SVGRadialGradient.tsx",
    "content": "import * as React from \"react\"\nimport { RadialGradient, Stop } from \"react-native-svg\"\n\nimport type { GradientOrigin, RadialGradientProps } from \"./types\"\n\nconst DEFAULT_ORIGIN: GradientOrigin = [0, 0]\nconst DEFAULT_CENTER: GradientOrigin = [0.5, 0.5]\nconst DEFAULT_RADIUS: GradientOrigin = [1, 1]\nconst DEFAULT_COLORS: string[] = [\"black\", \"white\"]\nconst DEFAULT_LOCATIONS: number[] = [0, 1]\n\ninterface SVGRadialGradientProps extends RadialGradientProps {\n  id: string\n  size: number\n  origin?: GradientOrigin\n}\n\nexport function SVGRadialGradient({\n  id,\n  size,\n  origin = DEFAULT_ORIGIN,\n  center = DEFAULT_CENTER,\n  radius = DEFAULT_RADIUS,\n  colors = DEFAULT_COLORS,\n  locations = DEFAULT_LOCATIONS,\n}: SVGRadialGradientProps) {\n  return (\n    <RadialGradient\n      id={id}\n      gradientUnits=\"userSpaceOnUse\"\n      cx={center[0] * size + origin[0]}\n      cy={center[1] * size + origin[1]}\n      rx={radius[0] * size}\n      ry={radius[1] * size}\n    >\n      {colors?.map((color, colorIndex) => {\n        const offset = locations?.[colorIndex]\n        return (\n          <Stop\n            key={`${String(color)}-${String(offset)}`}\n            offset={offset}\n            stopColor={color}\n            stopOpacity=\"1\"\n          />\n        )\n      })}\n    </RadialGradient>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/adapter.ts",
    "content": "import type { QRCodeOptions } from \"qrcode\"\nimport QRC from \"qrcode\"\n\nimport type { BitArray, BitMatrix } from \"./types\"\n\nexport type { QRCodeErrorCorrectionLevel, QRCodeOptions } from \"qrcode\"\nexport type QRCodeMessage = string | QRC.QRCodeSegment[]\n\nexport function createQRCode(\n  message: QRCodeMessage,\n  options: QRCodeOptions,\n): { size: number; bitMatrix: BitMatrix } {\n  const QRCodeData = QRC.create(message, options)\n  const { size = 0, data = [] } = QRCodeData?.modules || {}\n  const bitArray = Array.from(data).map((bit) => (bit ? 1 : 0))\n  const bitMatrix = transformBitArrayToMatrix(bitArray, size)\n\n  return {\n    size,\n    bitMatrix,\n  }\n}\n\nfunction transformBitArrayToMatrix(bitArray: BitArray, qrCodeSize: number): BitMatrix {\n  const matrix: BitArray[] = []\n  let row: BitArray = []\n\n  for (const [i, element] of bitArray.entries()) {\n    row.push(element || 0)\n\n    if ((i + 1) % qrCodeSize === 0) {\n      matrix.push([...row])\n      row = []\n    }\n  }\n\n  return matrix\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/constants.ts",
    "content": "export const OUTER_EYE_SIZE_IN_BITS = 7\nexport const INNER_EYE_SIZE_IN_BITS = 3\n\nexport const EYES_POSITIONS = [\"topLeft\", \"topRight\", \"bottomLeft\"]\n\n// QR code error collection percentages\nexport const QR_ECL_PERS = {\n  L: 0.03,\n  M: 0.06,\n  Q: 0.1,\n  H: 0.14,\n  low: 0.03,\n  medium: 0.06,\n  quartile: 0.1,\n  high: 0.14,\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/helper.ts",
    "content": "import { INNER_EYE_SIZE_IN_BITS, OUTER_EYE_SIZE_IN_BITS } from \"./constants\"\nimport type { BitMatrix, BorderRadius, CornerType, EyePosition } from \"./types\"\n\nexport function transformBorderRadiusToArray(borderRadius?: BorderRadius): number[] | undefined {\n  if (!borderRadius) {\n    return undefined\n  }\n\n  if (Array.isArray(borderRadius)) {\n    return borderRadius.length === 0 ? undefined : borderRadius\n  }\n\n  return Array.from({ length: 4 }, () => borderRadius)\n}\n\n// x, y - indexes in matrix\nexport function getPieceSquarePathData(x: number, y: number, size: number): string {\n  const _x = x * size\n  const _y = y * size\n\n  return `\n    M${_x} ${_y}\n    ${_x + size} ${_y}\n    ${_x + size} ${_y + size}\n    ${_x} ${_y + size}\n    z\n  `\n}\n\n// x, y - indexes in matrix\nexport function getPieceRoundedSquarePathData({\n  x,\n  y,\n  size,\n  cornerType,\n  borderRadius,\n  isGlued,\n  isLiquid,\n  bitMatrix,\n}: {\n  x: number\n  y: number\n  size: number\n  cornerType?: CornerType\n  borderRadius?: number[]\n  isGlued?: boolean\n  isLiquid?: boolean\n  bitMatrix: BitMatrix\n}): string {\n  const _x = x * size\n  const _y = y * size\n  const isCornerTypeCut = cornerType === \"cut\"\n  let [topLeftR = 0, topRightR = 0, bottomRightR = 0, bottomLeftR = 0] = borderRadius || []\n\n  const generateArcStart = (cornerPosition: number) =>\n    isCornerTypeCut ? \"L\" : `A${cornerPosition} ${cornerPosition} 0 0 1`\n\n  // check for surrounding pieces & remove related corner border radius\n  if (isGlued) {\n    if (bitMatrix[y]?.[x - 1] === 1) {\n      topLeftR = 0\n      bottomLeftR = 0\n    }\n    if (bitMatrix[y - 1]?.[x] === 1) {\n      topLeftR = 0\n      topRightR = 0\n    }\n    if (bitMatrix[y]?.[x + 1] === 1) {\n      topRightR = 0\n      bottomRightR = 0\n    }\n    if (bitMatrix[y + 1]?.[x] === 1) {\n      bottomLeftR = 0\n      bottomRightR = 0\n    }\n  }\n\n  if (isLiquid) {\n    if (bitMatrix[y - 1]?.[x - 1] === 1) {\n      topLeftR = 0\n    }\n    if (bitMatrix[y - 1]?.[x + 1] === 1) {\n      topRightR = 0\n    }\n    if (bitMatrix[y + 1]?.[x + 1] === 1) {\n      bottomRightR = 0\n    }\n    if (bitMatrix[y + 1]?.[x - 1] === 1) {\n      bottomLeftR = 0\n    }\n  }\n\n  // render svg if we have list of different border radius\n  return `\n    M${_x} ${_y + topLeftR}\n    ${generateArcStart(topLeftR)} ${_x + topLeftR} ${_y}\n    L${_x + size - topRightR} ${_y}\n    ${generateArcStart(topRightR)} ${_x + size} ${_y + topRightR}\n    L${_x + size} ${_y + size - bottomRightR}\n    ${generateArcStart(bottomRightR)}  ${_x + size - bottomRightR} ${_y + size}\n    L${_x + bottomLeftR} ${_y + size}\n    ${generateArcStart(bottomLeftR)}  ${_x} ${_y + size - bottomLeftR}\n    z\n  `\n}\n\nexport function getPieceLiquidPathData(\n  x: number,\n  y: number,\n  size: number,\n  borderRadius: number,\n): string {\n  const _x = x * size\n  const _y = y * size\n  const r = Math.min(borderRadius, size)\n\n  return `\n      M${_x} ${_y}\n      L${_x + r} ${_y}\n      A${r} ${r} 0 0 0 ${_x} ${_y + r} z`\n}\n\nexport function getOuterEyePathData(\n  position: EyePosition,\n  pieceSize: number,\n  qrSize: number,\n): string {\n  const outerEyeSize = OUTER_EYE_SIZE_IN_BITS * pieceSize\n\n  if (position === \"topLeft\") {\n    return `\n      M0 0\n      ${outerEyeSize} 0\n      ${outerEyeSize} ${outerEyeSize}\n      0 ${outerEyeSize} z\n      M${pieceSize} ${pieceSize}\n      ${outerEyeSize - pieceSize} ${pieceSize}\n      ${outerEyeSize - pieceSize} ${outerEyeSize - pieceSize}\n      ${pieceSize} ${outerEyeSize - pieceSize} z\n    `\n  }\n\n  if (position === \"topRight\") {\n    return `\n      M${qrSize - outerEyeSize} 0\n      ${qrSize} 0\n      ${qrSize} ${outerEyeSize}\n      ${qrSize - outerEyeSize} ${outerEyeSize} z\n      M${qrSize - outerEyeSize + pieceSize} ${pieceSize}\n      ${qrSize - pieceSize} ${pieceSize}\n      ${qrSize - pieceSize} ${outerEyeSize - pieceSize}\n      ${qrSize - outerEyeSize + pieceSize} ${outerEyeSize - pieceSize} z\n    `\n  }\n\n  if (position === \"bottomLeft\") {\n    return `\n      M0 ${qrSize - outerEyeSize}\n      ${outerEyeSize} ${qrSize - outerEyeSize}\n      ${outerEyeSize} ${qrSize}\n      0 ${qrSize} z\n      M${pieceSize} ${qrSize - outerEyeSize + pieceSize}\n      ${outerEyeSize - pieceSize} ${qrSize - outerEyeSize + pieceSize}\n      ${outerEyeSize - pieceSize} ${qrSize - pieceSize}\n      ${pieceSize} ${qrSize - pieceSize} z\n    `\n  }\n\n  return \"\"\n}\n\nexport function getRoundedOuterEyePathData(\n  position: EyePosition,\n  borderRadius: number[],\n  pieceSize: number,\n  qrSize: number,\n): string {\n  const outerEyeSize = OUTER_EYE_SIZE_IN_BITS * pieceSize\n  const [topLeftR = 0, topRightR = 0, bottomRightR = 0, bottomLeftR = 0] = borderRadius || []\n  const topLeftInnerR = pieceSize < topLeftR ? topLeftR - pieceSize : 0\n  const topRightInnerR = pieceSize < topRightR ? topRightR - pieceSize : 0\n  const bottomRightInnerR = pieceSize < bottomRightR ? bottomRightR - pieceSize : 0\n  const bottomLeftInnerR = pieceSize < bottomLeftR ? bottomLeftR - pieceSize : 0\n\n  if (position === \"topLeft\") {\n    return `\n      M0 ${topLeftR}\n      A${topLeftR} ${topLeftR} 0 0 1 ${topLeftR} 0\n      L${outerEyeSize - topRightR} 0\n      A${topRightR} ${topRightR} 0 0 1 ${outerEyeSize} ${topRightR}\n      L${outerEyeSize} ${outerEyeSize - bottomRightR}\n      A${bottomRightR} ${bottomRightR} 0 0 1 ${outerEyeSize - bottomRightR} ${outerEyeSize}\n      L${bottomLeftR} ${outerEyeSize}\n      ${bottomLeftR ? `A${bottomLeftR} ${bottomLeftR} 0 0 1 0 ${outerEyeSize - bottomLeftR}` : \"\"}\n      z\n      M${pieceSize} ${pieceSize + topLeftInnerR}\n      A${topLeftInnerR} ${topLeftInnerR} 0 0 1 ${pieceSize + topLeftInnerR} ${pieceSize}\n      L${outerEyeSize - pieceSize - topRightInnerR} ${pieceSize}\n      A${topRightInnerR} ${topRightInnerR} 0 0 1 ${outerEyeSize - pieceSize} ${\n        pieceSize + topRightInnerR\n      }\n      L${outerEyeSize - pieceSize} ${outerEyeSize - pieceSize - bottomRightInnerR}\n      A${bottomRightInnerR} ${bottomRightInnerR} 0 0 1 ${\n        outerEyeSize - pieceSize - bottomRightInnerR\n      } ${outerEyeSize - pieceSize}\n      L${pieceSize + bottomLeftInnerR} ${outerEyeSize - pieceSize}\n      A${bottomLeftInnerR} ${bottomLeftInnerR} 0 0 1 ${pieceSize} ${\n        outerEyeSize - pieceSize - bottomLeftInnerR\n      }\n      z\n    `\n  }\n\n  if (position === \"topRight\") {\n    return `\n      M${qrSize - outerEyeSize} ${topLeftR}\n      ${topLeftR ? `A${topLeftR} ${topLeftR} 0 0 1 ${qrSize - outerEyeSize + topLeftR} 0` : \"\"}\n      L${qrSize - topRightR} 0\n      ${topRightR ? `A${topRightR} ${topRightR} 0 0 1 ${qrSize} ${topRightR}` : \"\"}\n      L${qrSize} ${outerEyeSize - bottomRightR}\n      A${bottomRightR} ${bottomRightR} 0 0 1 ${qrSize - bottomRightR} ${outerEyeSize}\n      L${qrSize - outerEyeSize + bottomLeftR} ${outerEyeSize}\n      A${bottomLeftR} ${bottomLeftR} 0 0 1 ${qrSize - outerEyeSize} ${outerEyeSize - bottomLeftR}\n      z\n      M${qrSize - outerEyeSize + pieceSize} ${pieceSize + topLeftInnerR}\n      A${topLeftInnerR} ${topLeftInnerR} 0 0 1 ${\n        qrSize - outerEyeSize + pieceSize + topLeftInnerR\n      } ${pieceSize}\n      L${qrSize - pieceSize - topRightInnerR} ${pieceSize}\n      A${topRightInnerR} ${topRightInnerR} 0 0 1 ${qrSize - pieceSize} ${pieceSize + topRightInnerR}\n      L${qrSize - pieceSize} ${outerEyeSize - pieceSize - bottomRightInnerR}\n      A${bottomRightInnerR} ${bottomRightInnerR} 0 0 1 ${qrSize - pieceSize - bottomRightInnerR} ${\n        outerEyeSize - pieceSize\n      }\n      L${qrSize - outerEyeSize + pieceSize + bottomLeftInnerR} ${outerEyeSize - pieceSize}\n      A${bottomLeftInnerR} ${bottomLeftInnerR} 0 0 1 ${qrSize - outerEyeSize + pieceSize} ${\n        outerEyeSize - pieceSize - bottomLeftInnerR\n      }\n      z\n    `\n  }\n\n  if (position === \"bottomLeft\") {\n    return `\n      M0 ${qrSize - outerEyeSize + topLeftR}\n      A${topLeftR} ${topLeftR} 0 0 1 ${topLeftR} ${qrSize - outerEyeSize}\n      L${outerEyeSize - topRightR} ${qrSize - outerEyeSize}\n      A${topRightR} ${topRightR} 0 0 1 ${outerEyeSize} ${qrSize - outerEyeSize + topRightR}\n      L${outerEyeSize} ${qrSize - bottomRightR}\n      A${bottomRightR} ${bottomRightR} 0 0 1 ${outerEyeSize - bottomRightR} ${qrSize}\n      L${bottomLeftR} ${qrSize}\n      A${bottomLeftR} ${bottomLeftR} 0 0 1 0 ${qrSize - bottomLeftR}\n      z\n      M${pieceSize} ${qrSize - outerEyeSize + pieceSize + topLeftInnerR}\n      A${topLeftInnerR} ${topLeftInnerR} 0 0 1 ${pieceSize + topLeftInnerR} ${\n        qrSize - outerEyeSize + pieceSize\n      }\n      L${outerEyeSize - pieceSize - topRightInnerR} ${qrSize - outerEyeSize + pieceSize}\n      A${topRightInnerR} ${topRightInnerR} 0 0 1 ${outerEyeSize - pieceSize} ${\n        qrSize - outerEyeSize + pieceSize + topRightInnerR\n      }\n      L${outerEyeSize - pieceSize} ${qrSize - pieceSize - bottomRightInnerR}\n      A${bottomRightInnerR} ${bottomRightInnerR} 0 0 1 ${\n        outerEyeSize - pieceSize - bottomRightInnerR\n      } ${qrSize - pieceSize}\n      L${pieceSize + bottomLeftInnerR} ${qrSize - pieceSize}\n      A${bottomLeftInnerR} ${bottomLeftInnerR} 0 0 1 ${pieceSize} ${\n        qrSize - pieceSize - bottomLeftInnerR\n      }\n      z\n    `\n  }\n\n  return \"\"\n}\n\nexport function getInnerEyePathData(\n  position: EyePosition,\n  pieceSize: number,\n  qrSize: number,\n): string {\n  const outerSize = OUTER_EYE_SIZE_IN_BITS * pieceSize\n  const innerSize = INNER_EYE_SIZE_IN_BITS * pieceSize\n  const offset = 2 * pieceSize\n\n  if (position === \"topLeft\") {\n    return `\n      M${offset} ${offset}\n      ${offset + innerSize} ${offset}\n      ${offset + innerSize} ${offset + innerSize}\n      ${offset} ${offset + innerSize} z\n    `\n  }\n\n  if (position === \"topRight\") {\n    return `\n      M${qrSize - outerSize + offset} ${offset}\n      ${qrSize - offset} ${offset}\n      ${qrSize - offset} ${offset + innerSize}\n      ${qrSize - outerSize + offset} ${offset + innerSize} z\n    `\n  }\n\n  if (position === \"bottomLeft\") {\n    return `\n      M${offset} ${qrSize - outerSize + offset}\n      ${offset + innerSize} ${qrSize - outerSize + offset}\n      ${offset + innerSize} ${qrSize - offset}\n      ${offset} ${qrSize - offset} z\n    `\n  }\n\n  return \"\"\n}\n\nexport function getRoundedInnerEyePathData(\n  position: EyePosition,\n  borderRadius: number[],\n  pieceSize: number,\n  qrSize: number,\n): string {\n  const outerSize = OUTER_EYE_SIZE_IN_BITS * pieceSize\n  const innerSize = INNER_EYE_SIZE_IN_BITS * pieceSize\n  const offset = 2 * pieceSize\n  const [topLeftR = 0, topRightR = 0, bottomRightR = 0, bottomLeftR = 0] = borderRadius || []\n\n  if (position === \"topLeft\") {\n    return `\n      M${offset} ${offset + topLeftR}\n      A${topLeftR} ${topLeftR} 0 0 1 ${offset + topLeftR} ${offset}\n      L${offset + innerSize - topRightR} ${offset}\n      A${topRightR} ${topRightR} 0 0 1 ${offset + innerSize} ${offset + topRightR}\n      L${offset + innerSize} ${offset + innerSize - bottomRightR}\n      A${bottomRightR} ${bottomRightR} 0 0 1 ${offset + innerSize - bottomRightR} ${\n        offset + innerSize\n      }\n      L${offset + bottomLeftR} ${offset + innerSize} \n      A${bottomLeftR} ${bottomLeftR} 0 0 1 ${offset} ${offset + innerSize - bottomLeftR} z\n    `\n  }\n\n  if (position === \"topRight\") {\n    return `\n      M${qrSize - outerSize + offset} ${offset + topLeftR}\n      A${topLeftR} ${topLeftR} 0 0 1 ${qrSize - outerSize + offset + topLeftR} ${offset}\n      L${qrSize - offset - topRightR} ${offset}\n      A${topRightR} ${topRightR} 0 0 1 ${qrSize - offset} ${offset + topRightR}\n      L${qrSize - offset} ${offset + innerSize - bottomRightR}\n      A${bottomRightR} ${bottomRightR} 0 0 1 ${qrSize - offset - bottomRightR} ${offset + innerSize}\n      L${qrSize - outerSize + offset + bottomLeftR} ${offset + innerSize} \n      A${bottomLeftR} ${bottomLeftR} 0 0 1 ${qrSize - outerSize + offset} ${\n        offset + innerSize - bottomLeftR\n      } z\n    `\n  }\n\n  if (position === \"bottomLeft\") {\n    return `\n      M${offset} ${qrSize - outerSize + offset + topLeftR}\n      A${topLeftR} ${topLeftR} 0 0 1 ${offset + topLeftR} ${qrSize - outerSize + offset}\n      L${offset + innerSize - topRightR} ${qrSize - outerSize + offset}\n      A${topRightR} ${topRightR} 0 0 1 ${offset + innerSize} ${\n        qrSize - outerSize + offset + topRightR\n      }\n      L${offset + innerSize} ${qrSize - offset - bottomRightR}\n      A${bottomRightR} ${bottomRightR} 0 0 1 ${offset + innerSize - bottomRightR} ${qrSize - offset}\n      L${offset + bottomLeftR} ${qrSize - offset} \n      A${bottomLeftR} ${bottomLeftR} 0 0 1 ${offset} ${qrSize - offset - bottomLeftR} z\n    `\n  }\n\n  return \"\"\n}\n\nexport function isLiquidPieceInEyes(x: number, y: number, qrSize: number): boolean {\n  return (\n    // top left square\n    (x >= 1 && x < 6 && y >= 1 && y < 6) ||\n    // top right square\n    (x >= qrSize - 6 && x < qrSize && y >= 1 && y < 6) ||\n    // bottom left square\n    (x >= 1 && x < 6 && y >= qrSize - 6 && y < qrSize)\n  )\n}\n\nexport function isCoordsOfTopLeftOuterEye(x: number, y: number): boolean {\n  return (\n    (x >= 0 && x < OUTER_EYE_SIZE_IN_BITS && y === 0) ||\n    (x >= 0 && x < OUTER_EYE_SIZE_IN_BITS && y === OUTER_EYE_SIZE_IN_BITS - 1) ||\n    (y > 0 && y < OUTER_EYE_SIZE_IN_BITS - 1 && x === 0) ||\n    (y > 0 && y < OUTER_EYE_SIZE_IN_BITS - 1 && x === OUTER_EYE_SIZE_IN_BITS - 1)\n  )\n}\n\nexport function isCoordsOfTopRightOuterEye(x: number, y: number, qrSize: number): boolean {\n  return (\n    (x >= qrSize - OUTER_EYE_SIZE_IN_BITS && x < qrSize && y === 0) ||\n    (x >= qrSize - OUTER_EYE_SIZE_IN_BITS && x < qrSize && y === OUTER_EYE_SIZE_IN_BITS - 1) ||\n    (y > 0 && y < OUTER_EYE_SIZE_IN_BITS - 1 && x === qrSize - OUTER_EYE_SIZE_IN_BITS) ||\n    (y > 0 && y < OUTER_EYE_SIZE_IN_BITS - 1 && x === qrSize - 1)\n  )\n}\n\nexport function isCoordsOfBottomLeftOuterEye(x: number, y: number, qrSize: number): boolean {\n  return (\n    (x >= 0 && x < OUTER_EYE_SIZE_IN_BITS && y === qrSize - OUTER_EYE_SIZE_IN_BITS) ||\n    (x >= 0 && x < OUTER_EYE_SIZE_IN_BITS && y === qrSize - 1) ||\n    (y > qrSize - OUTER_EYE_SIZE_IN_BITS && y < qrSize && x === 0) ||\n    (y > qrSize - OUTER_EYE_SIZE_IN_BITS && y < qrSize && x === OUTER_EYE_SIZE_IN_BITS - 1)\n  )\n}\n\n// x, y is amount of matrix bits\nexport function isCoordsOfOuterEyes(x: number, y: number, qrSize: number): boolean {\n  return (\n    // top left square\n    isCoordsOfTopLeftOuterEye(x, y) ||\n    // top right square\n    isCoordsOfTopRightOuterEye(x, y, qrSize) ||\n    // bottom left square\n    isCoordsOfBottomLeftOuterEye(x, y, qrSize)\n  )\n}\n\nexport function isCoordsOfTopLeftInnerEye(x: number, y: number): boolean {\n  return x >= 2 && x < INNER_EYE_SIZE_IN_BITS + 2 && y >= 2 && y < INNER_EYE_SIZE_IN_BITS + 2\n}\n\nexport function isCoordsOfTopRightInnerEye(x: number, y: number, qrSize: number): boolean {\n  return (\n    x >= qrSize - OUTER_EYE_SIZE_IN_BITS + 2 &&\n    x < qrSize - 2 &&\n    y >= 2 &&\n    y < INNER_EYE_SIZE_IN_BITS + 2\n  )\n}\n\nexport function isCoordsOfBottomLeftInnerEye(x: number, y: number, qrSize: number): boolean {\n  return (\n    x >= 2 &&\n    x < INNER_EYE_SIZE_IN_BITS + 2 &&\n    y >= qrSize - OUTER_EYE_SIZE_IN_BITS + 2 &&\n    y < qrSize - 2\n  )\n}\n\n// x, y is amount of matrix bits\nexport function isCoordsOfInnerEyes(x: number, y: number, qrSize: number): boolean {\n  return (\n    // top left square\n    isCoordsOfTopLeftInnerEye(x, y) ||\n    // top right square\n    isCoordsOfTopRightInnerEye(x, y, qrSize) ||\n    // bottom left square\n    isCoordsOfBottomLeftInnerEye(x, y, qrSize)\n  )\n}\n\nexport function consoleWarn(message: string | unknown) {\n  console.warn(`QRCode warning: ${message}`)\n}\n\nexport function consoleError(message: string | unknown) {\n  console.error(`QRCode error: ${message}`)\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/types.ts",
    "content": "import type * as React from \"react\"\nimport type { ColorValue } from \"react-native\"\nimport type { ImageProps as SVGImageProps, PathProps } from \"react-native-svg\"\n\nexport type GradientOrigin = [number, number]\n\nexport type GradientType = \"linear\" | \"radial\" // default 'linear'\n\nexport type LinearGradientProps = {\n  colors?: ColorValue[]\n  start?: [number, number] // start point [x, y] (0 -> 0%, 1 -> 100%)\n  end?: [number, number] // end point [x, y] (0 -> 0%, 1 -> 100%)\n  locations?: number[] // list of colors positions (0 -> 0%, 1 -> 100%)\n}\n\nexport type RadialGradientProps = {\n  colors?: ColorValue[]\n  center?: [number, number] // center point [x, y] (0 -> 0%, 1 -> 100%)\n  radius?: [number, number] // radiusXY [x, y] (0 -> 0%, 1 -> 100%)\n  locations?: number[] // list of colors positions (0 -> 0%, 1 -> 100%)\n}\n\nexport type GradientProps = {\n  type?: GradientType\n  options?: LinearGradientProps | RadialGradientProps\n}\n\nexport type CornerType = \"rounded\" | \"cut\" // default 'rounded'\n\nexport type BorderRadius = number | number[]\n\nexport type Bit = 0 | 1\n\nexport type BitArray = Bit[]\n\nexport type BitMatrix = BitArray[]\n\nexport type PieceOptions = {\n  pieceSize?: number\n  pieceScale?: PathProps[\"scale\"] // scaleXY | [scaleX, scaleY]\n  pieceRotation?: string | number\n  pieceCornerType?: CornerType\n  pieceBorderRadius?: BorderRadius\n  pieceStroke?: ColorValue\n  pieceStrokeWidth?: number\n  color?: ColorValue\n  gradient?: GradientProps\n}\n\nexport type EyePosition = \"topLeft\" | \"topRight\" | \"bottomLeft\"\n\nexport type EyeOptions = {\n  scale?: PathProps[\"scale\"] // scaleXY | [scaleX, scaleY]\n  rotation?: string | number\n  borderRadius?: BorderRadius\n  color?: ColorValue\n  gradient?: GradientProps\n  stroke?: ColorValue\n  strokeWidth?: number\n}\n\nexport type AllEyesOptions = Partial<Record<EyePosition, EyeOptions>>\n\nexport type RenderCustomPieceItem = ({\n  x,\n  y,\n  pieceSize,\n  qrSize,\n  bitMatrix,\n}: {\n  x: number\n  y: number\n  pieceSize: number\n  qrSize: number\n  bitMatrix: BitMatrix\n}) => React.ReactElement | null\n\nexport type LogoArea = {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport type LogoOptions = {\n  hidePieces?: boolean\n  padding?: number\n  scale?: number\n  onChange?: (logoArea?: LogoArea) => void\n} & SVGImageProps\n"
  },
  {
    "path": "apps/mobile/src/components/ui/qrcode/useQRCodeData.ts",
    "content": "import { useMemo } from \"react\"\n\nimport type { QRCodeMessage, QRCodeOptions } from \"./adapter\"\nimport { createQRCode } from \"./adapter\"\nimport type { BitMatrix } from \"./types\"\n\nexport default function useQRCodeData(\n  data: QRCodeMessage,\n  options: QRCodeOptions,\n): { bitMatrix: BitMatrix; qrCodeSize: number } {\n  const QRCodeData = useMemo(() => {\n    try {\n      return createQRCode(data, options)\n    } catch {\n      return\n    }\n  }, [data, options])\n\n  const { size: qrCodeSize = 0, bitMatrix = [] } = QRCodeData || {}\n\n  return {\n    bitMatrix,\n    qrCodeSize,\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/slider/Slider.tsx",
    "content": "import * as React from \"react\"\nimport { useCallback, useImperativeHandle, useMemo } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { Pressable, StyleSheet, View } from \"react-native\"\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\nimport Animated, {\n  runOnJS,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\n\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nexport interface SliderProps {\n  /**\n   * The current value of the slider\n   */\n  value?: number\n\n  /**\n   * The minimum value of the slider\n   */\n  minimumValue?: number\n\n  /**\n   * The maximum value of the slider\n   */\n  maximumValue?: number\n\n  /**\n   * Callback that is called when the user starts sliding\n   */\n  onSlidingStart?: () => void\n\n  /**\n   * Callback that is called when the user stops sliding\n   */\n  onSlidingComplete?: (value: number) => void\n\n  /**\n   * Callback that is called when the value changes\n   */\n  onValueChange?: (value: number) => void\n\n  /**\n   * The width of the thumb\n   */\n  thumbWidth?: number\n\n  /**\n   * The height of the track\n   */\n  trackHeight?: number\n\n  /**\n   * Custom style for the container\n   */\n  style?: StyleProp<ViewStyle>\n\n  /**\n   * Whether the slider is disabled\n   */\n  disabled?: boolean\n\n  /**\n   * Step value for the slider\n   */\n  step?: number\n}\n\nexport type SliderRef = {\n  setValue: (value: number) => void\n}\n\nconst THUMB_SIZE = 28\nconst TRACK_HEIGHT = 4\n\nexport const Slider = ({\n  ref,\n  value = 0,\n  minimumValue = 0,\n  maximumValue = 1,\n  onSlidingStart,\n  onSlidingComplete,\n  onValueChange,\n  thumbWidth = THUMB_SIZE,\n  trackHeight = TRACK_HEIGHT,\n  style,\n  disabled = false,\n  step,\n}: SliderProps & { ref?: React.Ref<SliderRef | null> }) => {\n  const trackWidth = useSharedValue(0)\n  const thumbPosition = useSharedValue(0)\n  const isSliding = useSharedValue(false)\n  const thumbScale = useSharedValue(1)\n\n  const activeColor = accentColor\n  const inactiveColor = useColor(\"gray4\")\n  const thumbColor = \"#FFFFFF\"\n  const thumbDimensionsStyle = useMemo(\n    () => ({\n      width: thumbWidth,\n      height: thumbWidth,\n      backgroundColor: thumbColor,\n    }),\n    [thumbWidth],\n  )\n\n  // Calculate thumb position based on value\n  const getThumbPosition = useCallback(\n    (val: number) => {\n      \"worklet\"\n      const clampedValue = Math.max(minimumValue, Math.min(maximumValue, val))\n      const percentage = (clampedValue - minimumValue) / (maximumValue - minimumValue)\n      return percentage * (trackWidth.value - thumbWidth)\n    },\n    [maximumValue, minimumValue, thumbWidth, trackWidth],\n  )\n\n  // Calculate value based on thumb position\n  const getValue = (position: number) => {\n    \"worklet\"\n    const percentage = position / (trackWidth.value - thumbWidth)\n    let val = minimumValue + percentage * (maximumValue - minimumValue)\n\n    if (step) {\n      val = Math.round(val / step) * step\n    }\n\n    return Math.max(minimumValue, Math.min(maximumValue, val))\n  }\n\n  // Update thumb position when value changes\n  React.useEffect(() => {\n    if (!isSliding.value) {\n      thumbPosition.value = withSpring(getThumbPosition(value))\n    }\n  }, [getThumbPosition, isSliding, thumbPosition, thumbWidth, trackWidth, value])\n\n  useImperativeHandle(ref, () => ({\n    setValue: (newValue: number) => {\n      thumbPosition.value = withSpring(getThumbPosition(newValue))\n    },\n  }))\n\n  const startPosition = useSharedValue(0)\n\n  const panGesture = Gesture.Pan()\n    .onBegin(() => {\n      if (disabled) return\n\n      startPosition.value = thumbPosition.value\n      isSliding.value = true\n      thumbScale.value = withSpring(1.2, { damping: 12, stiffness: 400 })\n\n      if (onSlidingStart) {\n        runOnJS(onSlidingStart)()\n      }\n    })\n    .onUpdate((event) => {\n      if (disabled) return\n\n      const newPosition = Math.max(\n        0,\n        Math.min(trackWidth.value - thumbWidth, startPosition.value + event.translationX),\n      )\n      thumbPosition.value = newPosition\n\n      if (onValueChange) {\n        const newValue = getValue(newPosition)\n        runOnJS(onValueChange)(newValue)\n      }\n    })\n    .onEnd(() => {\n      if (disabled) return\n\n      isSliding.value = false\n      thumbScale.value = withSpring(1, { damping: 12, stiffness: 400 })\n\n      if (onSlidingComplete) {\n        const finalValue = getValue(thumbPosition.value)\n        runOnJS(onSlidingComplete)(finalValue)\n      }\n    })\n\n  const trackAnimatedStyle = useAnimatedStyle(() => {\n    const activeWidth = thumbPosition.value + thumbWidth / 2\n\n    return {\n      width: activeWidth,\n    }\n  })\n\n  const thumbAnimatedStyle = useAnimatedStyle(() => {\n    return {\n      transform: [{ translateX: thumbPosition.value }, { scale: thumbScale.value }],\n    }\n  })\n\n  const onTrackPress = (event: any) => {\n    if (disabled) return\n\n    const { locationX } = event.nativeEvent\n    const newPosition = Math.max(\n      0,\n      Math.min(trackWidth.value - thumbWidth, locationX - thumbWidth / 2),\n    )\n\n    thumbPosition.value = withSpring(newPosition)\n\n    if (onValueChange) {\n      const newValue = getValue(newPosition)\n      onValueChange(newValue)\n    }\n  }\n\n  return (\n    <View style={[styles.container, style]}>\n      <Pressable\n        style={[styles.trackContainer, { height: Math.max(trackHeight, thumbWidth) }]}\n        onPress={onTrackPress}\n        onLayout={(event) => {\n          trackWidth.value = event.nativeEvent.layout.width\n          thumbPosition.value = getThumbPosition(value)\n        }}\n      >\n        {/* Background track */}\n        <View\n          style={[\n            styles.track,\n            {\n              backgroundColor: inactiveColor,\n              height: trackHeight,\n            },\n          ]}\n        />\n\n        {/* Active track */}\n        <Animated.View\n          style={[\n            styles.activeTrack,\n            {\n              backgroundColor: activeColor,\n              height: trackHeight,\n            },\n            trackAnimatedStyle,\n          ]}\n        />\n\n        {/* Thumb */}\n        <GestureDetector gesture={panGesture}>\n          <Animated.View\n            style={[\n              styles.thumb,\n              thumbDimensionsStyle,\n              disabled && styles.thumbDisabled,\n              thumbAnimatedStyle,\n            ]}\n          />\n        </GestureDetector>\n      </Pressable>\n    </View>\n  )\n}\n\nconst styles = StyleSheet.create({\n  container: {\n    justifyContent: \"center\",\n  },\n  trackContainer: {\n    justifyContent: \"center\",\n    position: \"relative\",\n  },\n  track: {\n    borderRadius: 2,\n    position: \"absolute\",\n    width: \"100%\",\n  },\n  activeTrack: {\n    borderRadius: 2,\n    position: \"absolute\",\n  },\n  thumb: {\n    borderRadius: THUMB_SIZE / 2,\n    position: \"absolute\",\n    shadowColor: \"#000000\",\n    shadowOffset: {\n      width: 0,\n      height: 2,\n    },\n    shadowOpacity: 0.15,\n    shadowRadius: 3,\n    elevation: 3,\n  },\n  thumbDisabled: {\n    opacity: 0.6,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/slider/index.tsx",
    "content": "export * from \"./Slider\"\n"
  },
  {
    "path": "apps/mobile/src/components/ui/switch/Switch.tsx",
    "content": "import { useImperativeHandle } from \"react\"\nimport type { SwitchChangeEvent } from \"react-native\"\nimport { Platform, StyleSheet, Switch as NativeSwitch } from \"react-native\"\n\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nexport interface SwitchProps {\n  onChange?: ((event: SwitchChangeEvent) => Promise<void> | void) | null | undefined\n\n  /**\n   * Invoked with the new value when the value changes.\n   */\n  onValueChange?: ((value: boolean) => Promise<void> | void) | null | undefined\n\n  /**\n   * The value of the switch. If true the switch will be turned on.\n   * Default value is false.\n   */\n  value?: boolean | undefined\n\n  size?: \"sm\" | \"default\"\n\n  disabled?: boolean | undefined\n\n  testID?: string | undefined\n}\n\nexport type SwitchRef = {\n  value: boolean\n}\nexport const Switch = ({\n  ref,\n  value = false,\n  onValueChange,\n  onChange,\n  size = \"default\",\n  disabled = false,\n  testID,\n}: SwitchProps & { ref?: React.Ref<SwitchRef | null> }) => {\n  const gray4 = useColor(\"gray4\")\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      value,\n    }),\n    [value],\n  )\n\n  return (\n    <NativeSwitch\n      disabled={disabled}\n      ios_backgroundColor={gray4}\n      onChange={onChange ?? undefined}\n      onValueChange={onValueChange ?? undefined}\n      style={size === \"sm\" ? styles.small : styles.default}\n      testID={testID}\n      thumbColor={Platform.OS === \"android\" ? \"#FFFFFF\" : undefined}\n      trackColor={{ false: gray4, true: accentColor }}\n      value={value}\n    />\n  )\n}\n\nconst styles = StyleSheet.create({\n  default: {\n    transform: [{ scaleX: 0.94 }, { scaleY: 0.94 }],\n  },\n  small: {\n    transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/tabview/TabBar.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { debounce } from \"es-toolkit/compat\"\nimport type { FC } from \"react\"\nimport { memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from \"react\"\nimport type {\n  Animated as AnimatedNative,\n  LayoutChangeEvent,\n  PressableProps,\n  StyleProp,\n  ViewStyle,\n} from \"react-native\"\nimport { Pressable, ScrollView, StyleSheet, View } from \"react-native\"\nimport Animated, { useAnimatedStyle, useSharedValue, withSpring } from \"react-native-reanimated\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport type { Tab } from \"./types\"\n\ninterface TabBarProps {\n  tabs: Tab[]\n  tabbarClassName?: string\n  tabbarStyle?: StyleProp<ViewStyle>\n  TabItem?: FC<\n    {\n      isSelected: boolean\n      tab: Tab\n    } & Pick<PressableProps, \"onLayout\">\n  >\n  onTabItemPress?: (index: number) => void\n  currentTab?: number\n  tabScrollContainerAnimatedX?: AnimatedNative.Value\n}\nconst springConfig = {\n  stiffness: 100,\n  damping: 10,\n}\nexport const TabBar = ({\n  ref,\n  tabs,\n  TabItem = Pressable,\n  tabbarClassName,\n  tabbarStyle,\n  onTabItemPress,\n  currentTab: tab,\n  tabScrollContainerAnimatedX: pagerOffsetX,\n}: TabBarProps & {\n  ref?: React.Ref<ScrollView | null>\n}) => {\n  const [currentTab, setCurrentTab] = useState(tab || 0)\n  const [tabWidths, setTabWidths] = useState<number[]>([])\n  const [tabPositions, setTabPositions] = useState<number[]>([])\n  const indicatorPosition = useSharedValue(0)\n  useEffect(() => {\n    if (typeof tab === \"number\") {\n      setCurrentTab(tab)\n    }\n  }, [tab])\n  const sharedPagerOffsetX = useSharedValue(0)\n  const [tabBarWidth, setTabBarWidth] = useState(0)\n  useEffect(() => {\n    if (pagerOffsetX) {\n      return\n    }\n    sharedPagerOffsetX.value = withSpring(currentTab * tabBarWidth, springConfig)\n  }, [currentTab, pagerOffsetX, sharedPagerOffsetX, tabBarWidth])\n  useEffect(() => {\n    if (!pagerOffsetX) return\n    const id = pagerOffsetX.addListener(({ value }) => {\n      sharedPagerOffsetX.value = value\n    })\n    return () => {\n      pagerOffsetX.removeListener(id)\n    }\n  }, [pagerOffsetX, sharedPagerOffsetX])\n  const tabRef = useRef<ScrollView>(null)\n  const handleChangeTabIndex = useCallback(\n    (index: number) => {\n      setCurrentTab(index)\n      onTabItemPress?.(index)\n    },\n    [onTabItemPress],\n  )\n  useEffect(() => {\n    if (!pagerOffsetX) return\n    const listener = pagerOffsetX.addListener(\n      debounce(({ value }) => {\n        // Calculate which tab should be active based on scroll position\n        const tabIndex = Math.round(value / tabBarWidth)\n        if (tabIndex !== currentTab) {\n          handleChangeTabIndex(tabIndex)\n        }\n      }, 36),\n    )\n    return () => pagerOffsetX.removeListener(listener)\n  }, [currentTab, handleChangeTabIndex, onTabItemPress, pagerOffsetX, tabBarWidth])\n  useImperativeHandle(ref, () => tabRef.current!)\n  useEffect(() => {\n    if (tabWidths.length > 0) {\n      indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig)\n    }\n  }, [currentTab, indicatorPosition, tabPositions, tabWidths.length])\n  const tabBarScrollX = useRef(0)\n  useEffect(() => {\n    // If the current tab is not within the visible range of the scrollview, then scroll the scrollview to the visible area.\n    if (tabRef.current && tabPositions[currentTab] !== undefined && tabWidths[currentTab]) {\n      const tabPosition = tabPositions[currentTab]\n      const tabWidth = tabWidths[currentTab]\n\n      // Get the current scroll position and visible width of the ScrollView\n      const scrollView = tabRef.current\n      const currentScrollX = tabBarScrollX.current\n      const visibleWidth = tabBarWidth\n\n      // Check if the tab is outside the visible area\n      const isTabOutsideView =\n        tabPosition < currentScrollX ||\n        // tab is to the left of visible area\n        tabPosition + tabWidth > currentScrollX + visibleWidth // tab is to the right\n\n      if (isTabOutsideView) {\n        // Add some padding to ensure the tab isn't right at the edge\n        const padding = 16\n        scrollView.scrollTo({\n          x: Math.max(0, tabPosition - padding),\n          animated: true,\n        })\n      }\n    }\n  }, [currentTab, sharedPagerOffsetX.value, tabPositions, tabWidths, tabBarWidth])\n  const handleTabItemLayout = useCallback((event: LayoutChangeEvent, index: number) => {\n    const { width, x } = event.nativeEvent.layout\n    setTabWidths((prev) => {\n      const newWidths = [...prev]\n      newWidths[index] = width - 16\n      return newWidths\n    })\n    setTabPositions((prev) => {\n      const newPositions = [...prev]\n      newPositions[index] = x + 12\n      return newPositions\n    })\n  }, [])\n  const indicatorStyle = useAnimatedStyle(() => {\n    const scrollProgress = Math.max(sharedPagerOffsetX.value / tabBarWidth, 0)\n    const currentIndex = Math.floor(scrollProgress)\n    const nextIndex = Math.min(currentIndex + 1, tabs.length - 1)\n    const progress = scrollProgress - currentIndex\n\n    // Interpolate between current and next tab positions\n    const xPosition =\n      tabPositions[currentIndex]! +\n      (tabPositions[nextIndex]! - tabPositions[currentIndex]!) * progress\n\n    // Interpolate between current and next tab widths\n    const width =\n      tabWidths[currentIndex]! + (tabWidths[nextIndex]! - tabWidths[currentIndex]!) * progress\n    return {\n      transform: [\n        {\n          translateX: Math.max(xPosition, 0),\n        },\n      ],\n      width,\n      backgroundColor: tabs[currentTab]!.activeColor || accentColor,\n    }\n  })\n  return (\n    <ScrollView\n      onLayout={(event) => {\n        setTabBarWidth(event.nativeEvent.layout.width)\n      }}\n      onScroll={(event) => {\n        tabBarScrollX.current = event.nativeEvent.contentOffset.x\n      }}\n      showsHorizontalScrollIndicator={false}\n      className={cn(\"relative shrink-0 grow-0 border-tertiary-system-background\", tabbarClassName)}\n      horizontal\n      ref={tabRef}\n      contentContainerStyle={styles.tabScroller}\n      style={[styles.root, tabbarStyle]}\n    >\n      <View className=\"flex-row gap-x-3\">\n        {tabs.map((tab, index) => (\n          <TarBarItem\n            TabItem={TabItem}\n            key={tab.value}\n            index={index}\n            onTabItemPress={handleChangeTabIndex}\n            onLayout={handleTabItemLayout}\n            isSelected={currentTab === index}\n            tab={tab}\n          />\n        ))}\n      </View>\n\n      <Animated.View style={[styles.indicator, indicatorStyle]} />\n    </ScrollView>\n  )\n}\nconst styles = StyleSheet.create({\n  tabScroller: {\n    alignItems: \"center\",\n    flexDirection: \"row\",\n    paddingHorizontal: 4,\n  },\n  root: {\n    paddingHorizontal: 6,\n  },\n  indicator: {\n    position: \"absolute\",\n    bottom: 0,\n    height: 2,\n    borderRadius: 1,\n  },\n})\nconst TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => {\n  const gray = useColor(\"gray\")\n  return (\n    <View className=\"px-3 py-2\">\n      <Text\n        style={{\n          color: isSelected ? accentColor : gray,\n        }}\n        className={cn(\"text-[15px] leading-none\", isSelected ? \"font-bold\" : \"font-medium\")}\n      >\n        {tab.name}\n      </Text>\n    </View>\n  )\n}\nconst TarBarItem: FC<{\n  TabItem: FC<\n    {\n      isSelected: boolean\n      tab: Tab\n    } & Pick<PressableProps, \"onLayout\">\n  >\n  onTabItemPress: (index: number) => void\n  isSelected: boolean\n  tab: Tab\n  index: number\n  onLayout: (event: LayoutChangeEvent, index: number) => void\n}> = memo(({ TabItem = Pressable, onTabItemPress, isSelected, tab, onLayout, index }) => {\n  return (\n    <TabItem\n      onPress={() => {\n        onTabItemPress(index)\n      }}\n      key={tab.value}\n      isSelected={isSelected}\n      onLayout={(event) => {\n        onLayout(event, index)\n      }}\n      tab={tab}\n      className=\"py-1\"\n    >\n      <TabItemInner tab={tab} isSelected={isSelected} />\n    </TabItem>\n  )\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/tabview/TabView.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { FC } from \"react\"\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\"\nimport type { PressableProps, ScrollView, StyleProp, ViewStyle } from \"react-native\"\nimport {\n  Animated as RnAnimated,\n  Pressable,\n  useAnimatedValue,\n  useWindowDimensions,\n  View,\n} from \"react-native\"\nimport type { ViewProps } from \"react-native-svg/lib/typescript/fabric/utils\"\n\nimport { AnimatedScrollView } from \"../../common/AnimatedComponents\"\nimport { TabBar } from \"./TabBar\"\n\ntype Tab = {\n  name: string\n  activeColor?: string\n  value: string\n}\n\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport type TabComponent<T extends {} = {}> = FC<\n  { isSelected: boolean; tab: Tab } & T & Pick<ViewProps, \"onLayout\">\n>\ninterface TabViewProps {\n  tabs: Tab[]\n  Tab?: TabComponent\n  TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick<PressableProps, \"onLayout\">>\n  initialTab?: number\n  onTabChange?: (tab: number) => void\n\n  // styles\n  tabbarClassName?: string\n  tabbarStyle?: StyleProp<ViewStyle>\n  scrollerStyle?: StyleProp<ViewStyle>\n  scrollerContainerStyle?: StyleProp<ViewStyle>\n  scrollerContainerClassName?: string\n  scrollerClassName?: string\n\n  lazyTab?: boolean\n  lazyOnce?: boolean\n}\n\nexport const TabView: FC<TabViewProps> = ({\n  tabs,\n  Tab = View,\n  TabItem = Pressable,\n  initialTab,\n  onTabChange,\n\n  tabbarClassName,\n  tabbarStyle,\n  scrollerClassName,\n  scrollerStyle,\n  scrollerContainerStyle,\n  scrollerContainerClassName,\n\n  lazyOnce,\n  lazyTab,\n}) => {\n  const [currentTab, setCurrentTab] = useState(initialTab ?? 0)\n\n  const pagerOffsetX = useAnimatedValue(0)\n\n  const { width: windowWidth } = useWindowDimensions()\n\n  const [lazyTabSet, setLazyTabSet] = useState(() => new Set<number>())\n\n  const shouldRenderCurrentTab = (index: number) => {\n    if (!lazyTab) return true\n    if (index === currentTab) return true\n    if (lazyOnce && lazyTabSet.has(index)) return true\n    return lazyTabSet.has(index)\n  }\n\n  useEffect(() => {\n    setLazyTabSet((prev) => {\n      const newSet = new Set(prev)\n      newSet.add(currentTab)\n      return newSet\n    })\n  }, [currentTab])\n\n  const contentScrollerRef = useRef<ScrollView>(null)\n\n  return (\n    <>\n      <TabBar\n        onTabItemPress={useCallback(\n          (index: number) => {\n            contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true })\n            setCurrentTab(index)\n            onTabChange?.(index)\n          },\n          [onTabChange, windowWidth],\n        )}\n        tabs={tabs}\n        currentTab={currentTab}\n        tabbarClassName={tabbarClassName}\n        tabbarStyle={tabbarStyle}\n        TabItem={TabItem}\n        tabScrollContainerAnimatedX={pagerOffsetX}\n      />\n\n      <AnimatedScrollView\n        onScroll={RnAnimated.event([{ nativeEvent: { contentOffset: { x: pagerOffsetX } } }], {\n          useNativeDriver: true,\n        })}\n        ref={contentScrollerRef}\n        horizontal\n        pagingEnabled\n        className={cn(\"flex-1\", scrollerClassName)}\n        style={scrollerStyle}\n        contentContainerStyle={scrollerContainerStyle}\n        contentContainerClassName={scrollerContainerClassName}\n        showsHorizontalScrollIndicator={false}\n        nestedScrollEnabled\n      >\n        {tabs.map((tab, index) => (\n          <View className=\"flex-1\" style={{ width: windowWidth }} key={tab.value}>\n            {shouldRenderCurrentTab(index) && (\n              <TabComponent\n                key={tab.value}\n                tab={tab}\n                isSelected={currentTab === index}\n                Tab={Tab as TabComponent}\n              />\n            )}\n          </View>\n        ))}\n      </AnimatedScrollView>\n    </>\n  )\n}\n\nconst TabComponent: FC<{\n  tab: Tab\n  isSelected: boolean\n  Tab: TabComponent\n}> = memo(({ tab, isSelected, Tab = View }) => {\n  const { width: windowWidth } = useWindowDimensions()\n  return (\n    <View className=\"flex-1\" style={{ width: windowWidth }} key={tab.value}>\n      <Tab isSelected={isSelected} tab={tab} />\n    </View>\n  )\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/tabview/types.ts",
    "content": "export type Tab = {\n  name: string\n  activeColor?: string\n  value: string\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/toast/CenteredToast.tsx",
    "content": "import { withOpacity } from \"@follow/utils\"\nimport { createElement, use, useEffect, useState } from \"react\"\nimport { StyleSheet, View } from \"react-native\"\nimport Animated, { FadeOut } from \"react-native-reanimated\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport { toastTypeToIcons } from \"./constants\"\nimport { ToastActionContext } from \"./ctx\"\nimport type { ToastProps } from \"./types\"\n\nexport const CenteredToast = (props: ToastProps) => {\n  const renderMessage = props.render ? null : props.message ? (\n    <Text className=\"font-semibold text-white\">{props.message}</Text>\n  ) : null\n  const { register } = use(ToastActionContext)\n  useEffect(() => {\n    const disposer = register(props.currentIndex, {\n      dismiss: async () => {},\n    })\n    return () => {\n      disposer()\n    }\n  }, [props.currentIndex, register])\n  const renderIcon =\n    props.icon === false\n      ? null\n      : (props.icon ?? (\n          <View className=\"mr-2\">\n            {createElement(toastTypeToIcons[props.type], {\n              color: \"white\",\n              height: 20,\n              width: 20,\n            })}\n          </View>\n        ))\n  const [measureHeight, setMeasureHeight] = useState(-1)\n  return (\n    <Animated.View\n      onLayout={({ nativeEvent }) => {\n        setMeasureHeight(nativeEvent.layout.height)\n      }}\n      exiting={FadeOut}\n      style={StyleSheet.flatten([\n        styles.toast,\n        measureHeight === -1 ? styles.hidden : {},\n        measureHeight > 50 ? styles.rounded : styles.roundedFull,\n      ])}\n    >\n      {renderIcon}\n      {renderMessage}\n    </Animated.View>\n  )\n}\nconst styles = StyleSheet.create({\n  toast: {\n    borderWidth: StyleSheet.hairlineWidth,\n    flexDirection: \"row\",\n    paddingHorizontal: 16,\n    paddingVertical: 12,\n    alignItems: \"center\",\n    borderColor: withOpacity(\"#ffffff\", 0.3),\n    backgroundColor: withOpacity(\"#000000\", 0.9),\n  },\n  hidden: {\n    opacity: 0,\n  },\n  rounded: {\n    borderRadius: 16,\n  },\n  roundedFull: {\n    borderRadius: 9999,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/toast/ToastContainer.tsx",
    "content": "import { useAtomValue } from \"jotai\"\nimport { use, useMemo } from \"react\"\nimport { View } from \"react-native\"\n\nimport { CenteredToast } from \"./CenteredToast\"\nimport { ToastContainerContext } from \"./ctx\"\nimport type { ToastProps } from \"./types\"\n\nexport const ToastContainer = () => {\n  const stackAtom = use(ToastContainerContext)\n  const stack = useAtomValue(stackAtom)\n\n  const { renderCenterReplaceToast, renderBottomStackToasts } = useMemo(() => {\n    const { centerToasts, bottomToasts } = stack.reduce(\n      (acc, toast) => {\n        if (toast.variant === \"center-replace\") {\n          acc.centerToasts.push(toast)\n        } else if (toast.variant === \"bottom-stack\") {\n          acc.bottomToasts.push(toast)\n        }\n        return acc\n      },\n      { centerToasts: [] as ToastProps[], bottomToasts: [] as ToastProps[] },\n    )\n\n    const renderCenterReplaceToast =\n      centerToasts.length > 0\n        ? centerToasts.reduce((latest, toast) =>\n            latest.currentIndex > toast.currentIndex ? latest : toast,\n          )\n        : null\n\n    const renderBottomStackToasts = bottomToasts.sort((a, b) => a.currentIndex - b.currentIndex)\n\n    return { renderCenterReplaceToast, renderBottomStackToasts }\n  }, [stack])\n\n  void renderBottomStackToasts\n\n  return (\n    <View className=\"absolute inset-0\" pointerEvents=\"box-only\">\n      {/* Center replace container */}\n      <View className=\"absolute inset-0 items-center justify-center px-5\" pointerEvents=\"box-only\">\n        {renderCenterReplaceToast && <CenteredToast {...renderCenterReplaceToast} />}\n      </View>\n      {/* Bottom stack */}\n      {/* <View className=\"absolute bottom-safe\" pointerEvents=\"box-only\"></View> */}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/toast/constants.ts",
    "content": "import { CheckCircleFilledIcon } from \"@/src/icons/check_circle_filled\"\nimport { CloseCircleFillIcon } from \"@/src/icons/close_circle_fill\"\nimport { InfoCircleFillIcon } from \"@/src/icons/info_circle_fill\"\n\nexport const toastTypeToIcons = {\n  success: CheckCircleFilledIcon,\n  error: CloseCircleFillIcon,\n  info: InfoCircleFillIcon,\n} as const\n"
  },
  {
    "path": "apps/mobile/src/components/ui/toast/ctx.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext } from \"react\"\n\nimport type { ToastProps, ToastRef } from \"./types\"\n\nexport const ToastContainerContext = createContext<PrimitiveAtom<ToastProps[]>>(null!)\n\ntype Disposer = () => void\ninterface ToastActionContext {\n  register: (currentIndex: number, ref: ToastRef) => Disposer\n}\n\nexport const ToastActionContext = createContext<ToastActionContext>(null!)\n"
  },
  {
    "path": "apps/mobile/src/components/ui/toast/manager.tsx",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { atom, Provider } from \"jotai\"\nimport RootSiblings from \"react-native-root-siblings\"\n\nimport { FullWindowOverlay } from \"../../common/FullWindowOverlay\"\nimport { ToastActionContext, ToastContainerContext } from \"./ctx\"\nimport { ToastContainer } from \"./ToastContainer\"\nimport type { BottomToastProps, CenterToastProps, ToastProps, ToastRef } from \"./types\"\n\nexport class ToastManager {\n  private stackAtom = atom<ToastProps[]>([])\n  private portal: RootSiblings | null = null\n\n  private propsMap = {} as Record<number, ToastProps>\n  private currentIndex = 0\n\n  private defaultProps: Omit<ToastProps, \"currentIndex\"> = {\n    duration: 3000,\n    action: [],\n    type: \"info\",\n    variant: \"bottom-stack\",\n    message: \"\",\n    render: null,\n    icon: null,\n    canClose: true,\n  }\n\n  private toastRefs = {} as Record<number, ToastRef>\n\n  private register(currentIndex: number, ref: ToastRef) {\n    if (currentIndex in this.toastRefs) {\n      console.warn(\"Conflict toast ref\", currentIndex, this.toastRefs[currentIndex], ref)\n    }\n    this.toastRefs[currentIndex] = ref\n    return () => {\n      delete this.toastRefs[currentIndex]\n    }\n  }\n\n  mount() {\n    this.portal = new RootSiblings(\n      <FullWindowOverlay>\n        <Provider store={jotaiStore}>\n          <ToastContainerContext value={this.stackAtom}>\n            <ToastActionContext value={{ register: this.register.bind(this) }}>\n              <ToastContainer />\n            </ToastActionContext>\n          </ToastContainerContext>\n        </Provider>\n      </FullWindowOverlay>,\n    )\n  }\n\n  private push(props: ToastProps) {\n    this.propsMap[props.currentIndex] = props\n    jotaiStore.set(this.stackAtom, [...jotaiStore.get(this.stackAtom), props])\n  }\n\n  private remove(index: number) {\n    delete this.propsMap[index]\n    jotaiStore.set(\n      this.stackAtom,\n      jotaiStore.get(this.stackAtom).filter((toast) => toast.currentIndex !== index),\n    )\n  }\n\n  private scheduleDismiss(index: number) {\n    const props = this.propsMap[index]!\n\n    if (props.duration === Infinity) {\n      return\n    }\n\n    setTimeout(async () => {\n      await this.toastRefs[index]?.dismiss()\n      this.remove(index)\n    }, props.duration)\n  }\n\n  // @ts-expect-error\n  show(props: CenterToastProps): Promise<() => void>\n  show(props: BottomToastProps): Promise<() => void>\n  show(props: Omit<Partial<ToastProps>, \"currentIndex\">) {\n    if (!this.portal) {\n      this.mount()\n    }\n\n    const nextProps = { ...this.defaultProps, ...props }\n\n    if (nextProps.canClose === false) {\n      nextProps.duration = Infinity\n    }\n\n    if (nextProps.variant === \"center-replace\") {\n      // Find and remove the toast if it exists\n      const index = jotaiStore\n        .get(this.stackAtom)\n        .findIndex((toast) => toast.variant === \"center-replace\")\n      if (index !== -1) {\n        this.remove(index)\n      }\n    }\n\n    const currentIndex = ++this.currentIndex\n    this.push({ ...nextProps, currentIndex })\n    this.scheduleDismiss(currentIndex)\n    return () => this.remove(currentIndex)\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/toast/types.ts",
    "content": "export interface ToastProps {\n  currentIndex: number\n  variant: \"bottom-stack\" | \"center-replace\"\n  type: \"success\" | \"error\" | \"info\"\n  message: string\n  render: React.ReactNode\n\n  action: {\n    label: React.ReactNode\n    onPress: () => void\n    variant?: \"normal\" | \"destructive\"\n  }[]\n  duration: number\n  icon?: React.ReactNode | false\n  canClose?: boolean\n\n  position?: \"top\" | \"center\" | \"bottom\"\n}\n\nexport type CenterToastProps = Partial<\n  Pick<ToastProps, \"message\" | \"render\" | \"type\" | \"duration\" | \"icon\">\n> & {\n  variant: \"center-replace\"\n}\n\nexport type BottomToastProps = Partial<ToastProps> & {\n  variant: \"bottom-stack\"\n  canClose?: boolean\n}\n\nexport interface ToastRef {\n  dismiss: () => Promise<void>\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/typography/HtmlWeb.tsx",
    "content": "\"use dom\"\nimport \"@follow/components/assets/colors-media.css\"\nimport \"@follow/components/assets/tailwind.css\"\n\nimport type { HtmlProps } from \"@follow/components/ui/markdown/html.tsx\"\nimport { Html } from \"@follow/components/ui/markdown/html.tsx\"\nimport { useEffect } from \"react\"\n\nfunction useSize(callback: (size: [number, number]) => void) {\n  useEffect(() => {\n    const lastSize = [document.body.clientWidth, document.body.clientHeight] as [number, number]\n\n    // Observe window size changes\n    const observer = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const { width, height } = entry.contentRect\n\n        if (\n          width.toFixed(0) !== lastSize[0].toFixed(0) ||\n          height.toFixed(0) !== lastSize[1].toFixed(0)\n        ) {\n          lastSize[0] = width\n          lastSize[1] = height\n          callback([width, height])\n        }\n      }\n    })\n\n    observer.observe(document.body)\n\n    callback([document.body.clientWidth, document.body.clientHeight])\n\n    return () => {\n      observer.disconnect()\n    }\n  }, [callback])\n}\n\nexport default function HtmlWeb({\n  content,\n  dom,\n  onLayout,\n  ...options\n}: {\n  dom?: import(\"expo/dom\").DOMProps\n  onLayout: (size: [number, number]) => void\n} & HtmlProps) {\n  useSize(onLayout)\n  return <Html content={content} {...options} />\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/typography/MarkdownNative.tsx",
    "content": "import { useMemo } from \"react\"\n\nimport { renderMarkdown } from \"@/src/lib/markdown\"\n\nexport const MarkdownNative: WebComponent<{\n  value: string\n}> = ({ value }) => {\n  return useMemo(() => {\n    return renderMarkdown(value)\n  }, [value])\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/typography/MonoText.tsx",
    "content": "import type { TextProps } from \"react-native\"\nimport { Platform, StyleSheet, Text } from \"react-native\"\n\nexport const MonoText = ({\n  ref,\n  ...props\n}: TextProps & {\n  ref?: React.Ref<Text | null>\n}) => {\n  return <Text ref={ref} {...props} style={StyleSheet.flatten([props.style, styles.mono])} />\n}\nconst styles = StyleSheet.create({\n  mono: {\n    fontFamily: Platform.select({\n      ios: \"Menlo-Regular\",\n      android: \"monospace\",\n    }),\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ui/typography/Text.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { FC, Ref } from \"react\"\nimport type { TextProps } from \"react-native\"\nimport { Text as RNText } from \"react-native\"\n\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\n\nexport const Text: FC<TextProps & { ref?: Ref<RNText> }> = (props) => {\n  const systemFontScaling = useUISettingKey(\"useSystemFontScaling\")\n\n  return (\n    <RNText\n      {...props}\n      className={cn(\"text-base text-label\", props.className)}\n      allowFontScaling={systemFontScaling}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/video/PlayerAction.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useCallback } from \"react\"\nimport { StyleSheet, View } from \"react-native\"\n\nimport { ThemedBlurView } from \"@/src/components/common/ThemedBlurView\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { PauseCuteFiIcon } from \"@/src/icons/pause_cute_fi\"\nimport { PlayCuteFiIcon } from \"@/src/icons/play_cute_fi\"\nimport type { SimpleMediaState } from \"@/src/lib/player\"\n\nimport { NativePressable } from \"../pressable/NativePressable\"\n\ninterface PlayerActionProps {\n  /**\n   * This is the state of the media instead of the play button.\n   *\n   * When the media is paused, the play button should be shown.\n   */\n  mediaState: SimpleMediaState\n  onPress: () => void\n  className?: string\n  iconSize?: number\n  buttonClassName?: string\n}\n\nexport function PlayerAction({\n  mediaState,\n  onPress,\n  className = \"\",\n  iconSize = 24,\n  buttonClassName = \"\",\n}: PlayerActionProps) {\n  const handlePressPlay = useCallback(() => {\n    onPress()\n  }, [onPress])\n\n  let playButtonIcon = <PlayCuteFiIcon color=\"white\" width={iconSize} height={iconSize} />\n  switch (mediaState) {\n    case \"playing\": {\n      playButtonIcon = <PauseCuteFiIcon color=\"white\" width={iconSize} height={iconSize} />\n      break\n    }\n    case \"loading\": {\n      playButtonIcon = <PlatformActivityIndicator />\n      break\n    }\n  }\n\n  return (\n    <NativePressable\n      className={cn(\"absolute inset-0 flex items-center justify-center\", className)}\n      onPress={handlePressPlay}\n    >\n      <View className={cn(\"overflow-hidden rounded-full p-2\", buttonClassName)}>\n        <ThemedBlurView\n          useGlass\n          style={StyleSheet.absoluteFillObject}\n          intensity={30}\n          experimentalBlurMethod=\"none\"\n        />\n        {playButtonIcon}\n      </View>\n    </NativePressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ui/video/VideoPlayer.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useEvent } from \"expo\"\nimport type { VideoSource } from \"expo-video\"\nimport { useVideoPlayer, VideoView } from \"expo-video\"\nimport { useCallback, useMemo, useRef, useState } from \"react\"\nimport type { ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { isIOS } from \"@/src/lib/platform\"\n\nimport { PlayerAction } from \"./PlayerAction\"\n\nexport function VideoPlayer({\n  source,\n  placeholder,\n  width,\n  height,\n  view,\n}: {\n  source: VideoSource\n  placeholder?: React.ReactNode\n  width?: number\n  height?: number\n  view: FeedViewType\n}) {\n  const [isFullScreen, setIsFullScreen] = useState(false)\n  const videoViewRef = useRef<null | VideoView>(null)\n  const player = useVideoPlayer(source, (player) => {\n    player.loop = true\n    player.muted = true\n    // player.play()\n  })\n  const { status } = useEvent(player, \"statusChange\", { status: player.status })\n\n  const handlePressPlay = useCallback(() => {\n    if (!videoViewRef.current) {\n      console.warn(\"VideoView ref is not set\")\n      return\n    }\n    setIsFullScreen(true)\n    // Ensure the nativeControls is ready before entering fullscreen for Android\n    setTimeout(() => {\n      try {\n        videoViewRef.current?.enterFullscreen()\n        player.muted = false\n        player.play()\n      } catch (e) {\n        console.warn(\"VideoPlayer fullscreen failed:\", e)\n      }\n    }, 0)\n  }, [player])\n  const videoStyle = useMemo<ViewStyle>(\n    () => ({\n      width: view === FeedViewType.Pictures ? width : \"100%\",\n      height: view === FeedViewType.Pictures ? height : undefined,\n      aspectRatio: width && height ? width / height : 1,\n    }),\n    [height, view, width],\n  )\n\n  return (\n    <View className=\"flex flex-1\">\n      <VideoView\n        ref={videoViewRef}\n        style={videoStyle}\n        contentFit={isFullScreen ? \"contain\" : \"cover\"}\n        player={player}\n        allowsFullscreen\n        allowsPictureInPicture\n        // The Android native controls will be shown when the video is paused\n        nativeControls={isIOS || isFullScreen}\n        accessible={false}\n        onFullscreenEnter={() => {\n          setIsFullScreen(true)\n        }}\n        onFullscreenExit={() => {\n          setIsFullScreen(false)\n          try {\n            player.muted = true\n            player.pause()\n          } catch (e) {\n            console.warn(\"VideoPlayer pause failed:\", e)\n          }\n        }}\n      />\n      {status !== \"readyToPlay\" && <View className=\"absolute inset-0\">{placeholder}</View>}\n      <PlayerAction\n        iconSize={32}\n        mediaState={status === \"readyToPlay\" ? \"paused\" : \"loading\"}\n        onPress={handlePressPlay}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/constants/native-images.ts",
    "content": "import { BlackBoard2CuteFiIcon } from \"../icons/black_board_2_cute_fi\"\nimport { BlackBoard2CuteReIcon } from \"../icons/black_board_2_cute_re\"\nimport { Home5CuteFiIcon } from \"../icons/home_5_cute_fi\"\nimport { Home5CuteReIcon } from \"../icons/home_5_cute_re\"\nimport { Search3CuteFiIcon } from \"../icons/search_3_cute_fi\"\nimport { Search3CuteReIcon } from \"../icons/search_3_cute_re\"\nimport { Settings1CuteFiIcon } from \"../icons/settings_1_cute_fi\"\nimport { Settings1CuteReIcon } from \"../icons/settings_1_cute_re\"\n\nexport const IconNodeMap = {\n  home5CuteRe: Home5CuteReIcon,\n  home5CuteFi: Home5CuteFiIcon,\n  settings1CuteRe: Settings1CuteReIcon,\n  settings1CuteFi: Settings1CuteFiIcon,\n  blackBoard2CuteRe: BlackBoard2CuteReIcon,\n  blackBoard2CuteFi: BlackBoard2CuteFiIcon,\n  search3CuteRe: Search3CuteReIcon,\n  search3CuteFi: Search3CuteFiIcon,\n} as const\n\nexport const IconNativeNameMap = {\n  home5CuteRe: \"home_5_cute_re\",\n  home5CuteFi: \"home_5_cute_fi\",\n  settings1CuteRe: \"settings_1_cute_re\",\n  settings1CuteFi: \"settings_1_cute_fi\",\n  blackBoard2CuteRe: \"black_board_2_cute_re\",\n  blackBoard2CuteFi: \"black_board_2_cute_fi\",\n  search3CuteRe: \"search_3_cute_re\",\n  search3CuteFi: \"search_3_cute_fi\",\n} as const\n\nexport type IconNativeName = keyof typeof IconNativeNameMap\n\nexport type IconNativeValues = (typeof IconNativeNameMap)[keyof typeof IconNativeNameMap]\n"
  },
  {
    "path": "apps/mobile/src/constants/spring.ts",
    "content": "import type { WithSpringConfig } from \"react-native-reanimated\"\n\nexport const gentleSpringPreset: WithSpringConfig = {\n  damping: 15,\n  stiffness: 100,\n  mass: 1,\n}\n\nexport const softSpringPreset: WithSpringConfig = {\n  damping: 20,\n  stiffness: 80,\n  mass: 1,\n}\n\nexport const quickSpringPreset: WithSpringConfig = {\n  damping: 10,\n  stiffness: 200,\n  mass: 1,\n}\n"
  },
  {
    "path": "apps/mobile/src/constants/ui.ts",
    "content": "export const TIMELINE_VIEW_SELECTOR_HEIGHT = 58\n"
  },
  {
    "path": "apps/mobile/src/constants/views.tsx",
    "content": "import type { ViewDefinition as ViewDefinitionBase } from \"@follow/constants\"\nimport { FeedViewType, getViewList } from \"@follow/constants\"\n\nimport { AnnouncementCuteFiIcon } from \"../icons/announcement_cute_fi\"\nimport { BubbleCuteFiIcon } from \"../icons/bubble_cute_fi\"\nimport { MicCuteFiIcon } from \"../icons/mic_cute_fi\"\nimport { PaperCuteFiIcon } from \"../icons/paper_cute_fi\"\nimport { PicCuteFiIcon } from \"../icons/pic_cute_fi\"\nimport { ThoughtCuteFiIcon } from \"../icons/thought_cute_fi\"\nimport { VideoCuteFiIcon } from \"../icons/video_cute_fi\"\n\ninterface ViewDefinitionExtended {\n  icon: React.FC<{ color?: string; height?: number; width?: number }>\n}\n\nconst extendMap: Record<FeedViewType, ViewDefinitionExtended> = {\n  [FeedViewType.All]: {\n    icon: BubbleCuteFiIcon,\n  },\n  [FeedViewType.Articles]: {\n    icon: PaperCuteFiIcon,\n  },\n  [FeedViewType.SocialMedia]: {\n    icon: ThoughtCuteFiIcon,\n  },\n  [FeedViewType.Pictures]: {\n    icon: PicCuteFiIcon,\n  },\n  [FeedViewType.Videos]: {\n    icon: VideoCuteFiIcon,\n  },\n  [FeedViewType.Audios]: {\n    icon: MicCuteFiIcon,\n  },\n  [FeedViewType.Notifications]: {\n    icon: AnnouncementCuteFiIcon,\n  },\n}\n\nexport interface ViewDefinition extends Omit<ViewDefinitionBase, \"icon\">, ViewDefinitionExtended {}\n\nexport const views: ViewDefinition[] = getViewList().map((view) => {\n  const extendedView = extendMap[view.view]\n  return {\n    ...view,\n    ...extendedView,\n  }\n})\n"
  },
  {
    "path": "apps/mobile/src/database/index.ts",
    "content": "import * as FileSystem from \"expo-file-system/legacy\"\n\nexport const getDbPath = () => {\n  return `${FileSystem.documentDirectory}SQLite/follow.db`\n}\nif (__DEV__) console.info(\"SQLite:\", getDbPath())\n"
  },
  {
    "path": "apps/mobile/src/global.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "apps/mobile/src/hooks/useBackHandler.ts",
    "content": "import { useEffect } from \"react\"\nimport { BackHandler } from \"react-native\"\n\nimport { useLightboxControls } from \"../components/ui/lightbox/lightboxState\"\nimport { useCanDismiss, useNavigation } from \"../lib/navigation/hooks\"\nimport { isAndroid } from \"../lib/platform\"\n\nexport const useBackHandler = () => {\n  const navigation = useNavigation()\n  const canDismiss = useCanDismiss()\n  const { closeLightbox } = useLightboxControls()\n\n  useEffect(() => {\n    if (!isAndroid) return\n\n    // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener -- listener.remove() handles cleanup\n    const listener = BackHandler.addEventListener(\"hardwareBackPress\", () => {\n      const lightboxWasActive = closeLightbox()\n      if (lightboxWasActive) {\n        return true\n      }\n      if (canDismiss) {\n        navigation.dismiss()\n        return true\n      } else if (navigation.canGoBack()) {\n        navigation.back()\n        return true\n      }\n      return false\n    })\n\n    return () => {\n      listener.remove()\n    }\n  }, [canDismiss, closeLightbox, navigation])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useDefaultHeaderHeight.ts",
    "content": "// https://github.com/react-navigation/react-navigation/blob/main/packages/native-stack/src/views/NativeStackView.native.tsx\n\nimport { Platform } from \"react-native\"\nimport { useSafeAreaFrame, useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { getDefaultHeaderHeight } from \"../components/layouts/utils\"\n\nexport const useDefaultHeaderHeight = () => {\n  const insets = useSafeAreaInsets()\n  const frame = useSafeAreaFrame()\n\n  const isIPhone = Platform.OS === \"ios\" && !(Platform.isPad || Platform.isTV)\n  const isLandscape = frame.width > frame.height\n  const topInset = isIPhone && isLandscape ? 0 : insets.top\n  const ANDROID_DEFAULT_HEADER_HEIGHT = 56\n\n  const defaultHeaderHeight = Platform.select({\n    android: ANDROID_DEFAULT_HEADER_HEIGHT + topInset,\n\n    default: getDefaultHeaderHeight({\n      landscape: frame.width > frame.height,\n      modalPresentation: false,\n      topInset,\n    }),\n  })\n\n  return defaultHeaderHeight\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useIntentHandler.ts",
    "content": "import { invalidateUserSession } from \"@follow/store/user/hooks\"\nimport * as Linking from \"expo-linking\"\nimport { useEffect } from \"react\"\n\nimport { useNavigation } from \"../lib/navigation/hooks\"\nimport { FollowScreen } from \"../screens/(modal)/FollowScreen\"\n\nlet previousIntentUrl = \"\"\nexport const resetIntentUrl = () => {\n  previousIntentUrl = \"\"\n}\n\ntype DeepLinkParams =\n  | { id: string | null; type: string | null; url?: string | null; view?: string | null }\n  | \"refresh\"\n  | null\n\nconst handleIncomingUrl = (\n  incomingUrl: string | null,\n  navigation: ReturnType<typeof useNavigation>,\n) => {\n  if (!incomingUrl || incomingUrl === previousIntentUrl) {\n    return\n  }\n\n  previousIntentUrl = incomingUrl\n\n  const searchParams = extractParamsFromDeepLink(incomingUrl)\n  if (!searchParams) {\n    console.warn(\"No valid params found in deep link:\", incomingUrl)\n    return\n  }\n\n  if (searchParams === \"refresh\") {\n    invalidateUserSession()\n    return\n  }\n\n  navigation.presentControllerView(FollowScreen, {\n    id: searchParams.id ?? undefined,\n    type: (searchParams.type as \"url\" | \"feed\" | \"list\") ?? undefined,\n    url: searchParams.url ?? undefined,\n    view: searchParams.view ?? undefined,\n  })\n}\n\nexport function useIntentHandler() {\n  const navigation = useNavigation()\n\n  useEffect(() => {\n    void Linking.getInitialURL().then((url) => {\n      handleIncomingUrl(url, navigation)\n    })\n\n    const subscription = Linking.addEventListener(\"url\", ({ url }) => {\n      handleIncomingUrl(url, navigation)\n    })\n\n    return () => {\n      subscription.remove()\n    }\n  }, [navigation])\n}\n\nconst extractParamsFromDeepLink = (incomingUrl: string | null): DeepLinkParams => {\n  if (!incomingUrl) return null\n\n  try {\n    const url = new URL(incomingUrl)\n    if (url.protocol !== \"follow:\" && url.protocol !== \"folo:\") return null\n\n    switch (url.hostname) {\n      case \"add\": {\n        const { searchParams } = url\n        if (!searchParams.has(\"id\") && !searchParams.has(\"url\")) return null\n\n        return {\n          id: searchParams.get(\"id\"),\n          type: searchParams.get(\"type\"),\n          url: searchParams.get(\"url\"),\n          view: searchParams.get(\"view\"),\n        }\n      }\n      case \"list\": {\n        const { searchParams } = url\n        if (!searchParams.has(\"id\") && !searchParams.has(\"url\")) return null\n\n        return {\n          id: searchParams.get(\"id\"),\n          type: \"list\",\n          view: searchParams.get(\"view\"),\n        }\n      }\n      case \"feed\": {\n        const { searchParams } = url\n        if (!searchParams.has(\"id\") && !searchParams.has(\"url\")) return null\n\n        return {\n          id: searchParams.get(\"id\"),\n          type: \"feed\",\n          url: searchParams.get(\"url\"),\n          view: searchParams.get(\"view\"),\n        }\n      }\n      case \"refresh\": {\n        return \"refresh\"\n      }\n      default: {\n        return null\n      }\n    }\n  } catch {\n    return null\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useLoadingCallback.tsx",
    "content": "import { useSetAtom } from \"jotai\"\nimport { useCallback } from \"react\"\n\nimport { loadingAtom, loadingVisibleAtom } from \"../atoms/app\"\n\nexport const useLoadingCallback = () => {\n  const setLoadingCaller = useSetAtom(loadingAtom)\n  const setVisible = useSetAtom(loadingVisibleAtom)\n\n  return useCallback(\n    (\n      thenable: Promise<any>,\n      options: Partial<{\n        finish: () => any\n        cancel: () => any\n        done: (r: unknown) => any\n        error: (err: any) => any\n      }>,\n    ) => {\n      setLoadingCaller({\n        thenable,\n        finish: options.finish,\n        cancel: options.cancel,\n        done: options.done,\n        error: options.error,\n      })\n      setVisible(true)\n    },\n    [setLoadingCaller, setVisible],\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useMessaging.ts",
    "content": "import { useHasNotificationActions } from \"@follow/store/action/hooks\"\nimport { ROUTE_FEED_IN_INBOX } from \"@follow/store/constants/app\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { getApp } from \"@react-native-firebase/app\"\nimport type { FirebaseMessagingTypes } from \"@react-native-firebase/messaging\"\nimport { getMessaging } from \"@react-native-firebase/messaging\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useEffect } from \"react\"\nimport { Platform } from \"react-native\"\n\nimport { followClient } from \"@/src/lib/api-client\"\nimport { kv } from \"@/src/lib/kv\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { requestNotificationPermission } from \"@/src/lib/permission\"\nimport { EntryDetailScreen } from \"@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen\"\n\nconst FIREBASE_MESSAGING_TOKEN_STORAGE_KEY = \"firebase_messaging_token\"\n\nasync function saveMessagingToken() {\n  const app = getApp()\n  const token = await getMessaging(app).getToken()\n  await followClient.api.messaging.createToken({ token, channel: Platform.OS })\n  kv.set(FIREBASE_MESSAGING_TOKEN_STORAGE_KEY, token)\n}\n\nexport function useUpdateMessagingToken() {\n  const whoami = useWhoami()\n  const hasNotificationActions = useHasNotificationActions()\n  const { mutate } = useMutation({\n    mutationFn: async () => {\n      return Promise.all([saveMessagingToken(), requestNotificationPermission()])\n    },\n  })\n\n  useEffect(() => {\n    if (!whoami?.id || !hasNotificationActions) return\n    mutate()\n  }, [hasNotificationActions, mutate, whoami?.id])\n}\n\nexport function useMessaging() {\n  const navigation = useNavigation()\n  useEffect(() => {\n    function navigateToEntry(message: FirebaseMessagingTypes.RemoteMessage) {\n      if (\n        !message.data ||\n        message.data.type !== \"new-entry\" ||\n        typeof message.data.view !== \"string\" ||\n        typeof message.data.entryId !== \"string\"\n      ) {\n        return\n      }\n\n      navigation.pushControllerView(EntryDetailScreen, {\n        entryId: message.data.entryId,\n        view: Number.parseInt(message.data.view),\n        isInbox: String(message.data.feedId).startsWith(ROUTE_FEED_IN_INBOX),\n      })\n    }\n\n    const app = getApp()\n    async function init() {\n      const message = await getMessaging(app).getInitialNotification()\n      if (message) {\n        navigateToEntry(message)\n      }\n    }\n\n    init()\n\n    const unsubscribe = getMessaging(app).onNotificationOpenedApp((remoteMessage) => {\n      navigateToEntry(remoteMessage)\n    })\n\n    return () => {\n      unsubscribe()\n    }\n  }, [navigation])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useOnboarding.ts",
    "content": "import { useIsNewUser, useWhoami } from \"@follow/store/user/hooks\"\nimport { useEffect, useRef } from \"react\"\n\nimport { useNavigation } from \"../lib/navigation/hooks\"\nimport { hasFinishedOnboarding } from \"../lib/onboarding\"\nimport { OnboardingScreen } from \"../screens/OnboardingScreen\"\n\nexport function useOnboarding() {\n  const whoami = useWhoami()\n  const isNewUser = useIsNewUser({ enabled: !!whoami })\n  const navigation = useNavigation()\n  const hasPresentedRef = useRef(false)\n  useEffect(() => {\n    async function checkOnboarding() {\n      if (hasPresentedRef.current || !isNewUser) return\n      if (isNewUser && !(await hasFinishedOnboarding())) {\n        hasPresentedRef.current = true\n        navigation.presentControllerView(OnboardingScreen, undefined, \"fullScreenModal\")\n      }\n    }\n    checkOnboarding()\n  }, [isNewUser, navigation])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useUnreadCountBadge.ts",
    "content": "import { useUnreadAll } from \"@follow/store/unread/hooks\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useEffect } from \"react\"\n\nimport { setBadgeCountAsyncWithPermission } from \"../lib/permission\"\n\nexport function useUnreadCountBadge() {\n  const unreadCount = useUnreadAll()\n  const { mutate, isPending } = useMutation({\n    mutationFn: (unread: number) => setBadgeCountAsyncWithPermission(unread),\n  })\n  useEffect(() => {\n    if (!isPending) {\n      mutate(unreadCount)\n    }\n  }, [unreadCount, mutate, isPending])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/useWebViewNavigation.tsx",
    "content": "import { parseSafeUrl, transformVideoUrl } from \"@follow/utils\"\nimport type { RefObject } from \"react\"\nimport { useCallback } from \"react\"\nimport type WebView from \"react-native-webview\"\nimport type { WebViewNavigation } from \"react-native-webview\"\n\nimport { openLink } from \"../lib/native\"\n\nconst allowHosts = new Set([\"app.follow.is\", \"app.folo.is\"])\nexport function useWebViewNavigation({ webViewRef }: { webViewRef: RefObject<WebView> }) {\n  const onNavigationStateChange = useCallback(\n    (newNavState: WebViewNavigation) => {\n      const { url: urlStr } = newNavState\n      const url = parseSafeUrl(urlStr)\n      if (!url) return\n      if (url.protocol === \"file:\") return\n      if (allowHosts.has(url.host)) return\n\n      webViewRef.current?.stopLoading()\n\n      const formattedUrl = transformVideoUrl({ url: urlStr })\n      if (formattedUrl) {\n        openLink(formattedUrl)\n        return\n      }\n      openLink(urlStr)\n    },\n    [webViewRef],\n  )\n\n  return { onNavigationStateChange }\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/AZ_sort_ascending_letters_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AZSortAscendingLettersCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AZSortAscendingLettersCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AZSortAscendingLettersCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.47 3.041c-.243.061-.541.23-.717.406-.397.397-1.509 3.125-2.198 5.393-.28.921-.555 2.011-.553 2.19.004.335.236.704.534.848.41.199.842.133 1.161-.177.194-.188.217-.249.466-1.211L6.29 10h3.42l.127.49c.249.961.272 1.023.465 1.21.184.179.457.3.678.3.591 0 1.073-.526 1.005-1.098-.037-.321-.502-1.98-.835-2.982-.454-1.368-1.004-2.76-1.508-3.823-.271-.569-.443-.766-.835-.949-.242-.113-.306-.125-.726-.134-.254-.006-.529.006-.611.027m9.205 1.022c-.215.066-.49.314-.588.53-.061.135-.069.698-.087 6.687l-.02 6.54-.275-.342a9.344 9.344 0 0 1-.524-.74c-.365-.582-.665-.777-1.112-.724a1.008 1.008 0 0 0-.853.735c-.108.369-.035.591.426 1.291a7.942 7.942 0 0 0 2.308 2.311c.562.369.764.457 1.05.457.286 0 .488-.088 1.05-.457a8.006 8.006 0 0 0 2.599-2.791c.178-.326.213-.551.13-.832-.151-.508-.707-.826-1.207-.691-.272.073-.481.267-.753.701a9.344 9.344 0 0 1-.524.74l-.275.342-.02-6.54c-.022-7.33.008-6.677-.321-6.997-.267-.259-.625-.337-1.004-.22M8.254 5.89c.238.565.504 1.242.7 1.78l.12.33H6.926l.113-.31c.401-1.097.909-2.33.96-2.33.017 0 .132.239.255.53m-3.581 7.173C4.309 13.176 4 13.606 4 14c0 .233.114.504.287.682.298.308.199.296 2.57.318l2.117.02-2.217 1.663c-1.219.915-2.278 1.725-2.352 1.8a1.83 1.83 0 0 0-.26.397c-.111.232-.125.299-.124.62 0 .305.016.395.107.589.148.319.429.605.747.761l.263.13 2.977.011c3.391.013 3.264.023 3.581-.294.184-.183.304-.459.304-.697 0-.223-.12-.51-.283-.679-.303-.311-.201-.299-2.574-.321l-2.117-.02 2.217-1.663c1.219-.915 2.278-1.725 2.352-1.8a1.83 1.83 0 0 0 .26-.397c.111-.232.125-.299.124-.62 0-.305-.016-.395-.107-.589a1.654 1.654 0 0 0-.747-.761l-.263-.13-3.001-.008c-2.531-.006-3.03.001-3.188.051\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/AZ_sort_descending_letters_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AZSortDescendingLettersCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AZSortDescendingLettersCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AZSortDescendingLettersCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.47 3.041c-.243.061-.541.23-.717.406-.397.397-1.509 3.125-2.198 5.393-.28.921-.555 2.011-.553 2.19.004.335.236.704.534.848.41.199.842.133 1.161-.177.194-.188.217-.249.466-1.211L6.29 10h3.42l.127.49c.249.961.272 1.023.465 1.21.184.179.457.3.678.3.591 0 1.073-.526 1.005-1.098-.037-.321-.502-1.98-.835-2.982-.454-1.368-1.004-2.76-1.508-3.823-.271-.569-.443-.766-.835-.949-.242-.113-.306-.125-.726-.134-.254-.006-.529.006-.611.027m9.336 1.164c-.192.036-.469.181-.926.488a8.028 8.028 0 0 0-2.238 2.267c-.464.705-.534.921-.424 1.3.151.517.727.852 1.206.701.309-.098.447-.226.762-.711.167-.257.414-.603.549-.768l.245-.3.02 6.539c.022 7.329-.008 6.676.321 6.996.169.163.456.283.679.283.223 0 .51-.12.679-.283.329-.32.299.333.321-6.997l.02-6.54.281.348c.154.191.388.522.52.735.269.435.427.587.713.682.516.171 1.091-.144 1.248-.685.083-.287.051-.485-.132-.82a8.02 8.02 0 0 0-2.6-2.791c-.636-.418-.892-.509-1.244-.444M8.254 5.89c.238.565.504 1.242.7 1.78l.12.33H6.926l.113-.31c.401-1.097.909-2.33.96-2.33.017 0 .132.239.255.53m-3.581 7.173C4.309 13.176 4 13.606 4 14c0 .233.114.504.287.682.298.308.199.296 2.57.318l2.117.02-2.217 1.663c-1.219.915-2.278 1.725-2.352 1.8a1.83 1.83 0 0 0-.26.397c-.111.232-.125.299-.124.62 0 .305.016.395.107.589.148.319.429.605.747.761l.263.13 2.977.011c3.391.013 3.264.023 3.581-.294.184-.183.304-.459.304-.697 0-.223-.12-.51-.283-.679-.303-.311-.201-.299-2.574-.321l-2.117-.02 2.217-1.663c1.219-.915 2.278-1.725 2.352-1.8a1.83 1.83 0 0 0 .26-.397c.111-.232.125-.299.124-.62 0-.305-.016-.395-.107-.589a1.654 1.654 0 0 0-.747-.761l-.263-.13-3.001-.008c-2.531-.006-3.03.001-3.188.051\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/VIP_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VIP2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VIP2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VIP2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.68 2.028a2.027 2.027 0 0 0-1.56 1.312 1.963 1.963 0 0 0 .456 2.041c.134.134.287.271.339.303l.095.059-.127.252c-.326.644-1.142 1.93-1.567 2.468-.98 1.241-1.884 1.622-3.716 1.565-.624-.02-1.538-.113-1.587-.162a1.954 1.954 0 0 1-.075-.25A1.46 1.46 0 0 0 2.5 8.52c-1.012 0-1.722.949-1.425 1.905.148.479.46.809.926.979l.23.083.931 2.407c1.067 2.757 1.351 3.455 1.679 4.126.846 1.733 1.932 2.569 3.68 2.836.708.109 1.543.143 3.479.143 1.936 0 2.771-.034 3.479-.143 1.705-.26 2.769-1.055 3.61-2.696.322-.63.643-1.41 1.71-4.166l.971-2.507.234-.085a1.495 1.495 0 0 0 .694-2.292c-.479-.655-1.456-.792-2.109-.295-.23.175-.455.516-.527.801-.034.13-.067.242-.075.25-.049.049-.963.142-1.587.162-1.832.057-2.736-.324-3.716-1.565-.425-.538-1.241-1.824-1.567-2.468l-.127-.252.095-.061c.349-.226.676-.658.819-1.082.107-.316.098-.935-.019-1.257-.329-.908-1.273-1.471-2.205-1.315\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/VIP_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VIP2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VIP2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VIP2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.477 2.073c-1.254.328-1.866 1.834-1.196 2.944.134.222.433.535.639.669l.1.065-.134.274c-.432.884-1.307 2.263-1.838 2.897-.535.638-.966.94-1.568 1.097-.557.145-1.866.125-3.026-.045l-.445-.066-.065-.264A1.454 1.454 0 0 0 2.5 8.52c-.52 0-.941.22-1.229.642a1.484 1.484 0 0 0 .73 2.242l.229.083.731 1.887c1.636 4.228 1.929 4.897 2.475 5.665.714 1.005 1.657 1.574 2.985 1.801 1.243.213 5.906.214 7.144.003 1.815-.311 2.853-1.18 3.737-3.129.116-.256.355-.823.531-1.26.268-.665 1.77-4.521 1.893-4.859.029-.081.096-.127.276-.192a1.495 1.495 0 0 0 .696-2.293c-.28-.383-.701-.59-1.198-.59-.662 0-1.228.403-1.404 1a3.05 3.05 0 0 1-.094.28c-.045.09-.793.16-1.802.167-1.842.013-2.496-.267-3.474-1.485-.514-.64-1.254-1.784-1.609-2.487l-.127-.252.095-.059c.052-.032.204-.168.338-.302.303-.304.479-.651.544-1.072A1.995 1.995 0 0 0 12 2.005c-.143 0-.378.031-.523.068m.912 6.587c.498.748.972 1.331 1.483 1.826.693.671 1.224.995 1.988 1.214.726.208 1.022.238 2.35.239.677.001 1.23.006 1.23.012 0 .025-1.188 3.082-1.488 3.829-.833 2.075-1.257 2.654-2.169 2.96-.653.22-.924.236-3.783.236-2.845 0-3.095-.015-3.772-.23-.701-.222-1.14-.676-1.63-1.686-.301-.622-2.035-4.999-1.994-5.036a5.91 5.91 0 0 1 .596.044c1.031.103 2.278.027 3.006-.183 1.181-.341 2.189-1.248 3.329-2.994.184-.281.365-.572.403-.646.038-.074.083-.119.1-.1.017.019.175.251.351.515\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/add_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AddCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AddCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AddCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.493 3.592a1.54 1.54 0 0 0-.844.768l-.109.22-.02 2.96-.02 2.96-2.96.02-2.96.02-.221.109a1.677 1.677 0 0 0-.71.71c-.098.198-.109.265-.109.641 0 .377.011.443.11.644.132.268.475.606.74.728.189.087.208.088 3.15.108l2.96.02.02 2.96c.02 2.942.021 2.961.108 3.15.122.265.46.608.728.74.201.099.267.11.644.11s.443-.011.644-.11c.268-.132.606-.475.728-.74.087-.189.088-.208.108-3.15l.02-2.96 2.96-.02 2.96-.02.224-.11c.268-.132.606-.475.728-.74.127-.275.127-.945 0-1.22-.122-.265-.46-.608-.728-.74l-.224-.11-2.96-.02-2.96-.02-.02-2.96c-.02-2.942-.021-2.961-.108-3.15-.122-.265-.46-.608-.725-.738-.295-.145-.838-.173-1.154-.06\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/add_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AddCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AddCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AddCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.673 4.063c-.261.08-.533.358-.612.627-.052.175-.061.641-.061 3.257V11H7.947c-3.461 0-3.33-.011-3.641.3a.96.96 0 0 0 0 1.4c.311.311.18.3 3.641.3H11v3.073c0 3.471-.014 3.307.306 3.627.18.179.458.3.694.3.237 0 .514-.12.697-.303.314-.315.303-.178.303-3.644V13h3.053c3.466 0 3.329.011 3.644-.303.183-.183.303-.46.303-.697 0-.237-.12-.514-.303-.697-.315-.314-.178-.303-3.644-.303H13V7.947c0-3.466.011-3.329-.303-3.644-.279-.279-.63-.361-1.024-.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/ai_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AiCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AiCuteFiIcon = ({ width = 24, height = 24, color = \"#10161F\" }: AiCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.664 2.061c-.366.139-.508.319-.708.899-.225.652-.344.771-.996.996-.476.164-.61.247-.761.465a.99.99 0 0 0 0 1.158c.151.218.285.301.761.465.652.225.771.344.996.996.164.476.247.61.465.761a.99.99 0 0 0 1.158 0c.223-.154.301-.285.483-.814.205-.594.331-.72.925-.925.529-.182.66-.26.814-.483a.99.99 0 0 0 0-1.158c-.151-.218-.285-.301-.761-.465-.662-.228-.761-.327-.998-1.001-.087-.246-.21-.512-.274-.591-.242-.299-.749-.438-1.104-.303m-7.957 2.063c-.574.063-1.17.472-1.436.985a10.14 10.14 0 0 0-.389.987c-.289.833-.467 1.234-.758 1.702a7.219 7.219 0 0 1-2.326 2.326c-.468.291-.863.467-1.736.773-.915.32-1.166.45-1.431.74-.716.782-.698 2.023.04 2.761.292.291.477.385 1.401.709.863.303 1.259.479 1.726.769 1.161.721 2.141 1.812 2.686 2.987.082.178.268.662.413 1.075.32.915.45 1.166.74 1.431.779.713 2.026.694 2.761-.04.292-.292.385-.478.709-1.405.315-.899.554-1.407.927-1.964a6.979 6.979 0 0 1 1.926-1.926c.557-.373 1.064-.612 1.964-.927.927-.324 1.113-.417 1.405-.709.752-.752.752-2.044 0-2.796-.292-.291-.477-.385-1.401-.709-.863-.303-1.259-.479-1.726-.769a7.176 7.176 0 0 1-2.305-2.291c-.313-.506-.483-.885-.794-1.771-.32-.915-.45-1.166-.74-1.43-.257-.236-.692-.459-.958-.492-.354-.043-.427-.045-.698-.016\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/ai_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AiCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AiCuteReIcon = ({ width = 24, height = 24, color = \"#10161F\" }: AiCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.726 2.046c-.21.06-.436.22-.542.384a3.129 3.129 0 0 0-.223.51c-.23.659-.356.792-.945.996-.456.158-.571.219-.731.384-.293.302-.354.753-.155 1.151.121.243.308.386.668.512.166.058.416.16.556.226.292.138.427.314.571.746.205.616.382.847.753.98.382.138.856 0 1.102-.32.066-.086.192-.361.28-.612.206-.589.338-.733.824-.901.523-.181.671-.257.833-.424.291-.3.352-.752.153-1.149-.121-.243-.308-.386-.668-.512-.487-.17-.785-.327-.894-.47a2.35 2.35 0 0 1-.221-.469c-.19-.535-.239-.63-.403-.789a.989.989 0 0 0-.958-.243m-8.023 2.081c-.562.07-1.17.485-1.433.978-.061.113-.229.545-.374.96-.145.415-.351.937-.457 1.159-.512 1.067-1.379 2.059-2.395 2.742-.549.369-1.064.611-1.979.93-.908.318-1.096.414-1.393.713-.751.756-.751 2.025 0 2.783.291.293.539.42 1.428.727.875.302 1.396.547 1.944.915 1.016.683 1.883 1.675 2.395 2.742.106.222.312.744.457 1.159.318.908.414 1.096.713 1.393.756.751 2.025.751 2.783 0 .293-.291.42-.539.727-1.428.302-.875.547-1.396.916-1.944.683-1.017 1.674-1.883 2.741-2.395.222-.106.744-.312 1.159-.457.908-.318 1.096-.414 1.393-.713.751-.756.751-2.025 0-2.783-.291-.293-.539-.42-1.428-.727-.875-.302-1.396-.547-1.944-.916-1.017-.683-1.883-1.674-2.395-2.741a14.793 14.793 0 0 1-.457-1.159c-.145-.415-.313-.847-.374-.96-.251-.471-.873-.909-1.37-.965l-.3-.036a2.13 2.13 0 0 0-.357.023m.557 2.723c.744 2.139 1.935 3.697 3.721 4.869.483.317 1.44.766 2.189 1.027.368.129.67.243.67.254 0 .011-.311.128-.69.26-1.481.515-2.557 1.161-3.565 2.138-1.058 1.027-1.789 2.206-2.325 3.752-.132.379-.249.69-.26.69-.011 0-.128-.311-.26-.69-.538-1.546-1.182-2.597-2.237-3.653-1.05-1.049-2.116-1.7-3.673-2.243-.368-.129-.67-.243-.67-.254 0-.011.311-.128.69-.26 1.531-.531 2.604-1.188 3.653-2.237 1.049-1.049 1.7-2.115 2.243-3.673.129-.368.243-.669.254-.669.011-.001.128.31.26.689\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/alert_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AlertCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AlertCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AlertCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        fill={color}\n        fillRule=\"evenodd\"\n        d=\"M8.597 4.49C9.534 3.377 10.584 2.63 12 2.63c1.415 0 2.465.747 3.402 1.862.897 1.066 1.824 2.63 2.972 4.567.284.48.562.962.835 1.447 1.104 1.963 1.995 3.547 2.47 4.858.496 1.368.619 2.651-.089 3.877s-1.88 1.761-3.314 2.016c-1.372.243-3.19.264-5.442.29-.556.006-1.112.006-1.668 0-2.253-.026-4.07-.047-5.443-.29C4.29 21 3.118 20.466 2.41 19.24c-.708-1.226-.586-2.509-.09-3.877.476-1.31 1.367-2.895 2.47-4.858.274-.486.552-.968.836-1.446C6.774 7.12 7.7 5.558 8.597 4.49M12 8a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V9a1 1 0 0 1 1-1m0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2\"\n        clipRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/align_justify_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AlignJustifyCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AlignJustifyCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AlignJustifyCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.673 3.063C3.31 3.175 3 3.606 3 4c0 .405.309.826.69.939C3.87 4.993 4.86 5 12 5c7.14 0 8.13-.007 8.31-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.18-.054-1.166-.061-8.327-.058-6.953.002-8.15.011-8.31.06m0 5C3.31 8.175 3 8.606 3 9c0 .405.309.826.69.939.18.054 1.17.061 8.31.061 7.14 0 8.13-.007 8.31-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.18-.054-1.166-.061-8.327-.058-6.953.002-8.15.011-8.31.06m0 5C3.31 13.175 3 13.606 3 14c0 .405.309.826.69.939.18.054 1.17.061 8.31.061 7.14 0 8.13-.007 8.31-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.18-.054-1.166-.061-8.327-.058-6.953.002-8.15.011-8.31.06m0 5C3.31 18.175 3 18.606 3 19c0 .405.309.826.69.939.18.054 1.17.061 8.31.061 7.14 0 8.13-.007 8.31-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.18-.054-1.166-.061-8.327-.058-6.953.002-8.15.011-8.31.06\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/align_left_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AlignLeftCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AlignLeftCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AlignLeftCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.673 3.063C3.31 3.175 3 3.606 3 4c0 .405.309.826.69.939C3.87 4.993 4.86 5 12 5c7.14 0 8.13-.007 8.31-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.18-.054-1.166-.061-8.327-.058-6.953.002-8.15.011-8.31.06m0 5C3.31 8.175 3 8.606 3 9c0 .405.309.826.69.939.178.053.856.061 5.31.061s5.132-.008 5.31-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.3-.089-10.347-.087-10.637.002m0 5C3.31 13.175 3 13.606 3 14c0 .405.309.826.69.939.18.054 1.17.061 8.31.061 7.14 0 8.13-.007 8.31-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.18-.054-1.166-.061-8.327-.058-6.953.002-8.15.011-8.31.06m0 5C3.31 18.175 3 18.606 3 19c0 .405.309.826.69.939.178.053.856.061 5.31.061s5.132-.008 5.31-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.3-.089-10.347-.087-10.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/announcement_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AnnouncementCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AnnouncementCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AnnouncementCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M16.72 1.875c-.33.105-.528.234-1.589 1.031-1.414 1.063-2.001 1.435-2.883 1.829-1.531.685-2.633.911-5.088 1.044-1.158.063-1.602.13-2.081.312-1.641.623-2.768 2.022-3.022 3.749a4.697 4.697 0 0 0 .865 3.446l.193.259.804 2.818c.596 2.091.844 2.9.961 3.141.463.95 1.545 1.569 2.59 1.481.41-.034.623-.092.99-.267a2.65 2.65 0 0 0 1.297-1.326c.218-.479.243-.721.243-2.308v-1.44l.21.062c.518.153 1.258.452 1.81.733 1.096.557 1.767 1.048 3.075 2.252.915.842 1.124 1.013 1.398 1.142.519.244 1.205.222 1.754-.057.422-.213.815-.681.957-1.14.126-.404.377-2.442.475-3.856.065-.925.052-.879.252-.938a3.67 3.67 0 0 0 .955-.514c.458-.379.81-.917 1-1.528.12-.387.13-1.155.019-1.557a3.118 3.118 0 0 0-1.235-1.73c-.256-.174-.719-.39-.838-.392-.083-.001-.094-.063-.152-.881-.091-1.29-.336-3.37-.461-3.91a2 2 0 0 0-1.434-1.474c-.285-.076-.797-.067-1.065.019M19.978 11c0 .158-.026.315-.066.4-.064.138-.065.131-.065-.4s.001-.538.065-.4c.04.085.066.242.066.4M6.104 15.157c.152.021.51.048.796.062.286.013.65.033.81.046l.29.022-.001 1.566c-.001 1.435-.007 1.581-.074 1.727a.61.61 0 0 1-.375.358.67.67 0 0 1-.846-.272c-.053-.091-.305-.904-.562-1.806l-.488-1.715c-.019-.064-.007-.072.076-.051.054.014.223.042.374.063\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/apple_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AppleCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AppleCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AppleCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M13.2 1.414a3.191 3.191 0 0 0-1.938 1.588c-.421.811-.447 1.441-.076 1.874.425.497 1.035.532 1.914.11a3.193 3.193 0 0 0 1.699-2.033c.158-.571.082-.957-.256-1.296-.344-.344-.757-.418-1.343-.243m-5.38 4.29c-1.38.205-2.527.76-3.422 1.654-1.676 1.676-2.268 4.366-1.637 7.448.368 1.795 1.129 3.613 2.102 5.018.776 1.12 1.73 2.019 2.637 2.483.501.257.745.33 1.2.358.466.03.944-.049 1.739-.286 1.438-.429 1.666-.43 3.101-.018.92.264 1.378.34 1.839.305.423-.033.676-.111 1.151-.354 1.335-.684 2.595-2.124 3.571-4.083.737-1.479.64-1.943-.73-3.493-.177-.2-.388-.45-.469-.557-.581-.76-.708-1.771-.34-2.704.043-.109.246-.463.451-.786.886-1.396 1.015-1.709.98-2.382-.042-.791-.534-1.342-1.753-1.964-.996-.509-2.079-.74-2.931-.627-.479.064-.926.199-1.729.521-.818.329-1.07.404-1.439.431-.406.029-.736-.056-1.683-.429-1.043-.411-1.453-.518-2.058-.538a5.57 5.57 0 0 0-.58.003\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/arrow_left_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ArrowLeftCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ArrowLeftCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ArrowLeftCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.756 5.374c-.294.081-1.342.776-2.195 1.456a17.39 17.39 0 0 0-3.594 3.923c-.347.529-.42.738-.423 1.227-.003.476.054.67.334 1.137.22.366 1.068 1.505 1.566 2.103.468.563 1.457 1.541 1.97 1.95.8.636 2.014 1.382 2.384 1.465a.977.977 0 0 0 .91-.282c.348-.364.384-.91.085-1.314-.051-.069-.281-.243-.513-.387a15.302 15.302 0 0 1-3.082-2.535c-.437-.473-.918-1.044-.918-1.09 0-.015 3.136-.027 6.97-.028 6.659-.001 6.977-.004 7.13-.074.389-.177.62-.522.619-.925a.978.978 0 0 0-.579-.905l-.2-.094L13.307 11c-4.226 0-6.908-.014-6.9-.037.036-.102.883-1.051 1.413-1.584a14.732 14.732 0 0 1 2.275-1.894c.638-.432.736-.527.852-.825.122-.312.006-.79-.252-1.038a1.033 1.033 0 0 0-.939-.248\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/arrow_right_circle_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ArrowRightCircleCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ArrowRightCircleCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ArrowRightCircleCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m2.083 5.651c1.323.683 2.44 1.705 3.293 3.014.351.538.44.804.44 1.311 0 .477-.065.693-.352 1.172-.725 1.212-1.826 2.272-3.131 3.014-.491.279-.645.334-.928.333-.295-.001-.508-.101-.717-.339a.969.969 0 0 1 .226-1.487c.124-.076.397-.239.606-.362.501-.294.782-.51 1.233-.949l.372-.362L10.93 13l-3.476-.02-.197-.121a.998.998 0 0 1 0-1.718l.197-.121L10.93 11l3.475-.02-.372-.362a5.713 5.713 0 0 0-1.233-.951c-.809-.478-.828-.492-.944-.69a1.001 1.001 0 0 1 .155-1.207c.239-.235.393-.293.732-.28.244.009.33.035.62.185\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/arrow_right_up_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ArrowRightUpCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ArrowRightUpCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ArrowRightUpCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.74 5.182c-1.152.116-2.563.394-2.847.56-.388.227-.569.745-.416 1.193.065.192.291.447.48.543.265.134.425.134 1.044.003 1.305-.278 2.145-.37 3.379-.373.765-.002 1.884.066 1.938.118.01.009-2.197 2.235-4.905 4.945-5.451 5.457-5.079 5.047-5.046 5.563.032.509.39.867.899.899.516.033.107.404 5.554-5.038 2.707-2.703 4.931-4.915 4.944-4.915.054 0 .131 1.16.13 1.96-.001 1.199-.104 2.126-.377 3.376-.128.59-.125.779.02 1.059.166.32.638.558 1.001.503.262-.039.578-.243.709-.458.116-.191.25-.724.396-1.575.163-.951.201-1.406.224-2.625.024-1.335-.018-1.965-.209-3.16-.121-.753-.183-.98-.366-1.328-.268-.513-.77-.845-1.497-.992a21.112 21.112 0 0 0-1.975-.278c-.65-.056-2.441-.044-3.08.02\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/arrow_up_circle_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ArrowUpCircleCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ArrowUpCircleCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ArrowUpCircleCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.38 4.978c.71.269 1.915 1.213 2.595 2.034.474.572 1.069 1.506 1.21 1.899.073.205.07.552-.007.736a1.125 1.125 0 0 1-.59.553 1.06 1.06 0 0 1-.845-.08c-.198-.116-.24-.174-.692-.944a5.574 5.574 0 0 0-.949-1.233l-.362-.372L13 13.07l-.02 3.476-.121.197a.998.998 0 0 1-1.718 0l-.121-.197L11 13.07l-.02-3.475-.364.372c-.449.46-.753.871-1.114 1.507-.175.308-.329.528-.417.594a.984.984 0 0 1-1.275-.041.984.984 0 0 1-.288-1.055c.128-.39.727-1.34 1.211-1.921.826-.99 2.098-1.936 2.863-2.131.243-.062.797-.019 1.064.082\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/at_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AtCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AtCuteReIcon = ({ width = 24, height = 24, color = \"#10161F\" }: AtCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.2 2.04a9.993 9.993 0 0 0-9.038 8.252c-.643 3.731.879 7.523 3.918 9.754a10.003 10.003 0 0 0 7.88 1.75c1.03-.202 2.343-.688 2.697-.999.527-.463.416-1.285-.217-1.61-.317-.162-.613-.141-1.1.08-2.211 1.004-4.674.975-6.84-.081-1.595-.777-2.929-2.109-3.679-3.672-.818-1.706-1.031-3.587-.604-5.334.593-2.425 2.154-4.353 4.386-5.415 1.779-.847 3.872-.998 5.862-.423a7.728 7.728 0 0 1 4.954 4.356c.4.927.578 1.716.609 2.702.057 1.85-.48 3.194-1.477 3.694-.36.18-.75.282-.946.247-.334-.061-.576-.347-.627-.743-.014-.103.087-1.277.239-2.785.145-1.433.263-2.707.263-2.831a.855.855 0 0 0-.241-.637.994.994 0 0 0-.681-.335.96.96 0 0 0-.8.341l-.136.161-.241-.223c-.985-.913-2.492-1.401-3.861-1.25-2.392.263-4.231 2.085-4.48 4.439-.22 2.082.882 4.06 2.781 4.995.343.168.826.344 1.069.388.095.018.15.05.15.088 0 .053.107.058.83.041.912-.023 1.275-.077 1.843-.276a4.736 4.736 0 0 0 1.224-.641c.175-.126.333-.219.351-.208a.82.82 0 0 1 .118.188c.144.283.69.789 1.054.976.687.353 1.585.388 2.427.096 1.644-.571 2.676-1.956 3.042-4.085.118-.681.126-2.082.017-2.74-.358-2.157-1.291-3.978-2.798-5.458-1.536-1.51-3.492-2.453-5.688-2.742a13.715 13.715 0 0 0-2.26-.06m1.64 7.073c.368.112.542.192.845.388 1.032.666 1.563 1.905 1.321 3.076a2.993 2.993 0 0 1-1.626 2.105c-.484.238-.754.297-1.36.297-.432 0-.571-.016-.819-.094-1.536-.478-2.437-1.929-2.145-3.451.115-.594.351-1.04.798-1.505a3.04 3.04 0 0 1 1.547-.869c.355-.081 1.09-.053 1.439.053\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/attachment_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface AttachmentCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const AttachmentCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: AttachmentCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        stroke={color}\n        strokeLinecap=\"round\"\n        strokeWidth={2}\n        d=\"m12.877 4.308 6.54 6.54a5.25 5.25 0 0 1 0 7.425v0a5.25 5.25 0 0 1-7.424 0l-7.954-7.954a3.501 3.501 0 0 1 0-4.952v0a3.501 3.501 0 0 1 4.952 0l7.953 7.953a1.751 1.751 0 0 1 0 2.477v0a1.751 1.751 0 0 1-2.477 0L7.22 8.55\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/back_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Back2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Back2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Back2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.64 6.04c-1.948.202-3.634.888-5.12 2.084-.657.529-1.499 1.454-1.965 2.158l-.064.097-.045-.209c-.136-.633-.175-1.191-.156-2.23.015-.802.007-1.075-.034-1.191-.237-.672-1.124-.87-1.631-.364-.297.298-.366.746-.332 2.175.031 1.356.232 2.467.656 3.64.389 1.074.624 1.436 1.077 1.658.292.142.586.179 1.454.181 1.732.003 3.292-.321 4.81-.998.558-.249.715-.36.841-.593.268-.495.112-1.078-.362-1.357-.162-.095-.231-.111-.489-.109-.266.001-.341.02-.666.17-1.051.485-2.274.803-3.353.873l-.43.027.051-.096c.626-1.18 1.711-2.287 2.919-2.977 1.584-.904 3.423-1.188 5.255-.813 2.506.514 4.642 2.384 5.503 4.817.263.744.387 1.388.438 2.272.034.594.093.76.348.984.205.18.37.241.655.241.285 0 .45-.061.655-.241.281-.247.326-.385.321-.999-.015-2.271-.973-4.579-2.608-6.285a9.444 9.444 0 0 0-5.544-2.858c-.497-.072-1.724-.104-2.184-.057\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/black_board_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface BlackBoard2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const BlackBoard2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: BlackBoard2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.66 3.358c-.517.083-.977.294-1.504.689-.185.138-.849.757-1.476 1.376L7.54 6.548l-.4.028a6.083 6.083 0 0 0-1.469.289c-1.223.436-2.37 1.583-2.806 2.806-.288.808-.345 1.519-.345 4.329 0 2.81.057 3.521.345 4.329.47 1.321 1.725 2.503 3.064 2.888.85.244.831.243 6.071.243s5.221.001 6.071-.243c1.435-.412 2.736-1.713 3.146-3.145.214-.748.232-1.016.252-3.67.023-3.142-.028-3.874-.334-4.731-.436-1.223-1.583-2.37-2.806-2.806a6.083 6.083 0 0 0-1.469-.289l-.4-.028-1.14-1.125c-1.192-1.176-1.606-1.525-2.119-1.783-.495-.249-1.081-.356-1.541-.282m.6 2.037c.088.04.27.161.404.269.278.223.896.798.896.833 0 .013-.703.023-1.563.023-.906 0-1.558-.015-1.55-.036.018-.05.653-.635.915-.843.249-.197.492-.319.638-.319.055 0 .172.033.26.073m2.08 5.671c.369.126.66.538.66.934 0 .242-.119.521-.299.701-.311.312-.156.299-3.703.299-3.42 0-3.326.006-3.624-.222a1.19 1.19 0 0 1-.243-.289c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.125-.213.318-.375.539-.454.12-.043.775-.054 3.313-.055 2.842-.002 3.182.005 3.357.064m-4 4c.369.126.66.538.66.934s-.291.808-.66.934c-.276.094-2.399.098-2.662.005a.986.986 0 0 1-.547-.45c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.061-.103.173-.236.25-.294.264-.202.352-.213 1.602-.215.981-.002 1.193.008 1.357.064\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/black_board_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface BlackBoard2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const BlackBoard2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: BlackBoard2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.76 3.343c-.716.12-1.23.374-1.88.931a45.53 45.53 0 0 0-1.3 1.249L7.54 6.549l-.375.027c-.998.073-1.798.333-2.528.824-.377.254-1.03.923-1.289 1.322-.393.604-.589 1.143-.734 2.018-.055.332-.069.817-.084 2.86-.025 3.489.047 4.191.528 5.163.256.517.473.815.918 1.261.923.922 1.802 1.292 3.36 1.415.686.054 8.642.054 9.328 0 1.193-.094 1.932-.324 2.699-.839.331-.222 1.015-.906 1.237-1.237.515-.767.745-1.506.839-2.699.053-.669.053-4.659 0-5.328-.094-1.193-.324-1.932-.839-2.699-.222-.331-.906-1.015-1.237-1.237-.73-.491-1.53-.751-2.528-.824l-.375-.027-1.04-1.026c-1.114-1.1-1.419-1.376-1.806-1.638-.288-.195-.758-.416-1.014-.477-.214-.051-.699-.088-.84-.065m.549 2.088c.242.126.641.453 1.01.827l.259.262h-3.156l.279-.282c.544-.551 1.061-.916 1.299-.916.055 0 .194.049.309.109m4.411 3.127c.894.106 1.388.322 1.894.828.346.346.596.765.701 1.175.151.591.179 1.118.179 3.439 0 2.321-.028 2.848-.179 3.439-.217.849-1.027 1.659-1.876 1.876-.675.173-.999.184-5.439.184s-4.764-.011-5.439-.184c-.849-.217-1.659-1.027-1.876-1.876-.151-.591-.179-1.118-.179-3.439 0-2.881.053-3.385.423-4.031.298-.52.908-1.045 1.43-1.229.347-.122.871-.188 1.741-.22 1.298-.047 8.155-.017 8.62.038m-9.047 2.505C7.31 11.175 7 11.606 7 12c0 .405.309.826.69.939.307.091 6.313.091 6.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.297-.089-6.349-.086-6.637.002m0 4C7.31 15.175 7 15.606 7 16c0 .405.309.826.69.939.297.088 2.323.088 2.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.287-.086-2.358-.084-2.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/book_6_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Book6CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Book6CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Book6CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.545 2.565c-.64.083-1.615.332-2.182.559-.611.245-1.226.757-1.632 1.36-.465.689-.624 1.191-.691 2.172-.053.777-.052 9.7.001 10.219.137 1.331.622 2.052 1.599 2.379.583.195.906.226 2.4.227 2.205.001 2.561.053 4.097.598 1.244.441 1.453.497 1.863.497.41 0 .619-.056 1.863-.497 1.536-.545 1.892-.597 4.097-.598 1.494-.001 1.817-.032 2.4-.227.972-.325 1.459-1.049 1.599-2.374.052-.502.053-9.466 0-10.238-.08-1.177-.429-1.992-1.175-2.741-.501-.504-.967-.758-1.871-1.019-.91-.263-1.572-.362-2.42-.362-1.401 0-2.823.378-4.164 1.105l-.33.18-.29-.159c-.979-.538-2.15-.93-3.181-1.066-.49-.065-1.532-.072-1.983-.015m2.077 2.058c.617.121 1.181.316 1.848.64l.53.257v6.36c0 3.636-.015 6.36-.035 6.36-.02 0-.267-.082-.55-.183a10.972 10.972 0 0 0-1.635-.445c-.231-.042-.834-.067-2.16-.09-1.519-.027-1.891-.044-2.134-.101-.521-.123-.495.204-.478-5.921.015-5.526.001-5.205.25-5.695.125-.247.464-.62.677-.744.091-.053.374-.159.63-.236 1.164-.352 2.012-.408 3.057-.202m8.801-.04c.444.067 1.283.303 1.564.44.245.118.611.498.754.78.25.492.236.169.251 5.697.017 6.125.043 5.798-.478 5.921-.244.057-.609.075-2.134.101-1.33.023-1.929.048-2.16.09a10.94 10.94 0 0 0-1.635.445c-.283.101-.53.183-.55.183-.02 0-.035-2.725-.035-6.363V5.515l.533-.257c1.407-.676 2.565-.877 3.89-.675\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/bookmark_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface BookmarkCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const BookmarkCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: BookmarkCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.5 2.545c-2.157.124-3.278.692-3.917 1.986-.569 1.154-.597 1.609-.572 9.409.015 4.685.026 5.484.08 5.76.161.838.473 1.31 1.055 1.599.197.098.295.119.622.133.804.035 1.183-.161 3.196-1.656 1.68-1.248 1.855-1.345 2.229-1.234.243.072.608.316 1.843 1.234 1.56 1.158 1.968 1.416 2.504 1.581.988.305 1.908-.162 2.24-1.137.192-.566.19-.494.208-6.26.025-7.816-.002-8.266-.573-9.429-.552-1.126-1.547-1.732-3.175-1.934-.355-.044-5.123-.087-5.74-.052m5.231 2.017c1.09.087 1.651.333 1.908.836.108.212.23.671.286 1.082.033.242.052 2.023.064 6.16.016 5.471.004 6.72-.066 6.72-.057 0-.717-.462-1.683-1.179-1.294-.96-1.591-1.161-2.021-1.366-.45-.214-.781-.294-1.219-.294-.438 0-.769.08-1.219.294-.43.205-.727.406-2.021 1.366-.954.708-1.626 1.179-1.681 1.179-.015 0-.039-.148-.053-.33-.042-.546-.029-11.183.015-11.946.074-1.267.26-1.825.71-2.123.44-.292.987-.394 2.349-.441 1.121-.039 3.95-.013 4.631.042\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/bubble_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface BubbleCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const BubbleCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: BubbleCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M15.34 3.046c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835M6.433 11.04a4.383 4.383 0 0 0-1.718.682c-.326.22-.773.667-.993.993a4.848 4.848 0 0 0-.609 1.347c-.126.509-.126 1.367 0 1.876a4.211 4.211 0 0 0 1.064 1.885 4.211 4.211 0 0 0 1.885 1.064c.509.126 1.367.126 1.876 0a4.211 4.211 0 0 0 1.885-1.064 4.211 4.211 0 0 0 1.064-1.885c.126-.509.126-1.367 0-1.876a4.211 4.211 0 0 0-1.064-1.885 4.186 4.186 0 0 0-1.865-1.057c-.386-.094-1.175-.136-1.525-.08m9.07 4a3.033 3.033 0 0 0-2.205 1.7c-.226.481-.277.714-.277 1.26s.051.779.277 1.26a2.905 2.905 0 0 0 1.442 1.444 2.873 2.873 0 0 0 2.52 0 2.925 2.925 0 0 0 1.444-1.444c.183-.387.296-.867.296-1.26 0-.86-.446-1.781-1.127-2.329a3.668 3.668 0 0 0-1.117-.574c-.265-.073-.986-.106-1.253-.057\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/bug_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface BugCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const BugCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: BugCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.363 3.043a5.056 5.056 0 0 0-3.367 1.972c-.332.447-.437.676-.441.968-.006.399.184.715.545.911l.16.086h7.48l.16-.086c.363-.197.54-.494.54-.909 0-.303-.104-.53-.459-1.002-1.057-1.404-2.87-2.166-4.618-1.94M3.667 6.065c-.413.127-.711.608-.652 1.051.051.379.488 1.038 1.001 1.509a7.97 7.97 0 0 0 1.435.981c.204.1.264.147.243.193-.128.286-.396 1.195-.485 1.646L5.1 12H3.997c-1.296 0-1.411.02-1.691.3a.96.96 0 0 0 0 1.4c.279.279.398.3 1.661.3H5.04v.192c0 .716.326 2.215.641 2.951l.076.176-.173.118c-.872.6-1.467 1.851-1.569 3.297-.033.48.036.71.291.965.18.181.458.301.694.301.355 0 .775-.274.908-.593.034-.081.075-.318.092-.527.017-.209.061-.506.099-.66.119-.483.455-1.1.599-1.1.028 0 .151.131.274.291.313.409 1.059 1.12 1.508 1.438.716.506 1.651.905 2.48 1.057.52.096 1.56.096 2.08 0 .829-.152 1.764-.551 2.48-1.057.449-.318 1.195-1.029 1.508-1.438.123-.16.247-.291.277-.291.075 0 .283.291.415.58.151.329.245.729.279 1.18.015.209.057.446.091.527.138.32.556.593.91.593.238 0 .514-.12.697-.304.25-.249.323-.503.286-.989-.108-1.44-.697-2.665-1.571-3.272l-.169-.116.076-.176c.313-.731.641-2.24.641-2.951V14h1.073c1.266 0 1.382-.021 1.664-.303.183-.183.303-.46.303-.697 0-.237-.12-.514-.303-.697-.283-.283-.395-.303-1.694-.303H18.9l-.109-.555c-.089-.451-.357-1.36-.485-1.646-.021-.046.039-.094.243-.194a8.062 8.062 0 0 0 1.287-.847c.525-.441 1.036-1.143 1.131-1.554.125-.543-.219-1.055-.791-1.175-.445-.094-.789.086-1.11.582-.313.482-.765.875-1.342 1.167-.461.233-.149.221-5.724.221s-5.263.012-5.724-.221c-.573-.29-1.051-.706-1.356-1.181-.213-.333-.338-.445-.602-.538-.208-.073-.4-.071-.651.006m12.616 4.101c.406.796.664 1.907.706 3.039.11 3.004-1.416 5.735-3.664 6.556l-.325.118v-3.552c0-4.023.014-3.826-.303-4.144A1.052 1.052 0 0 0 12 11.88c-.237 0-.514.12-.697.303-.317.318-.303.121-.303 4.144v3.552l-.325-.118c-2.248-.821-3.774-3.551-3.664-6.556a7.743 7.743 0 0 1 .694-3.035l.075-.169L11.989 10h4.209l.085.166\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/calendar_time_add_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CalendarTimeAddCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CalendarTimeAddCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CalendarTimeAddCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.643 3.069a1.118 1.118 0 0 0-.343.229c-.252.252-.3.439-.3 1.164v.608l-.39.049c-2.038.255-3.171 1.347-3.469 3.343-.106.708-.139 1.785-.14 4.498-.001 4.165.071 4.953.536 5.94.526 1.116 1.546 1.779 3.046 1.979.486.065 1.655.119 2.587.12L10 21V19.011l-1.37-.022c-2.17-.036-2.741-.17-3.178-.742-.16-.21-.267-.548-.361-1.137-.064-.408-.071-.791-.071-4.11 0-3.319.007-3.702.071-4.11.201-1.265.511-1.582 1.749-1.796.334-.058.975-.068 5.32-.082 3.558-.012 5.013-.004 5.2.028.784.137 1.454.799 1.597 1.577.023.131.043.495.043.81V10h2l-.001-.51c-.002-1.034-.09-1.532-.379-2.155a3.768 3.768 0 0 0-.797-1.158c-.603-.603-1.493-1.047-2.238-1.117a24.74 24.74 0 0 1-.415-.044L17 4.995v-.57c0-.684-.052-.879-.299-1.126a.984.984 0 0 0-1.402 0c-.247.247-.299.442-.299 1.128V5H9v-.573c0-.686-.052-.881-.299-1.128a.998.998 0 0 0-1.058-.23m0 7C7.291 10.193 7 10.614 7 11c0 .242.119.521.299.701.294.294.33.299 2.201.299s1.907-.005 2.201-.299a.984.984 0 0 0 0-1.402c-.295-.295-.328-.299-2.218-.296-1.398.003-1.691.013-1.84.066m8.697 1.977c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835m-8.697 2.021C7.289 14.196 7 14.615 7 15c0 .405.293.811.677.939.304.101 1.035.073 1.267-.048.19-.1.402-.328.481-.517.071-.169.071-.579 0-.748a1.198 1.198 0 0 0-.481-.517c-.221-.116-1.027-.142-1.301-.042m9.938-.005a3 3 0 0 1 2.357 2.357c.427 2.063-1.456 3.946-3.519 3.519a2.993 2.993 0 0 1-2.357-2.357c-.376-1.817 1.04-3.558 2.91-3.578.171-.002.445.025.609.059m-.964.515c-.182.079-.41.293-.509.479-.083.154-.088.214-.086 1.004.002.786.008.855.096 1.079.128.324.424.618.743.739.184.07.355.095.742.112.619.026.842-.032 1.097-.288a.984.984 0 0 0 .001-1.403c-.155-.156-.455-.299-.623-.299-.059 0-.071-.056-.086-.39-.013-.296-.037-.429-.102-.554a1.196 1.196 0 0 0-.516-.481c-.166-.07-.596-.069-.757.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/celebrate_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CelebrateCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CelebrateCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CelebrateCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M12 2.201c-.238.111-.347.217-.469.459-.124.241-.131.551-.023.892.059.186.072.362.071.948-.002.641-.014.771-.111 1.184a11.668 11.668 0 0 1-.582 1.764c-.257.6-.222.938.133 1.294.24.241.466.321.808.286.549-.057.806-.411 1.255-1.728.53-1.556.658-3.075.357-4.24-.164-.635-.452-.909-.975-.931-.222-.009-.323.007-.464.072m5.85 2.76c-.242.049-.451.208-.938.713-.375.387-.437.471-.494.668-.14.481.068.954.521 1.189.212.11.616.114.841.009.207-.096 1.104-.992 1.198-1.197.101-.219.098-.594-.006-.823a1.004 1.004 0 0 0-1.122-.559m-2.937 2.853c-.207.077-.376.221-1.001.854-.617.624-.71.775-.71 1.152 0 .563.414.977.98.979.388.002.552-.106 1.265-.829.342-.346.653-.691.691-.765a1.17 1.17 0 0 0 .067-.709 1.072 1.072 0 0 0-.465-.598c-.222-.13-.601-.169-.827-.084m-7.933.227c-.684.137-1.306.597-1.77 1.309-.513.787-.913 1.832-2.332 6.09-.78 2.34-.962 3.085-.972 3.98-.007.592.054.899.266 1.337.475.981 1.514 1.472 2.805 1.326.937-.106 1.778-.343 4.523-1.276 3.3-1.121 3.716-1.275 4.52-1.669 1.15-.564 1.75-1.228 1.929-2.134.171-.868-.165-1.758-1.086-2.872-.466-.563-4.436-4.534-4.995-4.995-1.114-.92-2.032-1.268-2.888-1.096m10.991.901c-.34.17-.532.49-.53.887a.977.977 0 0 0 .578.896c.236.107 1.287.107 1.521 0a.982.982 0 0 0 .578-.905.926.926 0 0 0-.281-.697c-.244-.244-.383-.281-1.059-.282-.566-.001-.615.005-.807.101M7.811 10.12c.486.243 1.144.845 3.411 3.127 1.013 1.019 1.99 2.032 2.171 2.251.341.412.607.876.607 1.057-.001.369-.553.721-1.96 1.248-.749.281-3.4 1.195-4.784 1.65-1.889.621-2.825.788-3.164.565-.378-.248-.255-1.115.476-3.338.93-2.828 1.8-5.256 2.098-5.856.391-.787.655-.949 1.145-.704m7.789 1.522c-.337.041-.526.124-.716.314-.399.399-.355 1.123.089 1.461.275.21.34.22 1.29.195 1.304-.034 2.029.094 2.894.511.461.222.664.267.906.202.75-.202 1.02-1.126.485-1.658-.204-.203-.851-.529-1.392-.701a8.447 8.447 0 0 0-1.63-.325c-.471-.047-1.538-.046-1.926.001\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/certificate_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CertificateCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CertificateCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CertificateCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.244 1.482c-.68.178-.987.385-1.909 1.287-.399.39-.777.731-.84.759-.077.033-.49.057-1.255.074-1.06.023-1.163.031-1.465.124a3.072 3.072 0 0 0-2.112 2.322c-.047.23-.063.549-.063 1.256 0 .627-.016 1.002-.049 1.111-.038.131-.197.318-.77.905-.723.742-.942 1.018-1.123 1.414-.346.755-.346 1.777 0 2.532.18.393.396.665 1.122 1.412.572.587.732.776.771.907.032.108.049.476.049 1.08 0 .503.02 1.032.043 1.175a3.056 3.056 0 0 0 2.517 2.517c.143.023.672.043 1.175.043.604 0 .972.017 1.08.049.131.039.32.199.907.771.747.726 1.019.942 1.412 1.122.755.346 1.777.346 2.532 0 .393-.18.665-.396 1.412-1.122.587-.572.776-.732.907-.771.108-.032.476-.049 1.08-.049.503 0 1.032-.02 1.175-.043a3.056 3.056 0 0 0 2.517-2.517c.023-.143.043-.672.043-1.175 0-.604.017-.972.049-1.08.039-.131.199-.32.771-.907.927-.953 1.123-1.245 1.3-1.938a3.39 3.39 0 0 0 0-1.48c-.177-.694-.377-.992-1.301-1.94-.573-.587-.732-.774-.77-.905-.033-.109-.049-.484-.049-1.111 0-1.021-.041-1.35-.221-1.794a3.056 3.056 0 0 0-2.227-1.847c-.23-.047-.549-.063-1.256-.063-.627 0-1.002-.016-1.111-.049-.131-.038-.318-.197-.905-.77-.948-.924-1.246-1.124-1.94-1.301-.419-.107-1.083-.106-1.496.002m4.897 7.225c.428.145.734.623.687 1.073-.043.421-.216.629-.868 1.042-1.662 1.053-3.026 2.417-4.014 4.014-.151.244-.339.506-.418.583-.284.275-.786.344-1.16.157-.168-.083-.289-.202-.623-.61a13.883 13.883 0 0 0-1.797-1.785c-.459-.366-.586-.551-.617-.9-.034-.369.043-.58.307-.843.244-.243.421-.318.75-.318.378 0 .797.29 1.868 1.292l.476.445.104-.137a15.696 15.696 0 0 1 2.524-2.616c.603-.483 1.828-1.315 2.066-1.402.21-.077.481-.075.715.005\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/certificate_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CertificateCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CertificateCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CertificateCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.406 1.485c-.286.047-.761.241-1.04.424-.124.082-.55.466-.946.855-.396.388-.783.74-.86.781-.123.066-.272.077-1.26.099-.616.013-1.21.045-1.32.071-1.091.252-2.013 1.174-2.265 2.265-.026.11-.058.704-.071 1.32-.022.986-.033 1.137-.099 1.26-.041.077-.384.455-.763.84-.917.932-1.155 1.302-1.289 2.01-.076.397-.052 1.097.048 1.427.183.602.394.902 1.241 1.763.379.385.722.763.763.84.066.123.077.274.099 1.26.013.616.045 1.21.071 1.32.25 1.082 1.156 1.995 2.245 2.263.099.025.693.056 1.32.071 1.003.023 1.157.035 1.28.101.077.041.455.384.84.763.721.709.999.927 1.429 1.116.674.297 1.668.297 2.342 0 .43-.189.708-.407 1.429-1.116.385-.379.763-.722.84-.763.123-.066.274-.077 1.26-.099.616-.013 1.21-.045 1.32-.071 1.082-.25 1.995-1.156 2.263-2.245.025-.099.056-.693.071-1.32.023-1.003.035-1.157.101-1.28.041-.077.384-.455.763-.84.378-.385.746-.786.817-.89a3.028 3.028 0 0 0 .03-3.37c-.087-.132-.468-.555-.847-.94-.379-.385-.722-.763-.763-.84-.066-.123-.077-.274-.099-1.26-.013-.616-.045-1.21-.071-1.32-.252-1.091-1.174-2.013-2.265-2.265-.11-.026-.704-.058-1.32-.071-.986-.022-1.137-.033-1.26-.099-.077-.041-.455-.384-.84-.763-.756-.744-1.038-.959-1.49-1.14-.477-.191-1.134-.252-1.704-.157m.994 2.038c.122.056.406.304.88.772.722.713.889.848 1.303 1.056.463.234.612.256 1.877.284 1.287.028 1.315.033 1.593.312.278.277.283.306.312 1.593.028 1.265.051 1.415.284 1.877.208.414.343.581 1.056 1.303.759.769.853.911.853 1.28s-.094.511-.853 1.28c-.713.722-.848.889-1.056 1.303-.234.463-.256.612-.284 1.877-.028 1.287-.033 1.315-.312 1.593-.278.279-.306.284-1.593.312-1.265.028-1.414.05-1.877.284-.414.208-.581.343-1.303 1.056-.769.759-.911.853-1.28.853s-.511-.094-1.28-.853c-.738-.728-.921-.875-1.34-1.071-.485-.227-.589-.242-1.84-.269-1.287-.028-1.315-.033-1.593-.312-.279-.278-.284-.306-.312-1.593-.028-1.265-.05-1.414-.284-1.877-.208-.414-.343-.581-1.056-1.303-.759-.769-.853-.911-.853-1.28s.094-.511.853-1.28c.713-.722.848-.889 1.056-1.303.234-.463.256-.612.284-1.877.028-1.287.033-1.315.312-1.593.278-.279.306-.284 1.593-.312 1.251-.027 1.355-.042 1.84-.269.414-.194.596-.34 1.38-1.106.514-.503.783-.737.88-.768.232-.073.561-.059.76.031m3.124 5.203c-.162.047-.91.515-1.468.919-1.179.852-2.354 1.998-3.113 3.035l-.197.267a9.67 9.67 0 0 1-.466-.436c-.539-.518-1.263-1.12-1.484-1.233-.486-.248-1.09-.039-1.337.464-.054.11-.077.243-.078.438-.001.396.108.567.63.986.522.418 1.397 1.294 1.832 1.832.411.509.592.622.996.622a.855.855 0 0 0 .461-.103c.172-.088.24-.163.487-.54.756-1.153 1.239-1.758 1.979-2.481.698-.683 1.259-1.12 2.413-1.881.313-.206.374-.266.476-.464a.987.987 0 0 0-.591-1.412c-.224-.067-.343-.069-.54-.013\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/check_circle_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CheckCircleCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CheckCircleCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CheckCircleCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.54 2.017c2.788.311 5.174 1.99 6.38 4.488a7.95 7.95 0 0 1-.001 6.945A8.02 8.02 0 0 1 12 19.999a8.014 8.014 0 0 1-7.2-4.528 7.948 7.948 0 0 1 0-6.942A7.973 7.973 0 0 1 8.529 4.8c1.323-.64 2.886-.916 4.291-.759m3.134 4.092c-.512.192-1.933 1.128-2.787 1.836-.805.667-1.809 1.764-2.425 2.647l-.261.374-.39-.398c-.717-.73-1.756-1.552-2.067-1.637-.2-.054-.57-.025-.728.057a1.21 1.21 0 0 0-.48.532.995.995 0 0 0-.049.426c.025.392.143.556.703.975.611.457 1.605 1.45 2.078 2.076.444.589.607.699 1.035.699.305 0 .533-.087.706-.27.058-.06.208-.29.334-.51 1.2-2.089 2.801-3.68 4.886-4.856.376-.212.581-.403.676-.63.071-.169.071-.579 0-.748a1.172 1.172 0 0 0-.478-.513c-.204-.104-.56-.132-.753-.06\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/check_circle_filled.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CheckCircleFilledIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CheckCircleFilledIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CheckCircleFilledIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m5.27 6.117c.4.119.691.53.69.976-.001.396.021.371-3.166 3.55-2.955 2.948-2.975 2.967-3.205 3.033a.979.979 0 0 1-.705-.048c-.177-.072-3.121-2.99-3.269-3.24a1 1 0 0 1 .078-1.1c.203-.267.425-.372.787-.372.254 0 .328.016.48.107.099.059.666.595 1.26 1.19l1.079 1.083 2.521-2.516c1.386-1.384 2.583-2.547 2.66-2.586.3-.151.482-.169.79-.077\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/check_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CheckCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CheckCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CheckCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M20.239 5.214c-.1.027-.397.172-.66.323-3.769 2.156-6.552 4.587-8.958 7.823-.418.562-1.212 1.739-1.441 2.134l-.112.194-.104-.134c-.536-.691-1.843-2.06-2.644-2.771-.871-.774-2.093-1.717-2.42-1.867-.338-.156-.845-.051-1.108.229a1.013 1.013 0 0 0-.103 1.242c.05.075.388.358.751.63.808.605 1.134.876 1.88 1.565.963.89 1.785 1.809 2.665 2.978.272.363.556.701.629.751a.976.976 0 0 0 .966.074c.274-.13.365-.239.731-.885 1.674-2.95 3.645-5.33 6.149-7.424 1.116-.933 2.733-2.037 4.097-2.796.527-.294.696-.429.809-.651.385-.756-.316-1.636-1.127-1.415\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/check_filled.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CheckFilledIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CheckFilledIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CheckFilledIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M20.051 4.736c-.092.03-.245.101-.34.157-.094.056-2.502 2.434-5.35 5.284l-5.18 5.182-2.4-2.393c-2.122-2.114-2.429-2.406-2.638-2.502a1.531 1.531 0 0 0-1.458.096 1.49 1.49 0 0 0-.528 1.898c.104.211.508.629 3.171 3.287 2.929 2.923 3.062 3.049 3.29 3.133.246.09.645.117.905.062.406-.086.341-.023 6.384-6.071 6.252-6.256 5.979-5.964 6.044-6.459.092-.702-.237-1.314-.862-1.6-.276-.127-.769-.162-1.038-.074\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/check_line.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CheckLineIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CheckLineIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CheckLineIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path d=\"M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z\" />\n      <Path\n        fill={color}\n        d=\"M21.192 5.465a1 1 0 0 1 0 1.414L9.95 18.122a1.1 1.1 0 0 1-1.556 0l-5.586-5.586a1 1 0 1 1 1.415-1.415l4.95 4.95L19.777 5.465a1 1 0 0 1 1.414 0Z\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/classify_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Classify2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Classify2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Classify2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M16.52 2.278c-.679.146-1.118.389-1.77.981-.587.534-1.302 1.304-1.514 1.632-.196.303-.38.752-.459 1.116-.076.351-.085 1.082-.018 1.414a3.8 3.8 0 0 0 .633 1.419c.167.223 1.362 1.426 1.649 1.66.67.549 1.351.791 2.218.788.7-.003 1.314-.176 1.85-.525.603-.392 1.969-1.793 2.238-2.297.223-.416.293-.603.377-1.006.203-.973-.032-2.047-.608-2.782-.237-.302-1.143-1.225-1.574-1.603a3.424 3.424 0 0 0-3.022-.797m-11.1.28a3.561 3.561 0 0 0-2.119 1.23c-.27.327-.541.844-.667 1.272-.091.309-.094.368-.094 1.7 0 1.338.003 1.389.094 1.68.328 1.045.95 1.773 1.898 2.222.516.245.82.299 1.828.325 1.005.025 1.766-.025 2.14-.141A3.53 3.53 0 0 0 10.846 8.5c.183-.588.205-2.699.035-3.36-.258-1.003-.989-1.89-1.896-2.3-.628-.284-.681-.292-2.085-.304-.704-.005-1.37.004-1.48.022m12.474 1.783c.181.09.396.278.942.823.882.883.944.986.944 1.576 0 .597-.061.704-.864 1.52-.356.362-.739.721-.851.798a1.44 1.44 0 0 1-1.448.101c-.21-.101-.378-.245-.953-.819-.774-.773-.921-.993-.969-1.447-.03-.285.062-.681.211-.913.145-.225 1.445-1.514 1.614-1.601.273-.141.454-.178.8-.166.279.01.382.033.574.128M7.52 4.515c.768.062 1.248.441 1.423 1.125.051.199.061.434.05 1.233-.013.986-.013.987-.126 1.231a1.698 1.698 0 0 1-.767.764l-.24.112h-1.1c-1.056 0-1.109-.004-1.328-.092a1.5 1.5 0 0 1-.757-.713c-.163-.33-.211-.823-.177-1.807.03-.842.081-1.014.408-1.368.293-.316.569-.45.993-.483a12.354 12.354 0 0 1 1.621-.002m-1.9 8.527a3.38 3.38 0 0 0-2.098 1.005c-.43.432-.686.868-.888 1.513-.091.291-.094.342-.094 1.68 0 1.332.003 1.391.094 1.7a3.57 3.57 0 0 0 2.426 2.426c.308.09.37.094 1.66.094 1.444 0 1.526-.009 2.079-.222.938-.362 1.681-1.16 2.028-2.178.217-.638.236-2.816.03-3.525a3.484 3.484 0 0 0-.894-1.498c-.609-.609-1.279-.921-2.139-.997a17.814 17.814 0 0 0-2.204.002m10.5 0c-.99.088-1.94.656-2.507 1.498-.521.773-.643 1.407-.6 3.1.026 1.008.08 1.312.325 1.828.449.948 1.177 1.57 2.222 1.898.291.091.342.094 1.68.094 1.332 0 1.391-.003 1.7-.094a3.57 3.57 0 0 0 2.426-2.426c.09-.308.094-.37.094-1.66 0-1.444-.009-1.526-.222-2.079-.294-.763-.912-1.434-1.678-1.821a3.12 3.12 0 0 0-1.24-.34 18.138 18.138 0 0 0-2.2.002m-8.016 2.091c.309.143.617.452.764.767l.112.24v1.1c0 1.056-.004 1.109-.092 1.328a1.505 1.505 0 0 1-.717.76c-.243.119-.311.133-.834.167-.35.023-.8.023-1.165 0-.693-.043-.9-.118-1.225-.442-.323-.324-.4-.535-.443-1.22-.053-.849.006-1.661.143-1.958.124-.269.346-.524.571-.656.329-.193.52-.217 1.622-.208l1.02.009.244.113m10.35-.052c.443.137.771.45.95.908.085.219.09.286.089 1.271 0 1.27-.022 1.356-.449 1.784-.377.377-.526.425-1.41.456-.985.036-1.479-.012-1.809-.175a1.5 1.5 0 0 1-.713-.757c-.088-.219-.092-.272-.092-1.328v-1.1l.112-.24c.193-.414.603-.759 1.018-.857.082-.019.576-.037 1.096-.039.844-.004.974.005 1.208.077\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/close_circle_fill.tsx",
    "content": "import { G, Path, Svg } from \"react-native-svg\"\n\ninterface IconProps {\n  width?: number\n  height?: number\n  color?: string\n}\nexport const CloseCircleFillIcon = ({ width = 24, height = 24, color = \"#10161F\" }: IconProps) => (\n  <Svg width={width} height={height} viewBox=\"0 0 24 24\">\n    <G fill=\"none\">\n      <Path d=\"M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z\" />\n      <Path\n        fill={color}\n        d=\"M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2ZM9.879 8.464a1 1 0 0 0-1.498 1.32l.084.095 2.12 2.12-2.12 2.122a1 1 0 0 0 1.32 1.498l.094-.083L12 13.414l2.121 2.122a1 1 0 0 0 1.498-1.32l-.083-.095L13.414 12l2.122-2.121a1 1 0 0 0-1.32-1.498l-.095.083L12 10.586 9.879 8.464Z\"\n      />\n    </G>\n  </Svg>\n)\n"
  },
  {
    "path": "apps/mobile/src/icons/close_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CloseCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CloseCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CloseCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.42 4.676c-.282.073-.54.286-.683.564-.097.19-.097.607.002.82.05.11.968 1.057 2.957 3.05L10.579 12l-2.883 2.89c-1.989 1.993-2.907 2.94-2.957 3.05-.105.226-.099.63.01.84.182.348.503.553.871.555.475.003.302.154 3.47-3.009L12 13.421l2.89 2.885c1.59 1.587 2.951 2.916 3.025 2.953.455.229 1.046.045 1.312-.407.097-.165.113-.234.112-.492 0-.212-.022-.341-.074-.44-.04-.077-1.372-1.441-2.959-3.03L13.42 12l2.867-2.87c2.021-2.025 2.892-2.923 2.957-3.05.116-.23.125-.633.019-.84a1.234 1.234 0 0 0-.483-.491c-.227-.118-.617-.115-.86.007-.127.065-1.026.936-3.05 2.958L12 10.58 9.13 7.714C7.238 5.824 6.204 4.82 6.096 4.766c-.189-.094-.502-.136-.676-.09\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/comment_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Comment2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Comment2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Comment2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.48 3.086c-2.843.139-5.566 1.753-7.088 4.202a9.697 9.697 0 0 0-1.166 2.892c-.162.776-.199 1.228-.173 2.158.03 1.123.175 1.893.536 2.859l.142.379-.045.722a24.953 24.953 0 0 0-.046 1.342c.001.992.173 1.531.648 2.025.294.306.657.504 1.137.62.336.08.41.083 1.395.053 1.755-.053 1.642-.061 2.26.159.946.336 1.971.504 2.283.373.199-.084.426-.292.528-.486.065-.123.087-.234.087-.444 0-.331-.088-.529-.33-.748-.178-.161-.307-.211-.708-.273a7.809 7.809 0 0 1-1.44-.381c-.834-.3-.701-.291-2.565-.177-.669.041-1.137.013-1.223-.073-.086-.086-.114-.554-.073-1.223.024-.377.054-.928.067-1.225l.024-.54-.244-.74c-.308-.933-.403-1.391-.441-2.12-.081-1.538.301-2.959 1.132-4.216C7.14 5.257 10.908 4.225 14.1 5.779a6.943 6.943 0 0 1 2.191 1.679c.408.461.738.963 1.038 1.577.268.548.404.728.647.856.228.12.659.121.884.001a.974.974 0 0 0 .54-.891c-.001-.227-.026-.317-.196-.689-.888-1.947-2.531-3.583-4.457-4.443-1.386-.618-2.701-.86-4.267-.783m5.422 7.958c-1.258.133-2.552.768-3.384 1.661-1.646 1.767-1.982 4.363-.833 6.439a5.48 5.48 0 0 0 2.967 2.531c.632.223 1.089.298 1.828.301.721.003 1.105-.053 1.695-.247.309-.102.365-.108 1.145-.108.74-.001.842-.009 1.047-.087a2.022 2.022 0 0 0 1.167-1.167c.078-.205.086-.307.087-1.047.001-.779.007-.836.108-1.145.199-.607.249-.942.249-1.655-.002-1.194-.279-2.122-.921-3.087-1.117-1.678-3.114-2.604-5.155-2.389m1.478 2.074c1.221.318 2.165 1.26 2.507 2.502.072.263.088.423.086.9-.001.558-.007.598-.152 1.06-.226.72-.229.743-.199 1.431l.028.63-.735-.01-.735-.011-.446.149c-.565.189-.934.246-1.414.22a3.509 3.509 0 0 1-3.309-3.309c-.094-1.711 1.082-3.231 2.789-3.605.437-.096 1.116-.078 1.58.043\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/comment_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CommentCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CommentCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CommentCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.619 4.041c-.768.062-1.235.185-1.83.484-.516.26-.898.536-1.313.951-.754.754-1.22 1.647-1.386 2.654-.058.355-.067.768-.069 3.07l-.001 2.66.094.36c.2.766.667 1.495 1.261 1.965.382.302.935.585 1.331.68l.282.068.024.623c.035.931.203 1.367.644 1.671.631.435 1.262.321 2.246-.407l.297-.22-.183-.268c-.794-1.16-1.15-2.726-1.02-4.485.079-1.065.256-1.76.647-2.541.823-1.643 2.299-2.771 4.183-3.195.385-.087.465-.089 3.434-.102 1.672-.007 3.171.003 3.33.022.279.033.29.031.29-.049 0-.174-.172-.697-.357-1.086a4.762 4.762 0 0 0-.999-1.42c-.74-.739-1.635-1.213-2.615-1.383-.317-.055-.896-.065-4.129-.074-2.068-.006-3.941.004-4.161.022m5.722 6.021c-1.46.245-2.653 1.269-3.138 2.694-.285.839-.285 2.653.001 3.488.356 1.041 1.052 1.844 2.016 2.325.7.35 1.165.431 2.469.431h.948l.892.742c.49.409.996.813 1.125.9.545.366 1.158.374 1.691.02.409-.271.599-.657.641-1.303l.026-.399.164-.042a3.66 3.66 0 0 0 1.224-.521c.691-.447 1.224-1.181 1.469-2.027.096-.328.103-.419.121-1.457.02-1.199-.021-1.653-.195-2.161-.492-1.439-1.683-2.451-3.164-2.692-.511-.082-5.795-.081-6.29.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/comment_cute_li.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CommentCuteLiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CommentCuteLiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CommentCuteLiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.544 4.281c-1.978.182-3.645 1.616-4.146 3.567-.139.541-.165 1.201-.148 3.652.016 2.213.021 2.335.103 2.649.332 1.277 1.309 2.23 2.572 2.509l.306.068.017.787c.019.894.05 1.015.349 1.339.242.262.503.366.923.367.307.001.361-.011.56-.123.316-.178.453-.388.453-.689a.731.731 0 0 0-.618-.744l-.146-.025-.017-.789c-.015-.726-.024-.803-.109-.969-.214-.419-.472-.57-1.083-.636-.337-.036-.478-.073-.712-.184a1.944 1.944 0 0 1-.919-.928l-.149-.312-.011-2.599c-.012-2.812-.007-2.897.207-3.426.168-.416.354-.685.744-1.075.391-.391.659-.576 1.08-.746.528-.213.54-.214 4.702-.214 2.581 0 3.924.014 4.066.043.939.19 1.632.66 2.188 1.484.262.388.439.511.736.512a.729.729 0 0 0 .748-.731c0-.32-.345-.87-.89-1.421a4.874 4.874 0 0 0-2.37-1.298c-.349-.081-.475-.084-4.22-.092-2.123-.004-4.02.007-4.216.024m5.051 5.002c-.425.045-.76.138-1.143.317-.855.399-1.406.94-1.813 1.78-.335.689-.398 1.103-.398 2.62 0 1.462.064 1.918.359 2.548.399.855.94 1.406 1.78 1.813.722.351 1.073.397 3.002.398l1.362.001 1.038.866c.571.476 1.119.904 1.218.95.28.13.75.118 1.047-.028.268-.131.447-.322.585-.624.085-.184.104-.296.119-.702l.019-.483.325-.063c.618-.122 1.094-.368 1.573-.812.503-.468.803-.96.977-1.604.078-.291.088-.446.105-1.72.026-1.985-.026-2.414-.389-3.16-.407-.84-.958-1.381-1.813-1.78-.399-.187-.7-.267-1.193-.319-.447-.047-6.314-.045-6.76.002m6.979 1.54c.376.097.697.287 1.006.597.311.311.5.63.598 1.012.056.217.064.48.054 1.863l-.012 1.609-.148.301a1.967 1.967 0 0 1-.888.879c-.2.094-.353.13-.671.161-.475.046-.674.125-.908.36-.239.239-.319.438-.354.884l-.031.386-.811-.673c-.874-.726-1.096-.86-1.521-.922-.147-.021-.931-.039-1.742-.039-1.629-.001-1.753-.015-2.206-.254-.309-.163-.764-.618-.927-.927-.235-.447-.253-.591-.253-2.06s.018-1.613.253-2.06c.157-.298.614-.761.907-.918.493-.265.351-.256 4.054-.259 2.937-.003 3.385.005 3.6.06\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/comment_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CommentCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CommentCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CommentCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.4 4.043c-2.015.233-3.73 1.736-4.239 3.717-.147.574-.168 1.066-.151 3.66.017 2.664.014 2.624.234 3.22.404 1.092 1.516 2.058 2.602 2.26l.146.027.015.727c.014.695.02.736.125.951.163.335.392.572.699.724.226.111.319.133.608.144.394.016.687-.071.97-.287.367-.28.485-.829.267-1.246-.114-.217-.382-.451-.558-.486l-.11-.022-.015-.706c-.013-.663-.02-.72-.119-.932a1.344 1.344 0 0 0-1.117-.777c-.607-.053-.931-.186-1.26-.514a1.62 1.62 0 0 1-.455-.823c-.035-.162-.044-.889-.034-2.74.014-2.514.014-2.521.107-2.8A3.141 3.141 0 0 1 6.14 6.114l.28-.094 3.9-.012c2.715-.008 3.979.002 4.16.033.858.145 1.56.624 2.11 1.439.309.457.496.567.933.551.256-.009.334-.03.499-.132.288-.178.433-.434.451-.795.013-.261.003-.306-.125-.551-.2-.384-.54-.819-.925-1.183a5.1 5.1 0 0 0-2.363-1.261c-.353-.083-.442-.085-4.36-.092-2.2-.005-4.135.007-4.3.026m4.958 5.018a4.013 4.013 0 0 0-2.894 2.099C8.064 11.942 8 12.33 8 14s.064 2.058.464 2.84a3.978 3.978 0 0 0 1.696 1.696c.804.411 1.147.463 3.051.464h1.43l1.01.842c.575.48 1.104.888 1.229.95.195.095.266.108.62.108.361 0 .423-.011.638-.117.315-.154.56-.407.713-.735.108-.229.129-.328.147-.684l.022-.416.28-.068c1.048-.251 1.974-1.038 2.402-2.04.257-.604.27-.711.287-2.44.021-2.073-.025-2.404-.453-3.24-.46-.899-1.307-1.622-2.291-1.957-.566-.192-.795-.203-4.285-.199-2.502.003-3.339.016-3.602.057m7.134 1.999c.36.093.626.25.912.536.296.295.448.561.536.933.081.346.086 2.99.006 3.291a1.423 1.423 0 0 1-.433.726c-.334.323-.459.37-1.307.493a1.376 1.376 0 0 0-1.165 1.093l-.048.232-.206-.174c-.954-.803-1.101-.91-1.447-1.053l-.28-.115-1.76-.023c-1.701-.021-1.767-.025-1.984-.111a2.087 2.087 0 0 1-1.256-1.396c-.087-.331-.087-2.653 0-2.984.093-.36.25-.626.536-.912.28-.28.55-.442.895-.534.32-.087 6.67-.089 7.001-.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/compass_3_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Compass3CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Compass3CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Compass3CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M17.46 3.508c-.796.103-1.908.459-3.72 1.192-1.765.714-4.109 1.701-4.593 1.935-1.257.607-1.905 1.255-2.512 2.512-.397.823-2.117 4.987-2.509 6.073-.878 2.435-.864 3.686.052 4.602.528.528 1.098.723 2.002.684.727-.032 1.392-.194 2.64-.646 1.056-.381 5.235-2.11 6.033-2.495 1.257-.607 1.905-1.255 2.512-2.512.377-.78 2.023-4.756 2.47-5.964.606-1.636.798-2.726.625-3.549a2.359 2.359 0 0 0-1.807-1.801c-.249-.053-.898-.07-1.193-.031m.943 2.089c.109.109.117.137.117.43 0 .532-.25 1.4-.82 2.853-.589 1.499-2.059 5.009-2.151 5.134-.043.058-.397-.282-2.848-2.732L9.903 8.483l.358-.166c.436-.202 3.867-1.628 4.767-1.981 1.502-.59 2.396-.851 2.925-.854.316-.002.339.004.45.115m-7.104 7.122 2.798 2.798-.358.166c-.198.091-1.007.436-1.799.766-4.012 1.67-5.155 2.071-5.913 2.071-.293 0-.321-.008-.43-.117-.109-.109-.117-.137-.117-.43 0-.526.269-1.451.853-2.94.609-1.549 2.11-5.113 2.155-5.113.007 0 1.272 1.259 2.811 2.799\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/compass_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CompassCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CompassCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CompassCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m4.531 5.496c.833.258.975 1.258.416 2.929-.527 1.573-1.429 3.069-2.343 3.884-1.29 1.151-3.767 2.227-5.127 2.227-.686 0-1.084-.246-1.243-.77-.284-.936.292-2.86 1.384-4.624.778-1.256 1.592-1.968 3.163-2.768 1.542-.785 2.97-1.119 3.75-.878m-4.168 3.549c-.352.124-.643.545-.643.931 0 .527.473 1 1 1 .242 0 .521-.119.701-.299.753-.753-.048-1.989-1.058-1.632\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/copy_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Copy2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Copy2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Copy2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.52 1.563c-1.659.105-2.724.541-3.622 1.481a6.026 6.026 0 0 0-.471.562c-.224.316-.57 1.028-.521 1.073.019.017.444.167.944.333l.911.302.103-.219c.2-.422.667-.917 1.091-1.156.226-.128.616-.247 1.044-.32.586-.1 3.266-.156 5.065-.106 1.601.045 2.025.086 2.546.247.45.139.839.409 1.185.823.328.392.473.745.588 1.435.159.955.159 6.01 0 6.964-.074.444-.193.837-.32 1.061-.235.413-.69.848-1.119 1.069l-.257.132.302.908c.166.499.315.923.332.942.017.018.187-.039.377-.129 1.197-.56 2.127-1.63 2.476-2.845.254-.888.306-1.665.306-4.62 0-2.315-.035-3.247-.145-3.9-.18-1.068-.547-1.836-1.213-2.534-.943-.988-1.95-1.393-3.742-1.505-.737-.047-5.115-.045-5.86.002m-5 5c-1.574.1-2.578.482-3.456 1.316-.983.934-1.391 1.948-1.503 3.741-.053.829-.053 4.931 0 5.76.112 1.789.522 2.809 1.503 3.741.902.857 1.859 1.212 3.556 1.318.829.053 4.931.053 5.76 0 1.694-.106 2.633-.453 3.54-1.307.994-.934 1.406-1.952 1.519-3.752.053-.829.053-4.931 0-5.76-.106-1.694-.453-2.633-1.307-3.54-.934-.994-1.952-1.406-3.752-1.519-.737-.047-5.115-.045-5.86.002M11.4 8.52c1.875.057 2.412.179 3.017.685.57.476.821.947.966 1.813.159.955.159 6.01 0 6.964-.145.866-.396 1.337-.966 1.813-.392.328-.745.472-1.435.588-.954.159-6.009.159-6.964 0-.698-.117-1.06-.267-1.453-.602-.408-.349-.668-.725-.804-1.168-.162-.524-.203-.945-.248-2.549-.05-1.799.006-4.479.106-5.065.146-.859.415-1.354.981-1.808.412-.332.733-.46 1.438-.577.677-.111 3.285-.157 5.362-.094\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/copy_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface CopyCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const CopyCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: CopyCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.631 1.58c-1.79.301-3.311 1.632-3.891 3.404a3.8 3.8 0 0 0-.127.482l-.023.166-.245.047c-1.316.254-2.54 1.171-3.219 2.413-.319.583-.49 1.247-.564 2.186-.056.713-.056 6.731 0 7.444.074.939.245 1.603.564 2.186.551 1.008 1.531 1.866 2.545 2.227.409.146.98.254 1.607.303.698.055 4.746.055 5.444 0 .939-.074 1.603-.245 2.186-.564 1.242-.679 2.159-1.903 2.413-3.219l.047-.245.166-.023a3.8 3.8 0 0 0 .482-.127 5.046 5.046 0 0 0 3.243-3.239c.217-.67.221-.743.221-4.623 0-3.698-.021-4.342-.163-5.047a4.527 4.527 0 0 0-1.343-2.434c-.904-.868-1.797-1.241-3.252-1.355-.802-.063-5.696-.048-6.091.018m6.129 1.979c.743.087 1.225.271 1.647.63.468.397.763.84.908 1.362.164.593.183 1.079.184 4.809.001 4 .018 3.761-.321 4.46a2.938 2.938 0 0 1-.777.991c-.209.178-.634.427-.831.487l-.09.027v-2.754c0-3.422-.045-4.058-.345-4.9-.436-1.223-1.583-2.37-2.806-2.806-.778-.277-1.542-.345-3.9-.345H8.675l.027-.09c.06-.197.309-.622.488-.833a3.078 3.078 0 0 1 1.962-1.078c.381-.048 5.142-.014 5.608.04m-4 4.002c.943.106 1.501.386 2.047 1.028.382.45.552.913.64 1.742.074.696.074 6.646.001 7.338-.089.831-.265 1.309-.643 1.741-.589.676-1.107.928-2.125 1.036-.634.068-4.73.068-5.36.001-1.018-.109-1.565-.376-2.127-1.036-.364-.428-.546-.904-.634-1.651-.066-.571-.097-5.285-.042-6.56.048-1.121.073-1.343.201-1.76.228-.745.97-1.489 1.716-1.721.387-.12.718-.163 1.526-.2.957-.042 4.308-.013 4.8.042m-5.714 3.063c-.366.183-.547.516-.519.956a.929.929 0 0 0 .451.759l.196.121h4.652l.196-.121c.121-.075.242-.196.317-.317.109-.176.121-.229.121-.522 0-.293-.012-.346-.121-.522a1.042 1.042 0 0 0-.317-.317l-.196-.121-2.283-.011-2.284-.011-.213.106m0 5c-.366.183-.547.516-.519.956a.929.929 0 0 0 .451.759l.196.121h2.652l.196-.121c.121-.075.242-.196.317-.317.109-.176.121-.229.121-.522 0-.293-.012-.346-.121-.522a1.042 1.042 0 0 0-.317-.317l-.196-.121-1.283-.011-1.283-.012-.214.107\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/cursor_3_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Cursor3CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Cursor3CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Cursor3CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.665 2.059c-.23.082-.431.263-.557.503l-.111.213.011 1.266.012 1.265.121.197a.99.99 0 0 0 1.721-.029l.118-.214V2.74l-.118-.214a1 1 0 0 0-1.197-.467M4.34 3.853a.984.984 0 0 0-.579.909c-.002.38.103.527 1.028 1.443.941.932 1.017.981 1.464.946.398-.032.702-.265.849-.65a1.13 1.13 0 0 0-.042-.785C6.969 5.537 5.38 3.95 5.2 3.859a1.282 1.282 0 0 0-.4-.087c-.215-.014-.295 0-.46.081m8.575-.04c-.226.085-.325.171-1.143.994-.851.857-.93.974-.93 1.373 0 .305.093.511.328.727a.991.991 0 0 0 1.067.171c.193-.088 1.598-1.445 1.807-1.745.447-.643.038-1.509-.736-1.558-.15-.01-.305.005-.393.038M2.665 8.059A1.028 1.028 0 0 0 2 9c0 .42.318.849.711.958.205.057 2.342.055 2.548-.003a.992.992 0 0 0 0-1.91c-.246-.069-2.396-.057-2.594.014m6.985.242a1.782 1.782 0 0 0-1.203.939c-.317.648-.274 1.236.237 3.26a37.209 37.209 0 0 0 2.07 5.958c.441.986.709 1.392 1.13 1.71.376.283.515.327 1.036.33.433.002.475-.005.72-.121.307-.146.667-.463.878-.774.234-.345.586-1.076 1.12-2.331l.489-1.148.447-.188c2.532-1.066 3.027-1.323 3.466-1.803.117-.128.269-.35.337-.493.114-.242.123-.291.123-.7 0-.412-.008-.457-.127-.709-.296-.624-.81-.993-2.273-1.63a37.616 37.616 0 0 0-5.86-1.979c-1.512-.371-2.054-.438-2.59-.321m1.084 2.021c1.104.225 2.742.682 4.026 1.125 1.221.421 3.434 1.331 3.572 1.469.054.054-.346.247-1.806.87-1.396.596-1.531.666-1.801.93-.258.252-.369.462-.884 1.68-.47 1.11-.86 1.964-.898 1.964-.059 0-.455-.877-.912-2.02a33.985 33.985 0 0 1-1.404-4.3c-.18-.697-.387-1.628-.387-1.741 0-.075.009-.075.494.023m-4.994.614c-.278.141-1.782 1.651-1.897 1.904-.212.469-.009 1.044.457 1.293.214.114.694.12.9.012.184-.097 1.771-1.685 1.86-1.861a1.13 1.13 0 0 0 .042-.785c-.153-.401-.456-.624-.882-.649-.24-.014-.308-.002-.48.086\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/danmaku_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface DanmakuCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const DanmakuCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: DanmakuCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.503 3.042a3.045 3.045 0 0 0-2.191 1.662c-.308.64-.288.335-.299 4.436-.009 3.138-.003 3.658.043 3.53a1.05 1.05 0 0 1 .455-.539l.189-.111 3.217-.011c3.632-.012 3.469-.025 3.783.289a.983.983 0 0 1 0 1.404c-.313.313-.152.301-3.764.289l-3.198-.011-.195-.1a1.035 1.035 0 0 1-.486-.545c-.09-.244-.056 2.113.035 2.421a3.098 3.098 0 0 0 1.648 1.948c.479.226.864.296 1.642.296h.613l.012.91c.013.903.014.912.125 1.137.131.269.321.447.626.587.284.131.691.139.962.019.1-.044.953-.659 1.898-1.367L11.334 18h3.449c3.904 0 3.843.004 4.477-.296a2.925 2.925 0 0 0 1.444-1.444c.307-.65.296-.427.296-5.76s.011-5.11-.296-5.76a2.94 2.94 0 0 0-1.408-1.428c-.658-.317.056-.289-7.676-.297-3.806-.003-7.009.009-7.117.027M7.34 7.066c.369.126.66.538.66.934s-.291.808-.66.934c-.276.094-2.399.098-2.662.005a.986.986 0 0 1-.547-.45C4.036 8.328 4.02 8.256 4.02 8s.016-.328.111-.489c.061-.103.173-.236.25-.294.264-.202.352-.213 1.602-.215.981-.002 1.193.008 1.357.064m12 0c.369.126.66.538.66.934 0 .242-.119.521-.299.701-.317.317-.038.299-4.703.299-4.514 0-4.321.009-4.624-.222a1.19 1.19 0 0 1-.243-.289c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.125-.213.318-.375.539-.454.122-.043.94-.054 4.313-.055 3.773-.002 4.181.004 4.357.064m-4 5c.369.126.66.538.66.934s-.291.808-.66.934c-.276.094-2.399.098-2.662.005a.986.986 0 0 1-.547-.45c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.061-.103.173-.236.25-.294.264-.202.352-.213 1.602-.215.981-.002 1.193.008 1.357.064\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/database.tsx",
    "content": "import type { SvgProps } from \"react-native-svg\"\nimport Svg, { Path } from \"react-native-svg\"\n\nexport function DatabaseIcon(\n  props: SvgProps & {\n    width?: number\n    height?: number\n    color?: string\n  },\n) {\n  return (\n    <Svg width={props.width} height={props.height} viewBox=\"0 0 24 24\" {...props}>\n      <Path\n        fill={props.color}\n        d=\"M12 11q-3.75 0-6.375-1.175T3 7q0-1.65 2.625-2.825Q8.25 3 12 3t6.375 1.175Q21 5.35 21 7q0 1.65-2.625 2.825Q15.75 11 12 11Zm0 5q-3.75 0-6.375-1.175T3 12V9.5q0 1.1 1.025 1.863q1.025.762 2.45 1.237q1.425.475 2.963.687q1.537.213 2.562.213t2.562-.213q1.538-.212 2.963-.687q1.425-.475 2.45-1.237Q21 10.6 21 9.5V12q0 1.65-2.625 2.825Q15.75 16 12 16Zm0 5q-3.75 0-6.375-1.175T3 17v-2.5q0 1.1 1.025 1.863q1.025.762 2.45 1.237q1.425.475 2.963.688q1.537.212 2.562.212t2.562-.212q1.538-.213 2.963-.688t2.45-1.237Q21 15.6 21 14.5V17q0 1.65-2.625 2.825Q15.75 21 12 21Z\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/delete_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Delete2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Delete2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Delete2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.58 1.565c-.815.102-1.522.486-1.935 1.051-.29.395-.47.779-.811 1.724l-.057.16-1.802.02-1.801.02-.196.121a1.042 1.042 0 0 0-.317.317c-.109.176-.121.229-.121.522 0 .293.012.346.121.522a.965.965 0 0 0 .696.449l.197.019.024.285c.014.157.141 2.166.283 4.465.288 4.645.351 5.564.441 6.376.187 1.672.598 2.625 1.507 3.488.919.874 1.823 1.226 3.422 1.335.763.052 4.775.052 5.538 0 1.599-.109 2.503-.461 3.422-1.335.719-.683 1.103-1.388 1.343-2.466.186-.833.239-1.49.605-7.398.142-2.299.269-4.308.283-4.465l.024-.285.197-.019a.965.965 0 0 0 .696-.449c.109-.176.121-.229.121-.522 0-.293-.012-.346-.121-.522a1.042 1.042 0 0 0-.317-.317l-.196-.121-1.801-.02-1.802-.02-.057-.16c-.407-1.129-.649-1.583-1.066-2-.44-.44-.996-.69-1.739-.779-.41-.05-4.38-.046-4.781.004m4.753 2.009c.145.035.279.1.355.171.117.11.392.618.392.724 0 .041-.606.051-3.08.051s-3.08-.01-3.08-.051c0-.099.27-.61.38-.719.234-.232.689-.269 3.1-.248 1.3.011 1.755.028 1.933.072m4.083 2.939c.016.027-.384 6.69-.536 8.927-.186 2.724-.327 3.378-.867 4.004-.486.565-.898.792-1.693.935-.91.164-5.73.164-6.64 0-.706-.127-1.109-.327-1.542-.765-.582-.59-.767-1.164-.917-2.854-.09-1.015-.662-10.207-.637-10.246.027-.044 12.805-.045 12.832-.001m-8.743 3.55c-.244.075-.523.351-.609.603-.056.165-.064.512-.064 2.834 0 2.994-.011 2.883.306 3.2.18.179.458.3.694.3.237 0 .514-.12.697-.303.311-.311.303-.229.303-3.197s.008-2.886-.303-3.197c-.279-.279-.63-.361-1.024-.24m4 0c-.244.075-.523.351-.609.603-.056.165-.064.512-.064 2.834 0 2.994-.011 2.883.306 3.2.18.179.458.3.694.3.237 0 .514-.12.697-.303.311-.311.303-.229.303-3.197s.008-2.886-.303-3.197c-.279-.279-.63-.361-1.024-.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/department_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface DepartmentCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const DepartmentCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: DepartmentCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.499 3.04a3.042 3.042 0 0 0-2.374 2.099c-.083.273-.098.403-.098.861 0 .458.015.588.098.861.238.78.803 1.449 1.545 1.826l.33.168V11h-.582c-1.19 0-1.86.139-2.69.556-1.291.65-2.301 1.955-2.609 3.369-.045.204-.062.229-.183.266-.54.166-1.23.768-1.568 1.368-.255.455-.341.818-.341 1.441 0 .458.015.588.098.861a3.06 3.06 0 0 0 2.014 2.014c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.06 3.06 0 0 0 2.014-2.014c.083-.273.098-.403.098-.861 0-.458-.015-.588-.098-.861a3.084 3.084 0 0 0-1.468-1.788c-.343-.189-.341-.176-.087-.691.356-.724 1.037-1.296 1.84-1.546l.3-.094h5.08l.3.094c.803.25 1.484.822 1.84 1.546.254.515.256.502-.087.691a3.084 3.084 0 0 0-1.468 1.788c-.083.273-.098.403-.098.861 0 .458.015.588.098.861a3.06 3.06 0 0 0 2.014 2.014c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.06 3.06 0 0 0 2.014-2.014c.083-.273.098-.403.098-.861 0-.623-.086-.986-.341-1.441-.338-.6-1.028-1.202-1.568-1.368-.121-.037-.138-.062-.183-.266-.302-1.388-1.251-2.635-2.528-3.323-.828-.446-1.545-.602-2.771-.602H13V8.855l.33-.168a3.077 3.077 0 0 0 1.545-1.826c.083-.273.098-.403.098-.861 0-.458-.015-.588-.098-.861-.291-.956-1.071-1.734-2.014-2.01-.361-.106-1.029-.149-1.362-.089m1.013 2.107c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132m-6 12c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132m12 0c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/discord_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface DiscordCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const DiscordCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: DiscordCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.475 4.043c-.802.118-2.223.642-2.856 1.054-.945.616-1.666 1.731-2.378 3.683-.623 1.705-1.125 3.962-1.284 5.778-.066.744-.029 1.816.074 2.173.248.855.958 1.383 3.738 2.773.765.383.968.469 1.154.486a.99.99 0 0 0 1.001-.567 1.096 1.096 0 0 0-.022-.859c-.13-.256-.27-.367-.791-.629l-.436-.218.159-.189c.087-.103.212-.239.278-.301l.119-.113.574.198c2.631.909 5.804.904 8.435-.014l.539-.189.271.293c.148.161.27.303.269.315 0 .013-.189.111-.419.219-.702.328-.9.567-.9 1.088 0 .244.016.307.13.496.186.31.432.455.806.473l.284.015 1.04-.519c1.881-.939 2.738-1.452 3.205-1.918.472-.472.611-.933.614-2.031.006-2.838-1.195-7.297-2.476-9.195-.581-.861-1.113-1.289-2.115-1.7-1.038-.426-1.737-.611-2.348-.619-.34-.005-.424.007-.561.08-.332.178-.52.455-.564.832-.021.18-.039.22-.091.205-.52-.156-3.328-.156-3.848 0-.052.015-.069-.024-.087-.201-.045-.429-.336-.788-.73-.897-.18-.05-.452-.051-.784-.002m.744 6.525c.805.204 1.383 1.074 1.262 1.899a1.684 1.684 0 0 1-.573 1.081c-.605.558-1.542.597-2.212.092-.81-.61-.925-1.81-.244-2.548.475-.516 1.086-.697 1.767-.524m6.561.02c.396.121.833.485 1.017.846.573 1.124-.167 2.459-1.417 2.555a1.754 1.754 0 0 1-1.586-.778c-.457-.664-.361-1.61.223-2.194a1.733 1.733 0 0 1 1.763-.429\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/docment_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface DocmentCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const DocmentCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: DocmentCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.96 1.545c-.077.008-.365.033-.64.057-2.04.173-3.391.987-4.15 2.498-.303.604-.476 1.272-.577 2.22-.077.728-.077 10.685 0 11.38.173 1.55.609 2.587 1.418 3.367.808.779 1.805 1.183 3.309 1.341.716.075 6.653.074 7.36-.001 1.513-.16 2.501-.56 3.309-1.34.81-.781 1.254-1.841 1.418-3.387.077-.726.077-10.634 0-11.36-.16-1.506-.565-2.505-1.34-3.309-.765-.794-1.79-1.232-3.307-1.415-.354-.043-6.441-.089-6.8-.051m6.38 6.521c.369.126.66.538.66.934 0 .242-.119.521-.299.701-.311.312-.156.299-3.703.299-3.42 0-3.326.006-3.624-.222a1.19 1.19 0 0 1-.243-.289C8.036 9.328 8.02 9.256 8.02 9s.016-.328.111-.489c.125-.213.318-.375.539-.454.12-.043.775-.054 3.313-.055 2.842-.002 3.182.005 3.357.064m-3 5c.369.126.66.538.66.934s-.291.808-.66.934c-.279.095-3.395.1-3.662.005a.986.986 0 0 1-.547-.45c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.061-.103.173-.236.25-.294.274-.209.313-.213 2.102-.215 1.446-.002 1.688.007 1.857.064\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/docment_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface DocmentCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const DocmentCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: DocmentCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.825 1.564c-1.86.102-2.934.495-3.85 1.411-.939.939-1.304 1.964-1.413 3.965-.055.998-.055 9.122 0 10.12.109 2.001.474 3.026 1.413 3.965.939.939 1.964 1.304 3.965 1.413.975.053 5.128.054 6.12.001 1.282-.068 2.108-.242 2.84-.6a3.935 3.935 0 0 0 1.208-.892c.866-.912 1.229-1.972 1.331-3.887.053-.988.053-9.132 0-10.12-.068-1.282-.242-2.108-.6-2.84a4.27 4.27 0 0 0-2.13-2.033c-.7-.3-1.425-.439-2.649-.505-.874-.048-5.349-.046-6.235.002M15.2 3.563c.923.064 1.653.244 2.04.503.077.052.254.207.393.344.417.413.647.983.765 1.89.125.969.158 9.358.042 10.9-.07.935-.249 1.656-.506 2.04a2.716 2.716 0 0 1-.9.798c-.637.319-1.158.388-3.344.446-1.792.047-4.726-.001-5.41-.088-.769-.099-1.351-.306-1.705-.607-.586-.497-.845-1.059-.973-2.109-.159-1.298-.159-10.062 0-11.36.097-.799.301-1.383.609-1.745.5-.589 1.059-.846 2.129-.976.913-.111 5.416-.135 6.86-.036m-6.527 4.5C8.31 8.175 8 8.606 8 9c0 .405.309.826.69.939.307.091 6.313.091 6.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.297-.089-6.349-.086-6.637.002m0 5a.94.94 0 0 0-.366.236.96.96 0 0 0-.001 1.401c.294.293.342.3 2.194.3 1.856 0 1.9-.006 2.197-.303.183-.183.303-.46.303-.697 0-.402-.312-.827-.69-.939-.292-.087-3.354-.085-3.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/documents_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface DocumentsCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const DocumentsCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: DocumentsCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.423 1.581c-1.02.142-1.841.583-2.47 1.327-.581.687-.836 1.391-.911 2.517-.019.298-.044.551-.055.561-.01.011-.201.037-.423.057a3.876 3.876 0 0 0-2.383 1.138 3.87 3.87 0 0 0-1.14 2.408c-.053.557-.053 6.885 0 8.109.063 1.464.171 2.01.549 2.782.201.412.263.498.587.82.659.654 1.352.944 2.647 1.109.289.036 1.275.05 3.636.05l3.24.001.32-.09c1.295-.366 2.378-1.386 2.773-2.61.094-.295.15-.602.187-1.044l.026-.305.267-.023c.375-.032.85-.134 1.236-.265.526-.18.905-.417 1.314-.823.324-.322.386-.408.587-.82.38-.776.488-1.325.549-2.805.05-1.184.052-5.033.003-5.615a5.586 5.586 0 0 0-.546-2.058c-.337-.713-.636-1.104-1.572-2.054-1.138-1.154-1.485-1.433-2.265-1.821a6.077 6.077 0 0 0-1.399-.496c-.337-.079-.49-.085-2.36-.095-1.532-.008-2.093.003-2.397.045m2.145 2.004c.683.201 1.26.836 1.391 1.53.023.119.041.41.041.647 0 .555.081.968.263 1.347.266.554.928 1.117 1.517 1.29.088.026.475.063.86.081.757.037 1.001.089 1.347.285.475.269.878.837.978 1.375.055.299.019 3.877-.044 4.38-.12.953-.361 1.393-.901 1.646-.578.27-1.259.326-4.02.326-2.761 0-3.442-.056-4.02-.326-.555-.26-.786-.697-.909-1.721-.073-.607-.074-9.035-.001-9.385.073-.348.268-.716.507-.955.419-.419.756-.541 1.622-.586.693-.037 1.081-.018 1.369.066m3.496.54c.603.394 2 1.798 2.327 2.34.187.31.202.357.099.315-.368-.152-.788-.227-1.43-.254-.988-.043-1.06-.102-1.06-.875 0-.494-.086-1.062-.221-1.461-.05-.148-.077-.27-.06-.27s.172.092.345.205M7.001 10.39c.001 4.286.074 5.038.589 6.09.201.412.263.498.587.82.647.642 1.35.942 2.587 1.102.185.023 1.2.053 2.256.065 1.056.011 1.932.033 1.948.047.015.014.012.15-.007.302-.069.549-.492 1.163-.988 1.433-.465.253-.442.251-3.413.247-3.367-.005-3.979-.049-4.58-.33-.426-.2-.625-.457-.778-1.007-.172-.622-.182-.919-.182-5.339 0-3.991.004-4.211.075-4.42.278-.822.949-1.332 1.835-1.395.066-.005.07.134.071 2.385\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/download_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Download2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Download2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Download2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.516 1.577a1.707 1.707 0 0 0-.878.803l-.098.2-.01 5.153c-.007 3.261-.025 5.149-.049 5.14-.069-.023-.441-.485-.688-.852a10.1 10.1 0 0 1-.516-.907c-.151-.302-.33-.612-.398-.689-.702-.8-2.024-.607-2.466.36a1.575 1.575 0 0 0-.128.861c.073.463.707 1.654 1.306 2.456.808 1.08 1.836 1.936 3.095 2.579.574.293.824.362 1.314.362.49 0 .74-.069 1.314-.362 1.259-.643 2.287-1.499 3.095-2.579.599-.802 1.233-1.993 1.306-2.456a1.575 1.575 0 0 0-.128-.861c-.442-.967-1.764-1.16-2.466-.36-.068.077-.242.377-.387.667-.145.291-.345.654-.444.808-.207.323-.728.98-.776.98-.018 0-.038-2.317-.043-5.15l-.011-5.15-.098-.2a1.792 1.792 0 0 0-.709-.722c-.198-.1-.282-.117-.611-.126-.253-.007-.431.008-.526.045m-8 13.002a1.692 1.692 0 0 0-.878.801c-.097.197-.098.213-.097 1.26.002 1.488.108 2.342.382 3.095.378 1.037 1.091 1.802 2.077 2.228.654.283 1.275.41 2.32.476.868.054 8.492.054 9.36 0 .716-.045 1.218-.118 1.669-.243 1.576-.436 2.549-1.477 2.913-3.116.144-.649.195-1.285.197-2.44.001-1.045 0-1.063-.097-1.26a1.793 1.793 0 0 0-.711-.723c-.312-.158-.839-.187-1.163-.064a1.575 1.575 0 0 0-.887.876c-.06.157-.082.396-.116 1.292-.083 2.147-.222 2.435-1.267 2.619-.519.091-2.274.138-5.218.138-2.938 0-4.699-.047-5.215-.138-1.048-.184-1.187-.47-1.27-2.619-.034-.896-.056-1.135-.116-1.292a1.575 1.575 0 0 0-.877-.872c-.241-.091-.781-.101-1.006-.018\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/download_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Download2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Download2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Download2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.673 2.063c-.261.08-.533.358-.612.627-.053.179-.061.901-.061 5.74v5.535l-.19-.132a7.57 7.57 0 0 1-.652-.593c-.54-.538-.886-1.031-1.308-1.863-.156-.308-.335-.612-.397-.675-.352-.355-1.012-.359-1.385-.007a1.272 1.272 0 0 0-.215.33c-.159.347-.124.565.2 1.224.906 1.848 2.117 3.098 3.867 3.988.86.438 1.3.438 2.16 0 1.755-.893 3.011-2.195 3.891-4.032.298-.621.331-.842.176-1.18a1.272 1.272 0 0 0-.215-.33c-.371-.35-1.002-.352-1.365-.003-.075.072-.258.374-.417.686-.423.832-.769 1.325-1.308 1.862a7.57 7.57 0 0 1-.652.593l-.19.132V8.43c0-6.237.022-5.802-.303-6.127-.279-.279-.63-.361-1.024-.24m-8 12.998c-.273.087-.534.363-.618.651-.056.192-.062.346-.04 1.007.035 1.019.093 1.696.185 2.143.296 1.444 1.057 2.345 2.346 2.776.925.309 1.866.361 6.454.361 5.185.001 5.938-.062 6.98-.581a3.037 3.037 0 0 0 1.412-1.378c.399-.756.531-1.493.593-3.321.022-.661.016-.815-.04-1.007-.152-.525-.759-.839-1.269-.657a1.1 1.1 0 0 0-.547.46c-.105.179-.108.203-.135 1.08-.042 1.403-.114 1.95-.311 2.386-.266.587-.7.821-1.756.948-.615.074-9.239.074-9.854 0-1.552-.187-1.904-.612-2.032-2.449a20.768 20.768 0 0 1-.042-1.12c-.003-.584-.039-.747-.216-.979-.24-.315-.706-.45-1.11-.32\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/edit_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface EditCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const EditCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: EditCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.3 2.546c-2.267.137-3.355.489-4.289 1.388-.827.794-1.235 1.838-1.419 3.626-.074.714-.074 8.166 0 8.88.18 1.748.549 2.725 1.342 3.549.794.827 1.838 1.235 3.626 1.419.714.074 8.166.074 8.88 0 1.748-.18 2.725-.549 3.549-1.342.676-.65 1.107-1.562 1.307-2.766.141-.844.162-1.341.163-3.743l.001-2.383-.121-.196a1.02 1.02 0 0 0-.317-.317c-.176-.109-.229-.121-.522-.121-.293 0-.346.012-.522.121a1.042 1.042 0 0 0-.317.317l-.121.196-.028 2.043c-.039 2.883-.1 3.645-.356 4.446-.117.369-.329.742-.54.953-.473.474-1.268.718-2.696.831-.859.067-6.985.067-7.84 0-1.165-.092-1.902-.27-2.343-.565-.301-.2-.329-.225-.503-.442-.38-.474-.577-1.203-.681-2.52-.068-.86-.068-6.985 0-7.84.166-2.097.582-2.849 1.787-3.236.794-.255 1.565-.317 4.443-.356l2.043-.028.196-.121c.121-.075.242-.196.317-.317.109-.176.121-.229.121-.522 0-.293-.012-.346-.121-.522a1.042 1.042 0 0 0-.317-.317l-.196-.121-2.163-.003c-1.19-.002-2.253.002-2.363.009m11.34.522c-.157.053-.833.714-5.345 5.226-4.753 4.753-5.171 5.182-5.23 5.363A1.353 1.353 0 0 0 9 14c0 .405.309.826.69.939.258.077.362.077.633-.002.212-.062.331-.177 5.384-5.23 5.053-5.053 5.168-5.172 5.23-5.384.079-.271.079-.375.002-.633-.155-.523-.752-.809-1.299-.622\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/emoji_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Emoji2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Emoji2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Emoji2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.995 2.044C7.799 2.29 5.173 3.742 3.589 6.14c-.297.449-.774 1.425-.968 1.98-.441 1.259-.657 2.799-.606 4.32.065 1.937.382 3.346 1.068 4.746 1.009 2.059 2.727 3.493 5.089 4.248 2.316.741 5.34.741 7.656 0 3.049-.974 5.023-3.087 5.807-6.214.285-1.133.424-2.873.33-4.11-.205-2.691-1.084-4.761-2.704-6.37-1.99-1.977-4.972-2.95-8.266-2.696m2.485 2.055c2.379.366 4.155 1.433 5.29 3.181.986 1.519 1.399 3.62 1.172 5.961-.326 3.358-1.954 5.476-4.882 6.35-1.825.545-4.295.545-6.12 0-2.832-.846-4.432-2.838-4.859-6.051-.083-.624-.095-2.026-.024-2.7.297-2.799 1.634-4.85 3.883-5.959.93-.458 1.823-.696 3.22-.857.328-.038 1.921.013 2.32.075M8.673 7.063c-.244.075-.523.351-.609.603-.094.277-.094 2.391 0 2.668.124.363.549.666.936.666.237 0 .514-.12.697-.303C9.98 10.414 10 10.303 10 9s-.02-1.414-.303-1.697c-.279-.279-.63-.361-1.024-.24m5.967.006c-.144.05-.315.197-.844.727C13.091 8.501 13 8.639 13 9s.091.499.796 1.206c.606.607.683.671.881.73.27.08.374.08.633.003.378-.112.69-.537.69-.939 0-.244-.119-.505-.343-.752L15.433 9l.224-.248c.325-.358.403-.654.282-1.062-.155-.524-.754-.81-1.299-.621m-6.2 4.975c-.486.095-1.013.509-1.244.977-.181.368-.224.732-.163 1.389.026.292.081.656.121.81.624 2.398 2.885 3.99 5.316 3.744.961-.097 1.703-.38 2.502-.955.726-.523 1.415-1.441 1.718-2.289.239-.673.361-1.596.282-2.142-.109-.762-.71-1.4-1.447-1.538-.269-.05-6.827-.046-7.085.004m6.544 2.258c-.108 1.187-.961 2.216-2.144 2.584-.434.135-1.246.135-1.68 0-1.183-.368-2.036-1.397-2.144-2.584L8.989 14h6.022l-.027.302\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/exit_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ExitCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ExitCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ExitCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.96 2.04c-2.533.112-3.893.576-4.94 1.683-.832.88-1.256 2.016-1.427 3.817-.076.802-.076 8.118 0 8.92.136 1.435.434 2.434.972 3.26.224.343.864.978 1.216 1.207.777.505 1.659.794 2.851.934.698.081 2.494.15 3.443.132.758-.015.793-.019 1.029-.127.31-.142.617-.45.762-.766.095-.206.111-.292.111-.6s-.016-.394-.111-.6a1.662 1.662 0 0 0-.762-.765l-.244-.111-1.496-.03c-1.659-.033-2.355-.079-2.927-.195-1.354-.274-1.717-.831-1.883-2.883-.069-.856-.069-6.976 0-7.832.166-2.052.529-2.609 1.883-2.883.572-.116 1.268-.162 2.927-.195l1.496-.03.244-.111c.31-.141.617-.449.762-.765.095-.206.111-.292.111-.6s-.016-.394-.111-.6a1.68 1.68 0 0 0-.762-.767l-.244-.113-1.1-.005a46.469 46.469 0 0 0-1.8.025m7.101 5.707c-.252.077-.583.295-.72.475a2.497 2.497 0 0 0-.213.369c-.092.195-.108.279-.105.569.005.497.111.744.466 1.09l.277.27-2.293.001-2.293.001-.227.093a1.48 1.48 0 0 0-.914 1.672c.087.488.428.898.914 1.098l.227.093 2.293.001 2.293.001-.277.27c-.356.346-.461.593-.466 1.09-.003.364.062.568.285.891.32.464 1.056.702 1.635.53.216-.064.811-.444 1.315-.84.916-.718 1.885-1.816 2.178-2.467a2.39 2.39 0 0 0-.009-1.931c-.208-.453-.892-1.301-1.53-1.896-.625-.584-1.571-1.254-1.951-1.383-.234-.079-.622-.078-.885.003\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/exit_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ExitCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ExitCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ExitCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.48 2.565c-1.414.09-2.396.329-3.17.771-1.102.628-1.754 1.609-2.049 3.08-.208 1.041-.26 2.147-.26 5.584s.052 4.543.26 5.584c.447 2.231 1.773 3.415 4.205 3.752.711.099 1.918.151 3.177.137l1.183-.013.196-.121c.284-.175.432-.432.452-.785.013-.237 0-.305-.094-.497a.941.941 0 0 0-.576-.497c-.079-.021-.864-.057-1.744-.079-1.703-.044-2.232-.082-2.787-.202-1.441-.31-1.964-1.003-2.183-2.893-.061-.536-.07-1.043-.07-4.386s.009-3.85.07-4.386c.219-1.89.742-2.583 2.183-2.893.555-.12 1.084-.158 2.787-.202.88-.022 1.665-.058 1.744-.079a.941.941 0 0 0 .576-.497c.094-.192.107-.26.094-.497-.02-.353-.168-.61-.452-.785l-.196-.121-1.403-.005a41.48 41.48 0 0 0-1.943.03m7.749 5.652c-.414.111-.71.511-.708.958a.952.952 0 0 0 .244.666c.052.056.284.23.515.388.231.158.542.395.691.528l.271.241-3.011.011c-3 .011-3.012.011-3.171.097-.359.194-.54.495-.54.894s.181.7.54.894c.159.086.171.086 3.167.097l3.007.011-.187.171a6.835 6.835 0 0 1-.887.685 3.227 3.227 0 0 0-.413.315 1.05 1.05 0 0 0-.132 1.09c.186.4.638.632 1.058.544.762-.159 2.787-2.009 3.27-2.987.162-.328.218-.688.171-1.09-.044-.369-.234-.736-.646-1.25-.768-.957-2.213-2.154-2.748-2.277-.223-.051-.254-.05-.491.014\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/external_link_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ExternalLinkCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ExternalLinkCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ExternalLinkCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M16.392 2.659c-.523.079-1.091.207-1.528.344-.478.149-.699.337-.801.681-.078.261-.079.369-.004.619.112.375.435.634.853.684.152.018.292-.005.613-.099.228-.068.554-.151.725-.186.576-.117 1.482-.181 1.65-.116.051.019-.99 1.084-4.351 4.45-4.053 4.058-4.424 4.44-4.484 4.621A1.353 1.353 0 0 0 9 14c0 .405.309.826.69.939.258.077.362.077.633-.002.212-.062.325-.172 4.644-4.487l4.427-4.423.029.152c.062.332-.053 1.357-.224 1.983l-.144.536c-.236.884.601 1.579 1.412 1.174.348-.173.484-.444.715-1.417.175-.739.229-1.312.206-2.195-.022-.824-.071-1.194-.252-1.915-.272-1.076-.644-1.351-2.236-1.647-.483-.09-2.019-.114-2.508-.039M7.52 5.563c-1.574.1-2.578.482-3.456 1.316-.983.934-1.391 1.948-1.503 3.741-.053.829-.053 4.931 0 5.76.112 1.789.522 2.809 1.503 3.741.902.857 1.859 1.212 3.556 1.318.829.053 4.931.053 5.76 0 1.694-.106 2.633-.453 3.54-1.307.786-.739 1.221-1.578 1.415-2.732.101-.601.145-1.544.145-3.151 0-1.633.001-1.629-.241-1.904-.205-.233-.41-.323-.739-.323-.319 0-.515.082-.728.304-.225.235-.252.356-.254 1.123-.002 1.305-.074 3.163-.136 3.533-.114.689-.259 1.043-.587 1.435-.346.414-.735.684-1.185.823-.521.161-.945.202-2.546.247-1.799.05-4.479-.006-5.065-.106-.681-.116-1.046-.269-1.434-.6-.408-.349-.668-.725-.804-1.168-.162-.524-.203-.945-.248-2.549-.05-1.799.006-4.479.106-5.065.146-.859.415-1.354.981-1.808.409-.329.732-.459 1.418-.573.374-.062 2.216-.134 3.513-.137.783-.001.872-.02 1.124-.242.233-.205.323-.41.323-.739 0-.319-.082-.515-.304-.728-.264-.253-.265-.253-2.043-.246a57.32 57.32 0 0 0-2.111.037\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/eye_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Eye2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Eye2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Eye2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.64 4.584c-4.183.499-7.851 3.288-8.544 6.496-.101.468-.101 1.372 0 1.84.632 2.928 3.751 5.539 7.564 6.335 1.457.304 3.223.304 4.68 0 3.154-.658 5.86-2.549 7.06-4.935.42-.836.576-1.465.576-2.32 0-.855-.156-1.484-.576-2.32-1.342-2.666-4.547-4.688-8.08-5.097-.664-.077-2.033-.077-2.68.001m2.4 1.979c2.175.231 4.231 1.217 5.639 2.704 1.295 1.369 1.623 2.737.978 4.082-.779 1.623-2.72 3.101-4.917 3.745-1.55.454-3.216.52-4.743.188-1.878-.409-3.473-1.279-4.676-2.549-1.295-1.369-1.623-2.737-.978-4.082.592-1.232 1.987-2.507 3.522-3.215 1.671-.773 3.389-1.062 5.175-.873M11.499 9.04a3.042 3.042 0 0 0-2.374 2.099c-.083.273-.098.403-.098.861 0 .458.015.588.098.861a3.06 3.06 0 0 0 2.014 2.014c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.06 3.06 0 0 0 2.014-2.014c.083-.273.098-.403.098-.861 0-.458-.015-.588-.098-.861-.291-.956-1.071-1.734-2.014-2.01-.361-.106-1.029-.149-1.362-.089m1.013 2.107c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/eye_close_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface EyeCloseCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const EyeCloseCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: EyeCloseCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.667 8.065c-.412.126-.708.603-.654 1.055.03.261.351 1.129.627 1.7.195.403.349.669.734 1.269.038.06-.055.169-.642.76-.744.749-.85.904-.851 1.251-.001.328.067.493.296.723.23.229.395.297.723.296.343-.001.504-.109 1.206-.806.444-.442.663-.632.698-.61.029.017.188.141.354.275a9.767 9.767 0 0 0 2.071 1.233c.158.067.243.125.231.158-.126.357-.42 1.589-.419 1.758.001.231.107.512.251.665.313.334.892.402 1.274.149.285-.189.354-.329.599-1.229.126-.461.235-.845.242-.851.007-.007.22.012.473.042.589.07 1.65.07 2.24 0 .295-.035.469-.04.484-.015.014.021.119.391.233.821.217.814.307 1.013.532 1.185.191.146.341.194.611.195.409.001.719-.19.885-.547.144-.309.126-.527-.12-1.435l-.215-.794.245-.104c.693-.295 1.656-.878 2.172-1.316.138-.117.264-.213.28-.213.017 0 .319.287.672.638.697.692.858.8 1.201.801.328.001.493-.067.723-.296.229-.23.297-.395.296-.723-.001-.346-.107-.503-.841-1.241-.373-.375-.678-.696-.678-.713 0-.017.07-.133.155-.258C20.294 11.093 21 9.459 21 9.005 21 8.473 20.53 8 20 8c-.387 0-.802.289-.928.647-.345.979-.496 1.324-.799 1.829-1.053 1.756-2.696 2.906-4.792 3.354-.463.099-.574.108-1.461.109-.839.001-1.015-.01-1.402-.089-1.607-.326-2.875-1.004-3.919-2.095-.763-.798-1.259-1.632-1.663-2.795-.145-.417-.201-.527-.338-.664-.273-.273-.633-.354-1.031-.231\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/facebook_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FacebookCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FacebookCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FacebookCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-.099.009-.387.044-.64.078-3.737.51-6.847 3.065-8.095 6.649-.664 1.908-.71 4.177-.122 6.115a10.022 10.022 0 0 0 7.907 6.992l.15.026V15.007l-.67-.014c-.636-.012-.682-.019-.914-.126a1.698 1.698 0 0 1-.764-.767c-.097-.207-.112-.29-.112-.6 0-.32.014-.389.124-.62.157-.33.406-.579.736-.736.253-.121.277-.124.926-.137l.665-.014.019-1.246c.017-1.102.029-1.283.103-1.556.191-.709.446-1.153.945-1.653.501-.501.962-.764 1.645-.939.405-.103 1.16-.144 1.519-.082.53.093.951.41 1.171.883.088.187.103.276.103.6 0 .324-.015.413-.103.6a1.565 1.565 0 0 1-1.13.877 4.257 4.257 0 0 1-.594.043c-.353 0-.359.002-.493.136l-.136.136v2.201l.67.014c.636.012.682.019.914.126.309.143.617.452.764.767.097.207.112.29.112.6s-.015.393-.112.6a1.698 1.698 0 0 1-.764.767c-.232.107-.278.114-.914.126l-.67.014v3.436c0 1.891.011 3.437.024 3.437.013 0 .206-.037.43-.082a10.022 10.022 0 0 0 6.881-5.161c1.565-2.967 1.508-6.645-.149-9.546-1.547-2.709-4.127-4.488-7.213-4.972-.495-.078-1.782-.133-2.213-.095\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/facebook_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FacebookCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FacebookCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FacebookCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.08 2.045c-1.874.165-3.723.904-5.28 2.109-.437.339-1.307 1.209-1.646 1.646-1.8 2.326-2.505 5.195-1.976 8.046.29 1.566.959 3.04 1.976 4.354.339.437 1.209 1.307 1.646 1.646 2.441 1.889 5.453 2.566 8.44 1.895 2.487-.559 4.752-2.144 6.145-4.301.806-1.247 1.283-2.527 1.521-4.08.098-.641.098-2.079 0-2.72-.285-1.858-.936-3.388-2.06-4.84-.339-.437-1.209-1.307-1.646-1.646-2.067-1.599-4.554-2.336-7.12-2.109m1.752 1.997a8.182 8.182 0 0 1 4.208 1.747c.354.286 1.027.972 1.286 1.311A8.123 8.123 0 0 1 20 12a8.1 8.1 0 0 1-1.789 5.04c-.286.354-.972 1.027-1.311 1.286a8.467 8.467 0 0 1-2.4 1.269c-.479.156-1.203.325-1.398.325H13V14h.553c.696 0 .893-.052 1.144-.303.183-.183.303-.46.303-.697 0-.237-.12-.514-.303-.697-.251-.251-.448-.303-1.144-.303H13l.002-1.09c.002-1.172.015-1.267.215-1.529a1.18 1.18 0 0 1 .291-.248c.17-.099.234-.111.729-.134.615-.028.772-.082 1.002-.344.18-.205.241-.37.241-.655 0-.285-.061-.45-.241-.655-.254-.29-.375-.325-1.099-.324-.541.002-.69.016-.962.093-.958.27-1.756 1.057-2.054 2.025-.094.306-.099.379-.115 1.591L10.993 12h-.549c-.691 0-.89.053-1.138.3a.96.96 0 0 0 0 1.4c.248.248.447.3 1.141.3H11v5.92h-.102c-.426 0-1.649-.371-2.369-.72a7.375 7.375 0 0 1-2.083-1.459 7.632 7.632 0 0 1-1.645-2.267c-1.321-2.735-.987-5.939.873-8.374.259-.339.932-1.025 1.286-1.311a8.254 8.254 0 0 1 4.16-1.745 10.09 10.09 0 0 1 1.712-.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/fast_forward_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FastForwardCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FastForwardCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FastForwardCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.743 5.624a3.05 3.05 0 0 0-1.033.514c-.606.481-.948 1.185-1.092 2.249-.102.756-.155 3.374-.103 5.189.061 2.187.127 2.634.493 3.38.26.528.611.897 1.1 1.155.959.505 1.884.438 3.191-.23.819-.419 3.271-1.911 3.95-2.405.149-.108.286-.196.305-.196.019 0 .047.131.061.291.044.485.174.943.392 1.385.261.528.613.898 1.101 1.155.888.468 1.772.44 2.912-.089 1.003-.467 4.173-2.441 5.41-3.369.909-.682 1.356-1.257 1.549-1.993.076-.288.086-.925.02-1.248-.11-.54-.521-1.194-1.032-1.645-1.124-.99-5.232-3.553-6.402-3.994-.569-.214-1.282-.274-1.804-.151a2.785 2.785 0 0 0-1.462.928c-.353.446-.62 1.176-.683 1.87-.02.219-.041.299-.078.285a3.885 3.885 0 0 1-.324-.214C9.437 7.938 7.037 6.48 6.28 6.1c-.556-.279-.881-.404-1.248-.482-.346-.072-.976-.07-1.289.006M4.76 7.608c.527.158 3.169 1.714 4.418 2.603.878.626 1.169 1.109 1.135 1.885-.039.892-.463 1.315-2.713 2.713-1.416.88-2.411 1.431-2.834 1.57-.419.137-.824.014-.986-.299-.191-.369-.256-1.123-.286-3.34-.024-1.734.023-3.482.111-4.147.119-.889.47-1.189 1.155-.985m9 0c.787.235 5.013 2.869 5.893 3.673.537.49.536.964-.002 1.439-.747.66-4.106 2.808-5.391 3.449-.618.308-.998.349-1.277.136-.375-.286-.451-.841-.489-3.565-.024-1.734.023-3.482.111-4.147.119-.889.47-1.189 1.155-.985\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/file_import_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FileImportCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FileImportCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FileImportCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.24 1.546c-.775.064-1.489.237-1.969.478-1.129.565-2.084 1.648-2.466 2.796-.248.745-.258.895-.275 4.19L3.515 12H5.48l.001-2.17c.001-2.37.043-3.62.137-4.076.205-.994 1.127-1.922 2.122-2.135.413-.089 2.446-.165 2.92-.109.745.087 1.346.506 1.641 1.145.176.379.217.673.218 1.537.001.833.055 1.121.304 1.628a2.882 2.882 0 0 0 1.357 1.357c.507.249.795.303 1.628.304.381 0 .815.018.965.04.893.129 1.588.847 1.713 1.768.056.41.019 5.252-.046 5.971-.085.943-.243 1.583-.48 1.94a3.359 3.359 0 0 1-.347.41c-.685.693-1.366.837-4.243.894l-1.37.028v1.958l1.67-.022c1.708-.021 2.154-.05 2.89-.188 1.104-.207 1.906-.636 2.608-1.395.727-.787 1.04-1.581 1.237-3.145.066-.524.096-8.064.035-8.598-.15-1.294-.736-2.654-1.561-3.623-.354-.416-1.524-1.602-2.098-2.128-1.03-.942-2.097-1.483-3.501-1.776-.3-.063-.606-.072-2.6-.077-1.243-.004-2.341 0-2.44.008m6.362 2.678c.164.101.415.279.558.397.409.335 2.206 2.171 2.407 2.459.221.316.409.638.386.66-.009.01-.106-.01-.215-.044-.438-.135-.788-.176-1.5-.176-.393 0-.805-.02-.917-.043a.988.988 0 0 1-.701-.537l-.12-.24-.025-.96c-.024-.935-.064-1.232-.218-1.634-.039-.102.023-.081.345.118M6.569 14.285c-.389.192-.617.65-.533 1.069.048.24.185.481.333.584.05.036.307.207.571.38.264.173.57.397.68.497l.2.181-2.483.002c-2.803.002-2.732-.005-3.038.301a.984.984 0 0 0 0 1.402c.306.306.235.299 3.038.301l2.483.002-.2.181c-.11.1-.416.324-.68.497-.264.173-.521.344-.571.38-.148.103-.285.344-.333.584-.085.421.147.882.537 1.07.244.119.621.114.887-.01.316-.148 1.127-.708 1.509-1.042.624-.546 1.376-1.487 1.642-2.055.111-.236.126-.311.126-.614 0-.317-.012-.369-.147-.64-.303-.608-1-1.476-1.621-2.019-.382-.334-1.193-.894-1.509-1.042-.267-.125-.649-.129-.891-.009\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/file_upload_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FileUploadCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FileUploadCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FileUploadCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.24 1.546c-.775.064-1.489.237-1.969.478-1.129.565-2.084 1.647-2.466 2.796-.27.811-.265.671-.265 6.84 0 4.354.012 5.664.053 6.04.172 1.556.519 2.428 1.287 3.236.855.899 1.783 1.296 3.44 1.471.712.075 6.648.075 7.36 0 1.677-.177 2.628-.592 3.488-1.522.727-.787 1.04-1.581 1.237-3.145.066-.524.096-8.064.035-8.598-.15-1.294-.736-2.654-1.561-3.623-.354-.416-1.524-1.602-2.098-2.128-1.03-.942-2.097-1.483-3.501-1.776-.3-.063-.606-.072-2.6-.077-1.243-.004-2.341 0-2.44.008m2.533 1.975a2.053 2.053 0 0 1 1.706 1.706c.022.15.04.584.04.965.001.833.055 1.121.304 1.628.392.799 1.162 1.412 2.005 1.597.199.044.501.063.98.064.381 0 .815.018.965.04.893.129 1.588.847 1.713 1.768.056.411.019 5.253-.046 5.971-.117 1.299-.306 1.836-.83 2.353-.309.306-.531.447-.891.567-.801.267-1.511.315-4.719.315s-3.918-.048-4.719-.315c-.36-.12-.582-.261-.891-.567-.52-.513-.705-1.033-.832-2.333-.029-.292-.052-2.077-.066-4.9-.021-4.569.008-6.074.127-6.64.21-.994 1.138-1.915 2.141-2.124.507-.106 2.503-.169 3.013-.095m3.829.703c.164.101.415.279.558.397.409.335 2.206 2.171 2.407 2.459.221.316.409.638.386.66-.009.01-.106-.01-.215-.044-.438-.135-.788-.176-1.5-.176-.393 0-.805-.02-.917-.043a.988.988 0 0 1-.701-.537l-.12-.24-.025-.96c-.024-.935-.064-1.232-.218-1.634-.039-.102.023-.081.345.118m-2.945 6.752c-.241.059-.719.31-1.082.569-.583.416-1.111 1.052-1.465 1.765-.194.389-.21.444-.21.706 0 .238.018.314.111.473.321.545 1.03.672 1.518.27.087-.072.205-.243.298-.429l.153-.31.02 1.62c.02 1.598.021 1.623.114 1.81a.988.988 0 0 0 1.211.489.982.982 0 0 0 .561-.489c.093-.187.094-.212.114-1.806l.02-1.616.153.306c.09.18.212.354.298.424a1.003 1.003 0 0 0 1.518-.269c.093-.159.111-.234.111-.475 0-.267-.015-.315-.218-.717-.483-.956-1.124-1.618-2.042-2.108-.484-.258-.778-.311-1.183-.213\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/filter_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FilterCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FilterCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FilterCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.767 3.043c-.498.065-.847.23-1.17.554-.253.253-.405.52-.508.894-.102.37-.1 2.172.002 2.549.168.615.428 1.012 1.418 2.165.79.919 1.894 2.047 2.763 2.825 1.323 1.182 1.557 1.405 1.632 1.558.071.143.077.305.098 2.652.021 2.398.025 2.51.105 2.749.247.734.616 1.028 2.241 1.79.399.187.881.385 1.073.44.414.12.891.135 1.22.037.435-.129.853-.49 1.057-.911.264-.544.299-1.013.301-4.03 0-1.37.017-2.39.041-2.516a.969.969 0 0 1 .142-.345c.056-.074.582-.563 1.17-1.088 1.953-1.744 3.906-3.885 4.347-4.765.248-.496.28-.703.28-1.841 0-.841-.012-1.064-.068-1.269-.196-.712-.664-1.186-1.38-1.401-.215-.064-.728-.069-7.371-.075-3.927-.003-7.254.009-7.393.028M18.992 5.64c.016.807-.008.989-.157 1.216-.228.343-1.373 1.645-2.093 2.379-.67.682-1.128 1.111-2.43 2.273-.556.496-.816.811-1.018 1.233-.264.552-.263.541-.29 3.666-.013 1.554-.037 2.839-.053 2.855-.045.045-.327-.076-1.165-.497l-.766-.385-.024-2.4c-.026-2.653-.03-2.696-.29-3.239-.226-.472-.473-.748-1.403-1.571-1.77-1.567-4.063-4.008-4.247-4.523-.055-.155-.081-1.569-.029-1.62.015-.015 3.16-.023 6.99-.017l6.963.01.012.62\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/finger_press_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FingerPressCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FingerPressCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FingerPressCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.814 2.041c-1.483.156-2.819.8-3.896 1.877-1.911 1.911-2.434 4.653-1.368 7.18.134.319.23.484.35.604a.984.984 0 0 0 1.405-.005c.198-.198.316-.527.285-.793a3.004 3.004 0 0 0-.187-.555 4.555 4.555 0 0 1-.392-1.652c-.132-2.844 2.43-5.113 5.232-4.632A4.506 4.506 0 0 1 14.88 9.52c-.13.571-.064.89.246 1.188.184.176.388.252.674.252.285 0 .45-.061.655-.241.185-.163.275-.338.358-.699a6.456 6.456 0 0 0-.156-3.574c-.543-1.636-1.782-3.051-3.317-3.787a6.61 6.61 0 0 0-3.526-.618m.146 4.021c-.837.179-1.557.843-1.855 1.711-.083.241-.085.319-.105 3.458l-.02 3.21-.28-.206c-.325-.239-.895-.521-1.24-.613a3.552 3.552 0 0 0-1.367-.047c-.876.153-1.361.507-1.617 1.181-.107.282-.143.82-.077 1.14.06.289 2.688 5.578 2.88 5.797a.984.984 0 0 0 1.201.203c.347-.189.52-.486.52-.894 0-.235-.026-.293-1.3-2.841-.715-1.431-1.3-2.611-1.3-2.621 0-.047.454-.014.628.045.455.154 1.085.711 1.881 1.663.336.402.503.57.63.631.411.2.86.128 1.164-.184.316-.325.297-.014.297-4.924 0-4.83-.016-4.529.252-4.693a.451.451 0 0 1 .496 0c.261.159.252.066.252 2.753 0 1.576.015 2.518.043 2.672.122.667.64 1.27 1.288 1.497.141.05 1.098.193 2.654.398 1.729.228 2.505.346 2.675.407a1.971 1.971 0 0 1 1.279 1.413c.079.309.079 1.076.001 1.602a5.958 5.958 0 0 1-.412 1.5c-.236.578-.25.822-.068 1.154a.99.99 0 0 0 1.43.344c.201-.133.321-.326.535-.858.396-.985.547-1.807.552-3 .003-.776-.006-.895-.09-1.22a4.822 4.822 0 0 0-.784-1.57 4.095 4.095 0 0 0-2.343-1.407 145.83 145.83 0 0 0-2.38-.323 191.702 191.702 0 0 1-2.27-.304l-.11-.022-.001-2.427c0-2.739-.002-2.759-.288-3.326-.502-.997-1.642-1.536-2.751-1.299\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/fire_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FireCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FireCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FireCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.991 2.774a2.25 2.25 0 0 0-.988.734c-.11.15-.271.455-.381.725-.676 1.646-1.73 3.099-3.262 4.492-1.719 1.565-2.669 2.893-3.178 4.445-.252.768-.318 1.192-.319 2.03 0 .787.058 1.192.262 1.829.678 2.125 2.44 3.836 4.775 4.639.39.134.483.151.82.15.338-.001.405-.013.611-.116.285-.143.56-.431.701-.733.153-.331.153-.819-.002-1.269-.407-1.183-.268-2.144.445-3.088.264-.349.993-1.062 1.063-1.04.023.007.169.204.325.437s.438.628.626.878c.592.784.733 1.122.776 1.853.017.292.001.573-.061 1.043-.094.724-.076.957.102 1.309.305.601.893.896 1.574.788 1.156-.182 2.405-.64 3.24-1.189 1.609-1.057 2.518-2.64 2.807-4.891.075-.577.063-1.934-.021-2.418-.326-1.887-1.298-3.949-2.816-5.972-.57-.761-.878-1.013-1.375-1.128-.486-.112-1.168.03-1.486.31-.09.079-.114.05-.227-.272-.353-1.004-1.043-2.077-1.877-2.916-.31-.314-.449-.421-.703-.547-.299-.148-.339-.157-.739-.168-.366-.01-.459.002-.692.085\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/fire_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FireCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FireCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FireCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11 2.771c-.682.247-1.043.641-1.422 1.553-.66 1.59-1.744 3.07-3.247 4.436-1.351 1.227-2.122 2.184-2.711 3.37-.816 1.642-.988 3.211-.525 4.793.648 2.213 2.547 4.052 4.934 4.78.394.12.517.141.751.128a1.454 1.454 0 0 0 1.252-.862c.154-.331.151-.814-.006-1.289-.399-1.205-.264-2.124.449-3.068.246-.326.98-1.052 1.063-1.052.024 0 .124.129.223.286.098.158.392.576.652.93.489.666.7 1.045.792 1.424.076.318.067 1.133-.02 1.76-.072.511-.072.554-.009.799a1.48 1.48 0 0 0 1.224 1.124c.289.047.624-.004 1.46-.222 1.381-.36 2.464-.97 3.329-1.876 1.074-1.123 1.621-2.508 1.774-4.485.134-1.732-.214-3.256-1.182-5.181a17.995 17.995 0 0 0-1.106-1.888c-.327-.489-.937-1.291-1.124-1.479a1.779 1.779 0 0 0-2.071-.313 1.465 1.465 0 0 0-.251.153c-.09.079-.115.05-.226-.272-.144-.413-.533-1.187-.802-1.597a7.477 7.477 0 0 0-1.091-1.331c-.305-.303-.46-.422-.698-.54-.288-.142-.332-.152-.729-.163-.362-.01-.46.002-.683.082m.918 2.259c.619.675 1.012 1.375 1.342 2.391.241.743.35.951.659 1.262.259.26.569.423.908.478.527.085 1.091-.155 1.359-.579a.847.847 0 0 1 .134-.179c.028 0 .468.594.712.962.948 1.423 1.595 2.837 1.849 4.04.162.772.146 1.891-.04 2.775-.314 1.487-1.025 2.455-2.274 3.094-.316.161-1.195.486-1.316.486-.017 0-.02-.193-.006-.43.05-.844-.059-1.63-.313-2.25-.182-.444-.335-.695-.867-1.419a18.193 18.193 0 0 1-.656-.948c-.367-.597-.888-1.001-1.438-1.115a3.002 3.002 0 0 0-.594-.031c-.326.014-.402.033-.666.164-.373.184-1.066.772-1.497 1.27-.717.83-1.115 1.584-1.318 2.499-.082.372-.098 1.522-.025 1.819a.595.595 0 0 1 .03.194c-.028.027-.577-.28-.888-.496-.886-.617-1.58-1.47-1.893-2.327-.442-1.208-.36-2.349.26-3.63.401-.829.877-1.452 1.756-2.3 1.836-1.771 2.375-2.376 3.124-3.5.549-.824.957-1.598 1.239-2.35.04-.104.086-.19.103-.19s.16.139.316.31\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/flag_1_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Flag1CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Flag1CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Flag1CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.489 3.042c-1.139.064-1.786.202-2.429.519-.718.354-1.147.783-1.498 1.499-.329.67-.451 1.26-.521 2.52-.023.421-.04 3.392-.04 7.143-.001 7.213-.025 6.655.298 6.978a.984.984 0 0 0 1.402 0c.307-.307.299-.224.299-3.127v-2.572l6.57-.012c5.785-.011 6.596-.02 6.79-.075.484-.136.809-.382 1.005-.763.132-.255.15-.734.04-1.059-.132-.388-.3-.645-1.599-2.433-.671-.924-1.271-1.76-1.333-1.858-.126-.198-.142-.35-.054-.52.033-.063.632-.904 1.333-1.868.7-.965 1.338-1.862 1.417-1.994.446-.743.409-1.506-.095-1.96-.183-.166-.396-.278-.703-.369-.213-.064-.621-.07-5.271-.075-2.772-.003-5.297.009-5.611.026\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/folder_open_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FolderOpenCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FolderOpenCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FolderOpenCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.96 3.04c-1.241.062-1.918.241-2.545.672-.767.525-1.183 1.342-1.34 2.628-.062.501-.096 10.244-.039 11.253.022.412.068.875.1 1.03.265 1.256.985 1.976 2.241 2.241.582.122 1.714.141 7.563.126 4.431-.012 5.894-.028 6.2-.067 1.987-.257 2.86-1.091 3.652-3.49.567-1.715 1.213-4.006 1.346-4.769.078-.445.079-1.118.002-1.484-.119-.571-.549-1.235-.968-1.499l-.158-.098-.046-.462c-.102-1.002-.45-1.734-1.164-2.443-.554-.55-1.051-.84-1.803-1.05-.29-.081-.41-.085-3.281-.107-2.734-.021-2.993-.028-3.139-.093-.216-.097-.243-.124-.821-.829-.707-.863-1.042-1.151-1.6-1.376-.449-.181-.557-.192-2.1-.203a51.934 51.934 0 0 0-2.1.02m3.383 2.019c.207.061.411.244.782.701.779.958.991 1.157 1.505 1.415.577.29.426.277 3.71.307 2.123.018 3.011.039 3.14.074a2.107 2.107 0 0 1 1.44 1.399c0 .06-.483.065-5.75.065-4.553 0-5.85.011-6.23.054-2.002.224-2.922 1.066-3.7 3.386l-.242.72.016-3.28c.014-2.85.025-3.324.082-3.617.147-.745.369-.996 1.013-1.147.401-.094.989-.124 2.594-.13 1.163-.005 1.481.005 1.64.053m10.937 6.016c1.033.182 1.123.445.703 2.062a79.85 79.85 0 0 1-.93 3.183c-.647 2.058-.897 2.392-1.936 2.589-.351.066-.787.071-6.217.071-5.258 0-5.876-.007-6.206-.066-.47-.085-.709-.191-.813-.361-.109-.18-.108-.63.003-1.144.175-.808 1.125-4.006 1.439-4.841.43-1.146.81-1.431 2.037-1.528.633-.051 11.618-.018 11.92.035\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/forward_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Forward2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Forward2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Forward2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.84 6.026c-1.118.107-1.908.284-2.767.618a9.511 9.511 0 0 0-5.015 4.523c-.682 1.339-1.025 2.689-1.034 4.073-.005.614.04.752.321.999.205.18.37.241.655.241.285 0 .45-.061.655-.241.255-.224.314-.39.348-.984.064-1.125.279-2.013.711-2.947.707-1.526 2.069-2.888 3.598-3.595 1.642-.761 3.532-.918 5.208-.434a7.5 7.5 0 0 1 3.255 1.899c.52.517 1.038 1.203 1.343 1.778l.051.096-.43-.027c-1.078-.07-2.378-.411-3.399-.89-.278-.131-.359-.151-.62-.153-.258-.002-.326.014-.489.109-.332.195-.545.618-.495.983.033.243.207.543.385.667.558.386 2.288.968 3.439 1.155.865.141 2.316.192 2.933.103.76-.11 1.08-.479 1.558-1.799.424-1.173.625-2.284.656-3.64.025-1.043-.022-1.674-.139-1.905a.985.985 0 0 0-1.363-.434.927.927 0 0 0-.448.479c-.034.084-.05.51-.054 1.42-.005 1.152-.016 1.352-.093 1.755-.047.25-.093.461-.101.47-.008.008-.111-.123-.228-.292a9.94 9.94 0 0 0-2.765-2.618c-.443-.275-1.444-.755-1.896-.908-1.197-.406-2.696-.605-3.78-.501\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/fullscreen_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Fullscreen2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Fullscreen2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Fullscreen2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M16.68 2.643c-.797.079-1.949.35-2.233.525-.191.118-.355.363-.411.615-.131.586.262 1.131.867 1.202.155.019.3-.004.639-.099a6.603 6.603 0 0 1 1.798-.273l.64-.021-2.665 2.674c-2.957 2.967-2.82 2.805-2.788 3.308.032.509.39.867.899.899.503.032.342.168 3.298-2.781l2.664-2.658.023.279c.042.503-.074 1.383-.274 2.073-.144.496-.157.646-.08.911.172.592.836.862 1.41.575.269-.134.399-.317.534-.752a9.035 9.035 0 0 0 .117-4.848c-.157-.615-.41-.975-.838-1.192-.253-.128-.871-.286-1.48-.38-.513-.078-1.605-.108-2.12-.057m-6.6 9.958c-.136.062-.828.73-2.824 2.725l-2.644 2.641-.026-.253c-.048-.478.073-1.398.278-2.103.143-.493.156-.643.079-.908a.856.856 0 0 0-.238-.4.987.987 0 0 0-1.407-.004c-.147.148-.193.235-.296.568a9.014 9.014 0 0 0-.118 4.869c.155.609.409.967.841 1.186.239.121.782.271 1.375.379.497.09 1.799.124 2.316.061.939-.116 1.983-.395 2.224-.596.528-.437.433-1.335-.173-1.638-.304-.152-.517-.155-.992-.016-.228.068-.554.151-.725.186-.576.117-1.482.181-1.65.116-.051-.019.565-.659 2.591-2.69 2.95-2.956 2.814-2.795 2.782-3.298a.925.925 0 0 0-.45-.764 1.086 1.086 0 0 0-.943-.061\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/fullscreen_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FullscreenCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FullscreenCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FullscreenCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.84 2.546c-1.896.291-3.38 1.491-4.04 3.266-.189.508-.248.866-.269 1.633-.023.844-.003.956.221 1.203.219.242.417.33.748.33.329 0 .534-.09.739-.323.194-.221.241-.369.241-.764.001-1.151.27-1.881.93-2.527.647-.633 1.349-.883 2.481-.884.395 0 .543-.047.764-.241.233-.205.323-.41.323-.739 0-.319-.082-.515-.304-.728-.235-.225-.357-.253-1.083-.247-.358.003-.696.013-.751.021m8.76.035c-.184.083-.386.28-.492.479-.12.225-.119.656.001.884.1.19.328.402.517.481.077.032.276.055.483.055 1.132.001 1.834.251 2.481.884.66.646.929 1.376.93 2.527 0 .401.058.58.252.783.213.222.409.304.728.304.337 0 .541-.093.755-.342.22-.258.237-.353.214-1.191-.026-.928-.114-1.328-.463-2.097-.271-.599-.879-1.362-1.412-1.772a5.385 5.385 0 0 0-1.954-.94c-.465-.116-1.823-.153-2.04-.055M3.24 15.035c-.311.102-.593.361-.68.625-.081.244-.03 1.553.076 1.98.419 1.679 1.561 2.959 3.176 3.56.508.189.866.248 1.633.269.838.023.933.006 1.191-.214.249-.214.342-.418.342-.755 0-.319-.082-.515-.304-.728-.203-.194-.382-.252-.783-.252-1.151-.001-1.881-.27-2.527-.93-.633-.647-.883-1.349-.884-2.481 0-.207-.023-.406-.055-.483a1.172 1.172 0 0 0-.478-.513c-.176-.089-.548-.13-.707-.078m17 0c-.297.097-.552.32-.658.574-.04.095-.062.274-.062.5-.001 1.132-.251 1.834-.884 2.481-.646.66-1.376.929-2.527.93-.401 0-.58.058-.783.252-.222.213-.304.409-.304.728 0 .337.093.541.342.755.258.22.353.237 1.191.214.576-.016.822-.04 1.085-.106a5 5 0 0 0 1.61-.698c1.052-.692 1.798-1.761 2.114-3.025.103-.414.157-1.736.08-1.969-.064-.192-.3-.457-.495-.557-.177-.09-.549-.132-.709-.079\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/fullscreen_exit_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface FullscreenExitCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const FullscreenExitCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: FullscreenExitCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.6 2.584a1.13 1.13 0 0 0-.491.476c-.071.136-.088.264-.113.84-.022.526-.049.744-.116.96a3.094 3.094 0 0 1-2.02 2.02c-.216.067-.434.094-.96.116-.753.033-.877.069-1.128.33-.176.184-.252.388-.252.674 0 .4.184.704.54.891.302.16 1.461.132 2.18-.052 1.761-.45 3.161-1.866 3.615-3.659.169-.665.189-1.831.036-2.12a1.111 1.111 0 0 0-.5-.478c-.188-.079-.608-.078-.791.002m8 0a1.135 1.135 0 0 0-.491.476c-.16.303-.132 1.464.052 2.18a5.052 5.052 0 0 0 3.599 3.599c.416.107 1.118.173 1.608.153.472-.019.653-.089.871-.337.18-.205.241-.37.241-.655 0-.285-.061-.45-.241-.655-.242-.275-.374-.316-1.139-.349-.526-.022-.744-.049-.96-.116a3.094 3.094 0 0 1-2.02-2.02c-.067-.216-.094-.434-.116-.96-.025-.576-.042-.704-.113-.84a1.101 1.101 0 0 0-.5-.478c-.188-.079-.608-.078-.791.002M3.24 15.037a1.075 1.075 0 0 0-.658.572A1.256 1.256 0 0 0 2.52 16c0 .286.076.49.252.674.251.261.375.297 1.128.33.526.022.744.049.96.116.959.3 1.72 1.061 2.02 2.02.067.216.094.434.116.96.025.578.042.704.114.844.099.189.326.402.516.481.169.071.579.071.748 0 .189-.079.417-.292.516-.481.161-.308.134-1.462-.051-2.184a5.054 5.054 0 0 0-3.599-3.598c-.547-.14-1.712-.212-2-.125m16.16.005a5.026 5.026 0 0 0-4.239 3.718c-.185.719-.212 1.876-.051 2.184.099.189.327.402.516.481.169.071.579.071.748 0a1.19 1.19 0 0 0 .516-.481c.072-.14.089-.266.114-.844.022-.526.049-.744.116-.96a3.094 3.094 0 0 1 2.02-2.02c.216-.067.434-.094.96-.116.765-.033.897-.074 1.139-.349.18-.205.241-.37.241-.655 0-.285-.061-.45-.241-.655-.24-.274-.386-.322-.999-.331a8.486 8.486 0 0 0-.84.028\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/ghost_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface GhostCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const GhostCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: GhostCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.34 2.024c-.908.094-1.475.209-2.18.442-2.904.96-5.173 3.414-5.893 6.374-.251 1.033-.242.804-.257 6.105-.015 5.366-.024 5.153.244 5.714.27.566.78 1.022 1.373 1.227.235.081.346.094.793.091.634-.003.738-.036 1.596-.511.78-.431.89-.472 1.324-.496.458-.025.727.031 1.102.232.665.356 1.271.638 1.498.697.781.202 1.737.163 2.412-.098.202-.078.546-.25 1.206-.6.376-.2.645-.256 1.102-.231.434.024.544.065 1.324.496.858.475.962.508 1.596.511.447.003.558-.01.793-.091a2.424 2.424 0 0 0 1.373-1.227c.268-.561.259-.348.244-5.714-.015-5.331-.004-5.085-.27-6.15a8.62 8.62 0 0 0-.659-1.775c-1.271-2.621-3.814-4.477-6.741-4.919-.409-.062-1.658-.11-1.98-.077m1.528 2.038a6.899 6.899 0 0 1 4.073 1.997c1.044 1.043 1.666 2.245 1.97 3.801.054.276.064.973.077 5.095.017 5.314.037 4.908-.255 5.031-.137.057-.152.054-.427-.082a28.73 28.73 0 0 1-.766-.403 5.592 5.592 0 0 0-.74-.343 4.132 4.132 0 0 0-2.611.025c-.315.111-.374.14-.989.48-.514.283-.811.377-1.2.377-.389 0-.686-.094-1.2-.377-.614-.339-.674-.368-.989-.481-.801-.286-1.747-.295-2.611-.025-.143.045-.548.237-.9.428-.867.469-.869.469-1.035.4-.286-.12-.265.263-.265-4.812 0-4.947.004-5.041.22-5.893a7.02 7.02 0 0 1 4.311-4.824c.565-.216 1.086-.334 1.889-.43.315-.037 1.023-.02 1.448.036M8.637 9.055c-.444.095-.864.472-1.034.926-.118.315-.113.763.012 1.066.28.681.929 1.037 1.664.913 1.087-.183 1.578-1.514.883-2.393-.2-.253-.514-.45-.818-.514a1.356 1.356 0 0 0-.707.002m6 0c-.444.095-.864.472-1.034.926-.118.315-.113.763.012 1.066.28.681.929 1.037 1.664.913 1.087-.183 1.578-1.514.883-2.393-.2-.253-.514-.45-.818-.514a1.356 1.356 0 0 0-.707.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/gift_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface GiftCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const GiftCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: GiftCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.247 2.045c-.454.075-.915.418-1.087.807-.294.663-.177 2.032.246 2.877.063.126.114.238.114.25 0 .011-.265.021-.59.021-1.499.001-2.241.184-2.847.701-.654.559-.955 1.187-1.042 2.179-.053.603-.052 1.991.001 2.387.074.548.42 1.127.824 1.377l.134.083.001.866c.001 1.216.064 2.636.138 3.137.184 1.235.555 2.009 1.338 2.793.782.782 1.558 1.154 2.785 1.336 1.243.185 6.233.185 7.476 0 1.222-.181 2.006-.558 2.785-1.336.778-.779 1.155-1.563 1.336-2.785.073-.491.139-1.972.14-3.165l.001-.846.134-.083c.404-.25.75-.829.824-1.377.053-.396.054-1.784.001-2.387-.087-.992-.388-1.62-1.042-2.179-.606-.517-1.348-.7-2.847-.701-.325 0-.59-.01-.59-.021 0-.012.051-.124.114-.25.261-.521.406-1.182.405-1.849 0-.685-.115-1.071-.417-1.393-.321-.344-.748-.486-1.462-.486a4.157 4.157 0 0 0-2.928 1.15l-.192.18-.192-.18a4.48 4.48 0 0 0-1.563-.947c-.517-.177-1.445-.251-1.998-.159M9.492 4.06c.126.032.323.107.438.165.269.136.709.576.845.844.11.218.223.632.224.821.001.098-.014.11-.131.11-.411 0-.91-.233-1.272-.596C9.233 5.042 9 4.543 9 4.132 9 4.009 9.009 4 9.132 4c.072 0 .234.027.36.06M15 4.124c0 .185-.112.583-.229.813-.133.263-.575.703-.841.838-.24.121-.607.225-.798.225-.123 0-.132-.009-.132-.132 0-.411.233-.91.596-1.272.364-.364.832-.586 1.254-.593.142-.003.15.004.15.121M11 9.5V11H5l.001-.93c0-.511.019-1.056.041-1.21.068-.47.337-.751.781-.816.13-.019 1.348-.036 2.707-.039L11 8v1.5m7.368-1.426c.192.063.496.369.556.558.029.091.054.577.065 1.258l.019 1.11H13V7.996l2.61.014c1.926.011 2.649.027 2.758.064M11 16.006v3.007l-.93-.028c-2.071-.06-2.661-.248-3.376-1.073-.515-.594-.643-1.244-.681-3.462L5.988 13H11v3.006m6.987-1.556c-.038 2.218-.166 2.868-.681 3.462-.715.825-1.305 1.013-3.376 1.073l-.93.028V13h5.012l-.025 1.45\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/github_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Github2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Github2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Github2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m-2.5 4.054c.421.101.795.245 1.262.485l.382.196.318-.067c.451-.095 2.015-.096 2.498-.001l.339.066.421-.215c.231-.118.618-.282.86-.365.383-.132.489-.152.82-.154.319-.003.407.011.545.085.549.294.802 1.026.766 2.212l-.018.58.172.3c.358.623.512 1.196.509 1.9a3.575 3.575 0 0 1-.514 1.895c-.452.771-1.231 1.466-2.135 1.905l-.33.16.033.2c.049.305.02 3.327-.034 3.532-.062.235-.345.542-.581.633a1.001 1.001 0 0 1-1.292-.552c-.087-.202-.088-.231-.076-1.769.014-1.714.025-1.628-.235-1.919-.358-.4-.388-.906-.077-1.314.162-.212.369-.331.715-.41 1.854-.423 2.948-1.839 2.378-3.078a3.123 3.123 0 0 0-.3-.475 4.072 4.072 0 0 1-.268-.393c-.109-.21-.117-.479-.023-.787.047-.154.085-.318.085-.365v-.085l-.17.087a4.656 4.656 0 0 0-.39.234c-.393.264-.638.298-1.153.161-.715-.19-1.724-.206-2.407-.039-.701.172-.969.145-1.352-.134a3.066 3.066 0 0 0-.358-.223L9 8.278v.089c0 .048.038.223.084.388.132.469.077.694-.276 1.125-.676.827-.606 1.8.188 2.601.463.468 1.107.805 1.876.98.346.079.553.198.715.41.311.408.281.914-.077 1.314-.259.29-.248.212-.272 1.955-.018 1.38-.03 1.62-.088 1.745a1.02 1.02 0 0 1-.919.608c-.626.001-1.088-.569-1.021-1.26.018-.19.017-.193-.098-.193-.259 0-.751-.132-1.068-.286-.398-.194-.834-.599-1.043-.97-.18-.319-.332-.483-.56-.607-.464-.253-.568-.779-.23-1.163.249-.284.583-.331.973-.136.288.145.537.392.956.953.364.486.578.649.895.68l.195.018.023-.614c.013-.338.035-.669.048-.735.024-.117.016-.124-.305-.28-1.346-.653-2.299-1.769-2.582-3.026-.081-.36-.091-1.166-.019-1.474.126-.538.316-1.007.554-1.366.081-.123.095-.18.073-.298-.049-.26-.026-1.067.039-1.411.09-.473.204-.751.392-.955.339-.367.694-.445 1.327-.292\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/github_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface GithubCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const GithubCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: GithubCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.1 2.094c-.407.19-.686.645-.82 1.335-.176.911-.2 1.943-.061 2.646l.059.296-.243.363a6.308 6.308 0 0 0-.941 2.251c-.096.489-.096 1.541 0 2.03.338 1.708 1.409 3.215 3.064 4.311.464.307 1.339.762 1.802.936.165.062.314.124.332.139.017.015-.003.115-.045.223a4.293 4.293 0 0 0-.23 1.062l-.027.306-.172.026c-.305.046-.951.026-1.189-.037-.576-.151-.937-.465-1.568-1.361-.532-.757-.971-1.192-1.433-1.421-.567-.281-.949-.258-1.32.077-.215.194-.306.405-.307.714-.002.427.219.759.62.93.228.098.384.263.8.842.507.705.862 1.117 1.184 1.374.846.674 1.634.926 2.805.898l.59-.015v.564c0 .675.052.872.299 1.118.311.312.157.299 3.701.299 3.546 0 3.389.013 3.702-.3.305-.305.306-.315.288-2.449-.015-1.746-.022-1.892-.101-2.191a4.586 4.586 0 0 0-.149-.475c-.036-.086-.051-.168-.033-.184.018-.015.186-.087.373-.16a11.114 11.114 0 0 0 1.763-.916c1.616-1.07 2.689-2.554 3.041-4.207.118-.552.128-1.596.022-2.133a6.233 6.233 0 0 0-.931-2.235c-.26-.391-.263-.398-.221-.563.151-.608.152-1.628.001-2.515-.29-1.709-.955-2.02-2.849-1.333a9.253 9.253 0 0 0-1.656.772l-.41.245-.43-.097c-.806-.181-1.356-.234-2.41-.234-1.056 0-1.604.053-2.414.235l-.433.097-.407-.245a8.862 8.862 0 0 0-1.652-.773c-.98-.356-1.595-.431-1.994-.245\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/google_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface GoogleCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const GoogleCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: GoogleCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.12 2.04c-1.773.184-3.318.734-4.699 1.674-1.437.978-2.533 2.215-3.298 3.723-.556 1.095-.857 2.043-1.026 3.223-.082.579-.095 1.936-.024 2.5.478 3.766 2.857 6.817 6.381 8.183 2.306.894 5.005.861 7.326-.089a10.234 10.234 0 0 0 4.527-3.694c1.19-1.776 1.883-4.271 1.519-5.468a2.897 2.897 0 0 0-1.481-1.754c-.677-.335-.392-.313-4.165-.328-3.752-.015-3.712-.017-4.165.254-1.178.706-1.361 2.274-.373 3.19.122.113.315.256.429.318.402.217.529.227 3.159.227 1.901.001 2.41.012 2.41.051-.001.12-.375.865-.614 1.221-1.176 1.755-2.991 2.73-5.079 2.729-2.058-.001-4.038-1.139-5.116-2.94-.295-.494-.59-1.283-.738-1.98-.11-.515-.102-1.722.016-2.24.181-.801.523-1.615.93-2.219.848-1.257 2.201-2.158 3.766-2.508.588-.131 1.732-.131 2.355.001 1.168.247 1.992.67 2.932 1.504.46.407.689.547 1.056.642 1.119.292 2.286-.477 2.463-1.623a2.117 2.117 0 0 0-.178-1.196c-.215-.439-1.054-1.212-1.949-1.797-1.211-.792-2.518-1.287-4.026-1.525-.592-.093-1.803-.134-2.338-.079\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/grid_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Grid2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Grid2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Grid2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.073 2.562c-.729.111-1.178.33-1.667.809-.419.41-.656.832-.791 1.409-.069.296-.075.52-.075 2.98 0 2.085.012 2.712.054 2.9.233 1.036 1.006 1.887 1.97 2.166.492.142 1.217.188 2.575.161 1.516-.029 1.785-.077 2.401-.43.532-.304 1.054-.926 1.249-1.488.168-.487.18-.654.199-2.947.025-2.898-.012-3.35-.332-3.989a2.952 2.952 0 0 0-1.85-1.503c-.285-.079-.418-.085-1.906-.094-.88-.005-1.702.006-1.827.026m10.262.018c-1.166.253-2.075 1.215-2.284 2.418-.076.433-.048 1.429.048 1.776.287 1.03 1.078 1.821 2.127 2.13.333.098 3.27.132 3.782.044a2.839 2.839 0 0 0 1.593-.828c.349-.345.587-.723.744-1.18.1-.291.111-.384.127-1.044.02-.889-.031-1.194-.29-1.716-.4-.804-1.189-1.431-2.015-1.6-.398-.082-3.457-.082-3.832 0M8.3 4.564c.242.061.514.306.607.547.068.176.073.362.073 2.629 0 1.899-.012 2.478-.052 2.614-.07.228-.346.504-.574.574-.13.039-.54.052-1.614.052-1.612 0-1.637-.004-1.927-.27-.295-.273-.299-.3-.321-2.43-.023-2.133.015-3.02.14-3.263.177-.345.403-.46.988-.5.597-.042 2.458-.009 2.68.047m10.446-.024c.303.09.508.258.64.527.104.213.114.27.114.673 0 .27-.021.5-.055.595-.07.198-.293.446-.502.558-.158.084-.212.087-1.617.099-1.276.01-1.479.004-1.656-.055a1.029 1.029 0 0 1-.604-.597c-.078-.228-.09-.851-.021-1.122.049-.196.296-.505.478-.6.221-.114.428-.13 1.72-.134 1.046-.003 1.342.008 1.503.056M15.8 11.042c-.508.046-.859.141-1.211.329-.547.291-.93.674-1.215 1.215-.333.632-.371 1.004-.373 3.654-.001 2.59.039 3.021.343 3.627a2.96 2.96 0 0 0 1.932 1.522c.266.062.511.071 1.964.071 1.794 0 2.009-.021 2.5-.239.712-.317 1.335-.997 1.558-1.701.175-.554.188-.815.173-3.5-.014-2.417-.018-2.532-.098-2.82a2.961 2.961 0 0 0-1.422-1.815c-.325-.182-.743-.293-1.291-.344-.498-.046-2.344-.046-2.86.001m3.1 2.051c.088.039.228.137.311.218.268.261.274.316.297 2.409.023 2.133-.015 3.019-.14 3.263-.189.37-.383.455-1.144.505-.681.045-2.276.01-2.534-.055-.217-.054-.499-.302-.595-.522-.07-.159-.076-.33-.087-2.467-.008-1.524.002-2.392.032-2.58.073-.475.323-.744.76-.82.121-.021.832-.034 1.58-.03 1.207.007 1.378.016 1.52.079M4.939 15.057c-.597.111-1.096.381-1.558.844-.372.372-.542.64-.713 1.129-.117.331-.124.39-.14 1.074-.02.889.031 1.195.29 1.716.302.607.755 1.06 1.362 1.362.588.292.707.305 2.707.29l1.733-.012.35-.125c.486-.174.752-.343 1.127-.715.694-.689.933-1.376.89-2.56-.022-.622-.074-.892-.245-1.279-.277-.626-.896-1.246-1.521-1.522-.514-.228-.553-.232-2.341-.243-1.238-.009-1.731.002-1.941.041m3.401 2.009c.253.087.507.341.594.594.078.229.089.851.021 1.124-.057.226-.334.541-.565.641-.152.066-.311.076-1.502.087-.884.009-1.405-.002-1.549-.032a1.002 1.002 0 0 1-.718-.537c-.113-.222-.121-.266-.121-.68 0-.558.065-.749.343-.998.108-.097.264-.195.347-.217.084-.022.763-.042 1.553-.044 1.208-.003 1.43.005 1.597.062\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/grid_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface GridCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const GridCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: GridCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.42 2.558a3.561 3.561 0 0 0-2.119 1.23c-.27.327-.541.844-.667 1.272-.091.309-.094.368-.094 1.7 0 1.338.003 1.389.094 1.68.328 1.045.95 1.773 1.898 2.222.516.245.82.299 1.828.325 1.005.025 1.766-.025 2.14-.141A3.53 3.53 0 0 0 10.846 8.5c.183-.588.205-2.699.035-3.36-.258-1.003-.989-1.89-1.896-2.3-.628-.284-.681-.292-2.085-.304-.704-.005-1.37.004-1.48.022m10.48.002a4.053 4.053 0 0 0-1 .331c-.617.311-1.189.894-1.52 1.549-.294.581-.378 1.098-.379 2.32-.001 1.408.13 1.984.612 2.7.207.307.62.72.927.927.773.521 1.407.643 3.1.6 1.008-.026 1.312-.08 1.828-.325.948-.449 1.57-1.177 1.898-2.222.091-.291.094-.342.094-1.68 0-1.332-.003-1.391-.094-1.7a3.583 3.583 0 0 0-2.406-2.422c-.28-.084-.386-.091-1.56-.101-.693-.006-1.368.005-1.5.023M7.52 4.515c.768.062 1.248.441 1.423 1.125.051.199.061.434.05 1.233-.013.986-.013.987-.126 1.231a1.698 1.698 0 0 1-.767.764l-.24.112h-1.1c-1.056 0-1.109-.004-1.328-.092a1.5 1.5 0 0 1-.757-.713c-.163-.33-.211-.823-.177-1.807.03-.842.081-1.014.408-1.368.293-.316.569-.45.993-.483a12.354 12.354 0 0 1 1.621-.002m11 .081c.391.149.734.491.881.876.093.245.143 1.374.089 2.009-.054.636-.277 1.043-.71 1.297-.34.199-.571.229-1.653.215-.985-.013-.988-.013-1.227-.125a1.698 1.698 0 0 1-.767-.764l-.113-.244v-1.1c0-1.056.004-1.109.092-1.328.129-.319.396-.601.718-.76.331-.163.62-.192 1.698-.169.656.013.822.029.992.093m-12.9 8.446a3.38 3.38 0 0 0-2.098 1.005c-.43.432-.686.868-.888 1.513-.091.291-.094.342-.094 1.68 0 1.332.003 1.391.094 1.7a3.57 3.57 0 0 0 2.426 2.426c.308.09.37.094 1.66.094 1.444 0 1.526-.009 2.079-.222.938-.362 1.681-1.16 2.028-2.178.217-.638.236-2.816.03-3.525a3.484 3.484 0 0 0-.894-1.498c-.609-.609-1.279-.921-2.139-.997a17.814 17.814 0 0 0-2.204.002m10.5 0c-.99.088-1.94.656-2.507 1.498-.521.773-.643 1.407-.6 3.1.026 1.008.08 1.312.325 1.828.449.948 1.177 1.57 2.222 1.898.291.091.342.094 1.68.094 1.332 0 1.391-.003 1.7-.094a3.57 3.57 0 0 0 2.426-2.426c.09-.308.094-.37.094-1.66 0-1.444-.009-1.526-.222-2.079-.294-.763-.912-1.434-1.678-1.821a3.12 3.12 0 0 0-1.24-.34 18.138 18.138 0 0 0-2.2.002m-8.016 2.091c.309.143.617.452.764.767l.112.24v1.1c0 1.056-.004 1.109-.092 1.328a1.505 1.505 0 0 1-.717.76c-.243.119-.311.133-.834.167-.35.023-.8.023-1.165 0-.693-.043-.9-.118-1.225-.442-.323-.324-.4-.535-.443-1.22-.053-.849.006-1.661.143-1.958.124-.269.346-.524.571-.656.329-.193.52-.217 1.622-.208l1.02.009.244.113m10.35-.052c.443.137.771.45.95.908.085.219.09.286.089 1.271 0 1.27-.022 1.356-.449 1.784-.377.377-.526.425-1.41.456-.985.036-1.479-.012-1.809-.175a1.5 1.5 0 0 1-.713-.757c-.088-.219-.092-.272-.092-1.328v-1.1l.112-.24c.193-.414.603-.759 1.018-.857.082-.019.576-.037 1.096-.039.844-.004.974.005 1.208.077\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/hammer_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface HammerCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const HammerCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: HammerCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.08 2.04c-1.001.123-1.945.846-2.332 1.786-.196.477-.208.574-.208 1.674s.012 1.197.208 1.674A3.017 3.017 0 0 0 6.22 8.702c.557.26.612.266 2.269.287l1.509.019.011 5.686.011 5.686.093.26c.226.631.687 1.078 1.307 1.267.201.061.379.073 1.1.072.794-.001.88-.008 1.12-.093a2 2 0 0 0 1.266-1.286c.07-.208.074-.519.085-5.91L15.002 9h1.971c2.09 0 2.213-.009 2.583-.198.288-.147.592-.467.754-.794.147-.298.15-.313.15-.766 0-.413-.011-.485-.104-.683-.146-.312-.894-1.287-1.368-1.784a9.07 9.07 0 0 0-4.433-2.534c-.883-.209-.968-.214-4.195-.224-1.661-.005-3.137.005-3.28.023m6.28 2.018a7.098 7.098 0 0 1 3.09 1.171c.465.314 1.18.98 1.536 1.431l.252.32-5.449.011c-6.104.012-5.633.034-5.975-.28C6.542 6.459 6.5 6.298 6.5 5.5c.001-.471.017-.728.055-.835.076-.217.309-.461.545-.571l.2-.093h2.82c2.204 0 2.912.013 3.24.057M13 14.5V20h-1V9h1v5.5\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/heart_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface HeartCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const HeartCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: HeartCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.72 3.042c-.6.075-1.338.304-1.907.591-.722.365-1.602 1.092-2.16 1.786-1.393 1.732-1.96 4.187-1.471 6.368.575 2.56 2.552 4.967 5.98 7.277 1.563 1.054 2.129 1.342 2.976 1.515.424.087 1.3.087 1.724 0 .841-.172 1.422-.468 2.976-1.515 2.094-1.412 3.732-2.945 4.717-4.419 1.271-1.899 1.7-3.909 1.284-6.005-.557-2.805-2.664-5.04-5.228-5.546-.248-.05-.544-.07-1.011-.069-1.163.001-1.852.21-3.133.95-.215.124-.427.225-.472.225-.046 0-.25-.098-.453-.218-.841-.495-1.394-.726-2.073-.865-.47-.096-1.295-.132-1.749-.075\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/history_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface HistoryCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const HistoryCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: HistoryCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.32 2.027c-.498.036-1.238.156-1.74.281-3.929.981-6.849 4.201-7.466 8.232-.114.748-.124 2.091-.021 2.8.553 3.779 3.003 6.786 6.587 8.084.508.184 1.211.36 1.853.464.717.115 2.101.124 2.807.017 2.014-.305 3.683-1.056 5.18-2.329.388-.331.507-.513.546-.841a.927.927 0 0 0-.205-.705c-.195-.256-.443-.37-.801-.369-.332.001-.431.047-.81.373-1.591 1.366-3.458 2.023-5.55 1.954-.669-.022-.884-.048-1.44-.173a8.081 8.081 0 0 1-4.007-2.267c-1.155-1.211-1.832-2.553-2.142-4.248-.116-.635-.124-1.951-.014-2.54.32-1.73.983-3.063 2.126-4.275a7.977 7.977 0 0 1 7.605-2.265c2.916.697 5.181 2.919 5.913 5.8.072.286.144.608.159.716.024.173.019.192-.042.169a10.93 10.93 0 0 0-.553-.15c-.377-.095-.543-.119-.745-.105a1.09 1.09 0 0 0-.958.638c-.104.21-.122.289-.122.554 0 .261.017.341.11.513.061.112.497.649.97 1.191 1.064 1.222 1.24 1.349 1.86 1.349.354.001.643-.096.901-.3.508-.401.651-.967.654-2.595.003-.949-.016-1.16-.181-2-.347-1.77-1.275-3.552-2.549-4.892-1.255-1.32-3.01-2.348-4.749-2.783-1.046-.261-2.198-.369-3.176-.298m.353 4.036c-.261.08-.533.358-.612.627-.087.293-.089 4.101-.003 4.67.108.702.395 1.351.848 1.913.135.169.766.823 1.4 1.453 1.084 1.076 1.167 1.149 1.371 1.21.27.08.374.08.633.003.279-.083.546-.35.629-.629.077-.259.077-.363-.003-.632-.061-.204-.14-.293-1.332-1.498-.882-.892-1.299-1.342-1.374-1.483-.219-.413-.229-.535-.229-2.76-.001-2.341 0-2.329-.304-2.634-.279-.279-.63-.361-1.024-.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/home_5_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Home5CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Home5CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Home5CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.299 2.381c-.636.14-1.291.477-2.194 1.13-.624.451-6.634 5.13-6.84 5.325-.277.263-.384.509-.385.886 0 .364.114.631.367.86.259.234.449.298.97.325.454.025.46.026.481.129.043.208.141 1.125.222 2.064.413 4.82.666 5.779 1.837 6.94.921.913 1.819 1.278 3.443 1.399.701.053 4.899.053 5.6 0 1.624-.121 2.522-.486 3.443-1.399.77-.764 1.126-1.505 1.379-2.873.117-.635.298-2.222.438-3.847.098-1.142.196-2.063.244-2.29.021-.106.032-.11.269-.111.636-.002 1.004-.125 1.267-.425.207-.236.278-.433.279-.774 0-.375-.106-.62-.384-.886-.245-.234-6.354-4.98-6.975-5.419-.838-.593-1.469-.907-2.083-1.036-.343-.072-1.046-.071-1.378.002m1.282 7.681a2.901 2.901 0 0 1 1.533.824 2.968 2.968 0 0 1 .652 3.265 3.046 3.046 0 0 1-1.615 1.615 2.998 2.998 0 0 1-4.089-2.185c-.427-2.063 1.456-3.946 3.519-3.519\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/home_5_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Home5CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Home5CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Home5CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.3 2.384c-.721.16-1.376.52-2.58 1.417-1.04.776-6.269 4.859-6.468 5.051-.256.247-.376.51-.377.824-.002.304.058.515.202.714.267.368.536.489 1.15.52l.452.022.02.124c.011.068.12 1.132.242 2.364.325 3.272.462 4.189.74 4.944.259.703.539 1.142 1.056 1.657.935.933 1.858 1.306 3.506 1.418.748.051 4.766.051 5.514 0 1.64-.112 2.582-.492 3.506-1.416.539-.54.837-1.021 1.09-1.763.262-.768.395-1.687.726-5.04.111-1.122.211-2.096.222-2.164l.02-.124.456-.023c.508-.025.706-.089.957-.309a1.195 1.195 0 0 0 .059-1.721c-.134-.134-5.61-4.404-6.513-5.078-1.05-.785-1.739-1.182-2.361-1.362-.402-.116-1.219-.144-1.619-.055m1.079 1.984c.534.134.973.447 4.728 3.369l2.001 1.557-.204.204a1.932 1.932 0 0 0-.519.982c-.033.165-.152 1.209-.264 2.32-.337 3.354-.426 4.03-.62 4.675-.155.52-.346.841-.702 1.181-.513.49-1.034.695-1.98.78-1.31.117-5.35.08-6.118-.056-.737-.131-1.124-.329-1.595-.817-.287-.297-.468-.623-.61-1.096-.19-.636-.281-1.325-.617-4.667-.111-1.111-.229-2.146-.261-2.3a2.003 2.003 0 0 0-.516-.996l-.21-.21 2.001-1.557c3.902-3.037 4.23-3.267 4.843-3.396.244-.052.346-.047.643.027m-.939 5.177c-1.164.224-2.059.892-2.575 1.923-.259.519-.325.827-.325 1.532 0 .525.014.668.09.932a3.512 3.512 0 0 0 1.838 2.203c.519.259.827.325 1.532.325.525 0 .668-.014.932-.09a3.547 3.547 0 0 0 2.438-2.438c.076-.264.09-.407.09-.932 0-.525-.014-.668-.09-.932a3.55 3.55 0 0 0-2.417-2.431c-.222-.065-.429-.088-.853-.098a6.907 6.907 0 0 0-.66.006m1 2.024a1.51 1.51 0 0 1 1.037 1.213 1.493 1.493 0 1 1-2.954.436 1.499 1.499 0 0 1 1.917-1.649\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/hotkey_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface HotkeyCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const HotkeyCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: HotkeyCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.24 2.546c-2.117.11-3.311.502-4.229 1.387C3.33 4.59 2.93 5.422 2.72 6.619c-.17.975-.18 1.264-.18 5.421 0 3.543.008 4.047.07 4.588.197 1.694.588 2.656 1.401 3.439.804.775 1.708 1.131 3.361 1.323.542.062 1.047.07 4.628.07 3.581 0 4.086-.008 4.628-.07 1.653-.192 2.557-.548 3.361-1.323.806-.777 1.208-1.76 1.4-3.427.064-.555.071-1.04.071-4.64 0-3.581-.008-4.086-.07-4.628-.197-1.694-.588-2.656-1.401-3.439-.796-.768-1.702-1.128-3.32-1.319-.49-.058-1.094-.068-4.369-.074-2.09-.004-3.917-.001-4.06.006m6.13 1.96.63.027v3.723c-.001 3.825-.023 4.552-.158 5.216-.099.483-.235.758-.486.984-.328.295-.809.427-1.836.504-.307.022-2.149.04-4.266.04H4.529l-.03-.87c-.038-1.079.006-5.452.061-6.13.115-1.427.338-2.129.83-2.622.568-.567 1.498-.795 3.49-.855 1.295-.04 4.695-.05 5.49-.017m3.05.255c.471.116.924.353 1.191.622.503.507.71 1.17.835 2.677.072.854.071 7 0 7.88-.057.698-.174 1.464-.255 1.658l-.051.124-1.357-1.358-1.356-1.358.107-.246c.24-.555.367-1.263.426-2.392.022-.411.039-2.309.04-4.218 0-2.749.01-3.47.05-3.469.028 0 .194.036.37.08M8.643 7.069a1.118 1.118 0 0 0-.343.229C7.997 7.601 8 7.572 8 10c0 2.427-.003 2.399.299 2.701.601.601 1.585.239 1.696-.624l.025-.198.755.504c.415.277.826.529.915.56.757.268 1.519-.5 1.251-1.261-.104-.294-.215-.397-.955-.889a31.19 31.19 0 0 1-.76-.514c-.019-.018.269-.234.64-.48.37-.247.734-.505.808-.574.22-.207.304-.405.304-.725 0-.329-.09-.534-.323-.739-.207-.182-.371-.241-.668-.241-.312 0-.381.035-1.282.637a15.11 15.11 0 0 1-.684.443c-.012 0-.021-.168-.021-.373 0-.47-.076-.705-.299-.928a.998.998 0 0 0-1.058-.23m7.734 10.728 1.356 1.357-.216.062c-.715.205-1.56.26-4.437.289-2.628.027-4.95-.025-5.72-.126-1.279-.168-1.956-.531-2.33-1.248a3.566 3.566 0 0 1-.329-.961L4.673 17l3.493-.001c3.367-.001 4.562-.028 5.251-.12.436-.058 1.043-.213 1.307-.334.119-.054.234-.1.256-.102.022-.002.651.608 1.397 1.354\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/inbox_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface InboxCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const InboxCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: InboxCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.857 4.042c-2.08.118-3.14.71-4.253 2.373-.353.527-.584.915-1.557 2.613-1.204 2.102-1.329 2.375-1.468 3.205-.059.354-.065.656-.047 2.475.019 2.026.038 2.35.171 2.997.228 1.11.854 2.077 1.81 2.794.693.521 1.327.756 2.447.909.553.076 11.527.076 12.08 0 1.33-.181 2.087-.52 2.912-1.305a4.501 4.501 0 0 0 1.343-2.383c.136-.666.154-.977.173-3.012.03-3.065.074-2.896-1.424-5.519-1.845-3.233-2.25-3.79-3.222-4.422-.694-.452-1.449-.65-2.77-.727-.767-.044-5.406-.043-6.195.002m6.312 1.998c1.022.085 1.498.262 1.968.735.455.458.784.973 2.173 3.405.832 1.458.97 1.711.97 1.776 0 .024-.62.044-1.65.054-1.564.014-1.665.02-1.944.104-.846.255-1.485.79-1.866 1.56-.186.376-.256.642-.299 1.132-.02.235-.066.497-.101.581a1.056 1.056 0 0 1-.52.518l-.2.094h-3.4l-.2-.094a1.056 1.056 0 0 1-.52-.518c-.035-.084-.081-.346-.101-.581-.045-.505-.114-.76-.32-1.166a3.006 3.006 0 0 0-1.84-1.525c-.285-.086-.376-.091-1.949-.105-1.03-.01-1.65-.03-1.65-.054 0-.065.138-.318.97-1.776 1.389-2.432 1.718-2.947 2.173-3.405.456-.459.97-.656 1.91-.733.549-.046 5.855-.047 6.396-.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/inbox_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface InboxCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const InboxCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: InboxCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.96 4.04c-1.05.053-1.793.215-2.411.525-.439.221-.627.354-1.003.713-.675.643-1.037 1.189-2.594 3.917-1.019 1.785-1.164 2.09-1.34 2.805-.082.336-.12 3.473-.055 4.489.109 1.683.506 2.659 1.457 3.581.922.892 1.842 1.262 3.413 1.368.788.054 10.358.054 11.146 0 1.309-.089 2.138-.356 2.917-.94.775-.582 1.362-1.37 1.649-2.216.282-.828.355-1.767.33-4.245-.025-2.437-.002-2.353-1.271-4.579-1.657-2.909-2.051-3.512-2.704-4.143-.727-.702-1.493-1.048-2.694-1.217-.382-.054-.95-.067-3.34-.078-1.584-.006-3.159.002-3.5.02m6.14 1.999c1.472.103 1.936.393 2.805 1.753.275.429 1.855 3.145 1.855 3.188 0 .008-.733.021-1.63.03-1.794.016-1.803.017-2.31.284-.351.184-.855.696-1.031 1.046-.177.354-.22.511-.269.971-.022.206-.068.414-.102.467C14.272 14 14.268 14 12 14c-1.823 0-2.105-.008-2.222-.063-.199-.095-.261-.222-.298-.612-.042-.448-.095-.642-.271-.985-.182-.353-.687-.867-1.029-1.046-.507-.267-.516-.268-2.31-.284a52.673 52.673 0 0 1-1.63-.029c0-.019 1.316-2.314 1.571-2.741C6.846 6.509 7.342 6.147 8.82 6.043c.627-.044 5.658-.047 6.28-.004m-7.878 7.024c.197.094.254.213.298.626.049.46.092.617.269.971.176.35.68.862 1.031 1.047.533.28.45.273 3.18.273s2.647.007 3.18-.273c.342-.181.847-.694 1.029-1.047.176-.343.229-.537.271-.985.037-.39.099-.517.298-.612.116-.055.372-.063 1.914-.063h1.78l.029.248c.016.137.017.843.002 1.57-.051 2.561-.158 3.033-.855 3.758-.538.56-1.016.759-2.088.872-.723.075-10.397.075-11.12 0-.845-.089-1.291-.226-1.687-.518-.67-.495-1.025-1.09-1.153-1.93-.055-.36-.139-3.399-.103-3.73l.029-.27h1.781c1.543 0 1.799.008 1.915.063\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/info_circle_fill.tsx",
    "content": "import { G, Path, Svg } from \"react-native-svg\"\n\ninterface IconProps {\n  width?: number\n  height?: number\n  color?: string\n}\nexport const InfoCircleFillIcon = ({ width = 24, height = 24, color = \"#10161F\" }: IconProps) => (\n  <Svg width={width} height={height} viewBox=\"0 0 24 24\">\n    <G fill=\"none\">\n      <Path d=\"M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z\" />\n      <Path\n        fill={color}\n        d=\"M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm-.01 8H11a1 1 0 0 0-.117 1.993L11 12v4.99c0 .52.394.95.9 1.004l.11.006h.49a1 1 0 0 0 .596-1.803L13 16.134V11.01c0-.52-.394-.95-.9-1.004L11.99 10ZM12 7a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z\"\n      />\n    </G>\n  </Svg>\n)\n"
  },
  {
    "path": "apps/mobile/src/icons/information_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface InformationCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const InformationCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: InformationCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.54 2.017c2.788.311 5.174 1.99 6.38 4.488a7.95 7.95 0 0 1-.001 6.945A8.02 8.02 0 0 1 12 19.999a8.014 8.014 0 0 1-7.2-4.528 7.948 7.948 0 0 1 0-6.942A7.973 7.973 0 0 1 8.529 4.8c1.323-.64 2.886-.916 4.291-.759m-1.177 3.028C11.291 7.193 11 7.614 11 8c0 .242.119.521.299.701a.993.993 0 0 0 1.57-.212c.095-.161.111-.233.111-.489s-.016-.328-.111-.489a1.006 1.006 0 0 0-1.226-.442m-1 2.997c-.355.13-.643.549-.643.934 0 .396.291.808.66.934.107.036.226.066.266.066.067 0 .072.128.083 2.43l.011 2.43.112.24c.148.317.455.625.764.765.2.092.301.112.564.113.372.001.564-.078.779-.323.181-.206.241-.371.241-.661 0-.303-.1-.53-.321-.73l-.156-.141-.012-2.492-.011-2.491-.112-.24a1.675 1.675 0 0 0-.764-.765c-.22-.1-.294-.112-.764-.122-.401-.009-.56.004-.697.053\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/instagram_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface InstagramCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const InstagramCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: InstagramCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.16 2.545c-1.548.124-2.453.417-3.56 1.153-1.366.909-2.449 2.41-2.838 3.936-.208.813-.222 1.088-.222 4.326 0 3.166.011 3.412.186 4.24.146.69.563 1.612 1.047 2.314.931 1.35 2.513 2.434 4.029 2.759.816.176 1.071.187 4.198.187 3.127 0 3.382-.011 4.198-.187 1.336-.287 2.795-1.2 3.693-2.311.695-.861 1.217-1.917 1.406-2.846.152-.75.163-1.029.163-4.116 0-3.087-.011-3.366-.163-4.116-.199-.975-.793-2.139-1.523-2.982-.882-1.02-2.281-1.885-3.494-2.16-.813-.185-.996-.194-4.04-.203a183.89 183.89 0 0 0-3.08.006m7.788 4.063c.336.158.541.495.543.892.003.566-.417.987-.992.993-.716.007-1.221-.783-.901-1.413.119-.235.226-.349.422-.45.323-.167.605-.174.928-.022m-3.91 1.53c2.036.562 3.296 2.59 2.879 4.636-.375 1.844-1.893 3.136-3.777 3.214-1.858.077-3.501-1.14-4.008-2.971-.098-.354-.11-.464-.11-1.017 0-.551.012-.663.108-1.011.435-1.568 1.634-2.651 3.267-2.951a5.21 5.21 0 0 1 .723-.019c.425.012.621.037.918.119m-1.458 1.909c-.543.104-1.128.563-1.373 1.078-.578 1.214.167 2.637 1.488 2.842a1.99 1.99 0 0 0 2.272-1.662 1.937 1.937 0 0 0-.563-1.709c-.491-.49-1.127-.682-1.824-.549\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/key_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Key2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Key2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Key2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M14.06 2.536c-2.614.434-4.688 2.315-5.319 4.823-.151.602-.19 1-.185 1.899.005.895-.031 1.145-.206 1.401-.045.067-1.21 1.255-2.589 2.641l-2.508 2.52-.187.38c-.263.534-.325.8-.323 1.38.002.546.066.839.279 1.284a3.093 3.093 0 0 0 1.838 1.579c.24.077.393.092 1.07.108.949.021 1.206-.016 1.67-.239.785-.377 1.355-1.255 1.359-2.092.002-.33.213-.499.623-.5.227 0 .571-.097.888-.249.387-.187.863-.669 1.057-1.071.157-.325.216-.548.257-.974.034-.358.123-.469.398-.495.157-.014.278.011.586.123.727.264 1.237.356 2.092.377.647.015.834.005 1.256-.067 2.65-.451 4.722-2.453 5.284-5.104.11-.519.148-1.488.082-2.059-.322-2.773-2.374-5.008-5.142-5.602-.511-.11-1.786-.145-2.28-.063M15.584 4.5c.589.054 1.432.371 1.978.741.325.221.83.704 1.083 1.037.276.363.614 1.065.743 1.542.098.365.108.464.11 1.14.001.689-.006.767-.109 1.138a4.63 4.63 0 0 1-2.089 2.75c-.507.304-1.177.521-1.826.593-.684.076-1.246-.004-2.129-.301-.875-.295-1.497-.294-2.139.002-.746.345-1.272 1.046-1.371 1.829-.088.698-.126.742-.671.793a2.403 2.403 0 0 0-2.186 2.187c-.052.578-.098.609-.887.608-.508-.001-.616-.012-.771-.082a.992.992 0 0 1-.54-1.242c.066-.203.214-.359 2.64-2.795l2.57-2.58.185-.38c.324-.663.36-.862.367-2.04.007-1.101.039-1.357.24-1.945a4.551 4.551 0 0 1 1.98-2.416 4.652 4.652 0 0 1 1.858-.595c.351-.032.462-.03.964.016m-.194 2.301a1.618 1.618 0 0 0-1.017.859c-.096.207-.112.29-.111.6 0 .309.016.395.112.604.141.309.45.617.766.763.207.096.29.112.6.111.309 0 .395-.016.604-.112.309-.141.617-.45.763-.766.096-.207.112-.29.111-.6 0-.309-.016-.395-.112-.604a1.67 1.67 0 0 0-.756-.757c-.258-.117-.703-.163-.96-.098\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/layout_4_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Layout4CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Layout4CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Layout4CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.3 2.545c-2.312.151-3.352.488-4.289 1.389-.827.794-1.235 1.838-1.419 3.626-.074.714-.074 8.166 0 8.88.18 1.748.549 2.725 1.342 3.549.794.827 1.838 1.235 3.626 1.419.714.074 8.166.074 8.88 0 1.748-.18 2.725-.549 3.549-1.342.676-.65 1.107-1.562 1.307-2.766.153-.92.164-1.266.164-5.3 0-4.114-.01-4.407-.18-5.381-.389-2.221-1.594-3.462-3.768-3.879-.943-.181-1.187-.19-5.192-.199-2.101-.005-3.91-.004-4.02.004m7.68 2.015c1.358.103 2.169.357 2.636.824.473.473.718 1.267.831 2.696.067.855.067 6.981 0 7.84-.113 1.428-.357 2.223-.831 2.696-.467.467-1.278.721-2.636.824-.724.056-5.942.098-6.012.05-.066-.047-.105-.436-.206-2.05A75.752 75.752 0 0 1 9.6 12c0-2.192.037-3.428.162-5.44.089-1.421.138-1.975.182-2.046.036-.058 5.187-.019 6.036.046m-8.088.03c.009.016.001.192-.017.39-.354 3.814-.354 10.226 0 14.04.018.198.026.373.017.389-.036.066-1.095-.106-1.555-.253-.369-.117-.742-.329-.953-.54-.473-.473-.718-1.268-.831-2.696-.068-.86-.068-6.985 0-7.84.104-1.318.301-2.046.681-2.52.174-.217.202-.242.503-.442.362-.242 1.119-.453 1.903-.531l.188-.021c.026-.003.055.008.064.024m4.781 2.473C12.31 7.175 12 7.606 12 8c0 .405.309.826.69.939.302.09 3.318.09 3.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.292-.087-3.354-.085-3.637.002m0 4a.94.94 0 0 0-.366.236.96.96 0 0 0-.001 1.401c.294.293.342.3 2.194.3 1.856 0 1.9-.006 2.197-.303.183-.183.303-.46.303-.697 0-.402-.312-.827-.69-.939-.292-.087-3.354-.085-3.637.002m0 4a.94.94 0 0 0-.366.236.96.96 0 0 0-.001 1.401c.294.293.342.3 2.194.3 1.856 0 1.9-.006 2.197-.303.183-.183.303-.46.303-.697 0-.402-.312-.827-.69-.939-.292-.087-3.354-.085-3.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/layout_leftbar_close_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LayoutLeftbarCloseCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LayoutLeftbarCloseCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LayoutLeftbarCloseCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.3 2.545c-2.312.151-3.352.488-4.289 1.389-.827.794-1.235 1.838-1.419 3.626-.074.714-.074 8.166 0 8.88.18 1.748.549 2.725 1.342 3.549.794.827 1.838 1.235 3.626 1.419.714.074 8.166.074 8.88 0 1.748-.18 2.725-.549 3.549-1.342.676-.65 1.107-1.562 1.307-2.766.153-.92.164-1.266.164-5.3 0-4.114-.01-4.407-.18-5.381-.389-2.221-1.594-3.462-3.768-3.879-.943-.181-1.187-.19-5.192-.199-2.101-.005-3.91-.004-4.02.004m7.68 2.015c1.358.103 2.169.357 2.636.824.473.473.718 1.267.831 2.696.067.855.067 6.981 0 7.84-.113 1.428-.357 2.223-.831 2.696-.467.467-1.278.721-2.636.824-.753.058-5.942.098-6.016.047-.039-.027-.071-.225-.108-.673-.299-3.594-.342-8.097-.114-11.954.081-1.368.157-2.274.198-2.34.026-.043.523-.048 2.756-.028 1.498.014 2.976.044 3.284.068m-8.089.141C7.673 7.638 7.6 9.471 7.6 12c0 2.316.097 4.859.256 6.772.028.334.044.621.036.637-.035.066-1.096-.106-1.555-.253-.787-.251-1.243-.706-1.493-1.493-.298-.933-.34-1.632-.34-5.663 0-3.362.024-4.094.161-4.92.07-.417.219-.931.344-1.18.081-.161.317-.475.43-.572.373-.32.951-.551 1.661-.664.771-.122.803-.121.791.037m7.3 4.233c-.194.073-.672.353-.951.559-.71.523-1.369 1.288-1.673 1.94-.088.189-.105.284-.105.567 0 .384.077.596.374 1.033A5.93 5.93 0 0 0 15 14.983c.288.15.691.161.944.028a.984.984 0 0 0 .536-.896c0-.432-.175-.676-.708-.989a5.048 5.048 0 0 1-.54-.382c-.26-.216-.672-.672-.672-.744 0-.072.412-.528.672-.744.148-.123.407-.303.576-.402.491-.285.672-.547.672-.974a.964.964 0 0 0-.917-.985.985.985 0 0 0-.372.039\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/layout_leftbar_open_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LayoutLeftbarOpenCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LayoutLeftbarOpenCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LayoutLeftbarOpenCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.3 2.545c-2.312.151-3.352.488-4.289 1.389-.827.794-1.235 1.838-1.419 3.626-.074.714-.074 8.166 0 8.88.18 1.748.549 2.725 1.342 3.549.794.827 1.838 1.235 3.626 1.419.714.074 8.166.074 8.88 0 1.748-.18 2.725-.549 3.549-1.342.676-.65 1.107-1.562 1.307-2.766.153-.92.164-1.266.164-5.3 0-4.114-.01-4.407-.18-5.381-.389-2.221-1.594-3.462-3.768-3.879-.943-.181-1.187-.19-5.192-.199-2.101-.005-3.91-.004-4.02.004m7.68 2.015c1.358.103 2.169.357 2.636.824.337.337.594.943.719 1.696.136.824.16 1.562.16 4.92 0 4.031-.041 4.73-.339 5.663-.25.787-.706 1.243-1.493 1.493-.854.272-1.533.32-4.967.352-2.234.02-2.729.015-2.756-.028-.064-.104-.223-2.437-.299-4.4-.051-1.314-.051-4.846 0-6.16.076-1.963.235-4.296.299-4.4.026-.043.523-.048 2.756-.028 1.498.014 2.976.044 3.284.068m-8.089.141C7.673 7.638 7.6 9.471 7.6 12c0 2.316.097 4.859.256 6.772.028.334.044.621.036.637-.035.066-1.096-.106-1.555-.253a2.87 2.87 0 0 1-.898-.484 2.675 2.675 0 0 1-.43-.572c-.226-.452-.376-1.168-.456-2.18-.068-.86-.068-6.985 0-7.84.141-1.777.457-2.543 1.242-3.01.343-.205 1.129-.411 1.845-.484.238-.024.261-.014.251.115m5.349 4.216a1.065 1.065 0 0 0-.66.589 1.19 1.19 0 0 0-.06.374c0 .426.179.686.672.975.169.1.428.281.576.402.259.214.672.67.672.743 0 .072-.412.528-.672.744a5.194 5.194 0 0 1-.576.402c-.494.287-.672.547-.672.98a.86.86 0 0 0 .241.649c.205.233.41.323.739.323.317-.001.475-.067 1.04-.44a5.776 5.776 0 0 0 1.624-1.625c.297-.436.374-.649.375-1.033 0-.298-.015-.372-.124-.6-.275-.575-.934-1.359-1.526-1.814-.323-.247-.946-.616-1.128-.666a1.023 1.023 0 0 0-.521-.003\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/left_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LeftCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LeftCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LeftCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M14.309 4.881c-.199.05-.399.154-.864.451A17.577 17.577 0 0 0 8.253 10.4c-.533.793-.682 1.442-.496 2.17.091.353.217.615.496 1.03a17.58 17.58 0 0 0 5.231 5.09c.628.399.752.447 1.156.447.472 0 .739-.109 1.064-.433.326-.327.434-.593.43-1.064-.002-.275-.022-.383-.104-.565-.156-.346-.351-.527-1.092-1.016a20.005 20.005 0 0 1-1.201-.862c-.568-.452-1.604-1.464-2.043-1.997-.319-.386-.894-1.158-.894-1.2 0-.042.575-.814.893-1.2.413-.5 1.502-1.572 2.007-1.974.242-.193.776-.575 1.186-.848.813-.541 1.021-.737 1.159-1.092a1.66 1.66 0 0 0-.001-1.1c-.131-.332-.502-.702-.824-.825-.268-.101-.682-.137-.911-.08\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/left_small_sharp.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LeftSmallSharpIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LeftSmallSharpIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LeftSmallSharpIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.22 10.24 9.46 12l1.77 1.77L13 15.54l.35-.35.35-.35-1.42-1.42L10.86 12l1.42-1.42 1.419-1.419-.339-.341a4.49 4.49 0 0 0-.359-.34c-.012 0-.813.792-1.781 1.76\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/line_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LineCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LineCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LineCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M19.64 3.068c-.158.053-1.179 1.06-8.345 8.226-7.546 7.546-8.17 8.181-8.23 8.363A1.353 1.353 0 0 0 3 20c0 .405.309.826.69.939.258.077.362.077.633-.002.213-.062.367-.213 8.384-8.23 8.017-8.017 8.168-8.171 8.23-8.384.079-.271.079-.375.002-.633-.155-.523-.751-.809-1.299-.622\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/link_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LinkCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LinkCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LinkCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.82 2.446a5.094 5.094 0 0 0-2.001.688c-2.546 1.545-3.18 4.959-1.361 7.324.119.155.988 1.049 1.929 1.985 1.774 1.764 1.982 1.944 2.633 2.268 1.118.556 2.619.66 3.778.262a5.077 5.077 0 0 0 1.731-.995c.409-.354.527-.58.505-.972-.031-.534-.384-.889-.914-.919-.339-.019-.54.063-.858.351a3 3 0 0 1-3.692.295c-.243-.159-2.745-2.61-3.316-3.247-.389-.435-.593-.777-.729-1.225-.081-.267-.098-.409-.099-.841-.003-.61.055-.869.305-1.379.135-.275.238-.411.548-.724.43-.436.749-.636 1.26-.792.419-.128 1.214-.144 1.621-.033.676.185.952.382 2.06 1.472 1 .982 1.04 1.01 1.465 1.004a.93.93 0 0 0 .672-.291c.177-.177.249-.335.273-.603.039-.428-.012-.505-1.005-1.508-.973-.983-1.307-1.262-1.869-1.561a4.586 4.586 0 0 0-1.83-.563 4.177 4.177 0 0 0-1.106.004m6.41 6.357c-.83.084-1.783.453-2.41.932-.703.536-.88.798-.854 1.259.031.534.384.889.914.919.337.019.525-.058.86-.355.642-.567 1.212-.781 2.06-.774.823.007 1.352.216 2.003.789.491.433 3.075 3.051 3.249 3.292.663.92.721 2.259.141 3.248-.171.292-.788.909-1.08 1.08-1.03.605-2.435.51-3.353-.226a27.672 27.672 0 0 1-1.02-.971c-.462-.454-.903-.858-.979-.897a.94.94 0 0 0-.404-.071c-.57 0-.991.419-.995.992-.002.375.099.513 1.143 1.543.96.947 1.295 1.218 1.854 1.498 1.48.741 3.322.683 4.741-.149a4.984 4.984 0 0 0 1.974-2.12c.352-.72.495-1.343.498-2.172.003-.877-.16-1.576-.535-2.295-.351-.672-.558-.908-2.387-2.73-1.871-1.863-2.044-2.01-2.775-2.352a5.175 5.175 0 0 0-2.645-.44\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_check_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListCheck2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListCheck2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListCheck2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.678 2.756a1.349 1.349 0 0 0-.2.085c-.19.1-.945.745-1.465 1.251l-.526.513-.354-.277c-.54-.425-.746-.528-1.056-.528-.587.001-.998.4-1.002.975-.003.376.13.615.477.857.339.238.809.707 1.068 1.067.258.359.478.482.859.48.421-.003.552-.096 1.149-.819A13.636 13.636 0 0 1 8.18 4.818c.527-.441.598-.515.678-.707.077-.184.08-.589.007-.765a1.172 1.172 0 0 0-.478-.513c-.176-.09-.548-.13-.709-.077m2.995 1.307C10.31 4.175 10 4.606 10 5c0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002M7.678 9.756a1.349 1.349 0 0 0-.2.085c-.191.1-.945.745-1.466 1.253l-.529.514-.319-.255a6.252 6.252 0 0 0-.57-.404c-.222-.131-.283-.149-.513-.149-.594.001-1.005.402-1.009.983-.002.386.111.573.552.908.452.344.611.504.935.937.326.435.522.553.92.551.421-.003.552-.096 1.149-.819a13.636 13.636 0 0 1 1.552-1.542c.527-.441.598-.515.678-.707.077-.184.08-.589.007-.765a1.172 1.172 0 0 0-.478-.513c-.176-.09-.548-.13-.709-.077m2.995 1.307c-.363.112-.673.543-.673.937 0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002m-2.995 5.693a1.349 1.349 0 0 0-.2.085c-.19.1-.945.745-1.465 1.251l-.526.513-.32-.252a6.774 6.774 0 0 0-.551-.392c-.704-.426-1.539.019-1.544.822-.002.37.117.581.471.837.355.258.823.726 1.077 1.079.258.359.478.482.859.48.421-.003.552-.096 1.149-.819a13.636 13.636 0 0 1 1.552-1.542c.527-.441.598-.515.678-.707.077-.184.08-.589.007-.765a1.172 1.172 0 0 0-.478-.513c-.176-.09-.548-.13-.709-.077m2.995 1.307c-.363.112-.673.543-.673.937 0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_check_3_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListCheck3CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListCheck3CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListCheck3CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.36 2.821c-.668.137-1.145.397-1.644.895-.748.749-.956 1.354-.956 2.784 0 1.43.208 2.035.956 2.784.51.509.984.763 1.678.899.384.075 1.788.075 2.206 0a3.162 3.162 0 0 0 1.75-.957 3.097 3.097 0 0 0 .846-1.7c.057-.364.057-1.688 0-2.052-.106-.687-.392-1.237-.912-1.758-.51-.509-.984-.763-1.678-.899-.383-.075-1.875-.072-2.246.004m7.313.242c-.369.114-.673.546-.673.957 0 .385.318.809.69.919.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002M6.46 4.843c.298.112.585.399.697.697.073.196.083.311.083.96 0 .886-.046 1.063-.362 1.378-.315.316-.492.362-1.378.362-.886 0-1.063-.046-1.378-.362-.316-.315-.362-.492-.362-1.378 0-.886.046-1.062.362-1.378.315-.315.475-.357 1.366-.36.665-.002.774.007.972.081m5.213 2.22c-.369.114-.673.546-.673.957 0 .385.318.809.69.919.304.091 4.316.091 4.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.295-.088-4.352-.086-4.637.002M4.38 12.819a3.091 3.091 0 0 0-1.609.835 3.159 3.159 0 0 0-.954 1.746c-.072.4-.072 1.8 0 2.2.119.665.446 1.263.954 1.746.48.457.999.723 1.629.837.402.073 1.798.073 2.2 0a3.062 3.062 0 0 0 1.629-.837 3.159 3.159 0 0 0 .954-1.746c.073-.403.072-1.801 0-2.2a3.062 3.062 0 0 0-.837-1.629 3.159 3.159 0 0 0-1.746-.954c-.391-.071-1.837-.069-2.22.002m7.293.244c-.369.114-.673.546-.673.957 0 .385.318.809.69.919.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002m-5.219 1.778c.29.109.596.415.705.705.071.189.081.311.081.954 0 .89-.043 1.052-.366 1.374-.322.323-.484.366-1.374.366-.884 0-1.051-.044-1.373-.36-.321-.317-.367-.488-.367-1.38 0-.886.046-1.062.362-1.378.315-.315.475-.357 1.366-.36.658-.002.775.008.966.079m5.219 2.222c-.369.114-.673.546-.673.957 0 .385.318.809.69.919.304.091 4.316.091 4.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.295-.088-4.352-.086-4.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_check_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListCheckCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListCheckCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListCheckCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.953 3.615c-.495.204-.83.611-.914 1.114-.16.948.509 1.749 1.461 1.749.539 0 .949-.221 1.254-.678.407-.61.266-1.501-.311-1.957-.414-.328-1.02-.421-1.49-.228m4.72.448C8.31 4.175 8 4.606 8 5c0 .405.309.826.69.939.179.053.908.061 5.81.061 4.902 0 5.631-.008 5.81-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.3-.089-11.347-.087-11.637.002m-4.72 6.552c-.495.204-.83.611-.914 1.114-.16.948.509 1.749 1.461 1.749.953 0 1.621-.804 1.46-1.757-.118-.702-.723-1.199-1.46-1.199-.244 0-.374.022-.547.093m4.72.448C8.31 11.175 8 11.606 8 12c0 .405.309.826.69.939.179.053.908.061 5.81.061 4.902 0 5.631-.008 5.81-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.3-.089-11.347-.087-11.637.002m-4.72 6.552c-.495.204-.83.611-.914 1.114-.16.948.509 1.749 1.461 1.749.953 0 1.621-.804 1.46-1.757-.118-.702-.723-1.199-1.46-1.199-.244 0-.374.022-.547.093m4.72.448C8.31 18.175 8 18.606 8 19c0 .405.309.826.69.939.179.053.908.061 5.81.061 4.902 0 5.631-.008 5.81-.061.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.3-.089-11.347-.087-11.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_collapse_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListCollapseCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListCollapseCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListCollapseCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.493 3.594c-.413.143-.798.545-.918.958-.086.297-.06.799.056 1.058.117.263.455.607.725.74l.224.11h8.322l.267-.131c1.093-.538 1.093-2.119 0-2.658l-.267-.131-4.101-.009c-3.852-.008-4.114-.005-4.308.063m14.182.459c-.49.183-.782.534-1.575 1.887-.475.811-.877 1.587-.977 1.889-.12.358-.113.756.016 1.002.055.104.122.217.15.251.115.139.363.29.594.362.68.212 3.558.209 4.248-.004.541-.167.841-.55.841-1.074 0-.408-.143-.784-.642-1.686-.922-1.669-1.397-2.331-1.829-2.55-.24-.121-.614-.157-.826-.077M3.493 10.594c-.413.143-.798.545-.918.958-.086.297-.06.799.056 1.058.117.263.455.607.725.74l.224.11h8.322l.267-.131c1.093-.538 1.093-2.119 0-2.658l-.267-.131-4.101-.009c-3.852-.008-4.114-.005-4.308.063m14.182 3.459c-.49.183-.782.534-1.575 1.887-.475.811-.877 1.587-.977 1.889-.12.358-.113.756.016 1.002.055.104.122.217.15.251.115.139.363.29.594.362.68.212 3.558.209 4.248-.004.541-.167.841-.55.841-1.074 0-.408-.143-.784-.642-1.686-.922-1.669-1.397-2.331-1.829-2.55-.24-.121-.614-.157-.826-.077M3.493 17.594c-.413.143-.798.545-.918.958-.086.297-.06.799.056 1.058.117.263.455.607.725.74l.224.11h8.322l.267-.131c1.093-.538 1.093-2.119 0-2.658l-.267-.131-4.101-.009c-3.852-.008-4.114-.005-4.308.063\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_collapse_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListCollapseCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListCollapseCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListCollapseCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.673 4.063C3.31 4.175 3 4.606 3 5c0 .405.309.826.69.939.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002m13.946.831c-.309.144-.621.52-1.115 1.344-.557.928-.964 1.733-1.025 2.027-.156.752.254 1.196 1.201 1.299.625.068 2.746.026 3.06-.06.296-.081.52-.233.679-.459.112-.16.121-.197.121-.521v-.348l-.307-.618c-.35-.704-.977-1.768-1.277-2.169-.407-.542-.868-.713-1.337-.495M3.673 11.063C3.31 11.175 3 11.606 3 12c0 .405.309.826.69.939.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002m14.087 3.774c-.384.117-.693.462-1.256 1.401-.557.928-.964 1.733-1.025 2.027-.086.414.025.777.308 1.01.338.279.84.342 2.511.315.947-.016 1.253-.034 1.442-.086.654-.18.946-.688.762-1.324-.161-.553-1.185-2.361-1.671-2.948-.273-.33-.73-.499-1.071-.395M3.673 18.063C3.31 18.175 3 18.606 3 19c0 .405.309.826.69.939.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_expansion_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListExpansionCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListExpansionCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListExpansionCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.493 3.594c-.413.143-.798.545-.918.958-.086.297-.06.799.056 1.058.117.263.455.607.725.74l.224.11h8.322l.267-.131c1.093-.538 1.093-2.119 0-2.658l-.267-.131-4.101-.009c-3.852-.008-4.114-.005-4.308.063m13.077.849c-.813.062-1.197.256-1.434.725-.126.248-.131.649-.013 1.003.1.302.502 1.078.977 1.889.698 1.192 1.005 1.59 1.386 1.802.185.102.255.118.514.117.62-.002.957-.322 1.769-1.679.581-.971 1.018-1.82 1.152-2.238.09-.282.065-.667-.058-.92-.218-.445-.626-.634-1.516-.702a27.961 27.961 0 0 0-2.777.003M3.493 10.594c-.413.143-.798.545-.918.958-.086.297-.06.799.056 1.058.117.263.455.607.725.74l.224.11h8.322l.267-.131c1.093-.538 1.093-2.119 0-2.658l-.267-.131-4.101-.009c-3.852-.008-4.114-.005-4.308.063m13.077 3.849c-.813.062-1.197.256-1.434.725-.126.248-.131.649-.013 1.003.1.302.502 1.078.977 1.889.698 1.192 1.005 1.59 1.386 1.802.185.102.255.118.514.117.62-.002.957-.322 1.769-1.679.581-.971 1.018-1.82 1.152-2.238.09-.282.065-.667-.058-.92-.218-.445-.626-.634-1.516-.702a27.961 27.961 0 0 0-2.777.003M3.493 17.594c-.413.143-.798.545-.918.958-.086.297-.06.799.056 1.058.117.263.455.607.725.74l.224.11h8.322l.267-.131c1.093-.538 1.093-2.119 0-2.658l-.267-.131-4.101-.009c-3.852-.008-4.114-.005-4.308.063\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/list_expansion_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ListExpansionCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ListExpansionCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ListExpansionCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.673 4.063C3.31 4.175 3 4.606 3 5c0 .405.309.826.69.939.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002m13.015.778c-.59.061-.925.223-1.108.535-.281.48-.155.923.671 2.364.65 1.132.992 1.592 1.315 1.766.094.05.223.072.437.073.268.001.325-.013.499-.12.108-.067.29-.238.405-.38.334-.414 1.252-1.986 1.5-2.57.358-.84.058-1.459-.789-1.63-.292-.059-2.46-.087-2.93-.038M3.673 11.063C3.31 11.175 3 11.606 3 12c0 .405.309.826.69.939.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002M16.71 14.84c-.604.058-.944.219-1.13.536-.281.479-.149.941.683 2.389.881 1.534 1.215 1.871 1.813 1.828.473-.035.777-.333 1.398-1.373.799-1.339 1.045-1.853 1.077-2.25.046-.573-.285-.961-.933-1.091-.29-.058-2.41-.087-2.908-.039M3.673 18.063C3.31 18.175 3 18.606 3 19c0 .405.309.826.69.939.308.092 8.312.092 8.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-8.348-.087-8.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/loading_3_cute_li.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Loading3CuteLiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Loading3CuteLiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Loading3CuteLiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.52 2.267A9.765 9.765 0 0 0 5.385 4.84c-.27.25-.345.396-.345.669 0 .686.843.986 1.32.471.165-.178.798-.675 1.169-.916 1.194-.778 2.881-1.303 4.19-1.304.44-.001.625-.055.806-.236.497-.497.109-1.314-.605-1.276l-.4.019\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/loading_3_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Loading3CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Loading3CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Loading3CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.3 2.026c-2.227.181-4.178.989-5.849 2.424-.46.395-.587.56-.651.846-.149.674.462 1.307 1.139 1.18.244-.045.399-.132.639-.355a7.99 7.99 0 0 1 3.502-1.879c.552-.137.958-.195 1.687-.238.487-.029.566-.044.728-.139.733-.43.633-1.523-.165-1.808-.139-.05-.62-.064-1.03-.031\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/love_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LoveCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LoveCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LoveCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        fill={color}\n        fillRule=\"evenodd\"\n        d=\"M15.446 4.003c1.937.693 3.597 2.434 4.174 4.8.032.128.059.257.082.385a4.464 4.464 0 0 0-2.772-.066c-1.4-1.04-3.13-1.11-4.57-.481-1.641.716-2.906 2.31-3.21 4.296-.418 2.724 1.28 5.164 3.637 7.223a4.124 4.124 0 0 1-1.663-.115c-4.773-1.343-7.97-3.446-8.887-6.584-.683-2.338-.115-4.675 1.216-6.244 1.35-1.591 3.503-2.39 5.793-1.526.14.053.326.003.42-.113 1.55-1.893 3.815-2.277 5.78-1.575m.73 7.127a.643.643 0 0 0 .774.136c.623-.333 1.248-.402 1.815-.27 1.494.345 2.588 2.074 2.208 3.98-.333 1.667-1.927 3.041-4.783 4.12a1.991 1.991 0 0 1-1.979-.348 22.356 22.356 0 0 1-.108-.094c-2.238-1.955-3.23-3.76-2.976-5.415.406-2.65 3.334-4.023 5.049-2.109\"\n        clipRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/love_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface LoveCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const LoveCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: LoveCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M12.94 3.719c-1.19.171-2.102.647-3.014 1.576-.221.225-.417.39-.48.404-.064.014-.222-.016-.402-.078-.989-.336-2.264-.351-3.197-.038-1.335.448-2.476 1.463-3.163 2.813-.543 1.068-.758 2.08-.711 3.344.119 3.211 2.183 5.61 6.3 7.325 1.229.512 2.912 1.037 3.638 1.136.698.095 1.554-.009 2.016-.245l.159-.081.287.119c.365.152.666.206 1.136.206.492 0 .817-.078 1.526-.367 3.423-1.397 5.093-3.334 4.992-5.793-.038-.935-.282-1.691-.789-2.445-.238-.355-.76-.86-1.128-1.093-.308-.195-.31-.197-.31-.367a7.19 7.19 0 0 0-.06-.629c-.368-2.809-2.428-5.143-5.04-5.71-.41-.089-1.38-.132-1.76-.077m1.203 2.003c1.293.229 2.466 1.15 3.123 2.452.207.411.383.926.451 1.326.026.154.056.319.065.367.016.078-.007.092-.212.132a5.418 5.418 0 0 0-.538.149l-.307.104-.152-.133c-1.461-1.282-3.677-1.122-5.142.371a4.612 4.612 0 0 0-1.325 3.27c.003 1.378.58 2.693 1.81 4.118.158.183.28.34.271.348-.027.027-.934-.22-1.582-.431-2.352-.766-4.026-1.68-5.145-2.81-.834-.842-1.257-1.637-1.445-2.72-.094-.539-.058-1.473.077-1.989.498-1.903 1.97-3.099 3.588-2.916.195.022.558.105.807.183.737.234 1.323.205 1.953-.094.246-.116.372-.215.7-.548.709-.72 1.088-.968 1.74-1.138a3.441 3.441 0 0 1 1.263-.041m.704 5.647c.131.06.356.224.538.393.195.181.412.338.574.414.242.115.292.124.701.124.425 0 .451-.005.769-.152.181-.083.436-.17.566-.193.848-.15 1.752.61 1.988 1.671.09.406.063 1.046-.061 1.414-.377 1.122-1.645 2.136-3.735 2.987-.495.201-.799.235-1.069.12-.332-.143-1.525-1.322-2.063-2.039-.376-.501-.682-1.066-.837-1.548-.151-.468-.16-1.077-.023-1.52.27-.876.88-1.526 1.633-1.742.295-.085.747-.054 1.019.071\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/magic_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Magic2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Magic2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Magic2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.468 2.597c-.388.156-.7.465-.862.855-.1.242-.103.276-.118 1.336-.018 1.229.017 1.627.174 1.935.29.574.936.897 1.582.793.469-.075.873-.38 1.1-.828l.116-.228V3.62l-.113-.244a1.617 1.617 0 0 0-.706-.721c-.293-.151-.872-.18-1.173-.058M5.611 4.616c-.414.135-.74.423-.926.819-.091.196-.105.276-.105.625 0 .619.046.692 1.096 1.748.769.771.895.882 1.144.999.253.12.315.133.64.132.309 0 .394-.016.6-.112.285-.132.59-.416.721-.671.206-.402.211-.975.011-1.374-.047-.094-.46-.544-.983-1.069-.776-.781-.935-.923-1.145-1.018-.293-.131-.78-.168-1.053-.079m9.885.004c-.09.028-.243.1-.34.16-.252.156-1.801 1.722-1.923 1.944-.427.781-.125 1.703.687 2.092.233.111.298.124.623.123.589-.002.693-.07 1.765-1.147.713-.717.907-.935.988-1.112a1.494 1.494 0 0 0-.103-1.436c-.186-.284-.513-.537-.808-.625-.239-.071-.663-.071-.889.001M3.468 9.598A1.48 1.48 0 0 0 2.514 11c0 .64.37 1.177.968 1.404.193.074.297.078 1.592.067l1.386-.011.221-.109c.283-.138.572-.427.71-.71.098-.198.109-.265.109-.641s-.011-.443-.109-.641a1.677 1.677 0 0 0-.71-.71L6.46 9.54l-1.4-.009c-1.292-.008-1.415-.003-1.592.067m11.925.016a1.44 1.44 0 0 0-.684.557c-.187.284-.243.474-.243.829 0 .356.056.545.245.832.17.258.422.457.719.568.213.08.287.083 1.607.072l1.383-.012.22-.114c.305-.158.573-.431.707-.722.099-.214.113-.291.113-.624s-.014-.41-.113-.624a1.621 1.621 0 0 0-.707-.722l-.22-.114-1.4-.01c-1.392-.01-1.401-.01-1.627.084m-3.877.963a1.707 1.707 0 0 0-.878.803c-.085.174-.098.255-.098.62 0 .72-.371.295 4.497 5.163 4.751 4.75 4.442 4.474 5.041 4.509.555.032.986-.167 1.307-.603.22-.299.275-.483.274-.915-.002-.632.288-.304-4.519-5.123-4.802-4.814-4.424-4.481-5.098-4.499-.253-.007-.431.008-.526.045m-4.476 2.52c-.317.087-.521.253-1.383 1.117-1.029 1.032-1.077 1.109-1.077 1.727 0 .371.01.42.13.665.154.313.405.558.723.708.196.091.279.106.608.106.609 0 .706-.063 1.787-1.148 1.055-1.06 1.107-1.142 1.11-1.732.002-.322-.011-.387-.122-.62-.329-.687-1.053-1.023-1.776-.823m3.689 1.387c-.624.104-1.115.618-1.208 1.265-.049.34-.052 2.106-.003 2.441.073.506.385.938.841 1.161.198.098.265.109.641.109.384 0 .439-.01.64-.114.305-.158.573-.431.707-.722l.113-.244v-2.84l-.116-.228c-.14-.277-.419-.566-.67-.694-.149-.076-.627-.192-.714-.173l-.231.039\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/magic_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Magic2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Magic2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Magic2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.673 3.063c-.25.077-.525.353-.612.614-.056.168-.064.373-.053 1.43.011 1.175.016 1.241.098 1.393.182.336.516.538.897.539.42.002.845-.315.955-.71.024-.087.042-.628.042-1.292 0-1.343-.018-1.449-.303-1.734-.279-.279-.63-.361-1.024-.24m-4.844 2.02a.995.995 0 0 0-.693.577c-.091.212-.087.6.006.803.044.094.393.482.875.969.945.956 1.05 1.026 1.512 1.002.243-.012.321-.035.47-.135.099-.067.233-.201.3-.3.1-.149.123-.227.135-.47.024-.462-.046-.567-1.002-1.512-.447-.441-.875-.831-.952-.865a1.147 1.147 0 0 0-.651-.069m9.901-.002c-.259.053-.445.206-1.274 1.045-.865.877-.918.96-.889 1.408.032.509.39.867.899.899.451.029.528-.021 1.439-.922.92-.911 1.025-1.059 1.025-1.449a.979.979 0 0 0-1.2-.981M3.673 10.062a.954.954 0 0 0-.366.237.96.96 0 0 0-.001 1.401c.281.281.391.3 1.731.3.664 0 1.205-.018 1.292-.042.395-.11.712-.535.71-.955a1.01 1.01 0 0 0-.539-.897c-.153-.082-.214-.086-1.4-.094-.996-.007-1.277.003-1.427.05m12.007-.025c-.865.263-.977 1.426-.18 1.857.152.082.219.087 1.372.098 1.43.015 1.532-.002 1.824-.295.184-.183.304-.459.304-.697 0-.237-.12-.514-.303-.697-.287-.286-.388-.304-1.754-.3-.64.002-1.208.018-1.263.034m-3.959 1.008c-.264.08-.456.248-.61.535-.121.225-.118.615.006.86.126.251 8.434 8.557 8.661 8.66.225.102.634.097.842-.011.189-.097.383-.294.481-.488.095-.189.095-.612-.001-.823-.089-.195-8.351-8.49-8.61-8.644a1.109 1.109 0 0 0-.769-.089M7.25 13.562a1.118 1.118 0 0 0-.34.152c-.273.19-1.687 1.647-1.768 1.823-.093.203-.097.591-.006.803.091.215.286.413.501.51.229.103.611.107.826.008.221-.102 1.737-1.607 1.875-1.862a1.06 1.06 0 0 0 .033-.88c-.182-.411-.656-.645-1.121-.554m3.35 1.481a1.013 1.013 0 0 0-.494.457c-.082.152-.087.218-.098 1.393-.014 1.43-.001 1.507.298 1.806.18.181.458.301.694.301.238 0 .514-.12.697-.304.293-.292.31-.394.295-1.824-.011-1.153-.016-1.22-.098-1.372a1.007 1.007 0 0 0-.894-.538c-.136 0-.289.031-.4.081\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mail_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface MailCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const MailCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: MailCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M6.825 3.564c-1.86.102-2.934.495-3.85 1.411-.939.939-1.304 1.964-1.413 3.965-.054.983-.054 5.137 0 6.12.109 2.001.474 3.026 1.413 3.965.939.939 1.964 1.304 3.965 1.413.983.054 9.117.055 10.12.001 1.282-.068 2.108-.242 2.84-.6a3.935 3.935 0 0 0 1.208-.892c.866-.912 1.229-1.972 1.331-3.887.052-.983.052-5.137 0-6.12-.068-1.282-.242-2.108-.6-2.84a4.27 4.27 0 0 0-2.13-2.033c-.7-.3-1.425-.439-2.649-.505-.89-.049-9.338-.047-10.235.002m9.135 1.955c1.453.039 1.906.08 2.46.22.448.114.766.263.999.469l.181.159-1.95 1.624c-3.154 2.628-3.828 3.145-4.556 3.496-.4.192-.818.312-1.094.312-.266 0-.685-.116-1.057-.292-.751-.356-1.325-.796-4.613-3.533L4.4 6.367l.18-.159c.234-.206.551-.355 1-.469.541-.137 1.047-.186 2.26-.217 1.639-.043 6.589-.044 8.12-.003M5.279 9.702c3.256 2.709 3.955 3.228 4.924 3.663 1.237.556 2.357.556 3.594 0 .981-.44 1.697-.975 5.063-3.781l1.52-1.267.029.131c.057.256.116 3.271.092 4.672-.04 2.308-.117 3.085-.37 3.72-.132.333-.234.482-.536.788-.42.424-1.004.659-1.915.77-1.298.159-10.062.159-11.36 0-1.05-.128-1.612-.387-2.109-.973-.301-.354-.508-.936-.607-1.705-.088-.694-.135-3.62-.086-5.431.025-.94.056-1.768.069-1.84.019-.114.031-.124.087-.08.036.028.758.628 1.605 1.333\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mic_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface MicCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const MicCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: MicCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.34 2.046c-.698.089-1.527.406-2.148.822-1.152.771-1.978 2.128-2.15 3.531-.056.454-.056 5.748 0 6.202.1.817.464 1.729.95 2.38.302.404.785.867 1.188 1.139 1.723 1.163 3.907 1.162 5.644-.001.405-.271 1.024-.89 1.295-1.295a5.2 5.2 0 0 0 .839-2.223c.056-.454.056-5.748 0-6.202a5.2 5.2 0 0 0-.839-2.223c-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835M4.745 12.059a1.03 1.03 0 0 0-.559.503c-.104.201-.111.243-.092.538.012.176.084.564.159.863.635 2.513 2.421 4.529 4.867 5.496.413.164 1.283.396 1.65.442l.23.028v.608c0 .725.048.913.299 1.164a.984.984 0 0 0 1.402 0c.251-.251.299-.439.299-1.164v-.608l.23-.028c.367-.046 1.237-.278 1.65-.442 2.446-.967 4.233-2.985 4.867-5.496.186-.74.211-1.085.096-1.345a.912.912 0 0 0-.426-.482c-.596-.35-1.369.04-1.476.744a8.079 8.079 0 0 1-.408 1.435c-.794 1.854-2.368 3.14-4.373 3.571-.35.075-.542.09-1.16.09-.618 0-.81-.015-1.16-.09-2.464-.53-4.237-2.322-4.738-4.789-.056-.277-.132-.521-.195-.628-.222-.379-.739-.561-1.162-.41\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mic_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface MicCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const MicCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: MicCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.4 2.044c-2.009.227-3.733 1.739-4.238 3.716C7.019 6.317 7 6.766 7 9.5s.019 3.183.162 3.74c.393 1.541 1.541 2.832 3.045 3.423 1.091.43 2.495.43 3.586 0a5.08 5.08 0 0 0 3.062-3.483c.124-.495.145-1.022.145-3.68 0-2.732-.019-3.181-.161-3.74-.441-1.736-1.859-3.154-3.599-3.598a5.223 5.223 0 0 0-1.84-.118m1.46 2.077c.965.3 1.721 1.06 2.026 2.039l.094.3v6.08l-.094.3a3.086 3.086 0 0 1-2.046 2.046c-.434.135-1.246.135-1.68 0a3.086 3.086 0 0 1-2.046-2.046l-.094-.3V6.46l.094-.3c.342-1.095 1.259-1.921 2.357-2.121.326-.06 1.075-.016 1.389.082m-8.115 7.938c-.367.131-.665.534-.665.898 0 .218.114.82.238 1.258.711 2.514 2.718 4.584 5.222 5.386a8.45 8.45 0 0 0 1.25.299l.21.028v.609c0 .731.045.902.306 1.163.18.179.458.3.694.3.237 0 .514-.12.697-.303.254-.255.303-.445.303-1.181v-.588l.21-.028c.115-.015.39-.07.61-.121a8.008 8.008 0 0 0 5.615-4.847c.249-.626.485-1.579.485-1.958 0-.622-.679-1.124-1.264-.933-.438.142-.64.431-.777 1.111-.382 1.888-1.58 3.434-3.295 4.251a6.973 6.973 0 0 1-1.204.435c-.57.157-1.517.201-2.2.103A6.036 6.036 0 0 1 6.1 13.08c-.085-.457-.212-.713-.434-.87-.301-.213-.607-.263-.921-.151\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mind_map_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface MindMapCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const MindMapCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: MindMapCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M13.499 2.04a3.042 3.042 0 0 0-2.374 2.099c-.138.456-.144 1.218-.012 1.681.15.526.426.971.853 1.373l.186.176-.11.325-.109.325-.281-.013c-.837-.038-1.858.24-2.554.698l-.284.186c-.028.018-.21-.106-.46-.316-.342-.286-.413-.364-.408-.45.003-.057.018-.266.034-.464.127-1.634-1.379-2.939-3-2.6a2.502 2.502 0 0 0-1.695 1.302c-.375.762-.375 1.514 0 2.276.538 1.091 1.834 1.614 3.025 1.221l.326-.108.424.351c.313.26.416.368.396.415a15.61 15.61 0 0 0-.159.403c-.512 1.333-.326 2.925.478 4.092l.188.273-.392.468-.391.468-.32-.097c-.27-.081-.404-.096-.86-.097-.459 0-.588.015-.861.098a3.06 3.06 0 0 0-2.014 2.014c-.083.273-.098.403-.098.861 0 .458.015.588.098.861a3.06 3.06 0 0 0 2.014 2.014c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.06 3.06 0 0 0 2.014-2.014c.137-.454.144-1.219.013-1.674a3.394 3.394 0 0 0-.164-.46l-.075-.147.408-.489c.224-.269.418-.5.432-.513.013-.013.192.039.397.116.591.22.93.279 1.614.279.638 0 1.007-.057 1.483-.23l.251-.091.466.468.467.468-.066.216c-.1.332-.12.844-.046 1.196a2.504 2.504 0 0 0 2.965 1.94 2.527 2.527 0 0 0 1.92-1.92A2.504 2.504 0 0 0 17 16.055a2.767 2.767 0 0 0-1.196.046l-.216.066-.35-.349-.35-.348.238-.322a4.74 4.74 0 0 0 .68-1.358l.09-.31h1.299l.132.25c.076.142.255.373.415.534a2.486 2.486 0 0 0 3.52-.002 2.488 2.488 0 0 0 0-3.524 2.486 2.486 0 0 0-3.52-.002c-.16.161-.339.392-.415.534l-.132.25h-1.299l-.09-.31c-.191-.657-.627-1.384-1.13-1.886-.161-.161-.742-.629-.825-.665a3.12 3.12 0 0 1 .097-.327l.106-.324.263-.028a3.024 3.024 0 0 0 2.558-2.119c.083-.273.098-.403.098-.861 0-.458-.015-.588-.098-.861-.291-.956-1.071-1.734-2.014-2.01-.361-.106-1.029-.149-1.362-.089m1.013 2.107c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132M5.724 7.062a.488.488 0 0 1 .198.686.485.485 0 0 1-.844 0 .478.478 0 0 1 .174-.67.453.453 0 0 1 .472-.016m6.596 3.074c.71.248 1.284.822 1.547 1.544.098.269.109.354.109.82 0 .466-.011.551-.109.82a2.589 2.589 0 0 1-1.547 1.548c-.268.097-.355.108-.82.108-.466 0-.551-.011-.82-.109a2.567 2.567 0 0 1-1.544-1.547 2.755 2.755 0 0 1-.082-1.35c.201-.943.994-1.727 1.949-1.929.328-.069.983-.022 1.317.095m7.404 1.926A.56.56 0 0 1 20 12.5c0 .248-.252.5-.5.5-.244 0-.5-.252-.5-.492 0-.251.248-.508.492-.508a.61.61 0 0 1 .232.062M6.512 18.147c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132m10.212-.085A.56.56 0 0 1 17 18.5c0 .248-.252.5-.5.5-.244 0-.5-.252-.5-.492 0-.251.248-.508.492-.508a.61.61 0 0 1 .232.062\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mingcute_down_line.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { G, Path } from \"react-native-svg\"\n\ninterface MingcuteDownLineIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const MingcuteDownLineIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: MingcuteDownLineIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <G fill=\"none\" fillRule=\"evenodd\">\n        <Path d=\"M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035q-.016-.005-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427q-.004-.016-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093q.019.005.029-.008l.004-.014-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014-.034.614q.001.018.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z\" />\n        <Path\n          fill={color}\n          d=\"M12.707 15.707a1 1 0 0 1-1.414 0L5.636 10.05A1 1 0 1 1 7.05 8.636l4.95 4.95 4.95-4.95a1 1 0 0 1 1.414 1.414z\"\n        />\n      </G>\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mingcute_left_line.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { G, Path } from \"react-native-svg\"\n\ninterface MingcuteLeftLineIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const MingcuteLeftLineIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: MingcuteLeftLineIconProps) => {\n  return (\n    <Svg width={width} height={height} viewBox=\"0 0 24 24\">\n      <G fill=\"none\" fillRule=\"evenodd\">\n        <Path d=\"M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z\" />\n        <Path\n          fill={color}\n          d=\"M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414l-5.657-5.657Z\"\n        />\n      </G>\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/mingcute_right_line.tsx",
    "content": "import Svg, { G, Path } from \"react-native-svg\"\n\nexport function MingcuteRightLine({\n  color,\n  height,\n  width,\n}: {\n  width?: number\n  height?: number\n  color?: string\n}) {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <G fill=\"none\" fillRule=\"evenodd\">\n        <Path d=\"M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z\" />\n        <Path\n          fill={color}\n          d=\"M15.707 11.293a1 1 0 0 1 0 1.414l-5.657 5.657a1 1 0 1 1-1.414-1.414l4.95-4.95l-4.95-4.95a1 1 0 0 1 1.414-1.414l5.657 5.657Z\"\n        />\n      </G>\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/more_1_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface More1CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const More1CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: More1CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.516 10.577a1.707 1.707 0 0 0-.878.803c-.085.174-.098.255-.098.62 0 .385.01.439.117.651.139.273.445.575.723.711.174.085.255.098.62.098s.446-.013.62-.098c.278-.136.584-.438.723-.711.107-.212.117-.266.117-.651 0-.365-.013-.446-.098-.62a1.792 1.792 0 0 0-.709-.722c-.198-.1-.282-.117-.611-.126-.253-.007-.431.008-.526.045m6 0a1.707 1.707 0 0 0-.878.803c-.085.174-.098.255-.098.62 0 .385.01.439.117.651.139.273.445.575.723.711.174.085.255.098.62.098s.446-.013.62-.098c.278-.136.584-.438.723-.711.107-.212.117-.266.117-.651 0-.365-.013-.446-.098-.62a1.792 1.792 0 0 0-.709-.722c-.198-.1-.282-.117-.611-.126-.253-.007-.431.008-.526.045m6 0a1.707 1.707 0 0 0-.878.803c-.085.174-.098.255-.098.62 0 .385.01.439.117.651.139.273.445.575.723.711.174.085.255.098.62.098s.446-.013.62-.098c.278-.136.584-.438.723-.711.107-.212.117-.266.117-.651 0-.365-.013-.446-.098-.62a1.792 1.792 0 0 0-.709-.722c-.198-.1-.282-.117-.611-.126-.253-.007-.431.008-.526.045\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/music_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Music2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Music2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Music2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        fill={color}\n        fillRule=\"evenodd\"\n        d=\"m14 10.72 1.885-.628c.978-.326 2.078-.612 2.85-1.335a4 4 0 0 0 .899-1.248c.442-.961.366-2.094.366-3.125 0-.614.075-1.306-.204-1.876a2 2 0 0 0-1.53-1.103c-.577-.077-1.148.168-1.683.347-1.113.37-1.88.625-2.497 1.068a5 5 0 0 0-1.862 2.584C11.989 6.16 12 7.009 12 8.27v5.488a4.5 4.5 0 1 0 2 3.742z\"\n        clipRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/notification_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface NotificationCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const NotificationCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: NotificationCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.204 2.043A7.587 7.587 0 0 0 5.993 5.02a7.874 7.874 0 0 0-1.239 2.554c-.19.731-.201.862-.235 2.766-.042 2.377-.053 2.432-.738 3.86-.433.9-.485 1.043-.521 1.422-.037.388.031.738.219 1.131.273.567.743.965 1.382 1.169.111.035.628.058 1.712.074l1.553.024.094.268a4.044 4.044 0 0 0 1.94 2.248c.451.231.77.335 1.235.406.484.072.726.072 1.21 0a4.015 4.015 0 0 0 3.175-2.654l.094-.268 1.553-.024c1.084-.016 1.601-.039 1.712-.074.639-.204 1.109-.602 1.382-1.169.188-.393.256-.743.219-1.131-.036-.376-.088-.517-.522-1.422-.682-1.423-.7-1.516-.739-3.88-.032-1.886-.043-2.013-.233-2.746-.228-.88-.709-1.854-1.3-2.633-.318-.42-1.004-1.104-1.406-1.402-1.008-.747-2.088-1.219-3.26-1.423-.536-.093-1.574-.13-2.076-.073m1.576 2.016c1.99.289 3.716 1.689 4.379 3.551.294.828.323 1.076.359 3.11.034 1.894.048 2.041.267 2.808.136.477.326.936.677 1.636.164.327.298.621.298.653 0 .031-.028.086-.063.12-.056.057-.719.063-6.697.063s-6.641-.006-6.697-.063c-.035-.034-.063-.089-.063-.12 0-.032.134-.326.298-.653.351-.7.541-1.159.677-1.636.219-.767.233-.914.267-2.808.017-.99.051-1.895.077-2.04.219-1.253.688-2.169 1.559-3.043.731-.734 1.463-1.166 2.442-1.442a5.559 5.559 0 0 1 2.22-.136m.94 13.982c0 .022-.13.173-.29.335-.309.313-.566.467-.938.564a2.073 2.073 0 0 1-1.423-.165c-.2-.101-.789-.649-.789-.734 0-.027.578-.041 1.72-.041s1.72.014 1.72.041\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/numbers_09_sort_ascending_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Numbers09SortAscendingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Numbers09SortAscendingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Numbers09SortAscendingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.499 3.04a3.05 3.05 0 0 0-2.387 2.138c-.089.31-.092.376-.092 1.822 0 1.446.003 1.512.092 1.822a3.07 3.07 0 0 0 2.027 2.053c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.07 3.07 0 0 0 2.027-2.053c.089-.31.092-.376.092-1.822 0-1.446-.003-1.512-.092-1.822-.277-.965-1.073-1.77-2.027-2.049-.361-.106-1.029-.149-1.362-.089m9.176 1.023c-.215.066-.49.314-.588.53-.061.135-.069.698-.087 6.687l-.02 6.54-.275-.342a9.344 9.344 0 0 1-.524-.74c-.365-.582-.665-.777-1.112-.724a1.008 1.008 0 0 0-.853.735c-.108.369-.035.591.426 1.291a7.942 7.942 0 0 0 2.308 2.311c.562.369.764.457 1.05.457.286 0 .488-.088 1.05-.457a8.006 8.006 0 0 0 2.599-2.791c.178-.326.213-.551.13-.832-.151-.508-.707-.826-1.207-.691-.272.073-.481.267-.753.701a9.344 9.344 0 0 1-.524.74l-.275.342-.02-6.54c-.022-7.33.008-6.677-.321-6.997-.267-.259-.625-.337-1.004-.22M8.5 5.138c.212.124.402.367.462.59.027.098.038.607.03 1.368l-.012 1.21-.121.197a.998.998 0 0 1-1.718 0l-.121-.197V5.7l.111-.189a.954.954 0 0 1 .937-.496.904.904 0 0 1 .432.123M7.499 12.04a3.042 3.042 0 0 0-2.374 2.099c-.083.273-.098.403-.098.861 0 .458.015.588.098.861a3.02 3.02 0 0 0 2.564 2.119l.269.029-.129.184c-.071.101-.368.431-.66.733-.292.302-.558.613-.59.69-.074.178-.076.585-.004.758.079.189.291.417.481.517.235.124.657.121.904-.006.217-.113.998-.894 1.364-1.365.967-1.243 1.495-2.509 1.639-3.92.107-1.063-.103-1.863-.659-2.512-.591-.689-1.299-1.036-2.184-1.069a3.935 3.935 0 0 0-.621.021m1.013 2.107c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/numbers_09_sort_descending_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Numbers09SortDescendingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Numbers09SortDescendingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Numbers09SortDescendingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.499 3.04a3.05 3.05 0 0 0-2.387 2.138c-.089.31-.092.376-.092 1.822 0 1.446.003 1.512.092 1.822a3.07 3.07 0 0 0 2.027 2.053c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.07 3.07 0 0 0 2.027-2.053c.089-.31.092-.376.092-1.822 0-1.446-.003-1.512-.092-1.822-.277-.965-1.073-1.77-2.027-2.049-.361-.106-1.029-.149-1.362-.089m9.307 1.165c-.192.036-.469.181-.926.488a8.028 8.028 0 0 0-2.238 2.267c-.464.705-.534.921-.424 1.3.151.517.727.852 1.206.701.309-.098.447-.226.762-.711.167-.257.414-.603.549-.768l.245-.3.02 6.539c.022 7.329-.008 6.676.321 6.996.169.163.456.283.679.283.223 0 .51-.12.679-.283.329-.32.299.333.321-6.997l.02-6.54.281.348c.154.191.388.522.52.735.269.435.427.587.713.682.516.171 1.091-.144 1.248-.685.083-.287.051-.485-.132-.82a8.02 8.02 0 0 0-2.6-2.791c-.636-.418-.892-.509-1.244-.444M8.5 5.138c.212.124.402.367.462.59.027.098.038.607.03 1.368l-.012 1.21-.121.197a.998.998 0 0 1-1.718 0l-.121-.197V5.7l.111-.189a.954.954 0 0 1 .937-.496.904.904 0 0 1 .432.123M7.499 12.04a3.042 3.042 0 0 0-2.374 2.099c-.083.273-.098.403-.098.861 0 .458.015.588.098.861a3.02 3.02 0 0 0 2.564 2.119l.269.029-.129.184c-.071.101-.368.431-.66.733-.292.302-.558.613-.59.69-.074.178-.076.585-.004.758.079.189.291.417.481.517.235.124.657.121.904-.006.217-.113.998-.894 1.364-1.365.967-1.243 1.495-2.509 1.639-3.92.107-1.063-.103-1.863-.659-2.512-.591-.689-1.299-1.036-2.184-1.069a3.935 3.935 0 0 0-.621.021m1.013 2.107c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/numbers_90_sort_ascending_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Numbers90SortAscendingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Numbers90SortAscendingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Numbers90SortAscendingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.499 3.04a3.042 3.042 0 0 0-2.374 2.099c-.083.273-.098.403-.098.861 0 .458.015.588.098.861A3.02 3.02 0 0 0 7.689 8.98l.269.029-.129.184c-.071.101-.368.431-.66.733-.292.302-.558.613-.59.69-.074.178-.076.585-.004.758.079.189.291.417.481.517.235.124.657.121.904-.006.217-.113.998-.894 1.364-1.365.967-1.243 1.495-2.509 1.639-3.92.107-1.063-.103-1.863-.659-2.512-.591-.689-1.299-1.036-2.184-1.069a3.935 3.935 0 0 0-.621.021m9.307 1.165c-.192.036-.469.181-.926.488a8.028 8.028 0 0 0-2.238 2.267c-.464.705-.534.921-.424 1.3.151.517.727.852 1.206.701.309-.098.447-.226.762-.711.167-.257.414-.603.549-.768l.245-.3.02 6.539c.022 7.329-.008 6.676.321 6.996.169.163.456.283.679.283.223 0 .51-.12.679-.283.329-.32.299.333.321-6.997l.02-6.54.281.348c.154.191.388.522.52.735.269.435.427.587.713.682.516.171 1.091-.144 1.248-.685.083-.287.051-.485-.132-.82a8.02 8.02 0 0 0-2.6-2.791c-.636-.418-.892-.509-1.244-.444m-8.294.942c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132M7.499 13.04a3.05 3.05 0 0 0-2.387 2.138c-.089.31-.092.376-.092 1.822 0 1.446.003 1.512.092 1.822a3.07 3.07 0 0 0 2.027 2.053c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.07 3.07 0 0 0 2.027-2.053c.089-.31.092-.376.092-1.822 0-1.446-.003-1.512-.092-1.822-.277-.965-1.073-1.77-2.027-2.049-.361-.106-1.029-.149-1.362-.089M8.5 15.138c.212.124.402.367.462.59.027.098.038.607.03 1.368l-.012 1.21-.121.197a.998.998 0 0 1-1.718 0l-.121-.197V15.7l.111-.189a.954.954 0 0 1 .937-.496.904.904 0 0 1 .432.123\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/numbers_90_sort_descending_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Numbers90SortDescendingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Numbers90SortDescendingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Numbers90SortDescendingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.499 3.04a3.042 3.042 0 0 0-2.374 2.099c-.083.273-.098.403-.098.861 0 .458.015.588.098.861A3.02 3.02 0 0 0 7.689 8.98l.269.029-.129.184c-.071.101-.368.431-.66.733-.292.302-.558.613-.59.69-.074.178-.076.585-.004.758.079.189.291.417.481.517.235.124.657.121.904-.006.217-.113.998-.894 1.364-1.365.967-1.243 1.495-2.509 1.639-3.92.107-1.063-.103-1.863-.659-2.512-.591-.689-1.299-1.036-2.184-1.069a3.935 3.935 0 0 0-.621.021m9.176 1.023c-.215.066-.49.314-.588.53-.061.135-.069.698-.087 6.687l-.02 6.54-.275-.342a9.344 9.344 0 0 1-.524-.74c-.365-.582-.665-.777-1.112-.724a1.008 1.008 0 0 0-.853.735c-.108.369-.035.591.426 1.291a7.942 7.942 0 0 0 2.308 2.311c.562.369.764.457 1.05.457.286 0 .488-.088 1.05-.457a8.006 8.006 0 0 0 2.599-2.791c.178-.326.213-.551.13-.832-.151-.508-.707-.826-1.207-.691-.272.073-.481.267-.753.701a9.344 9.344 0 0 1-.524.74l-.275.342-.02-6.54c-.022-7.33.008-6.677-.321-6.997-.267-.259-.625-.337-1.004-.22M8.512 5.147c.63.387.642 1.299.023 1.692a.998.998 0 0 1-1.394-.336c-.107-.174-.121-.232-.121-.5 0-.259.016-.33.111-.492a.955.955 0 0 1 .941-.496.836.836 0 0 1 .44.132M7.499 13.04a3.05 3.05 0 0 0-2.387 2.138c-.089.31-.092.376-.092 1.822 0 1.446.003 1.512.092 1.822a3.07 3.07 0 0 0 2.027 2.053c.273.083.403.098.861.098.458 0 .588-.015.861-.098a3.07 3.07 0 0 0 2.027-2.053c.089-.31.092-.376.092-1.822 0-1.446-.003-1.512-.092-1.822-.277-.965-1.073-1.77-2.027-2.049-.361-.106-1.029-.149-1.362-.089M8.5 15.138c.212.124.402.367.462.59.027.098.038.607.03 1.368l-.012 1.21-.121.197a.998.998 0 0 1-1.718 0l-.121-.197V15.7l.111-.189a.954.954 0 0 1 .937-.496.904.904 0 0 1 .432.123\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/palette_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PaletteCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PaletteCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PaletteCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.818.183 1.292.235 2.18.236.811.001.852-.003 1.169-.109 1.091-.364 1.772-1.358 1.77-2.583 0-.492-.063-.793-.264-1.277-.253-.609-.178-1.043.249-1.447.461-.437.671-.462 1.976-.24 1.183.201 1.598.229 2.141.146a3.595 3.595 0 0 0 1.789-.784c.89-.745 1.21-1.734 1.206-3.722-.001-.909-.052-1.377-.236-2.2-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m-1.179 5.111c.313.143.622.45.766.761.098.21.113.293.113.604 0 .31-.015.393-.112.6a1.698 1.698 0 0 1-.764.767c-.21.098-.293.113-.604.113-.31 0-.393-.015-.6-.112a1.698 1.698 0 0 1-.767-.764c-.098-.21-.113-.293-.113-.604 0-.32.014-.389.124-.62.273-.573.795-.893 1.417-.867.223.009.362.041.54.122m5 0c.313.143.622.45.766.761.098.21.113.293.113.604 0 .31-.015.393-.112.6a1.698 1.698 0 0 1-.764.767c-.21.098-.293.113-.604.113-.31 0-.393-.015-.6-.112a1.698 1.698 0 0 1-.767-.764c-.098-.21-.113-.293-.113-.604 0-.32.014-.389.124-.62.273-.573.795-.893 1.417-.867.223.009.362.041.54.122m-7 4c.313.143.622.45.766.761.098.21.113.293.113.604 0 .31-.015.393-.112.6a1.698 1.698 0 0 1-.764.767c-.21.098-.293.113-.604.113-.31 0-.393-.015-.6-.112a1.698 1.698 0 0 1-.767-.764c-.098-.21-.113-.293-.113-.604 0-.32.014-.389.124-.62.273-.573.795-.893 1.417-.867.223.009.362.041.54.122\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/palette_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PaletteCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PaletteCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PaletteCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.36 2.022c-.772.087-1.002.12-1.4.2-3.595.731-6.547 3.472-7.562 7.023a10.06 10.06 0 0 0 .16 6.015c1.259 3.631 4.447 6.199 8.28 6.668.21.026.733.047 1.162.048.75 0 .792-.004 1.1-.111 1.126-.391 1.781-1.342 1.779-2.581 0-.492-.063-.793-.264-1.277-.257-.62-.179-1.05.269-1.464.26-.241.576-.383.848-.382.092 0 .591.073 1.108.161 1.566.268 2.165.253 2.973-.077a3.267 3.267 0 0 0 1.214-.82c.507-.544.717-1.027.856-1.965.337-2.283-.13-4.641-1.306-6.586-1.564-2.588-4.097-4.287-7.09-4.756-.48-.075-1.784-.134-2.127-.096m1.94 2.073c1.716.321 3.241 1.111 4.371 2.264.677.69 1.077 1.255 1.49 2.101.436.895.66 1.623.782 2.541.116.878.024 2.323-.175 2.769-.188.418-.664.706-1.22.738-.266.016-.512-.013-1.308-.151-1.551-.27-2.078-.241-2.9.161-.905.442-1.551 1.224-1.785 2.158-.07.282-.081.422-.067.86.018.555 0 .483.361 1.464.07.19.075.249.038.431-.053.26-.177.44-.352.514-.285.119-1.531.041-2.335-.147a8.314 8.314 0 0 1-3.032-1.426c-.696-.52-1.51-1.414-1.979-2.173-.352-.57-.717-1.421-.917-2.139-.353-1.266-.358-2.696-.013-4.035.365-1.421.994-2.547 1.998-3.579 1.312-1.347 2.871-2.126 4.783-2.39.487-.067 1.816-.044 2.26.039M9.174 7.039c-.425.09-.847.439-1.04.861-.095.206-.111.291-.11.6 0 .308.016.396.111.604.141.31.449.617.765.762.206.095.292.111.6.111s.394-.016.6-.111c.316-.145.624-.452.765-.762.095-.209.111-.296.111-.604s-.016-.395-.111-.604a1.658 1.658 0 0 0-.746-.753 1.794 1.794 0 0 0-.945-.104m5 0c-.425.09-.847.439-1.04.861-.095.206-.111.291-.11.6 0 .308.016.396.111.604.141.31.449.617.765.762.206.095.292.111.6.111s.394-.016.6-.111c.316-.145.624-.452.765-.762.095-.209.111-.296.111-.604s-.016-.395-.111-.604a1.658 1.658 0 0 0-.746-.753 1.794 1.794 0 0 0-.945-.104m-7 4c-.425.09-.847.439-1.04.861-.095.206-.111.291-.11.6 0 .308.016.396.111.604.141.31.449.617.765.762.206.095.292.111.6.111s.394-.016.6-.111c.316-.145.624-.452.765-.762.095-.209.111-.296.111-.604s-.016-.395-.111-.604a1.658 1.658 0 0 0-.746-.753 1.794 1.794 0 0 0-.945-.104\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/paper_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PaperCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PaperCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PaperCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.74 3.024c-1.013.136-1.751.577-2.239 1.336-.356.555-.443.894-.5 1.96-.049.928-.007 1.664.111 1.93.161.365.484.624.88.706C3.109 8.98 3.604 9 4.1 9h.896l.016 5.03c.017 5.35.016 5.321.21 5.97.121.406.375.834.667 1.126.22.221.353.312.682.47.46.22.769.305 1.315.361.637.066 3.584.085 7.054.047 3.805-.042 3.747-.037 4.46-.388.669-.33 1.22-.977 1.415-1.659.229-.805.272-2.74.07-3.206a1.44 1.44 0 0 0-.636-.639c-.151-.068-.293-.088-.715-.103l-.526-.018-.02-3.626c-.012-2.111-.039-3.825-.064-4.105-.178-1.959-.529-2.904-1.405-3.779-.875-.875-1.811-1.224-3.779-1.405-.456-.042-8.718-.089-9-.052M5 6v1H3.991l.015-.61c.013-.574.021-.624.127-.84.063-.128.176-.278.253-.337.122-.093.414-.202.564-.21.039-.002.05.222.05.997m9.34 2.066c.369.126.66.538.66.934 0 .242-.119.521-.299.701-.302.302-.273.299-2.703.299-2.328 0-2.334 0-2.624-.222a1.19 1.19 0 0 1-.243-.289C9.036 9.328 9.02 9.256 9.02 9s.016-.328.111-.489c.125-.213.318-.375.539-.454.118-.042.611-.054 2.313-.055 1.911-.002 2.185.006 2.357.064m-2 4c.369.126.66.538.66.934s-.291.808-.66.934c-.276.094-2.399.098-2.662.005a.986.986 0 0 1-.547-.45c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.061-.103.173-.236.25-.294.264-.202.352-.213 1.602-.215.981-.002 1.193.008 1.357.064m6.648 6.484c-.028.667-.098.929-.303 1.135-.304.304-.136.292-4.22.306l-3.635.012.027-.091c.091-.308.14-.761.142-1.302L11 18h8.012l-.024.55\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/paste_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PasteCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PasteCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PasteCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.38 1.576c-.449.112-.967.431-1.257.775-.145.173-.183.195-.422.24-1.331.252-2.539 1.305-2.973 2.589-.211.627-.208.544-.208 4.948.001 2.422.017 4.288.041 4.572.1 1.173.331 1.906.839 2.663.222.331.906 1.015 1.237 1.237.983.66 1.978.88 3.987.88h.772l.171.329c.226.435.577.903.954 1.272.745.73 1.775 1.232 2.779 1.355.577.07 3.497.058 4.005-.018 1.817-.269 3.367-1.6 3.952-3.392.2-.612.223-.954.223-3.251 0-2.242-.021-2.598-.185-3.175a5.927 5.927 0 0 0-.704-1.469 5.287 5.287 0 0 0-1.78-1.563l-.326-.169-.015-1.81c-.017-1.951-.014-1.914-.252-2.55-.454-1.21-1.604-2.18-2.898-2.445-.242-.05-.279-.073-.541-.334a2.562 2.562 0 0 0-.962-.63l-.237-.09-3-.007c-2.288-.006-3.047.004-3.2.043m5.871 1.995a.501.501 0 0 1 0 .858c-.099.066-.299.072-2.609.083-1.376.007-2.578.001-2.672-.012-.554-.08-.633-.818-.105-.979.063-.019 1.277-.032 2.697-.028 2.392.006 2.589.012 2.689.078M5.695 4.97c.145.34.454.748.734.97.278.221.713.436 1.003.496.298.062 5.844.061 6.136-.001.708-.15 1.358-.683 1.7-1.395.079-.165.149-.306.155-.314.025-.031.368.236.54.42.222.237.367.481.46.774.061.188.073.427.087 1.645l.017 1.426-1.574.021c-1.683.023-1.908.045-2.531.249-.732.238-1.352.628-1.943 1.218-.59.591-.98 1.211-1.218 1.943-.217.663-.228.802-.249 3.018l-.02 2.06-.886-.026c-.88-.026-1.392-.089-1.747-.214-.75-.264-1.479-1.057-1.675-1.823-.166-.652-.18-1.06-.182-5.257-.002-3.764.002-4.014.071-4.24a1.95 1.95 0 0 1 .443-.774c.152-.166.493-.443.548-.445.013-.001.072.112.131.249m11.565 6.125a3.093 3.093 0 0 1 2.118 2.065c.126.415.18 2.616.102 4.22-.03.644-.051.804-.136 1.05-.323.941-.972 1.59-1.914 1.915-.257.088-.398.104-1.23.142-1.153.052-2.694-.003-3.04-.109a3.013 3.013 0 0 1-1.842-1.558c-.289-.588-.288-.579-.308-2.707-.02-2.207.006-2.656.187-3.153a2.97 2.97 0 0 1 1.766-1.764c.469-.171.757-.191 2.517-.178 1.278.01 1.581.023 1.78.077\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/pause_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PauseCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PauseCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PauseCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.58 3.047c-.733.14-1.4.821-1.537 1.57-.061.334-.061 14.432 0 14.766.147.802.829 1.456 1.652 1.584 1.051.163 2.073-.553 2.262-1.584.061-.334.061-14.432 0-14.766-.141-.768-.806-1.433-1.574-1.574a2.106 2.106 0 0 0-.803.004m8 0c-.733.14-1.4.821-1.537 1.57-.061.334-.061 14.432 0 14.766.147.802.829 1.456 1.652 1.584 1.051.163 2.073-.553 2.262-1.584.061-.334.061-14.432 0-14.766-.141-.768-.806-1.433-1.574-1.574a2.106 2.106 0 0 0-.803.004\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/pause_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PauseCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PauseCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PauseCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.673 4.063c-.244.075-.523.351-.609.603C7.006 4.836 7 5.551 7 12c0 8.032-.026 7.367.306 7.7.18.179.458.3.694.3.402 0 .827-.312.939-.69C8.993 19.13 9 18.245 9 12c0-7.991.024-7.369-.303-7.697-.279-.279-.63-.361-1.024-.24m8 0c-.244.075-.523.351-.609.603-.058.17-.064.885-.064 7.334 0 8.032-.026 7.367.306 7.7.18.179.458.3.694.3.402 0 .827-.312.939-.69.054-.18.061-1.065.061-7.31 0-7.991.024-7.369-.303-7.697-.279-.279-.63-.361-1.024-.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/pdf_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PdfCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PdfCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PdfCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.96 1.579c-.982.113-1.509.295-2.24.776-.978.642-1.738 1.699-2.004 2.785-.185.758-.195 1.091-.195 6.28-.001 4.869.023 6.003.142 6.8.182 1.226.577 2.069 1.312 2.805.939.939 1.964 1.304 3.965 1.413.975.053 5.128.054 6.12.001 1.282-.068 2.108-.242 2.84-.6a3.935 3.935 0 0 0 1.208-.892c.666-.701 1.007-1.441 1.207-2.615.14-.829.173-2.007.157-5.772-.015-3.466-.016-3.504-.107-3.939a7.019 7.019 0 0 0-1.458-3.061c-.366-.438-2.025-2.098-2.47-2.47a6.985 6.985 0 0 0-3.058-1.454c-.422-.087-.527-.091-2.699-.1-1.671-.008-2.38.004-2.72.043m3.15 2.013a2.079 2.079 0 0 1 1.311 1.328c.05.164.075.468.099 1.2.035 1.09.059 1.224.318 1.74a2.958 2.958 0 0 0 1.302 1.302c.517.259.649.283 1.74.318.813.026 1.023.045 1.23.111a2.08 2.08 0 0 1 1.317 1.349c.068.223.073.427.067 2.9-.008 3.443-.067 4.257-.363 5-.289.726-.915 1.238-1.757 1.434-.59.137-.925.164-2.684.21-1.792.047-4.726-.001-5.41-.088-.769-.099-1.351-.306-1.705-.607-.586-.497-.845-1.059-.973-2.109-.078-.632-.119-2.878-.119-6.42.001-3.649.033-4.978.135-5.48.215-1.063 1.09-1.939 2.162-2.163.37-.077 1.597-.135 2.48-.117.462.01.657.031.85.092m3.51.648c.174.11.461.324.638.476.368.317 1.927 1.887 2.16 2.176.176.219.542.786.542.841 0 .02-.041.024-.09.009-.603-.183-.648-.189-1.612-.217-.945-.027-.966-.03-1.19-.139a1.1 1.1 0 0 1-.327-.241c-.213-.28-.232-.381-.264-1.405-.031-.956-.039-1.019-.219-1.61-.015-.05-.011-.09.009-.09.02 0 .179.09.353.2m-2.961 6.824a.987.987 0 0 0-.535.467c-.045.094-.119.385-.164.648-.144.842-.377 1.47-.766 2.062-.299.455-.605.792-1.145 1.256-.539.465-.622.599-.626 1.003-.003.236.014.309.106.466.317.539.758.642 1.52.356 1.392-.523 2.51-.523 3.902 0 .631.237.964.216 1.304-.083.233-.205.323-.41.323-.739 0-.356-.095-.545-.404-.807-.742-.631-.996-.897-1.344-1.409-.397-.585-.646-1.253-.792-2.124-.058-.34-.116-.554-.182-.669a1.005 1.005 0 0 0-1.197-.427\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/photo_album_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PhotoAlbumCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PhotoAlbumCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PhotoAlbumCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.24 2.548c-.143.006-.533.039-.866.072-1.596.158-2.554.532-3.363 1.313-.813.784-1.205 1.748-1.4 3.439-.063.544-.07 1.085-.07 5.111l-.001 4.503.121.196c.075.121.196.242.317.317.176.109.229.121.522.121.293 0 .346-.012.522-.121.121-.075.242-.196.317-.317l.121-.196.025-4.203c.025-4.202.045-4.851.181-5.723.237-1.518.888-2.163 2.416-2.396.915-.14 1.369-.151 6.738-.168 3.255-.011 5.416-.032 5.502-.056.187-.051.459-.288.568-.494.122-.231.123-.66.001-.89-.1-.19-.328-.402-.517-.481-.11-.046-1.023-.054-5.503-.047-2.954.004-5.488.013-5.631.02m1.402 3.015c-2.41.185-3.619 1.18-3.981 3.277-.118.682-.141 1.457-.141 4.844 0 3.394.021 4.093.142 4.816.235 1.403.864 2.294 1.991 2.821.972.455 1.8.517 6.847.517 5.056 0 5.874-.062 6.854-.52 1.329-.622 1.932-1.699 2.085-3.718.054-.709.054-7.171 0-7.88-.099-1.315-.354-2.116-.883-2.782-.674-.848-1.639-1.253-3.276-1.377-.637-.048-9.006-.046-9.638.002m9.698 1.995c1.324.151 1.763.456 1.979 1.372.165.698.179 1.088.18 4.777.001 1.939-.013 3.54-.029 3.557-.017.017-.359-.293-.76-.689-1.071-1.055-1.32-1.211-1.99-1.245-.331-.017-.416-.007-.662.078-.313.109-.644.32-.978.624l-.22.201-1.36-1.349c-.748-.741-1.452-1.414-1.565-1.496a3.754 3.754 0 0 0-.479-.276c-.548-.256-1.211-.191-1.776.174-.178.114-.87.775-2.189 2.088-1.061 1.057-1.943 1.907-1.96 1.89-.056-.055-.036-7.027.021-7.544.197-1.78.617-2.107 2.828-2.2 1.154-.048 8.477-.018 8.96.038m-2.391 2.057c-.552.231-.871.664-.935 1.266-.049.458.198 1.017.575 1.304.285.217.578.304.972.288.289-.011.382-.033.608-.144 1.093-.539 1.093-2.119 0-2.658-.233-.114-.313-.132-.628-.142-.307-.009-.395.004-.592.086\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/photo_album_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PhotoAlbumCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PhotoAlbumCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PhotoAlbumCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.3 2.546c-2.244.131-3.356.49-4.289 1.388-.642.617-1.061 1.46-1.271 2.554-.184.961-.19 1.142-.208 5.869-.019 5.012-.036 4.682.256 4.983.2.206.42.298.712.298a.923.923 0 0 0 .712-.298c.277-.285.262-.036.283-4.64.011-2.512.036-4.374.063-4.7.14-1.701.465-2.471 1.237-2.93.248-.148.819-.328 1.285-.405.839-.138 1.424-.153 6.78-.17 3.225-.01 5.376-.031 5.462-.055.187-.051.459-.287.568-.494.066-.125.088-.235.088-.446 0-.319-.082-.515-.304-.728-.282-.27.129-.253-5.803-.245-2.954.004-5.461.013-5.571.019m1.44 3.018c-2.181.135-3.398.938-3.914 2.582-.267.85-.306 1.558-.306 5.534 0 3.173.029 4.158.14 4.8.313 1.803 1.269 2.806 3.01 3.157.814.165 1.374.182 5.83.182 4.456 0 5.016-.017 5.83-.182 1.536-.31 2.469-1.14 2.876-2.557.236-.824.273-1.556.273-5.42.001-3.312-.028-4.205-.159-4.902-.241-1.286-.817-2.145-1.768-2.64-.646-.336-1.314-.491-2.392-.557-.775-.048-8.646-.045-9.42.003m9.527 1.994c1.609.144 2.027.565 2.172 2.182.055.61.102 5.206.062 5.993l-.03.594-.745-.737c-.751-.742-1.041-.973-1.446-1.153-.19-.084-.28-.097-.663-.097-.44 0-.445.001-.782.168a3.384 3.384 0 0 0-.657.445l-.318.278-1.36-1.348c-1.515-1.503-1.804-1.734-2.349-1.882-.279-.075-.799-.037-1.071.08-.459.196-.701.411-2.624 2.327l-1.924 1.918-.026-.393c-.072-1.087-.001-5.987.096-6.613.147-.948.441-1.364 1.114-1.576.458-.145.85-.191 1.904-.225 1.37-.045 8.051-.014 8.647.039m-2.314 2.057c-.495.204-.83.611-.914 1.114-.16.948.509 1.749 1.461 1.749.539 0 .949-.221 1.254-.678.407-.61.266-1.501-.311-1.957-.414-.328-1.02-.421-1.49-.228m-2.055 6.503c1.607 1.604 2.166 2.137 2.302 2.199.232.105.6.108.821.008.591-.268.765-1.016.36-1.547l-.096-.126.149-.146c.082-.08.171-.146.197-.146.027 0 .61.555 1.296 1.233l1.248 1.234-.049.12c-.123.296-.54.576-1.055.709-.578.149-1.085.164-5.571.164s-4.993-.015-5.571-.164c-.514-.133-.932-.413-1.054-.708l-.049-.119 2.422-2.414C10.581 15.087 11.695 14 11.724 14c.029 0 1.008.953 2.174 2.118\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/pic_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PicCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PicCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PicCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.24 2.546c-2.11.108-3.311.502-4.229 1.387-.813.783-1.204 1.745-1.401 3.439-.062.542-.07 1.047-.07 4.628 0 3.581.008 4.086.07 4.628.197 1.694.588 2.656 1.401 3.439.804.775 1.708 1.131 3.361 1.323.549.063 1.092.07 5.628.07s5.079-.007 5.628-.07c1.359-.158 2.136-.406 2.881-.919.33-.228.767-.671 1.002-1.018a2.21 2.21 0 0 1 .217-.283c.07-.058.207-.369.268-.61l.165-.634c.288-1.101.329-1.965.309-6.426-.015-3.038-.028-3.689-.084-4.169-.196-1.658-.592-2.622-1.397-3.398-.796-.768-1.698-1.126-3.32-1.319-.502-.06-1.134-.069-5.369-.075-2.64-.003-4.917 0-5.06.007M17 4.557c.672.058 1.279.153 1.598.251.673.206 1.131.573 1.403 1.127.313.638.427 1.386.48 3.16.066 2.204.027 6.55-.065 7.197l-.027.192-.705-.695c-.803-.793-1.028-.98-1.417-1.175a2.762 2.762 0 0 0-1.307-.299c-.72-.003-1.264.182-1.839.627l-.29.224-1.246-1.235c-1.602-1.59-1.945-1.881-2.59-2.199a2.422 2.422 0 0 0-1.115-.251c-.437 0-.775.081-1.177.283-.648.324-.886.535-3.188 2.827L3.61 16.488l-.023-.134c-.11-.647-.131-7.077-.027-8.354.115-1.42.338-2.13.823-2.614.585-.586 1.45-.798 3.537-.867 1.346-.044 8.47-.015 9.08.038m-1.85 2.484a1.618 1.618 0 0 0-1.017.859c-.096.207-.112.29-.111.6 0 .309.016.395.112.604.141.309.45.617.766.763.207.096.29.112.6.111.309 0 .395-.016.604-.112.309-.141.617-.45.763-.766.096-.207.112-.29.111-.6 0-.309-.016-.395-.112-.604a1.67 1.67 0 0 0-.756-.757c-.258-.117-.703-.163-.96-.098\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/pic_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PicCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PicCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PicCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.24 2.546c-2.11.108-3.311.502-4.229 1.387C2.33 4.59 1.93 5.422 1.72 6.619c-.17.975-.18 1.264-.18 5.421 0 3.543.008 4.047.07 4.588.197 1.694.588 2.656 1.401 3.439.804.775 1.708 1.131 3.361 1.323.549.063 1.092.07 5.628.07s5.079-.007 5.628-.07c1.653-.192 2.557-.548 3.361-1.323.806-.777 1.208-1.76 1.4-3.427.064-.555.071-1.04.071-4.64 0-3.581-.008-4.086-.07-4.628-.197-1.694-.588-2.656-1.401-3.439-.796-.768-1.698-1.126-3.32-1.319-.502-.06-1.134-.069-5.369-.075-2.64-.003-4.917 0-5.06.007M17 4.557c1.444.126 2.132.344 2.611.826.503.507.709 1.168.836 2.677.056.667.076 7.39.023 7.443-.017.017-.359-.293-.76-.689-.834-.821-1.056-1.005-1.443-1.2-.574-.29-1.37-.383-2.007-.236-.366.084-.857.33-1.173.585l-.252.205-1.308-1.295c-.719-.713-1.46-1.417-1.648-1.565-.765-.605-1.301-.827-1.999-.827-.437 0-.775.081-1.177.283-.649.324-.884.534-3.212 2.851-1.061 1.056-1.943 1.907-1.96 1.89-.054-.055-.034-6.77.022-7.445.127-1.507.337-2.181.837-2.682.567-.566 1.487-.792 3.49-.858 1.387-.046 8.503-.017 9.12.037m-1.85 2.484a1.618 1.618 0 0 0-1.017.859c-.096.207-.112.29-.111.6 0 .309.016.395.112.604.141.309.45.617.766.763.207.096.29.112.6.111.309 0 .395-.016.604-.112.309-.141.617-.45.763-.766.096-.207.112-.29.111-.6 0-.309-.016-.395-.112-.604a1.67 1.67 0 0 0-.756-.757c-.258-.117-.703-.163-.96-.098m-4.978 5.505c.311.16.737.556 2.788 2.596 1.199 1.193 2.241 2.2 2.316 2.238.211.106.568.125.788.041.396-.152.656-.524.655-.941-.001-.266-.095-.489-.304-.721l-.153-.169.134-.112c.312-.261.802-.243 1.154.041.105.084.71.675 1.345 1.312l1.155 1.16-.114.209c-.174.321-.496.617-.871.801-.887.436-1.991.517-7.065.517s-6.178-.081-7.065-.517c-.375-.184-.697-.48-.871-.801l-.114-.21 2.375-2.377c2.368-2.37 2.862-2.839 3.198-3.031a.696.696 0 0 1 .649-.036\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/play_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PlayCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PlayCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PlayCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.662 3.444c-.813.11-1.742.639-2.26 1.286-.254.316-.571.949-.697 1.391-.227.795-.29 1.506-.351 3.928-.05 2.028-.005 5.269.087 6.251.152 1.616.473 2.461 1.215 3.204.707.706 1.533 1.056 2.492 1.056 1.077-.001 2.036-.36 4.332-1.62 2.482-1.363 5.041-2.905 5.955-3.59.467-.35 1.027-.882 1.273-1.209a3.598 3.598 0 0 0 .693-2.488c-.131-1.394-.879-2.346-2.856-3.633-1.724-1.122-5.744-3.406-6.958-3.953-1.191-.536-2.116-.733-2.925-.623\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/play_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PlayCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PlayCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PlayCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.44 3.497c-.713.159-1.471.592-1.895 1.084-.757.877-1.023 1.765-1.151 3.839-.065 1.07-.065 6.083.001 7.16.131 2.157.428 3.078 1.265 3.92.749.754 1.618 1.094 2.68 1.047 1.174-.052 2.228-.509 5.64-2.446 1.081-.614 3.061-1.806 3.724-2.243 1.964-1.294 2.704-2.356 2.7-3.878-.001-.483-.037-.723-.165-1.102-.345-1.026-1.093-1.818-2.699-2.859a104.783 104.783 0 0 0-3.76-2.248c-2.796-1.579-3.934-2.112-4.852-2.272-.401-.07-1.178-.071-1.488-.002m1.555 2.085c.91.304 3.062 1.453 5.818 3.105 2.732 1.638 3.393 2.182 3.573 2.947.181.769-.151 1.375-1.165 2.128-.944.699-3.784 2.394-6.181 3.687-2.292 1.237-3.072 1.386-3.887.745-.434-.342-.651-1.042-.753-2.434-.068-.921-.096-4.892-.045-6.36.065-1.9.171-2.639.45-3.163a1.665 1.665 0 0 1 1.025-.796c.261-.066.706-.012 1.165.141\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/plugin_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Plugin2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Plugin2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Plugin2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.002 2.041c-1.207.168-2.251.992-2.719 2.146-.184.453-.223.672-.223 1.263V6h-.64c-1.033.001-1.569.111-2.26.464A3.978 3.978 0 0 0 2.464 8.16c-.392.765-.475 1.244-.456 2.6.015 1.053.029 1.116.297 1.378.368.358.734.409 1.326.187.235-.089.401-.125.569-.125.81 0 1.429.756 1.266 1.546-.126.612-.581 1.005-1.206 1.044-.239.015-.328 0-.612-.104-.414-.152-.629-.185-.837-.129-.25.067-.534.28-.67.5l-.121.197-.012 1.464c-.016 1.945.035 2.3.456 3.122a3.978 3.978 0 0 0 1.696 1.696c.82.42 1.18.473 3.099.456 1.609-.013 1.6-.012 1.88-.297.348-.355.399-.721.182-1.326-.114-.319-.129-.405-.112-.639.044-.62.438-1.071 1.045-1.196.768-.158 1.48.392 1.537 1.188.017.244.003.324-.114.648-.217.6-.165.969.184 1.325.264.269.326.282 1.379.297 1.356.019 1.835-.064 2.6-.456a3.978 3.978 0 0 0 1.696-1.696c.353-.691.463-1.226.464-2.26v-.64l.55-.001c.591 0 .813-.039 1.263-.222a3.522 3.522 0 0 0 2.072-2.337c.121-.455.121-1.305 0-1.76a3.522 3.522 0 0 0-2.072-2.337c-.45-.183-.672-.222-1.263-.222L18 10.06v-.14c0-.077-.027-.313-.058-.525-.222-1.469-1.266-2.705-2.697-3.192A5.5 5.5 0 0 0 14.08 6h-.14v-.55c0-.591-.039-.81-.223-1.263-.412-1.015-1.306-1.808-2.329-2.066a4.505 4.505 0 0 0-1.386-.08m1.133 2.113c.293.145.562.416.709.715.08.161.094.253.095.591.001.395-.001.405-.189.803-.166.35-.19.435-.19.66.001.328.125.607.363.81.275.236.445.267 1.456.267.959 0 1.166.03 1.551.225.265.135.708.575.84.835.185.366.201.476.226 1.58.023.982.031 1.072.111 1.22.196.365.55.578.965.579.215.001.309-.026.655-.184.222-.102.495-.197.608-.211a1.474 1.474 0 0 1 1.514.828c.119.241.131.3.131.628s-.012.387-.131.628c-.148.3-.416.568-.718.716-.161.08-.253.094-.591.095-.394.001-.406-.002-.808-.189-.352-.164-.444-.19-.66-.19a1.034 1.034 0 0 0-.805.363c-.253.295-.267.399-.267 1.948 0 1.493-.017 1.649-.229 2.066-.133.263-.575.703-.841.838-.277.14-.615.225-.898.225H13.8v-.256c0-1.395-.971-2.674-2.361-3.11-.262-.082-.379-.094-.939-.094-.559 0-.678.012-.94.094-.797.248-1.527.822-1.922 1.51-.261.454-.438 1.101-.438 1.6V20h-.732c-1.052 0-1.381-.105-1.872-.596-.491-.491-.596-.82-.596-1.872V16.8l.27-.001c.338-.001.884-.12 1.242-.27.975-.41 1.744-1.381 1.929-2.434.073-.414.032-1.197-.081-1.555-.245-.78-.824-1.511-1.504-1.902-.454-.261-1.101-.438-1.6-.438H4v-.232c0-.795.529-1.542 1.316-1.856.215-.086.287-.091 1.804-.111 1.523-.02 1.586-.024 1.74-.107.52-.282.717-.859.482-1.414-.333-.785-.312-.71-.293-1.077.02-.397.109-.618.359-.902.305-.348.686-.507 1.159-.487.249.01.358.037.568.14\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/polygon_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PolygonCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PolygonCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PolygonCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.5 3.257c-.977.227-1.629.909-2.89 3.026-1.038 1.743-1.723 3.052-1.956 3.741a2.45 2.45 0 0 0-.165.897c-.022.555.026.818.221 1.21.102.205.232.377.447.591.6.597 1.2.789 2.816.901.307.021.349.033.326.091a7.123 7.123 0 0 0-.12.429 5.473 5.473 0 0 0 1.439 5.239c.795.794 1.747 1.297 2.887 1.524.483.097 1.498.096 1.99-.001 1.768-.347 3.197-1.446 3.964-3.045.373-.778.517-1.441.516-2.38 0-.522-.019-.743-.086-1.058a4.434 4.434 0 0 1-.077-.404c.004-.002.359-.026.788-.051.922-.055 1.365-.144 1.816-.364a2.406 2.406 0 0 0 1.187-1.187c.31-.635.396-1.375.396-3.416 0-2.654-.156-3.37-.895-4.101-.482-.479-.905-.673-1.758-.808-.381-.06-.725-.071-2.366-.07-1.666 0-1.977.009-2.352.072-.785.132-1.237.329-1.66.725a2.346 2.346 0 0 0-.63.889l-.125.289-.095-.168a19.25 19.25 0 0 0-.487-.757c-.857-1.284-1.577-1.813-2.527-1.854a2.432 2.432 0 0 0-.604.04m.746 2.041c.359.244 1.147 1.453 2.327 3.567.381.683.427.787.427.962v.197l-.339.049c-1.011.146-2.099.663-2.861 1.358l-.26.238-1.02-.033c-1.487-.047-1.917-.138-2.038-.431-.138-.334.393-1.464 1.81-3.845C7.255 5.743 7.684 5.2 8 5.2c.056 0 .166.044.246.098m9.952.781c.485.106.631.262.73.785.074.392.074 3.88 0 4.272-.142.749-.306.818-2.048.852l-1.1.022-.4-.396a5.56 5.56 0 0 0-2.1-1.314l-.26-.088V8.676c.001-1.614.027-1.936.183-2.241.121-.237.402-.346 1.057-.41.385-.037 3.729.009 3.938.054m-6.017 5.997c1.308.27 2.289 1.173 2.686 2.472.095.315.107.415.107.952 0 .537-.012.637-.107.952-.368 1.204-1.211 2.047-2.415 2.415-.315.095-.415.107-.952.107-.537 0-.637-.012-.952-.107-1.204-.368-2.047-1.211-2.415-2.415-.095-.315-.107-.415-.107-.952 0-.537.012-.637.107-.952.545-1.785 2.27-2.839 4.048-2.472\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/power.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PowerIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PowerIcon = ({ width = 24, height = 24, color = \"#10161F\" }: PowerIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M12 22C17.5229 22 22 17.5229 22 12C22 6.47715 17.5229 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5229 6.47715 22 12 22ZM10.4293 12.7184H8.70693C8.15511 12.7184 7.85969 12.0662 8.22199 11.6481L12.4415 6.85452C12.8316 6.4086 13.5674 6.6873 13.5674 7.27814V11.2914H15.2898C15.8472 11.2914 16.1426 11.9436 15.7748 12.3616L11.5553 17.1552C11.1651 17.6012 10.4293 17.3225 10.4293 16.7316V12.7184Z\"\n        fill={color}\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/power_mono.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PowerMonoIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PowerMonoIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PowerMonoIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M12 22C17.5229 22 22 17.5229 22 12C22 6.47715 17.5229 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5229 6.47715 22 12 22ZM10.4293 12.7184H8.70693C8.15511 12.7184 7.85969 12.0662 8.22199 11.6481L12.4415 6.85452C12.8316 6.4086 13.5674 6.6873 13.5674 7.27814V11.2914H15.2898C15.8472 11.2914 16.1426 11.9436 15.7748 12.3616L11.5553 17.1552C11.1651 17.6012 10.4293 17.3225 10.4293 16.7316V12.7184Z\"\n        fill={color}\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/power_outline.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface PowerOutlineIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const PowerOutlineIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: PowerOutlineIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.70678 12.7184H10.4292V16.7316C10.4292 17.3225 11.1649 17.6012 11.5551 17.1552L15.7746 12.3616C16.1425 11.9436 15.8471 11.2914 15.2897 11.2914H13.5673V7.27814C13.5673 6.6873 12.8315 6.4086 12.4413 6.85452L8.22185 11.6481C7.85954 12.0662 8.15496 12.7184 8.70678 12.7184Z\"\n        fill={color}\n      />\n      <Path\n        d=\"M21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C6.7533 21.5 2.5 17.2467 2.5 12C2.5 6.7533 6.7533 2.5 12 2.5C17.2467 2.5 21.5 6.7533 21.5 12Z\"\n        stroke={color}\n        strokeWidth={2}\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/question_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface QuestionCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const QuestionCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: QuestionCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.54 2.017c2.788.311 5.174 1.99 6.38 4.488a7.95 7.95 0 0 1-.001 6.945A8.02 8.02 0 0 1 12 19.999a8.014 8.014 0 0 1-7.2-4.528 7.948 7.948 0 0 1 0-6.942A7.973 7.973 0 0 1 8.529 4.8c1.323-.64 2.886-.916 4.291-.759M11.24 6.58a3.66 3.66 0 0 0-2.744 2.652c-.126.496-.137 1.044-.026 1.294a.985.985 0 0 0 1.651.243.868.868 0 0 0 .239-.601c0-.073.028-.256.063-.408.316-1.392 2.163-1.712 2.941-.509.209.323.302.742.244 1.094-.102.613-.401.99-1.032 1.299-1.084.531-1.573 1.214-1.575 2.199-.001.395.086.646.298.858.644.644 1.701.172 1.701-.761 0-.258.048-.305.56-.553a3.618 3.618 0 0 0 1.943-2.374 3.92 3.92 0 0 0 .001-1.78A3.698 3.698 0 0 0 12.94 6.62c-.337-.095-1.35-.118-1.7-.04m.403 9.489c-.352.124-.643.545-.643.931 0 .242.119.521.299.701a.993.993 0 0 0 1.57-.212c.095-.161.111-.233.111-.489s-.016-.328-.111-.489a1.006 1.006 0 0 0-1.226-.442\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/quill_pen_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface QuillPenCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const QuillPenCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: QuillPenCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.554 2.541c-2.349.408-4.701 1.283-6.571 2.446-2.664 1.657-4.588 4.025-5.845 7.192a20.53 20.53 0 0 0-1.036 3.541c-.212 1.126-.533 3.848-.569 4.822-.028.734-.003.861.219 1.106.219.242.417.33.748.33.329 0 .534-.09.739-.323.189-.216.229-.343.258-.835.051-.85.186-2.082.251-2.295.087-.282.365-.567.665-.679.119-.044.403-.101.632-.126 2.918-.315 5.427-1.239 7.382-2.72 2.002-1.515 3.448-3.778 4.255-6.657.123-.44.19-.605.319-.789.239-.34.579-1.081.686-1.494.08-.311.093-.453.091-1.04-.002-.809-.072-1.146-.36-1.731-.222-.452-.375-.633-.638-.755-.256-.119-.507-.118-1.226.007m.229 2.109c.016.071.028.265.026.43-.006.602-.318 1.29-.749 1.65-.821.688-2.725 1.709-4.225 2.268-.405.151-.582.285-.705.531-.255.511-.077 1.104.405 1.344.303.152.547.155.952.014.511-.177 1.138-.449 1.873-.809.358-.176.66-.312.67-.301.03.029-.483.99-.733 1.373-.847 1.3-1.904 2.275-3.32 3.064-1.084.604-2.659 1.138-4.036 1.37-.457.077-1.794.248-1.814.233-.031-.024.288-1.228.478-1.811 1.012-3.094 2.564-5.323 4.815-6.913 1.538-1.086 3.553-1.948 5.692-2.433.324-.074.6-.135.615-.137.015-.002.04.056.056.127\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rada_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RadaCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RadaCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RadaCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.076 4.254c-.276.114-.461.261-.798.635C2.926 6.387 2.081 8.054 1.705 9.96a11.17 11.17 0 0 0 .014 4.16 10.477 10.477 0 0 0 2.715 5.151c.422.434.644.543 1.146.562.295.011.402-.002.591-.073.303-.113.654-.437.811-.747.107-.212.118-.269.118-.633 0-.534-.08-.702-.618-1.304-1.396-1.561-2.09-3.511-1.961-5.516a6.791 6.791 0 0 1 .435-2.112c.325-.911.829-1.745 1.527-2.527.539-.604.617-.769.617-1.301 0-.364-.011-.421-.118-.633-.155-.306-.509-.634-.802-.744-.317-.118-.801-.114-1.104.011m12.733-.01c-.283.11-.641.448-.791.743-.107.212-.118.269-.118.633 0 .534.08.702.618 1.304 1.423 1.591 2.11 3.568 1.957 5.636a7.558 7.558 0 0 1-1.925 4.48c-.597.665-.694.875-.662 1.436.024.408.161.689.479.984.323.299.582.391 1.053.373.502-.019.723-.127 1.145-.561a10.419 10.419 0 0 0 2.73-5.232A9.798 9.798 0 0 0 22.48 12c0-1.647-.308-3.038-.997-4.501-.626-1.328-1.851-2.92-2.472-3.21-.318-.149-.885-.17-1.202-.045m-9.69 2.77c-.307.066-.541.215-.842.534a6.483 6.483 0 0 0-1.719 3.639c-.07.525-.029 1.601.081 2.113.11.515.328 1.155.53 1.558.48.955 1.226 1.853 1.739 2.093.204.096.28.109.632.109.364 0 .421-.011.633-.118.31-.157.634-.508.747-.811.071-.189.084-.296.073-.591-.018-.458-.121-.688-.494-1.098-.688-.756-.989-1.508-.986-2.462.002-.903.291-1.637.928-2.36.136-.154.303-.397.373-.54.112-.233.126-.297.125-.62 0-.309-.016-.394-.112-.6a1.497 1.497 0 0 0-1.086-.856c-.281-.052-.335-.052-.622.01m7.059.039c-.288.074-.632.317-.82.58-.215.3-.292.578-.27.969.023.385.138.636.46.999.65.731.939 1.462.939 2.377 0 .93-.292 1.683-.941 2.422-.299.341-.406.528-.466.819a1.497 1.497 0 0 0 2.143 1.641c.335-.164.953-.862 1.345-1.52a6.32 6.32 0 0 0 .911-3.34c0-1.446-.412-2.705-1.265-3.868-.355-.483-.716-.838-.989-.972a1.673 1.673 0 0 0-1.047-.107m-3.807 2.522c-.874.221-1.641 1.023-1.812 1.895-.071.36-.033 1.048.075 1.347a2.65 2.65 0 0 0 1.549 1.553c.205.076.316.088.817.088.642 0 .831-.045 1.276-.308.283-.167.707-.591.874-.874.263-.445.308-.634.308-1.276 0-.501-.012-.612-.088-.817a2.652 2.652 0 0 0-1.553-1.55c-.297-.108-1.118-.141-1.446-.058\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rada_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RadaCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RadaCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RadaCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.21 4.74c-.479.244-1.468 1.494-2.042 2.58a10.014 10.014 0 0 0 1.529 11.508c.398.426.637.551.996.523.287-.023.449-.096.639-.286.202-.201.283-.404.283-.705-.001-.335-.081-.491-.475-.92a7.694 7.694 0 0 1-1.34-1.969c-1.436-2.972-.911-6.459 1.34-8.911.394-.429.474-.585.475-.92 0-.301-.081-.504-.283-.705-.193-.193-.357-.265-.652-.284-.223-.015-.29-.002-.47.089m12.855-.059c-.434.13-.752.629-.685 1.075.04.266.14.434.48.804.578.63.976 1.214 1.34 1.969 1.435 2.97.91 6.46-1.34 8.911-.392.427-.473.585-.476.92-.002.3.078.499.284.705.19.19.352.263.639.286.365.029.598-.096 1.014-.543 3.036-3.263 3.536-8.145 1.228-11.988-.529-.88-1.383-1.89-1.757-2.076a1.027 1.027 0 0 0-.727-.063M8.052 7.556c-.374.174-1.04 1.019-1.449 1.835-.657 1.315-.772 3.022-.299 4.469.275.843.747 1.63 1.36 2.27.336.35.517.444.856.445.301 0 .504-.081.705-.283.219-.219.273-.357.273-.692 0-.364-.051-.473-.403-.853-.891-.965-1.267-2.27-1.013-3.519a3.934 3.934 0 0 1 .916-1.86c.4-.463.454-.58.436-.942-.019-.372-.161-.625-.451-.804-.256-.159-.67-.188-.931-.066m7.122.022c-.36.156-.596.534-.589.942.005.293.077.448.364.781.871 1.011 1.218 2.246.972 3.456-.15.735-.439 1.317-.951 1.912-.361.421-.41.526-.41.881 0 .237.017.302.13.49.179.298.43.451.776.473.365.023.547-.057.867-.385a5.887 5.887 0 0 0 1.338-2.188c.497-1.457.397-3.191-.261-4.52-.412-.832-1.056-1.656-1.426-1.824-.197-.089-.624-.099-.81-.018m-3.697 2.495c-.859.224-1.484 1.053-1.475 1.955.01.957.738 1.793 1.688 1.939.653.1 1.244-.094 1.713-.564.324-.324.498-.66.564-1.093A1.995 1.995 0 0 0 12 10.005c-.143 0-.378.031-.523.068\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/refresh_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Refresh2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Refresh2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Refresh2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"m11.28 2.023-.52.06c-2.496.285-4.968 1.665-6.562 3.661-.375.47-.845 1.205-1.125 1.76-.227.45-.248.513-.247.744.002.289.071.477.251.682.282.322.787.41 1.192.208.248-.124.342-.242.602-.759a7.667 7.667 0 0 1 1.575-2.12A7.375 7.375 0 0 1 8.529 4.8c2.727-1.318 5.941-.982 8.371.874.339.26 1.025.932 1.311 1.286.335.415.682.949.91 1.403.207.412.407.882.385.904-.007.008-.252-.04-.544-.107-.673-.152-.954-.156-1.236-.016-.675.337-.801 1.333-.226 1.796.349.28 2.281 1.556 2.475 1.633.149.06.297.082.545.083.297 0 .374-.015.602-.122.353-.165.532-.332.681-.633.153-.313.184-.602.119-1.128-.305-2.474-1.581-4.828-3.482-6.423-.812-.681-1.995-1.368-2.977-1.73a12.865 12.865 0 0 0-2.063-.519c-.434-.064-1.805-.114-2.12-.078M3.169 11.36c-.243.051-.63.268-.776.434-.302.344-.399.783-.317 1.428a10.018 10.018 0 0 0 3.713 6.607c1.43 1.137 3.067 1.825 4.991 2.098.545.077 2.015.065 2.58-.021 1.587-.243 2.887-.734 4.141-1.565a10.01 10.01 0 0 0 3.421-3.836c.231-.458.253-.523.253-.755a1 1 0 0 0-.601-.935c-.22-.092-.607-.071-.843.047-.248.124-.342.242-.602.759a7.667 7.667 0 0 1-1.575 2.12 7.375 7.375 0 0 1-2.083 1.459c-2.727 1.318-5.941.982-8.371-.874a10.805 10.805 0 0 1-1.311-1.286 8.418 8.418 0 0 1-.91-1.403c-.207-.412-.407-.882-.385-.904.007-.008.255.041.55.109.693.159 1.013.154 1.296-.022.385-.24.6-.731.509-1.166-.085-.405-.168-.483-1.43-1.342-.638-.434-1.257-.827-1.376-.873a1.764 1.764 0 0 0-.874-.079\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rewind_backward_15_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RewindBackward15CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RewindBackward15CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RewindBackward15CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M5.08 1.643a1.105 1.105 0 0 0-.613.719c-.04.166-.427 3.522-.427 3.701 0 .249.21.637.428.792.236.167.388.218.655.222.414.006.547-.071 1.13-.652 1.078-1.074 2.175-1.736 3.521-2.127a7.955 7.955 0 0 1 6.166.722c2.122 1.203 3.524 3.224 3.966 5.72.099.558.099 1.874.001 2.46-.441 2.632-2.053 4.814-4.401 5.956a7.736 7.736 0 0 1-3.506.806c-2.589.002-4.974-1.223-6.505-3.342a8.29 8.29 0 0 1-1.301-2.92C4.093 13.245 4 12.503 4 12.158c0-.497-.162-.822-.511-1.027-.161-.095-.233-.111-.489-.111-.257 0-.327.016-.492.113-.222.13-.409.37-.472.602-.081.299.03 1.574.207 2.39.526 2.427 1.987 4.605 4.064 6.059 2.548 1.784 5.845 2.26 8.833 1.277a10.013 10.013 0 0 0 6.703-7.749c.768-4.386-1.442-8.744-5.423-10.694a13.53 13.53 0 0 0-.997-.439c-1.829-.671-3.997-.773-5.923-.28-.735.189-1.261.389-2.017.77-.362.181-.665.322-.674.313a7.51 7.51 0 0 1-.135-.586c-.132-.634-.171-.731-.377-.931a1.133 1.133 0 0 0-1.217-.222m3.496 5.958c-.089.045-.53.328-.978.63-.995.67-1.058.745-1.058 1.269 0 .293.012.346.121.522.179.289.433.433.799.452.21.01.322-.003.413-.05l.125-.065.011 2.711c.011 2.702.011 2.71.099 2.874.102.19.33.403.518.481.169.071.579.071.748 0a1.21 1.21 0 0 0 .518-.481l.088-.164V8.22l-.086-.16a1.126 1.126 0 0 0-.503-.478c-.215-.09-.614-.08-.815.019m3.724-.025c-.354.111-.692.411-.886.784l-.114.22v2.6l.135.249c.248.457.702.782 1.185.848.235.032.419.013.937-.096.776-.164 1.337.084 1.564.691a1.192 1.192 0 0 1-1.505 1.553 1.213 1.213 0 0 1-.66-.576c-.175-.312-.301-.442-.536-.554-.352-.166-.819-.099-1.094.157-.172.161-.299.466-.302.728-.003.197.022.293.14.54.3.624.759 1.09 1.394 1.415.577.296 1.121.4 1.772.337a3.212 3.212 0 0 0 2.634-1.949c.249-.593.297-1.465.116-2.116a3.167 3.167 0 0 0-2.195-2.173c-.325-.087-1.197-.122-1.497-.061l-.151.031.011-.352.012-.352 1.42-.02c1.565-.022 1.529-.016 1.788-.287a.99.99 0 0 0-.033-1.421c-.268-.254-.251-.252-2.204-.249-1.305.002-1.813.016-1.931.053\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rewind_forward_30_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RewindForward30CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RewindForward30CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RewindForward30CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.013 1.645c-.196.095-.428.31-.514.478-.034.065-.113.371-.175.679a6.787 6.787 0 0 1-.132.579c-.01.01-.313-.131-.675-.312-1.328-.669-2.536-.991-3.977-1.063-1.772-.087-3.322.221-4.88.97A9.936 9.936 0 0 0 2.305 9.56a9.13 9.13 0 0 0-.291 2.06 9.935 9.935 0 0 0 2.783 7.272c.95.979 1.909 1.66 3.1 2.198 3.168 1.433 6.956 1.082 9.796-.906 2.631-1.842 4.235-4.813 4.279-7.927.008-.562.008-.562-.113-.76a.998.998 0 0 0-1.714-.006c-.108.175-.119.231-.147.721-.051.883-.161 1.498-.402 2.242a7.92 7.92 0 0 1-2.171 3.383c-1.511 1.393-3.383 2.127-5.425 2.125-.94 0-1.673-.118-2.538-.408a7.986 7.986 0 0 1-5.448-7.874 7.961 7.961 0 0 1 1.92-4.913c1.601-1.887 4.146-2.956 6.59-2.769 2.054.157 3.725.934 5.221 2.427.424.423.563.537.718.59.22.075.579.08.764.012.377-.139.674-.51.718-.896.019-.168-.343-3.481-.412-3.769a1.105 1.105 0 0 0-.613-.719c-.245-.111-.676-.11-.907.002M8.14 7.565c-.74.171-1.41.696-1.74 1.365-.146.296-.173.386-.171.584.003.298.11.542.319.731.198.178.403.252.692.249a.965.965 0 0 0 .86-.534c.192-.375.459-.514.832-.434.437.094.659.516.51.968-.065.197-.357.455-.548.485l-.292.046c-.277.043-.511.215-.705.515-.061.095-.077.192-.077.46s.016.365.077.46c.229.354.472.503.873.531.259.019.278.028.468.214.146.144.206.239.232.365a.804.804 0 0 1-.112.593c-.176.246-.633.35-.917.207-.133-.067-.179-.124-.394-.479a.954.954 0 0 0-.738-.44c-.45-.036-.783.145-.971.53-.162.331-.152.615.036.993a2.618 2.618 0 0 0 2.005 1.428 2.656 2.656 0 0 0 1.62-.252c.282-.134.4-.222.702-.526.561-.564.777-1.083.778-1.868a2.67 2.67 0 0 0-.512-1.615l-.12-.159.111-.141c.477-.606.651-1.526.442-2.341-.227-.886-1.014-1.673-1.9-1.9-.339-.087-1.057-.105-1.36-.035m6.231.01c-.87.22-1.644 1.027-1.809 1.886-.033.169-.043.952-.034 2.679.012 2.414.013 2.442.102 2.677a2.667 2.667 0 0 0 1.553 1.553c.205.076.316.088.817.088.501 0 .612-.012.817-.088a2.667 2.667 0 0 0 1.553-1.553c.09-.236.09-.243.09-2.817 0-2.574 0-2.581-.09-2.817a2.657 2.657 0 0 0-1.553-1.55c-.297-.108-1.118-.141-1.446-.058m.898 2.01c.05.031.122.105.16.164.066.099.071.27.071 2.251 0 1.981-.005 2.152-.071 2.251a.501.501 0 0 1-.858 0c-.065-.098-.072-.276-.083-2.109-.007-1.101-.001-2.078.012-2.172.057-.391.447-.586.769-.385\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/right_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RightCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RightCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RightCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.875 4.93c-.314.11-.502.246-.717.517a1.472 1.472 0 0 0 .067 1.873c.085.094.461.373.837.621.377.248.917.636 1.201.862.568.452 1.604 1.464 2.043 1.997.447.542.898 1.171.878 1.225-.031.079-.571.802-.878 1.175-.437.531-1.474 1.544-2.043 1.997-.284.226-.83.618-1.213.87-.383.253-.749.517-.814.586-.299.324-.453.895-.356 1.314.111.476.473.905.91 1.079.312.123.828.128 1.09.009.239-.108.958-.56 1.48-.93 1.41-1 2.856-2.424 3.904-3.845.663-.898.858-1.24.979-1.71.186-.728.037-1.378-.496-2.17-1.209-1.795-2.774-3.4-4.467-4.584-.511-.357-1.164-.765-1.377-.859a1.486 1.486 0 0 0-1.028-.027\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/right_cute_li.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RightCuteLiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RightCuteLiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RightCuteLiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.013 5.678c-.358.162-.512.639-.326 1.002.075.145.166.228.473.427.649.421 1.038.698 1.559 1.11.554.44 1.754 1.614 2.188 2.143.37.451.933 1.213 1.07 1.45l.111.19-.111.19c-.137.237-.7.999-1.07 1.45-.428.522-1.629 1.697-2.188 2.142-.517.41-.901.684-1.559 1.111-.307.199-.398.282-.473.427-.303.589.287 1.269.9 1.038.147-.056.856-.505 1.313-.833 1.029-.738 2.198-1.806 2.992-2.736.59-.692 1.28-1.637 1.477-2.024.17-.333.171-.339.171-.762 0-.414-.005-.437-.151-.733-.173-.35-.745-1.155-1.302-1.83-.474-.576-1.691-1.792-2.247-2.246-.738-.602-1.952-1.438-2.253-1.552a.774.774 0 0 0-.574.036\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/right_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RightCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RightCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RightCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.891 5.462c-.488.243-.675.839-.421 1.341.094.186.161.251.47.455.198.132.567.382.82.558 1.466 1.015 2.949 2.518 3.899 3.954l.152.23-.152.23c-.949 1.434-2.434 2.94-3.899 3.954-.253.176-.622.426-.82.558-.309.204-.376.269-.47.455-.343.676.12 1.445.868 1.442.305-.001.435-.06 1.1-.498a17.43 17.43 0 0 0 4.59-4.388c.634-.883.772-1.196.772-1.753 0-.557-.138-.87-.772-1.753a17.733 17.733 0 0 0-3.596-3.69c-.555-.418-1.452-1.014-1.686-1.12a1.066 1.066 0 0 0-.855.025\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/right_small_sharp.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RightSmallSharpIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RightSmallSharpIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RightSmallSharpIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"m10.64 8.82-.339.341 1.419 1.419L13.14 12l-1.42 1.42-1.42 1.42.35.35.35.35 1.77-1.77L14.54 12l-1.76-1.76c-.968-.968-1.769-1.76-1.781-1.76a4.49 4.49 0 0 0-.359.34\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rocket_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RocketCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RocketCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RocketCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M15.9 2.406c-3.281.283-6.284 1.957-8.724 4.861-.431.513-.599.663-.813.727-.084.025-.404.046-.711.046-.631 0-.994.071-1.43.279a3.155 3.155 0 0 0-.995.796c-.211.268-.769 1.391-.929 1.868-.245.728-.041 1.554.522 2.117.31.31.6.459 1.237.639.284.08.712.208.949.285l.432.139-.176.216c-.389.479-.702 1.151-1.016 2.184-.3.986-.378 1.373-.356 1.77.018.312.04.393.183.683.354.714.974 1.104 1.753 1.104.336 0 1.052-.173 1.969-.476.908-.3 1.494-.595 1.89-.953l.148-.134.142.432c.079.237.207.656.284.931.172.606.298.866.561 1.154.421.463.936.691 1.56.692.463.002.671-.068 1.579-.527.979-.495 1.37-.848 1.702-1.538.201-.418.267-.753.275-1.401.01-.765.039-.818.77-1.406 2.436-1.958 4.011-4.31 4.657-6.953.236-.963.293-1.488.294-2.701.002-1.497-.094-2.154-.415-2.828a3.424 3.424 0 0 0-2.015-1.788c-.644-.216-2.183-.317-3.327-.218m-1.318 5.535c.389.1.659.254.941.536.282.282.436.552.536.941.271 1.057-.447 2.192-1.531 2.418-1.224.255-2.408-.707-2.408-1.956 0-.523.21-1.018.596-1.403.507-.505 1.204-.706 1.866-.536m-6.697 7.488c.3.095.605.401.692.692.084.285.079.472-.019.734-.139.373-.379.54-1.181.824-.282.1-1.427.441-1.48.441-.024 0 .236-.919.39-1.38.292-.87.493-1.162.902-1.308.245-.088.429-.089.696-.003\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rocket_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RocketCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RocketCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RocketCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M16.18 2.383c-1.12.108-1.556.184-2.403.418-1.575.436-3.045 1.205-4.437 2.321-.582.467-1.607 1.471-2.058 2.018-.727.881-.705.869-1.682.904-.593.021-.784.042-.963.107-.584.21-1.072.536-1.385.924-.177.22-.748 1.341-.931 1.825-.111.297-.15.829-.083 1.151.118.567.571 1.171 1.067 1.421.133.068.473.188.756.267.282.08.708.208.946.285l.433.14-.203.248c-.411.503-.901 1.697-1.221 2.977-.13.519-.144.623-.126.941.018.315.039.395.183.686.352.71.978 1.104 1.751 1.104.312 0 .865-.128 1.75-.404.913-.286 1.599-.613 2.048-.979l.216-.176.184.589.319 1.02c.079.25.196.522.28.649.287.433.787.789 1.26.898.31.071.853.053 1.124-.039.28-.094 1.357-.626 1.658-.818.402-.257.806-.74 1.026-1.225.18-.397.242-.729.237-1.266-.007-.801.028-.86.994-1.652.633-.518 1.638-1.525 2.094-2.097 1.49-1.868 2.339-3.864 2.604-6.12.135-1.148.036-2.931-.204-3.68-.338-1.055-1.239-1.932-2.294-2.233-.388-.11-1.158-.19-1.98-.205-.44-.008-.872-.007-.96.001m1.822 2.034c.605.075.866.176 1.132.437.433.424.523.83.525 2.366.001.816-.014 1.128-.069 1.447-.306 1.766-1.011 3.308-2.165 4.733-.422.522-1.182 1.27-1.887 1.86-.758.633-.954.836-1.18 1.224-.315.538-.406.902-.415 1.656-.011.848-.08.943-1.017 1.386-.307.145-.577.252-.601.237-.024-.015-.116-.283-.204-.596-.698-2.468-1.608-4.235-2.637-5.115-.985-.843-2.547-1.572-4.656-2.173-.604-.172-.628-.181-.628-.251-.001-.097.496-1.15.606-1.283.063-.076.199-.179.302-.229.173-.083.237-.091.75-.082.644.01 1.001-.06 1.482-.289.458-.218.752-.474 1.36-1.183 2.121-2.473 4.374-3.787 7.1-4.142.533-.069 1.654-.071 2.202-.003m-4.156 3.386c-.509.067-1.036.409-1.339.869-.282.429-.327.59-.327 1.168 0 .483.005.51.132.779.27.569.697.961 1.248 1.143.349.115.877.116 1.243.001 1.411-.441 1.89-2.23.888-3.323-.461-.503-1.123-.732-1.845-.637m.372 2.04a.044.044 0 1 1-.076-.046.044.044 0 0 1 .076.046m-6.339 5.58c.157.047.267.117.42.271.334.334.422.727.259 1.161-.138.37-.388.543-1.198.83-.269.095-1.411.435-1.463.435-.022 0 .229-.897.369-1.317.214-.643.415-1.045.598-1.196.28-.232.635-.296 1.015-.184\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/round_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RoundCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RoundCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RoundCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/round_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RoundCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RoundCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RoundCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.08 2.045c-1.874.165-3.723.904-5.28 2.109-.437.339-1.307 1.209-1.646 1.646-1.8 2.326-2.505 5.195-1.976 8.046.29 1.566.959 3.04 1.976 4.354.339.437 1.209 1.307 1.646 1.646 2.441 1.889 5.453 2.566 8.44 1.895 2.487-.559 4.752-2.144 6.145-4.301.806-1.247 1.283-2.527 1.521-4.08.098-.641.098-2.079 0-2.72-.285-1.858-.936-3.388-2.06-4.84-.339-.437-1.209-1.307-1.646-1.646-2.067-1.599-4.554-2.336-7.12-2.109m1.752 1.997a8.182 8.182 0 0 1 4.208 1.747c.354.286 1.027.972 1.286 1.311A8.123 8.123 0 0 1 20 12a8.1 8.1 0 0 1-1.789 5.04c-.286.354-.972 1.027-1.311 1.286A8.123 8.123 0 0 1 12 20a8.123 8.123 0 0 1-4.9-1.674c-.339-.259-1.025-.932-1.311-1.286A7.99 7.99 0 0 1 4.8 8.529a7.375 7.375 0 0 1 1.459-2.083 7.632 7.632 0 0 1 2.267-1.645 8.025 8.025 0 0 1 4.306-.759\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rss_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Rss2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Rss2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Rss2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.571 3.575a1.632 1.632 0 0 0-.937.816c-.079.161-.093.251-.093.609-.001.384.009.44.116.658.186.379.526.671.922.789.066.02.481.044.921.054.44.01.98.037 1.2.06a12.005 12.005 0 0 1 7.28 3.459 12.006 12.006 0 0 1 3.459 7.281c.024.22.05.76.06 1.2.009.439.033.854.053.92.122.399.411.736.79.922.217.107.275.117.658.117.402 0 .431-.006.67-.136a1.58 1.58 0 0 0 .684-.717l.106-.227v-1.06c-.001-1.344-.101-2.196-.401-3.42a14.954 14.954 0 0 0-3.959-7 14.675 14.675 0 0 0-3.84-2.782c-1.566-.794-2.991-1.232-4.886-1.501-.62-.088-2.574-.117-2.803-.042m.024 7c-.385.104-.741.405-.942.796-.153.297-.178.829-.055 1.155.111.295.277.521.512.693.32.235.505.277 1.25.284.735.008 1.147.07 1.731.261 1.191.389 2.297 1.342 2.866 2.469.373.74.525 1.413.539 2.39.01.671.012.688.134.949.15.319.406.581.721.736.205.1.272.112.629.112.351 0 .428-.013.629-.108.293-.137.583-.427.733-.733l.118-.239v-.88c0-.918-.023-1.132-.207-1.9-.24-1.004-.769-2.092-1.447-2.98a10.087 10.087 0 0 0-1.246-1.271 8.172 8.172 0 0 0-4.119-1.747c-.463-.057-1.615-.049-1.846.013m.317 6.022A1.964 1.964 0 0 0 3.52 18.5c0 1.119.861 1.98 1.98 1.98s1.98-.861 1.98-1.98a1.96 1.96 0 0 0-1.394-1.901c-.331-.103-.857-.103-1.174-.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/rss_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface RssCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const RssCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: RssCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.24 2.546c-2.117.11-3.311.502-4.229 1.387-.813.783-1.204 1.745-1.401 3.439-.062.542-.07 1.047-.07 4.628 0 3.581.008 4.086.07 4.628.197 1.694.588 2.656 1.401 3.439.804.775 1.708 1.131 3.361 1.323.542.062 1.047.07 4.628.07 3.581 0 4.086-.008 4.628-.07 1.653-.192 2.557-.548 3.361-1.323.813-.783 1.204-1.745 1.401-3.439.062-.542.07-1.047.07-4.628 0-3.581-.008-4.086-.07-4.628-.197-1.694-.588-2.656-1.401-3.439-.796-.768-1.702-1.128-3.32-1.319-.49-.058-1.094-.068-4.369-.074-2.09-.004-3.917-.001-4.06.006m1.1 4.494c2.476.272 4.679 1.553 6.087 3.54 1.06 1.495 1.627 3.369 1.565 5.167-.015.43-.033.544-.11.704a1.06 1.06 0 0 1-.618.53c-.252.075-.328.074-.579-.007a.976.976 0 0 1-.605-.542c-.079-.184-.09-.272-.084-.692.024-1.916-.614-3.527-1.913-4.822-1.306-1.302-2.917-1.941-4.823-1.913-.421.006-.506-.004-.69-.084-.373-.162-.582-.485-.582-.901 0-.489.302-.864.792-.983.181-.044 1.152-.042 1.56.003m-.411 3.543a5.77 5.77 0 0 1 2.25.93 6.866 6.866 0 0 1 1.308 1.308c.611.86.984 1.979 1.002 3.003.005.301-.01.434-.063.556-.154.353-.552.619-.926.619-.373 0-.777-.269-.921-.615-.036-.086-.081-.356-.1-.6-.074-.942-.371-1.596-1.019-2.244-.648-.648-1.302-.945-2.244-1.019-.494-.039-.691-.104-.891-.293a.984.984 0 0 1-.231-1.128c.148-.316.391-.505.766-.594.119-.028.671.012 1.069.077m.172 3.552c.313.143.622.45.766.761.098.21.113.293.113.604 0 .31-.015.393-.112.6a1.698 1.698 0 0 1-.764.767c-.21.098-.293.113-.604.113-.31 0-.393-.015-.6-.112a1.698 1.698 0 0 1-.767-.764c-.098-.21-.113-.293-.113-.604 0-.32.014-.389.124-.62.273-.573.795-.893 1.417-.867.223.009.362.041.54.122\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/sad_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SadCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SadCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SadCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.995 2.044C7.799 2.29 5.173 3.742 3.589 6.14c-.297.449-.774 1.425-.968 1.98-.441 1.259-.657 2.799-.606 4.32.065 1.937.382 3.346 1.068 4.746 1.009 2.059 2.727 3.493 5.089 4.248 2.316.741 5.34.741 7.656 0 3.049-.974 5.023-3.087 5.807-6.214.285-1.133.424-2.873.33-4.11-.205-2.691-1.084-4.761-2.704-6.37-1.99-1.977-4.972-2.95-8.266-2.696m2.485 2.055c2.379.366 4.155 1.433 5.29 3.181.986 1.519 1.399 3.62 1.172 5.961-.326 3.358-1.954 5.476-4.882 6.35-1.825.545-4.295.545-6.12 0-2.832-.846-4.432-2.838-4.859-6.051-.083-.624-.095-2.026-.024-2.7.297-2.799 1.634-4.85 3.883-5.959.93-.458 1.823-.696 3.22-.857.328-.038 1.921.013 2.32.075m-5.306 3.94c-.425.09-.847.439-1.04.861-.095.206-.111.291-.11.6 0 .308.016.396.111.604.141.31.449.617.765.762.206.095.292.111.6.111s.394-.016.6-.111c.316-.145.624-.452.765-.762.095-.209.111-.296.111-.604s-.016-.395-.111-.604a1.658 1.658 0 0 0-.746-.753 1.794 1.794 0 0 0-.945-.104m7 0c-.425.09-.847.439-1.04.861-.095.206-.111.291-.11.6 0 .308.016.396.111.604.141.31.449.617.765.762.206.095.292.111.6.111s.394-.016.6-.111c.316-.145.624-.452.765-.762.095-.209.111-.296.111-.604s-.016-.395-.111-.604a1.658 1.658 0 0 0-.746-.753 1.794 1.794 0 0 0-.945-.104m-3.894 5.022c-1.067.147-2.444.862-2.787 1.447-.359.613.049 1.392.774 1.478.287.034.499-.049.898-.353.598-.455 1.068-.611 1.835-.611.767 0 1.243.16 1.848.621.383.293.599.377.885.343.571-.068.952-.536.894-1.098-.027-.267-.164-.503-.413-.711a5.036 5.036 0 0 0-3.934-1.116\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/safe_alert_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SafeAlertCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SafeAlertCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SafeAlertCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.64 2.226c-.659.099-.893.176-3.351 1.099-2.373.892-2.719 1.028-3.101 1.221-.822.414-1.378.976-1.774 1.794C3.042 7.111 3 7.535 3 10.516c.001 2.311.031 2.861.199 3.669.463 2.229 1.789 4.214 3.701 5.544.5.347 2.718 1.591 2.732 1.532.009-.039.081-.029.3.04 1.313.417 3.032.392 4.332-.064.933-.327 2.579-1.266 3.456-1.972 1.563-1.259 2.721-3.202 3.099-5.202.157-.827.181-1.304.181-3.587 0-2.938-.043-3.367-.414-4.136-.492-1.017-1.147-1.574-2.474-2.104-.922-.367-4.676-1.758-4.996-1.851-.534-.154-1.102-.215-1.476-.159m1.04 2.116c.364.115 4.051 1.494 4.798 1.794.247.099.536.236.642.304.371.238.651.616.787 1.06.068.222.073.437.073 2.9 0 2.966-.004 3.019-.286 3.96-.174.581-.643 1.535-1.003 2.038-.337.472-1.037 1.182-1.509 1.533-.383.285-1.564.974-2.066 1.205A4.75 4.75 0 0 1 12 19.6a4.75 4.75 0 0 1-2.116-.464c-.502-.231-1.683-.92-2.066-1.205-.472-.351-1.172-1.061-1.509-1.533-.36-.503-.829-1.457-1.003-2.038-.282-.941-.286-.994-.286-3.96 0-2.463.005-2.678.073-2.9.136-.444.416-.822.787-1.06.237-.152.598-.298 2.78-1.12 2.917-1.1 3.012-1.131 3.42-1.103.143.009.413.066.6.125m-1.007 2.721c-.261.08-.533.358-.612.627-.093.313-.091 4.369.003 4.644.124.363.549.666.936.666.237 0 .514-.12.697-.303.305-.306.303-.286.303-2.697s.002-2.391-.303-2.697c-.279-.279-.63-.361-1.024-.24m0 7c-.369.114-.673.546-.673.957 0 .395.319.811.709.924a.987.987 0 0 0 1.278-1.056.977.977 0 0 0-.665-.827c-.21-.074-.402-.074-.649.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/safe_lock_filled.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SafeLockFilledIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SafeLockFilledIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SafeLockFilledIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.554 2.12c-.241.061-7.194 2.674-7.467 2.806-.509.246-.945.831-1.05 1.407-.028.156-.037 1.291-.026 3.44.017 3.439.016 3.426.236 4.367.196.841.642 1.904 1.126 2.686A9.066 9.066 0 0 0 7.12 19.61c.241.158 1.278.705 2.43 1.283l2.011 1.007h.878l2.011-1.007c1.152-.578 2.189-1.125 2.43-1.283 2.2-1.441 3.605-3.624 4.028-6.258.056-.351.068-.88.081-3.6.011-2.126.002-3.265-.026-3.42-.099-.542-.519-1.13-.985-1.377-.109-.058-1.845-.723-3.858-1.477l-3.66-1.371-.38-.012a2.791 2.791 0 0 0-.526.025m.854 5.924c.369.077.709.266.995.552.617.619.765 1.48.392 2.272-.128.271-.441.64-.657.773l-.138.086v1.709c0 1.943-.004 1.97-.3 2.266a.987.987 0 0 1-1.169.172 1.065 1.065 0 0 1-.489-.585c-.026-.092-.042-.803-.042-1.856v-1.706l-.138-.086c-.216-.133-.529-.502-.657-.773-.374-.795-.226-1.655.391-2.271a1.956 1.956 0 0 1 1.812-.553\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/safety_certificate_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SafetyCertificateCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SafetyCertificateCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SafetyCertificateCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.64 2.226c-.659.099-.893.176-3.351 1.099-2.373.892-2.719 1.028-3.101 1.221-.822.414-1.378.976-1.774 1.794C3.042 7.111 3 7.535 3 10.516c.001 2.311.031 2.861.199 3.669.463 2.229 1.79 4.216 3.701 5.544.39.271 1.534.926 2.082 1.192 1.04.506 2.054.711 3.297.667 1.369-.049 2.179-.314 3.821-1.25 1.052-.599 1.63-1.024 2.281-1.676a8.985 8.985 0 0 0 2.578-5.582c.053-.598.053-4.599 0-5.251-.051-.629-.151-1.029-.373-1.489-.492-1.017-1.147-1.574-2.474-2.104-.922-.367-4.676-1.758-4.996-1.851-.534-.154-1.102-.215-1.476-.159m1.04 2.116c.364.115 4.051 1.494 4.798 1.794.247.099.536.236.642.304.371.238.651.616.787 1.06.068.222.073.437.073 2.9 0 2.966-.004 3.019-.286 3.96-.174.581-.643 1.535-1.003 2.038-.337.472-1.037 1.182-1.509 1.533-.383.285-1.564.974-2.066 1.205A4.75 4.75 0 0 1 12 19.6a4.75 4.75 0 0 1-2.116-.464c-.502-.231-1.683-.92-2.066-1.205-.472-.351-1.172-1.061-1.509-1.533-.36-.503-.829-1.457-1.003-2.038-.282-.941-.286-.994-.286-3.96 0-2.463.005-2.678.073-2.9.136-.444.416-.822.787-1.06.237-.152.598-.298 2.78-1.12 2.917-1.1 3.012-1.131 3.42-1.103.143.009.413.066.6.125m2.833 4.394c-.277.074-1.232.799-2.253 1.71a23.692 23.692 0 0 0-2.291 2.379l-.169.206-.483-.476c-.536-.527-1.288-1.158-1.524-1.278-.483-.247-1.088-.036-1.334.465-.054.11-.077.243-.078.438-.001.395.108.567.63.986a15.386 15.386 0 0 1 1.823 1.823c.205.255.395.448.505.513.346.202.833.162 1.126-.093.069-.06.29-.33.492-.6 1.139-1.524 2.452-2.829 4.111-4.084.417-.316.504-.401.592-.582.277-.565 0-1.227-.585-1.401-.246-.073-.312-.074-.562-.006\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/save_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SaveCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SaveCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SaveCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.3 1.545c-1.509.094-2.391.262-3.117.592-1.318.601-2.129 1.712-2.443 3.351-.185.964-.191 1.132-.209 5.992-.021 5.672.009 6.452.29 7.512.219.829.572 1.451 1.154 2.033.939.939 1.964 1.304 3.965 1.413.975.053 5.128.054 6.12.001 1.282-.068 2.108-.242 2.84-.6a3.935 3.935 0 0 0 1.208-.892c.666-.701 1.007-1.441 1.207-2.615.147-.869.171-1.966.157-7.272l-.013-5-.088-.311c-.259-.913-.529-1.309-1.67-2.45-1.142-1.141-1.54-1.413-2.45-1.67-.308-.086-.346-.087-3.531-.092-1.771-.002-3.31.001-3.42.008m6.477 2.031c.45.126.602.242 1.504 1.143.91.91 1.018 1.055 1.148 1.525.066.239.071.595.069 5.236-.002 5.241-.02 5.903-.182 6.7-.089.435-.235.84-.382 1.06a2.716 2.716 0 0 1-.9.798c-.637.319-1.158.388-3.344.446-1.792.047-4.726-.001-5.41-.088-.769-.099-1.351-.306-1.705-.607-.459-.39-.678-.748-.835-1.369-.145-.57-.179-.975-.225-2.68-.051-1.876-.02-7.931.044-8.74.135-1.693.465-2.474 1.236-2.93.218-.129.672-.285 1.065-.366.817-.169 2.149-.22 5.4-.205 1.98.01 2.311.02 2.517.077m-.499 5.542c-.164.05-.275.118-.668.408a22.814 22.814 0 0 0-3.576 3.334l-.441.52-.282-.274c-.527-.512-1.249-1.04-1.566-1.144a1.12 1.12 0 0 0-.711.054c-.38.181-.573.526-.547.98.021.377.136.535.653.901.51.36 1.13.98 1.449 1.449.301.441.427.553.72.638a.95.95 0 0 0 .995-.27c.068-.074.264-.32.434-.547a20.378 20.378 0 0 1 4.022-4.005c.48-.359.558-.436.665-.662a.985.985 0 0 0-.602-1.361c-.213-.064-.383-.07-.545-.021\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/search_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Search2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Search2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Search2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.54 3.023c-.825.079-1.29.177-1.92.404A7.02 7.02 0 0 0 3.205 8.34a7.56 7.56 0 0 0 0 3.32 7.04 7.04 0 0 0 5.135 5.135 7.56 7.56 0 0 0 3.32 0c.743-.18 1.647-.581 2.24-.992l.28-.194 2.64 2.632c2.552 2.544 2.647 2.634 2.857 2.695.271.08.375.08.633.003.279-.083.546-.35.629-.629.077-.258.077-.362-.003-.633-.061-.21-.151-.305-2.695-2.857l-2.632-2.64.194-.28c.411-.593.812-1.497.992-2.24a7.56 7.56 0 0 0 0-3.32c-.66-2.717-2.951-4.827-5.693-5.242-.376-.057-1.289-.101-1.562-.075M11 5.101c1 .218 1.8.655 2.522 1.377a4.763 4.763 0 0 1 1.21 1.922c.19.556.248.93.248 1.6 0 1.063-.259 1.921-.848 2.812-.245.37-.95 1.075-1.32 1.32-.891.589-1.749.848-2.812.848-1.062 0-1.92-.259-2.815-.848-.366-.242-1.075-.951-1.317-1.317-.589-.895-.848-1.753-.848-2.815s.259-1.92.848-2.815c.242-.366.951-1.075 1.317-1.317.687-.452 1.228-.655 2.255-.844.211-.039 1.27.013 1.56.077\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/search_3_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Search3CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Search3CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Search3CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.68 2.043A8.565 8.565 0 0 0 4.009 5.02C3.01 6.204 2.351 7.662 2.094 9.26c-.087.544-.098 1.828-.019 2.36.423 2.868 2.094 5.203 4.632 6.474.781.392 1.51.627 2.45.792.672.118 1.922.128 2.583.019 1.391-.228 2.464-.654 3.614-1.436l.413-.282 1.867 1.863c1.299 1.297 1.921 1.89 2.046 1.953.226.112.551.122.807.024.223-.085.481-.35.556-.57.081-.24.064-.567-.041-.777-.062-.125-.66-.751-1.951-2.045l-1.861-1.866.342-.514c.85-1.282 1.292-2.531 1.431-4.046.138-1.502-.171-3.109-.865-4.494a8.42 8.42 0 0 0-2.065-2.659A8.618 8.618 0 0 0 9.68 2.043m1.563 4.022a4.511 4.511 0 0 1 3.692 3.692c.094.548.085.898-.029 1.143-.29.622-1.061.793-1.558.345A1.063 1.063 0 0 1 13 10.48a2.56 2.56 0 0 0-.741-1.739A2.56 2.56 0 0 0 10.52 8a1.063 1.063 0 0 1-.765-.348c-.448-.497-.277-1.268.345-1.558.245-.114.595-.123 1.143-.029\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/search_3_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Search3CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Search3CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Search3CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.84 2.025c-2.094.192-3.893 1.025-5.341 2.474-1.306 1.306-2.108 2.887-2.406 4.746-.092.572-.092 1.938 0 2.51.298 1.859 1.1 3.44 2.406 4.746 1.301 1.302 2.893 2.109 4.746 2.406.572.092 1.938.092 2.51 0a8.79 8.79 0 0 0 3.777-1.556l.231-.165 1.909 1.901c1.714 1.709 1.928 1.909 2.105 1.967.812.27 1.546-.45 1.283-1.259-.066-.202-.189-.334-1.972-2.123l-1.902-1.909.165-.231a8.79 8.79 0 0 0 1.556-3.777c.092-.572.092-1.938 0-2.51-.297-1.853-1.104-3.445-2.406-4.746a8.417 8.417 0 0 0-4.726-2.4c-.391-.06-1.587-.106-1.935-.074m1.761 2.07c2.735.497 4.797 2.564 5.309 5.325.092.495.092 1.665 0 2.16-.514 2.771-2.559 4.816-5.33 5.33-.495.092-1.665.092-2.16 0-2.504-.465-4.468-2.22-5.159-4.609a6.485 6.485 0 0 1 1.747-6.482 6.497 6.497 0 0 1 3.626-1.761c.478-.068 1.49-.049 1.967.037M10.24 6.037a1.075 1.075 0 0 0-.658.572A1.256 1.256 0 0 0 9.52 7c0 .286.076.49.252.674.21.218.396.29.856.33.857.075 1.491.435 1.949 1.109.245.36.374.748.419 1.259.041.473.117.664.349.867.205.18.37.241.655.241.285 0 .45-.061.655-.241.307-.27.379-.552.307-1.213a4.486 4.486 0 0 0-3.902-3.982c-.405-.049-.672-.052-.82-.007\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/search_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SearchCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SearchCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SearchCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.84 2.025c-2.094.192-3.893 1.025-5.341 2.474-1.306 1.306-2.108 2.887-2.406 4.746-.092.572-.092 1.938 0 2.51.298 1.859 1.1 3.44 2.406 4.746 1.301 1.302 2.893 2.109 4.746 2.406.572.092 1.938.092 2.51 0a8.79 8.79 0 0 0 3.777-1.556l.231-.165 1.909 1.901c1.714 1.709 1.928 1.909 2.105 1.967.812.27 1.546-.45 1.283-1.259-.066-.202-.189-.334-1.972-2.123l-1.902-1.909.165-.231a8.79 8.79 0 0 0 1.556-3.777c.092-.572.092-1.938 0-2.51-.297-1.853-1.104-3.445-2.406-4.746a8.417 8.417 0 0 0-4.726-2.4c-.391-.06-1.587-.106-1.935-.074m1.761 2.07c2.735.497 4.797 2.564 5.309 5.325.092.495.092 1.665 0 2.16-.514 2.771-2.559 4.816-5.33 5.33-.495.092-1.665.092-2.16 0-2.504-.465-4.468-2.22-5.159-4.609a6.485 6.485 0 0 1 1.747-6.482 6.497 6.497 0 0 1 3.626-1.761c.478-.068 1.49-.049 1.967.037\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/send_plane_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SendPlaneCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SendPlaneCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SendPlaneCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.24 3.106c-1.06.135-1.761.296-5.82 1.337-4 1.027-5.141 1.334-6.032 1.623-2.111.686-3.077 1.403-3.494 2.594-.102.291-.112.371-.113.88-.001.476.013.605.092.86.319 1.026.988 1.905 2.881 3.783l1.134 1.126-.054.235c-.039.17-.054.574-.054 1.436 0 1.137.005 1.215.091 1.49a1.45 1.45 0 0 0 1.146 1.007c.562.097.985-.087 1.634-.714l.432-.416.095.076c.052.042.288.248.523.457 1.435 1.272 2.392 1.76 3.45 1.76 1.076 0 1.958-.528 2.669-1.598.315-.473.72-1.283 1.114-2.224.278-.664 2.284-5.814 2.834-7.278.896-2.381 1.136-3.534.928-4.472a2.545 2.545 0 0 0-1.836-1.891c-.237-.064-1.309-.111-1.62-.071m-.27 3.46a.39.39 0 0 1 .07.214c0 .12-.316.445-3.995 4.096a591.557 591.557 0 0 1-4.097 4.045c-.25.196-.63.248-.969.132-.229-.077-.841-.64-1.008-.925-.228-.388-.147-.944.181-1.257.136-.129 9.351-6.348 9.488-6.402.096-.039.254.008.33.097\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/send_plane_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SendPlaneCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SendPlaneCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SendPlaneCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.36 3.083c-.939.087-1.744.271-6.12 1.396-4.7 1.209-5.393 1.399-6.36 1.749C3.764 6.994 2.8 8.03 2.8 9.54c0 .505.096.905.334 1.4.434.901 1.058 1.653 2.765 3.333l1.022 1.005-.045.171c-.025.097-.053.676-.064 1.331-.015.948-.008 1.213.043 1.452.116.552.365.884.825 1.103.227.108.303.124.602.124.317.001.365-.01.654-.153.245-.121.407-.245.747-.574l.434-.42.408.366c1.592 1.43 2.524 1.925 3.635 1.933 1.373.009 2.331-.749 3.235-2.559.415-.831.722-1.593 2.637-6.552 1.538-3.984 1.744-4.664 1.745-5.76 0-.865-.179-1.334-.719-1.887-.261-.267-.384-.359-.66-.491-.538-.258-1.233-.353-2.038-.279m-.241 2.092c-.06.055-9.84 8.139-10.019 8.282l-.097.077-.612-.596C5.292 10.892 4.646 9.986 4.81 9.32c.062-.255.429-.611.855-.831.889-.459 1.604-.669 6.955-2.045 3.923-1.008 5.186-1.313 5.479-1.322.068-.002.07.005.02.053m1.439 1.812c-.183.63-.515 1.529-1.591 4.313-1.786 4.62-2.017 5.194-2.386 5.92-.479.942-.936 1.416-1.367 1.419-.494.004-1.245-.455-2.326-1.42-.528-.471-2.408-2.262-2.407-2.293.001-.014.41-.364.91-.776l5.089-4.205c2.299-1.899 4.185-3.44 4.192-3.423.007.017-.044.226-.114.465\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/settings_1_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Settings1CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Settings1CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Settings1CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.18 1.948c-.276.035-.965.226-1.291.357-.517.207-1.395.674-2.809 1.493C4.725 5.162 4.113 5.64 3.461 6.62c-.547.823-.812 1.561-.95 2.644-.07.554-.07 4.918 0 5.472.109.856.228 1.268.567 1.964.547 1.125 1.26 1.847 2.677 2.714 1.015.621 3.009 1.752 3.546 2.011 1.015.489 1.696.655 2.699.655 1.361 0 2.094-.261 4.32-1.537 2.826-1.621 3.448-2.077 4.125-3.023.266-.371.663-1.151.792-1.555.268-.835.302-1.289.302-3.965s-.034-3.13-.302-3.965c-.129-.405-.527-1.184-.798-1.565-.653-.915-1.239-1.354-3.759-2.811-1.96-1.134-2.829-1.53-3.677-1.675-.35-.059-1.472-.082-1.823-.036m1.401 7.114a3 3 0 0 1 2.357 2.357c.427 2.063-1.456 3.946-3.519 3.519a2.993 2.993 0 0 1-2.357-2.357c-.376-1.817 1.04-3.558 2.91-3.578.171-.002.445.025.609.059\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/settings_1_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Settings1CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Settings1CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Settings1CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.14 2.026c-1.011.145-2.023.628-4.86 2.32-1.101.656-1.546.969-2.023 1.424a5.597 5.597 0 0 0-1.593 2.951C2.546 9.363 2.52 9.946 2.52 12s.026 2.637.144 3.279c.257 1.401 1.11 2.705 2.336 3.569.427.301 1.556.982 2.84 1.712 2.137 1.216 2.818 1.454 4.16 1.454s2.023-.238 4.16-1.454c1.289-.733 2.414-1.411 2.839-1.712 1.229-.869 2.08-2.168 2.337-3.569.118-.642.144-1.225.144-3.279s-.026-2.637-.144-3.279c-.26-1.413-1.094-2.686-2.338-3.569-.696-.494-3.125-1.914-4.2-2.456-1.288-.65-2.392-.852-3.658-.67m1.506 1.991c.702.135 1.291.426 3.614 1.782 1.553.906 2.035 1.259 2.426 1.776.369.488.578.947.696 1.525.135.66.171 4.329.053 5.415-.08.742-.343 1.401-.774 1.942-.404.507-.907.871-2.438 1.764-2.696 1.572-3.264 1.816-4.223 1.816-.95 0-1.518-.242-4.12-1.757-1.977-1.151-2.476-1.558-2.901-2.368-.353-.67-.41-.991-.466-2.602-.053-1.506.002-3.72.104-4.21a3.59 3.59 0 0 1 .697-1.525c.39-.515.876-.871 2.41-1.767 2.335-1.362 2.913-1.65 3.596-1.788a4.212 4.212 0 0 1 1.326-.003m-1.288 4.044a4.013 4.013 0 0 0-2.894 2.099 3.63 3.63 0 0 0-.406 1.235 3.416 3.416 0 0 0 0 1.21c.222 1.469 1.266 2.705 2.697 3.192.295.1.925.203 1.245.203.32 0 .95-.103 1.245-.203.984-.335 1.831-1.058 2.291-1.957a3.63 3.63 0 0 0 .406-1.235 3.416 3.416 0 0 0 0-1.21 3.986 3.986 0 0 0-2.102-2.931 3.63 3.63 0 0 0-1.235-.406 3.728 3.728 0 0 0-1.247.003m1.134 1.999c.36.093.626.25.912.536.296.295.448.561.536.933a2.05 2.05 0 0 1-.169 1.408c-.133.263-.575.703-.841.838a2.047 2.047 0 0 1-1.86 0c-.269-.136-.709-.576-.845-.845a2.047 2.047 0 0 1 0-1.86c.137-.27.576-.709.846-.845a2.078 2.078 0 0 1 1.421-.165\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/settings_7_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Settings7CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Settings7CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Settings7CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.643 2.069a1.066 1.066 0 0 0-.577.591c-.05.148-.066.342-.066.807 0 .609-.001.613-.09.613-.198.001-1.067.208-1.52.362-.264.09-.495.149-.512.131a9.42 9.42 0 0 1-.298-.493c-.307-.53-.408-.648-.651-.765-.213-.104-.599-.112-.818-.019-.391.168-.61.535-.584.982.014.245.037.303.326.803l.312.539-.193.158c-.248.203-.991.946-1.194 1.194l-.158.193-.54-.312c-.515-.298-.553-.313-.82-.325-.243-.012-.309.001-.5.099a.982.982 0 0 0-.357 1.45c.125.173.206.232.807.581.215.125.39.236.39.247 0 .011-.072.233-.161.493-.152.447-.358 1.315-.359 1.512 0 .089-.004.09-.613.09-.73 0-.917.048-1.168.299a.984.984 0 0 0 0 1.402c.251.251.438.299 1.168.299.609 0 .613.001.613.09.001.198.208 1.067.362 1.52.09.264.149.495.131.512a9.42 9.42 0 0 1-.493.298c-.719.418-.858.592-.858 1.08 0 .331.088.529.33.748.205.185.394.244.723.226.248-.015.304-.037.806-.327l.539-.312.158.193c.203.248.946.991 1.194 1.194l.193.158-.312.539c-.289.5-.312.558-.326.803-.026.447.193.814.584.982.219.093.605.085.818-.019.243-.117.344-.235.651-.765a9.42 9.42 0 0 1 .298-.493c.017-.018.248.041.512.131.453.154 1.322.361 1.52.362.089 0 .09.004.09.613 0 .73.048.917.299 1.168a.984.984 0 0 0 1.402 0c.251-.251.299-.438.299-1.168 0-.609.001-.613.09-.613.198-.001 1.067-.208 1.52-.362.264-.09.495-.149.512-.131.017.018.151.24.298.493s.314.51.371.57c.276.292.777.369 1.155.177.375-.19.555-.517.527-.957-.014-.231-.044-.303-.327-.792l-.311-.538.193-.158c.248-.203.991-.946 1.194-1.194l.158-.193.539.312c.502.29.558.312.806.327.329.018.518-.041.723-.226.242-.219.33-.417.33-.748 0-.488-.139-.662-.858-1.08a9.42 9.42 0 0 1-.493-.298c-.018-.017.041-.248.131-.512.154-.453.361-1.322.362-1.52 0-.089.004-.09.613-.09.73 0 .917-.048 1.168-.299a.984.984 0 0 0 0-1.402c-.251-.251-.438-.299-1.168-.299-.609 0-.613-.001-.613-.09-.001-.198-.208-1.067-.362-1.52-.09-.264-.149-.495-.131-.512a9.42 9.42 0 0 1 .493-.298c.719-.418.858-.592.858-1.08 0-.331-.088-.529-.33-.748-.205-.185-.394-.244-.723-.226-.248.015-.304.037-.806.327l-.539.312-.158-.193a14.946 14.946 0 0 0-1.194-1.194l-.193-.158.312-.539c.29-.502.312-.558.327-.806.018-.329-.041-.518-.226-.723-.219-.242-.417-.33-.748-.33-.488 0-.662.139-1.08.858a9.42 9.42 0 0 1-.298.493c-.017.018-.248-.041-.512-.131-.453-.154-1.322-.361-1.52-.362-.089 0-.09-.004-.09-.613 0-.73-.048-.917-.299-1.168a.998.998 0 0 0-1.058-.23M11 8.752v2.672l-2.299 1.328c-1.265.73-2.308 1.328-2.319 1.328-.03 0-.209-.619-.29-1-.108-.514-.098-1.72.019-2.25.515-2.331 2.16-4.059 4.426-4.649.2-.052.385-.096.413-.098.04-.002.05.556.05 2.669m2.529-2.548c1.098.272 2.208.958 2.978 1.841.686.787 1.144 1.71 1.38 2.781.118.536.128 1.741.02 2.254-.082.384-.26 1-.289 1-.011 0-1.054-.598-2.319-1.328L13 11.424V6.078l.11.025c.061.013.249.058.419.101m.779 8.279a171.1 171.1 0 0 1 2.268 1.32c.084.077-.679.803-1.236 1.176a6.242 6.242 0 0 1-2.166.908c-.571.126-1.777.126-2.348 0-1.083-.239-2.007-.7-2.794-1.395-.357-.314-.654-.644-.615-.682.086-.086 4.572-2.646 4.611-2.631.027.009 1.053.596 2.28 1.304\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/share_forward_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ShareForwardCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ShareForwardCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ShareForwardCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.76 3.362c-.841.218-1.534.772-1.885 1.508-.243.509-.329.939-.475 2.37-.046.451-.097.832-.112.846a6.913 6.913 0 0 1-.628.193c-1.565.437-2.781 1.114-3.86 2.148a8.64 8.64 0 0 0-1.813 2.443c-.588 1.163-.887 2.318-.969 3.751-.031.543.023.851.204 1.159.293.501.911.775 1.557.691.332-.042.575-.174 1.053-.569 1.232-1.019 2.612-1.611 4.294-1.84l.186-.026.024.152c.013.084.042.35.066.592.182 1.875.357 2.418.977 3.044.55.555 1.204.85 1.961.884.976.045 1.536-.23 3.58-1.754 2.339-1.744 5.209-4.357 5.865-5.341.712-1.067.712-2.158 0-3.226-.484-.728-2.523-2.675-4.388-4.192-1.437-1.169-3.12-2.371-3.671-2.623-.666-.303-1.332-.374-1.966-.21m.984 1.963c.285.106.928.535 1.957 1.304a39.42 39.42 0 0 1 4.423 3.811c1.592 1.585 1.592 1.535.02 3.1-1.712 1.704-3.738 3.393-5.611 4.676-.805.551-1.034.609-1.491.376-.428-.217-.497-.422-.643-1.89-.134-1.337-.224-1.616-.669-2.06a1.972 1.972 0 0 0-1.63-.581c-.355.036-1.155.175-1.52.264-.994.242-2.278.798-3.075 1.332-.218.146-.404.257-.415.247-.01-.011.021-.204.069-.431.434-2.024 1.761-3.791 3.526-4.694.553-.283.787-.37 1.654-.618.386-.11.779-.238.872-.285.485-.244.842-.659.989-1.151.047-.158.12-.668.18-1.26.154-1.511.233-1.807.543-2.012.131-.087.429-.197.565-.21.016-.002.131.035.256.082\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/shuffle_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Shuffle2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Shuffle2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Shuffle2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M17.102 4.566c-.284.192-.381.4-.407.878-.032.599.029.54-.584.572-1.382.073-2.475.552-3.414 1.496-.308.309-.791.993-1.042 1.476-.172.329-.185.643-.039.957.223.478.82.676 1.329.439.23-.107.292-.181.677-.804.377-.609.771-.994 1.268-1.238.484-.237.75-.304 1.32-.331l.47-.022v.3c0 .602.121.901.443 1.09.247.145.482.106.96-.158a19.55 19.55 0 0 0 1.678-1.056c.869-.605 1.039-.807 1.039-1.241 0-.416-.072-.52-.66-.961a17.937 17.937 0 0 0-1.789-1.171c-.51-.293-.597-.33-.811-.344-.219-.014-.257-.004-.438.118M3.673 6.063C3.31 6.175 3 6.606 3 7c0 .409.313.832.693.937.147.04.533.063 1.327.077 1.283.024 1.533.062 2.052.311.466.223.844.592 1.269 1.235.181.275.997 1.61 1.812 2.967 2 3.33 2.213 3.65 2.781 4.182.837.783 1.798 1.165 3.123 1.244.298.017.554.044.569.06.015.015.043.244.06.508.034.492.08.647.249.833.222.244.569.276.944.086.721-.363 2.234-1.36 2.635-1.735a.83.83 0 0 0 .286-.671c0-.382-.094-.536-.532-.865-.979-.737-2.271-1.532-2.617-1.611-.324-.073-.736.159-.871.491-.038.094-.08.342-.094.551l-.026.38-.4-.013c-1.201-.038-1.881-.437-2.601-1.527-.181-.275-1-1.616-1.819-2.98-1.66-2.764-2.024-3.335-2.427-3.799a4.52 4.52 0 0 0-2.52-1.498c-.764-.156-2.83-.22-3.22-.1m5.382 7.553c-.23.107-.292.181-.677.804-.377.609-.771.994-1.268 1.238-.609.299-.686.311-2.11.339-1.413.028-1.432.031-1.713.321-.385.396-.376.987.02 1.383.297.296.404.313 1.871.288 1.142-.02 1.276-.03 1.662-.121a4.95 4.95 0 0 0 3.07-2.143c.47-.71.57-.925.57-1.218-.001-.425-.189-.721-.588-.922-.196-.1-.588-.085-.837.031\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/social_x_cute_li.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SocialXCuteLiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SocialXCuteLiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SocialXCuteLiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.628 2.82c-.813.223-1.371 1.106-1.232 1.947.025.153.098.381.161.506.072.141 1.31 1.677 3.261 4.044 1.73 2.1 3.136 3.841 3.126 3.868-.011.028-1.258 1.466-2.772 3.195-1.514 1.73-2.785 3.21-2.826 3.289a.766.766 0 0 0 .079.76.985.985 0 0 0 .258.221c.175.089.473.09.658.001.104-.049.917-.951 2.868-3.181 1.498-1.712 2.736-3.108 2.752-3.101.016.006 1.05 1.253 2.298 2.771 1.249 1.518 2.371 2.865 2.496 2.993.474.49 1.209.888 1.904 1.031.516.107 1.398.105 1.747-.003 1.015-.315 1.516-1.492 1.037-2.434-.072-.141-1.307-1.673-3.261-4.044-1.73-2.099-3.136-3.84-3.126-3.868.011-.028 1.279-1.489 2.817-3.247 1.966-2.247 2.812-3.241 2.847-3.347.153-.466-.21-.981-.692-.981-.367 0-.283-.086-3.216 3.266-1.509 1.725-2.758 3.132-2.774 3.125a208.81 208.81 0 0 1-2.299-2.771c-1.248-1.518-2.37-2.865-2.494-2.993-.472-.489-1.208-.888-1.904-1.031-.436-.09-1.411-.099-1.713-.016m1.657 1.548c.314.117.584.27.783.444.11.097 11.513 13.902 11.963 14.483.112.145.113.281.004.39-.08.08-.114.084-.57.065-.383-.016-.542-.041-.755-.12a2.41 2.41 0 0 1-.885-.548C16.69 18.943 6.498 6.588 5.149 4.929c-.298-.367-.34-.513-.179-.63.143-.104.962-.061 1.315.069\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/social_x_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SocialXCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SocialXCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SocialXCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.471 2.614c-1.21.412-1.725 1.875-1.037 2.948.063.098 1.485 1.842 3.16 3.875a396.456 396.456 0 0 1 3.045 3.72c0 .013-1.24 1.44-2.756 3.172-3.027 3.46-2.918 3.316-2.871 3.778a1 1 0 0 0 1.084.884c.416-.047.353.015 3.151-3.18a744.417 744.417 0 0 0 2.654-3.041c.032-.042.561.577 2.327 2.721 1.257 1.527 2.382 2.862 2.499 2.968.455.409 1.07.746 1.662.91.271.075.418.086 1.151.089.792.002.853-.003 1.073-.092 1.099-.443 1.582-1.765 1.025-2.801-.066-.124-1.506-1.907-3.199-3.962a389.162 389.162 0 0 1-3.078-3.76c0-.013 1.24-1.44 2.755-3.171C20.021 4.352 20 4.379 20 4c0-.568-.535-1.052-1.09-.986-.431.051-.353-.028-3.157 3.175a744.417 744.417 0 0 0-2.654 3.041c-.032.042-.562-.577-2.329-2.723-1.258-1.528-2.391-2.871-2.518-2.986a4.674 4.674 0 0 0-1.168-.725c-.49-.204-.926-.275-1.684-.274-.582 0-.692.011-.929.092M6.133 4.58c.32.096.642.292.875.532.26.268 11.787 14.27 11.822 14.359.02.054-.027.058-.374.034-.66-.045-1.082-.223-1.464-.617C16.729 18.616 5.206 4.621 5.171 4.53c-.022-.055.022-.059.373-.035.219.015.484.053.589.085\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/sort_ascending_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SortAscendingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SortAscendingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SortAscendingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M17.669 3.281c-.206.052-.428.17-.832.441a7.807 7.807 0 0 0-2.195 2.238c-.464.705-.534.921-.424 1.3.151.518.704.84 1.21.703.272-.073.481-.267.753-.701.137-.219.373-.552.524-.74l.275-.342.02 6.54c.022 7.33-.008 6.677.321 6.997.169.163.456.283.679.283.223 0 .51-.12.679-.283.329-.32.299.333.321-6.997l.02-6.54.275.342c.151.188.387.521.524.74.364.58.665.777 1.108.725.542-.064.964-.59.889-1.107-.079-.537-1.055-1.866-1.896-2.579-.432-.366-1.048-.792-1.335-.924-.249-.114-.67-.158-.916-.096m-13.996.782C3.31 4.175 3 4.606 3 5c0 .405.309.826.69.939.308.092 7.312.092 7.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.298-.089-7.349-.087-7.637.002m0 7C3.31 11.175 3 11.606 3 12c0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002m0 7C3.31 18.175 3 18.606 3 19c0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/sort_descending_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface SortDescendingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const SortDescendingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: SortDescendingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M3.673 4.063C3.31 4.175 3 4.606 3 5c0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002m14.002 0c-.215.066-.49.314-.588.53-.061.135-.069.698-.087 6.687l-.02 6.54-.275-.342a9.344 9.344 0 0 1-.524-.74c-.365-.582-.665-.777-1.112-.724a1.008 1.008 0 0 0-.853.735c-.108.369-.035.591.426 1.291a7.672 7.672 0 0 0 2.164 2.214c.585.404.783.484 1.194.484.411 0 .609-.08 1.194-.484a7.716 7.716 0 0 0 2.113-2.134c.489-.733.584-1.014.472-1.392-.151-.508-.707-.826-1.207-.691-.272.073-.481.267-.753.701a9.344 9.344 0 0 1-.524.74l-.275.342-.02-6.54c-.022-7.33.008-6.677-.321-6.997-.267-.259-.625-.337-1.004-.22m-14.002 7C3.31 11.175 3 11.606 3 12c0 .405.309.826.69.939.178.053.803.061 4.81.061s4.632-.008 4.81-.061c.378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.299-.089-9.348-.087-9.637.002m0 7C3.31 18.175 3 18.606 3 19c0 .405.309.826.69.939.308.092 7.312.092 7.62 0 .378-.112.69-.537.69-.939 0-.402-.312-.827-.69-.939-.298-.089-7.349-.087-7.637.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/star_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface StarCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const StarCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: StarCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.369 2.281c-.907.237-1.738.996-3.262 2.977-.683.888-.722.917-1.67 1.257-.913.327-1.817.692-2.337.943-1.433.693-2.072 1.579-2.008 2.782.046.866.493 1.775 1.887 3.84.573.85.575.855.624 2.172.097 2.603.315 3.546.984 4.268.271.294.638.528 1.042.666.416.143 1.076.172 1.729.076.524-.077 1.959-.428 2.734-.67.462-.144.575-.164.908-.164.333 0 .446.02.908.164.775.242 2.21.593 2.734.67.658.097 1.313.067 1.736-.078a2.545 2.545 0 0 0 1.458-1.28c.353-.724.477-1.533.564-3.704.021-.539.055-1.043.074-1.12.064-.259.152-.417.612-1.1 1.347-2.001 1.776-2.883 1.822-3.74.064-1.203-.575-2.089-2.008-2.782-.52-.251-1.424-.616-2.337-.943-.926-.332-1.011-.393-1.574-1.135-.824-1.087-1.715-2.091-2.198-2.478-.785-.628-1.6-.837-2.422-.621\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/star_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface StarCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const StarCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: StarCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.42 2.264c-.356.086-.84.342-1.215.642-.4.32-.899.868-1.834 2.014-1.039 1.274-.946 1.193-1.75 1.506-1.439.56-2.098.831-2.561 1.054-1.394.669-2.053 1.599-1.968 2.777.061.842.354 1.453 1.7 3.543.319.495.613.975.654 1.067.056.124.085.335.115.82.099 1.642.127 2.048.182 2.553.078.728.185 1.141.423 1.635.556 1.153 1.621 1.615 3.189 1.382.234-.035.992-.208 1.685-.385.693-.177 1.418-.359 1.61-.405l.35-.083.35.083c.193.046.917.228 1.61.405.693.177 1.451.35 1.685.385 1.203.178 2.058-.036 2.699-.678.239-.238.324-.361.488-.704.365-.762.439-1.246.587-3.844.068-1.197.016-1.037.66-2.031 1.565-2.414 1.818-2.959 1.82-3.92.001-.38-.016-.511-.096-.755-.185-.567-.614-1.087-1.237-1.501-.454-.302-1.104-.587-3.187-1.398-.804-.313-.711-.232-1.75-1.506-.935-1.146-1.434-1.694-1.834-2.014-.384-.307-.863-.557-1.235-.644a2.897 2.897 0 0 0-1.14.002m.86 2.016c.329.168.886.768 2.08 2.24.801.988 1.021 1.192 1.568 1.455.249.119.677.301.952.405 1.438.54 2.416.97 2.713 1.193.21.158.327.342.327.516 0 .361-.389 1.092-1.441 2.711-.25.385-.55.881-.665 1.102-.268.509-.313.761-.394 2.178-.13 2.283-.215 2.814-.501 3.1-.112.111-.145.121-.458.134-.416.017-.902-.079-2.504-.497-1.269-.331-1.672-.417-1.956-.417-.288 0-.741.096-1.911.405-1.565.413-2.116.524-2.537.508-.305-.011-.36-.025-.455-.113-.294-.273-.393-.863-.519-3.094-.034-.613-.089-1.266-.121-1.452-.07-.401-.262-.805-.72-1.514-1.819-2.814-1.937-3.132-1.33-3.567.355-.254.711-.409 3.032-1.321 1.06-.417 1.264-.577 2.2-1.732 1.768-2.181 2.139-2.495 2.64-2.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/stop_circle_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface StopCircleCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const StopCircleCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: StopCircleCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m2.04 5.496c.999.058 1.521.219 2.025.624.629.506.937 1.043 1.083 1.889.089.516.101 3.268.017 3.827-.132.873-.431 1.435-1.035 1.942-.434.364-.878.546-1.57.642-.524.074-3.156.074-3.68 0-.878-.122-1.449-.423-1.962-1.034-.536-.638-.663-1.156-.703-2.85-.026-1.139.026-2.326.121-2.76.128-.588.354-.999.778-1.418.59-.582 1.14-.797 2.206-.86a27.728 27.728 0 0 1 2.72-.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/telegram_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TelegramCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TelegramCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TelegramCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.221 4.525c-.587.085-1.448.357-2.636.832-1.051.421-8.787 3.649-9.561 3.99-2.222.981-3.27 1.694-3.691 2.513-.222.431-.267.665-.244 1.244.023.569.119.869.403 1.267.218.305.463.532.84.778.365.238 1.191.633 1.488.711.347.092.806.085 1.112-.017.719-.24 2.292-1.28 5.105-3.375 2.768-2.063 3.636-2.637 4.074-2.696.136-.018.168-.008.207.064.069.13-.057.368-.406.764-.311.353-.588.616-2.232 2.122-1.764 1.616-2.971 2.873-3.201 3.333-.22.44-.143.918.214 1.329.139.159.433.369 2.767 1.976 1.994 1.373 2.881 1.888 3.58 2.079.981.269 1.83.06 2.502-.615.251-.252.365-.406.512-.693.385-.753.571-1.506 1.023-4.151.653-3.824 1.085-6.441 1.161-7.027.223-1.735.12-2.69-.369-3.422-.541-.808-1.502-1.173-2.648-1.006\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/telegram_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TelegramCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TelegramCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TelegramCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.34 4.467c-.595.075-1.434.325-2.518.751-.735.289-7.532 3.116-9.11 3.789-1.28.546-2.64 1.218-3.152 1.558-.441.293-1.055.886-1.215 1.175-.228.408-.271.571-.293 1.1-.035.868.127 1.3.706 1.884.784.789 1.926 1.262 5.742 2.377.506.148.99.304 1.075.347.085.043.886.584 1.78 1.203 3.665 2.535 4.219 2.839 5.345 2.93.999.081 1.908-.483 2.419-1.501.411-.819.536-1.362 1.177-5.14.945-5.561 1.043-6.204 1.089-7.131.045-.881-.092-1.635-.389-2.156-.5-.876-1.516-1.329-2.656-1.186m.86 2.12c.219.245.254.802.119 1.895-.115.931-.178 1.323-.762 4.778-.74 4.372-.878 5.074-1.12 5.702-.156.403-.366.638-.57.638-.355 0-.846-.231-1.814-.854a154.94 154.94 0 0 1-3.367-2.297l-.135-.1 2.141-2.144c1.177-1.18 2.173-2.208 2.213-2.285.106-.204.1-.649-.013-.86a1.113 1.113 0 0 0-.501-.478c-.206-.086-.612-.079-.811.015-.108.051-.919.835-2.5 2.419-1.287 1.289-2.361 2.344-2.387 2.344-.085 0-2.935-.868-3.553-1.082-1.712-.593-2.296-1.005-2.098-1.483.151-.364 1.206-.98 3.04-1.775.876-.379 8.136-3.404 8.967-3.735 1.645-.657 2.285-.851 2.745-.834.273.01.303.02.406.136\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/thought_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ThoughtCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ThoughtCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ThoughtCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M12.367 2.92a4.433 4.433 0 0 0-2.472 1.171c-.507.481-.788.874-1.062 1.488-.091.205-.128.246-.24.275-1.009.255-1.687.625-2.313 1.264a4.463 4.463 0 0 0-.888 4.917c.314.71.834 1.345 1.518 1.851.48.356.804.679 1.177 1.175.661.878 1.304 1.363 2.233 1.689.513.179.837.23 1.46.229.608-.002.801-.033 1.503-.244.936-.28 1.089-.3 2.557-.334 1.274-.03 1.358-.036 1.712-.136a4.475 4.475 0 0 0 3.257-3.805c.043-.374.056-.412.196-.58.225-.269.537-.808.675-1.166.232-.602.295-.949.296-1.634 0-.549-.012-.671-.111-1.06-.404-1.59-1.611-2.827-3.164-3.243a5.672 5.672 0 0 0-.921-.14c-.971-.074-1.375-.218-1.997-.71-.613-.486-1.271-.804-1.949-.943-.382-.079-1.131-.111-1.467-.064M4.46 16.044c-.96.135-1.846.728-2.218 1.486-.757 1.54.385 3.206 2.355 3.437 1.65.194 3.17-.78 3.371-2.158.245-1.68-1.492-3.049-3.508-2.765\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/time_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TimeCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TimeCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TimeCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.08 2.045c-1.874.165-3.723.904-5.28 2.109-.437.339-1.307 1.209-1.646 1.646-1.8 2.326-2.505 5.195-1.976 8.046.29 1.566.959 3.04 1.976 4.354.339.437 1.209 1.307 1.646 1.646 2.441 1.889 5.453 2.566 8.44 1.895 2.487-.559 4.752-2.144 6.145-4.301.806-1.247 1.283-2.527 1.521-4.08.098-.641.098-2.079 0-2.72-.285-1.858-.936-3.388-2.06-4.84-.339-.437-1.209-1.307-1.646-1.646-2.067-1.599-4.554-2.336-7.12-2.109m1.752 1.997a8.182 8.182 0 0 1 4.208 1.747c.354.286 1.027.972 1.286 1.311A8.123 8.123 0 0 1 20 12a8.1 8.1 0 0 1-1.789 5.04c-.286.354-.972 1.027-1.311 1.286A8.123 8.123 0 0 1 12 20a8.123 8.123 0 0 1-4.9-1.674c-.339-.259-1.025-.932-1.311-1.286A7.99 7.99 0 0 1 4.8 8.529a7.375 7.375 0 0 1 1.459-2.083 7.632 7.632 0 0 1 2.267-1.645 8.025 8.025 0 0 1 4.306-.759m-1.159 2.021a.93.93 0 0 0-.369.24c-.305.305-.304.292-.304 2.634.001 2.587.029 2.813.464 3.663.257.502.564.856 1.842 2.126 1.084 1.076 1.167 1.149 1.371 1.21.27.08.374.08.633.003.279-.083.546-.35.629-.629.077-.259.077-.363-.003-.632-.061-.204-.14-.293-1.332-1.498-.882-.892-1.299-1.342-1.374-1.483-.219-.413-.229-.535-.229-2.76-.001-2.341 0-2.329-.304-2.634-.279-.279-.63-.361-1.024-.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/tool_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface ToolCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ToolCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: ToolCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"m8.64 2.425-.454.078c-.448.074-.746.214-1.031.486-.648.616-.717 1.624-.167 2.422.057.082.713.71 1.457 1.394 1.076.989 1.362 1.273 1.391 1.381.029.102.014.254-.061.624a4.643 4.643 0 0 1-.147.585c-.087.178-.233.243-.792.353-.467.092-.561.1-.666.056-.075-.031-.599-.569-1.352-1.388C5.969 7.493 5.515 7.03 5.35 6.92c-1.057-.706-2.476-.197-2.784 1-.205.799-.207 2.004-.006 2.929a7.033 7.033 0 0 0 3.683 4.775 6.929 6.929 0 0 0 3.654.733c.478-.036 1.193-.157 1.406-.238a.586.586 0 0 1 .137-.039c.019 0 .92 1.045 2.001 2.323 1.518 1.792 2.036 2.377 2.269 2.562.77.612 1.791.911 2.73.798.932-.112 1.668-.463 2.287-1.09.741-.751 1.1-1.682 1.061-2.753-.033-.937-.303-1.643-.898-2.351-.098-.116-1.213-1.087-2.478-2.157-2.25-1.905-2.3-1.949-2.277-2.069.012-.068.069-.339.125-.603.375-1.753-.016-3.708-1.05-5.26-.43-.645-1.253-1.468-1.89-1.89a7.287 7.287 0 0 0-2.56-1.072c-.331-.069-.59-.09-1.22-.1-.44-.006-.845-.003-.9.007m1.64 2.041c1.764.308 3.288 1.613 3.85 3.294.21.629.25.893.249 1.64-.002.65-.011.738-.13 1.225-.22.898-.149 1.421.279 2.046.071.103 1.035.948 2.427 2.126 1.27 1.075 2.367 2.02 2.437 2.1.271.309.417.715.413 1.151-.008.829-.527 1.477-1.355 1.693a1.719 1.719 0 0 1-1.505-.347c-.084-.07-1.032-1.165-2.105-2.433-1.073-1.267-2.012-2.351-2.086-2.406-.589-.445-1.217-.547-2.074-.336-.648.159-1.162.204-1.696.148-1.055-.11-1.894-.446-2.706-1.086-1.128-.888-1.796-2.216-1.864-3.701-.013-.286-.008-.577.011-.647l.035-.127 1.036 1.127c.57.62 1.135 1.212 1.256 1.315.255.218.691.441 1.008.516.533.126 1.639-.009 2.36-.288.532-.205.968-.597 1.262-1.132.164-.298.215-.47.358-1.194.108-.549.127-1.017.056-1.358-.063-.299-.317-.815-.522-1.059-.096-.114-.678-.67-1.294-1.235a41.618 41.618 0 0 1-1.133-1.061c-.032-.084.896-.065 1.433.029\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/train_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TrainCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TrainCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TrainCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.66 5.026c-1.137.062-1.794.25-2.438.695-.32.222-.805.703-1.016 1.01a4.347 4.347 0 0 0-.523 1.157C.52 8.526.506 8.783.505 11c-.001 1.87.008 2.195.071 2.578.164 1.005.494 1.654 1.171 2.307.671.647 1.401.962 2.487 1.073.574.058 13.472.058 14.208-.001 1.361-.108 2.426-.401 3.284-.902.234-.137.46-.32.756-.615.379-.377.444-.463.631-.84.309-.623.348-.808.344-1.64-.003-.624-.015-.741-.108-1.08-.767-2.797-3.873-5.382-7.689-6.399a15.548 15.548 0 0 0-1.88-.369c-.579-.082-.748-.086-4.74-.092a396.904 396.904 0 0 0-4.38.006M7 8.5V10H2.486l.016-.21c.009-.115.024-.408.034-.65.051-1.247.684-1.958 1.865-2.095.164-.019.817-.037 1.449-.04L7 7v1.5m5.79-1.474.21.025V10H9V7h1.79c.985.001 1.885.012 2 .026m3.41.721c1.236.446 2.585 1.239 3.382 1.988l.278.262-2.43.001L15 10V7.37l.35.098c.193.055.575.18.85.279M1.24 18.037a1.1 1.1 0 0 0-.665.589c-.071.169-.071.579 0 .748.078.188.291.416.481.518l.164.088h20.56l.164-.088c.19-.102.403-.33.481-.518.071-.169.071-.579 0-.748a1.21 1.21 0 0 0-.481-.518l-.164-.088-10.22-.007c-5.621-.003-10.265.007-10.32.024\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/translate_2_ai_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Translate2AiCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Translate2AiCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Translate2AiCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.726 1.046c-.191.055-.423.21-.521.347a3.981 3.981 0 0 0-.258.58c-.219.581-.377.844-.714 1.19a2.854 2.854 0 0 1-1.087.718l-.501.191c-.548.212-.786.857-.515 1.399.13.26.308.385.812.57.573.21.88.392 1.222.726.336.328.555.659.717 1.087l.191.501c.154.396.608.68 1.014.632.258-.031.535-.171.677-.343.062-.076.187-.34.278-.586.21-.573.392-.88.726-1.222a2.86 2.86 0 0 1 1.087-.717l.501-.191c.548-.212.786-.857.515-1.399-.13-.26-.308-.385-.812-.57-.573-.21-.88-.392-1.222-.726a2.86 2.86 0 0 1-.717-1.087l-.191-.501a1.018 1.018 0 0 0-1.202-.599M8.673 3.063c-.244.075-.523.351-.609.603-.047.138-.064.341-.064.761V5H5.947c-2.349 0-2.34-.001-2.641.3a.96.96 0 0 0 0 1.4c.314.314.127.3 4.101.3 3.22 0 3.513.005 3.513.065 0 .149-.218 1.021-.348 1.394-.285.818-.892 1.927-1.409 2.575l-.171.213-.257-.349A9.527 9.527 0 0 1 7.579 8.86c-.186-.442-.317-.615-.553-.733-.552-.275-1.21-.025-1.4.533-.097.284-.082.462.07.852.387.992.953 1.976 1.637 2.842l.263.334-.172.148c-.863.747-2.316 1.621-3.528 2.122-.475.196-.642.323-.773.585a.989.989 0 0 0 .82 1.449c.252.021.632-.121 1.637-.613 1.194-.583 2.298-1.298 3.168-2.051l.248-.214.272.231a17.26 17.26 0 0 0 1.627 1.188c.217.135.402.265.411.288.009.024-.049.222-.129.441-.48 1.319-1.129 3.357-1.164 3.658a.972.972 0 0 0 .285.78.987.987 0 0 0 1.404.001c.158-.159.19-.228.359-.768.102-.326.284-.895.406-1.263l.22-.67h5.626l.22.67c.122.368.304.937.406 1.263.169.54.201.609.359.768a.988.988 0 0 0 1.042.233c.412-.141.704-.612.645-1.042-.053-.387-.884-2.912-1.413-4.292-.731-1.907-1.718-4.047-2.406-5.215-.373-.632-.969-.985-1.666-.985-.701 0-1.291.351-1.673.996-.285.48-1.005 1.917-1.385 2.764a26.31 26.31 0 0 1-.346.756c-.05.056-1.688-1.164-1.666-1.242.006-.02.118-.171.249-.337 1.136-1.429 1.994-3.352 2.206-4.947.047-.353.06-.39.136-.39.047 0 .177-.027.289-.061.378-.112.69-.537.69-.939 0-.237-.12-.514-.303-.697C13.401 5.007 13.35 5 11.553 5H10v-.553c0-.696-.052-.893-.303-1.144-.279-.279-.63-.361-1.024-.24m10.715 1.54.403.404-.182.166c-.224.206-.23.212-.436.436l-.166.182-.396-.396L18.215 5l.383-.394c.21-.216.383-.396.384-.4.001-.003.184.175.406.397m-3.371 7.876c.605 1.197 1.543 3.319 1.543 3.491 0 .017-.918.03-2.04.03-1.122 0-2.04-.006-2.04-.012 0-.007.036-.087.08-.179a.803.803 0 0 0 .08-.269c0-.244 1.112-2.699 1.711-3.775.131-.236.146-.25.197-.181.03.041.241.444.469.895\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/translate_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Translate2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Translate2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Translate2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.673 3.063c-.244.075-.523.351-.609.603-.047.138-.064.341-.064.761V5H5.947c-2.349 0-2.34-.001-2.641.3a.96.96 0 0 0 0 1.4c.314.314.127.3 4.101.3 3.22 0 3.513.005 3.513.065 0 .149-.218 1.021-.348 1.394-.285.818-.892 1.927-1.409 2.575l-.171.213-.257-.349A9.527 9.527 0 0 1 7.579 8.86c-.186-.442-.317-.615-.553-.733-.552-.275-1.21-.025-1.4.533-.097.284-.082.462.07.852.387.992.953 1.976 1.637 2.842l.263.334-.172.148c-.863.747-2.316 1.621-3.528 2.122-.475.196-.642.323-.773.585a.989.989 0 0 0 .82 1.449c.252.021.632-.121 1.637-.613 1.194-.583 2.298-1.298 3.168-2.051l.248-.214.272.231a17.26 17.26 0 0 0 1.627 1.188c.217.135.402.265.411.288.009.024-.049.222-.129.441-.48 1.319-1.129 3.357-1.164 3.658a.972.972 0 0 0 .285.78.987.987 0 0 0 1.404.001c.158-.159.19-.228.359-.768.102-.326.284-.895.406-1.263l.22-.67h5.626l.22.67c.122.368.304.937.406 1.263.169.54.201.609.359.768a.988.988 0 0 0 1.042.233c.412-.141.704-.612.645-1.042-.053-.387-.884-2.912-1.413-4.292-.731-1.907-1.718-4.047-2.406-5.215-.373-.632-.969-.985-1.666-.985-.701 0-1.291.351-1.673.996-.285.48-1.005 1.917-1.385 2.764a26.31 26.31 0 0 1-.346.756c-.05.056-1.688-1.164-1.666-1.242.006-.02.118-.171.249-.337 1.136-1.429 1.994-3.352 2.206-4.947l.052-.39h.584c.731 0 .922-.049 1.176-.303.183-.183.303-.46.303-.697 0-.237-.12-.514-.303-.697-.305-.305-.292-.303-2.644-.303H10v-.553c0-.696-.052-.893-.303-1.144-.279-.279-.63-.361-1.024-.24m7.344 9.416c.605 1.197 1.543 3.319 1.543 3.491 0 .017-.918.03-2.04.03-1.122 0-2.04-.006-2.04-.012 0-.007.036-.087.08-.179a.803.803 0 0 0 .08-.269c0-.244 1.112-2.699 1.711-3.775.131-.236.146-.25.197-.181.03.041.241.444.469.895\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/trending_up_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TrendingUpCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TrendingUpCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TrendingUpCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M18.74 5.589c-.736.029-1.871.322-2.31.598-.395.248-.538.823-.313 1.259.122.234.21.323.431.434.317.158.501.152 1.039-.034.474-.163 1.129-.302 1.317-.278.087.01-.192.303-2.112 2.216-1.643 1.637-2.259 2.226-2.395 2.29a.951.951 0 0 1-.79-.001c-.116-.056-.511-.41-1.123-1.007-1.253-1.223-1.59-1.466-2.287-1.653-.347-.093-1.047-.093-1.397 0a3.41 3.41 0 0 0-1.08.529c-.143.106-1.459 1.391-2.925 2.855-2.429 2.427-2.671 2.68-2.73 2.86A1.353 1.353 0 0 0 2 16c0 .405.309.826.69.939.259.077.363.077.633-.003.21-.062.309-.156 2.917-2.753 1.782-1.774 2.754-2.715 2.86-2.766.215-.103.587-.103.798.001.087.043.555.467 1.04.941 1.27 1.241 1.539 1.449 2.162 1.667.293.102.368.112.9.112s.607-.01.9-.112a3.328 3.328 0 0 0 1.035-.598c.135-.111 1.204-1.156 2.375-2.323 1.949-1.942 2.13-2.112 2.13-1.999 0 .254-.126.831-.28 1.285-.175.513-.191.664-.099.934.124.364.466.628.862.666.33.032.723-.154.89-.421.198-.317.424-1.03.526-1.657.071-.436.08-1.294.018-1.738-.106-.76-.371-1.493-.653-1.805-.43-.476-1.764-.827-2.964-.781\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/trophy_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TrophyCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TrophyCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TrophyCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M9.54 3.002c-.886.025-1.044.04-1.385.13-1.309.344-2.378 1.337-2.791 2.593l-.087.266-.669.019c-.55.016-.71.034-.905.105-.658.238-1.106.688-1.335 1.341-.16.458-.14.836.106 2.029.112.542.246 1.101.297 1.242a3.462 3.462 0 0 0 2.412 2.163l.363.086.133.242c1.032 1.885 2.822 3.214 4.887 3.629l.434.087V19H9.427c-1.789 0-1.836.006-2.128.299a.984.984 0 0 0 0 1.402c.317.317.038.299 4.701.299 4.663 0 4.384.018 4.701-.299.18-.18.299-.459.299-.701 0-.242-.119-.521-.299-.701-.292-.293-.339-.299-2.128-.299H13v-2.066l.43-.085a7.216 7.216 0 0 0 4.845-3.554l.175-.318.365-.087a3.457 3.457 0 0 0 2.414-2.163c.051-.141.184-.7.296-1.242.246-1.196.266-1.575.107-2.029a2.125 2.125 0 0 0-1.335-1.341c-.195-.071-.355-.089-.903-.105l-.666-.019-.126-.351a4.017 4.017 0 0 0-2.757-2.507c-.347-.09-.503-.103-1.565-.134a93.683 93.683 0 0 0-4.74.003M4.986 8.013c.004.004-.02.214-.053.467s-.072.793-.086 1.2l-.027.74-.124-.26c-.17-.353-.242-.64-.355-1.406-.1-.683-.095-.732.072-.768.074-.016.553.007.573.027m14.72.036c.042.042.033.173-.045.698-.119.791-.19 1.077-.354 1.413l-.127.26-.025-.74a11.38 11.38 0 0 0-.137-1.547c-.019-.119-.012-.129.111-.146.204-.029.521.006.577.062\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/trophy_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TrophyCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TrophyCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TrophyCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M8.498 3.058c-1.24.193-2.318.983-2.906 2.129a4.429 4.429 0 0 0-.252.586l-.063.218-.669.019c-.55.016-.71.034-.905.105-.658.238-1.107.689-1.334 1.341-.158.452-.142.812.088 1.943.105.516.227 1.056.272 1.2.219.71.71 1.373 1.335 1.804.359.247 1.147.557 1.419.557.031 0 .11.102.175.226.198.379.64 1.015.96 1.383.992 1.139 2.433 1.972 3.948 2.281l.434.089V19H9.447c-1.794 0-1.848.008-2.141.3a.96.96 0 0 0 0 1.4c.317.316.059.3 4.694.3 4.641 0 4.377.017 4.697-.303.183-.183.303-.46.303-.697 0-.237-.12-.514-.303-.697-.296-.296-.347-.303-2.144-.303H13v-2.061l.434-.089a7.164 7.164 0 0 0 4.792-3.473c.129-.228.26-.415.291-.416.157-.003.619-.147.917-.285a3.396 3.396 0 0 0 1.837-2.077c.045-.144.167-.684.272-1.2.23-1.131.246-1.491.088-1.943-.227-.652-.676-1.103-1.334-1.341-.195-.071-.355-.089-.905-.105l-.669-.019-.063-.218a4.429 4.429 0 0 0-.252-.586c-.594-1.159-1.661-1.935-2.928-2.129-.513-.078-6.479-.078-6.982 0m6.813 2.002c.379.09.64.242.951.556.296.298.45.566.532.924.052.23.315 2.293.363 2.86.208 2.413-1.315 4.643-3.677 5.385-.328.103-1.083.215-1.449.215-3.037 0-5.446-2.6-5.188-5.6.048-.567.311-2.63.363-2.86.082-.358.236-.626.532-.924.305-.308.572-.466.933-.554.35-.085 6.28-.086 6.64-.002M4.981 8.13a12.464 12.464 0 0 0-.143 1.709l-.002.581-.104-.2c-.14-.269-.515-2.084-.447-2.166.031-.038.147-.054.382-.054h.337l-.023.13m14.734-.076c.068.082-.307 1.897-.447 2.166l-.104.2-.002-.581a12.464 12.464 0 0 0-.143-1.709L18.996 8h.337c.235 0 .351.016.382.054\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/twitter_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface TwitterCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const TwitterCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: TwitterCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M15.192 3.603c-.828.102-1.775.455-2.389.889-.35.247-.843.745-1.083 1.092-.491.71-.857 1.696-.979 2.638-.04.309-.031.305-.474.238A7.133 7.133 0 0 1 8.1 7.776c-.62-.307-1.1-.642-1.687-1.178-.651-.596-1.097-.798-1.751-.798-.568.001-1.006.177-1.383.555-.331.331-.467.64-.581 1.313-.146.859-.191 1.553-.167 2.544.047 1.866.456 3.268 1.342 4.592.638.955 1.379 1.644 2.476 2.302l.414.249-.292.17c-.421.247-.995.491-1.775.755-.377.128-.737.259-.8.293-.178.093-.402.346-.503.567-.128.28-.129.727-.003.982.169.344.47.567.91.675 2.908.717 5.786.829 8.302.323 1.649-.331 3.089-.964 4.147-1.824a12.847 12.847 0 0 0 1.43-1.436c1.279-1.571 2.024-3.513 2.284-5.96a23.92 23.92 0 0 0 .079-1.7l.016-1.1.178-.36c.098-.198.37-.636.604-.973.58-.832.638-.965.639-1.447.001-.329-.013-.41-.106-.609-.325-.693-1.02-1.025-1.798-.858-.298.064-.466.084-.618.073-.046-.003-.24-.129-.432-.28a4.877 4.877 0 0 0-2.045-.972c-.426-.09-1.341-.126-1.788-.071\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/up_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface UpCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const UpCuteReIcon = ({ width = 24, height = 24, color = \"#10161F\" }: UpCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.476 8.258c-.271.075-.597.261-1.156.662a17.374 17.374 0 0 0-4.461 4.642c-.438.665-.497.795-.498 1.1-.003.748.766 1.211 1.442.868.186-.094.251-.161.455-.47.132-.198.382-.567.558-.82 1.015-1.466 2.518-2.949 3.954-3.899l.23-.152.23.152c1.436.95 2.939 2.433 3.954 3.899.176.253.426.622.558.82.204.309.269.376.455.47.676.343 1.445-.12 1.442-.868-.001-.305-.06-.435-.498-1.1A17.374 17.374 0 0 0 13.68 8.92c-.575-.412-.884-.587-1.175-.664-.252-.068-.781-.066-1.029.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_3_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface User3CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const User3CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: User3CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.34 2.046c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835m-.12 10.979c-2.103.168-3.911.732-5.486 1.708-1.361.843-2.353 1.992-2.633 3.047-.385 1.456.399 2.706 2.122 3.379.98.383 2.334.629 4.237.768.399.029 1.45.049 2.54.049s2.141-.02 2.54-.049c3.047-.223 4.717-.713 5.683-1.667.551-.545.748-1.002.752-1.74.003-.547-.06-.815-.315-1.343-.964-2-3.884-3.667-7.14-4.077-.486-.062-1.897-.108-2.3-.075\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_3_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface User3CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const User3CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: User3CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.4 2.044c-2.009.227-3.728 1.734-4.239 3.716-.241.933-.178 2.115.159 2.992a5.115 5.115 0 0 0 2.887 2.911c1.091.43 2.495.43 3.586 0 1.342-.528 2.43-1.641 2.906-2.975.397-1.112.383-2.416-.036-3.481-.59-1.501-1.885-2.653-3.423-3.045a5.223 5.223 0 0 0-1.84-.118m1.46 2.077a3.086 3.086 0 0 1 2.026 2.039c.135.434.135 1.246 0 1.68a3.086 3.086 0 0 1-2.046 2.046c-.434.135-1.246.135-1.68 0-.59-.184-1.053-.495-1.481-.995a3.006 3.006 0 0 1-.565-1.051c-.135-.434-.135-1.246 0-1.68.341-1.094 1.259-1.921 2.357-2.121.326-.06 1.075-.016 1.389.082m-1.5 8.903c-2.882.194-5.391 1.198-7.032 2.813-1.233 1.213-1.627 2.585-1.074 3.742.571 1.196 2.227 1.936 5.006 2.237 1.177.127 1.949.16 3.74.16 1.791 0 2.563-.033 3.74-.16 2.779-.301 4.435-1.041 5.006-2.237.356-.745.329-1.548-.081-2.399-.975-2.025-4.039-3.743-7.305-4.096-.423-.046-1.66-.083-2-.06m1.7 2.033c.87.103 1.31.194 2.08.432 1.575.485 2.999 1.415 3.618 2.362.304.466.311.758.024 1.023-.563.519-1.968.871-4.202 1.053-.864.071-4.296.071-5.16 0-2.234-.182-3.639-.534-4.202-1.053-.287-.265-.28-.557.024-1.023.821-1.256 2.921-2.376 5.078-2.709.99-.153 1.917-.182 2.74-.085\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_4_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface User4CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const User4CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: User4CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.362 4.039c2.268.43 3.506 2.886 2.5 4.959a3.48 3.48 0 0 1-3.784 1.915c-2.268-.43-3.506-2.886-2.5-4.959a3.479 3.479 0 0 1 3.784-1.915m.298 8.978a10.93 10.93 0 0 1 4.124 1.216c.509.265 1.176.685 1.176.741 0 .062-.889.963-1.193 1.208a7.988 7.988 0 0 1-4.767 1.782 8.01 8.01 0 0 1-5.324-1.779C6.645 17.958 5.76 17.063 5.76 17c0-.102 1.276-.831 1.98-1.132a11.666 11.666 0 0 1 3.26-.825 13.364 13.364 0 0 1 1.94-.002\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_4_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface User4CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const User4CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: User4CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.08 2.045c-1.874.165-3.723.904-5.28 2.109-.437.339-1.307 1.209-1.646 1.646-1.8 2.326-2.505 5.195-1.976 8.046.29 1.566.959 3.04 1.976 4.354.339.437 1.209 1.307 1.646 1.646 2.441 1.889 5.453 2.566 8.44 1.895 2.487-.559 4.752-2.144 6.145-4.301.806-1.247 1.283-2.527 1.521-4.08.098-.641.098-2.079 0-2.72-.285-1.858-.936-3.388-2.06-4.84-.339-.437-1.209-1.307-1.646-1.646-2.067-1.599-4.554-2.336-7.12-2.109m1.752 1.997a8.182 8.182 0 0 1 4.208 1.747c.354.286 1.027.972 1.286 1.311.327.429.632.927.874 1.429a7.944 7.944 0 0 1-.003 6.951c-.229.473-.758 1.324-.893 1.437-.053.044-.116.016-.413-.18-1.244-.821-2.835-1.406-4.471-1.643-.667-.096-2.173-.096-2.84 0-1.636.237-3.227.822-4.471 1.643-.297.196-.36.224-.413.18-.135-.113-.664-.964-.893-1.437-1.323-2.742-.991-5.942.871-8.38.259-.339.932-1.025 1.286-1.311a8.254 8.254 0 0 1 4.16-1.745 10.09 10.09 0 0 1 1.712-.002m-1.474 2.019A4.013 4.013 0 0 0 8.464 8.16a3.63 3.63 0 0 0-.406 1.235 3.416 3.416 0 0 0 0 1.21c.222 1.469 1.266 2.705 2.697 3.192.295.1.925.203 1.245.203.32 0 .95-.103 1.245-.203.984-.335 1.831-1.058 2.291-1.957a3.63 3.63 0 0 0 .406-1.235 3.416 3.416 0 0 0 0-1.21 3.986 3.986 0 0 0-2.102-2.931 3.63 3.63 0 0 0-1.235-.406 3.728 3.728 0 0 0-1.247.003m1.134 1.999c.36.093.626.25.912.536.296.295.448.561.536.933.166.707-.02 1.36-.536 1.875-.403.403-.858.596-1.404.596-.546 0-1.001-.193-1.404-.596C10.193 11.001 10 10.546 10 10c0-.546.193-1.001.596-1.404.404-.405.833-.588 1.386-.593a2.32 2.32 0 0 1 .51.057m.808 9.033c.963.157 1.82.429 2.68.855.395.195.78.423.78.461 0 .064-.8.555-1.286.79a7.94 7.94 0 0 1-6.948 0c-.481-.232-1.286-.726-1.286-.788 0-.038.059-.076.486-.312.905-.5 2.11-.896 3.094-1.016.198-.024.405-.051.46-.06.212-.036 1.677.015 2.02.07\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_add_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface UserAdd2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const UserAdd2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: UserAdd2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.34 2.046c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835m-.12 10.982c-2.288.15-4.501.926-6.133 2.151-1.806 1.356-2.497 3.034-1.82 4.416.201.41.691.902 1.189 1.194 1.354.796 3.613 1.174 7.084 1.186 1.364.005 1.404.003 1.56-.081.191-.104.384-.301.483-.494.124-.241.095-.599-.079-.999a6.047 6.047 0 0 1-.046-4.699c.133-.32.295-.625.565-1.062.256-.414.229-.879-.069-1.217-.236-.269-.407-.332-1.032-.381-.5-.039-1.231-.045-1.702-.014m7.423 1.041a1.066 1.066 0 0 0-.577.591c-.055.162-.066.373-.066 1.267V17h-1.073c-1.235 0-1.35.021-1.628.299a.984.984 0 0 0 0 1.402c.278.278.393.299 1.628.299H17v1.073c0 1.235.021 1.35.299 1.628a.984.984 0 0 0 1.402 0c.278-.278.299-.393.299-1.628V19h1.073c1.235 0 1.35-.021 1.628-.299a.984.984 0 0 0 0-1.402c-.278-.278-.393-.299-1.628-.299H19v-1.073c0-1.235-.021-1.35-.299-1.628a.998.998 0 0 0-1.058-.23\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_heart_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface UserHeartCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const UserHeartCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: UserHeartCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.34 2.046c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835m-.12 10.982c-3.507.23-6.835 1.982-7.88 4.149-.214.444-.295.739-.325 1.183-.081 1.204.718 2.217 2.208 2.799 1.385.542 3.43.806 6.317.816 1.364.005 1.404.003 1.56-.081.191-.104.384-.301.483-.494.123-.24.095-.599-.077-.992-.364-.834-.484-1.436-.482-2.428 0-.689.011-.805.112-1.237a6.178 6.178 0 0 1 .83-2.008c.227-.371.282-.641.194-.954-.063-.226-.307-.508-.53-.615-.269-.129-1.49-.199-2.41-.138m6.202 1.334c-.323.05-.469.101-.828.285-.692.356-1.259 1.086-1.483 1.907-.121.446-.12 1.228.001 1.657.288 1.014 1.058 1.919 2.352 2.764 1.256.819 1.816.819 3.072 0 1.294-.845 2.064-1.75 2.352-2.764.122-.432.122-1.211-.001-1.662-.279-1.027-1.105-1.888-2.048-2.133a2.665 2.665 0 0 0-1.629.128l-.21.092-.21-.092a2.637 2.637 0 0 0-1.368-.182\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_heart_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface UserHeartCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const UserHeartCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: UserHeartCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.4 2.044c-2.009.227-3.728 1.734-4.239 3.716-.241.933-.178 2.115.159 2.992a5.115 5.115 0 0 0 2.887 2.911c1.091.43 2.495.43 3.586 0 1.342-.528 2.43-1.641 2.906-2.975.397-1.112.383-2.416-.036-3.481-.59-1.501-1.885-2.653-3.423-3.045a5.223 5.223 0 0 0-1.84-.118m1.46 2.077a3.086 3.086 0 0 1 2.026 2.039c.135.434.135 1.246 0 1.68a3.086 3.086 0 0 1-2.046 2.046c-.434.135-1.246.135-1.68 0-.59-.184-1.053-.495-1.481-.995a3.006 3.006 0 0 1-.565-1.051c-.135-.434-.135-1.246 0-1.68.341-1.094 1.259-1.921 2.357-2.121.326-.06 1.075-.016 1.389.082m-1.5 8.906c-2.77.15-5.396 1.2-7.032 2.81-1.233 1.213-1.627 2.585-1.074 3.742.769 1.61 3.435 2.38 8.306 2.396 1.343.005 1.384.003 1.54-.081.361-.196.552-.505.552-.895 0-.304-.08-.504-.28-.704-.276-.276-.237-.27-1.892-.3-2.059-.038-3.292-.143-4.389-.374-1.199-.253-2.002-.659-2.075-1.047-.14-.747 1.31-2.08 3.039-2.794 1.389-.574 2.68-.798 4.466-.774l1.024.014.196-.121a.998.998 0 0 0 .417-1.127c-.071-.253-.392-.587-.622-.648-.355-.094-1.384-.14-2.176-.097m6.065 1.337c-1.054.142-2 1.043-2.312 2.203-.117.433-.125 1.199-.018 1.593.148.54.474 1.115.917 1.616.457.518 1.426 1.24 2.239 1.67.328.173.331.174.749.174s.421-.001.749-.174c.493-.26 1.225-.752 1.642-1.102.777-.653 1.279-1.368 1.497-2.132.126-.441.125-1.171-.003-1.651a3.064 3.064 0 0 0-1.525-1.938 2.534 2.534 0 0 0-2.15-.079l-.21.092-.21-.092a2.589 2.589 0 0 0-1.365-.18m.547 2.01c.071.03.219.131.328.226.256.222.402.28.7.28.298 0 .479-.072.708-.284.38-.349.67-.345.993.014.326.363.383.854.155 1.334-.163.342-.753.918-1.339 1.308-.255.169-.488.308-.517.308-.029 0-.262-.138-.517-.307-.583-.386-1.173-.963-1.339-1.309-.228-.479-.172-.971.154-1.333.235-.262.441-.334.674-.237\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_setting_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface UserSettingCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const UserSettingCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: UserSettingCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.34 2.046c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835m-.12 10.982c-3.507.23-6.835 1.982-7.88 4.149-.214.444-.295.739-.325 1.183-.081 1.204.718 2.217 2.208 2.799 1.385.542 3.43.806 6.317.816 1.364.005 1.404.003 1.56-.081.191-.104.384-.301.483-.494.123-.24.095-.599-.077-.992-.364-.834-.484-1.436-.482-2.428 0-.689.011-.805.112-1.237a6.178 6.178 0 0 1 .83-2.008c.227-.371.282-.641.194-.954-.063-.226-.307-.508-.53-.615-.269-.129-1.49-.199-2.41-.138m5.78.969a1.034 1.034 0 0 0-.74.847c-.036.242.02.427.256.846l.197.35-.167.25a3.904 3.904 0 0 0-.267.476l-.099.226-.48.015c-.546.018-.71.077-.939.338-.18.205-.241.37-.241.655 0 .285.061.45.241.655.229.261.393.32.939.338l.48.015.099.226c.055.124.175.338.267.476l.167.25-.197.35c-.236.419-.292.604-.256.846.09.596.645.982 1.216.845.316-.076.492-.231.738-.65l.219-.371.568.002.568.002.218.369c.246.417.422.572.737.648.677.162 1.322-.431 1.212-1.116-.014-.09-.121-.335-.237-.545l-.211-.382.167-.249c.092-.137.211-.351.266-.475l.099-.226.48-.015c.546-.018.71-.077.939-.338.18-.205.241-.37.241-.655 0-.285-.061-.45-.241-.655-.229-.261-.393-.32-.939-.338l-.48-.015-.099-.226a3.616 3.616 0 0 0-.267-.473l-.169-.246.213-.385c.117-.211.224-.457.238-.547.11-.685-.535-1.278-1.212-1.116-.315.076-.491.231-.737.648l-.218.369-.568.002-.568.002-.217-.368c-.119-.203-.278-.416-.354-.473-.237-.181-.608-.259-.862-.182\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/user_setting_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface UserSettingCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const UserSettingCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: UserSettingCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.4 2.044c-2.009.227-3.728 1.734-4.239 3.716-.241.933-.178 2.115.159 2.992a5.115 5.115 0 0 0 2.887 2.911c1.091.43 2.495.43 3.586 0 1.342-.528 2.43-1.641 2.906-2.975.397-1.112.383-2.416-.036-3.481-.59-1.501-1.885-2.653-3.423-3.045a5.223 5.223 0 0 0-1.84-.118m1.46 2.077a3.086 3.086 0 0 1 2.026 2.039c.135.434.135 1.246 0 1.68a3.086 3.086 0 0 1-2.046 2.046c-.434.135-1.246.135-1.68 0-.59-.184-1.053-.495-1.481-.995a3.006 3.006 0 0 1-.565-1.051c-.135-.434-.135-1.246 0-1.68.341-1.094 1.259-1.921 2.357-2.121.326-.06 1.075-.016 1.389.082m-1.5 8.906c-2.77.15-5.396 1.2-7.032 2.81-1.233 1.213-1.627 2.585-1.074 3.742.769 1.61 3.435 2.38 8.306 2.396 1.343.005 1.384.003 1.54-.081.361-.196.552-.505.552-.895 0-.304-.08-.504-.28-.704-.276-.276-.237-.27-1.892-.3-2.059-.038-3.292-.143-4.389-.374-1.199-.253-2.002-.659-2.075-1.047-.14-.747 1.31-2.08 3.039-2.794 1.389-.574 2.68-.798 4.466-.774l1.024.014.196-.121a.998.998 0 0 0 .417-1.127c-.071-.253-.392-.587-.622-.648-.355-.094-1.384-.14-2.176-.097m5.74.96c-.326.073-.601.262-.722.496-.088.17-.145.48-.117.64.013.081.121.321.239.534l.214.388-.178.254a3.821 3.821 0 0 0-.278.473l-.1.22-.469.015c-.522.018-.681.073-.917.319-.176.184-.252.388-.252.674 0 .286.076.49.252.674.236.246.395.301.916.319l.467.015.136.286c.075.157.202.37.281.473l.144.188-.199.352c-.235.418-.292.604-.255.828.101.614.616.988 1.194.865.324-.069.495-.215.75-.638l.226-.375.567-.003.568-.004.212.36c.117.198.246.39.287.426.374.333.975.334 1.342.002.183-.166.254-.289.311-.545.061-.272.015-.458-.221-.884l-.212-.384.178-.254c.097-.139.222-.352.278-.473l.1-.22.469-.015c.534-.018.701-.079.928-.338.18-.205.241-.37.241-.655 0-.285-.061-.45-.241-.655-.227-.259-.394-.32-.927-.338l-.467-.015-.136-.286a3.248 3.248 0 0 0-.28-.473l-.144-.187.213-.384c.236-.427.282-.613.221-.885-.054-.242-.123-.369-.286-.525a1.01 1.01 0 0 0-1.293-.075c-.075.058-.235.269-.356.47l-.219.366-.566.003-.566.004-.217-.368c-.119-.203-.278-.416-.354-.473-.217-.165-.529-.244-.762-.192m2.322 3.108a.984.984 0 0 1 .475 1.336.997.997 0 0 1-1.319.474.984.984 0 0 1-.475-1.336.997.997 0 0 1 1.319-.474\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/video_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VideoCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VideoCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VideoCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.24 2.546c-2.11.108-3.311.502-4.229 1.387-.813.783-1.204 1.745-1.401 3.439-.062.542-.07 1.047-.07 4.628 0 3.581.008 4.086.07 4.628.197 1.694.588 2.656 1.401 3.439.804.775 1.708 1.131 3.361 1.323.549.063 1.092.07 5.628.07s5.079-.007 5.628-.07c1.653-.192 2.557-.548 3.361-1.323.813-.783 1.204-1.745 1.401-3.439.062-.542.07-1.047.07-4.628 0-3.581-.008-4.086-.07-4.628-.197-1.694-.588-2.656-1.401-3.439-.796-.768-1.698-1.126-3.32-1.319-.502-.06-1.134-.069-5.369-.075-2.64-.003-4.917 0-5.06.007m3.702 5.436c.356.092.908.351 1.778.835 1.359.757 2.276 1.349 2.642 1.704.611.596.769 1.434.412 2.188-.224.474-.708.875-1.963 1.627-2.307 1.383-3.073 1.68-3.83 1.483-.72-.188-1.242-.758-1.403-1.534-.175-.842-.175-3.929 0-4.766.201-.967.913-1.59 1.824-1.597.165-.001.408.026.54.06\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/video_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VideoCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VideoCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VideoCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        stroke={color}\n        strokeWidth={2}\n        d=\"M2.5 11.5c0-3.771 0-5.657 1.172-6.828C4.843 3.5 6.729 3.5 10.5 3.5h3c3.771 0 5.657 0 6.828 1.172C21.5 5.843 21.5 7.729 21.5 11.5v1c0 3.771 0 5.657-1.172 6.828C19.157 20.5 17.271 20.5 13.5 20.5h-3c-3.771 0-5.657 0-6.828-1.172C2.5 18.157 2.5 16.271 2.5 12.5z\"\n      />\n      <Path\n        stroke={color}\n        strokeWidth={2}\n        d=\"M11.905 9.51c-.95-.51-1.426-.764-1.908-.486s-.499.817-.532 1.896a31.063 31.063 0 0 0 0 1.95c.034 1.077.05 1.614.533 1.893.482.278.956.023 1.904-.486a31.97 31.97 0 0 0 1.696-.98c.912-.564 1.368-.847 1.369-1.403 0-.557-.456-.84-1.368-1.406a31.033 31.033 0 0 0-1.694-.978Z\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/voice_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VoiceCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VoiceCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VoiceCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.673 3.063c-.261.08-.533.358-.612.627-.054.18-.061 1.172-.061 8.33 0 9.13-.028 8.346.306 8.68.18.179.458.3.694.3.402 0 .827-.312.939-.69.054-.18.061-1.17.061-8.31 0-9.108.026-8.368-.303-8.697-.279-.279-.63-.361-1.024-.24m-4 3c-.244.075-.523.351-.609.603C7.007 6.835 7 7.386 7 12c0 5.791-.023 5.371.306 5.7.18.179.458.3.694.3.402 0 .827-.312.939-.69.053-.178.061-.856.061-5.31 0-5.757.02-5.373-.303-5.697-.279-.279-.63-.361-1.024-.24m8 0c-.244.075-.523.351-.609.603-.057.169-.064.72-.064 5.334 0 5.791-.023 5.371.306 5.7.18.179.458.3.694.3.402 0 .827-.312.939-.69.053-.178.061-.856.061-5.31 0-5.757.02-5.373-.303-5.697-.279-.279-.63-.361-1.024-.24m-12 3c-.244.075-.523.351-.609.603C3.008 9.829 3 10.137 3 12c0 2.436-.005 2.388.306 2.7.18.179.458.3.694.3.237 0 .514-.12.697-.303C5.002 14.391 5 14.411 5 12s.002-2.391-.303-2.697c-.279-.279-.63-.361-1.024-.24m16 0c-.244.075-.523.351-.609.603-.056.163-.064.471-.064 2.334 0 2.436-.005 2.388.306 2.7.18.179.458.3.694.3.237 0 .514-.12.697-.303.305-.306.303-.286.303-2.697s.002-2.391-.303-2.697c-.279-.279-.63-.361-1.024-.24\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/volume_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VolumeCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VolumeCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VolumeCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M12.245 2.846c-.548.079-1.153.389-2.046 1.051-.265.196-1.278.992-2.251 1.769-1.83 1.463-2.07 1.626-2.568 1.751a6.395 6.395 0 0 1-.79.1 6.217 6.217 0 0 0-.782.101 3.783 3.783 0 0 0-2.698 2.73c-.084.329-.09.436-.09 1.652s.006 1.323.09 1.652a3.768 3.768 0 0 0 2.164 2.543c.432.183.702.242 1.316.288 1.107.081 1.211.134 2.89 1.482 3.453 2.773 3.895 3.065 4.855 3.199.924.129 1.765-.347 2.196-1.244.272-.567.376-1.07.527-2.56.302-2.99.345-6.31.121-9.3-.144-1.922-.285-3-.465-3.552-.286-.874-.905-1.489-1.663-1.649-.301-.064-.433-.066-.806-.013m.444 2.024c.33.378.631 3.779.631 7.13 0 2.088-.084 3.716-.283 5.492-.123 1.105-.221 1.539-.376 1.667-.204.169-.731-.196-3.662-2.534-1.757-1.403-2.086-1.629-2.687-1.845-.423-.153-.747-.216-1.343-.261-.814-.061-1.069-.153-1.433-.511C3.066 13.546 3 13.298 3 12s.066-1.546.536-2.008c.364-.358.619-.45 1.433-.511.787-.06 1.207-.168 1.791-.46.45-.225.63-.358 2.353-1.731 2.298-1.831 2.73-2.156 3.162-2.375.262-.133.33-.14.414-.045m6.054 1.693c-.169.042-.388.187-.533.354-.208.24-.259.755-.104 1.055.034.066.237.304.451.529.716.753 1.137 1.545 1.35 2.539.053.246.073.512.073.96 0 .448-.02.714-.073.96-.213.994-.631 1.779-1.361 2.549-.208.22-.406.454-.44.519-.179.346-.098.85.18 1.129.221.22.406.298.714.299.372 0 .596-.133 1.068-.636a7.031 7.031 0 0 0 1.851-3.78 7.285 7.285 0 0 0-.278-3.25c-.169-.513-.63-1.42-.939-1.85-.359-.498-.966-1.137-1.206-1.268a1.124 1.124 0 0 0-.753-.109m-2.012 2.242a.996.996 0 0 0-.673.639c-.137.39-.036.725.341 1.132.421.454.601.881.601 1.424 0 .543-.18.97-.601 1.424-.285.307-.399.546-.399.836 0 .209.118.488.275.65.213.22.415.305.725.306.336 0 .603-.139.909-.475 1.475-1.62 1.435-4.04-.092-5.568-.231-.231-.329-.299-.499-.35-.235-.07-.393-.075-.587-.018\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/volume_mute_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VolumeMuteCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VolumeMuteCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VolumeMuteCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M12.245 2.846c-.548.079-1.153.389-2.046 1.051-.265.196-1.278.992-2.251 1.769-1.83 1.463-2.07 1.626-2.568 1.751a6.395 6.395 0 0 1-.79.1 6.217 6.217 0 0 0-.782.101 3.783 3.783 0 0 0-2.698 2.73c-.084.329-.09.436-.09 1.652s.006 1.323.09 1.652a3.768 3.768 0 0 0 2.164 2.543c.432.183.702.242 1.316.288 1.107.081 1.211.134 2.89 1.482 3.453 2.773 3.895 3.065 4.855 3.199.924.129 1.765-.347 2.196-1.244.272-.567.376-1.07.527-2.56.302-2.99.345-6.31.121-9.3-.144-1.922-.285-3-.465-3.552-.286-.874-.905-1.489-1.663-1.649-.301-.064-.433-.066-.806-.013m.444 2.024c.33.378.631 3.779.631 7.13 0 2.088-.084 3.716-.283 5.492-.123 1.105-.221 1.539-.376 1.667-.204.169-.731-.196-3.662-2.534-1.757-1.403-2.086-1.629-2.687-1.845-.423-.153-.747-.216-1.343-.261-.814-.061-1.069-.153-1.433-.511C3.066 13.546 3 13.298 3 12s.066-1.546.536-2.008c.364-.358.619-.45 1.433-.511.787-.06 1.207-.168 1.791-.46.45-.225.63-.358 2.353-1.731 2.298-1.831 2.73-2.156 3.162-2.375.262-.133.33-.14.414-.045m3.861 4.065a1.054 1.054 0 0 0-.539.456c-.095.162-.111.232-.11.489 0 .215.022.34.075.44.041.077.418.486.837.91l.762.769-.781.791c-.696.704-.789.814-.853 1.005a.99.99 0 0 0 1.044 1.313c.114-.012.267-.052.34-.09.074-.038.481-.412.905-.831l.769-.762.791.781c.671.663.82.791.987.847.81.274 1.549-.451 1.282-1.258-.064-.191-.157-.301-.853-1.005l-.781-.791.762-.769c.419-.424.796-.833.837-.91.102-.191.104-.686.003-.872-.171-.317-.563-.568-.887-.568-.069 0-.222.029-.341.064-.197.058-.283.131-1.007.847l-.793.784-.769-.762c-.424-.419-.832-.794-.908-.833-.183-.094-.58-.117-.772-.045\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/volume_off_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface VolumeOffCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const VolumeOffCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: VolumeOffCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M12.18 2.86c-.708.133-1.246.45-2.737 1.613-.409.319-1.019.804-1.357 1.078l-.614.499-1.326-1.323C4.695 3.28 4.646 3.242 4.22 3.242c-.4 0-.742.218-.905.577-.1.22-.097.589.008.821.118.263 15.725 15.876 16.015 16.021a1 1 0 0 0 1.347-.48c.1-.221.097-.589-.008-.821-.058-.127-.331-.43-.925-1.027l-.843-.846.195-.029c.388-.057.687-.286 1.249-.958a7.017 7.017 0 0 0 1.288-6.71c-.169-.513-.63-1.42-.939-1.85-.374-.519-.965-1.136-1.226-1.278-.552-.301-1.249-.022-1.421.569-.072.247-.051.543.051.741.034.066.237.304.451.529.716.753 1.137 1.545 1.35 2.539.053.246.073.512.073.96 0 .448-.02.714-.073.96-.213.994-.632 1.779-1.36 2.548-.435.46-.468.511-.525.822l-.043.23-.699-.702c-.556-.558-.675-.696-.58-.67.425.113.838-.039 1.209-.447 1.475-1.62 1.435-4.04-.093-5.569-.241-.241-.325-.298-.518-.354a.99.99 0 0 0-1.238.623c-.139.393-.039.727.339 1.135.421.454.601.881.601 1.424 0 .542-.171.947-.597 1.417-.222.244-.297.36-.348.533a1.07 1.07 0 0 0 .006.63c.016.044-.147-.094-.363-.307l-.392-.387.009-1.533a54.466 54.466 0 0 0-.257-5.713c-.152-1.495-.255-1.993-.527-2.56-.309-.643-.847-1.087-1.48-1.221-.325-.069-.5-.069-.871.001m.486 1.971c.073.047.167.311.232.649.177.935.373 3.437.407 5.2l.024 1.22-2.205-2.2C9.912 8.49 8.92 7.487 8.92 7.47c0-.017.437-.378.97-.803 2.029-1.615 2.568-1.971 2.776-1.836M3.49 7.716c-.483.168-.9.422-1.28.782-.399.376-.629.686-.848 1.145-.319.667-.338.798-.34 2.317-.002 1.26.003 1.361.088 1.692a3.768 3.768 0 0 0 2.164 2.543c.432.183.702.242 1.316.288.302.022.658.066.79.099.484.118.741.292 2.48 1.682 3.08 2.463 3.495 2.735 4.398 2.887 1.103.187 2.037-.439 2.444-1.638.215-.631.054-1.144-.438-1.404-.23-.122-.659-.121-.89.001-.239.126-.437.379-.538.686a1.376 1.376 0 0 1-.15.337c-.192.22-.695-.122-3.672-2.496-1.773-1.414-2.103-1.641-2.702-1.857-.423-.153-.747-.216-1.343-.261-.814-.061-1.069-.153-1.433-.511a1.608 1.608 0 0 1-.473-.795c-.085-.329-.09-2.087-.007-2.4a1.704 1.704 0 0 1 1.013-1.171c.593-.241.721-.347.864-.713.196-.501-.102-1.114-.617-1.266-.296-.088-.448-.079-.826.053\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/wallet_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface Wallet2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const Wallet2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: Wallet2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.06 2.044c-.817.066-1.693.256-2.283.494-.488.197-.884.456-1.188.779-.634.671-.756 1.506-.344 2.359.143.296.226.404.597.772l.431.429-.38.203c-2.406 1.285-4.183 3.624-4.711 6.2a8.83 8.83 0 0 0-.025 3.32c.572 2.943 2.852 4.764 6.583 5.259 1.699.225 3.934.158 5.48-.164 3.16-.659 5.102-2.418 5.623-5.095a8.832 8.832 0 0 0-.025-3.32c-.524-2.573-2.305-4.915-4.72-6.205l-.39-.209.116-.096c.25-.209.63-.598.744-.763a2.55 2.55 0 0 0 .402-1.011c.151-1.037-.502-1.956-1.747-2.458-1.056-.426-2.66-.617-4.163-.494m2.38 2.069c.789.142 1.516.43 1.552.616.019.098-.322.426-.631.607a5.326 5.326 0 0 1-1.35.533c-.37.093-.514.108-1.011.108-.498 0-.641-.015-1.014-.108-.545-.137-1.108-.371-1.467-.61-.297-.197-.528-.441-.509-.539.006-.033.11-.119.231-.191.358-.215 1.046-.399 1.824-.488.502-.057 1.887-.015 2.375.072m-2.632 6.414c.301.09.464.288.832 1.007.187.365.349.665.36.665.011 0 .17-.297.354-.66.26-.513.373-.693.508-.812a.988.988 0 0 1 1.604.469c.092.315.053.478-.282 1.179l-.298.625h.13c.511 0 .984.48.984 1 0 .242-.119.521-.299.701-.247.247-.442.299-1.128.299H13v.471l.61.017c.676.018.827.059 1.065.284.189.179.324.483.324.728s-.135.549-.324.728c-.238.225-.389.266-1.065.284l-.61.017v.308c0 .402-.087.652-.3.865a1 1 0 0 1-1.488-.095c-.163-.214-.211-.39-.212-.773v-.305l-.61-.017c-.676-.018-.827-.059-1.065-.284a1.098 1.098 0 0 1-.324-.728c0-.245.135-.549.324-.728.238-.225.389-.266 1.065-.284l.61-.017V15h-.575c-.788 0-1.055-.104-1.287-.499a.999.999 0 0 1 .756-1.489l.217-.022-.296-.607c-.337-.689-.384-.894-.28-1.209a1.011 1.011 0 0 1 1.273-.647\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/warning_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface WarningCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const WarningCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: WarningCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.54 2.017c2.788.311 5.174 1.99 6.38 4.488a7.95 7.95 0 0 1-.001 6.945A8.02 8.02 0 0 1 12 19.999a8.014 8.014 0 0 1-7.2-4.528 7.948 7.948 0 0 1 0-6.942A7.973 7.973 0 0 1 8.529 4.8c1.323-.64 2.886-.916 4.291-.759m-1.177 2.028a1.118 1.118 0 0 0-.343.229c-.313.313-.3.157-.3 3.702 0 3.544-.013 3.39.299 3.701.18.18.459.299.701.299.242 0 .521-.119.701-.299.312-.311.299-.157.299-3.701s.013-3.39-.299-3.701a.998.998 0 0 0-1.058-.23m0 9c-.352.124-.643.545-.643.931 0 .242.119.521.299.701a.993.993 0 0 0 1.57-.212c.095-.161.111-.233.111-.489s-.016-.328-.111-.489a1.006 1.006 0 0 0-1.226-.442\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/web_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface WebCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const WebCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: WebCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M7.825 3.564c-1.86.102-2.934.495-3.85 1.411-.939.939-1.304 1.964-1.413 3.965-.054.983-.054 5.137 0 6.12.109 2.001.474 3.026 1.413 3.965.939.939 1.964 1.304 3.965 1.413.977.053 7.124.054 8.12.001 1.282-.068 2.108-.242 2.84-.6a3.935 3.935 0 0 0 1.208-.892c.866-.912 1.229-1.972 1.331-3.887.022-.418.041-1.795.041-3.06 0-1.265-.019-2.642-.041-3.06-.068-1.282-.242-2.108-.6-2.84a4.27 4.27 0 0 0-2.13-2.033c-.7-.3-1.425-.439-2.649-.505-.881-.048-7.345-.046-8.235.002M16.2 5.561c.932.068 1.656.247 2.04.505.077.052.252.204.388.339.559.554.772 1.297.837 2.925l.027.67H4.508l.027-.67c.059-1.482.248-2.252.676-2.755.389-.459.747-.678 1.369-.836.536-.136 1.041-.186 2.26-.22 1.453-.042 6.623-.012 7.36.042m-9.527 1.5C6.308 7.178 6 7.608 6 8c0 .576.547 1.055 1.125.987.806-.096 1.163-1.089.603-1.681-.248-.261-.687-.363-1.055-.245m3 0C9.308 7.178 9 7.608 9 8c0 .576.547 1.055 1.125.987.806-.096 1.163-1.089.603-1.681-.248-.261-.687-.363-1.055-.245m3 0C12.308 7.178 12 7.608 12 8c0 .576.547 1.055 1.125.987.806-.096 1.163-1.089.603-1.681-.248-.261-.687-.363-1.055-.245M19.5 13.27c-.043 2.178-.122 2.95-.369 3.57-.133.334-.234.482-.541.793-.413.417-.984.648-1.89.765-.96.124-7.331.153-8.9.04-.951-.068-1.662-.247-2.06-.519a3.496 3.496 0 0 1-.674-.679c-.147-.22-.293-.624-.382-1.06-.102-.504-.153-1.298-.185-2.91L4.474 12h15.051l-.025 1.27\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/webhook_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface WebhookCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const WebhookCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: WebhookCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.358 3.061c-1.436.224-2.674 1.281-3.155 2.694C8.103 6.05 8 6.68 8 7c0 .49.129 1.078.362 1.661.046.115.035.14-.291.62a27.709 27.709 0 0 0-1.786 3.002l-.365.703-.45.063a3.625 3.625 0 0 0-1.31.415 3.978 3.978 0 0 0-1.696 1.696 3.63 3.63 0 0 0-.406 1.235 3.416 3.416 0 0 0 0 1.21c.222 1.469 1.266 2.705 2.697 3.192.295.1.925.203 1.245.203.322 0 .951-.103 1.245-.204a4.473 4.473 0 0 0 1.555-.94l.184-.172.698.064c.974.091 3.662.091 4.636 0l.698-.064.184.172a4.1 4.1 0 0 0 2.195 1.086c.484.072.726.072 1.21 0a3.986 3.986 0 0 0 2.931-2.102 3.63 3.63 0 0 0 .406-1.235 3.416 3.416 0 0 0 0-1.21 3.986 3.986 0 0 0-2.102-2.931 3.625 3.625 0 0 0-1.31-.415l-.45-.063-.375-.723a26.71 26.71 0 0 0-1.593-2.703c-.5-.751-.518-.784-.473-.9C15.878 8.047 16 7.489 16 7c0-.32-.103-.95-.203-1.245-.241-.707-.734-1.418-1.287-1.856a4.012 4.012 0 0 0-1.905-.841 3.728 3.728 0 0 0-1.247.003m1.134 1.999c.36.093.626.25.912.536.296.295.448.561.536.933a2.05 2.05 0 0 1-.169 1.408c-.133.263-.575.703-.841.838a2.047 2.047 0 0 1-1.86 0c-.269-.136-.709-.576-.845-.845a2.047 2.047 0 0 1 0-1.86c.137-.27.576-.709.846-.845a2.078 2.078 0 0 1 1.421-.165m-1.773 5.722c.319.112.942.218 1.281.218.339 0 .962-.106 1.281-.218.158-.055.438-.18.622-.278l.333-.177.361.553c.659 1.009 1.537 2.602 1.451 2.631-.076.025-.585.396-.777.566-.406.361-.86 1.075-1.067 1.679a4.216 4.216 0 0 0-.14 1.911c.022.079.004.089-.21.12-.304.043-3.404.043-3.708 0-.214-.031-.232-.041-.21-.12a4.216 4.216 0 0 0-.14-1.911c-.207-.604-.661-1.318-1.067-1.679-.192-.17-.701-.541-.777-.566-.096-.032.731-1.519 1.535-2.758l.277-.426.333.177c.184.098.464.223.622.278M6.492 15.06c.36.093.626.25.912.536.296.295.448.561.536.933a2.05 2.05 0 0 1-.169 1.408c-.133.263-.575.703-.841.838a2.047 2.047 0 0 1-1.86 0c-.269-.136-.709-.576-.845-.845a2.047 2.047 0 0 1 0-1.86c.137-.27.576-.709.846-.845a2.078 2.078 0 0 1 1.421-.165m12 0c.36.093.626.25.912.536.296.295.448.561.536.933.166.707-.02 1.36-.536 1.875-.403.403-.858.596-1.404.596-.546 0-1.001-.193-1.404-.596C16.193 18.001 16 17.546 16 17c0-.546.193-1.001.596-1.404.404-.405.833-.588 1.386-.593a2.32 2.32 0 0 1 .51.057\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/weibo_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface WeiboCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const WeiboCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: WeiboCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M16.643 3.069C16.291 3.193 16 3.614 16 4c0 .396.291.808.66.934.107.036.31.066.451.066.341 0 .98.133 1.358.283a4.013 4.013 0 0 1 2.248 2.248c.15.378.283 1.017.283 1.358 0 .348.096.609.299.812.64.64 1.701.172 1.701-.75 0-.107-.019-.369-.042-.583a5.986 5.986 0 0 0-5.326-5.326c-.516-.056-.775-.049-.989.027m-6.176 1.972c-.819.129-1.58.451-2.64 1.115-1.573.985-3.025 2.34-4.058 3.789-1.444 2.024-2.048 4.188-1.629 5.84.382 1.503 1.606 2.89 3.381 3.83 3.475 1.839 8.475 1.838 11.959-.001 1.705-.901 2.924-2.241 3.338-3.671a4.603 4.603 0 0 0 .167-1.458c-.096-1.255-.765-2.226-2.067-2.998l-.383-.227.057-.18c.031-.099.065-.423.076-.72.023-.624-.03-.928-.233-1.33-.665-1.318-2.113-1.794-4.041-1.327a12.27 12.27 0 0 1-.417.097c-.009 0-.017-.064-.017-.142 0-.255-.124-.711-.284-1.046a2.79 2.79 0 0 0-1.815-1.501c-.327-.088-1.051-.124-1.394-.07m.921 2.019c.56.167.747.84.455 1.64-.072.198-.15.441-.173.541-.137.59.33 1.171.946 1.175.211.002.303-.026.684-.202 1.055-.488 1.697-.669 2.38-.672.391-.002.488.011.649.085.466.216.504.813.092 1.473-.23.369-.288.597-.224.88.055.247.229.508.414.621.071.044.255.127.409.185.687.26 1.274.618 1.581.963.463.52.519 1.204.163 1.988-.724 1.597-2.985 2.831-5.844 3.19-.552.07-2.288.07-2.84 0-3.2-.402-5.618-1.896-6.025-3.722-.121-.545-.009-1.388.291-2.184.265-.708.812-1.638 1.398-2.381.425-.539 1.61-1.693 2.173-2.115 1.634-1.227 2.723-1.687 3.471-1.465\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/wifi_off_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface WifiOffCuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const WifiOffCuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: WifiOffCuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M4.487 3.064a.984.984 0 0 0-.428 1.318c.078.155 1.654 1.752 7.27 7.363 7.934 7.928 7.284 7.321 7.805 7.288a.925.925 0 0 0 .899-.899c.032-.508.219-.292-3.267-3.784-1.73-1.733-3.122-3.15-3.095-3.15.151 0 1.097.362 1.529.584a7.052 7.052 0 0 1 1.544 1.071c.411.369.592.463.896.464a.985.985 0 0 0 .99-1.073c-.028-.305-.11-.455-.406-.74a9.177 9.177 0 0 0-4.064-2.239c-.653-.159-1.196-.228-2.006-.254l-.747-.025-.869-.87-.869-.87.215-.043a11.245 11.245 0 0 1 4.651.091 11.004 11.004 0 0 1 4.94 2.643c.228.211.483.419.566.462a.99.99 0 0 0 1.306-.389c.097-.165.113-.234.112-.492-.001-.406-.081-.543-.588-1.015-3.333-3.104-7.96-4.232-12.385-3.018l-.453.124-1.247-1.243c-.915-.914-1.294-1.265-1.426-1.325a1.06 1.06 0 0 0-.873.021m-.011 4.338C3.537 8.074 2.737 8.811 2.6 9.13a.995.995 0 0 0 .684 1.351c.412.099.67-.01 1.203-.508.427-.398.814-.712 1.251-1.014l.304-.21-.069-.105c-.038-.057-.265-.41-.506-.784-.24-.374-.458-.703-.485-.732-.04-.042-.136.01-.506.274m3.103 2.763a8.924 8.924 0 0 0-1.703 1.242c-.388.349-.526.591-.527.928-.003.689.608 1.16 1.26.97.176-.051.289-.126.556-.37a7.81 7.81 0 0 1 1.345-.999c.181-.102.33-.193.33-.201 0-.025-.879-1.775-.892-1.775-.006 0-.172.092-.369.205m2.822 3.099c-.851.29-1.879 1.001-2.133 1.476-.12.223-.12.656 0 .88.19.357.492.54.892.54.32 0 .455-.062.826-.379.363-.309.64-.476 1.008-.605.197-.069.265-.112.263-.165a19.13 19.13 0 0 0-.217-.961c-.167-.691-.229-.89-.277-.889a2.48 2.48 0 0 0-.362.103m1.242 3.802c-.355.131-.643.549-.643.934 0 .407.292.811.679.94a.99.99 0 0 0 1.226-.518c.232-.499.028-1.067-.478-1.329-.178-.092-.57-.105-.784-.027\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/world_2_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface World2CuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const World2CuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: World2CuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m1.54 2.017c.636.072.604.058.74.333.317.645.216 1.353-.267 1.87-.291.31-.508.414-1.213.58-.74.174-1.076.331-1.348.632-.364.403-.519.912-.453 1.494.101.902-.348 1.67-1.152 1.971-.802.3-1.852-.061-2.327-.801-.277-.431-.294-.547-.311-2.08-.009-.759-.004-1.493.011-1.632l.027-.251.306-.263c1.15-.984 2.756-1.677 4.287-1.85a10.16 10.16 0 0 1 1.7-.003m2.56 9.418c.463.174 1.353.654 1.578.85.248.218.488.569.583.854.036.109.126.662.198 1.228l.133 1.029-.266.273a7.78 7.78 0 0 1-1.939 1.413l-.354.178-.192-.123c-.254-.164-.564-.497-.675-.727a5.1 5.1 0 0 1-.206-.595c-.176-.616-.306-.858-.676-1.262a3.067 3.067 0 0 1-.474-.652c-.131-.262-.148-.335-.163-.67-.014-.313-.001-.424.075-.66a1.807 1.807 0 0 1 2.378-1.136\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/world_2_cute_re.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface World2CuteReIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const World2CuteReIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: World2CuteReIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M11.08 2.045c-1.874.165-3.723.904-5.28 2.109-.437.339-1.307 1.209-1.646 1.646-1.8 2.326-2.505 5.195-1.976 8.046.29 1.566.959 3.04 1.976 4.354.339.437 1.209 1.307 1.646 1.646 2.441 1.889 5.468 2.569 8.44 1.894a9.98 9.98 0 0 0 5.584-3.523c1.121-1.409 1.794-2.979 2.082-4.857.098-.641.098-2.079 0-2.72-.285-1.858-.936-3.388-2.06-4.84-.339-.437-1.209-1.307-1.646-1.646-2.067-1.599-4.554-2.336-7.12-2.109m1.868 2.01c.031.03-.134.885-.215 1.122a.977.977 0 0 1-.391.502c-.076.047-.396.149-.71.226a5.74 5.74 0 0 0-.821.259c-.534.255-1.01.746-1.287 1.329-.239.502-.288.829-.247 1.668.017.355-.034.481-.281.693-.2.171-.423.216-.717.146-.298-.072-.556-.262-.677-.499L7.5 9.302l-.012-1.951-.012-1.95.284-.184c.895-.579 2.197-1.034 3.36-1.176.328-.039 1.786-.028 1.828.014m2.552.76a7.523 7.523 0 0 1 2.241 1.631A7.375 7.375 0 0 1 19.2 8.529c1.094 2.263 1.066 4.834-.077 7.089-.167.328-.322.603-.346.611-.026.008-.062-.138-.093-.378-.107-.826-.244-1.256-.544-1.708-.337-.509-.702-.784-1.7-1.284-.757-.378-.954-.448-1.418-.503a2.967 2.967 0 0 0-1.31.162c-.611.231-1.193.769-1.495 1.38-.234.475-.31.839-.286 1.382.032.731.283 1.307.821 1.884.152.163.308.358.347.433.126.246.66 2.179.61 2.21-.131.081-1.112.192-1.701.193A8.123 8.123 0 0 1 7.1 18.326c-.339-.259-1.025-.932-1.311-1.286-1.328-1.643-1.972-3.807-1.747-5.872.134-1.224.592-2.525 1.226-3.479L5.5 7.34l.021 1.2.021 1.2.124.324c.435 1.137 1.37 1.826 2.658 1.957.317.032.753-.029 1.128-.16.768-.267 1.374-.87 1.679-1.67.123-.324.126-.347.13-1.035.004-.685.007-.709.11-.885a.904.904 0 0 1 .217-.252c.061-.041.354-.133.652-.205.797-.195 1.193-.402 1.661-.87.512-.513.697-.878.882-1.744.138-.648.135-.64.168-.64.016 0 .263.115.549.255m.132 9.869c.706.355.872.493.964.8.027.09.128.742.225 1.449.165 1.212.171 1.29.107 1.361-.09.099-.845.603-1.09.727l-.193.098-.06-.21-.287-1.009c-.324-1.14-.475-1.446-.987-2a2.794 2.794 0 0 1-.325-.408c-.08-.156-.086-.489-.011-.663a.992.992 0 0 1 .49-.452c.266-.099.461-.048 1.167.307\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/icons/youtube_cute_fi.tsx",
    "content": "import * as React from \"react\"\nimport Svg, { Path } from \"react-native-svg\"\n\ninterface YoutubeCuteFiIconProps {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const YoutubeCuteFiIcon = ({\n  width = 24,\n  height = 24,\n  color = \"#10161F\",\n}: YoutubeCuteFiIconProps) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 24 24\">\n      <Path\n        d=\"M10.26 4.283c-2.068.06-4.575.236-5.088.357-1.238.293-2.286 1.333-2.59 2.57-.159.649-.312 3.001-.312 4.79 0 1.789.153 4.141.312 4.79.304 1.237 1.351 2.276 2.59 2.57.543.129 2.961.291 5.378.361 2.559.074 7.323-.134 8.278-.361 1.287-.305 2.355-1.403 2.611-2.683.159-.796.292-2.94.291-4.697 0-1.843-.158-4.166-.326-4.825-.308-1.203-1.356-2.227-2.576-2.515-.541-.128-2.915-.29-5.268-.359-1.459-.043-1.746-.043-3.3.002m.726 4.802c.4.123 1.348.621 2.494 1.308 1.383.831 1.681 1.115 1.679 1.602-.002.399-.25.691-.999 1.176-1.004.65-2.692 1.58-3.14 1.729-.591.197-1.076-.048-1.238-.625-.193-.685-.182-3.96.015-4.597.093-.299.297-.53.548-.619.214-.077.324-.072.641.026\"\n        fill={color}\n        fillRule=\"evenodd\"\n      />\n    </Svg>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/analytics.ts",
    "content": "import { whoami } from \"@follow/store/user/getters\"\nimport { setFirebaseTracker, setPostHogTracker, tracker } from \"@follow/tracker\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\nimport { getAnalytics } from \"@react-native-firebase/analytics\"\nimport { nativeApplicationVersion, nativeBuildVersion } from \"expo-application\"\nimport PostHog from \"posthog-react-native\"\n\nimport { proxyEnv } from \"../lib/proxy-env\"\n\nexport const initAnalytics = async () => {\n  setFirebaseTracker(getAnalytics())\n\n  if (proxyEnv.POSTHOG_KEY) {\n    setPostHogTracker(\n      new PostHog(proxyEnv.POSTHOG_KEY, {\n        host: proxyEnv.POSTHOG_HOST,\n        errorTracking: {\n          autocapture: {\n            uncaughtExceptions: true,\n            unhandledRejections: true,\n            console: false,\n          },\n        },\n      }),\n    )\n  }\n\n  tracker.manager.appendUserProperties({\n    build: \"rn\",\n    version: nativeApplicationVersion,\n    buildId: nativeBuildVersion,\n  })\n\n  const user = whoami()\n  if (user) {\n    tracker.identify(user as AuthUser)\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/app-check.ts",
    "content": "import { env } from \"@follow/shared/env.rn\"\nimport { getApp } from \"@react-native-firebase/app\"\nimport getAppCheck, {\n  initializeAppCheck as firebaseInitializeAppCheck,\n} from \"@react-native-firebase/app-check\"\n\nexport async function initializeAppCheck() {\n  const app = getApp()\n  const appCheck = getAppCheck(app)\n\n  const provider = appCheck.newReactNativeFirebaseAppCheckProvider()\n  provider.configure({\n    apple: {\n      provider: __DEV__ ? \"debug\" : \"appAttest\",\n      debugToken: env.APP_CHECK_DEBUG_TOKEN,\n    },\n    android: {\n      provider: __DEV__ ? \"debug\" : \"playIntegrity\",\n      debugToken: env.APP_CHECK_DEBUG_TOKEN,\n    },\n    isTokenAutoRefreshEnabled: true,\n  })\n\n  await firebaseInitializeAppCheck(app, {\n    provider,\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/background.ts",
    "content": "import { getUnreadAll } from \"@follow/store/unread/getters\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport * as BackgroundTask from \"expo-background-task\"\nimport * as TaskManager from \"expo-task-manager\"\n\nimport { getUISettings } from \"../atoms/settings/ui\"\nimport { setBadgeCountAsyncWithPermission } from \"../lib/permission\"\n\nconst BACKGROUND_FETCH_TASK = \"background-fetch\"\n\n// defineTask must be called at module load time (top level), not inside an\n// async function, otherwise iOS may not find the handler when the background\n// task fires and will crash with \"No launch handler registered\".\nTaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {\n  const user = whoami()\n  const { showUnreadCountBadgeMobile } = getUISettings()\n  if (!user || !showUnreadCountBadgeMobile) {\n    return BackgroundTask.BackgroundTaskResult.Success\n  }\n\n  try {\n    await unreadSyncService.resetFromRemote()\n    const allUnreadCount = getUnreadAll()\n    await setBadgeCountAsyncWithPermission(allUnreadCount)\n    return BackgroundTask.BackgroundTaskResult.Success\n  } catch (err) {\n    console.error(err)\n    return BackgroundTask.BackgroundTaskResult.Failed\n  }\n})\n\nexport async function initBackgroundTask() {\n  return BackgroundTask.registerTaskAsync(BACKGROUND_FETCH_TASK, {\n    minimumInterval: 60 * 15, // 15 minutes\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/dayjs.ts",
    "content": "import dayjs from \"dayjs\"\nimport duration from \"dayjs/plugin/duration\"\nimport localizedFormat from \"dayjs/plugin/localizedFormat\"\nimport relativeTime from \"dayjs/plugin/relativeTime\"\n// Initialize dayjs\nexport const initializeDayjs = () => {\n  dayjs.extend(duration)\n  dayjs.extend(relativeTime)\n  dayjs.extend(localizedFormat)\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/device.ts",
    "content": "import { getDeviceTypeAsync } from \"expo-device\"\n\nimport { appAtoms } from \"../atoms/app\"\nimport { jotaiStore } from \"../lib/jotai\"\n\nexport async function initDeviceType() {\n  const type = await getDeviceTypeAsync()\n  jotaiStore.set(appAtoms.deviceType, type)\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/hydrate.ts",
    "content": "import { persistQueryClient } from \"@tanstack/react-query-persist-client\"\n\nimport { initializeDefaultDataSettings } from \"../atoms/settings/data\"\nimport { initializeDefaultGeneralSettings } from \"../atoms/settings/general\"\nimport { initializeDefaultUISettings } from \"../atoms/settings/ui\"\nimport { kvStoragePersister, queryClient } from \"../lib/query-client\"\n\ndeclare module \"@tanstack/react-query\" {\n  interface Meta {\n    queryMeta: { persist?: boolean }\n  }\n\n  interface Register extends Meta {}\n}\n\nexport const hydrateSettings = () => {\n  initializeDefaultUISettings()\n  initializeDefaultGeneralSettings()\n  initializeDefaultDataSettings()\n}\nexport const hydrateQueryClient = () => {\n  persistQueryClient({\n    queryClient,\n    persister: kvStoragePersister,\n    dehydrateOptions: {\n      shouldDehydrateQuery: (query) => {\n        if (!query.meta?.persist) return false\n        const queryIsReadyForPersistence = query.state.status === \"success\"\n        if (queryIsReadyForPersistence) {\n          return (\n            !((query.state?.data as any)?.pages?.length > 1) &&\n            query.queryKey?.[0] !== \"check-eagle\"\n          )\n        } else {\n          return false\n        }\n      },\n      shouldDehydrateMutation() {\n        return false\n      },\n    },\n    maxAge: 1000 * 60 * 60 * 24 * 1,\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/index.ts",
    "content": "import { initializeDB } from \"@follow/database/db\"\nimport { hydrateDatabaseToStore } from \"@follow/store/hydrate\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport { tracker } from \"@follow/tracker\"\nimport { nativeApplicationVersion } from \"expo-application\"\n\nimport { migrateLegacyApiSession } from \"../lib/auth-cookie-migration\"\nimport { settingSyncQueue } from \"../modules/settings/sync-queue\"\nimport { initAnalytics } from \"./analytics\"\nimport { initializeAppCheck } from \"./app-check\"\nimport { initBackgroundTask } from \"./background\"\nimport { initializeDayjs } from \"./dayjs\"\nimport { initDeviceType } from \"./device\"\nimport { hydrateQueryClient, hydrateSettings } from \"./hydrate\"\nimport { migrateDatabase } from \"./migration\"\nimport { initializePlayer } from \"./player\"\n\ntype RequestIdleCallback = (callback: () => void, options?: { timeout?: number }) => number\n\nconst runWhenIdle = (callback: () => void) => {\n  const requestIdle = (globalThis as { requestIdleCallback?: RequestIdleCallback })\n    .requestIdleCallback\n\n  if (requestIdle) {\n    requestIdle(callback, { timeout: 5000 })\n    return\n  }\n\n  setTimeout(callback, 0)\n}\n\n/* eslint-disable no-console */\nexport const initializeApp = async () => {\n  console.log(`Initialize...`)\n\n  const now = Date.now()\n\n  await initDeviceType()\n  await initializeDB()\n  void apm(\"migrateLegacyApiSession\", migrateLegacyApiSession).catch((error) => {\n    console.error(\"migrateLegacyApiSession failed\", error)\n  })\n\n  await apm(\"migrateDatabase\", migrateDatabase)\n  initializeDayjs()\n\n  await apm(\"hydrateSettings\", hydrateSettings)\n  let dataHydratedTime = Date.now()\n  await apm(\"hydrateDatabaseToStore\", () => {\n    return hydrateDatabaseToStore()\n  })\n\n  dataHydratedTime = Date.now() - dataHydratedTime\n  await apm(\"hydrateQueryClient\", hydrateQueryClient)\n  await apm(\"initializeAppCheck\", initializeAppCheck)\n  runWhenIdle(() => {\n    apm(\"initializePlayer\", initializePlayer)\n  })\n  await initAnalytics()\n\n  void apm(\"setting sync\", async () => {\n    await settingSyncQueue.init()\n\n    await userSyncService.whoami().catch(() => null)\n\n    if (!whoami()) {\n      return\n    }\n    await settingSyncQueue.syncLocal()\n  }).catch((error) => {\n    console.error(\"setting sync failed\", error)\n    void tracker.manager.captureException(error, {\n      module: \"setting_sync\",\n      stage: \"bootstrap\",\n    })\n  })\n  const loadingTime = Date.now() - now\n  tracker.appInit({\n    rn: true,\n    loading_time: loadingTime,\n    version: nativeApplicationVersion!,\n    data_hydrated_time: dataHydratedTime,\n    electron: false,\n    using_indexed_db: true,\n  })\n\n  initBackgroundTask()\n  console.log(`Initialize done,`, `${loadingTime}ms`)\n}\n\nconst apm = async (label: string, fn: () => Promise<any> | any) => {\n  const start = Date.now()\n  const result = await fn()\n  const end = Date.now()\n  console.log(`${label} took ${end - start}ms`)\n  return result\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/migration.ts",
    "content": "import { migrateDB } from \"@follow/database/db\"\nimport { useSyncExternalStore } from \"react\"\n\nlet storeChangeFn: () => void\nconst subscribe = (onStoreChange: () => void) => {\n  storeChangeFn = onStoreChange\n\n  return () => {\n    storeChangeFn = () => {}\n  }\n}\nconst migrateStore = {\n  success: false,\n  error: null as Error | null,\n}\n\nexport const migrateDatabase = async () => {\n  try {\n    await migrateDB()\n    migrateStore.success = true\n    storeChangeFn?.()\n  } catch (error) {\n    migrateStore.error = error as Error\n\n    console.error(error)\n\n    storeChangeFn?.()\n  }\n}\n\nconst getSnapshot = () => {\n  return migrateStore\n}\nconst getServerSnapshot = () => {\n  return migrateStore\n}\nexport const useDatabaseMigration = () => {\n  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n}\n"
  },
  {
    "path": "apps/mobile/src/initialize/player.ts",
    "content": "import { AppState, Platform } from \"react-native\"\nimport TrackPlayer, { Capability, Event } from \"react-native-track-player\"\n\nexport let PlayerRegistered = false\n\nfunction waitForForeground(): Promise<void> {\n  if (AppState.currentState === \"active\") return Promise.resolve()\n  return new Promise((resolve) => {\n    const sub = AppState.addEventListener(\"change\", (state) => {\n      if (state === \"active\") {\n        sub.remove()\n        resolve()\n      }\n    })\n  })\n}\n\nexport async function initializePlayer() {\n  TrackPlayer.registerPlaybackService(() => async () => {\n    TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play())\n    TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause())\n    TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop())\n    TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext())\n    TrackPlayer.addEventListener(Event.RemotePrevious, () => TrackPlayer.skipToPrevious())\n    TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => TrackPlayer.seekTo(position))\n  })\n\n  // On Android, setupPlayer must be called in the foreground to avoid\n  // ForegroundServiceStartNotAllowedException\n  if (Platform.OS === \"android\") {\n    await waitForForeground()\n  }\n\n  const setup = async (retry = 10) => {\n    if (retry <= 0) {\n      console.error(\"Failed to setup player after multiple attempts\")\n      return\n    }\n    try {\n      await TrackPlayer.setupPlayer()\n      PlayerRegistered = true\n    } catch (_err) {\n      const err = _err as Error & { code?: string }\n      console.error(\"Failed to setup player:\", \"Code:\", err.code, err.message)\n\n      if (err.code === \"android_cannot_setup_player_in_background\") {\n        await waitForForeground()\n        await setup(retry - 1)\n      }\n    }\n  }\n\n  await setup()\n\n  if (!PlayerRegistered) return\n\n  await TrackPlayer.updateOptions({\n    capabilities: [\n      Capability.Play,\n      Capability.Pause,\n      Capability.SkipToNext,\n      Capability.SkipToPrevious,\n      Capability.Stop,\n      Capability.SeekTo,\n    ],\n    compactCapabilities: [Capability.Play, Capability.Pause],\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/interfaces/settings/data.ts",
    "content": "export interface DataSettings {\n  sendAnonymousData: boolean\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api-client.ts",
    "content": "import { userActions } from \"@follow/store/user/store\"\nimport { createMobileAPIHeaders } from \"@follow/utils/headers\"\nimport { FollowClient } from \"@follow-app/client-sdk\"\nimport { fetch } from \"expo/fetch\"\nimport { nativeApplicationVersion } from \"expo-application\"\nimport { Platform } from \"react-native\"\nimport DeviceInfo from \"react-native-device-info\"\n\nimport { LoginScreen } from \"../screens/(modal)/LoginScreen\"\nimport { getAuthStateRevision, getCookie, getLastAuthStateChangeAt } from \"./auth\"\nimport { getClientId, getSessionId } from \"./client-session\"\nimport { getUserAgent } from \"./native/user-agent\"\nimport { Navigation } from \"./navigation/Navigation\"\nimport { proxyEnv } from \"./proxy-env\"\n\nexport const followClient = new FollowClient({\n  credentials: \"omit\",\n  timeout: 30000,\n  baseURL: proxyEnv.API_URL,\n  fetch: async (input, options = {}) => fetch(input.toString(), options as any) as any,\n})\n\nexport const followApi = followClient.api\nfollowClient.addRequestInterceptor(async (ctx) => {\n  const { url } = ctx\n\n  try {\n    const urlObj = new URL(url)\n    urlObj.searchParams.set(\"t\", Date.now().toString())\n    ctx.url = urlObj.toString()\n  } catch {\n    /* empty */\n  }\n\n  return ctx\n})\nfollowClient.addRequestInterceptor(async (ctx) => {\n  const { options } = ctx\n  ;(options as typeof options & { __followAuthRevision?: number }).__followAuthRevision =\n    getAuthStateRevision()\n\n  const header = options.headers || {}\n  header[\"X-Client-Id\"] = getClientId()\n  header[\"X-Session-Id\"] = getSessionId()\n  header[\"User-Agent\"] = await getUserAgent()\n  header[\"cookie\"] = getCookie()\n\n  const apiHeader = createMobileAPIHeaders({\n    version: nativeApplicationVersion || \"\",\n    rnPlatform: {\n      OS: Platform.OS,\n      isPad: Platform.OS === \"ios\" && Platform.isPad,\n    },\n    installerPackageName: await DeviceInfo.getInstallerPackageName(),\n  })\n\n  options.headers = {\n    ...header,\n    ...apiHeader,\n  }\n  return ctx\n})\n\nconst getRequestCookie = (headers: HeadersInit | undefined) => {\n  if (!headers) {\n    return\n  }\n\n  if (headers instanceof Headers) {\n    return headers.get(\"cookie\") ?? undefined\n  }\n\n  if (Array.isArray(headers)) {\n    return headers.find(([key]) => key.toLowerCase() === \"cookie\")?.[1]\n  }\n\n  return Object.entries(headers).find(([key]) => key.toLowerCase() === \"cookie\")?.[1]\n}\n\nconst getRequestAuthRevision = (options: Record<string, unknown>) => {\n  const revision = options.__followAuthRevision\n  return typeof revision === \"number\" ? revision : undefined\n}\n\nfollowClient.addResponseInterceptor(async (ctx) => {\n  const { options, response } = ctx\n  if (response.status === 401) {\n    const currentCookie = getCookie()\n    const requestCookie = getRequestCookie(options.headers)\n    const requestAuthRevision = getRequestAuthRevision(options as Record<string, unknown>)\n    const currentAuthRevision = getAuthStateRevision()\n\n    if (typeof requestAuthRevision === \"number\" && requestAuthRevision < currentAuthRevision) {\n      return ctx.response\n    }\n\n    if (currentCookie && requestCookie !== currentCookie) {\n      return ctx.response\n    }\n\n    if (currentCookie) {\n      return ctx.response\n    }\n\n    if (Date.now() - getLastAuthStateChangeAt() < 10_000) {\n      return ctx.response\n    }\n\n    userActions.removeCurrentUser()\n    Navigation.rootNavigation.presentControllerView(LoginScreen)\n  } else if (response.status >= 400) {\n    // try {\n    //   const isJSON = response.headers.get(\"content-type\")?.includes(\"application/json\")\n    //   const json = isJSON ? await response.json() : null\n    //   if (isJSON && json?.code?.toString().startsWith(\"11\")) {\n    //   }\n    // } catch (error) {\n    //   console.error(`Request failed with status ${response.status}`, error)\n    // }\n  }\n\n  return ctx.response\n})\n"
  },
  {
    "path": "apps/mobile/src/lib/auth-cookie-migration.ts",
    "content": "import { createMobileAPIHeaders } from \"@follow/utils/headers\"\nimport { nativeApplicationVersion } from \"expo-application\"\nimport { Platform } from \"react-native\"\nimport DeviceInfo from \"react-native-device-info\"\n\nimport { getCookie, oneTimeToken } from \"./auth\"\nimport { getClientId, getSessionId } from \"./client-session\"\nimport { getUserAgent } from \"./native/user-agent\"\nimport { proxyEnv } from \"./proxy-env\"\n\nconst LEGACY_PROD_API_URL = \"https://api.follow.is\"\nconst NEW_PROD_API_URL = \"https://api.folo.is\"\n\nconst authSessionEndpoint = \"/better-auth/get-session\"\nconst migrationRequestTimeout = 6000\nlet migrationAttempted = false\n\nconst fetchWithTimeout = async (input: string, options: RequestInit) => {\n  const controller = new AbortController()\n  const timer = setTimeout(() => {\n    controller.abort()\n  }, migrationRequestTimeout)\n\n  try {\n    return await fetch(input, {\n      ...options,\n      signal: controller.signal,\n    })\n  } finally {\n    clearTimeout(timer)\n  }\n}\n\nconst createMigrationHeaders = async () => {\n  const headers = createMobileAPIHeaders({\n    version: nativeApplicationVersion || \"\",\n    rnPlatform: {\n      OS: Platform.OS,\n      isPad: Platform.OS === \"ios\" && Platform.isPad,\n    },\n    installerPackageName: await DeviceInfo.getInstallerPackageName(),\n  })\n\n  return {\n    ...headers,\n    \"X-Client-Id\": getClientId(),\n    \"X-Session-Id\": getSessionId(),\n    \"User-Agent\": await getUserAgent(),\n    \"expo-origin\": \"follow://\",\n    \"x-skip-oauth-proxy\": \"true\",\n  }\n}\n\nconst hasValidSessionOnApiDomain = async (apiURL: string) => {\n  const cookie = getCookie()\n  if (!cookie) {\n    return false\n  }\n\n  try {\n    const migrationHeaders = await createMigrationHeaders()\n    const response = await fetchWithTimeout(`${apiURL}${authSessionEndpoint}`, {\n      credentials: \"omit\",\n      headers: {\n        cookie,\n        ...migrationHeaders,\n      },\n      method: \"GET\",\n    })\n\n    if (!response.ok) {\n      return false\n    }\n\n    const data = (await response.json()) as { user?: unknown }\n    return Boolean(data?.user)\n  } catch {\n    return false\n  }\n}\n\nconst getLegacyOneTimeToken = async () => {\n  const cookie = getCookie()\n  if (!cookie) {\n    return null\n  }\n\n  try {\n    const migrationHeaders = await createMigrationHeaders()\n    const response = await fetchWithTimeout(\n      `${LEGACY_PROD_API_URL}/better-auth/one-time-token/generate`,\n      {\n        credentials: \"omit\",\n        headers: {\n          cookie,\n          ...migrationHeaders,\n        },\n        method: \"GET\",\n      },\n    )\n\n    if (!response.ok) {\n      return null\n    }\n\n    const data = (await response.json()) as { token?: string }\n    return data.token ?? null\n  } catch {\n    return null\n  }\n}\n\nexport const migrateLegacyApiSession = async () => {\n  if (migrationAttempted) {\n    return\n  }\n  migrationAttempted = true\n\n  if (proxyEnv.API_URL !== NEW_PROD_API_URL) {\n    return\n  }\n\n  const hasSessionOnNewApi = await hasValidSessionOnApiDomain(NEW_PROD_API_URL)\n  if (hasSessionOnNewApi) {\n    return\n  }\n\n  const hasSessionOnLegacyApi = await hasValidSessionOnApiDomain(LEGACY_PROD_API_URL)\n  if (!hasSessionOnLegacyApi) {\n    return\n  }\n\n  const token = await getLegacyOneTimeToken()\n  if (!token) {\n    return\n  }\n\n  await oneTimeToken.apply({ token })\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/auth.ts",
    "content": "import { expoClient } from \"@better-auth/expo/client\"\nimport { baseAuthPlugins } from \"@follow/shared/auth\"\nimport { isNewUserQueryKey } from \"@follow/store/user/constants\"\nimport { whoamiQueryKey } from \"@follow/store/user/hooks\"\nimport { userActions } from \"@follow/store/user/store\"\nimport { createMobileAPIHeaders } from \"@follow/utils/headers\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { createAuthClient } from \"better-auth/react\"\nimport { nativeApplicationVersion } from \"expo-application\"\nimport * as FileSystem from \"expo-file-system/legacy\"\nimport Storage from \"expo-sqlite/kv-store\"\nimport { Platform } from \"react-native\"\nimport DeviceInfo from \"react-native-device-info\"\n\nimport { getDbPath } from \"@/src/database\"\n\nimport { getClientId, getSessionId } from \"./client-session\"\nimport { getUserAgent } from \"./native/user-agent\"\nimport { Navigation } from \"./navigation/Navigation\"\nimport { getEnvProfile, proxyEnv } from \"./proxy-env\"\nimport { queryClient } from \"./query-client\"\nimport { safeSecureStore } from \"./secure-store\"\n\nconst storagePrefix = \"follow_auth\"\nexport const cookieKey = `${storagePrefix}_cookie`\nexport const sessionTokenKey = \"__Secure-better-auth.session_token\"\nconst sessionDataKey = `${storagePrefix}_session_data`\n\nlet authStateRevision = 0\nlet lastAuthStateChangeAt = 0\n\nexport const getAuthStateRevision = () => authStateRevision\nexport const getLastAuthStateChangeAt = () => lastAuthStateChangeAt\n\nconst bumpAuthStateRevision = () => {\n  authStateRevision += 1\n  lastAuthStateChangeAt = Date.now()\n  return authStateRevision\n}\n\n// Session-scoped queries must be reset on auth transitions, otherwise mounted timeline\n// queries can keep rendering anonymous cache under the new session.\nconst refreshSessionQueries = () =>\n  Promise.allSettled([\n    queryClient.invalidateQueries({ queryKey: whoamiQueryKey }),\n    queryClient.invalidateQueries({ queryKey: isNewUserQueryKey }),\n    queryClient.resetQueries({ queryKey: [\"entries\"] }),\n    queryClient.resetQueries({ queryKey: [\"subscription\"] }),\n    queryClient.resetQueries({ queryKey: [\"unread\"] }),\n    queryClient.resetQueries({ queryKey: [\"owned\", \"lists\"] }),\n  ])\n\nconst plugins = [\n  ...baseAuthPlugins,\n  expoClient({\n    scheme: \"follow\",\n    storagePrefix,\n    storage: {\n      setItem(key: string, value: string) {\n        const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null\n        try {\n          safeSecureStore.setItem(key, value)\n        } catch (e) {\n          console.warn(\"SecureStore.setItem failed:\", e)\n          return\n        }\n\n        if (key === cookieKey) {\n          if (__DEV__) {\n            const env = getEnvProfile()\n            try {\n              safeSecureStore.setItem(`${cookieKey}_${env}`, value)\n            } catch {\n              // Keychain may be unavailable in background\n            }\n          }\n          bumpAuthStateRevision()\n          const authStateChanged = !previousValue\n          if (authStateChanged) {\n            void refreshSessionQueries()\n          }\n        }\n      },\n      getItem(key: string) {\n        try {\n          return safeSecureStore.getItem(key)\n        } catch (e) {\n          console.warn(\"SecureStore.getItem failed:\", e)\n          return null\n        }\n      },\n      removeItem(key: string) {\n        const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null\n        try {\n          safeSecureStore.removeItem(key)\n        } catch (e) {\n          console.warn(\"SecureStore.removeItem failed:\", e)\n          return\n        }\n\n        if (key === cookieKey) {\n          if (__DEV__) {\n            const env = getEnvProfile()\n            safeSecureStore.removeItem(`${cookieKey}_${env}`)\n          }\n          bumpAuthStateRevision()\n          if (previousValue) {\n            void refreshSessionQueries()\n          }\n        }\n      },\n    } as any,\n  }),\n]\n\nexport const authClient = createAuthClient({\n  baseURL: `${proxyEnv.API_URL}/better-auth`,\n  fetchOptions: {\n    cache: \"no-store\",\n    // Learn more: https://better-fetch.vercel.app/docs/hooks\n    onRequest: async (ctx) => {\n      const headers = createMobileAPIHeaders({\n        version: nativeApplicationVersion || \"\",\n        rnPlatform: {\n          OS: Platform.OS,\n          isPad: Platform.OS === \"ios\" && Platform.isPad,\n        },\n        installerPackageName: await DeviceInfo.getInstallerPackageName(),\n      })\n\n      Object.entries(headers).forEach(([key, value]) => {\n        ctx.headers.set(key, value)\n      })\n      ctx.headers.set(\"User-Agent\", await getUserAgent())\n\n      const value = Storage.getItemSync(\"referral-code\")\n      if (value) {\n        const referralCode = JSON.parse(value)\n        if (referralCode) {\n          ctx.headers.set(\"folo-referral-code\", referralCode)\n        }\n      }\n\n      return ctx\n    },\n    headers: {\n      \"X-Client-Id\": getClientId(),\n      \"X-Session-Id\": getSessionId(),\n    },\n  },\n  plugins,\n})\n\n// @keep-sorted\nexport const {\n  changeEmail,\n  changePassword,\n  getAccountInfo,\n  getCookie,\n  getProviders,\n  linkSocial,\n  oneTimeToken,\n  sendVerificationEmail,\n  signIn,\n  signUp,\n  twoFactor,\n  unlinkAccount,\n  updateUser,\n  useSession,\n} = authClient\n\nexport const forgetPassword = authClient.requestPasswordReset\n\nexport interface AuthProvider {\n  name: string\n  id: string\n  color: string\n  icon: string\n  icon64: string\n  iconDark64?: string\n}\n\nexport const useAuthProviders = () => {\n  return useQuery({\n    queryKey: [\"providers\"],\n    queryFn: async () => {\n      const data = (await getProviders()).data as Record<string, AuthProvider>\n      if (Platform.OS !== \"ios\") {\n        delete data.apple\n      }\n      return data\n    },\n  })\n}\n\nexport function isAuthCodeValid(authCode: string) {\n  return (\n    authCode.length === 6 && !Array.from(authCode).some((c) => Number.isNaN(Number.parseInt(c)))\n  )\n}\n\nexport const signOut = async () => {\n  await authClient.signOut()\n  safeSecureStore.removeItem(cookieKey)\n  safeSecureStore.removeItem(sessionTokenKey)\n  safeSecureStore.removeItem(sessionDataKey)\n  if (__DEV__) {\n    safeSecureStore.removeItem(`${cookieKey}_${getEnvProfile()}`)\n  }\n  await userActions.removeCurrentUser()\n  Navigation.rootNavigation.popToRoot()\n  bumpAuthStateRevision()\n  await refreshSessionQueries()\n  const dbPath = getDbPath()\n  await FileSystem.deleteAsync(dbPath)\n  await expo.reloadAppAsync(\"User sign out\")\n}\n\nexport const deleteUser = async ({ TOTPCode }: { TOTPCode?: string }) => {\n  if (!TOTPCode) {\n    return\n  }\n  await authClient.deleteUserCustom({\n    TOTPCode,\n  })\n  await signOut()\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/client-session.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\n\nimport { kv } from \"./kv\"\n\nconst CLIENT_ID_KEY = getStorageNS(\"client_id\")\nconst SESSION_ID_KEY = getStorageNS(\"session_id\")\n\nconst uuid = () => {\n  return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replaceAll(/[xy]/g, (c) => {\n    const r = (Math.random() * 16) | 0,\n      v = c === \"x\" ? r : (r & 0x3) | 0x8\n    return v.toString(16)\n  })\n}\n// In-memory cache for session data\nconst sessionCache: Record<string, string> = {}\n\nexport const getClientId = (): string => {\n  const clientId = kv.getSync(CLIENT_ID_KEY)\n  if (!clientId) {\n    const newClientId = uuid()\n    kv.setSync(CLIENT_ID_KEY, newClientId)\n    return newClientId\n  }\n  return clientId\n}\n\nexport const getSessionId = (): string => {\n  // First check in-memory cache\n  if (sessionCache[SESSION_ID_KEY]) {\n    return sessionCache[SESSION_ID_KEY]\n  }\n\n  // Check persistent storage\n  const sessionId = kv.getSync(SESSION_ID_KEY)\n  if (!sessionId) {\n    const newSessionId = uuid()\n    // Store in both cache and persistent storage\n    sessionCache[SESSION_ID_KEY] = newSessionId\n    kv.setSync(SESSION_ID_KEY, newSessionId)\n    return newSessionId\n  }\n\n  // Store in cache for future access\n  sessionCache[SESSION_ID_KEY] = sessionId\n  return sessionId\n}\n\nexport const clearSessionId = (): void => {\n  // Clear from both cache and persistent storage\n  delete sessionCache[SESSION_ID_KEY]\n  kv.delete(SESSION_ID_KEY)\n}\n\nexport const clearClientId = (): void => {\n  kv.delete(CLIENT_ID_KEY)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/dialog-state.ts",
    "content": "import { atom } from \"jotai\"\n\nexport const dialogCountAtom = atom(0)\n"
  },
  {
    "path": "apps/mobile/src/lib/dialog.tsx",
    "content": "/* eslint-disable react-hooks/rules-of-hooks */\nimport { cn } from \"@follow/utils\"\nimport { t } from \"i18next\"\nimport type { Dispatch, FC, ReactElement, ReactNode, SetStateAction } from \"react\"\nimport {\n  cloneElement,\n  createContext,\n  createElement,\n  isValidElement,\n  use,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\"\nimport { Pressable, View } from \"react-native\"\nimport Animated, { SlideInUp, SlideOutUp } from \"react-native-reanimated\"\nimport RootSiblings from \"react-native-root-siblings\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport type { ImageProps } from \"react-native-svg\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { dialogCountAtom } from \"@/src/lib/dialog-state\"\nimport { jotaiStore } from \"@/src/lib/jotai\"\n\nimport { FullWindowOverlay } from \"../components/common/FullWindowOverlay\"\nimport { Overlay } from \"../components/ui/overlay/Overlay\"\nimport { Navigation } from \"./navigation/Navigation\"\nimport { NavigationInstanceContext } from \"./navigation/NavigationInstanceContext\"\n\nexport interface DialogProps<Ctx> {\n  title?: string\n  content: ReactNode\n  variant?: \"destructive\" | \"default\"\n  onClose?: (ctx: Ctx & DialogContextType) => void\n  onConfirm?: (ctx: Ctx & DialogContextType) => void\n  cancelText?: string\n  confirmText?: string\n  headerIcon?: ReactNode\n  HeaderComponent?: FC<{\n    title: string\n    onClose: () => void\n  }>\n  id: string\n}\ninterface ShowDialogOptions<Ctx> {\n  override?: {\n    onClose?: (ctx: Ctx & DialogContextType) => void\n    onConfirm?: (ctx: Ctx & DialogContextType) => void\n    cancelText?: string\n    confirmText?: string\n  }\n}\nconst entering = SlideInUp.springify()\nconst exiting = SlideOutUp.duration(400)\ntype DialogContextType = {\n  dismiss: () => void\n  bizOnConfirm: (() => void) | null\n  bizOnCancel: (() => void) | null\n}\nconst DialogDynamicButtonActionContext = createContext<{\n  onConfirm: (() => void) | null\n  onCancel: (() => void) | null\n}>({\n  onConfirm: null,\n  onCancel: null,\n})\nconst SetDialogDynamicButtonActionContext = createContext<{\n  setOnConfirm: Dispatch<SetStateAction<(() => void) | null>>\n  setOnCancel: Dispatch<SetStateAction<(() => void) | null>>\n}>({\n  setOnConfirm: () => {},\n  setOnCancel: () => {},\n})\nconst DialogContext = createContext<DialogContextType | null>(null)\nexport type DialogComponent<Ctx = unknown> = FC<{\n  ctx: Ctx\n}> &\n  Omit<DialogProps<Ctx>, \"content\">\nclass DialogStatic {\n  useDialogContext = () => {\n    return use(DialogContext)\n  }\n  private currentStackedDialogs = new Set<string>()\n\n  // Components\n  DialogConfirm: FC<{\n    onPress: () => void\n  }> = ({ onPress }) => {\n    const { setOnConfirm } = use(SetDialogDynamicButtonActionContext)\n\n    useEffect(() => {\n      setOnConfirm(() => {\n        return onPress\n      })\n    }, [onPress, setOnConfirm])\n    return null\n  }\n  DialogCancel: FC<{\n    onPress: () => void\n  }> = ({ onPress }) => {\n    const { setOnCancel } = use(SetDialogDynamicButtonActionContext)\n\n    const { dismiss } = use(DialogContext)!\n\n    useEffect(() => {\n      let timeout: NodeJS.Timeout\n      setOnCancel(() => {\n        return () => {\n          dismiss()\n          clearTimeout(timeout)\n          timeout = setTimeout(() => {\n            onPress()\n          }, 16)\n        }\n      })\n      return () => {\n        clearTimeout(timeout)\n      }\n    }, [onPress, setOnCancel, dismiss])\n    return null\n  }\n  show<Ctx>(\n    propsOrComponent: DialogProps<Ctx> | DialogComponent<Ctx>,\n    options?: ShowDialogOptions<Ctx>,\n  ) {\n    const isExist = this.currentStackedDialogs.has(propsOrComponent.id)\n    const override = options?.override\n    if (isExist) {\n      return\n    }\n    const props =\n      \"content\" in propsOrComponent\n        ? propsOrComponent\n        : (propsOrComponent as unknown as DialogProps<Ctx>)\n    const dismiss = () => this.destroy(props.id, siblings)\n    const reactCtx: DialogContextType = {\n      dismiss,\n      get bizOnConfirm() {\n        return () => {\n          handleConfirm()\n        }\n      },\n      get bizOnCancel() {\n        return () => {\n          handleClose()\n        }\n      },\n    }\n    const mergeCtx = (ctx: Ctx) => ({\n      ...ctx,\n      ...reactCtx,\n    })\n    const ctx = {} as Ctx\n    const children =\n      \"content\" in propsOrComponent\n        ? propsOrComponent.content\n        : createElement(propsOrComponent, {\n            ctx,\n          })\n    const handleClose = () => {\n      dismiss()\n      setTimeout(() => {\n        if (override?.onClose) {\n          override.onClose(mergeCtx(ctx))\n        } else {\n          props.onClose?.(mergeCtx(ctx))\n        }\n      }, 16)\n    }\n    const handleConfirm = () => {\n      if (override?.onConfirm) {\n        override.onConfirm(mergeCtx(ctx))\n      } else {\n        props.onConfirm?.(mergeCtx(ctx))\n        handleClose()\n      }\n    }\n    const Header = props.HeaderComponent ? (\n      createElement(props.HeaderComponent, {\n        title: props.title ?? \"\",\n        onClose: handleClose,\n      })\n    ) : props.title ? (\n      <DefaultHeader title={props.title} headerIcon={props.headerIcon} />\n    ) : null\n    const siblings = new RootSiblings(\n      <NavigationInstanceContext value={Navigation.rootNavigation}>\n        <FullWindowOverlay>\n          <Overlay onPress={handleClose} />\n          <Animated.View\n            className=\"absolute inset-x-0 -top-8 z-10 bg-system-background pt-8\"\n            entering={entering}\n            exiting={exiting}\n          >\n            <SafeInsetTop />\n            <DialogDynamicButtonActionProvider>\n              {Header}\n              <View className={cn(\"px-6 pb-4\", Header ? \"pt-4\" : \"pt-0\")}>\n                <DialogContext value={reactCtx}>{children}</DialogContext>\n              </View>\n\n              <View className=\"flex-row gap-4 px-6 pb-4\">\n                <DialogDynamicButtonAction\n                  fallbackCaller={handleClose}\n                  text={override?.cancelText ?? props.cancelText ?? t(\"common:words.cancel\")}\n                  type=\"cancel\"\n                  textClassName={cn(props.variant === \"destructive\" && \"font-bold\")}\n                />\n\n                <DialogDynamicButtonAction\n                  fallbackCaller={handleConfirm}\n                  text={override?.confirmText ?? props.confirmText ?? t(\"common:words.confirm\")}\n                  type=\"confirm\"\n                  className={props.variant === \"destructive\" ? \"bg-red\" : \"bg-accent\"}\n                  textClassName={cn(\"text-white\", props.variant !== \"destructive\" && \"font-bold\")}\n                />\n              </View>\n            </DialogDynamicButtonActionProvider>\n          </Animated.View>\n        </FullWindowOverlay>\n      </NavigationInstanceContext>,\n    )\n    this.currentStackedDialogs.add(props.id)\n    jotaiStore.set(dialogCountAtom, this.currentStackedDialogs.size)\n    return siblings\n  }\n  destroy(id: string, siblings: RootSiblings) {\n    this.currentStackedDialogs.delete(id)\n    jotaiStore.set(dialogCountAtom, this.currentStackedDialogs.size)\n    siblings.destroy()\n  }\n}\nconst SafeInsetTop = () => {\n  const insets = useSafeAreaInsets()\n  return (\n    <View\n      style={{\n        height: insets.top,\n      }}\n    />\n  )\n}\nexport const Dialog = new DialogStatic()\nconst DefaultHeader = (props: { title?: string; headerIcon?: ReactNode }) => {\n  const label = useColor(\"label\")\n  if (!props.title) return null\n  return (\n    <View className=\"flex-row items-center gap-2 px-6\">\n      {isValidElement(props.headerIcon) &&\n        props.headerIcon &&\n        typeof props.headerIcon === \"object\" &&\n        cloneElement(\n          props.headerIcon as ReactElement,\n          {\n            color: label,\n            height: 20,\n            width: 20,\n          } as Partial<ImageProps>,\n        )}\n      <Text className=\"text-lg font-semibold text-label\">{props.title}</Text>\n    </View>\n  )\n}\nconst DialogDynamicButtonAction = (props: {\n  type: \"confirm\" | \"cancel\"\n  text: string\n  fallbackCaller: () => void\n  className?: string\n  textClassName?: string\n}) => {\n  const { onConfirm, onCancel } = use(DialogDynamicButtonActionContext)\n  const caller =\n    {\n      confirm: onConfirm,\n      cancel: onCancel,\n    }[props.type] || props.fallbackCaller\n  return (\n    <Pressable\n      className={cn(\n        \"flex-1 items-center justify-center rounded-full bg-system-fill px-6 py-3\",\n        props.className,\n      )}\n      onPress={caller}\n    >\n      <Text className={cn(\"text-base font-medium text-label\", props.textClassName)}>\n        {props.text}\n      </Text>\n    </Pressable>\n  )\n}\nconst DialogDynamicButtonActionProvider = (props: { children: ReactNode }) => {\n  const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null)\n  const [onCancel, setOnCancel] = useState<(() => void) | null>(null)\n  const ctx1 = useMemo(\n    () => ({\n      onConfirm,\n      onCancel,\n    }),\n    [onConfirm, onCancel],\n  )\n  const ctx2 = useMemo(\n    () => ({\n      setOnConfirm,\n      setOnCancel,\n    }),\n    [setOnConfirm, setOnCancel],\n  )\n  return (\n    <DialogDynamicButtonActionContext value={ctx1}>\n      <SetDialogDynamicButtonActionContext value={ctx2}>\n        {props.children}\n      </SetDialogDynamicButtonActionContext>\n    </DialogDynamicButtonActionContext>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/e2e-config.ts",
    "content": "import Constants from \"expo-constants\"\n\ninterface AppExtra {\n  e2eEnvProfile?: string | null\n  e2eLanguage?: string | null\n}\n\nconst getAppExtra = (): AppExtra => (Constants.expoConfig?.extra ?? {}) as AppExtra\n\nexport const getE2EEnvProfile = () =>\n  getAppExtra().e2eEnvProfile ?? process.env.EXPO_PUBLIC_E2E_ENV_PROFILE ?? null\n\nexport const getE2ELanguage = () =>\n  getAppExtra().e2eLanguage ?? process.env.EXPO_PUBLIC_E2E_LANGUAGE ?? null\n\nexport const isE2EEnabled = () => Boolean(getE2EEnvProfile() || getE2ELanguage())\n"
  },
  {
    "path": "apps/mobile/src/lib/error-parser.ts",
    "content": "import { FollowAPIError } from \"@follow-app/client-sdk\"\nimport { t } from \"i18next\"\nimport { FetchError } from \"ofetch\"\n\nimport { getIsPaymentEnabled } from \"@/src/atoms/server-configs\"\nimport { showUpgradeRequiredDialog } from \"@/src/modules/dialogs/UpgradeRequiredDialog\"\n\nimport { toast } from \"./toast\"\n\nexport const getFetchErrorInfo = (\n  error: Error,\n): {\n  message: string\n  code?: number\n} => {\n  if (error instanceof FetchError) {\n    try {\n      const json = JSON.parse(error.response?._data)\n\n      const { reason, code, message } = json\n      const i18nKey = `errors:${code}` as any\n      const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)\n      return {\n        message: `${i18nMessage}${reason ? `: ${reason}` : \"\"}`,\n        code,\n      }\n    } catch {\n      return { message: error.message }\n    }\n  }\n\n  if (error instanceof FollowAPIError && error.code) {\n    const code = Number(error.code)\n    try {\n      const i18nKey = `errors:${code}` as any\n      const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)\n      return {\n        message: i18nMessage,\n        code,\n      }\n    } catch {\n      return { message: error.message }\n    }\n  }\n\n  return { message: error.message }\n}\n\nexport const getFetchErrorMessage = (error: Error) => {\n  const { message } = getFetchErrorInfo(error)\n  return message\n}\n\n/**\n * Just a wrapper around `toastFetchError` to create a function that can be used as a callback.\n */\nexport const createErrorToaster = (title?: string) => (err: Error) =>\n  toastFetchError(err, { title })\n\nexport const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {\n  const { message: fallbackMessage } = error\n  let message = fallbackMessage\n  let _reason = \"\"\n  let code: number | undefined\n  let status: number | undefined\n\n  if (error instanceof FetchError) {\n    try {\n      const resolvedStatus = error.statusCode ?? error.response?.status\n      if (resolvedStatus != null) {\n        status = Number(resolvedStatus)\n      }\n      const json =\n        typeof error.response?._data === \"string\"\n          ? JSON.parse(error.response?._data)\n          : error.response?._data\n\n      const { reason, code: _code, message: _message } = json\n      if (_code != null) {\n        code = typeof _code === \"number\" ? _code : Number(_code)\n      }\n      message = typeof _message === \"string\" && _message.trim() ? _message : message\n      if (code != null) {\n        const tValue = t(`errors:${code}` as any)\n        if (tValue !== `${code}`) {\n          message = tValue\n        }\n      }\n\n      if (typeof reason === \"string\" && reason.trim()) {\n        _reason = reason\n      }\n    } catch {\n      message = fallbackMessage\n    }\n  }\n\n  if (error instanceof FollowAPIError) {\n    if (error.code) {\n      code = Number(error.code)\n    }\n    status = error.status ? Number(error.status) : status\n    try {\n      if (error.code) {\n        const tValue = t(`errors:${code}` as any)\n        const i18nMessage = tValue === code?.toString() ? error.message : tValue\n        message = i18nMessage\n      } else {\n        message = fallbackMessage\n      }\n    } catch {\n      message = fallbackMessage\n    }\n  }\n\n  // 2fa errors are handled by the form\n  if (code === 4007 || code === 4008) {\n    return\n  }\n\n  const isPaymentFeatureEnabled = getIsPaymentEnabled()\n\n  if (status === 402 && !isPaymentFeatureEnabled) {\n    return toast.error(t(\"errors:1004\"))\n  }\n\n  const needUpgradeError = status === 402 && isPaymentFeatureEnabled\n\n  if (needUpgradeError) {\n    showUpgradeRequiredDialog({\n      title: message || t(\"settings:subscription.actions.upgrade\"),\n      message: t(\"settings:subscription.summary.free_description\"),\n    })\n    return\n  }\n\n  if (!_reason) {\n    const title = _title || message || \"Unknown error\"\n    return toast.error(title)\n  } else {\n    return toast.error(message || _title || \"Unknown error\")\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/event-bus.ts",
    "content": "// eslint-disable-next-line unicorn/no-empty-file\n"
  },
  {
    "path": "apps/mobile/src/lib/ga4.ts",
    "content": "import { followClient } from \"./api-client\"\nimport { getClientId, getSessionId } from \"./client-session\"\n\nclass Analytics4 {\n  private clientID: string\n  private sessionID: string\n  private userID: string | null = null\n  private userProperties: Record<string, { value: unknown }> | null = null\n\n  constructor(clientID: string = getClientId(), sessionID = getSessionId()) {\n    this.clientID = clientID\n    this.sessionID = sessionID\n  }\n\n  async setUserId(id: string) {\n    this.userID = id\n  }\n\n  async setUserProperties(upValue?: Record<string, unknown>) {\n    const userProperties = Object.entries(upValue || {}).reduce(\n      (acc, [key, value]) => {\n        acc[key as string] = {\n          value,\n        }\n        return acc\n      },\n      {} as Record<string, { value: unknown }>,\n    )\n    this.userProperties = userProperties\n  }\n\n  async logEvent(eventName: string, params?: Record<string, unknown>): Promise<any> {\n    delete params?.__code\n    delete params?.__eventName\n\n    const payload = {\n      client_id: this.clientID,\n      user_id: this.userID,\n      events: [\n        {\n          name: eventName,\n          params: {\n            session_id: this.sessionID,\n            engagement_time_msec: 1000,\n            ...params,\n          },\n        },\n      ],\n      user_properties: this.userProperties,\n    }\n\n    return followClient.api.data.sendAnalytics(payload as any)\n  }\n}\n\nexport const ga4 = new Analytics4()\n"
  },
  {
    "path": "apps/mobile/src/lib/i18n.ts",
    "content": "import dayjs from \"dayjs\"\nimport { getLocales } from \"expo-localization\"\nimport i18n from \"i18next\"\nimport { initReactI18next } from \"react-i18next\"\n\nimport {\n  currentSupportedLanguages,\n  dayjsLocaleImportMap,\n  defaultNS,\n  ns,\n} from \"@/src/@types/constants\"\nimport { defaultResources } from \"@/src/@types/default-resource\"\n\nimport { getGeneralSettings } from \"../atoms/settings/general\"\nimport { getE2ELanguage } from \"./e2e-config\"\n\nconst fallbackLanguage = \"en\"\n\nconst getForcedLanguage = () => {\n  const language = getE2ELanguage()\n  if (!language) {\n    return null\n  }\n\n  return currentSupportedLanguages.includes(language) ? language : null\n}\n\nexport const updateDayjsLocale = async (lang: string) => {\n  if (!(lang in dayjsLocaleImportMap)) return\n  const dayjsImport = dayjsLocaleImportMap[lang as keyof typeof dayjsLocaleImportMap]\n  const [locale, loader] = dayjsImport\n  await loader()\n  dayjs.locale(locale)\n}\n\nexport async function initializeI18n() {\n  const { language: storedLanguage } = getGeneralSettings()\n  const language = getForcedLanguage() ?? storedLanguage\n\n  return Promise.all([\n    updateDayjsLocale(language),\n    i18n.use(initReactI18next).init({\n      ns,\n      defaultNS,\n      resources: defaultResources,\n\n      lng: language,\n      fallbackLng: {\n        default: [fallbackLanguage],\n        \"zh-TW\": [\"zh-CN\", fallbackLanguage],\n      },\n\n      interpolation: {\n        escapeValue: false,\n      },\n    }),\n  ])\n}\n\nexport function getDeviceLanguage() {\n  const locale = getLocales()[0]\n  if (!locale) {\n    return fallbackLanguage\n  }\n\n  const { languageCode, languageRegionCode } = locale\n  const possibleDeviceLanguage = [\n    languageCode,\n    languageRegionCode,\n    languageCode && languageRegionCode ? `${languageCode}-${languageRegionCode}` : null,\n  ].filter((i) => i !== null)\n\n  return (\n    possibleDeviceLanguage.find((lang) => currentSupportedLanguages.includes(lang)) ||\n    fallbackLanguage\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/image.ts",
    "content": "import { getUrlIcon } from \"@follow/utils/utils\"\n\nimport type { FeedIconFeed } from \"../components/ui/icon/feed-icon\"\n\n/**\n * get feed icon source\n */\nexport const getFeedIconSource = (\n  feed?: FeedIconFeed,\n  siteUrl?: string,\n  fallback = false,\n): string | undefined => {\n  switch (true) {\n    case !feed && !!siteUrl: {\n      const [src] = getFeedIconSrc(siteUrl, fallback)\n      return src\n    }\n    case !!feed && !!feed.image: {\n      return feed.image\n    }\n    case !!feed && !feed.image && !!feed.siteUrl: {\n      const [src] = getFeedIconSrc(feed.siteUrl, fallback)\n      return src\n    }\n    default: {\n      return undefined\n    }\n  }\n}\nconst getFeedIconSrc = (siteUrl: string, fallback: boolean) => {\n  const ret = getUrlIcon(siteUrl, fallback)\n\n  return [ret.src, ret.fallbackUrl]\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/img-proxy.ts",
    "content": "import {\n  getImageProxyUrl as getImageProxyUrlUtils,\n  replaceImgUrlIfNeed as replaceImgUrlIfNeedUtils,\n} from \"@follow/utils/img-proxy\"\n\nexport const getImageProxyUrl = (\n  params: Omit<Parameters<typeof getImageProxyUrlUtils>[0], \"canUseProxy\">,\n) => {\n  return getImageProxyUrlUtils({\n    ...params,\n    canUseProxy: true,\n  })\n}\n\nexport const replaceImgUrlIfNeed = (\n  params: Omit<Parameters<typeof replaceImgUrlIfNeedUtils>[0], \"canUseProxy\">,\n) => {\n  return replaceImgUrlIfNeedUtils({\n    ...params,\n    canUseProxy: true,\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/jotai.ts",
    "content": "import Storage from \"expo-sqlite/kv-store\"\nimport type { SyncStorage } from \"jotai/vanilla/utils/atomWithStorage\"\n\nexport { createAtomAccessor, createAtomHooks, jotaiStore } from \"@follow/utils\"\n\nexport const JotaiPersistSyncStorage: SyncStorage<any> = {\n  getItem: (key, defaultValue) => {\n    const res = Storage.getItemSync(key)\n    if (res === null) {\n      return defaultValue\n    }\n    return JSON.parse(res)\n  },\n  setItem: (key, value) => {\n    return Storage.setItemSync(key, JSON.stringify(value))\n  },\n  removeItem: (key) => {\n    return Storage.removeItemSync(key)\n  },\n  subscribe() {\n    return () => {}\n  },\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/kv.ts",
    "content": "import Storage from \"expo-sqlite/kv-store\"\n\nclass KV {\n  setSync(key: string, value: string) {\n    Storage.setItemSync(key, value)\n  }\n\n  getSync(key: string) {\n    return Storage.getItemSync(key)\n  }\n\n  set(key: string, value: string) {\n    Storage.setItem(key, value)\n  }\n\n  get(key: string) {\n    return Storage.getItem(key)\n  }\n\n  delete(key: string) {\n    return Storage.removeItem(key)\n  }\n\n  clear() {\n    return Storage.clear()\n  }\n\n  keys() {\n    return Storage.getAllKeysSync()\n  }\n\n  [Symbol.iterator]() {\n    return Storage.getAllKeysSync().values()\n  }\n}\n\nexport const kv = new KV()\n"
  },
  {
    "path": "apps/mobile/src/lib/loading.tsx",
    "content": "import { BlurView } from \"expo-blur\"\nimport type { FC } from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { Platform, Pressable, StyleSheet, View } from \"react-native\"\nimport RootSiblings from \"react-native-root-siblings\"\n\nimport { FullWindowOverlay } from \"../components/common/FullWindowOverlay\"\nimport { RotateableLoading } from \"../components/common/RotateableLoading\"\nimport { PlatformActivityIndicator } from \"../components/ui/loading/PlatformActivityIndicator\"\nimport { CloseCuteReIcon } from \"../icons/close_cute_re\"\n\nclass LoadingStatic {\n  start(): { done: () => void }\n  start<T>(promise: Promise<T>): Promise<T>\n  start<T>(promise?: Promise<T>) {\n    const siblings = new RootSiblings(<LoadingContainer cancel={() => siblings.destroy()} />)\n\n    if (promise) {\n      promise.finally(() => siblings.destroy())\n      return promise\n    } else {\n      return {\n        done: () => {\n          siblings.destroy()\n        },\n      }\n    }\n  }\n}\n\nexport const loading = new LoadingStatic()\n\nconst LoadingContainer: FC<{\n  cancel: () => void\n}> = ({ cancel }) => {\n  const cancelTimerRef = useRef<NodeJS.Timeout | null>(null)\n\n  const [showCancelButton, setShowCancelButton] = useState(false)\n  useEffect(() => {\n    cancelTimerRef.current = setTimeout(() => {\n      setShowCancelButton(true)\n    }, 3000)\n    return () => {\n      if (cancelTimerRef.current) {\n        clearTimeout(cancelTimerRef.current)\n      }\n    }\n  }, [])\n\n  return (\n    <FullWindowOverlay>\n      {/* Pressable to prevent the overlay from being clicked */}\n      <Pressable style={StyleSheet.absoluteFillObject} className=\"items-center justify-center\">\n        <View className=\"relative overflow-hidden rounded-2xl border border-non-opaque-separator p-12\">\n          <BlurView style={StyleSheet.absoluteFillObject} tint=\"systemChromeMaterialDark\" />\n          {Platform.OS === \"ios\" ? (\n            <PlatformActivityIndicator size={\"large\"} color=\"white\" />\n          ) : (\n            <RotateableLoading />\n          )}\n        </View>\n        {showCancelButton && (\n          <View className=\"absolute inset-x-0 bottom-24 flex-row justify-center\">\n            <Pressable onPress={cancel}>\n              <View className=\"rounded-full border-2 border-opaque-separator p-2\">\n                <CloseCuteReIcon color=\"gray\" height={20} width={20} />\n              </View>\n            </Pressable>\n          </View>\n        )}\n      </Pressable>\n    </FullWindowOverlay>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/markdown.tsx",
    "content": "import { parseMarkdown } from \"@follow/components/utils/parse-markdown.tsx\"\nimport * as React from \"react\"\nimport { Linking, TextInput, View } from \"react-native\"\n\n// Helper function to ensure text is wrapped in Text component\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nconst wrapText = (children: any): any => {\n  if (typeof children === \"string\") {\n    if (children.trim() === \"\") {\n      return null\n    }\n    return <Text className=\"text-base text-label\">{children}</Text>\n  }\n  if (Array.isArray(children)) {\n    const textKeyCounter = new Map<string, number>()\n    return children.map((child) => {\n      if (typeof child === \"string\") {\n        const normalizedText = child.trim()\n        if (normalizedText === \"\") {\n          return null\n        }\n        const duplicateCount = (textKeyCounter.get(normalizedText) ?? 0) + 1\n        textKeyCounter.set(normalizedText, duplicateCount)\n        return (\n          <Text key={`${normalizedText}-${duplicateCount}`} className=\"text-base text-label\">\n            {child}\n          </Text>\n        )\n      }\n      if (React.isValidElement(child)) {\n        return child\n      }\n      // Handle other types recursively\n      return wrapText(child)\n    })\n  }\n  if (React.isValidElement(children)) {\n    return children\n  }\n  // For any other type, try to convert to string and wrap\n  if (children != null) {\n    if (children.trim() === \"\") {\n      return null\n    }\n    return <Text className=\"text-base text-label\">{String(children)}</Text>\n  }\n  return children\n}\n\n// Helper function to safely render children in Text components\nconst renderTextChildren = (children: any): any => {\n  if (typeof children === \"string\") {\n    return children\n  }\n  if (Array.isArray(children)) {\n    const elementKeyCounter = new Map<string, number>()\n    return children.map((child) => {\n      if (typeof child === \"string\") {\n        return child\n      }\n      if (React.isValidElement(child)) {\n        // If it's already a React element, render it as is\n        const baseKey = child.key != null ? String(child.key) : String(child.type)\n        const duplicateCount = (elementKeyCounter.get(baseKey) ?? 0) + 1\n        elementKeyCounter.set(baseKey, duplicateCount)\n        return <React.Fragment key={`${baseKey}-${duplicateCount}`}>{child}</React.Fragment>\n      }\n      return String(child)\n    })\n  }\n  if (React.isValidElement(children)) {\n    return children\n  }\n  return String(children || \"\")\n}\nexport const renderMarkdown = (markdown: string) => {\n  // Fallback component for unknown HTML elements\n  const FallbackComponent = ({ children, node, ..._props }: any) => {\n    // For text-like elements, use Text\n    if (\n      typeof children === \"string\" ||\n      (Array.isArray(children) && children.every((child) => typeof child === \"string\"))\n    ) {\n      return <Text className=\"text-base text-label\">{renderTextChildren(children)}</Text>\n    }\n    // For container-like elements, use View and wrap any text children\n    return <View>{wrapText(children)}</View>\n  }\n\n  // Create components object with fallback for unknown elements\n  const components = new Proxy(\n    {\n      // React Native compatible components - GitHub markdown style\n      p: ({ children, node, ...props }: any) => (\n        <TextInput className=\"mb-4 text-base text-label\" multiline readOnly {...props}>\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      h1: ({ children, node, ...props }: any) => (\n        <TextInput\n          readOnly\n          multiline\n          className=\"mb-4 mt-6 border-b border-non-opaque-separator pb-2 text-2xl font-semibold text-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      h2: ({ children, node, ...props }: any) => (\n        <TextInput\n          readOnly\n          multiline\n          className=\"mb-4 mt-6 border-b border-non-opaque-separator pb-2 text-xl font-semibold text-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      h3: ({ children, node, ...props }: any) => (\n        <TextInput\n          readOnly\n          multiline\n          className=\"mb-4 mt-6 text-lg font-semibold text-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      h4: ({ children, node, ...props }: any) => (\n        <TextInput\n          readOnly\n          multiline\n          className=\"mb-4 mt-6 text-base font-semibold text-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      h5: ({ children, node, ...props }: any) => (\n        <TextInput\n          readOnly\n          multiline\n          className=\"mb-4 mt-6 text-base font-semibold text-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      h6: ({ children, node, ...props }: any) => (\n        <TextInput\n          readOnly\n          multiline\n          className=\"mb-4 mt-6 text-sm font-semibold text-secondary-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </TextInput>\n      ),\n      ul: ({ children, node, ...props }: any) => (\n        <View className=\"mb-4 pl-4\" {...props}>\n          {wrapText(children)}\n        </View>\n      ),\n      ol: ({ children, node, ...props }: any) => (\n        <View className=\"mb-4 pl-4\" {...props}>\n          {wrapText(children)}\n        </View>\n      ),\n      li: ({ children, node, ordered, index, ...props }: any) => {\n        const bullet = ordered ? `${(index || 0) + 1}.` : \"•\"\n        return (\n          <View className=\"mb-1 flex-row items-start\" {...props}>\n            <Text className=\"mt-0 min-w-[24px] text-base text-label\">{bullet}</Text>\n            <View className=\"flex-1\">\n              <Text className=\"text-base text-label\">{renderTextChildren(children)}</Text>\n            </View>\n          </View>\n        )\n      },\n      strong: ({ children, node, ...props }: any) => (\n        <Text className=\"font-semibold\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      b: ({ children, node, ...props }: any) => (\n        <Text className=\"font-semibold\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      em: ({ children, node, ...props }: any) => (\n        <Text className=\"italic\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      i: ({ children, node, ...props }: any) => (\n        <Text className=\"italic\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      code: ({ children, node, ...props }: any) => (\n        <Text\n          className=\"rounded-md bg-quaternary-system-fill px-2 py-1 font-mono text-callout text-label\"\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      pre: ({ children, node, ...props }: any) => (\n        <View\n          className=\"mb-4 rounded-lg border border-non-opaque-separator bg-secondary-system-background p-4\"\n          {...props}\n        >\n          <Text className=\"font-mono text-callout text-label\">{renderTextChildren(children)}</Text>\n        </View>\n      ),\n      blockquote: ({ children, node, ...props }: any) => (\n        <View\n          className=\"mb-4 rounded-r-lg border-l-4 border-non-opaque-separator border-l-accent bg-secondary-system-background py-2 pl-4\"\n          {...props}\n        >\n          <Text className=\"text-base italic text-secondary-label\">\n            {renderTextChildren(children)}\n          </Text>\n        </View>\n      ),\n      a: ({ children, href, node, ...props }: any) => (\n        <Text\n          className=\"font-medium text-accent underline\"\n          onPress={() => href && Linking.openURL(href)}\n          {...props}\n        >\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      hr: ({ node, ..._props }: any) => <View className=\"my-3 h-px bg-non-opaque-separator\" />,\n      br: ({ node, ..._props }: any) => <Text>{\"\\n\"}</Text>,\n      // Common HTML elements that might appear\n      div: ({ children, node, ...props }: any) => <View {...props}>{wrapText(children)}</View>,\n      span: ({ children, node, ...props }: any) => (\n        <Text className=\"text-base text-label\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      // Table elements (GFM table support) - GitHub style with improved mobile layout\n      table: ({ children, node, ...props }: any) => (\n        <View\n          className=\"mb-4 overflow-hidden rounded-md border border-non-opaque-separator\"\n          {...props}\n        >\n          {wrapText(children)}\n        </View>\n      ),\n      thead: ({ children, node, ...props }: any) => (\n        <View className=\"bg-secondary-system-background\" {...props}>\n          {wrapText(children)}\n        </View>\n      ),\n      tbody: ({ children, node, ...props }: any) => <View {...props}>{wrapText(children)}</View>,\n      tr: ({ children, node, ...props }: any) => (\n        <View className=\"flex-row border-b border-non-opaque-separator last:border-b-0\" {...props}>\n          {wrapText(children)}\n        </View>\n      ),\n      th: ({ children, node, ...props }: any) => {\n        // Get text alignment from node properties if available\n        const align = node?.properties?.align || \"left\"\n        const textAlignStyle =\n          align === \"center\" ? \"text-center\" : align === \"right\" ? \"text-right\" : \"text-left\"\n        return (\n          <Text\n            className={`flex-1 px-3 py-2 text-base font-semibold text-label ${textAlignStyle}`}\n            {...props}\n          >\n            {renderTextChildren(children)}\n          </Text>\n        )\n      },\n      td: ({ children, node, ...props }: any) => {\n        // Get text alignment from node properties if available\n        const align = node?.properties?.align || \"left\"\n        const textAlignStyle =\n          align === \"center\" ? \"text-center\" : align === \"right\" ? \"text-right\" : \"text-left\"\n        return (\n          <Text className={`flex-1 px-3 py-2 text-callout text-label ${textAlignStyle}`} {...props}>\n            {renderTextChildren(children)}\n          </Text>\n        )\n      },\n      // Not implemented\n      img: ({ src, alt, node, ..._props }: any) => null,\n      del: ({ children, node, ...props }: any) => (\n        <Text className=\"text-label line-through\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      sup: ({ children, node, ...props }: any) => (\n        <Text className=\"text-callout text-label\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      sub: ({ children, node, ...props }: any) => (\n        <Text className=\"text-callout text-label\" {...props}>\n          {renderTextChildren(children)}\n        </Text>\n      ),\n      section: ({ children, node, ...props }: any) => (\n        <View className=\"mb-4\" {...props}>\n          {wrapText(children)}\n        </View>\n      ),\n    },\n    {\n      get(target, prop) {\n        // If the component exists, return it\n        if (prop in target) {\n          return target[prop as keyof typeof target]\n        }\n        // Otherwise, return the fallback component\n        return FallbackComponent\n      },\n    },\n  )\n\n  // Convert hast to React Native components\n  const result = parseMarkdown(markdown, {\n    components,\n  }).content\n  return {\n    ...result,\n    props: {\n      ...result.props,\n      children: Array.isArray(result.props?.children)\n        ? result.props.children.filter((child: any) => {\n            if (typeof child === \"string\") {\n              return child.trim() !== \"\"\n            }\n            return true\n          })\n        : result.props.children,\n    },\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/native/index.ios.ts",
    "content": "import { requireNativeModule } from \"expo\"\nimport { openURL } from \"expo-linking\"\n\nimport { getGeneralSettings } from \"@/src/atoms/settings/general\"\n\ninterface NativeModule {\n  openLink: (url: string) => Promise<{\n    type: \"dismiss\"\n  }>\n  previewImage: (images: string[]) => void\n  scrollToTop: (reactTag: number) => void\n  isScrollToEnd: (reactTag: number) => Promise<boolean>\n}\nconst nativeModule = requireNativeModule(\"Helper\") as NativeModule\nexport const openLink = (url: string, onDismiss?: () => void) => {\n  const { openLinksInExternalApp } = getGeneralSettings()\n  if (openLinksInExternalApp) {\n    openURL(url)\n    return\n  }\n  nativeModule.openLink(url).then((res) => {\n    if (res.type === \"dismiss\") {\n      onDismiss?.()\n    }\n  })\n}\n\nexport const performNativeScrollToTop = (reactTag: number) => {\n  nativeModule.scrollToTop(reactTag)\n}\n\nexport const showIntelligenceGlowEffect = () => {\n  requireNativeModule(\"AppleIntelligenceGlowEffect\").show()\n\n  return hideIntelligenceGlowEffect\n}\n\nexport const hideIntelligenceGlowEffect = () => {\n  requireNativeModule(\"AppleIntelligenceGlowEffect\").hide()\n}\n\nexport const isScrollToEnd = async (tag: number) => {\n  return nativeModule.isScrollToEnd(tag)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/native/index.ts",
    "content": "import { openURL } from \"expo-linking\"\nimport * as WebBrowser from \"expo-web-browser\"\n\nimport { getGeneralSettings } from \"@/src/atoms/settings/general\"\n\nexport const openLink = (\n  url: string,\n  /**\n   * @ios Only available on iOS\n   */\n  _onDismiss?: () => void,\n) => {\n  const { openLinksInExternalApp } = getGeneralSettings()\n  if (openLinksInExternalApp) {\n    openURL(url)\n    return\n  }\n  WebBrowser.openBrowserAsync(url)\n}\n\nexport const performNativeScrollToTop = (_reactTag: number) => {\n  throw new Error(\"performNativeScrollToTop is not supported on this platform\")\n}\n\nexport const showIntelligenceGlowEffect = () => {\n  return hideIntelligenceGlowEffect\n}\n\nexport const hideIntelligenceGlowEffect = () => {}\n\nexport const isScrollToEnd = async (_reactTag: number) => {\n  return false\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/native/picker.ts",
    "content": "import * as FileSystem from \"expo-file-system/legacy\"\nimport * as ImageManipulator from \"expo-image-manipulator\"\nimport * as ImagePicker from \"expo-image-picker\"\n\nexport const pickImage = async (\n  options?: ImagePicker.ImagePickerOptions & {\n    fileName?: string\n    maxSizeKB?: number\n  },\n) => {\n  const result = await ImagePicker.launchImageLibraryAsync({\n    mediaTypes: [\"images\"],\n    allowsEditing: true,\n    aspect: [1, 1],\n    quality: 1,\n    ...options,\n  })\n\n  if (result.assets?.[0]) {\n    let image = result.assets[0]\n\n    if (options?.maxSizeKB) {\n      const maxSizeKB = options?.maxSizeKB\n      image = await compressImage(image, maxSizeKB)\n    }\n\n    const formData = new FormData()\n    formData.append(\"file\", {\n      uri: image.uri,\n      type: image.mimeType || \"application/octet-stream\",\n      name: options?.fileName ?? image.fileName ?? \"untitled.jpg\",\n    } as any)\n\n    return {\n      formData,\n      image,\n    }\n  }\n  return null\n}\n\nasync function compressImage(\n  image: ImagePicker.ImagePickerAsset,\n  maxSizeKB: number,\n): Promise<ImagePicker.ImagePickerAsset> {\n  const fileInfo = await FileSystem.getInfoAsync(image.uri)\n\n  // If the file size is already less than the max size, return the image\n  if (fileInfo.exists && fileInfo.size !== undefined && fileInfo.size / 1024 <= maxSizeKB) {\n    return image\n  }\n\n  let quality = 0.9\n  let compressedImage = image\n  let fileSize = fileInfo.exists ? fileInfo.size || 0 : 0\n\n  // Loop until the file size is less than the max size\n  while (fileSize / 1024 > maxSizeKB && quality > 0.1) {\n    // Compress the image\n    const manipResult = await ImageManipulator.manipulateAsync(\n      compressedImage.uri,\n      [], // Do not change the size\n      { compress: quality, format: ImageManipulator.SaveFormat.JPEG },\n    )\n\n    // Update the compressed image and file information\n    compressedImage = {\n      ...compressedImage,\n      uri: manipResult.uri,\n      mimeType: \"image/jpeg\",\n    }\n\n    const newFileInfo = await FileSystem.getInfoAsync(manipResult.uri)\n    fileSize = newFileInfo.exists ? newFileInfo.size || 0 : 0\n\n    // Lower the quality for the next compression\n    quality -= 0.1\n  }\n\n  return compressedImage\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/native/user-agent.ts",
    "content": "import { nativeApplicationVersion, nativeBuildVersion } from \"expo-application\"\nimport Constants from \"expo-constants\"\n\nconst expoUserAgentPromise = Constants.getWebViewUserAgentAsync()\n\nexport const getUserAgent = async () => {\n  const expoUserAgent = await expoUserAgentPromise\n  return `${expoUserAgent ? `${expoUserAgent} ` : \"\"}Folo/${nativeApplicationVersion}.${nativeBuildVersion}`\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/AttachNavigationScrollViewContext.tsx",
    "content": "import { createContext, use } from \"react\"\nimport type { ScrollView } from \"react-native\"\n\nexport const AttachNavigationScrollViewContext = createContext<React.RefObject<ScrollView> | null>(\n  null,\n)\n\nexport const SetAttachNavigationScrollViewContext = createContext<\n  React.Dispatch<React.SetStateAction<React.RefObject<ScrollView> | null>>\n>(null!)\n\nexport const useAttachNavigationScrollView = () => {\n  return use(AttachNavigationScrollViewContext)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/ChainNavigationContext.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext } from \"react\"\n\nimport type {\n  NavigationControllerView,\n  NavigationControllerViewExtraProps,\n  NavigationControllerViewType,\n} from \"./types\"\n\nexport interface Route {\n  id: string\n\n  Component?: NavigationControllerView<any>\n  element?: React.ReactElement\n\n  type: NavigationControllerViewType\n  props?: unknown\n  screenOptions?: NavigationControllerViewExtraProps\n}\nexport type ChainNavigationContextType = {\n  routesAtom: PrimitiveAtom<Route[]>\n}\nexport const ChainNavigationContext = createContext<ChainNavigationContextType>(null!)\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/GroupedNavigationRouteContext.ts",
    "content": "import { createContext } from \"react\"\n\nimport type { Route } from \"./ChainNavigationContext\"\n\nexport const GroupedNavigationRouteContext = createContext<Route[][]>(null!)\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/Navigation.ts",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { EventEmitter } from \"expo\"\nimport { atom } from \"jotai\"\n\nimport type { ChainNavigationContextType, Route } from \"./ChainNavigationContext\"\nimport type {\n  NavigationControllerView,\n  NavigationControllerViewExtraProps,\n  NavigationControllerViewType,\n} from \"./types\"\n\nexport class Navigation {\n  private ctxValue: ChainNavigationContextType\n  constructor(ctxValue: ChainNavigationContextType) {\n    this.ctxValue = ctxValue\n\n    this.pushControllerView = this.pushControllerView.bind(this)\n    this.presentControllerView = this.presentControllerView.bind(this)\n    this.dismiss = this.dismiss.bind(this)\n    this.back = this.back.bind(this)\n    this.popToRoot = this.popToRoot.bind(this)\n  }\n\n  __dangerous_getCtxValue() {\n    return this.ctxValue\n  }\n\n  private viewIdCounter = 0\n\n  static readonly rootNavigation: Navigation = new Navigation({\n    routesAtom: atom<Route[]>([]),\n  })\n\n  private __push(route: Route) {\n    const routes = jotaiStore.get(this.ctxValue.routesAtom)\n    const hasRoute = routes.some((r) => r.id === route.id)\n    if (hasRoute && routes.at(-1)?.id === route.id) {\n      console.warn(`Top of stack is already ${route.id}`)\n      return\n    } else if (hasRoute) {\n      route.id = `${route.id}-${this.viewIdCounter++}`\n    }\n    jotaiStore.set(this.ctxValue.routesAtom, [...routes, route])\n    this.emit(\"screenChange\", { screenId: route.id, type: \"appear\", route })\n  }\n\n  private resolveScreenOptions<T>(\n    view: NavigationControllerView<T>,\n  ): Required<NavigationControllerViewExtraProps> {\n    return {\n      transparent: view.transparent ?? false,\n      id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`,\n      title: view.title ?? \"\",\n      // Form Sheet\n      sheetAllowedDetents: view.sheetAllowedDetents ?? \"fitToContents\",\n      sheetCornerRadius: view.sheetCornerRadius ?? 16,\n      sheetExpandsWhenScrolledToEdge: view.sheetExpandsWhenScrolledToEdge ?? true,\n      sheetElevation: view.sheetElevation ?? 24,\n      sheetGrabberVisible: view.sheetGrabberVisible ?? true,\n      sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0,\n      sheetLargestUndimmedDetentIndex: view.sheetLargestUndimmedDetentIndex ?? \"medium\",\n      // Transition-related props\n      stackAnimation: view.stackAnimation ?? \"default\",\n      // Default to \"pop\" but we specified it as \"push\" making it more intuitive\n      replaceAnimation: view.replaceAnimation ?? \"push\",\n      transitionDuration: view.transitionDuration ?? 500,\n    }\n  }\n\n  pushControllerView<T>(view: NavigationControllerView<T>, props?: T) {\n    const screenOptions = this.resolveScreenOptions(view)\n    this.__push({\n      id: screenOptions.id,\n      type: \"push\",\n      Component: view,\n      props,\n      screenOptions,\n    })\n  }\n\n  replaceControllerView<T>(\n    view: NavigationControllerView<T>,\n    props?: T,\n    extractScreenOptions?: NavigationControllerViewExtraProps,\n  ) {\n    const screenOptions = this.resolveScreenOptions(view)\n    this.__replace({\n      id: screenOptions.id,\n      type: \"push\",\n      Component: view,\n      props,\n      screenOptions: {\n        ...screenOptions,\n        ...extractScreenOptions,\n      },\n    })\n  }\n\n  presentControllerView<T>(\n    view: NavigationControllerView<T>,\n    props?: T,\n    type: Exclude<NavigationControllerViewType, \"push\"> = \"modal\",\n  ) {\n    const screenOptions = this.resolveScreenOptions(view)\n    this.__push({\n      id: screenOptions.id,\n      type,\n      Component: view,\n      props,\n      screenOptions,\n    })\n  }\n\n  private __pop() {\n    const routes = jotaiStore.get(this.ctxValue.routesAtom)\n    const lastRoute = routes.at(-1)\n    if (!lastRoute) {\n      return\n    }\n    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1))\n    this.emit(\"screenChange\", { screenId: lastRoute.id, type: \"disappear\", route: lastRoute })\n  }\n\n  private __replace(route: Route) {\n    const routes = jotaiStore.get(this.ctxValue.routesAtom)\n    const hasRoute = routes.some((r) => r.id === route.id)\n    if (hasRoute) {\n      route.id = `${route.id}-${this.viewIdCounter++}`\n    }\n    jotaiStore.set(this.ctxValue.routesAtom, [...routes.slice(0, -1), route])\n    this.emit(\"screenChange\", { screenId: route.id, type: \"appear\", route })\n  }\n\n  /**\n   * Dismiss the current modal.\n   */\n  dismiss() {\n    const routes = jotaiStore.get(this.ctxValue.routesAtom)\n    const lastModalIndex = routes.findLastIndex((r) => r.type !== \"push\")\n    if (lastModalIndex === -1) {\n      return\n    }\n    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))\n  }\n\n  back() {\n    return this.__pop()\n  }\n\n  __internal_dismiss(routeId: string) {\n    const routes = jotaiStore.get(this.ctxValue.routesAtom)\n    const lastModalIndex = routes.findLastIndex((r) => r.id === routeId)\n    if (lastModalIndex === -1) {\n      return\n    }\n    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))\n  }\n\n  // eslint-disable-next-line unicorn/prefer-event-target\n  private bus = new EventEmitter<{\n    willAppear: (payload: LifecycleEventPayload) => void\n    didAppear: (payload: LifecycleEventPayload) => void\n    willDisappear: (payload: LifecycleEventPayload) => void\n    didDisappear: (payload: LifecycleEventPayload) => void\n  }>()\n  on(event: \"willAppear\", callback: (payload: LifecycleEventPayload) => void): Disposer\n  on(event: \"didAppear\", callback: (payload: LifecycleEventPayload) => void): Disposer\n  on(event: \"willDisappear\", callback: (payload: LifecycleEventPayload) => void): Disposer\n  on(event: \"didDisappear\", callback: (payload: LifecycleEventPayload) => void): Disposer\n  on(event: \"screenChange\", callback: (payload: ScreenChangeEventPayload) => void): Disposer\n  on(event: string, callback: (payload: any) => void): Disposer {\n    const subscription = this.bus.addListener(event as any, callback)\n    return () => {\n      subscription.remove()\n    }\n  }\n\n  emit(event: \"willAppear\", payload: LifecycleEventPayload): void\n  emit(event: \"didAppear\", payload: LifecycleEventPayload): void\n  emit(event: \"willDisappear\", payload: LifecycleEventPayload): void\n  emit(event: \"didDisappear\", payload: LifecycleEventPayload): void\n  emit(event: \"screenChange\", payload: ScreenChangeEventPayload): void\n  emit(event: string, payload: LifecycleEventPayload): void {\n    this.bus.emit(event as any, payload)\n  }\n\n  canGoBack() {\n    const routes = jotaiStore.get(this.ctxValue.routesAtom)\n    return routes.length > 0\n  }\n\n  popToRoot() {\n    jotaiStore.set(this.ctxValue.routesAtom, [])\n  }\n}\n\ntype Disposer = () => void\n\ntype LifecycleEventPayload = {\n  screenId: string\n}\n\ntype ScreenChangeEventPayload = {\n  screenId: string\n  type: \"appear\" | \"disappear\"\n  route?: Route\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/NavigationInstanceContext.ts",
    "content": "import { createContext } from \"react\"\n\nimport type { Navigation } from \"./Navigation\"\n\nexport const NavigationInstanceContext = createContext<Navigation>(null!)\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/NavigationLink.tsx",
    "content": "import type { Text as RNText, TextProps } from \"react-native\"\nimport type { StackPresentationTypes } from \"react-native-screens\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport { useNavigation } from \"./hooks\"\nimport type { NavigationControllerView } from \"./types\"\n\ninterface NavigationLinkProps<T> extends TextProps {\n  destination: NavigationControllerView<T>\n  stackPresentation?: StackPresentationTypes\n  props?: T\n  ref?: React.Ref<RNText>\n}\nexport function NavigationLink<T>({\n  destination,\n  children,\n  stackPresentation = \"push\",\n  props,\n  ref,\n  ...rest\n}: NavigationLinkProps<T>) {\n  const navigation = useNavigation()\n  return (\n    <Text\n      onPress={() => {\n        if (stackPresentation === \"push\") {\n          navigation.pushControllerView(destination, props)\n        } else {\n          navigation.presentControllerView(destination, props, stackPresentation)\n        }\n      }}\n      {...rest}\n      ref={ref}\n    >\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/ScreenItemContext.ts",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport type { ReactNode } from \"react\"\nimport { createContext, use } from \"react\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport { useDerivedValue } from \"react-native-reanimated\"\n\nexport interface ScreenItemContextType {\n  screenId: string\n\n  isFocusedAtom: PrimitiveAtom<boolean>\n  isAppearedAtom: PrimitiveAtom<boolean>\n  isDisappearedAtom: PrimitiveAtom<boolean>\n\n  // For Layout ScrollView\n  reAnimatedScrollY: SharedValue<number>\n  scrollViewHeight: SharedValue<number>\n  scrollViewContentHeight: SharedValue<number>\n\n  Slot: PrimitiveAtom<{\n    header: ReactNode\n  }>\n}\nexport const ScreenItemContext = createContext<ScreenItemContextType>(null!)\n\nexport const useScrollViewProgress = () => {\n  const { reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight } = use(ScreenItemContext)!\n\n  // Use useDerivedValue to create a reactive SharedValue that updates\n  // whenever any of the input SharedValues change\n  const progress = useDerivedValue(() => {\n    // Calculate how far we've scrolled as a proportion of scrollable content\n    // Scrollable content = total content height - visible height\n    // Progress is clamped between 0 and 1\n    const MAGIC_SHIFT = 95 // Adjust this value based on your layout needs\n    const scrollableHeight = Math.max(\n      0,\n      scrollViewContentHeight.value - scrollViewHeight.value - MAGIC_SHIFT,\n    )\n\n    if (scrollableHeight <= 0) {\n      // If there's no scrollable content, we're at 100% progress\n      return 1\n    }\n\n    // Calculate progress as a value between 0 and 1\n    const progress = Math.min(1, Math.max(0, reAnimatedScrollY.value / scrollableHeight))\n    return progress\n  }, [reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight])\n\n  return progress\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/ScreenNameContext.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { useAtomValue } from \"jotai\"\nimport { createContext, use } from \"react\"\n\nexport const ScreenNameContext = createContext<PrimitiveAtom<string>>(null!)\n\nexport const useScreenName = () => {\n  const name = use(ScreenNameContext)\n  if (!name) {\n    throw new Error(\"ScreenNameContext not mounted\")\n  }\n  return useAtomValue(name)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/ScreenOptionsContext.ts",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { useStore } from \"jotai\"\nimport { createContext, use, useCallback } from \"react\"\n\nexport interface ScreenOptionsContextType {\n  gestureEnabled?: boolean\n  preventNativeDismiss?: boolean\n\n  nativeHeader?: boolean\n  headerLeftArea?: React.ReactNode\n  headerRightArea?: React.ReactNode\n  headerTitleArea?: React.ReactNode\n}\nexport const ScreenOptionsContext = createContext<PrimitiveAtom<ScreenOptionsContextType>>(null!)\n\nexport const useSetScreenOptions = () => {\n  const ctx = use(ScreenOptionsContext)\n  if (!ctx) {\n    throw new Error(\"ScreenOptionsContext not found\")\n  }\n\n  const store = useStore()\n  return useCallback(\n    (options: ScreenOptionsContextType) => {\n      const prev = store.get(ctx)\n      store.set(ctx, { ...prev, ...options })\n    },\n    [store, ctx],\n  )\n}\n\nexport const ModalScreenItemOptionsContext = createContext<PrimitiveAtom<ScreenOptionsContextType>>(\n  null!,\n)\n\nexport const useSetModalScreenOptions = () => {\n  const ctx = use(ModalScreenItemOptionsContext)\n\n  const store = useStore()\n  return useCallback(\n    (options: ScreenOptionsContextType) => {\n      if (!ctx) {\n        return\n      }\n\n      const prev = store.get(ctx)\n      store.set(ctx, { ...prev, ...options })\n    },\n    [store, ctx],\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/StackNavigation.tsx",
    "content": "import { StatusBar } from \"expo-status-bar\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { atom, useAtomValue, useStore } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { memo, use, useEffect, useMemo, useRef, useState } from \"react\"\nimport type { ScrollView } from \"react-native\"\nimport { StyleSheet } from \"react-native\"\nimport {\n  SafeAreaFrameContext,\n  SafeAreaInsetsContext,\n  useSafeAreaFrame,\n  useSafeAreaInsets,\n} from \"react-native-safe-area-context\"\nimport type { ScreenStackHeaderConfigProps } from \"react-native-screens\"\nimport { ScreenStack } from \"react-native-screens\"\n\nimport { isAndroid, isIOS } from \"../platform\"\nimport {\n  AttachNavigationScrollViewContext,\n  SetAttachNavigationScrollViewContext,\n} from \"./AttachNavigationScrollViewContext\"\nimport type { Route } from \"./ChainNavigationContext\"\nimport { ChainNavigationContext } from \"./ChainNavigationContext\"\nimport { GroupedNavigationRouteContext } from \"./GroupedNavigationRouteContext\"\nimport { useNavigation } from \"./hooks\"\nimport { Navigation } from \"./Navigation\"\nimport { NavigationInstanceContext } from \"./NavigationInstanceContext\"\nimport { ScreenNameContext } from \"./ScreenNameContext\"\nimport type { ScreenOptionsContextType } from \"./ScreenOptionsContext\"\nimport { ModalScreenItemOptionsContext } from \"./ScreenOptionsContext\"\nimport type { NavigationControllerView } from \"./types\"\nimport { WrappedScreenItem } from \"./WrappedScreenItem\"\n\ninterface RootStackNavigationProps {\n  children: React.ReactNode\n\n  headerConfig?: ScreenStackHeaderConfigProps\n}\nexport const RootStackNavigation = ({ children, headerConfig }: RootStackNavigationProps) => {\n  return (\n    <AttachNavigationScrollViewProvider>\n      <ScreenNameContext value={useMemo(() => atom(\"\"), [])}>\n        <ChainNavigationContext value={Navigation.rootNavigation.__dangerous_getCtxValue()}>\n          <NavigationInstanceContext value={Navigation.rootNavigation}>\n            <ScreenStack style={StyleSheet.absoluteFill}>\n              <WrappedScreenItem headerConfig={headerConfig} screenId=\"root\">\n                {children}\n              </WrappedScreenItem>\n\n              <ScreenItemsMapper />\n              <StateHandler />\n            </ScreenStack>\n          </NavigationInstanceContext>\n        </ChainNavigationContext>\n      </ScreenNameContext>\n    </AttachNavigationScrollViewProvider>\n  )\n}\n\nconst AttachNavigationScrollViewProvider: FC<PropsWithChildren> = ({ children }) => {\n  const [attachNavigationScrollViewRef, setAttachNavigationScrollViewRef] =\n    useState<React.RefObject<ScrollView> | null>(null)\n\n  return (\n    <AttachNavigationScrollViewContext value={attachNavigationScrollViewRef}>\n      <SetAttachNavigationScrollViewContext value={setAttachNavigationScrollViewRef}>\n        {children}\n      </SetAttachNavigationScrollViewContext>\n    </AttachNavigationScrollViewContext>\n  )\n}\nconst StateHandler = () => {\n  const navigation = useNavigation()\n  const nameAtom = use(ScreenNameContext)\n  const navigationInstance = use(NavigationInstanceContext)\n  const jotaiStore = useStore()\n  const previousName = useRef(jotaiStore.get(nameAtom))\n  useEffect(() => {\n    return navigation.on(\"screenChange\", (payload) => {\n      if (!payload.route) return\n      const Component = payload.route.Component as NavigationControllerView\n      const state = jotaiStore.get(navigationInstance.__dangerous_getCtxValue().routesAtom)\n      if (payload.type === \"appear\" && state.at(-1)?.id === payload.route.id) {\n        previousName.current = jotaiStore.get(nameAtom)\n        jotaiStore.set(nameAtom, Component.title || Component.displayName || Component.name)\n      }\n      if (payload.type === \"disappear\") {\n        const lastRoute = state.at(-1)\n\n        if (lastRoute && lastRoute.Component) {\n          previousName.current = jotaiStore.get(nameAtom)\n          jotaiStore.set(\n            nameAtom,\n            lastRoute?.Component.title ||\n              lastRoute?.Component.displayName ||\n              lastRoute?.Component.name,\n          )\n        } else {\n          jotaiStore.set(nameAtom, previousName.current)\n        }\n      }\n    })\n  }, [jotaiStore, nameAtom, navigation, navigationInstance])\n  return null\n}\n\nconst ScreenItemsMapper = () => {\n  const chainCtxValue = use(ChainNavigationContext)\n  const routes = useAtomValue(chainCtxValue.routesAtom)\n\n  const routeGroups = useMemo(() => {\n    const groups: Route[][] = []\n    let currentGroup: Route[] = []\n\n    routes.forEach((route, index) => {\n      // Start a new group if this is the first route or if it's a modal (non-push)\n      if (index === 0 || route.type !== \"push\") {\n        // Save the previous group if it's not empty\n        if (currentGroup.length > 0) {\n          groups.push(currentGroup)\n        }\n        // Start a new group with this route\n        currentGroup = [route]\n      } else {\n        // Add to the current group if it's a push route\n        currentGroup.push(route)\n      }\n    })\n\n    // Add the last group if it's not empty\n    if (currentGroup.length > 0) {\n      groups.push(currentGroup)\n    }\n\n    return groups\n  }, [routes])\n\n  return (\n    <GroupedNavigationRouteContext value={routeGroups}>\n      {routeGroups.map((group) => {\n        const isPushGroup = group.at(0)?.type === \"push\"\n        if (!isPushGroup) {\n          return <ModalScreenStackItems key={group.at(0)?.id} routes={group} />\n        }\n        return <MapScreenStackItems key={group.at(0)?.id} routes={group} />\n      })}\n    </GroupedNavigationRouteContext>\n  )\n}\n\nconst MapScreenStackItems: FC<{\n  routes: Route[]\n}> = memo(({ routes }) => {\n  return routes.map((route) => {\n    return (\n      <WrappedScreenItem\n        stackPresentation={\"push\"}\n        key={route.id}\n        screenId={route.id}\n        screenOptions={route.screenOptions}\n      >\n        <ResolveView comp={route.Component} element={route.element} props={route.props} />\n      </WrappedScreenItem>\n    )\n  })\n})\n\nconst ModalScreenStackItems: FC<{\n  routes: Route[]\n}> = memo(({ routes }) => {\n  const rootModalRoute = routes.at(0)\n  const modalScreenOptionsCtxValue = useMemo<PrimitiveAtom<ScreenOptionsContextType>>(\n    () => atom({}),\n    [],\n  )\n\n  const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue)\n\n  if (!rootModalRoute) {\n    return null\n  }\n  const isFormSheet = rootModalRoute.type === \"formSheet\"\n  const isStackModal = !isFormSheet\n\n  // Modal screens are always full screen on Android\n  const isFullScreen =\n    isAndroid || (rootModalRoute.type !== \"modal\" && rootModalRoute.type !== \"formSheet\")\n\n  if (isStackModal) {\n    return (\n      <ModalScreenItemOptionsContext value={modalScreenOptionsCtxValue}>\n        <WrappedScreenItem\n          stackPresentation={rootModalRoute?.type}\n          key={rootModalRoute.id}\n          screenId={rootModalRoute.id}\n          screenOptions={rootModalRoute.screenOptions}\n          {...modalScreenOptions}\n        >\n          <ModalSafeAreaInsetsContext hasTopInset={isFullScreen}>\n            {isIOS && <StatusBar style=\"light\" />}\n            <ScreenStack style={StyleSheet.absoluteFill}>\n              <WrappedScreenItem\n                screenId={rootModalRoute.id}\n                screenOptions={rootModalRoute.screenOptions}\n              >\n                <ResolveView\n                  comp={rootModalRoute.Component}\n                  element={rootModalRoute.element}\n                  props={rootModalRoute.props}\n                />\n              </WrappedScreenItem>\n              {routes.slice(1).map((route) => {\n                return (\n                  <WrappedScreenItem\n                    stackPresentation={\"push\"}\n                    key={route.id}\n                    screenId={route.id}\n                    screenOptions={route.screenOptions}\n                  >\n                    <ResolveView\n                      comp={route.Component}\n                      element={route.element}\n                      props={route.props}\n                    />\n                  </WrappedScreenItem>\n                )\n              })}\n            </ScreenStack>\n          </ModalSafeAreaInsetsContext>\n        </WrappedScreenItem>\n      </ModalScreenItemOptionsContext>\n    )\n  }\n\n  return routes.map((route) => {\n    return (\n      <ModalScreenItemOptionsContext value={modalScreenOptionsCtxValue} key={route.id}>\n        <ModalSafeAreaInsetsContext hasTopInset={!isFormSheet}>\n          <WrappedScreenItem\n            screenId={route.id}\n            stackPresentation={route.type}\n            screenOptions={route.screenOptions}\n          >\n            <ResolveView comp={route.Component} element={route.element} props={route.props} />\n          </WrappedScreenItem>\n        </ModalSafeAreaInsetsContext>\n      </ModalScreenItemOptionsContext>\n    )\n  })\n})\n\nconst ResolveView: FC<{\n  comp?: NavigationControllerView<any>\n  element?: React.ReactElement\n  props?: unknown\n}> = ({ comp: Component, element, props }) => {\n  if (Component && typeof Component === \"function\") {\n    return <Component {...(props as any)} />\n  }\n  if (element) {\n    return element\n  }\n  throw new Error(\"No component or element provided\")\n}\n\nconst ModalSafeAreaInsetsContext: FC<{\n  children: React.ReactNode\n  hasTopInset?: boolean\n}> = ({ children, hasTopInset = true }) => {\n  const rootInsets = useSafeAreaInsets()\n  const rootFrame = useSafeAreaFrame()\n\n  return (\n    <SafeAreaFrameContext value={rootFrame}>\n      <SafeAreaInsetsContext\n        value={useMemo(\n          () => ({\n            ...rootInsets,\n            top: hasTopInset ? rootInsets.top : 0,\n          }),\n          [hasTopInset, rootInsets],\n        )}\n      >\n        {children}\n      </SafeAreaInsetsContext>\n    </SafeAreaFrameContext>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/StackScreenHeaderPortal.tsx",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { useStore } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { use, useEffect } from \"react\"\n\nimport { ScreenItemContext } from \"./ScreenItemContext\"\n\nexport const StackScreenHeaderPortal: FC<PropsWithChildren> = ({ children }) => {\n  const ctxValue = use(ScreenItemContext)\n\n  const store = useStore()\n  useEffect(() => {\n    jotaiStore.set(ctxValue.Slot, {\n      ...store.get(ctxValue.Slot),\n      header: children,\n    })\n  }, [ctxValue, children, store])\n  return null\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/WrappedScreenItem.tsx",
    "content": "import { isUndefined } from \"es-toolkit/compat\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport type { FC, ReactNode } from \"react\"\nimport { memo, use, useCallback, useMemo, useRef } from \"react\"\nimport type { NativeSyntheticEvent, StyleProp, ViewStyle } from \"react-native\"\nimport { StyleSheet, View } from \"react-native\"\nimport { useSharedValue } from \"react-native-reanimated\"\nimport type { ScreenStackHeaderConfigProps, StackPresentationTypes } from \"react-native-screens\"\nimport {\n  ScreenStackHeaderCenterView,\n  ScreenStackHeaderLeftView,\n  ScreenStackHeaderRightView,\n  ScreenStackItem,\n} from \"react-native-screens\"\n\nimport { ErrorBoundary } from \"@/src/components/common/ErrorBoundary\"\nimport { ScreenErrorScreen } from \"@/src/components/errors/ScreenErrorScreen\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { useCombinedLifecycleEvents } from \"./__internal/hooks\"\nimport { defaultHeaderConfig } from \"./config\"\nimport type { ScreenItemContextType } from \"./ScreenItemContext\"\nimport { ScreenItemContext } from \"./ScreenItemContext\"\nimport type { ScreenOptionsContextType } from \"./ScreenOptionsContext\"\nimport { ScreenOptionsContext } from \"./ScreenOptionsContext\"\nimport type { NavigationControllerViewExtraProps } from \"./types\"\n\nexport const WrappedScreenItem: FC<\n  {\n    screenId: string\n    children: React.ReactNode\n    stackPresentation?: StackPresentationTypes\n\n    headerConfig?: ScreenStackHeaderConfigProps\n    screenOptions?: NavigationControllerViewExtraProps\n    style?: StyleProp<ViewStyle>\n  } & ScreenOptionsContextType\n> = memo(\n  ({\n    screenId,\n    children,\n    stackPresentation,\n    headerConfig,\n    screenOptions: screenOptionsProp,\n    style,\n    ...rest\n  }) => {\n    const navigation = useNavigation()\n    const reAnimatedScrollY = useSharedValue(0)\n    const scrollViewHeight = useSharedValue(0)\n    const scrollViewContentHeight = useSharedValue(0)\n    const ctxValue = useMemo<ScreenItemContextType>(\n      () => ({\n        screenId,\n        isFocusedAtom: atom(false),\n        isAppearedAtom: atom(false),\n        isDisappearedAtom: atom(false),\n        reAnimatedScrollY,\n        Slot: atom<{\n          header: ReactNode\n        }>({\n          header: null,\n        }),\n        scrollViewHeight,\n        scrollViewContentHeight,\n      }),\n      [screenId, reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight],\n    )\n    const setIsFocused = useSetAtom(ctxValue.isFocusedAtom)\n    const setIsAppeared = useSetAtom(ctxValue.isAppearedAtom)\n    const setIsDisappeared = useSetAtom(ctxValue.isDisappearedAtom)\n\n    const combinedLifecycleEvents = useCombinedLifecycleEvents(ctxValue.screenId, {\n      onAppear: () => {\n        setIsFocused(true)\n        setIsAppeared(true)\n        setIsDisappeared(false)\n      },\n      onDisappear: () => {\n        setIsFocused(false)\n        setIsAppeared(false)\n        setIsDisappeared(true)\n      },\n      onWillAppear: () => {\n        setIsFocused(false)\n        setIsAppeared(true)\n        setIsDisappeared(false)\n      },\n      onWillDisappear: () => {\n        setIsFocused(false)\n        setIsAppeared(false)\n        setIsDisappeared(true)\n      },\n    }) as any\n\n    const screenOptionsCtxValue = useMemo<PrimitiveAtom<ScreenOptionsContextType>>(\n      () => atom({}),\n      [],\n    )\n\n    const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue)\n\n    const backgroundColor = useColor(\"systemBackground\")\n\n    const handleDismiss = useCallback(\n      (\n        e: NativeSyntheticEvent<{\n          dismissCount: number\n        }>,\n      ) => {\n        if (e.nativeEvent.dismissCount > 0) {\n          for (let i = 0; i < e.nativeEvent.dismissCount; i++) {\n            navigation.__internal_dismiss(screenId)\n          }\n        }\n      },\n      [navigation, screenId],\n    )\n\n    const ref = useRef<View>(null)\n\n    return (\n      <ScreenItemContext value={ctxValue}>\n        <ScreenOptionsContext value={screenOptionsCtxValue}>\n          <ScreenStackItem\n            key={screenId}\n            {...combinedLifecycleEvents}\n            headerConfig={{\n              ...defaultHeaderConfig,\n              ...headerConfig,\n            }}\n            screenId={screenId}\n            ref={ref}\n            stackPresentation={stackPresentation}\n            style={[\n              StyleSheet.absoluteFill,\n              { backgroundColor: screenOptionsProp?.transparent ? undefined : backgroundColor },\n              style,\n            ]}\n            // Priority: Ctx > Define on Component\n            {...rest}\n            {...screenOptionsProp}\n            {...resolveScreenOptions(screenOptionsFromCtx)}\n            onDismissed={handleDismiss}\n            onNativeDismissCancelled={handleDismiss}\n          >\n            <Header />\n            <ErrorBoundary fallbackRender={ScreenErrorScreen}>{children}</ErrorBoundary>\n          </ScreenStackItem>\n        </ScreenOptionsContext>\n      </ScreenItemContext>\n    )\n  },\n)\nconst Header = () => {\n  const ctxValue = use(ScreenItemContext)\n\n  const Slot = useAtomValue(ctxValue.Slot)\n\n  if (!Slot.header) {\n    return null\n  }\n  return <View className=\"absolute inset-x-0 top-0 z-10\">{Slot.header}</View>\n}\n\nWrappedScreenItem.displayName = \"WrappedScreenItem\"\n\ntype ExtractFC<T> = T extends FC<infer P> ? P : never\nconst resolveScreenOptions = (\n  options: ScreenOptionsContextType,\n): Partial<ExtractFC<typeof ScreenStackItem>> => {\n  const headerConfig = {\n    ...defaultHeaderConfig,\n  }\n\n  if (options.nativeHeader) {\n    headerConfig.hidden = false\n    headerConfig.translucent = true\n    headerConfig.blurEffect = \"systemChromeMaterial\"\n    headerConfig.backgroundColor = \"rgba(255, 255, 255, 0)\"\n\n    const headerAeras = [] as ReactNode[]\n    if (options.headerLeftArea) {\n      headerAeras[0] = options.headerLeftArea\n    }\n    if (options.headerRightArea) {\n      headerAeras[2] = options.headerRightArea\n    }\n    if (options.headerTitleArea) {\n      headerAeras[1] = options.headerTitleArea\n    }\n\n    if (headerAeras.length > 0) {\n      headerConfig.children = (\n        <>\n          {!!headerAeras[0] && (\n            <ScreenStackHeaderLeftView>{headerAeras[0]}</ScreenStackHeaderLeftView>\n          )}\n          {!!headerAeras[1] && (\n            <ScreenStackHeaderCenterView>{headerAeras[1]}</ScreenStackHeaderCenterView>\n          )}\n          {!!headerAeras[2] && (\n            <ScreenStackHeaderRightView>{headerAeras[2]}</ScreenStackHeaderRightView>\n          )}\n        </>\n      )\n    }\n  }\n\n  const finalConfig: Partial<ExtractFC<typeof ScreenStackItem>> = {\n    headerConfig,\n  }\n\n  if (!isUndefined(options.preventNativeDismiss)) {\n    finalConfig.preventNativeDismiss = options.preventNativeDismiss\n  }\n\n  if (!isUndefined(options.gestureEnabled)) {\n    finalConfig.gestureEnabled = options.gestureEnabled\n  }\n\n  return finalConfig\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/__internal/hooks.ts",
    "content": "import { useEffect, useMemo, useRef } from \"react\"\nimport type { NativeSyntheticEvent } from \"react-native\"\n\nimport { useNavigation } from \"../hooks\"\n\ntype LifecycleEvent = NativeSyntheticEvent<{\n  dismissCount: number\n}>\nexport const useCombinedLifecycleEvents = (\n  screenId: string,\n  {\n    onAppear,\n    onDisappear,\n    onWillAppear,\n    onWillDisappear,\n  }: {\n    onAppear?: (e: LifecycleEvent) => void\n    onDisappear?: (e: LifecycleEvent) => void\n    onWillAppear?: (e: LifecycleEvent) => void\n    onWillDisappear?: (e: LifecycleEvent) => void\n  } = {},\n) => {\n  const navigation = useNavigation()\n  const stableRef = useRef({\n    onAppear,\n    onDisappear,\n    onWillAppear,\n    onWillDisappear,\n  })\n\n  useEffect(() => {\n    stableRef.current = {\n      onAppear,\n      onDisappear,\n      onWillAppear,\n      onWillDisappear,\n    }\n  }, [onAppear, onDisappear, onWillAppear, onWillDisappear])\n  return useMemo(() => {\n    return {\n      onAppear: (e: LifecycleEvent) => {\n        navigation.emit(\"didAppear\", { screenId })\n        stableRef.current.onAppear?.(e)\n      },\n      onDisappear: (e: LifecycleEvent) => {\n        navigation.emit(\"didDisappear\", { screenId })\n        stableRef.current.onDisappear?.(e)\n      },\n      onWillAppear: (e: LifecycleEvent) => {\n        navigation.emit(\"willAppear\", { screenId })\n        stableRef.current.onWillAppear?.(e)\n      },\n      onWillDisappear: (e: LifecycleEvent) => {\n        navigation.emit(\"willDisappear\", { screenId })\n        stableRef.current.onWillDisappear?.(e)\n      },\n    }\n  }, [navigation, screenId])\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/biz/Destination.ts",
    "content": "import { DeviceType } from \"expo-device\"\n\nimport { getDeviceType } from \"@/src/atoms/hooks/useDeviceType\"\nimport { LoginScreen } from \"@/src/screens/(modal)/LoginScreen\"\n\nimport { isIOS } from \"../../platform\"\nimport { Navigation } from \"../Navigation\"\n\nclass Destination {\n  private navigation = Navigation.rootNavigation\n\n  get isIpad() {\n    return getDeviceType() === DeviceType.TABLET && isIOS\n  }\n\n  get presentControllerView() {\n    return this.navigation.presentControllerView\n  }\n\n  get pushControllerView() {\n    return this.navigation.pushControllerView\n  }\n\n  Login() {\n    this.presentControllerView(LoginScreen, void 0, this.isIpad ? \"formSheet\" : \"modal\")\n  }\n}\n\nexport const destination = new Destination()\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/BottomTabContext.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext } from \"react\"\n\nimport type { ResolvedTabScreenProps } from \"./types\"\n\nexport interface BottomTabContextType {\n  currentIndexAtom: PrimitiveAtom<number>\n\n  loadedableIndexAtom: PrimitiveAtom<Set<number>>\n\n  tabScreensAtom: PrimitiveAtom<ResolvedTabScreenProps[]>\n  tabHeightAtom: PrimitiveAtom<number>\n}\nexport const BottomTabContext = createContext<BottomTabContextType>(null!)\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.tsx",
    "content": "import { useSetAtom } from \"jotai\"\nimport { use } from \"react\"\nimport { StyleSheet, View } from \"react-native\"\n\nimport { BottomTabContext } from \"./BottomTabContext\"\nimport { TabBarPortalWrapper } from \"./native\"\n\nexport const TabBarPortal = ({ children }: { children: React.ReactNode }) => {\n  const { tabHeightAtom } = use(BottomTabContext)\n  const setTabHeight = useSetAtom(tabHeightAtom)\n  return (\n    <TabBarPortalWrapper style={styles.container}>\n      <View\n        onLayout={(e) => {\n          setTabHeight(e.nativeEvent.layout.height)\n        }}\n      >\n        {children}\n      </View>\n    </TabBarPortalWrapper>\n  )\n}\n\nconst styles = StyleSheet.create({\n  container: {\n    position: \"absolute\",\n    bottom: 0,\n    left: 0,\n    right: 0,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/TabRoot.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { useAtom } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { use, useCallback, useMemo } from \"react\"\nimport { StyleSheet } from \"react-native\"\n\nimport { useNavigationScrollToTop } from \"@/src/components/layouts/tabbar/hooks\"\n\nimport { BottomTabContext } from \"./BottomTabContext\"\nimport { TabBarRootWrapper } from \"./native\"\nimport { TabScreen } from \"./TabScreen\"\nimport type { TabScreenProps } from \"./types\"\n\nexport const TabRoot: FC<PropsWithChildren> = ({ children }) => {\n  const { currentIndexAtom } = use(BottomTabContext)\n  const [tabIndex, setTabIndex] = useAtom(currentIndexAtom)\n\n  const scrollToTop = useNavigationScrollToTop()\n\n  const MapChildren = useMemo(() => {\n    let cnt = 0\n    return React.Children.map(children, (child) => {\n      if (typeof child === \"object\" && child && \"type\" in child && child.type === TabScreen) {\n        return React.cloneElement(child, {\n          tabScreenIndex: cnt++,\n        } as Partial<TabScreenProps>)\n      }\n      return child\n    })\n  }, [children])\n  return (\n    <TabBarRootWrapper\n      style={StyleSheet.absoluteFill}\n      onTabIndexChange={useCallback(\n        (e) => {\n          const index = e.nativeEvent?.index\n          if (index != null) {\n            setTabIndex(index)\n          }\n        },\n        [setTabIndex],\n      )}\n      onTabItemPress={useTypeScriptHappyCallback(\n        (e) => {\n          const { nativeEvent } = e\n          if (!nativeEvent) return\n          const { index, currentIndex } = nativeEvent\n          if (index != null && index === currentIndex) {\n            scrollToTop()\n          }\n        },\n        [scrollToTop],\n      )}\n      selectedIndex={tabIndex}\n    >\n      {MapChildren}\n    </TabBarRootWrapper>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/TabScreen.tsx",
    "content": "import { atom, useAtom, useAtomValue, useSetAtom } from \"jotai\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { use, useEffect, useMemo } from \"react\"\nimport { StyleSheet } from \"react-native\"\n\nimport { IconNativeNameMap, IconNodeMap } from \"@/src/constants/native-images\"\n\nimport { isIOS } from \"../../platform\"\nimport { WrappedScreenItem } from \"../WrappedScreenItem\"\nimport { BottomTabContext } from \"./BottomTabContext\"\nimport { TabScreenWrapper } from \"./native\"\nimport { LifecycleEvents, ScreenNameRegister } from \"./shared\"\nimport type { TabScreenContextType } from \"./TabScreenContext\"\nimport { TabScreenContext } from \"./TabScreenContext\"\nimport type { ResolvedTabScreenProps, TabScreenComponent, TabScreenProps } from \"./types\"\n\nexport const TabScreen: FC<PropsWithChildren<Omit<TabScreenProps, \"tabScreenIndex\">>> = ({\n  children,\n  icon,\n  activeIcon,\n  ...props\n}) => {\n  const { tabScreenIndex } = props as any as TabScreenProps\n\n  const {\n    loadedableIndexAtom,\n    currentIndexAtom,\n    tabScreensAtom: tabScreens,\n  } = use(BottomTabContext)\n\n  const setTabScreens = useSetAtom(tabScreens)\n\n  const mergedProps = useMemo((): ResolvedTabScreenProps => {\n    const propsFromChildren: Partial<ResolvedTabScreenProps> = {}\n    if (children && typeof children === \"object\") {\n      const childType = (children as any).type as TabScreenComponent\n\n      if (\"lazy\" in childType) {\n        propsFromChildren.lazy = childType.lazy\n      }\n      if (\"identifier\" in childType && typeof childType.identifier === \"string\") {\n        propsFromChildren.identifier = childType.identifier\n      }\n    }\n    return {\n      ...propsFromChildren,\n      ...props,\n      title: props.title,\n      tabScreenIndex,\n      icon: ({ focused, color }) => {\n        const Icon = !focused ? icon : activeIcon\n\n        const ResolvedIcon = IconNodeMap[Icon]\n\n        return <ResolvedIcon color={color} height={24} width={24} />\n      },\n    }\n  }, [activeIcon, children, icon, props, tabScreenIndex])\n  useEffect(() => {\n    setTabScreens((prev) => [\n      ...prev,\n      {\n        ...mergedProps,\n        tabScreenIndex,\n      },\n    ])\n\n    return () => {\n      setTabScreens((prev) =>\n        prev.filter((tabScreen) => tabScreen.tabScreenIndex !== tabScreenIndex),\n      )\n    }\n  }, [mergedProps, setTabScreens, tabScreenIndex])\n\n  const currentSelectedIndex = useAtomValue(currentIndexAtom)\n\n  const isSelected = useMemo(\n    () => currentSelectedIndex === tabScreenIndex,\n    [currentSelectedIndex, tabScreenIndex],\n  )\n\n  const [loadedableIndexSet, setLoadedableIndex] = useAtom(loadedableIndexAtom)\n\n  const isLoadedBefore = loadedableIndexSet.has(tabScreenIndex)\n  useEffect(() => {\n    if (isSelected) {\n      setLoadedableIndex((prev) => {\n        prev.add(tabScreenIndex)\n        return new Set(prev)\n      })\n    }\n  }, [setLoadedableIndex, tabScreenIndex, isSelected])\n\n  const ctxValue = useMemo<TabScreenContextType>(\n    () => ({\n      tabScreenIndex,\n      identifierAtom: atom(mergedProps.identifier ?? \"\"),\n      titleAtom: atom(mergedProps.title),\n    }),\n    [tabScreenIndex, mergedProps.identifier, mergedProps.title],\n  )\n  const shouldLoadReact = mergedProps.lazy ? isSelected || isLoadedBefore : true\n\n  const render = !__DEV__ && isIOS ? true : isSelected\n  return (\n    <TabScreenWrapper\n      style={StyleSheet.absoluteFill}\n      title={mergedProps.title}\n      icon={IconNativeNameMap[icon]}\n      activeIcon={IconNativeNameMap[activeIcon]}\n    >\n      <TabScreenContext value={ctxValue}>\n        {shouldLoadReact && render && (\n          <WrappedScreenItem screenId={`tab-screen-${tabScreenIndex}`}>\n            {children}\n            <ScreenNameRegister />\n            <LifecycleEvents isSelected={isSelected} />\n          </WrappedScreenItem>\n        )}\n      </TabScreenContext>\n    </TabScreenWrapper>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/TabScreenContext.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext } from \"react\"\n\nexport interface TabScreenContextType {\n  tabScreenIndex: number\n\n  titleAtom: PrimitiveAtom<string>\n  identifierAtom: PrimitiveAtom<string>\n}\nexport const TabScreenContext = createContext<TabScreenContextType>(null!)\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/hooks.ts",
    "content": "import { useAtomValue, useSetAtom } from \"jotai\"\nimport { use, useCallback } from \"react\"\n\nimport { ScreenItemContext } from \"../ScreenItemContext\"\nimport { BottomTabContext } from \"./BottomTabContext\"\nimport { TabScreenContext } from \"./TabScreenContext\"\n\nexport const useScreenIsAppeared = () => {\n  const { isAppearedAtom } = use(ScreenItemContext)\n\n  return useAtomValue(isAppearedAtom)\n}\n\nexport const useTabScreenIsFocused = () => {\n  const { currentIndexAtom } = use(BottomTabContext)\n  const currentIndex = useAtomValue(currentIndexAtom)\n  const { isFocusedAtom } = use(ScreenItemContext)\n  const isFocused = useAtomValue(isFocusedAtom)\n  const { tabScreenIndex } = use(TabScreenContext)\n\n  return currentIndex === tabScreenIndex && isFocused\n}\n\nexport const useSwitchTab = () => {\n  const { currentIndexAtom } = use(BottomTabContext)\n  const setCurrentIndex = useSetAtom(currentIndexAtom)\n  return useCallback(\n    (index: number) => {\n      setCurrentIndex(index)\n    },\n    [setCurrentIndex],\n  )\n}\n\nexport const useBottomTabHeight = () => {\n  const { tabHeightAtom } = use(BottomTabContext)\n  return useAtomValue(tabHeightAtom)\n}\n\nexport const useTabScreenIdentifier = () => {\n  const { identifierAtom } = use(TabScreenContext)\n  return useAtomValue(identifierAtom)\n}\n\nexport const useInTabScreen = () => {\n  return !!use(TabScreenContext)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/native.ios.tsx",
    "content": "import { requireNativeView } from \"expo\"\nimport type { ViewProps } from \"react-native\"\n\nimport type { TabBarRootWrapperProps } from \"./types\"\n\nexport const TabBarPortalWrapper = requireNativeView<ViewProps>(\"TabBarPortal\")\nexport const TabBarBottomAccessoryWrapper = requireNativeView<ViewProps>(\"TabBarBottomAccessory\")\n\nexport type TabScreenNativeProps = ViewProps & { title?: string }\n\nexport const TabScreenWrapper = requireNativeView<TabScreenNativeProps>(\"TabScreen\")\n\nexport const TabBarRootWrapper = requireNativeView<TabBarRootWrapperProps>(\"TabBarRoot\")\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/native.tsx",
    "content": "import type { FC } from \"react\"\nimport { View } from \"react-native\"\n\nimport type { IconNativeValues } from \"@/src/constants/native-images\"\n\nimport type { TabbarIconProps, TabBarRootWrapperProps } from \"./types\"\n\nexport { View as TabBarPortalWrapper } from \"react-native\"\nexport { View as TabBarBottomAccessoryWrapper } from \"react-native\"\nexport type TabScreenNativeProps = React.ComponentProps<typeof View> & {\n  title?: string\n  icon?: FC<TabbarIconProps> | IconNativeValues\n  activeIcon?: FC<TabbarIconProps> | IconNativeValues\n}\nexport const TabScreenWrapper = ({ title, icon, activeIcon, ...rest }: TabScreenNativeProps) => {\n  // Non-iOS: no native TabBar, omit custom prop\n  return <View {...rest} />\n}\n\nexport const TabBarRootWrapper = ({\n  onTabIndexChange,\n  onTabItemPress,\n  selectedIndex,\n  ...props\n}: TabBarRootWrapperProps) => {\n  return <View {...props} />\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/shared.tsx",
    "content": "import { useAtomValue, useSetAtom, useStore } from \"jotai\"\nimport { use, useEffect } from \"react\"\n\nimport { ScreenItemContext } from \"../ScreenItemContext\"\nimport { ScreenNameContext } from \"../ScreenNameContext\"\nimport { useTabScreenIsFocused } from \"./hooks\"\nimport { TabScreenContext } from \"./TabScreenContext\"\n\nexport const ScreenNameRegister = () => {\n  const nameAtom = use(ScreenNameContext)\n  const { titleAtom } = use(TabScreenContext)\n  const isFocused = useTabScreenIsFocused()\n  const title = useAtomValue(titleAtom)\n  const store = useStore()\n  useEffect(() => {\n    if (isFocused) {\n      store.set(nameAtom, title)\n    }\n  }, [isFocused, title, nameAtom, store])\n  return null\n}\n\nexport const TabScreenIdentifierRegister = () => {\n  const { identifierAtom } = use(TabScreenContext)\n  const identifier = useAtomValue(identifierAtom)\n  const store = useStore()\n  const isFocused = useTabScreenIsFocused()\n  useEffect(() => {\n    if (isFocused) {\n      store.set(identifierAtom, identifier)\n    }\n  }, [identifier, identifierAtom, store, isFocused])\n  return null\n}\n\nexport const LifecycleEvents = ({ isSelected }: { isSelected: boolean }) => {\n  const { isFocusedAtom, isAppearedAtom, isDisappearedAtom } = use(ScreenItemContext)\n  const setIsFocused = useSetAtom(isFocusedAtom)\n  const setIsAppeared = useSetAtom(isAppearedAtom)\n  const setIsDisappeared = useSetAtom(isDisappearedAtom)\n  useEffect(() => {\n    if (isSelected) {\n      setIsFocused(true)\n      setIsAppeared(true)\n      setIsDisappeared(false)\n    } else {\n      setIsFocused(false)\n      setIsAppeared(false)\n      setIsDisappeared(true)\n    }\n  }, [isSelected, setIsAppeared, setIsDisappeared, setIsFocused])\n  return null\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/bottom-tab/types.ts",
    "content": "import type { FC } from \"react\"\nimport type { NativeSyntheticEvent, ViewProps } from \"react-native\"\n\nimport type { IconNativeName } from \"@/src/constants/native-images\"\n\nexport type TabbarIconProps = {\n  focused: boolean\n  size?: number\n  color?: string\n\n  [key: string]: any\n}\nexport type TabScreenComponent = FC & {\n  lazy?: boolean\n}\nexport interface TabScreenProps {\n  title: string\n  tabScreenIndex: number\n\n  lazy?: boolean\n  identifier?: string\n\n  icon: IconNativeName\n  activeIcon: IconNativeName\n}\n\nexport interface ResolvedTabScreenProps extends Omit<TabScreenProps, \"icon\" | \"activeIcon\"> {\n  icon: (props: TabbarIconProps) => React.ReactNode\n}\n\nexport interface TabBarRootWrapperProps extends ViewProps {\n  /**\n   *\n   * iOS only\n   */\n  onTabIndexChange: (e: NativeSyntheticEvent<{ index: number }>) => void\n  /**\n   * iOS only\n   */\n  onTabItemPress?: (e: NativeSyntheticEvent<{ index: number; currentIndex: number }>) => void\n\n  selectedIndex: number\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/config.ts",
    "content": "import type { ScreenStackHeaderConfigProps } from \"react-native-screens\"\n\nexport const defaultHeaderConfig: ScreenStackHeaderConfigProps = {\n  hidden: true,\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/debug/DebugButtonGroup.tsx",
    "content": "/* eslint-disable no-console */\nimport { useEffect, useRef } from \"react\"\nimport { Button, Pressable, SafeAreaView, ScrollView, View } from \"react-native\"\n\nimport { FullWindowOverlay } from \"@/src/components/common/FullWindowOverlay\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CloseCuteReIcon } from \"@/src/icons/close_cute_re\"\n\nimport { useNavigation } from \"../hooks\"\nimport { Navigation } from \"../Navigation\"\nimport type { NavigationControllerView } from \"../types\"\n\nconst TEST_SCREEN_ROWS = Array.from({ length: 100 }, (_, row) => `test-screen-row-${row}`)\n\nexport const DebugButtonGroup = () => {\n  useEffect(() => {\n    const disposers: (() => void)[] = []\n    disposers.push(\n      Navigation.rootNavigation.on(\"willAppear\", (payload) => {\n        console.log(\"willAppear\", payload)\n      }),\n      Navigation.rootNavigation.on(\"didAppear\", (payload) => {\n        console.log(\"didAppear\", payload)\n      }),\n      Navigation.rootNavigation.on(\"willDisappear\", (payload) => {\n        console.log(\"willDisappear\", payload)\n      }),\n      Navigation.rootNavigation.on(\"didDisappear\", (payload) => {\n        console.log(\"didDisappear\", payload)\n      }),\n    )\n    return () => {\n      disposers.forEach((disposer) => disposer())\n    }\n  }, [])\n  const cntRef = useRef(0)\n  return (\n    <View className=\"flex-1 bg-gray-50\">\n      <SafeAreaView>\n        <Text>\n          Ad eveniet laboriosam hic voluptas non facilis sint. Laborum rem et provident blanditiis\n          iure rem. Porro voluptate ipsa explicabo voluptatem cumque est architecto. Sit\n          voluptatibus exercitationem recusandae cupiditate tenetur inventore amet repellendus\n          ratione. Nobis nulla harum soluta aliquam iure unde saepe. Modi ipsam harum aspernatur\n          aperiam quod pariatur nisi corporis. Doloribus molestiae a dolore. Veniam commodi nesciunt\n          beatae itaque aliquid nemo. Ut labore rem voluptates. Reprehenderit recusandae voluptate\n          earum consectetur tempora corrupti. Nulla ducimus enim sit ipsam eum esse debitis saepe.\n          Nobis ut voluptas. Id sapiente voluptate soluta ipsa esse corrupti facere nemo recusandae.\n          Dignissimos eum mollitia hic corrupti. Reiciendis voluptates et provident sed laborum\n          consequuntur. Quod nemo nesciunt dignissimos doloribus veniam odio.\n        </Text>\n      </SafeAreaView>\n      <FullWindowOverlay>\n        <View className=\"absolute inset-x-0 bottom-24\">\n          <Button\n            title=\"Push\"\n            onPress={() => {\n              Navigation.rootNavigation.pushControllerView(\n                cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,\n              )\n            }}\n          />\n          <Button\n            title=\"formSheet\"\n            onPress={() => {\n              Navigation.rootNavigation.presentControllerView(\n                cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,\n                void 0,\n                \"formSheet\",\n              )\n            }}\n          />\n          <Button\n            title=\"Modal\"\n            onPress={() => {\n              Navigation.rootNavigation.presentControllerView(\n                cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,\n              )\n            }}\n          />\n          <Button\n            title=\"Transparent Modal\"\n            onPress={() => {\n              Navigation.rootNavigation.presentControllerView(\n                cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,\n                void 0,\n                \"transparentModal\",\n              )\n            }}\n          />\n          <Button\n            title=\"Full Screen Modal\"\n            onPress={() => {\n              Navigation.rootNavigation.presentControllerView(\n                cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,\n                void 0,\n                \"fullScreenModal\",\n              )\n            }}\n          />\n        </View>\n      </FullWindowOverlay>\n    </View>\n  )\n}\nconst TestScreen3: NavigationControllerView = () => {\n  const navigation = useNavigation()\n  return (\n    <View className=\"flex-1 bg-white\">\n      <Pressable\n        className=\"absolute right-5 top-12 z-10 p-4\"\n        onPress={() => {\n          navigation.back()\n        }}\n      >\n        <CloseCuteReIcon height={20} width={20} color=\"black\" />\n      </Pressable>\n\n      <Text>TestScreen3</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n      <Text>Hello2</Text>\n    </View>\n  )\n}\nTestScreen3.sheetAllowedDetents = [0.7, 1]\nTestScreen3.sheetCornerRadius = 0\nconst TestScreen: NavigationControllerView = () => {\n  const navigation = useNavigation()\n  return (\n    <View className=\"flex-1\">\n      <Pressable\n        className=\"absolute right-5 top-12 z-10 p-4\"\n        onPress={() => {\n          navigation.back()\n        }}\n      >\n        <CloseCuteReIcon height={20} width={20} color=\"black\" />\n      </Pressable>\n      <ScrollView className=\"flex-1 bg-white\">\n        {TEST_SCREEN_ROWS.map((rowKey) => {\n          return (\n            <Text key={rowKey} className=\"text-black\">\n              TestScreen\n            </Text>\n          )\n        })}\n      </ScrollView>\n    </View>\n  )\n}\nTestScreen.sheetAllowedDetents = [0.5, 1]\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/hooks.ts",
    "content": "import { use } from \"react\"\nimport type { StackPresentationTypes } from \"react-native-screens\"\n\nimport { GroupedNavigationRouteContext } from \"./GroupedNavigationRouteContext\"\nimport { NavigationInstanceContext } from \"./NavigationInstanceContext\"\nimport { ScreenItemContext } from \"./ScreenItemContext\"\n\nexport const useCanBack = () => {\n  const { screenId } = use(ScreenItemContext)\n\n  const routeGroups = use(GroupedNavigationRouteContext)\n  if (!routeGroups) return false\n\n  const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))\n  if (!routeGroup) return false\n\n  if (routeGroup.length === 0) return false\n  // If routeGroup is M, P, P and current route is P, then we can back\n\n  const firstIsModal = routeGroup.at(0)?.type !== \"push\"\n  const onlyOne = routeGroup.length === 1\n  if (firstIsModal && onlyOne) return false\n  return true\n}\n\n/**\n * If screen present as a modal, then we can dismiss it.\n */\nexport const useCanDismiss = () => {\n  const { screenId } = use(ScreenItemContext)\n\n  const routeGroups = use(GroupedNavigationRouteContext)\n  if (!routeGroups) return false\n\n  const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))\n  if (!routeGroup || routeGroup.length === 0) return false\n\n  return routeGroup.at(0)?.type !== \"push\"\n}\n\nexport const useNavigation = () => {\n  const navigation = use(NavigationInstanceContext)\n  if (!navigation) {\n    throw new Error(\"Navigation not found\")\n  }\n  return navigation\n}\n\nexport const useScreenIsInModal = useCanDismiss\n\nconst sheetTypes = new Set<StackPresentationTypes>([\"formSheet\", \"modal\"])\nexport const useScreenIsInSheetModal = () => {\n  const { screenId } = use(ScreenItemContext)\n\n  const routeGroups = use(GroupedNavigationRouteContext)\n  if (!routeGroups) return false\n\n  const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))\n  if (!routeGroup || routeGroup.length === 0) return false\n  const first = routeGroup.at(0)\n  if (!first) return false\n  return sheetTypes.has(first.type)\n}\n\nexport const useIsSingleRouteInGroup = () => {\n  const { screenId } = use(ScreenItemContext)\n\n  const routeGroups = use(GroupedNavigationRouteContext)\n  if (!routeGroups) return false\n\n  const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))\n  if (!routeGroup || routeGroup.length === 0) return false\n  return routeGroup.length === 1\n}\n\nexport const useIsTopRouteInGroup = () => {\n  const { screenId } = use(ScreenItemContext)\n\n  const routeGroups = use(GroupedNavigationRouteContext)\n  if (!routeGroups) return false\n  const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))\n\n  if (!routeGroup || routeGroup.length === 0) return false\n\n  return routeGroup.at(0)?.screenOptions?.id === screenId\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/readme.md",
    "content": "## Docs\n\nhttps://www.notion.so/rss3/React-Native-Navigation-Yet-1c035ea049b48068bdc1c21009961622\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/sitemap/registry.ts",
    "content": "import type { NavigationControllerView, NavigationControllerViewType } from \"../types\"\n\ntype RegisterOptions = {\n  stackPresentation: NavigationControllerViewType\n}\ntype Registry = RegisterOptions & {\n  Component: NavigationControllerView<any>\n  props: any\n}\n\nclass NavigationSitemapRegistryStatic {\n  private map = new Map<string, Registry>()\n  registerByComponent<T>(\n    controllerView: NavigationControllerView<T>,\n    props?: T,\n    options?: Partial<RegisterOptions>,\n  ) {\n    const title = controllerView.id || controllerView.displayName || controllerView.name\n    if (!title) {\n      if (__DEV__) {\n        console.error(\"registerByComponent: no name, ignore\")\n      }\n      return\n    }\n    this.map.set(title, {\n      Component: controllerView,\n      props,\n      stackPresentation: \"push\",\n      ...options,\n    })\n  }\n\n  [Symbol.iterator]() {\n    return this.map.entries()\n  }\n  entries() {\n    return [...this.map.entries()]\n  }\n}\n\nexport const NavigationSitemapRegistry = new NavigationSitemapRegistryStatic()\n"
  },
  {
    "path": "apps/mobile/src/lib/navigation/types.ts",
    "content": "import type { FC } from \"react\"\nimport type { ScreenProps, StackPresentationTypes } from \"react-native-screens\"\n\nexport interface NavigationPushOptions<T> {\n  Component?: NavigationControllerView<T>\n  element?: React.ReactElement\n}\n\nexport interface NavigationControllerViewExtraProps extends Pick<\n  ScreenProps,\n  | \"sheetAllowedDetents\"\n  | \"sheetCornerRadius\"\n  | \"sheetExpandsWhenScrolledToEdge\"\n  | \"sheetElevation\"\n  | \"sheetGrabberVisible\"\n  | \"sheetInitialDetentIndex\"\n  | \"sheetLargestUndimmedDetentIndex\"\n  // Transition-related props\n  | \"stackAnimation\"\n  | \"replaceAnimation\"\n  | \"transitionDuration\"\n> {\n  /**\n   * Unique identifier for the view.\n   */\n  id?: string\n\n  /**\n   * Title for the view.\n   */\n  title?: string\n\n  /**\n   * Whether the view is transparent.\n   */\n  transparent?: boolean\n}\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport type NavigationControllerView<P = {}> = FC<P> & NavigationControllerViewExtraProps\nexport type NavigationControllerViewType = StackPresentationTypes\n"
  },
  {
    "path": "apps/mobile/src/lib/onboarding.ts",
    "content": "import { isOnboardingFinishedStorageKey } from \"@follow/store/user/constants\"\n\nimport { kv } from \"./kv\"\nimport { safeSecureStore } from \"./secure-store\"\n\nconst onboardingFinishedBackupKey = `${isOnboardingFinishedStorageKey}:persistent`\n\nexport const markOnboardingFinished = async () => {\n  await kv.set(isOnboardingFinishedStorageKey, \"true\")\n  safeSecureStore.setItem(onboardingFinishedBackupKey, \"true\")\n}\n\nexport const hasFinishedOnboarding = async () => {\n  const finished = await kv.get(isOnboardingFinishedStorageKey)\n  if (finished === \"true\") {\n    return true\n  }\n\n  const persistedFinished = safeSecureStore.getItem(onboardingFinishedBackupKey)\n  if (persistedFinished === \"true\") {\n    await kv.set(isOnboardingFinishedStorageKey, \"true\")\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/parse-api-error.ts",
    "content": "// Deprecated: replaced by FollowClient in lib/api-client\nimport { FetchError } from \"ofetch\"\n\nexport const getBizFetchErrorMessage = (error: Error) => {\n  if (error instanceof FetchError && error.response) {\n    try {\n      const data = JSON.parse(error.response._data)\n\n      if (data.message && data.code) {\n        // TODO i18n handle by code\n        return data.message\n      }\n    } catch {\n      return error.message\n    }\n  }\n  return error.message\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/payment.ts",
    "content": "import DeviceInfo from \"react-native-device-info\"\n\nimport { isAndroid } from \"./platform\"\n\nconst GOOGLE_PLAY_INSTALLER_PACKAGE_NAME = \"com.android.vending\"\nlet isAndroidApkInstallCache: boolean | undefined\n\nconst isGooglePlayInstall = (installerPackageName?: string | null) =>\n  installerPackageName === GOOGLE_PLAY_INSTALLER_PACKAGE_NAME\n\nexport const isAndroidApkInstall = () => {\n  if (isAndroidApkInstallCache !== undefined) {\n    return isAndroidApkInstallCache\n  }\n\n  if (!isAndroid) {\n    isAndroidApkInstallCache = false\n    return isAndroidApkInstallCache\n  }\n\n  try {\n    isAndroidApkInstallCache = !isGooglePlayInstall(DeviceInfo.getInstallerPackageNameSync())\n    return isAndroidApkInstallCache\n  } catch {\n    // Treat unknown installer as APK-style install and keep behavior permissive.\n    isAndroidApkInstallCache = true\n    return isAndroidApkInstallCache\n  }\n}\n\nexport const isPaymentFeatureEnabled = (paymentEnabled?: boolean | null) =>\n  Boolean(paymentEnabled) && (!isAndroid || isAndroidApkInstall())\n"
  },
  {
    "path": "apps/mobile/src/lib/permission.ts",
    "content": "import * as Notifications from \"expo-notifications\"\nimport { Platform } from \"react-native\"\n\nimport { getUISettings } from \"../atoms/settings/ui\"\nimport { toast } from \"./toast\"\n\nexport async function requestNotificationPermission() {\n  if (Platform.OS === \"android\") {\n    Notifications.setNotificationChannelAsync(\"default\", {\n      name: \"default\",\n      importance: Notifications.AndroidImportance.MAX,\n      vibrationPattern: [0, 250, 250, 250],\n      lightColor: \"#FF231F7C\",\n    })\n  }\n\n  const { status: existingStatus } = await Notifications.getPermissionsAsync()\n  let finalStatus = existingStatus\n  if (existingStatus !== \"granted\") {\n    const { status } = await Notifications.requestPermissionsAsync()\n    finalStatus = status\n  }\n  if (finalStatus !== \"granted\") {\n    toast.error(\"Permission not granted for notification!\")\n    return false\n  }\n  return true\n}\n\nexport async function setBadgeCountAsyncWithPermission(\n  badgeCount: number,\n  skipSettingCheck = false,\n) {\n  const { showUnreadCountBadgeMobile } = getUISettings()\n  if (!showUnreadCountBadgeMobile && !skipSettingCheck) {\n    return false\n  }\n\n  const permissionGranted = await requestNotificationPermission()\n  if (!permissionGranted) {\n    return false\n  }\n\n  const currentBadgeCount = await Notifications.getBadgeCountAsync()\n  if (badgeCount === currentBadgeCount) {\n    return false\n  }\n\n  return await Notifications.setBadgeCountAsync(badgeCount)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/platform.ts",
    "content": "import { DeviceType } from \"expo-device\"\nimport { Platform } from \"react-native\"\n\nimport { useDeviceType } from \"../atoms/hooks/useDeviceType\"\n\nexport const isIOS = Platform.OS === \"ios\"\nexport const isAndroid = Platform.OS === \"android\"\nexport const isNative = isIOS || isAndroid\nexport const devicePlatform = isIOS ? \"ios\" : isAndroid ? \"android\" : \"web\"\nexport const isWeb = !isNative\n\nexport const useIsiPad = () => {\n  return useDeviceType() === DeviceType.TABLET && isIOS\n}\n\nexport const isIos26 = Number.parseFloat(String(Platform.Version)) >= 26\n"
  },
  {
    "path": "apps/mobile/src/lib/player.ts",
    "content": "import { atom, useAtom } from \"jotai\"\nimport { useCallback, useEffect } from \"react\"\nimport TrackPlayer, { useActiveTrack, useIsPlaying } from \"react-native-track-player\"\n\nimport { PlayerRegistered } from \"../initialize/player\"\nimport { toast } from \"./toast\"\n\nexport type SimpleMediaState = \"playing\" | \"paused\" | \"loading\"\n\n/**\n * Learn more https://rntp.dev/docs/guides/play-button\n */\nexport function useAudioPlayState(audioUrl?: string): SimpleMediaState {\n  const playState = useIsPlaying()\n  const activeTrack = useActiveTrack()\n  const playingUrl = activeTrack?.url\n\n  const isCurrentTrack = !audioUrl || playingUrl === audioUrl\n  if (!playingUrl || !isCurrentTrack) {\n    // By default the audio should be in \"paused\" state\n    return \"paused\"\n  }\n\n  if (playState.bufferingDuringPlay === true) {\n    return \"loading\"\n  }\n  return playState.playing ? \"playing\" : \"paused\"\n}\n\nclass Player {\n  async play(newTrack?: {\n    url: string\n    title?: string | null\n    artist?: string | null\n    artwork?: string | null\n  }) {\n    if (!PlayerRegistered) {\n      toast.error(\"Player is not registered. Please wait for the app to initialize.\")\n    }\n    if (newTrack) {\n      const activeTrack = await TrackPlayer.getActiveTrack()\n      if (activeTrack?.url !== newTrack.url) {\n        const { url, title, artist, artwork } = newTrack\n\n        await TrackPlayer.load({\n          url,\n          title: title ?? \"Unknown Title\",\n          artist: artist ?? \"Unknown Artist\",\n          artwork: artwork ?? undefined,\n        })\n      }\n    }\n\n    await TrackPlayer.play()\n  }\n\n  async pause() {\n    await TrackPlayer.pause()\n  }\n\n  async reset() {\n    await TrackPlayer.reset()\n  }\n\n  async seekBy(offset: number) {\n    await TrackPlayer.seekBy(offset)\n  }\n\n  async seekTo(position: number) {\n    await TrackPlayer.seekTo(position)\n  }\n}\n\nexport const player = new Player()\n\nexport { useActiveTrack, useIsPlaying, useProgress } from \"react-native-track-player\"\n\nexport const allowedRate = [0.75, 1, 1.25, 1.5, 1.75, 2]\nexport type Rate = (typeof allowedRate)[number]\n\nconst rateAtom = atom<Rate>(1)\n\nexport function useRate() {\n  const [rate, setRate] = useAtom(rateAtom)\n\n  useEffect(() => {\n    async function getRate() {\n      const rate = await TrackPlayer.getRate()\n      if (allowedRate.includes(rate)) {\n        setRate(rate as Rate)\n      } else {\n        setRate(1)\n      }\n    }\n\n    getRate()\n  }, [setRate])\n\n  const setRateAndSave = useCallback(\n    async (rate: Rate) => {\n      if (allowedRate.includes(rate)) {\n        await TrackPlayer.setRate(rate)\n        setRate(rate)\n      }\n    },\n    [setRate],\n  )\n\n  return [rate, setRateAndSave] as const\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/proxy-env.ts",
    "content": "import type { env, envProfileMap } from \"@follow/shared/env.rn\"\nimport { getEnvProfiles__dangerously } from \"@follow/shared/env.rn\"\nimport { createAtomHooks } from \"@follow/utils\"\nimport { reloadAppAsync } from \"expo\"\nimport { atomWithStorage } from \"jotai/utils\"\nimport type { SyncStorage } from \"jotai/vanilla/utils/atomWithStorage\"\n\nimport { cookieKey } from \"./auth\"\nimport { getE2EEnvProfile } from \"./e2e-config\"\nimport { JotaiPersistSyncStorage } from \"./jotai\"\nimport { safeSecureStore } from \"./secure-store\"\n\nconst getForcedEnvProfile = (): keyof typeof envProfileMap | null => {\n  const profile = getE2EEnvProfile()\n  if (!profile) {\n    return null\n  }\n\n  const envProfiles = getEnvProfiles__dangerously()\n  return profile in envProfiles ? (profile as keyof typeof envProfileMap) : null\n}\n\nconst [, , useStoredEnvProfile, , getStoredEnvProfile, _setEnvProfile] = createAtomHooks(\n  atomWithStorage(\n    \"##Follow-Current-Env-Profile\",\n    __DEV__ ? \"dev\" : \"prod\",\n    JotaiPersistSyncStorage as SyncStorage<string>,\n    {\n      getOnInit: true,\n    },\n  ),\n)\n\nconst getEnvProfile = () =>\n  getForcedEnvProfile() ?? (getStoredEnvProfile() as keyof typeof envProfileMap)\n\nconst useEnvProfile = () => {\n  const storedProfile = useStoredEnvProfile() as keyof typeof envProfileMap\n  return getForcedEnvProfile() ?? storedProfile\n}\n\nexport const proxyEnv = new Proxy(\n  {},\n  {\n    get(target, prop) {\n      const profile = getEnvProfile() as keyof typeof envProfileMap\n      const envProfiles = getEnvProfiles__dangerously()\n      const envMap = envProfiles[profile]\n\n      return envMap[prop as keyof typeof envMap]\n    },\n  },\n) as any as typeof env\n\nexport const setEnvProfile = (profile: keyof typeof envProfileMap) => {\n  if (getForcedEnvProfile()) return\n\n  const currentProfile = getEnvProfile()\n  if (currentProfile === profile) return\n  _setEnvProfile(profile)\n  try {\n    const input = safeSecureStore.getItem(`${cookieKey}_${profile}`)\n    if (input) {\n      safeSecureStore.setItem(cookieKey, input)\n    }\n  } catch (e) {\n    console.warn(\"SecureStore access failed during env profile switch:\", e)\n  }\n\n  reloadAppAsync()\n}\nexport { getEnvProfile, useEnvProfile }\n"
  },
  {
    "path": "apps/mobile/src/lib/query-client.ts",
    "content": "import { FollowAPIError } from \"@follow-app/client-sdk\"\nimport { createSyncStoragePersister } from \"@tanstack/query-sync-storage-persister\"\nimport { QueryClient } from \"@tanstack/react-query\"\nimport { FetchError } from \"ofetch\"\n\nimport { kv } from \"./kv\"\n\nconst defaultStaleTime = 600_000 // 10min\nconst DO_NOT_RETRY_CODES = new Set([400, 401, 403, 404, 422, 402])\nexport const queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      gcTime: 1000 * 60 * 60 * 24,\n      refetchOnWindowFocus: false,\n      retryDelay: 1000,\n      staleTime: defaultStaleTime,\n      retry(failureCount, error) {\n        if (\n          error instanceof FetchError &&\n          (error.statusCode === undefined || DO_NOT_RETRY_CODES.has(error.statusCode))\n        ) {\n          return false\n        }\n\n        if (error instanceof FollowAPIError && DO_NOT_RETRY_CODES.has(error.status)) {\n          return false\n        }\n\n        return !!(3 - failureCount)\n      },\n      // throwOnError: import.meta.env.DEV,\n    },\n  },\n})\n\nexport const kvStoragePersister = createSyncStoragePersister({\n  storage: {\n    getItem: (key: string) => kv.getSync(key),\n    setItem: (key: string, value: string) => kv.setSync(key, value),\n    removeItem: (key: string) => kv.delete(key),\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/lib/responsive.ts",
    "content": "import { DeviceType } from \"expo-device\"\nimport { useCallback, useMemo } from \"react\"\nimport type { ViewStyle } from \"react-native\"\nimport { Dimensions, useWindowDimensions } from \"react-native\"\n\nimport { useDeviceType } from \"../atoms/hooks/useDeviceType\"\nimport { isIOS } from \"./platform\"\n\nconst baseWidth = 375\nconst baseHeight = 812\nconst maxResponsiveHeight = 932\nconst tabletMinLength = 744\nconst windowDim = Dimensions.get(\"window\")\n\nDimensions.addEventListener(\"change\", ({ window }) => {\n  Object.assign(windowDim, window)\n})\n/**\n * This scaleWidth is not responsive, it's just a simple scale util\n * @param size\n * @returns\n */\nexport const scaleWidth = (size: number) => {\n  return (size / baseWidth) * windowDim.width\n}\n/**\n * This scaleHeight is not responsive, it's just a simple scale util\n * @param size\n * @returns\n */\nexport const scaleHeight = (size: number) => {\n  return (size / baseHeight) * Math.min(windowDim.height, maxResponsiveHeight)\n}\nexport const useScaleWidth = () => {\n  const windowDim = useWindowDimensions()\n\n  return useCallback(\n    (size: number) => {\n      return (size / baseWidth) * windowDim.width\n    },\n    [windowDim.width],\n  )\n}\n\nexport const useScaleHeight = () => {\n  const windowDim = useWindowDimensions()\n\n  return useCallback(\n    (size: number) => {\n      return (size / baseHeight) * Math.min(windowDim.height, maxResponsiveHeight)\n    },\n    [windowDim.height],\n  )\n}\n\nexport const useIsTabletLayout = () => {\n  const deviceType = useDeviceType()\n  const { width, height } = useWindowDimensions()\n\n  if (!isIOS) {\n    return false\n  }\n\n  return deviceType === DeviceType.TABLET || Math.min(width, height) >= tabletMinLength\n}\n\nexport const useReadableContainerStyle = (maxWidth: number, gutter = 24) => {\n  const isTablet = useIsTabletLayout()\n  const { width } = useWindowDimensions()\n\n  return useMemo<ViewStyle | undefined>(() => {\n    if (!isTablet) {\n      return\n    }\n\n    return {\n      width: \"100%\",\n      maxWidth: Math.max(Math.min(maxWidth, width - gutter * 2), 0),\n      alignSelf: \"center\",\n    }\n  }, [gutter, isTablet, maxWidth, width])\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/secure-store.ts",
    "content": "import * as SecureStore from \"expo-secure-store\"\nimport Storage from \"expo-sqlite/kv-store\"\n\nconst fallbackPrefix = \"follow_secure_store_fallback\"\nconst warnedFallbackKeys = new Set<string>()\nlet forceFallback = false\n\nconst getFallbackKey = (key: string) => `${fallbackPrefix}:${key}`\n\nconst isSecureStoreUnavailable = (error: unknown) => {\n  if (!(error instanceof Error)) {\n    return false\n  }\n\n  return (\n    error.message.includes(\"KeyChainException\") ||\n    error.message.includes(\"required entitlement\") ||\n    error.message.includes(\"keychain\")\n  )\n}\n\nconst warnFallback = (action: \"getItem\" | \"setItem\", key: string, error: unknown) => {\n  const warnKey = `${action}:${key}`\n  if (warnedFallbackKeys.has(warnKey)) {\n    return\n  }\n\n  warnedFallbackKeys.add(warnKey)\n\n  if (!(error instanceof Error)) {\n    console.warn(`[auth-storage] SecureStore ${action} fallback enabled for ${key}`)\n    return\n  }\n\n  console.warn(`[auth-storage] SecureStore ${action} fallback enabled for ${key}: ${error.message}`)\n}\n\nconst getFallbackValue = (key: string) => Storage.getItemSync(getFallbackKey(key))\n\nexport const safeSecureStore = {\n  getItem(key: string) {\n    if (forceFallback) {\n      return getFallbackValue(key)\n    }\n\n    try {\n      const value = SecureStore.getItem(key)\n      if (value != null) {\n        return value\n      }\n    } catch (error) {\n      if (isSecureStoreUnavailable(error)) {\n        forceFallback = true\n        warnFallback(\"getItem\", key, error)\n        return getFallbackValue(key)\n      }\n\n      throw error\n    }\n\n    return getFallbackValue(key)\n  },\n  setItem(key: string, value: string) {\n    if (forceFallback) {\n      Storage.setItemSync(getFallbackKey(key), value)\n      return\n    }\n\n    try {\n      SecureStore.setItem(key, value)\n      Storage.removeItemSync(getFallbackKey(key))\n      return\n    } catch (error) {\n      if (isSecureStoreUnavailable(error)) {\n        forceFallback = true\n        warnFallback(\"setItem\", key, error)\n        Storage.setItemSync(getFallbackKey(key), value)\n        return\n      }\n\n      throw error\n    }\n  },\n  removeItem(key: string) {\n    Storage.removeItemSync(getFallbackKey(key))\n\n    if (forceFallback) {\n      return\n    }\n\n    void SecureStore.deleteItemAsync(key).catch((error) => {\n      if (isSecureStoreUnavailable(error)) {\n        forceFallback = true\n        warnFallback(\"setItem\", key, error)\n        return\n      }\n\n      console.warn(\n        `[auth-storage] SecureStore removeItem failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,\n      )\n    })\n  },\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/toast.tsx",
    "content": "import { requireNativeModule } from \"expo\"\nimport { Platform } from \"react-native\"\n\nimport { ToastManager } from \"../components/ui/toast/manager\"\nimport type { ToastProps } from \"../components/ui/toast/types\"\n\nexport const toastInstance = new ToastManager()\n\ntype CommandToastOptions = Partial<\n  Pick<ToastProps, \"duration\" | \"icon\" | \"render\" | \"message\" | \"position\">\n>\ntype Toast = {\n  // [key in \"error\" | \"success\" | \"info\"]: (message: string) => void;\n  show: typeof toastInstance.show\n  error: (message: string, options?: CommandToastOptions) => void\n  success: (message: string, options?: CommandToastOptions) => void\n  info: (message: string, options?: CommandToastOptions) => void\n}\n\ninterface NativeToasterOptions {\n  title: string\n  message: string\n  type: \"error\" | \"success\" | \"info\" | \"warn\"\n  /**\n   * seconds\n   */\n  duration?: number\n  position?: \"top\" | \"center\" | \"bottom\"\n}\nexport const toast = {\n  show: toastInstance.show.bind(toastInstance),\n} as Toast\n;([\"error\", \"success\", \"info\"] as const).forEach((type) => {\n  toast[type] = (message: string, options: CommandToastOptions = {}) => {\n    if (Platform.OS === \"ios\") {\n      const NativeToaster = requireNativeModule(\"Toaster\")\n      NativeToaster.toast({\n        // title: message,\n        message,\n        type,\n        duration: options.duration ? options.duration / 1000 : 1.5,\n        position: options.position,\n      } as NativeToasterOptions)\n      return\n    }\n\n    toastInstance.show({\n      type,\n      message,\n      variant: \"center-replace\",\n      duration: options.duration ?? 1500,\n      ...options,\n    })\n  }\n})\n"
  },
  {
    "path": "apps/mobile/src/lib/token.ts",
    "content": "import { getApp } from \"@react-native-firebase/app\"\nimport getAppCheck, { getLimitedUseToken } from \"@react-native-firebase/app-check\"\n\nexport async function getTokenHeaders() {\n  const app = getApp()\n  const appCheck = getAppCheck(app)\n  let token = \"\"\n  try {\n    const appCheckToken = await getLimitedUseToken(appCheck)\n    token = appCheckToken.token\n  } catch (error) {\n    console.warn(\"[app-check] failed to get limited-use token, fallback to synthetic token\", error)\n  }\n\n  return {\n    \"x-token\": `ac:${token || \"fallback\"}`,\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/url-builder.ts",
    "content": "import { UrlBuilder as UrlBuilderClass } from \"@follow/utils/url-builder\"\n\nimport { proxyEnv } from \"./proxy-env\"\n\nexport const UrlBuilder = new UrlBuilderClass(proxyEnv.WEB_URL)\n"
  },
  {
    "path": "apps/mobile/src/lib/volume.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\"\nimport { VolumeManager } from \"react-native-volume-manager\"\n\nexport const useVolume = () => {\n  const [volume, setVolume] = useState<number | undefined>()\n\n  const updateVolume = useCallback(async (newVolume: number) => {\n    if (newVolume < 0 || newVolume > 1) return\n\n    setVolume(newVolume)\n\n    await VolumeManager.setVolume(newVolume)\n  }, [])\n\n  useEffect(() => {\n    async function getVolume() {\n      const result = await VolumeManager.getVolume()\n      setVolume(result.volume)\n    }\n\n    getVolume()\n  }, [])\n\n  useEffect(() => {\n    const volumeListener = VolumeManager.addVolumeListener((result) => {\n      setVolume(result.volume)\n    })\n\n    // Remove the volume listener\n    return () => {\n      volumeListener.remove()\n    }\n  }, [])\n\n  return { volume, updateVolume }\n}\n"
  },
  {
    "path": "apps/mobile/src/main.tsx",
    "content": "import \"./global.css\"\nimport \"./polyfill\"\n\nimport { apiContext, authClientContext, queryClientContext } from \"@follow/store/context\"\nimport { registerRootComponent } from \"expo\"\nimport { Image } from \"expo-image\"\nimport { LinearGradient } from \"expo-linear-gradient\"\nimport { cssInterop } from \"nativewind\"\nimport { useTranslation } from \"react-i18next\"\nimport { enableFreeze } from \"react-native-screens\"\n\nimport { App } from \"./App\"\nimport { BottomTabProvider } from \"./components/layouts/tabbar/BottomTabProvider\"\nimport { ReactNativeTab } from \"./components/layouts/tabbar/ReactNativeTab\"\nimport { Lightbox } from \"./components/ui/lightbox/Lightbox\"\nimport { initializeApp } from \"./initialize\"\nimport { followApi } from \"./lib/api-client\"\nimport { authClient } from \"./lib/auth\"\nimport { initializeI18n } from \"./lib/i18n\"\nimport { TabRoot } from \"./lib/navigation/bottom-tab/TabRoot\"\nimport { TabScreen } from \"./lib/navigation/bottom-tab/TabScreen\"\nimport { RootStackNavigation } from \"./lib/navigation/StackNavigation\"\nimport { queryClient } from \"./lib/query-client\"\nimport { RootProviders } from \"./providers\"\nimport { IndexTabScreen } from \"./screens/(stack)/(tabs)\"\nimport { DiscoverTabScreen } from \"./screens/(stack)/(tabs)/discover\"\nimport { SettingsTabScreen } from \"./screens/(stack)/(tabs)/settings\"\nimport { SubscriptionsTabScreen } from \"./screens/(stack)/(tabs)/subscriptions\"\nimport { registerSitemap } from \"./sitemap\"\n// @ts-expect-error\nglobal.APP_NAME = \"Folo\"\n// @ts-expect-error\nglobal.ELECTRON = false\n// @ts-expect-error\nauthClientContext.provide(authClient)\nqueryClientContext.provide(queryClient)\napiContext.provide(followApi)\n\nenableFreeze(true)\n;[Image, LinearGradient].forEach((Component) => {\n  cssInterop(Component, { className: \"style\" })\n})\n\ninitializeApp()\nregisterSitemap()\ninitializeI18n()\nregisterRootComponent(RootComponent)\n\nfunction RootComponent() {\n  const { t } = useTranslation()\n  return (\n    <RootProviders>\n      <BottomTabProvider>\n        <RootStackNavigation\n          headerConfig={{\n            hidden: true,\n          }}\n        >\n          <App>\n            <TabRoot>\n              <TabScreen\n                activeIcon={\"home5CuteFi\"}\n                icon={\"home5CuteRe\"}\n                title={t(\"tabs.home\")}\n                identifier=\"IndexTabScreen\"\n              >\n                <IndexTabScreen />\n              </TabScreen>\n\n              <TabScreen\n                activeIcon={\"blackBoard2CuteFi\"}\n                icon={\"blackBoard2CuteRe\"}\n                title={t(\"tabs.subscriptions\")}\n                identifier=\"SubscriptionsTabScreen\"\n              >\n                <SubscriptionsTabScreen />\n              </TabScreen>\n\n              <TabScreen\n                activeIcon={\"search3CuteFi\"}\n                icon={\"search3CuteRe\"}\n                title={t(\"tabs.discover\")}\n                identifier=\"DiscoverTabScreen\"\n              >\n                <DiscoverTabScreen />\n              </TabScreen>\n              <TabScreen\n                activeIcon={\"settings1CuteFi\"}\n                icon={\"settings1CuteRe\"}\n                title={t(\"tabs.settings\")}\n                identifier=\"SettingsTabScreen\"\n              >\n                <SettingsTabScreen />\n              </TabScreen>\n\n              <ReactNativeTab />\n            </TabRoot>\n          </App>\n        </RootStackNavigation>\n        <Lightbox />\n      </BottomTabProvider>\n    </RootProviders>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/ai/summary.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { FollowAPIError } from \"@follow-app/client-sdk\"\nimport * as Haptics from \"expo-haptics\"\nimport type { FC, ReactNode } from \"react\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { LayoutChangeEvent } from \"react-native\"\nimport { Clipboard, Pressable, ScrollView, StyleSheet, TextInput, View } from \"react-native\"\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withRepeat,\n  withSequence,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { useIsPaymentEnabled } from \"@/src/atoms/server-configs\"\nimport { BottomModal } from \"@/src/components/ui/modal/BottomModal\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { AiCuteReIcon } from \"@/src/icons/ai_cute_re\"\nimport { CloseCuteReIcon } from \"@/src/icons/close_cute_re\"\nimport { CopyCuteReIcon } from \"@/src/icons/copy_cute_re\"\nimport { PowerMonoIcon } from \"@/src/icons/power_mono\"\nimport { RightCuteReIcon } from \"@/src/icons/right_cute_re\"\nimport { isAndroid, isIOS } from \"@/src/lib/platform\"\nimport { toast } from \"@/src/lib/toast\"\nimport { navigateToPlanScreen } from \"@/src/modules/settings/routes/navigateToPlanScreen\"\n\nexport const AISummary: FC<{\n  className?: string\n  summary?: string | ReactNode\n  pending?: boolean\n  rawSummaryForCopy?: string\n  error?: unknown\n  onRetry?: () => void\n}> = ({ className, summary, pending = false, rawSummaryForCopy, error, onRetry }) => {\n  const { t } = useTranslation()\n  const opacity = useSharedValue(0.3)\n  const height = useSharedValue(0)\n  const [isSheetOpen, setSheetOpen] = React.useState(false)\n  React.useEffect(() => {\n    if (pending) {\n      opacity.value = withRepeat(\n        withSequence(\n          withTiming(1, {\n            duration: 800,\n          }),\n          withTiming(0.3, {\n            duration: 800,\n          }),\n        ),\n        -1,\n      )\n    } else {\n      opacity.value = 1\n    }\n  }, [opacity, pending])\n  const animatedContentStyle = useAnimatedStyle(() => ({\n    height: height.value,\n    opacity:\n      height.value === 0\n        ? 0\n        : withTiming(1, {\n            duration: 200,\n          }),\n    overflow: \"hidden\",\n  }))\n  const loadingPulseStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n  }))\n  const [contentHeight, setContentHeight] = React.useState(0)\n  const measureContent = (event: LayoutChangeEvent) => {\n    setContentHeight(event.nativeEvent.layout.height + 10)\n    height.value = withSpring(event.nativeEvent.layout.height + 10, {\n      duration: 200,\n      dampingRatio: 0.8,\n      overshootClamping: true,\n    })\n  }\n  const purpleColor = useColor(\"purple\")\n  const isPaymentEnabled = useIsPaymentEnabled()\n  const followApiError = error instanceof FollowAPIError ? error : null\n  const shouldSuggestUpgrade = Boolean(isPaymentEnabled && followApiError?.status === 402)\n  const errorMessage =\n    typeof error === \"string\" ? error : error instanceof Error ? error.message : undefined\n  const showErrorContent = Boolean(errorMessage && !shouldSuggestUpgrade)\n  const upgradeTitle = t(\"ai.summary_upgrade_required_title\")\n  const upgradeDescription = t(\"ai.summary_upgrade_required_description\")\n  const upgradeCTA = t(\"ai.summary_upgrade_view_plans\")\n  const loadingTitle = t(\"ai.summary_generating\")\n  const summaryTitle = t(\"entry_content.ai_summary\")\n  const handleUpgradePress = () => {\n    void Haptics.selectionAsync()\n    void navigateToPlanScreen()\n  }\n\n  // Check if summary is a React element or string\n  const isReactElement = React.isValidElement(summary)\n  const summaryText = typeof summary === \"string\" ? summary : \"\"\n  const summaryTextForSheet = (rawSummaryForCopy || summaryText).trim()\n  if (!pending && !summary && !error) return null\n  const renderSummaryContent = (forMeasurement: boolean) => {\n    if (pending) {\n      return (\n        <View className=\"mt-2 gap-3\">\n          <View className=\"flex-row items-center gap-2\">\n            <Animated.View className=\"size-2.5 rounded-full bg-purple\" style={loadingPulseStyle} />\n            <Text className=\"text-[13px] text-secondary-label\">{loadingTitle}</Text>\n          </View>\n          <View className=\"gap-2\">\n            <Animated.View\n              className=\"h-3 rounded-full bg-quaternary-system-fill\"\n              style={[styles.loadingLine, styles.loadingLinePrimary, loadingPulseStyle]}\n            />\n            <Animated.View\n              className=\"h-3 rounded-full bg-quaternary-system-fill\"\n              style={[styles.loadingLine, styles.loadingLineSecondary, loadingPulseStyle]}\n            />\n            <Animated.View\n              className=\"h-3 rounded-full bg-quaternary-system-fill\"\n              style={[styles.loadingLine, styles.loadingLineTertiary, loadingPulseStyle]}\n            />\n          </View>\n        </View>\n      )\n    }\n\n    if (shouldSuggestUpgrade) {\n      return (\n        <UpgradePrompt\n          forMeasurement={forMeasurement}\n          iconColor={purpleColor}\n          title={upgradeTitle}\n          description={upgradeDescription}\n          ctaLabel={upgradeCTA}\n          onPress={handleUpgradePress}\n        />\n      )\n    }\n\n    if (showErrorContent && errorMessage) {\n      return (\n        <ErrorContent forMeasurement={forMeasurement} message={errorMessage} onRetry={onRetry} />\n      )\n    }\n\n    if (isReactElement) {\n      return <View className=\"mt-2\">{summary}</View>\n    }\n\n    return (\n      <Text className=\"mt-2 text-[13px] leading-5 text-label\" selectable={!forMeasurement}>\n        {summaryText.trim()}\n      </Text>\n    )\n  }\n  const mainContent = (\n    <Animated.View\n      className={cn(\n        \"my-2 rounded-xl border-opaque-separator/50\",\n        isReactElement ? \"px-4 pt-4\" : \"p-4\",\n        // Opacity modifier style incorrectly applied in Android\n        isAndroid ? \"bg-secondary-system-background\" : \"bg-secondary-system-background/30\",\n        className,\n      )}\n      style={styles.card}\n    >\n      <View className=\"mb-2 flex-row items-center justify-between\">\n        <View className=\"flex-row items-center gap-2\">\n          <AiCuteReIcon height={16} width={16} color={purpleColor} />\n          <Text className=\"text-[15px] font-semibold text-label\">{summaryTitle}</Text>\n        </View>\n        {summaryTextForSheet && (\n          <Pressable\n            onPress={() => {\n              Clipboard.setString(summaryTextForSheet)\n              Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)\n              toast.success(t(\"app.copied_to_clipboard\", { ns: \"common\" }))\n            }}\n            onLongPress={() => setSheetOpen(true)}\n            className=\"rounded-full bg-quaternary-system-fill p-1.5 active:opacity-70\"\n            hitSlop={{\n              top: 8,\n              bottom: 8,\n              left: 8,\n              right: 8,\n            }}\n          >\n            <CopyCuteReIcon width={14} height={14} color={purpleColor} />\n          </Pressable>\n        )}\n      </View>\n      <Animated.View style={animatedContentStyle}>\n        <View\n          style={{\n            height: contentHeight,\n          }}\n        >\n          {renderSummaryContent(false)}\n        </View>\n      </Animated.View>\n\n      <View className=\"absolute w-full opacity-0\" pointerEvents=\"none\">\n        <View onLayout={measureContent}>{renderSummaryContent(true)}</View>\n      </View>\n    </Animated.View>\n  )\n  return (\n    <>\n      <Pressable onLongPress={summaryTextForSheet ? () => setSheetOpen(true) : undefined}>\n        {mainContent}\n      </Pressable>\n      <SelectableTextSheet\n        visible={isSheetOpen}\n        onClose={() => setSheetOpen(false)}\n        text={summaryTextForSheet}\n      />\n    </>\n  )\n}\n\nconst ErrorContent = ({\n  forMeasurement,\n  message,\n  onRetry,\n}: {\n  forMeasurement: boolean\n  message: string\n  onRetry?: () => void\n}) => {\n  const { t } = useTranslation()\n  return (\n    <View className=\"mt-3\">\n      <View className=\"flex-row items-center gap-2\">\n        <Text className=\"flex-1 text-[14px] leading-[20px] text-red\">{message}</Text>\n      </View>\n      {onRetry &&\n        (forMeasurement ? (\n          <View className=\"mt-3 self-start rounded-full bg-quaternary-system-fill px-4 py-2\">\n            <Text className=\"text-[14px] font-medium text-label\">\n              {t(\"retry\", { ns: \"common\" })}\n            </Text>\n          </View>\n        ) : (\n          <Pressable\n            onPress={onRetry}\n            className=\"mt-3 self-start rounded-full bg-quaternary-system-fill px-4 py-2\"\n          >\n            <Text className=\"text-[14px] font-medium text-label\">\n              {t(\"retry\", { ns: \"common\" })}\n            </Text>\n          </Pressable>\n        ))}\n    </View>\n  )\n}\n\nconst UpgradePrompt = ({\n  forMeasurement,\n  iconColor,\n  title,\n  description,\n  ctaLabel,\n  onPress,\n}: {\n  forMeasurement: boolean\n  iconColor: string\n  title: string\n  description: string\n  ctaLabel: string\n  onPress: () => void\n}) => {\n  return (\n    <View className=\"mt-2 flex-row items-start gap-3\">\n      {/* Icon */}\n      <View className=\"relative\">\n        <View className=\"rounded-lg bg-purple p-2.5\">\n          <PowerMonoIcon width={18} height={18} color=\"white\" />\n        </View>\n      </View>\n\n      {/* Content */}\n      <View className=\"flex-1 gap-1\">\n        {/* Title */}\n        <Text className=\"text-sm font-medium text-label\">{title}</Text>\n\n        {/* Description */}\n        <Text className=\"text-sm text-secondary-label\">{description}</Text>\n\n        {/* CTA Button */}\n        {forMeasurement ? (\n          <View className=\"mt-1 flex-row items-center gap-1 self-start\">\n            <Text className=\"text-[13px] font-medium\" style={{ color: iconColor }}>\n              {ctaLabel}\n            </Text>\n          </View>\n        ) : (\n          <Pressable\n            onPress={onPress}\n            className=\"mt-1 flex-row items-center gap-1 self-start active:opacity-70\"\n          >\n            <Text className=\"text-[13px] font-medium\" style={{ color: iconColor }}>\n              {ctaLabel}\n            </Text>\n            <RightCuteReIcon width={14} height={14} color={iconColor} />\n          </Pressable>\n        )}\n      </View>\n    </View>\n  )\n}\n\nconst SelectableTextSheet: FC<{\n  visible: boolean\n  onClose: () => void\n  text: string\n}> = ({ visible, onClose, text }) => {\n  const { t } = useTranslation()\n  const insets = useSafeAreaInsets()\n  const textColor = useColor(\"label\")\n  const handleCopyAll = () => {\n    Clipboard.setString(text)\n    Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)\n    onClose()\n  }\n  return (\n    <BottomModal visible={visible} onClose={onClose}>\n      <View\n        className=\"m-4 mb-0 flex flex-1\"\n        style={{\n          paddingBottom: insets.bottom + 10,\n        }}\n      >\n        <View className=\"mb-4 flex-row items-center justify-between\">\n          <Pressable\n            onPress={handleCopyAll}\n            className=\"rounded-full bg-zinc-100 p-2 active:opacity-80 dark:bg-zinc-800\"\n          >\n            <CopyCuteReIcon width={18} height={18} color={textColor} />\n          </Pressable>\n          <Text className=\"text-lg font-semibold text-label\">{t(\"entry_content.ai_summary\")}</Text>\n          <Pressable\n            onPress={onClose}\n            className=\"rounded-full bg-zinc-100 p-2 active:opacity-80 dark:bg-zinc-800\"\n          >\n            <CloseCuteReIcon width={18} height={18} color={textColor} />\n          </Pressable>\n        </View>\n        <ScrollView showsVerticalScrollIndicator={false}>\n          <SelectableText className=\"text-base leading-6 text-label\">{text}</SelectableText>\n        </ScrollView>\n      </View>\n    </BottomModal>\n  )\n}\n\n/**\n * A component that allows text selection on both iOS and Android.\n *\n * https://stackoverflow.com/a/78267868\n */\nfunction SelectableText({ className, children }: { className?: string; children: ReactNode }) {\n  if (isIOS) {\n    return (\n      <TextInput multiline editable={false} className={className}>\n        {children}\n      </TextInput>\n    )\n  } else {\n    return (\n      <Text selectable className={className}>\n        {children}\n      </Text>\n    )\n  }\n}\nconst styles = StyleSheet.create({\n  card: {\n    borderWidth: 0.5,\n    elevation: 2,\n  },\n  loadingLine: {\n    borderRadius: 999,\n  },\n  loadingLinePrimary: {\n    width: \"100%\",\n  },\n  loadingLineSecondary: {\n    width: \"86%\",\n  },\n  loadingLineTertiary: {\n    width: \"72%\",\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/context-menu/entry.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { collectionSyncService } from \"@follow/store/collection/store\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { PortalProvider } from \"@gorhom/portal\"\nimport type { PropsWithChildren } from \"react\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Share, View } from \"react-native\"\n\nimport { getHideAllReadSubscriptions } from \"@/src/atoms/settings/general\"\nimport { EntryContentWebView } from \"@/src/components/native/webview/EntryContentWebView\"\nimport { WebViewManager } from \"@/src/components/native/webview/webview-manager\"\nimport { ContextMenu } from \"@/src/components/ui/context-menu\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\nimport { EntryDetailScreen } from \"@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen\"\n\nimport { getFetchEntryPayload, useSelectedFeed, useSelectedView } from \"../screen/atoms\"\n\nexport const EntryItemContextMenu = ({\n  id,\n  children,\n  view,\n}: PropsWithChildren<{\n  id: string\n  view: FeedViewType\n}>) => {\n  const { t } = useTranslation()\n  const selectedView = useSelectedView()\n  const selectedFeed = useSelectedFeed()\n  const isLoggedIn = useIsLoggedIn()\n  const entry = useEntry(id, (state) => ({\n    read: state.read,\n    feedId: state.feedId,\n    title: state.title,\n    publishedAt: state.publishedAt,\n    url: state.url,\n  }))\n  const feedId = entry?.feedId\n  const isEntryStarred = useIsEntryStarred(id)\n  const navigation = useNavigation()\n  const handlePressPreview = useCallback(() => {\n    if (entry) {\n      const fullEntry = getEntry(id)\n      if (fullEntry) {\n        WebViewManager.setEntry(fullEntry)\n      }\n      navigation.pushControllerView(EntryDetailScreen, {\n        entryId: id,\n        view: view!,\n      })\n    }\n  }, [entry, id, navigation, view])\n  if (!entry) return null\n  return (\n    <ContextMenu.Root>\n      <ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>\n\n      <ContextMenu.Content>\n        <ContextMenu.Preview size=\"STRETCH\" onPress={handlePressPreview}>\n          {() => (\n            <PortalProvider>\n              <View className=\"flex-1 bg-system-background\">\n                <Text className=\"mt-5 p-4 text-2xl font-semibold text-label\" numberOfLines={2}>\n                  {entry.title?.trim()}\n                </Text>\n                <EntryContentWebView entryId={id} />\n              </View>\n            </PortalProvider>\n          )}\n        </ContextMenu.Preview>\n\n        {isLoggedIn && (\n          <>\n            <ContextMenu.Item\n              key=\"MarkAsReadAbove\"\n              onSelect={() => {\n                const payload = getFetchEntryPayload(selectedFeed, selectedView)\n                const { publishedAt } = entry\n                unreadSyncService.markBatchAsRead({\n                  view: selectedView,\n                  filter: payload,\n                  time: {\n                    startTime: new Date(publishedAt).getTime() + 1,\n                    endTime: Date.now(),\n                  },\n                  excludePrivate: getHideAllReadSubscriptions(),\n                })\n              }}\n            >\n              <ContextMenu.ItemIcon\n                ios={{\n                  name: \"arrow.up\",\n                }}\n              />\n              <ContextMenu.ItemTitle>\n                {t(\"operation.mark_all_as_read_which\", {\n                  which: t(\"operation.mark_all_as_read_which_above\"),\n                })}\n              </ContextMenu.ItemTitle>\n            </ContextMenu.Item>\n\n            <ContextMenu.Item\n              key=\"MarkAsRead\"\n              onSelect={() => {\n                entry.read\n                  ? unreadSyncService.markEntryAsUnread(id)\n                  : unreadSyncService.markEntryAsRead(id)\n              }}\n            >\n              <ContextMenu.ItemTitle>\n                {entry.read ? t(\"operation.mark_as_unread\") : t(\"operation.mark_as_read\")}\n              </ContextMenu.ItemTitle>\n              <ContextMenu.ItemIcon\n                ios={{\n                  name: entry.read ? \"circle.fill\" : \"checkmark.circle\",\n                }}\n              />\n            </ContextMenu.Item>\n\n            <ContextMenu.Item\n              key=\"MarkAsReadBelow\"\n              onSelect={() => {\n                const payload = getFetchEntryPayload(selectedFeed, selectedView)\n                const { publishedAt } = entry\n                unreadSyncService.markBatchAsRead({\n                  view: selectedView,\n                  filter: payload,\n                  time: {\n                    startTime: 1,\n                    endTime: new Date(publishedAt).getTime() - 1,\n                  },\n                  excludePrivate: getHideAllReadSubscriptions(),\n                })\n              }}\n            >\n              <ContextMenu.ItemIcon\n                ios={{\n                  name: \"arrow.down\",\n                }}\n              />\n              <ContextMenu.ItemTitle>\n                {t(\"operation.mark_all_as_read_which\", {\n                  which: t(\"operation.mark_all_as_read_which_below\"),\n                })}\n              </ContextMenu.ItemTitle>\n            </ContextMenu.Item>\n          </>\n        )}\n\n        {isLoggedIn && feedId && view !== undefined && (\n          <ContextMenu.Item\n            key=\"Star\"\n            onSelect={() => {\n              if (isEntryStarred) {\n                collectionSyncService.unstarEntry({\n                  entryId: id,\n                })\n                toast.success(\"Unstarred\")\n              } else {\n                collectionSyncService.starEntry({\n                  entryId: id,\n                  view,\n                })\n                toast.success(\"Starred\")\n              }\n            }}\n          >\n            <ContextMenu.ItemIcon\n              ios={{\n                name: isEntryStarred ? \"star.slash\" : \"star\",\n              }}\n            />\n            <ContextMenu.ItemTitle>\n              {isEntryStarred ? t(\"operation.unstar\") : t(\"operation.star\")}\n            </ContextMenu.ItemTitle>\n          </ContextMenu.Item>\n        )}\n\n        {entry.url && (\n          <ContextMenu.Item\n            key=\"Share\"\n            onSelect={async () => {\n              if (!entry.url) return\n              await Share.share({\n                message: entry.url,\n                url: entry.url,\n                title: entry.title || \"Shared Link\",\n              })\n            }}\n          >\n            <ContextMenu.ItemIcon\n              ios={{\n                name: \"square.and.arrow.up\",\n              }}\n            />\n            <ContextMenu.ItemTitle>{t(\"operation.share\")}</ContextMenu.ItemTitle>\n          </ContextMenu.Item>\n        )}\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/context-menu/feeds.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useEntriesQuery, useEntryIdsByFeedId } from \"@follow/store/entry/hooks\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { getSubscriptionById } from \"@follow/store/subscription/getter\"\nimport { getSubscriptionCategory } from \"@follow/store/subscription/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport dayjs from \"dayjs\"\nimport { setStringAsync } from \"expo-clipboard\"\nimport { t } from \"i18next\"\nimport type { CSSProperties, FC, PropsWithChildren } from \"react\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { ListRenderItemInfo } from \"react-native\"\nimport { Alert, FlatList, View } from \"react-native\"\n\nimport { useFetchEntriesSettings } from \"@/src/atoms/settings/general\"\nimport { ContextMenu } from \"@/src/components/ui/context-menu\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { modalPrompt } from \"@/src/components/ui/modal/imperative-modal\"\nimport { views } from \"@/src/constants/views\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport { isIOS } from \"@/src/lib/platform\"\nimport { toast } from \"@/src/lib/toast\"\nimport { FollowScreen } from \"@/src/screens/(modal)/FollowScreen\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { ItemSeparator } from \"../entry-list/ItemSeparator\"\nimport { EntryNormalItem } from \"../entry-list/templates/EntryNormalItem\"\nimport { getSelectedView } from \"../screen/atoms\"\n\nexport const SubscriptionFeedItemContextMenu: FC<\n  PropsWithChildren & {\n    id: string\n  }\n> = ({ id, children }) => {\n  const navigation = useNavigation()\n  const [open, setOpen] = useState(false)\n  const [Content, setContent] = useState<React.ReactNode>(() =>\n    generateSubscriptionContextMenu(navigation, id),\n  )\n\n  useEffect(() => {\n    if (open) {\n      setContent(generateSubscriptionContextMenu(navigation, id))\n    }\n  }, [id, navigation, open])\n\n  return (\n    <ContextMenu.Root onOpenChange={setOpen}>\n      <ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>\n\n      {Content}\n    </ContextMenu.Root>\n  )\n}\n\nconst generateSubscriptionContextMenu = (navigation: Navigation, id: string) => {\n  const view = getSelectedView()\n\n  const feed = getFeedById(id)\n  const allCategories = getSubscriptionCategory(view)\n\n  return (\n    <ContextMenu.Content>\n      {view === FeedViewType.Articles && (\n        <ContextMenu.Preview\n          size=\"STRETCH\"\n          onPress={() => {\n            navigation.pushControllerView(FeedScreen, {\n              feedId: id,\n            })\n          }}\n        >\n          {() => <PreviewFeeds id={id} view={view!} />}\n        </ContextMenu.Preview>\n      )}\n\n      {!!feed?.errorAt && (\n        <ContextMenu.Item\n          key=\"ShowErrorMessage\"\n          onSelect={() => {\n            Alert.alert(\n              `${t(\"operation.error_since\")} ${dayjs\n                .duration(dayjs(feed.errorAt).diff(dayjs(), \"minute\"), \"minute\")\n                .humanize(true)}`,\n              feed.errorMessage ?? undefined,\n            )\n          }}\n        >\n          <ContextMenu.ItemTitle>{t(\"operation.show_error_message\")}</ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"exclamationmark.triangle\",\n            }}\n          />\n        </ContextMenu.Item>\n      )}\n      <ContextMenu.Item\n        key=\"MarkAllAsRead\"\n        onSelect={() => {\n          unreadSyncService.markFeedAsRead(id)\n        }}\n      >\n        <ContextMenu.ItemTitle>{t(\"operation.mark_all_as_read\")}</ContextMenu.ItemTitle>\n        <ContextMenu.ItemIcon\n          ios={{\n            name: \"checkmark.circle\",\n          }}\n        />\n      </ContextMenu.Item>\n\n      {/* <ContextMenu.Item key=\"Claim\">\n      <ContextMenu.ItemTitle>Claim</ContextMenu.ItemTitle>\n      <ContextMenu.ItemIcon\n        ios={{\n          name: \"checkmark.seal\",\n        }}\n      />\n    </ContextMenu.Item> */}\n\n      {/* <ContextMenu.Item key=\"Boost\">\n      <ContextMenu.ItemTitle>Boost</ContextMenu.ItemTitle>\n      <ContextMenu.ItemIcon\n        ios={{\n          name: \"bolt\",\n        }}\n      />\n    </ContextMenu.Item> */}\n\n      <ContextMenu.Sub key=\"AddToCategory\">\n        <ContextMenu.SubTrigger key=\"SubTrigger/AddToCategory\">\n          <ContextMenu.ItemTitle>{t(\"operation.add_feeds_to_category\")}</ContextMenu.ItemTitle>\n        </ContextMenu.SubTrigger>\n\n        <ContextMenu.SubContent>\n          <>\n            {allCategories.map((category) => {\n              const onSelect = () => {\n                const subscription = getSubscriptionById(id)\n                if (!subscription) return\n\n                // add to category\n                subscriptionSyncService.edit({\n                  ...subscription,\n                  category,\n                })\n              }\n              return (\n                <ContextMenu.Item key={`SubContent/${category}`} onSelect={onSelect}>\n                  <ContextMenu.ItemTitle>{category}</ContextMenu.ItemTitle>\n                </ContextMenu.Item>\n              )\n            })}\n          </>\n          <ContextMenu.Item\n            key={`SubContent/CreateNewCategory`}\n            onSelect={() => {\n              // create new category\n              const subscription = getSubscriptionById(id)\n              if (!subscription) return\n              const prompt = isIOS ? Alert.prompt : modalPrompt\n\n              prompt(\"Create New Category\", \"Enter the name of the new category\", (text) => {\n                subscriptionSyncService.edit({\n                  ...subscription,\n                  category: text,\n                })\n              })\n            }}\n          >\n            <ContextMenu.ItemTitle>Create New Category</ContextMenu.ItemTitle>\n            <ContextMenu.ItemIcon ios={{ name: \"plus\" }} />\n          </ContextMenu.Item>\n        </ContextMenu.SubContent>\n      </ContextMenu.Sub>\n\n      <ContextMenu.Item\n        key=\"Edit\"\n        onSelect={() => {\n          const subscription = getSubscriptionById(id)\n          if (!subscription || !subscription.feedId) return\n\n          navigation.presentControllerView(FollowScreen, {\n            type: \"feed\",\n            id: subscription.feedId,\n          })\n        }}\n      >\n        <ContextMenu.ItemTitle>{t(\"operation.edit\")}</ContextMenu.ItemTitle>\n        <ContextMenu.ItemIcon\n          ios={{\n            name: \"square.and.pencil\",\n          }}\n        />\n      </ContextMenu.Item>\n\n      <ContextMenu.Item\n        key=\"CopyLink\"\n        onSelect={() => {\n          const subscription = getSubscriptionById(id)\n          if (!subscription) return\n\n          switch (subscription.type) {\n            case \"feed\": {\n              if (!subscription.feedId) return\n              const feed = getFeedById(subscription.feedId)\n              if (!feed) return\n              setStringAsync(feed.url)\n              toast.success(t(\"operation.copy_which_success\", { which: t(\"operation.copy.link\") }))\n              return\n            }\n          }\n        }}\n      >\n        <ContextMenu.ItemTitle>\n          {t(\"operation.copy_which\", {\n            which: t(\"operation.copy.link\"),\n          })}\n        </ContextMenu.ItemTitle>\n        <ContextMenu.ItemIcon\n          ios={{\n            name: \"link\",\n          }}\n        />\n      </ContextMenu.Item>\n\n      <ContextMenu.Item\n        key=\"Unsubscribe\"\n        destructive\n        onSelect={() => {\n          // unsubscribe\n          Alert.alert(t(\"feed.unfollow.confirm_title\"), t(\"feed.unfollow.confirm_description\"), [\n            {\n              text: t(\"words.cancel\", { ns: \"common\" }),\n              style: \"cancel\",\n            },\n            {\n              text: t(\"operation.unfollow\"),\n              style: \"destructive\",\n              onPress: () => {\n                subscriptionSyncService.unsubscribe(id)\n              },\n            },\n          ])\n        }}\n      >\n        <ContextMenu.ItemTitle>{t(\"operation.unfollow\")}</ContextMenu.ItemTitle>\n        <ContextMenu.ItemIcon\n          ios={{\n            name: \"xmark\",\n          }}\n        />\n      </ContextMenu.Item>\n    </ContextMenu.Content>\n  )\n}\n\nexport const SubscriptionFeedCategoryContextMenu = ({\n  feedIds,\n  category,\n\n  children,\n  asChild,\n  style,\n}: PropsWithChildren<{\n  feedIds: string[]\n  category: string\n\n  asChild?: boolean\n  style?: CSSProperties\n}>) => {\n  const { t } = useTranslation()\n  const [currentView, setCurrentView] = useState<FeedViewType>(FeedViewType.Articles)\n\n  return (\n    <ContextMenu.Root\n      onOpenChange={(open) => {\n        if (open) {\n          setCurrentView(getSelectedView())\n        }\n      }}\n    >\n      <ContextMenu.Trigger asChild={asChild} style={style}>\n        {children}\n      </ContextMenu.Trigger>\n\n      <ContextMenu.Content>\n        <ContextMenu.Item\n          key=\"MarkAllAsRead\"\n          onSelect={useCallback(() => {\n            unreadSyncService.markFeedAsRead(feedIds)\n          }, [feedIds])}\n        >\n          <ContextMenu.ItemTitle>{t(\"operation.mark_all_as_read\")}</ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"checkmark.circle\",\n            }}\n          />\n        </ContextMenu.Item>\n\n        <ContextMenu.Sub key=\"ChangeToOtherView\">\n          <ContextMenu.SubTrigger key=\"SubTrigger/ChangeToOtherView\">\n            <ContextMenu.ItemTitle>{t(\"operation.change_to_other_view\")}</ContextMenu.ItemTitle>\n          </ContextMenu.SubTrigger>\n\n          <ContextMenu.SubContent>\n            {views.map((view) => {\n              const isSelected = view.view === currentView\n              return (\n                <ContextMenu.CheckboxItem\n                  key={`SubContent/${view.name}`}\n                  value={isSelected}\n                  onSelect={() => {\n                    subscriptionSyncService.changeCategoryView({\n                      category,\n                      currentView,\n                      newView: view.view,\n                    })\n                  }}\n                >\n                  <ContextMenu.ItemTitle>{t(view.name, { ns: \"common\" })}</ContextMenu.ItemTitle>\n                </ContextMenu.CheckboxItem>\n              )\n            })}\n          </ContextMenu.SubContent>\n        </ContextMenu.Sub>\n\n        <ContextMenu.Item\n          key=\"EditCategory\"\n          onSelect={() => {\n            const prompt = isIOS ? Alert.prompt : modalPrompt\n\n            const handleRenameCategory = async (newCategory: string) => {\n              if (!newCategory) return\n              await subscriptionSyncService.renameCategory({\n                lastCategory: category,\n                newCategory,\n                view: currentView,\n              })\n              toast.success(\"Category renamed successfully\")\n            }\n            prompt(\n              t(\"operation.rename_category\"),\n              t(\"operation.enter_new_name_for_category\", {\n                category,\n              }),\n              handleRenameCategory,\n              undefined,\n              category,\n            )\n          }}\n        >\n          <ContextMenu.ItemTitle>{t(\"operation.rename_category\")}</ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"square.and.pencil\",\n            }}\n          />\n        </ContextMenu.Item>\n        <ContextMenu.Item\n          key=\"DeleteCategory\"\n          destructive\n          onSelect={() => {\n            Alert.alert(\n              t(\"operation.delete_category_which\", { category }),\n              t(\"operation.delete_category_confirm\"),\n              [\n                {\n                  text: t(\"words.cancel\", { ns: \"common\" }),\n                  style: \"cancel\",\n                },\n                {\n                  text: t(\"words.delete\", { ns: \"common\" }),\n                  style: \"destructive\",\n                  onPress: () => {\n                    subscriptionSyncService.deleteCategory({ category, view: currentView })\n                  },\n                },\n              ],\n            )\n          }}\n        >\n          <ContextMenu.ItemTitle>{t(\"operation.delete_category\")}</ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"trash\",\n            }}\n          />\n        </ContextMenu.Item>\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n\nconst PreviewFeeds = (props: { id: string; view: FeedViewType }) => {\n  const { id: feedId } = props\n  const entryIds = useEntryIdsByFeedId(feedId)\n  const options = useFetchEntriesSettings()\n  const { isLoading } = useEntriesQuery({ feedId, limit: 5, ...options })\n\n  const renderItem = useCallback(\n    ({ item: id }: ListRenderItemInfo<string>) => (\n      <EntryNormalItem entryId={id} extraData={{ entryIds: null }} view={props.view} />\n    ),\n    [props.view],\n  )\n  return (\n    <View className=\"size-full flex-1 bg-system-background\">\n      {isLoading && !entryIds?.length && (\n        <PlatformActivityIndicator className=\"absolute inset-0 flex items-center justify-center\" />\n      )}\n      <FlatList\n        scrollEnabled={false}\n        data={useMemo(() => entryIds?.slice(0, 5), [entryIds])}\n        renderItem={renderItem}\n        ItemSeparatorComponent={ItemSeparator}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/context-menu/inbox.tsx",
    "content": "import { useIsInbox } from \"@follow/store/inbox/hooks\"\nimport { setStringAsync } from \"expo-clipboard\"\nimport type { PropsWithChildren } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { ContextMenu } from \"@/src/components/ui/context-menu\"\nimport { toast } from \"@/src/lib/toast\"\n\ntype InboxContextMenuProps = PropsWithChildren<{\n  inboxId: string\n}>\n\nexport const InboxContextMenu = ({ inboxId, children }: InboxContextMenuProps) => {\n  const { t } = useTranslation()\n  const isInbox = useIsInbox(inboxId)\n\n  if (!isInbox) {\n    return children\n  }\n\n  return (\n    <ContextMenu.Root>\n      <ContextMenu.Trigger>{children}</ContextMenu.Trigger>\n\n      <ContextMenu.Content>\n        <ContextMenu.Item\n          key=\"CopyEmailAddress\"\n          onSelect={() => {\n            setStringAsync(`${inboxId}@follow.re`).then(() => {\n              toast.success(\n                t(\"operation.copy_which_success\", { which: t(\"operation.copy.email_address\") }),\n              )\n            })\n          }}\n        >\n          <ContextMenu.ItemTitle>\n            {t(\"operation.copy_which\", { which: t(\"operation.copy.email_address\") })}\n          </ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"mail\",\n            }}\n          />\n        </ContextMenu.Item>\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/context-menu/lists.tsx",
    "content": "import { env } from \"@follow/shared/env.rn\"\nimport { getListById } from \"@follow/store/list/getters\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Alert, Clipboard } from \"react-native\"\n\nimport { ContextMenu } from \"@/src/components/ui/context-menu\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\nimport { FollowScreen } from \"@/src/screens/(modal)/FollowScreen\"\n\nexport const SubscriptionListItemContextMenu: FC<\n  PropsWithChildren & {\n    id: string\n  }\n> = ({ id, children }) => {\n  const { t } = useTranslation()\n  const navigation = useNavigation()\n  const actions = useMemo(\n    () =>\n      [\n        {\n          title: t(\"operation.mark_as_read\"),\n          onSelect: () => {\n            unreadSyncService.markListAsRead(id)\n          },\n        },\n        {\n          title: t(\"operation.edit\"),\n          onSelect: () => {\n            const list = getListById(id)\n            if (!list) return\n            navigation.presentControllerView(FollowScreen, {\n              type: \"list\",\n              id: list.id,\n            })\n          },\n        },\n        {\n          title: t(\"operation.copy_which\", { which: t(\"operation.copy.link\") }),\n          onSelect: () => {\n            const list = getListById(id)\n            if (!list) return\n            toast.success(t(\"operation.copy_which_success\", { which: t(\"operation.copy.link\") }))\n            Clipboard.setString(`${env.WEB_URL}/share/lists/${list.id}`)\n          },\n        },\n        {\n          title: t(\"operation.unfollow\"),\n          destructive: true,\n          onSelect: () => {\n            Alert.alert(t(\"feed.unfollow.confirm_title\"), t(\"feed.unfollow.confirm_description\"), [\n              {\n                text: t(\"words.cancel\", { ns: \"common\" }),\n                style: \"cancel\",\n              },\n              {\n                text: t(\"operation.unfollow\"),\n                style: \"destructive\",\n                onPress: () => {\n                  subscriptionSyncService.unsubscribe(id)\n                },\n              },\n            ])\n          },\n        },\n      ].filter((i) => !!i),\n    [id, navigation, t],\n  )\n\n  return (\n    <ContextMenu.Root>\n      <ContextMenu.Trigger>{children}</ContextMenu.Trigger>\n\n      <ContextMenu.Content>\n        {actions.map((action) => {\n          return (\n            <ContextMenu.Item\n              key={action.title}\n              destructive={action.destructive}\n              onSelect={action.onSelect}\n            >\n              <ContextMenu.ItemTitle>{action.title}</ContextMenu.ItemTitle>\n            </ContextMenu.Item>\n          )\n        })}\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/context-menu/video.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { collectionSyncService } from \"@follow/store/collection/store\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport type { PropsWithChildren } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Share } from \"react-native\"\n\nimport { ContextMenu } from \"@/src/components/ui/context-menu\"\nimport { toast } from \"@/src/lib/toast\"\n\ntype VideoContextMenuProps = PropsWithChildren<{\n  entryId: string\n}>\n\nexport const VideoContextMenu = ({ entryId, children }: VideoContextMenuProps) => {\n  const { t } = useTranslation()\n  const isLoggedIn = useIsLoggedIn()\n  const entry = useEntry(entryId, (state) => ({\n    read: state.read,\n    feedId: state.feedId,\n    title: state.title,\n    url: state.url,\n  }))\n  const feedId = entry?.feedId\n\n  const isEntryStarred = useIsEntryStarred(entryId)\n\n  if (!entry) {\n    return children\n  }\n\n  return (\n    <ContextMenu.Root>\n      <ContextMenu.Trigger>{children}</ContextMenu.Trigger>\n\n      <ContextMenu.Content>\n        {isLoggedIn && (\n          <ContextMenu.Item\n            key=\"MarkAsRead\"\n            onSelect={() => {\n              entry.read\n                ? unreadSyncService.markEntryAsUnread(entryId)\n                : unreadSyncService.markEntryAsRead(entryId)\n            }}\n          >\n            <ContextMenu.ItemTitle>\n              {entry.read ? t(\"operation.mark_as_unread\") : t(\"operation.mark_as_read\")}\n            </ContextMenu.ItemTitle>\n            <ContextMenu.ItemIcon\n              ios={{\n                name: entry.read ? \"circle.fill\" : \"checkmark.circle\",\n              }}\n            />\n          </ContextMenu.Item>\n        )}\n        {isLoggedIn && feedId && (\n          <ContextMenu.Item\n            key=\"Star\"\n            onSelect={() => {\n              if (isEntryStarred) {\n                collectionSyncService.unstarEntry({ entryId })\n                toast.success(\"Unstarred\")\n              } else {\n                collectionSyncService.starEntry({\n                  entryId,\n                  view: FeedViewType.Videos,\n                })\n                toast.success(\"Starred\")\n              }\n            }}\n          >\n            <ContextMenu.ItemIcon\n              ios={{\n                name: isEntryStarred ? \"star.slash\" : \"star\",\n              }}\n            />\n            <ContextMenu.ItemTitle>\n              {isEntryStarred ? t(\"operation.unstar\") : t(\"operation.star\")}\n            </ContextMenu.ItemTitle>\n          </ContextMenu.Item>\n        )}\n\n        <ContextMenu.Item\n          key=\"Share\"\n          onSelect={async () => {\n            if (!entry.url) return\n            await Share.share({\n              message: [entry.title, entry.url].filter(Boolean).join(\"\\n\"),\n              url: entry.url,\n              title: entry.title || \"Shared Video\",\n            })\n            return\n          }}\n        >\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"square.and.arrow.up\",\n            }}\n          />\n          <ContextMenu.ItemTitle>{t(\"operation.share\")}</ContextMenu.ItemTitle>\n        </ContextMenu.Item>\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/debug/index.tsx",
    "content": "import type { envProfileMap } from \"@follow/shared/env.rn\"\nimport { useAtom } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\nimport { useMemo } from \"react\"\nimport { Dimensions, View } from \"react-native\"\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\nimport { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { ReAnimatedPressable } from \"@/src/components/common/AnimatedComponents\"\nimport { DropdownMenu } from \"@/src/components/ui/context-menu\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { BugCuteReIcon } from \"@/src/icons/bug_cute_re\"\nimport { JotaiPersistSyncStorage } from \"@/src/lib/jotai\"\nimport { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport { setEnvProfile, useEnvProfile } from \"@/src/lib/proxy-env\"\nimport { DebugScreen } from \"@/src/screens/(headless)/DebugScreen\"\n\nexport const DebugButton = () => {\n  const cachedPositionAtom = useMemo(\n    () =>\n      atomWithStorage(\n        \"debug-button-position\",\n        {\n          x: 0,\n          y: 50,\n        },\n        JotaiPersistSyncStorage,\n        {\n          getOnInit: true,\n        },\n      ),\n    [],\n  )\n  const insets = useSafeAreaInsets()\n  const windowWidth = Dimensions.get(\"window\").width\n  const [point, setPoint] = useAtom(cachedPositionAtom)\n  const translateX = useSharedValue(point.x)\n  const translateY = useSharedValue(point.y)\n  const startX = useSharedValue(point.x)\n  const startY = useSharedValue(point.y)\n  const gestureEvent = Gesture.Pan()\n    .onStart(() => {\n      startX.value = translateX.value\n      startY.value = translateY.value\n    })\n    .onChange((event) => {\n      translateX.value = startX.value + event.translationX\n      translateY.value = startY.value + event.translationY\n    })\n    .onEnd((event) => {\n      if (Math.abs(event.translationX) < 5 && Math.abs(event.translationY) < 5) {\n        // @ts-expect-error\n        runOnJS(Navigation.rootNavigation.pushControllerView)(DebugScreen)\n        return\n      }\n      const snapToLeft = true\n      const finalX = snapToLeft ? insets.left : windowWidth - 40 - insets.right\n      translateX.value = withSpring(finalX)\n      translateY.value = withSpring(startY.value + event.translationY)\n      runOnJS(setPoint)({\n        x: finalX,\n        y: startY.value + event.translationY,\n      })\n    })\n  const animatedStyle = useAnimatedStyle(() => {\n    return {\n      transform: [\n        {\n          translateX: translateX.value,\n        },\n        {\n          translateY: translateY.value,\n        },\n      ],\n    }\n  })\n  return (\n    <GestureDetector gesture={gestureEvent}>\n      <ReAnimatedPressable\n        onPress={() => {\n          Navigation.rootNavigation.pushControllerView(DebugScreen)\n        }}\n        style={animatedStyle}\n        className=\"absolute right-0 top-[-20] z-[100] mt-5 flex size-8 items-center justify-center rounded-l-md bg-accent\"\n      >\n        <BugCuteReIcon height={24} width={24} color=\"#fff\" />\n      </ReAnimatedPressable>\n    </GestureDetector>\n  )\n}\nexport const EnvProfileIndicator = () => {\n  const envProfile = useEnvProfile()\n  if (!__DEV__ && envProfile === \"prod\") return null\n  return (\n    <View\n      className=\"absolute bottom-0 left-16 items-center justify-center\"\n      pointerEvents=\"box-none\"\n    >\n      <DropdownMenu.Root>\n        <DropdownMenu.Trigger>\n          <View className=\"rounded bg-accent p-1\">\n            <Text className=\"text-xs uppercase text-white\">{envProfile}</Text>\n          </View>\n        </DropdownMenu.Trigger>\n        <DropdownMenu.Content>\n          {[\"prod\", \"dev\", \"staging\", \"local\"].map((env) => {\n            return (\n              <DropdownMenu.Item\n                key={env}\n                onSelect={() => {\n                  setEnvProfile(env as keyof typeof envProfileMap)\n                }}\n              >\n                <DropdownMenu.ItemTitle>{env.toUpperCase()}</DropdownMenu.ItemTitle>\n              </DropdownMenu.Item>\n            )\n          })}\n        </DropdownMenu.Content>\n      </DropdownMenu.Root>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/dialogs/ConfirmPasswordDialog.tsx",
    "content": "import { View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { Key2CuteReIcon } from \"@/src/icons/key_2_cute_re\"\nimport type { DialogComponent } from \"@/src/lib/dialog\"\nimport { Dialog } from \"@/src/lib/dialog\"\n\nexport const ConfirmPasswordDialog: DialogComponent<{\n  password: string\n}> = ({ ctx }) => {\n  const label = useColor(\"label\")\n  const { bizOnConfirm } = Dialog.useDialogContext()!\n  return (\n    <View>\n      <View className=\"flex-row items-center gap-2\">\n        <Key2CuteReIcon color={label} height={20} width={20} />\n        <Text className=\"text-base font-medium text-label\">Confirm your password to continue</Text>\n      </View>\n      <PlainTextField\n        autoFocus\n        autoCapitalize=\"none\"\n        secureTextEntry\n        className=\"my-3 rounded-xl bg-system-background p-2 px-4 text-text\"\n        placeholder=\"Password\"\n        onChangeText={(text) => (ctx.password = text)}\n        returnKeyType=\"done\"\n        onSubmitEditing={() => {\n          bizOnConfirm?.()\n        }}\n      />\n    </View>\n  )\n}\nConfirmPasswordDialog.id = \"confirm-password-dialog\"\nConfirmPasswordDialog.confirmText = \"Confirm\"\nConfirmPasswordDialog.cancelText = \"Cancel\"\nConfirmPasswordDialog.onConfirm = (ctx) => {\n  ctx.dismiss()\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/dialogs/ConfirmTOTPCodeDialog.tsx",
    "content": "import { View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { Key2CuteReIcon } from \"@/src/icons/key_2_cute_re\"\nimport type { DialogComponent } from \"@/src/lib/dialog\"\nimport { Dialog } from \"@/src/lib/dialog\"\n\nexport const ConfirmTOTPCodeDialog: DialogComponent<{\n  totpCode: string\n}> = ({ ctx }) => {\n  const label = useColor(\"label\")\n  const { bizOnConfirm } = Dialog.useDialogContext()!\n  return (\n    <View>\n      <View className=\"flex-row items-center gap-2\">\n        <Key2CuteReIcon color={label} height={20} width={20} />\n        <Text className=\"text-base font-medium text-label\">Enter your TOTP code to continue</Text>\n      </View>\n      <PlainTextField\n        autoFocus\n        autoCapitalize=\"none\"\n        secureTextEntry\n        className=\"my-3 rounded-xl bg-system-background p-2 px-4 text-text\"\n        placeholder=\"TOTP Code\"\n        onChangeText={(text) => (ctx.totpCode = text)}\n        returnKeyType=\"done\"\n        onSubmitEditing={() => {\n          bizOnConfirm?.()\n        }}\n      />\n    </View>\n  )\n}\nConfirmTOTPCodeDialog.id = \"confirm-password-dialog\"\nConfirmTOTPCodeDialog.confirmText = \"Confirm\"\nConfirmTOTPCodeDialog.cancelText = \"Cancel\"\nConfirmTOTPCodeDialog.onConfirm = (ctx) => {\n  ctx.dismiss()\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/dialogs/MarkAllAsReadDialog.tsx",
    "content": "import { unreadSyncService } from \"@follow/store/unread/store\"\nimport { t } from \"i18next\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { getHideAllReadSubscriptions } from \"@/src/atoms/settings/general\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckCircleCuteReIcon } from \"@/src/icons/check_circle_cute_re\"\nimport type { DialogComponent } from \"@/src/lib/dialog\"\nimport { Dialog } from \"@/src/lib/dialog\"\n\nimport { getFetchEntryPayload, useSelectedFeed, useSelectedView } from \"../screen/atoms\"\n\nexport const MarkAllAsReadDialog: DialogComponent = () => {\n  const { t } = useTranslation()\n  const selectedView = useSelectedView()\n  const selectedFeed = useSelectedFeed()\n  const ctx = Dialog.useDialogContext()\n  return (\n    <View>\n      <Text className=\"text-label\">{t(\"operation.mark_all_as_read_confirm\")}</Text>\n      <Dialog.DialogConfirm\n        onPress={() => {\n          ctx?.dismiss()\n          if (typeof selectedView === \"number\") {\n            const payload = getFetchEntryPayload(selectedFeed, selectedView)\n            unreadSyncService.markBatchAsRead({\n              view: selectedView,\n              filter: payload,\n              excludePrivate: getHideAllReadSubscriptions(),\n            })\n          }\n        }}\n      />\n    </View>\n  )\n}\nMarkAllAsReadDialog.title = t(\"operation.mark_all_as_read\")\nMarkAllAsReadDialog.id = \"mark-all-as-read\"\nMarkAllAsReadDialog.headerIcon = <CheckCircleCuteReIcon />\n"
  },
  {
    "path": "apps/mobile/src/modules/dialogs/UpgradeRequiredDialog.tsx",
    "content": "import { t } from \"i18next\"\nimport { useEffect } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { getIsPaymentEnabled } from \"@/src/atoms/server-configs\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport type { DialogComponent } from \"@/src/lib/dialog\"\nimport { Dialog } from \"@/src/lib/dialog\"\n\nimport { navigateToPlanScreen } from \"../settings/routes/navigateToPlanScreen\"\n\ntype UpgradeDialogPayload = {\n  title?: string\n  message?: string\n}\n\nconst defaultPayload: UpgradeDialogPayload = {}\nlet currentPayload: UpgradeDialogPayload = defaultPayload\n\nconst getPayload = () => currentPayload\n\nexport const showUpgradeRequiredDialog = (payload?: UpgradeDialogPayload) => {\n  if (!getIsPaymentEnabled()) {\n    return\n  }\n\n  currentPayload = {\n    title: payload?.title,\n    message: payload?.message,\n  }\n  Dialog.show(UpgradeRequiredDialog)\n}\n\nconst UpgradeRequiredDialog: DialogComponent = () => {\n  const ctx = Dialog.useDialogContext()\n  const { t: tSettings } = useTranslation(\"settings\")\n  const payload = getPayload()\n\n  useEffect(() => {\n    return () => {\n      currentPayload = defaultPayload\n    }\n  }, [])\n\n  const title = payload.title?.trim() || tSettings(\"subscription.actions.upgrade\")\n  const description = payload.message?.trim() || tSettings(\"subscription.summary.free_description\")\n\n  return (\n    <View className=\"gap-3\">\n      <Text className=\"text-base font-semibold text-label\">{title}</Text>\n      <Text className=\"text-sm leading-relaxed text-secondary-label\">{description}</Text>\n      <Dialog.DialogConfirm\n        onPress={() => {\n          ctx?.dismiss()\n          setTimeout(() => {\n            void navigateToPlanScreen()\n          }, 16)\n        }}\n      />\n    </View>\n  )\n}\n\nUpgradeRequiredDialog.id = \"upgrade-required-dialog\"\nUpgradeRequiredDialog.confirmText = t(\"settings:subscription.actions.upgrade\")\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/Category.tsx",
    "content": "import type { RSSHubCategory } from \"@follow/constants\"\nimport { CategoryMap, RSSHubCategories } from \"@follow/constants\"\nimport { Image } from \"expo-image\"\nimport { LinearGradient } from \"expo-linear-gradient\"\nimport { memo, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Platform, Pressable, StyleSheet, View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { Grid } from \"@/src/components/ui/grid\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { FilterCuteReIcon } from \"@/src/icons/filter_cute_re\"\nimport { Grid2CuteReIcon } from \"@/src/icons/grid_2_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { Recommendations } from \"@/src/modules/discover/Recommendations\"\nimport { DiscoverSettingsScreen } from \"@/src/screens/(modal)/DiscoverSettingsScreen\"\nimport { RecommendationCategoryScreen } from \"@/src/screens/(stack)/recommendation/RecommendationCategoryScreen\"\n\nexport const Category = () => {\n  const { t } = useTranslation(\"common\")\n  const navigation = useNavigation()\n  const label = useColor(\"label\")\n  return (\n    <>\n      <View className=\"mt-4 flex-row items-center justify-between pb-1 pl-6 pr-5 pt-4\">\n        <View className=\"flex-row items-center gap-2\">\n          <Grid2CuteReIcon width={24} height={24} color={label} />\n          <Text className=\"pb-2 text-xl font-bold leading-[1.1] text-label\">\n            {t(\"words.categories\")}\n          </Text>\n        </View>\n        <ItemPressable\n          className=\"rounded-lg p-1\"\n          itemStyle={ItemPressableStyle.UnStyled}\n          onPress={() => {\n            navigation.presentControllerView(DiscoverSettingsScreen)\n          }}\n        >\n          <FilterCuteReIcon width={20} height={20} color={label} />\n        </ItemPressable>\n      </View>\n\n      <Grid columns={2} gap={12} className=\"p-4\">\n        {RSSHubCategories.map((category) => (\n          <CategoryItem key={category} category={category} />\n        ))}\n      </Grid>\n    </>\n  )\n}\n\nconst emojiCdnBaseUrl = \"https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72\"\n\nconst getEmojiImageUrl = (emoji: string) => {\n  const codePoints = Array.from(emoji)\n    .map((character) => character.codePointAt(0)?.toString(16))\n    .filter((value): value is string => Boolean(value) && value !== \"fe0f\")\n\n  return `${emojiCdnBaseUrl}/${codePoints.join(\"-\")}.png`\n}\n\nconst CategoryEmoji = ({ emoji }: { emoji: string }) => {\n  const [useTextFallback, setUseTextFallback] = useState(false)\n  const emojiImageUrl = useMemo(() => getEmojiImageUrl(emoji), [emoji])\n\n  return (\n    <View style={styles.emojiContainer}>\n      {useTextFallback ? (\n        <Text allowFontScaling={false} style={styles.emojiText}>\n          {emoji}\n        </Text>\n      ) : (\n        <Image\n          allowDownscaling\n          cachePolicy=\"memory-disk\"\n          contentFit=\"contain\"\n          onError={() => setUseTextFallback(true)}\n          source={emojiImageUrl}\n          style={styles.emojiImage}\n        />\n      )}\n    </View>\n  )\n}\n\nconst CategoryItem = memo(({ category }: { category: RSSHubCategory }) => {\n  const { t } = useTranslation(\"common\")\n  const name = t(`discover.category.${category}`)\n  const navigation = useNavigation()\n  const { emoji } = CategoryMap[category]\n  return (\n    <Pressable\n      className=\"overflow-hidden rounded-2xl\"\n      key={category}\n      onPress={() => {\n        if (category === \"all\") {\n          navigation.pushControllerView(Recommendations)\n        } else {\n          navigation.pushControllerView(RecommendationCategoryScreen, {\n            category,\n          })\n        }\n      }}\n    >\n      <LinearGradient\n        colors={[`${CategoryMap[category].color}80`, CategoryMap[category].color]}\n        start={{\n          x: 0,\n          y: 0,\n        }}\n        end={{\n          x: 0,\n          y: 1,\n        }}\n        className=\"rounded-2xl p-4\"\n        style={styles.cardItem}\n      >\n        <View className=\"flex-1\">\n          <CategoryEmoji emoji={emoji} />\n          <Text className=\"absolute bottom-0 left-2 text-lg font-bold text-white\">{name}</Text>\n        </View>\n      </LinearGradient>\n    </Pressable>\n  )\n})\nconst styles = StyleSheet.create({\n  cardItem: {\n    aspectRatio: 16 / 9,\n  },\n  emojiContainer: {\n    position: \"absolute\",\n    right: 8,\n    top: 8,\n    width: 56,\n    height: 56,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  emojiText: {\n    fontSize: 40,\n    lineHeight: 48,\n    textAlign: \"center\",\n    ...(Platform.OS === \"android\"\n      ? {\n          includeFontPadding: false,\n        }\n      : {}),\n  },\n  emojiImage: {\n    width: 40,\n    height: 40,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/Content.tsx",
    "content": "import { useAtomValue } from \"jotai\"\nimport { View } from \"react-native\"\n\nimport { useSearchPageContext } from \"@/src/modules/discover/ctx\"\nimport { DiscoverContent } from \"@/src/modules/discover/DiscoverContent\"\nimport { SearchContent } from \"@/src/modules/discover/SearchContent\"\n\nexport default function Content() {\n  const { searchFocusedAtom } = useSearchPageContext()\n  const isFocused = useAtomValue(searchFocusedAtom)\n\n  return <View>{isFocused ? <SearchContent /> : <DiscoverContent />}</View>\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/DiscoverContent.tsx",
    "content": "import { View } from \"react-native\"\n\nimport { Category } from \"@/src/modules/discover/Category\"\nimport { Trending } from \"@/src/modules/discover/Trending\"\n\nexport function DiscoverContent() {\n  return (\n    <View>\n      <Trending itemClassName=\"px-6\" />\n      <Category />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/FeedSummary.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { View } from \"react-native\"\n\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { FollowScreen } from \"@/src/screens/(modal)/FollowScreen\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { selectFeed, selectTimeline } from \"../screen/atoms\"\n\ntype FeedSummaryFeed = {\n  id?: string | null\n  title?: Nullable<string>\n  url?: Nullable<string>\n  image?: Nullable<string>\n  ownerUserId?: Nullable<string>\n  siteUrl?: Nullable<string>\n  description?: Nullable<string>\n\n  [key: string]: any\n}\n\nexport const FeedSummary = ({\n  feed,\n  children,\n  preChildren,\n  className,\n  testID,\n  simple,\n  view,\n  preview,\n}: {\n  feed: FeedSummaryFeed\n  children?: React.ReactNode\n  preChildren?: React.ReactNode\n  className?: string\n  testID?: string\n  simple?: boolean\n  view?: number | null\n  preview?: boolean\n}) => {\n  const navigation = useNavigation()\n  return (\n    <ItemPressable\n      itemStyle={ItemPressableStyle.UnStyled}\n      onPress={() => {\n        if (feed?.id) {\n          if (preview) {\n            if (typeof view === \"number\") {\n              selectTimeline({\n                type: \"view\",\n                viewId: view,\n              })\n            }\n\n            selectFeed({\n              type: \"feed\",\n              feedId: feed.id,\n            })\n            navigation.pushControllerView(FeedScreen, {\n              feedId: feed.id,\n            })\n          } else {\n            navigation.presentControllerView(FollowScreen, {\n              id: feed.id,\n              type: \"feed\",\n            })\n          }\n        } else if (feed.url) {\n          navigation.presentControllerView(FollowScreen, {\n            url: feed.url,\n            type: \"url\",\n          })\n        }\n      }}\n      className={className}\n      testID={testID}\n    >\n      {preChildren}\n      {/* Headline */}\n      <View className=\"flex-1 flex-row items-center gap-2 pr-2\">\n        <View className=\"size-[32px] overflow-hidden rounded-lg\">\n          <FeedIcon\n            size={32}\n            feed={\n              feed\n                ? {\n                    id: feed.id!,\n                    title: feed.title!,\n                    url: feed.url!,\n                    image: feed.image!,\n                    ownerUserId: feed.ownerUserId!,\n                    siteUrl: feed.siteUrl!,\n                    type: FeedViewType.Articles,\n                  }\n                : undefined\n            }\n          />\n        </View>\n        <View className=\"flex-1\">\n          <Text className=\"text-base font-semibold text-text\" numberOfLines={1}>\n            {feed.title}\n          </Text>\n          <Text className=\"text-xs leading-tight text-text opacity-60\" numberOfLines={1}>\n            {feed.url}\n          </Text>\n        </View>\n      </View>\n      {!simple && !!feed.description && (\n        <Text\n          className=\"mt-3 pl-[39] pr-2 text-subheadline text-text\"\n          ellipsizeMode=\"tail\"\n          numberOfLines={2}\n        >\n          {feed.description}\n        </Text>\n      )}\n\n      {children}\n    </ItemPressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/RecommendationListItem.tsx",
    "content": "import type { RSSHubCategories } from \"@follow/constants\"\nimport type { RSSHubRouteDeclaration } from \"@follow/models/rsshub\"\nimport type { FC } from \"react\"\nimport { memo, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { GroupedInsetListCard, GroupedInsetListCell } from \"@/src/components/ui/grouped/GroupedList\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { FireCuteReIcon } from \"@/src/icons/fire_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { RsshubFormScreen } from \"@/src/screens/(modal)/RsshubFormScreen\"\n\nexport const RecommendationListItem: FC<{\n  data: RSSHubRouteDeclaration\n  routePrefix: string\n}> = memo(({ data, routePrefix }) => {\n  const { t } = useTranslation(\"common\")\n  const { categories } = useMemo(() => {\n    const categories = new Set<string>()\n    for (const route in data.routes) {\n      const routeData = data.routes[route]!\n      if (routeData.categories) {\n        routeData.categories.forEach((c: string) => categories.add(c))\n      }\n    }\n    categories.delete(\"popular\")\n    return {\n      categories: Array.from(categories) as unknown as typeof RSSHubCategories,\n    }\n  }, [data])\n  const navigation = useNavigation()\n  return (\n    <View className=\"py-4\">\n      <View className=\"mt-1.5 flex-row justify-between overflow-hidden px-6\">\n        <View className=\"flex-row items-center gap-3\">\n          <FeedIcon siteUrl={`https://${data.url}`} size={24} />\n          <Text className=\"text-base font-medium text-text\">{data.name}</Text>\n        </View>\n        <View className=\"flex-row items-center justify-between gap-4\">\n          {/* Tags */}\n          <View className=\"shrink flex-row items-center\">\n            {categories.map((c) => (\n              <View\n                className=\"mr-1 items-center justify-center overflow-hidden rounded-full bg-system-fill px-3 py-1\"\n                key={c}\n              >\n                <Text className=\"text-xs text-text/70\" numberOfLines={1}>\n                  {t(`discover.category.${c}`)}\n                </Text>\n              </View>\n            ))}\n          </View>\n        </View>\n      </View>\n      <GroupedInsetListCard className=\"mt-5\">\n        {Object.keys(data.routes)\n          .sort((a, b) => (data.routes[b]!.heat || 0) - (data.routes[a]!.heat || 0))\n          .map((route) => (\n            <GroupedInsetListCell\n              key={route}\n              label={data.routes[route]!.name}\n              onPress={() => {\n                navigation.presentControllerView(RsshubFormScreen, {\n                  routePrefix,\n                  route: data.routes[route]!,\n                  name: data.name,\n                })\n              }}\n            >\n              <View className=\"flex-row items-center gap-1\">\n                {!!data.routes[route]!.heat && (\n                  <>\n                    <FireCuteReIcon width={12} height={12} />\n                    <Text\n                      ellipsizeMode=\"middle\"\n                      numberOfLines={1}\n                      className=\"whitespace-pre text-xs text-text/70\"\n                    >\n                      {data.routes[route]!.heat}\n                    </Text>\n                  </>\n                )}\n              </View>\n            </GroupedInsetListCell>\n          ))}\n      </GroupedInsetListCard>\n    </View>\n  )\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/Recommendations.tsx",
    "content": "import { RSSHubCategories } from \"@follow/constants\"\nimport type { RSSHubRouteDeclaration } from \"@follow/models/rsshub\"\nimport { isASCII } from \"@follow/utils\"\nimport type { FlashListRef } from \"@shopify/flash-list\"\nimport { FlashList } from \"@shopify/flash-list\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { ScrollView, StyleProp, ViewStyle } from \"react-native\"\nimport { Animated, useAnimatedValue, useWindowDimensions, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport { useSharedValue } from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport { AnimatedScrollView } from \"@/src/components/common/AnimatedComponents\"\nimport { BlurEffect } from \"@/src/components/common/BlurEffect\"\nimport { UINavigationHeaderActionButton } from \"@/src/components/layouts/header/NavigationHeader\"\nimport { useRegisterNavigationScrollView } from \"@/src/components/layouts/tabbar/hooks\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { TabBar } from \"@/src/components/ui/tabview/TabBar\"\nimport type { TabComponent } from \"@/src/components/ui/tabview/TabView\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { MingcuteLeftLineIcon } from \"@/src/icons/mingcute_left_line\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { fetchRsshubAnalysis, fetchRsshubPopular } from \"./api\"\nimport { RecommendationListItem } from \"./RecommendationListItem\"\n\nexport const Recommendations = () => {\n  const { t } = useTranslation(\"common\")\n  const animatedX = useAnimatedValue(0)\n  const [currentTab, setCurrentTab] = useState(0)\n  const windowWidth = useWindowDimensions().width\n  const ref = useRef<ScrollView>(null)\n  useEffect(() => {\n    ref.current?.scrollTo({\n      x: currentTab * windowWidth,\n      y: 0,\n      animated: true,\n    })\n  }, [ref, currentTab, windowWidth])\n  const [loadedTabIndex, setLoadedTabIndex] = useState(() => new Set([0]))\n  useEffect(() => {\n    setLoadedTabIndex((prev) => {\n      const next = new Set(prev)\n      next.add(currentTab)\n      return next\n    })\n  }, [currentTab])\n  const insets = useSafeAreaInsets()\n  const navigation = useNavigation()\n  const label = useColor(\"label\")\n  return (\n    <View className=\"flex-1\">\n      <View className=\"pt-safe absolute inset-x-0 top-0 z-10 flex flex-row items-center\">\n        <BlurEffect />\n\n        <UINavigationHeaderActionButton\n          onPress={() => {\n            navigation.back()\n          }}\n          className=\"-mb-2 ml-4\"\n        >\n          <MingcuteLeftLineIcon color={label} height={20} width={20} />\n        </UINavigationHeaderActionButton>\n        <TabBar\n          tabScrollContainerAnimatedX={animatedX}\n          tabbarClassName=\"pt-2\"\n          tabs={RSSHubCategories.map((category) => ({\n            name: t(`discover.category.${category}`),\n            value: category,\n          }))}\n          currentTab={currentTab}\n          onTabItemPress={(tab) => {\n            setCurrentTab(tab)\n          }}\n        />\n      </View>\n      <AnimatedScrollView\n        contentInsetAdjustmentBehavior=\"never\"\n        onScroll={Animated.event(\n          [\n            {\n              nativeEvent: {\n                contentOffset: {\n                  x: animatedX,\n                },\n              },\n            },\n          ],\n          {\n            useNativeDriver: true,\n          },\n        )}\n        ref={ref}\n        horizontal\n        pagingEnabled\n        showsHorizontalScrollIndicator={false}\n        nestedScrollEnabled\n      >\n        {RSSHubCategories.map((category, index) => (\n          <View\n            className=\"flex-1\"\n            style={{\n              width: windowWidth,\n            }}\n            key={category}\n          >\n            {loadedTabIndex.has(index) && (\n              <RecommendationTab\n                key={category}\n                insets={{\n                  top: 44 + insets.top - 10,\n                  bottom: insets.bottom,\n                }}\n                contentContainerStyle={{\n                  paddingTop: 44 + insets.top,\n                  paddingBottom: insets.bottom,\n                }}\n                tab={{\n                  name: t(`discover.category.${category}`),\n                  value: category,\n                }}\n                isSelected={currentTab === index}\n              />\n            )}\n          </View>\n        ))}\n      </AnimatedScrollView>\n    </View>\n  )\n}\nexport const RecommendationTab: TabComponent<{\n  contentContainerStyle?: StyleProp<ViewStyle>\n  insets?: {\n    top?: number\n    bottom?: number\n  }\n  reanimatedScrollY?: SharedValue<number>\n}> = ({\n  tab,\n  isSelected,\n  contentContainerStyle,\n  reanimatedScrollY,\n  insets: customInsets,\n  ...rest\n}) => {\n  const { t } = useTranslation(\"common\")\n  const discoverLanguage = useUISettingKey(\"discoverLanguage\")\n  const { data, isLoading } = useQuery({\n    queryKey: [\"rsshub-popular\", tab.value, discoverLanguage],\n    queryFn: () => fetchRsshubPopular(tab.value, discoverLanguage).then((res) => res.data),\n    staleTime: 1000 * 60 * 60 * 24,\n    // 1 day\n    meta: {\n      persist: true,\n    },\n  })\n  const { data: analysisData, isLoading: isAnalysisLoading } = useQuery({\n    queryKey: [\"rsshub-analysis\", discoverLanguage],\n    queryFn: () => fetchRsshubAnalysis(discoverLanguage).then((res) => res.data),\n    staleTime: 1000 * 60 * 60 * 24,\n    // 1 day\n    meta: {\n      persist: true,\n    },\n  })\n  const keys = useMemo(() => {\n    if (!data) {\n      return []\n    }\n    return Object.keys(data).sort((a, b) => {\n      const aname = data[a]!.name\n      const bname = data[b]!.name\n      const aRouteName = data[a]!.routes[Object.keys(data[a]!.routes)[0]!]!.name\n      const bRouteName = data[b]!.routes[Object.keys(data[b]!.routes)[0]!]!.name\n      const ia = isASCII(aname) && isASCII(aRouteName)\n      const ib = isASCII(bname) && isASCII(bRouteName)\n      if (ia && ib) {\n        return aname.toLowerCase() < bname.toLowerCase() ? -1 : 1\n      } else if (ia || ib) {\n        return ia > ib ? -1 : 1\n      } else {\n        return 0\n      }\n    })\n  }, [data])\n  const alphabetGroups = useMemo(() => {\n    const result = [] as Array<{ key: string; data: RSSHubRouteDeclaration }>\n    for (const item of keys) {\n      if (!data) {\n        continue\n      }\n      const dataWithAnalysis = data[item]! as RSSHubRouteDeclaration\n      for (const route in dataWithAnalysis.routes) {\n        const routeData = dataWithAnalysis.routes[route]!\n        routeData.heat = analysisData?.[`/${item}${route}`]?.subscriptionCount || 0\n        routeData.topFeeds = analysisData?.[`/${item}${route}`]?.topFeeds || []\n      }\n      result.push({\n        key: item,\n        data: dataWithAnalysis,\n      })\n    }\n    result.sort((a, b) => {\n      const aHeat = Object.values(a.data.routes).reduce((acc, route) => acc + (route.heat || 0), 0)\n      const bHeat = Object.values(b.data.routes).reduce((acc, route) => acc + (route.heat || 0), 0)\n      return bHeat - aHeat\n    })\n    return result\n  }, [data, keys, analysisData])\n\n  // Add ref for FlashList\n  const listRef = useRegisterNavigationScrollView<\n    FlashListRef<{\n      key: string\n      data: RSSHubRouteDeclaration\n    }>\n  >(isSelected)\n  const getItemType = useRef(\n    (\n      item:\n        | string\n        | {\n            key: string\n          },\n    ) => {\n      return typeof item === \"string\" ? \"sectionHeader\" : \"row\"\n    },\n  ).current\n  const keyExtractor = useRef(\n    (\n      item:\n        | string\n        | {\n            key: string\n          },\n    ) => {\n      return typeof item === \"string\" ? item : item.key\n    },\n  ).current\n  const scrollOffsetRef = useRef(0)\n  const animatedY = useSharedValue(0)\n  useEffect(() => {\n    if (isSelected) {\n      animatedY.value = scrollOffsetRef.current\n    }\n  }, [animatedY, isSelected])\n  const insets = useSafeAreaInsets()\n  if (isLoading || isAnalysisLoading) {\n    return <PlatformActivityIndicator className=\"flex-1 items-center justify-center\" />\n  }\n  if (keys.length === 0) {\n    return (\n      <View className=\"flex-1 items-center justify-center\">\n        <Text className=\"text-secondary-label\">{t(\"search.empty.no_results\")}</Text>\n      </View>\n    )\n  }\n  return (\n    <View className=\"flex-1 bg-system-grouped-background\" {...rest}>\n      <FlashList\n        onScroll={(e) => {\n          scrollOffsetRef.current = e.nativeEvent.contentOffset.y\n          animatedY.value = scrollOffsetRef.current\n          if (reanimatedScrollY) {\n            reanimatedScrollY.value = scrollOffsetRef.current\n          }\n        }}\n        scrollEventThrottle={16}\n        ref={listRef}\n        data={alphabetGroups}\n        keyExtractor={keyExtractor}\n        getItemType={getItemType}\n        renderItem={ItemRenderer}\n        automaticallyAdjustContentInsets={false}\n        contentInsetAdjustmentBehavior=\"never\"\n        automaticallyAdjustsScrollIndicatorInsets={false}\n        contentContainerStyle={contentContainerStyle}\n        scrollIndicatorInsets={{\n          right: -2,\n          top: customInsets?.top ?? 0,\n          bottom: customInsets?.bottom ?? insets.bottom,\n        }}\n        removeClippedSubviews\n      />\n    </View>\n  )\n}\nconst ItemRenderer = ({\n  item,\n}: {\n  item: {\n    key: string\n    data: RSSHubRouteDeclaration\n  }\n}) => {\n  return <RecommendationListItem data={item.data} routePrefix={item.key} />\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/SearchContent.tsx",
    "content": "import { useAtomValue } from \"jotai\"\nimport * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport type { ScrollView } from \"react-native\"\nimport { Animated, Dimensions, View } from \"react-native\"\n\nimport { AnimatedScrollView } from \"@/src/components/common/AnimatedComponents\"\nimport { SearchTabs, SearchType } from \"@/src/modules/discover/constants\"\nimport {\n  useSearchPageContext,\n  useSearchPageScrollContainerAnimatedX,\n} from \"@/src/modules/discover/ctx\"\nimport { SearchFeed } from \"@/src/modules/discover/search-tabs/SearchFeed\"\nimport { SearchList } from \"@/src/modules/discover/search-tabs/SearchList\"\n\nexport const SearchContent = () => {\n  const scrollContainerAnimatedX = useSearchPageScrollContainerAnimatedX()\n  const { searchTypeAtom } = useSearchPageContext()\n  const searchType = useAtomValue(searchTypeAtom)\n\n  const scrollRef = useRef<ScrollView>(null)\n  useEffect(() => {\n    if (scrollRef.current) {\n      const pageIndex = SearchTabs.findIndex((tab) => tab.value === searchType)\n      scrollRef.current.scrollTo({\n        x: pageIndex * Dimensions.get(\"window\").width,\n        y: 0,\n        animated: true,\n      })\n    }\n  }, [searchType])\n\n  const [loadedContentSet, setLoadedContentSet] = useState<Set<SearchType>>(\n    () => new Set([searchType]),\n  )\n\n  useEffect(() => {\n    setLoadedContentSet((prev) => {\n      const newSet = new Set(prev)\n      newSet.add(searchType)\n      return newSet\n    })\n  }, [searchType])\n\n  return (\n    <>\n      <AnimatedScrollView\n        ref={scrollRef}\n        horizontal\n        pagingEnabled\n        nestedScrollEnabled\n        scrollEnabled\n        showsHorizontalScrollIndicator={false}\n        className={\"flex-1\"}\n        scrollEventThrottle={16}\n        onScroll={Animated.event(\n          [{ nativeEvent: { contentOffset: { x: scrollContainerAnimatedX } } }],\n          {\n            useNativeDriver: true,\n          },\n        )}\n      >\n        {SearchTabs.map(({ value }) =>\n          loadedContentSet.has(value) ? (\n            React.createElement(SearchType2RenderContent[value], { key: value })\n          ) : (\n            <PlaceholderLazyView key={value} />\n          ),\n        )}\n      </AnimatedScrollView>\n    </>\n  )\n}\n\nconst SearchType2RenderContent: Record<SearchType, React.FC> = {\n  [SearchType.Feed]: SearchFeed,\n  [SearchType.List]: SearchList,\n  // [SearchType.User]: SearchUser,\n}\nconst PlaceholderLazyView = () => {\n  const windowWidth = Dimensions.get(\"window\").width\n  return <View className=\"flex-1\" style={{ width: windowWidth }} />\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/SearchTabBar.tsx",
    "content": "import { useAtom } from \"jotai\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { Animated } from \"react-native\"\n\nimport { TabBar } from \"@/src/components/ui/tabview/TabBar\"\n\nimport type { SearchType } from \"./constants\"\nimport { SearchTabs } from \"./constants\"\nimport { useSearchPageContext } from \"./ctx\"\n\nexport const SearchTabBar: FC<{\n  animatedX: Animated.Value\n}> = ({ animatedX }) => {\n  const { t } = useTranslation(\"common\")\n  const { searchTypeAtom } = useSearchPageContext()\n  const [searchType, setSearchType] = useAtom(searchTypeAtom)\n  const tabs = useMemo(\n    () =>\n      SearchTabs.map((tab) => ({\n        ...tab,\n        name: t(tab.name),\n      })),\n    [t],\n  )\n\n  return (\n    <TabBar\n      tabbarClassName=\"border-b border-b-opaque-separator/40\"\n      tabScrollContainerAnimatedX={animatedX}\n      tabs={tabs}\n      currentTab={tabs.findIndex((tab) => tab.value === searchType)}\n      onTabItemPress={(index) => {\n        setSearchType(tabs[index]!.value as SearchType)\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/Trending.tsx",
    "content": "import { cn, formatNumber } from \"@follow/utils\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { FilterCuteReIcon } from \"@/src/icons/filter_cute_re\"\nimport { TrendingUpCuteReIcon } from \"@/src/icons/trending_up_cute_re\"\nimport { User3CuteReIcon } from \"@/src/icons/user_3_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { DiscoverSettingsScreen } from \"@/src/screens/(modal)/DiscoverSettingsScreen\"\n\nimport { fetchFeedTrending } from \"./api\"\nimport { FeedSummary } from \"./FeedSummary\"\n\nexport const Trending = ({\n  className,\n  itemClassName,\n}: {\n  className?: string\n  itemClassName?: string\n}) => {\n  const { t } = useTranslation(\"common\")\n  const label = useColor(\"label\")\n  const discoverLanguage = useUISettingKey(\"discoverLanguage\")\n  const trendingLanguage = discoverLanguage === \"fra\" ? \"eng\" : discoverLanguage\n  const { data, isLoading } = useQuery({\n    queryKey: [\"trending\", \"feeds\", discoverLanguage],\n    queryFn: () =>\n      fetchFeedTrending({\n        lang: trendingLanguage === \"all\" ? undefined : trendingLanguage,\n        limit: 20,\n      }).then((res) => res.data),\n    meta: {\n      persist: true,\n    },\n  })\n  const navigation = useNavigation()\n  return (\n    <View className={className}>\n      <View className={cn(\"flex-row items-center justify-between pb-1 pt-4\", itemClassName)}>\n        <View className=\"flex-row items-center gap-2\">\n          <TrendingUpCuteReIcon width={24} height={24} color={label} />\n          <Text className=\"pb-2 text-xl font-bold leading-[1.1] text-label\">\n            {t(\"words.trending\")}\n          </Text>\n        </View>\n        <ItemPressable\n          className=\"rounded-lg p-1\"\n          itemStyle={ItemPressableStyle.UnStyled}\n          onPress={() => {\n            navigation.presentControllerView(DiscoverSettingsScreen)\n          }}\n        >\n          <FilterCuteReIcon width={20} height={20} color={label} />\n        </ItemPressable>\n      </View>\n\n      <View className=\"mt-4\">\n        {isLoading ? (\n          <View className=\"mt-5 flex h-12 items-center justify-center\">\n            <PlatformActivityIndicator />\n          </View>\n        ) : (\n          data?.map((item, index) => (\n            <FeedSummary\n              preview\n              view={item.view}\n              key={item.feed?.id}\n              feed={item.feed!}\n              className={cn(\"flex flex-1 flex-row items-center bg-none py-3\", itemClassName)}\n              simple\n              preChildren={\n                <View\n                  className={cn(\n                    \"mr-4 flex size-6 items-center justify-center rounded-full\",\n                    index < 3\n                      ? cn(\n                          \"bg-accent text-white\",\n                          index === 0 && \"bg-accent\",\n                          index === 1 && \"bg-accent/90\",\n                          index === 2 && \"bg-accent/80\",\n                        )\n                      : \"bg-gray-5/60 dark:bg-white/60\",\n                  )}\n                >\n                  <Text className={cn(\"text-xs font-medium text-text\", index < 3 && \"text-white\")}>\n                    {index + 1}\n                  </Text>\n                </View>\n              }\n            >\n              <View className=\"flex flex-row items-center gap-1 opacity-60\">\n                <User3CuteReIcon width={13} height={13} color={label} />\n                <Text className=\"text-xs text-text\">\n                  {formatNumber(item.analytics.subscriptionCount || 0)}\n                </Text>\n              </View>\n            </FeedSummary>\n          ))\n        )}\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/api.ts",
    "content": "import { followClient } from \"@/src/lib/api-client\"\n\nimport type { DiscoverCategories, Language } from \"./constants\"\n\nconst discoverLanguageMap = {\n  all: \"all\",\n  eng: \"en\",\n  cmn: \"zh-CN\",\n  fra: \"fr-FR\",\n} as const\n\nexport const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) => {\n  const mappedLanguage = discoverLanguageMap[lang]\n\n  return followClient.api.discover.rsshub({\n    categories: category === \"all\" ? \"popular\" : category,\n    ...(mappedLanguage !== \"all\" && { lang: mappedLanguage }),\n  })\n}\n\nexport const fetchRsshubAnalysis = (lang: Language) => {\n  return followClient.api.discover.rsshubAnalytics({\n    ...(lang !== \"all\" && { lang }),\n  })\n}\n\nexport const fetchFeedTrending = ({\n  lang,\n  view,\n  limit,\n}: {\n  lang?: \"eng\" | \"cmn\"\n  view?: number\n  limit: number\n}) => {\n  return followClient.api.trending.getFeeds({\n    language: lang,\n    view,\n    limit,\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/constants.ts",
    "content": "import type { RSSHubCategories } from \"@follow/constants\"\n\nexport enum SearchType {\n  Feed = \"feed\",\n  List = \"list\",\n  // User = \"user\",\n}\n\nexport const SearchTabs = [\n  { name: \"words.feeds\", value: SearchType.Feed },\n  { name: \"words.lists\", value: SearchType.List },\n  // { name: \"User\", value: SearchType.User },\n] as const\n\nexport type Language = \"all\" | \"eng\" | \"cmn\" | \"fra\"\nexport type DiscoverCategories = (typeof RSSHubCategories)[number] | string\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/ctx.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { atom } from \"jotai\"\nimport type { Dispatch, SetStateAction } from \"react\"\nimport { createContext, use, useState } from \"react\"\nimport type { Animated } from \"react-native\"\nimport { useAnimatedValue } from \"react-native\"\n\nimport { SearchType } from \"./constants\"\n\ninterface SearchPageContextType {\n  searchFocusedAtom: PrimitiveAtom<boolean>\n  searchValueAtom: PrimitiveAtom<string>\n\n  searchTypeAtom: PrimitiveAtom<SearchType>\n}\nexport const SearchPageContext = createContext<SearchPageContextType>(null!)\n\nconst SearchBarHeightContext = createContext<number>(0)\nconst setSearchBarHeightContext = createContext<Dispatch<SetStateAction<number>>>(() => {})\nexport const SearchBarHeightProvider = ({ children }: { children: React.ReactNode }) => {\n  const [searchBarHeight, setSearchBarHeight] = useState(0)\n  return (\n    <SearchBarHeightContext value={searchBarHeight}>\n      <setSearchBarHeightContext.Provider value={setSearchBarHeight}>\n        {children}\n      </setSearchBarHeightContext.Provider>\n    </SearchBarHeightContext>\n  )\n}\n\nexport const useSearchBarHeight = () => {\n  return use(SearchBarHeightContext)\n}\nexport const useSetSearchBarHeight = () => {\n  return use(setSearchBarHeightContext)\n}\n\nexport const SearchPageProvider = ({ children }: { children: React.ReactNode }) => {\n  const [atomRefs] = useState((): SearchPageContextType => {\n    const searchFocusedAtom = atom(false)\n    const searchValueAtom = atom(\"\")\n    const searchTypeAtom = atom(SearchType.Feed)\n    return {\n      searchFocusedAtom,\n      searchValueAtom,\n      searchTypeAtom,\n    }\n  })\n  return <SearchPageContext value={atomRefs}>{children}</SearchPageContext>\n}\n\nconst SearchPageScrollContainerAnimatedXContext = createContext<Animated.Value>(null!)\nexport const SearchPageScrollContainerAnimatedXProvider = ({\n  children,\n}: {\n  children: React.ReactNode\n}) => {\n  const scrollContainerAnimatedX = useAnimatedValue(0)\n  return (\n    <SearchPageScrollContainerAnimatedXContext value={scrollContainerAnimatedX}>\n      {children}\n    </SearchPageScrollContainerAnimatedXContext>\n  )\n}\n\nexport const useSearchPageScrollContainerAnimatedX = () => {\n  return use(SearchPageScrollContainerAnimatedXContext)\n}\nexport const useSearchPageContext = () => {\n  const ctx = use(SearchPageContext)\n  if (!ctx) throw new Error(\"useDiscoverPageContext must be used within a DiscoverPageProvider\")\n  return ctx\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx",
    "content": "import { useQuery } from \"@tanstack/react-query\"\nimport { useAtomValue } from \"jotai\"\nimport { useTranslation } from \"react-i18next\"\nimport { useWindowDimensions, View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { followClient } from \"@/src/lib/api-client\"\n\nimport { useSearchPageContext } from \"../ctx\"\nimport { ItemSeparator } from \"./__base\"\nimport { useDataSkeleton } from \"./hooks\"\nimport type { SearchFeedCardItem } from \"./SearchFeedCard\"\nimport { SearchFeedCard } from \"./SearchFeedCard\"\n\nconst isDirectFeedInput = (value: string) => value.includes(\"://\")\n\nconst createDirectFeedItem = (value: string): SearchFeedCardItem => ({\n  feed: {\n    title: value,\n    url: value,\n  },\n})\n\nexport const SearchFeed = () => {\n  const { t } = useTranslation(\"common\")\n  const { searchValueAtom } = useSearchPageContext()\n  const searchValue = useAtomValue(searchValueAtom)\n  const windowWidth = useWindowDimensions().width\n  const { data, isLoading } = useQuery({\n    queryKey: [\"searchFeed\", searchValue],\n    queryFn: () => {\n      return followClient.api.discover.discover({ keyword: searchValue, target: \"feeds\" })\n    },\n    enabled: !!searchValue,\n  })\n  const skeleton = useDataSkeleton(isLoading, data)\n  if (skeleton) return skeleton\n  if (data === undefined) return null\n\n  const discoveredItems = data.data ?? []\n  const items =\n    discoveredItems.length > 0\n      ? discoveredItems\n      : searchValue && isDirectFeedInput(searchValue)\n        ? [createDirectFeedItem(searchValue)]\n        : []\n\n  const resultCount = items.length\n  const resultLabel =\n    resultCount === 0\n      ? t(\"discover.search.results_zero\")\n      : t(\"discover.search.results_other\", { count: resultCount })\n\n  return (\n    <View\n      style={{\n        width: windowWidth,\n      }}\n    >\n      <Text className=\"px-6 pt-4 text-text/60\">{resultLabel}</Text>\n      <View>\n        {items.map((item, index) => (\n          <View key={item.feed?.id ?? item.feed?.url ?? `feed-${index}`}>\n            <SearchFeedCard item={item} />\n            <ItemSeparator />\n          </View>\n        ))}\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx",
    "content": "import { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { formatNumber } from \"@follow/utils\"\nimport type { DiscoveryItem, TrendingFeedItem } from \"@follow-app/client-sdk\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\n\nimport { RelativeDateTime } from \"@/src/components/ui/datetime/RelativeDateTime\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { SafeAlertCuteReIcon } from \"@/src/icons/safe_alert_cute_re\"\nimport { SafetyCertificateCuteReIcon } from \"@/src/icons/safety_certificate_cute_re\"\nimport { User3CuteReIcon } from \"@/src/icons/user_3_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { FollowScreen } from \"@/src/screens/(modal)/FollowScreen\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { FeedSummary } from \"../FeedSummary\"\n\nexport type SearchFeedCardItem = {\n  feed?: {\n    id?: string | null\n    title?: string | null\n    url?: string | null\n    image?: string | null\n    ownerUserId?: string | null\n    siteUrl?: string | null\n    description?: string | null\n  } | null\n  analytics?: {\n    subscriptionCount?: number | null\n    latestEntryPublishedAt?: string | null\n    updatesPerWeek?: number | null\n  } | null\n}\n\nexport const SearchFeedCard = ({\n  item,\n}: {\n  item: SearchFeedCardItem | TrendingFeedItem | DiscoveryItem\n}) => {\n  const { t } = useTranslation(\"common\")\n  const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? \"\")\n  const iconColor = useColor(\"secondaryLabel\")\n  const followerCount = item.analytics?.subscriptionCount || 0\n  const navigation = useNavigation()\n  const openFollow = useCallback(() => {\n    if (item.feed?.id) {\n      navigation.presentControllerView(FollowScreen, {\n        id: item.feed.id,\n        type: \"feed\",\n      })\n      return\n    }\n\n    if (item.feed?.url) {\n      navigation.presentControllerView(FollowScreen, {\n        url: item.feed.url,\n        type: \"url\",\n      })\n    }\n  }, [item.feed?.id, item.feed?.url, navigation])\n  return (\n    <FeedSummary feed={item.feed!} className=\"py-4 pl-4\" testID=\"discover-feed-card\">\n      <View className=\"mt-4 flex-row items-center gap-6\">\n        <View className=\"flex-row items-center gap-1.5\">\n          <User3CuteReIcon width={14} height={14} color={iconColor} />\n          <Text className=\"text-xs text-secondary-label\">\n            {formatNumber(followerCount)} {t(\"feed.follower_other\")}\n          </Text>\n        </View>\n        <View className=\"flex-row items-center gap-1.5\">\n          {item.analytics?.updatesPerWeek ? (\n            <>\n              <SafetyCertificateCuteReIcon width={14} height={14} color={iconColor} />\n              <Text className=\"text-xs text-secondary-label\">\n                {t(\"feed.entry_week_other\", { count: item.analytics.updatesPerWeek })}\n              </Text>\n            </>\n          ) : item.analytics?.latestEntryPublishedAt ? (\n            <>\n              <SafeAlertCuteReIcon width={14} height={14} color={iconColor} />\n              <Text className=\"text-xs text-secondary-label\">{t(\"feed.updated_at\")}</Text>\n              <RelativeDateTime\n                className=\"text-xs text-secondary-label\"\n                date={new Date(item.analytics.latestEntryPublishedAt)}\n              />\n            </>\n          ) : null}\n        </View>\n        <View className=\"ml-auto mr-4 mt-1\">\n          {isSubscribed ? (\n            <Pressable hitSlop={10} onPress={openFollow} testID=\"discover-feed-follow-action\">\n              <View className=\"px-5 py-2\">\n                <Text className=\"text-sm font-bold text-tertiary-label\">\n                  {t(\"feed.actions.followed\")}\n                </Text>\n              </View>\n            </Pressable>\n          ) : (\n            <Pressable hitSlop={10} onPress={openFollow} testID=\"discover-feed-follow-action\">\n              <View className=\"rounded-full bg-accent px-5 py-2\">\n                <Text className=\"text-sm font-bold text-white\">{t(\"feed.actions.follow\")}</Text>\n              </View>\n            </Pressable>\n          )}\n        </View>\n      </View>\n    </FeedSummary>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/search-tabs/SearchList.tsx",
    "content": "import { useSubscriptionByListId } from \"@follow/store/subscription/hooks\"\nimport { formatNumber } from \"@follow/utils\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useAtomValue } from \"jotai\"\nimport { memo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useWindowDimensions, View } from \"react-native\"\n\nimport { FallbackIcon } from \"@/src/components/ui/icon/fallback-icon\"\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { RightCuteReIcon } from \"@/src/icons/right_cute_re\"\nimport { User3CuteReIcon } from \"@/src/icons/user_3_cute_re\"\nimport { followClient } from \"@/src/lib/api-client\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { UrlBuilder } from \"@/src/lib/url-builder\"\nimport { FollowScreen } from \"@/src/screens/(modal)/FollowScreen\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { useSearchPageContext } from \"../ctx\"\nimport { ItemSeparator } from \"./__base\"\nimport { useDataSkeleton } from \"./hooks\"\n\ntype SearchResultItem = Awaited<\n  ReturnType<typeof followClient.api.discover.discover>\n>[\"data\"][number]\nexport const SearchList = () => {\n  const { t } = useTranslation(\"common\")\n  const { searchValueAtom } = useSearchPageContext()\n  const searchValue = useAtomValue(searchValueAtom)\n  const windowWidth = useWindowDimensions().width\n  const { data, isLoading } = useQuery({\n    queryKey: [\"searchList\", searchValue],\n    queryFn: () => followClient.api.discover.discover({ keyword: searchValue, target: \"lists\" }),\n    enabled: !!searchValue,\n  })\n  const skeleton = useDataSkeleton(isLoading, data)\n  if (skeleton) return skeleton\n  if (data === undefined) return null\n  const resultCount = data.data?.length ?? 0\n  const resultLabel =\n    resultCount === 0\n      ? t(\"discover.search.results_zero\")\n      : t(\"discover.search.results_other\", { count: resultCount })\n  return (\n    <View\n      style={{\n        width: windowWidth,\n      }}\n    >\n      <Text className=\"px-6 pt-4 text-text/60\">{resultLabel}</Text>\n      <View>\n        {data.data?.map((item, index) => (\n          <View key={item.list?.id ?? item.feed?.id ?? `list-${index}`}>\n            <SearchListCard item={item} />\n            <ItemSeparator />\n          </View>\n        ))}\n      </View>\n    </View>\n  )\n}\nconst SearchListCard = memo(({ item }: { item: SearchResultItem }) => {\n  const { t } = useTranslation(\"common\")\n  const isSubscribed = useSubscriptionByListId(item.list?.id ?? \"\")\n  const navigation = useNavigation()\n  const iconColor = useColor(\"text\")\n  const followerCount = item.analytics?.subscriptionCount || 0\n  return (\n    <ItemPressable\n      itemStyle={ItemPressableStyle.Plain}\n      className=\"py-8\"\n      onPress={() => {\n        if (item.list?.id) {\n          navigation.presentControllerView(FollowScreen, {\n            id: item.list.id,\n            type: \"list\",\n          })\n        }\n      }}\n    >\n      {/* Headline */}\n      <View className=\"flex-row items-center gap-2 pl-4 pr-2\">\n        <View className=\"size-[32px] overflow-hidden rounded-lg\">\n          {item.list?.image ? (\n            <Image\n              source={{\n                uri: item.list.image,\n              }}\n              className=\"size-full\"\n              contentFit=\"cover\"\n            />\n          ) : (\n            !!item.list?.title && <FallbackIcon title={item.list.title} size={32} />\n          )}\n        </View>\n        <View className=\"flex-1\">\n          <Text\n            className=\"text-base font-semibold text-text\"\n            ellipsizeMode=\"middle\"\n            numberOfLines={1}\n          >\n            {item.list?.title}\n          </Text>\n          {!!item.list?.description && (\n            <Text className=\"text-sm text-text/60\" ellipsizeMode=\"tail\" numberOfLines={1}>\n              {item.list?.description}\n            </Text>\n          )}\n        </View>\n        {/* Subscribe */}\n        {isSubscribed ? (\n          <View className=\"ml-auto\">\n            <View className=\"rounded-lg bg-gray-5/60 px-3 py-2\">\n              <Text className=\"text-sm font-bold text-gray-2\">{t(\"feed.actions.followed\")}</Text>\n            </View>\n          </View>\n        ) : (\n          <View className=\"ml-auto\">\n            <View className=\"rounded-lg bg-accent px-3 py-2\">\n              <Text className=\"text-sm font-bold text-white\">{t(\"feed.actions.follow\")}</Text>\n            </View>\n          </View>\n        )}\n      </View>\n\n      <View className=\"mt-3 flex-row items-center gap-1 pl-4 opacity-60\">\n        <RightCuteReIcon width={16} height={16} />\n        <Text className=\"text-sm text-text\">{UrlBuilder.shareList(item.list?.id ?? \"\")}</Text>\n      </View>\n\n      <View className=\"mt-4 flex-row items-center gap-6 pl-4 opacity-60\">\n        <View className=\"flex-row items-center gap-2\">\n          <User3CuteReIcon width={16} height={16} color={iconColor} />\n          <Text className=\"text-sm text-text\">\n            {formatNumber(followerCount)} {t(\"feed.follower_other\")}\n          </Text>\n        </View>\n      </View>\n    </ItemPressable>\n  )\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/search-tabs/__base.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { useWindowDimensions, View } from \"react-native\"\n\nimport { useSearchBarHeight } from \"../ctx\"\n\nexport const BaseSearchPageRootView = ({\n  children,\n  className,\n}: {\n  children: React.ReactNode\n  className?: string\n}) => {\n  const windowWidth = useWindowDimensions().width\n\n  const searchBarHeight = useSearchBarHeight()\n  const offsetTop = searchBarHeight\n  return (\n    <View className={cn(\"flex-1\", className)} style={{ paddingTop: offsetTop, width: windowWidth }}>\n      {children}\n    </View>\n  )\n}\n\nconst itemSeparator = (\n  <View className=\"h-px bg-opaque-separator/70\" style={{ transform: [{ scaleY: 0.5 }] }} />\n)\nexport const ItemSeparator = () => itemSeparator\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/search-tabs/hooks.tsx",
    "content": "import { withOpacity } from \"@follow/utils\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { SadCuteReIcon } from \"@/src/icons/sad_cute_re\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { BaseSearchPageRootView } from \"./__base\"\n\nexport const useDataSkeleton = (isLoading: boolean, data: any) => {\n  const { t } = useTranslation(\"common\")\n  const textColor = useColor(\"text\")\n  return useMemo(() => {\n    if (isLoading) {\n      return (\n        <BaseSearchPageRootView className=\"h-64 w-full items-center justify-center\">\n          <PlatformActivityIndicator />\n        </BaseSearchPageRootView>\n      )\n    }\n    if (data?.data.length === 0) {\n      return (\n        <BaseSearchPageRootView className=\"h-64 items-center justify-center\">\n          <SadCuteReIcon height={32} width={32} color={withOpacity(textColor, 0.5)} />\n          <Text className=\"mt-2 text-text/50\">{t(\"search.empty.no_results\")}</Text>\n        </BaseSearchPageRootView>\n      )\n    }\n    return null\n  }, [isLoading, data, t, textColor])\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/discover/search.tsx",
    "content": "import { useAtom, useAtomValue, useSetAtom } from \"jotai\"\nimport { use, useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { StyleSheet, Text, TextInput, View } from \"react-native\"\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\"\nimport { useSafeAreaFrame, useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { ReAnimatedPressable } from \"@/src/components/common/AnimatedComponents\"\nimport { BlurEffect } from \"@/src/components/common/BlurEffect\"\nimport { getDefaultHeaderHeight } from \"@/src/components/layouts/utils\"\nimport { SetNavigationHeaderHeightContext } from \"@/src/components/layouts/views/NavigationHeaderContext\"\nimport { Search2CuteReIcon } from \"@/src/icons/search_2_cute_re\"\nimport { useScreenIsInSheetModal } from \"@/src/lib/navigation/hooks\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport { useSearchPageContext, useSearchPageScrollContainerAnimatedX } from \"./ctx\"\nimport { SearchTabBar } from \"./SearchTabBar\"\n\nconst DynamicBlurEffect = () => {\n  const { reAnimatedScrollY } = use(ScreenItemContext)\n  const blurStyle = useAnimatedStyle(() => ({\n    opacity: Math.max(0, Math.min(1, reAnimatedScrollY.value / 50)),\n  }))\n  return (\n    <Animated.View className=\"absolute inset-0 flex-1\" style={blurStyle} pointerEvents={\"none\"}>\n      <BlurEffect />\n    </Animated.View>\n  )\n}\nexport const DiscoverHeader = () => {\n  const frame = useSafeAreaFrame()\n  const insets = useSafeAreaInsets()\n  const sheetModal = useScreenIsInSheetModal()\n  const headerHeight = getDefaultHeaderHeight({\n    landscape: frame.width > frame.height,\n    modalPresentation: sheetModal,\n    topInset: insets.top,\n  })\n  const scrollContainerAnimatedX = useSearchPageScrollContainerAnimatedX()\n  const { searchFocusedAtom } = useSearchPageContext()\n  const isFocused = useAtomValue(searchFocusedAtom)\n  const setHeaderHeight = use(SetNavigationHeaderHeightContext)\n  return (\n    <View\n      style={{\n        minHeight: headerHeight,\n        paddingTop: insets.top,\n      }}\n      className=\"relative\"\n      onLayout={(e) => {\n        setHeaderHeight(e.nativeEvent.layout.height)\n      }}\n    >\n      <DynamicBlurEffect />\n\n      <View style={styles.header}>\n        <SearchInput />\n      </View>\n      {isFocused && <SearchTabBar animatedX={scrollContainerAnimatedX} />}\n    </View>\n  )\n}\nconst SearchInput = () => {\n  const { t } = useTranslation(\"common\")\n  const { searchFocusedAtom, searchValueAtom } = useSearchPageContext()\n  const [isFocused, setIsFocused] = useAtom(searchFocusedAtom)\n  const placeholderTextColor = useColor(\"secondaryLabel\")\n  const searchValue = useAtomValue(searchValueAtom)\n  const setSearchValue = useSetAtom(searchValueAtom)\n  const inputRef = useRef<TextInput>(null)\n  const skeletonOpacity = useSharedValue(0)\n  const skeletonTranslateX = useSharedValue(0)\n  const placeholderOpacity = useSharedValue(0)\n  const marginRight = useSharedValue(0)\n  const cancelButtonTranslateX = useSharedValue(20)\n  const [tempSearchValue, setTempSearchValue] = useState(searchValue)\n  const focusOrHasValue = isFocused || searchValue || tempSearchValue\n  useEffect(() => {\n    if (focusOrHasValue) {\n      skeletonOpacity.value = withTiming(0, {\n        duration: 100,\n      })\n      skeletonTranslateX.value = withTiming(-150, {\n        duration: 100,\n        easing: Easing.inOut(Easing.ease),\n      })\n      placeholderOpacity.value = withTiming(1, {\n        duration: 200,\n      })\n      marginRight.value = withTiming(64, {\n        duration: 200,\n      })\n      cancelButtonTranslateX.value = withTiming(0, {\n        duration: 200,\n      })\n    } else {\n      skeletonOpacity.value = withTiming(1, {\n        duration: 100,\n      })\n      skeletonTranslateX.value = withTiming(0, {\n        duration: 100,\n        easing: Easing.inOut(Easing.ease),\n      })\n      placeholderOpacity.value = withTiming(0, {\n        duration: 200,\n      })\n      marginRight.value = withTiming(0, {\n        duration: 200,\n      })\n      cancelButtonTranslateX.value = withTiming(20, {\n        duration: 200,\n      })\n    }\n  }, [\n    focusOrHasValue,\n    placeholderOpacity,\n    skeletonOpacity,\n    skeletonTranslateX,\n    marginRight,\n    cancelButtonTranslateX,\n  ])\n  const skeletonAnimatedStyle = useAnimatedStyle(() => ({\n    opacity: skeletonOpacity.value,\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    left: 0,\n    right: 0,\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    transform: [\n      {\n        translateX: skeletonTranslateX.value,\n      },\n    ],\n  }))\n  const placeholderAnimatedStyle = useAnimatedStyle(() => ({\n    opacity: placeholderOpacity.value,\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    left: 12,\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  }))\n  const containerAnimatedStyle = useAnimatedStyle(() => ({\n    marginRight: marginRight.value,\n  }))\n  const cancelButtonAnimatedStyle = useAnimatedStyle(() => ({\n    transform: [\n      {\n        translateX: cancelButtonTranslateX.value,\n      },\n    ],\n  }))\n  useEffect(() => {\n    if (!isFocused) {\n      inputRef.current?.blur()\n    } else {\n      inputRef.current?.focus()\n    }\n  }, [isFocused])\n  return (\n    <Animated.View className=\"flex-row items-center justify-center\" style={containerAnimatedStyle}>\n      <View style={styles.searchbar} className=\"bg-tertiary-system-fill\">\n        {focusOrHasValue && (\n          <Animated.View style={placeholderAnimatedStyle}>\n            <Search2CuteReIcon color={placeholderTextColor} height={18} width={18} />\n            {!searchValue && !tempSearchValue && (\n              <Text className=\"ml-2 text-secondary-label\" style={styles.searchPlaceholderText}>\n                {t(\"words.search\")}\n              </Text>\n            )}\n          </Animated.View>\n        )}\n        <TextInput\n          testID=\"discover-search-input\"\n          allowFontScaling={false}\n          textAlignVertical=\"center\"\n          enterKeyHint=\"search\"\n          autoFocus={isFocused}\n          ref={inputRef}\n          onSubmitEditing={() => {\n            setSearchValue(tempSearchValue)\n          }}\n          defaultValue={searchValue}\n          cursorColor={accentColor}\n          selectionColor={accentColor}\n          style={styles.searchInput}\n          className=\"text-text\"\n          onFocus={() => setIsFocused(true)}\n          onBlur={() => !searchValue && !tempSearchValue && setIsFocused(false)}\n          onChangeText={(text) => {\n            setTempSearchValue(text)\n          }}\n        />\n\n        <Animated.View style={skeletonAnimatedStyle} pointerEvents=\"none\">\n          <Search2CuteReIcon color={placeholderTextColor} height={18} width={18} />\n          <Text\n            allowFontScaling={false}\n            className=\"ml-1 text-secondary-label\"\n            style={styles.searchPlaceholderText}\n          >\n            {t(\"words.search\")}\n          </Text>\n        </Animated.View>\n      </View>\n\n      <ReAnimatedPressable\n        testID=\"discover-search-cancel\"\n        hitSlop={10}\n        onPress={() => {\n          setIsFocused(false)\n          setSearchValue(\"\")\n          setTempSearchValue(\"\")\n        }}\n        className=\"absolute -right-20 w-20 pl-4\"\n        style={cancelButtonAnimatedStyle}\n      >\n        <Text\n          // Fix font scaling issues on Android\n          allowFontScaling={false}\n          className=\"text-[16px] font-medium text-accent\"\n        >\n          {t(\"words.cancel\")}\n        </Text>\n      </ReAnimatedPressable>\n    </Animated.View>\n  )\n}\nconst styles = StyleSheet.create({\n  header: {\n    flex: 1,\n    alignItems: \"center\",\n    flexDirection: \"row\",\n    marginBottom: 14,\n    marginHorizontal: 16,\n    position: \"relative\",\n    marginTop: 4,\n  },\n  searchbar: {\n    flex: 1,\n    display: \"flex\",\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    borderRadius: 50,\n    height: 40,\n    position: \"relative\",\n  },\n  searchInput: {\n    flex: 1,\n    fontSize: 16,\n    paddingRight: 16,\n    paddingLeft: 35,\n    height: \"100%\",\n  },\n  searchPlaceholderText: {\n    fontSize: 16,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/EntryAISummary.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { SummaryGeneratingStatus } from \"@follow/store/summary/enum\"\nimport { usePrefetchSummary, useSummary, useSummaryStatus } from \"@follow/store/summary/hooks\"\nimport { useAtomValue } from \"jotai\"\nimport type { FC } from \"react\"\nimport { useCallback, useMemo } from \"react\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { ErrorBoundary } from \"@/src/components/common/ErrorBoundary\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { renderMarkdown } from \"@/src/lib/markdown\"\n\nimport { AISummary } from \"../ai/summary\"\nimport { useEntryContentContext } from \"./ctx\"\n\nconst ATTRIBUTION_PREFIX_PATTERNS = [\n  /^(?:(?:据|根据)\\s*)?[^，。,:：\\n]{1,40}(?:消息|报道|报告|监测|研究|统计|数据|分析)?(?:称|显示|指出|提到|认为|披露|公布)[，,:：]\\s*/u,\n  /^[^\\n,.:;]{1,80}\\s+(?:reported|reports|said|says|stated|states|noted|notes)\\s*[,.:;]\\s*/i,\n  /^according to [^\\n,.:;]{1,80}[,.:;]\\s*/i,\n] as const\n\nconst stripLeadingAttribution = (text: string) => {\n  let result = text\n  for (const pattern of ATTRIBUTION_PREFIX_PATTERNS) {\n    result = result.replace(pattern, \"\")\n  }\n  return result\n}\n\nconst normalizeSummaryText = (source?: string | null) => {\n  if (!source) return \"\"\n\n  const normalizedWhitespace = source\n    .replaceAll(/\\r\\n?/g, \"\\n\")\n    .split(\"\\n\")\n    .map((line) => line.trim())\n    .join(\"\\n\")\n    .replaceAll(/[ \\t]+/g, \" \")\n    .replaceAll(/\\n{3,}/g, \"\\n\\n\")\n    .trim()\n\n  if (!normalizedWhitespace) return \"\"\n\n  const withoutAttribution = stripLeadingAttribution(normalizedWhitespace)\n\n  return withoutAttribution\n    .replace(/^[，。,:：\\s]+/u, \"\")\n    .replaceAll(/\\s+([，。！？；：])/g, \"$1\")\n    .trim()\n}\n\nconst looksLikeMarkdown = (source: string) => {\n  return /(?:^|\\n)\\s{0,3}(?:#{1,6}\\s|[-*+]\\s|\\d+\\.\\s|>\\s|```)|\\[[^\\]]+\\]\\([^)]+\\)|`[^`]+`|\\*\\*[^*]+\\*\\*|(?:^|\\n)\\s*[-*_]{3,}\\s*(?:$|\\n)|(?:^|\\n)\\|.+\\|/m.test(\n    source,\n  )\n}\n\nexport const EntryAISummary: FC<{\n  entryId: string\n}> = ({ entryId }) => {\n  const ctx = useEntryContentContext()\n  const showReadability = useAtomValue(ctx.showReadabilityAtom)\n  const showAISummaryOnce = useAtomValue(ctx.showAISummaryAtom)\n  const showAISummary = useGeneralSettingKey(\"summary\") || showAISummaryOnce\n  const entry = useEntry(\n    entryId,\n    useCallback(\n      (state) => {\n        const target =\n          showReadability && state.readabilityContent ? \"readabilityContent\" : \"content\"\n        return {\n          target,\n        } as const\n      },\n      [showReadability],\n    ),\n  )\n  const actionLanguage = useActionLanguage()\n  const summary = useSummary(entryId, actionLanguage)\n  const { error: summaryError, refetch: refetchSummary } = usePrefetchSummary({\n    entryId,\n    target: entry?.target || \"content\",\n    actionLanguage,\n    enabled: showAISummary,\n  })\n  const maybeMarkdown = showReadability\n    ? summary?.readabilitySummary || summary?.summary\n    : summary?.summary\n  const normalizedSummary = useMemo(() => normalizeSummaryText(maybeMarkdown), [maybeMarkdown])\n  const summaryToShow = useMemo(() => {\n    if (!normalizedSummary) return null\n    if (!looksLikeMarkdown(normalizedSummary)) return normalizedSummary\n    return renderMarkdown(normalizedSummary)\n  }, [normalizedSummary])\n  const status = useSummaryStatus({\n    entryId,\n    actionLanguage,\n    target: entry?.target || \"content\",\n  })\n  const handleRetry = useCallback(() => {\n    refetchSummary()\n  }, [refetchSummary])\n  if (!showAISummary) return null\n  return (\n    <ErrorBoundary\n      fallbackRender={() => (\n        <Text className=\"text-[16px] leading-[24px] text-label\">\n          Failed to generate summary. Rendering error.\n        </Text>\n      )}\n    >\n      <AISummary\n        className=\"my-3\"\n        rawSummaryForCopy={normalizedSummary || undefined}\n        summary={summaryToShow}\n        pending={status === SummaryGeneratingStatus.Pending}\n        error={summaryError}\n        onRetry={status === SummaryGeneratingStatus.Error ? handleRetry : undefined}\n      />\n    </ErrorBoundary>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx",
    "content": "import { useIsEntryStarred } from \"@follow/store/collection/hooks\"\nimport { collectionSyncService } from \"@follow/store/collection/store\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { entrySyncServices } from \"@follow/store/entry/store\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useSubscriptionById } from \"@follow/store/subscription/hooks\"\nimport { translationSyncService } from \"@follow/store/translation/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { setStringAsync } from \"expo-clipboard\"\nimport { useAtom } from \"jotai\"\nimport { useCallback, useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, Share, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, { interpolate, useAnimatedStyle } from \"react-native-reanimated\"\nimport { useColor } from \"react-native-uikit-colors\"\nimport type { MenuItemIconProps } from \"zeego/lib/typescript/menu\"\n\nimport { getActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { ActionBarItem } from \"@/src/components/ui/action-bar/ActionBarItem\"\nimport { DropdownMenu } from \"@/src/components/ui/context-menu\"\nimport { DocmentCuteReIcon } from \"@/src/icons/docment_cute_re\"\nimport { More1CuteReIcon } from \"@/src/icons/more_1_cute_re\"\nimport { ShareForwardCuteReIcon } from \"@/src/icons/share_forward_cute_re\"\nimport { StarCuteFiIcon } from \"@/src/icons/star_cute_fi\"\nimport { StarCuteReIcon } from \"@/src/icons/star_cute_re\"\nimport { Translate2CuteReIcon } from \"@/src/icons/translate_2_cute_re\"\nimport { hideIntelligenceGlowEffect, openLink } from \"@/src/lib/native\"\nimport { toast } from \"@/src/lib/toast\"\n\nimport { useEntryContentContext } from \"./ctx\"\n\ntype ActionItem = {\n  key: string\n  title: string\n  icon: React.JSX.Element\n  iconIOS: MenuItemIconProps[\"ios\"]\n  onPress: () => void\n  active?: boolean\n  iconColor?: string\n  isCheckbox?: boolean\n  inMenu?: boolean\n}\n\nexport function EntryContentHeaderRightActions(props: HeaderRightActionsProps) {\n  return <HeaderRightActionsImpl {...props} />\n}\n\ninterface HeaderRightActionsProps {\n  entryId: string\n  titleOpacityShareValue: SharedValue<number>\n  isHeaderTitleVisible: boolean\n}\n\nconst HeaderRightActionsImpl = ({\n  entryId,\n  titleOpacityShareValue,\n  isHeaderTitleVisible,\n}: HeaderRightActionsProps) => {\n  const { t } = useTranslation()\n  const labelColor = useColor(\"label\")\n  const isLoggedIn = useIsLoggedIn()\n  const isStarred = useIsEntryStarred(entryId)\n  const [extraActionContainerWidth, setExtraActionContainerWidth] = useState(0)\n\n  const entry = useEntry(\n    entryId,\n    (entry) =>\n      entry && {\n        url: entry.url,\n        feedId: entry.feedId,\n        title: entry.title,\n        settings: entry.settings,\n      },\n  )\n\n  const { showReadabilityAtom, showAITranslationAtom } = useEntryContentContext()\n  const [showTranslation, setShowTranslation] = useAtom(showAITranslationAtom)\n  const [showReadability, setShowReadability] = useAtom(showReadabilityAtom)\n  const showAITranslationSetting =\n    useGeneralSettingKey(\"translation\") || !!entry?.settings?.translation\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const showReadabilitySetting = !!entry?.settings?.readability\n\n  const feed = useFeedById(entry?.feedId as string, (feed) => feed && { feedId: feed.id })\n  const subscription = useSubscriptionById(feed?.feedId as string)\n\n  const handleToggleStar = () => {\n    if (!entry || !feed || !subscription) return\n\n    isStarred\n      ? collectionSyncService.unstarEntry({ entryId })\n      : collectionSyncService.starEntry({\n          entryId,\n          view: subscription.view,\n        })\n  }\n\n  const handleShare = () => {\n    if (!entry?.title || !entry?.url) return\n    Share.share({\n      message: entry.url,\n      title: entry.title,\n      url: entry.url,\n    })\n  }\n\n  const toggleAITranslation = () => {\n    translationSyncService.generateTranslation({\n      entryId,\n      language: getActionLanguage(),\n      withContent: true,\n      target: showReadability ? \"readabilityContent\" : \"content\",\n      mode: translationMode,\n    })\n    setShowTranslation((prev) => !prev)\n  }\n\n  const toggleReadability = useCallback(() => {\n    entrySyncServices.fetchEntryReadabilityContent(entryId)\n    setShowReadability((prev) => !prev)\n  }, [entryId, setShowReadability])\n\n  const handleCopyLink = () => {\n    if (!entry?.url) return\n    setStringAsync(entry.url)\n    toast.success(\"Link copied to clipboard\")\n  }\n\n  const handleOpenInBrowser = () => {\n    if (!entry?.url) return\n    openLink(entry.url)\n  }\n\n  useEffect(() => {\n    return () => hideIntelligenceGlowEffect()\n  }, [])\n\n  // Define action items for reuse\n  const actionItems = [\n    isLoggedIn &&\n      subscription && {\n        key: \"Star\",\n        title: isStarred ? t(\"operation.unstar\") : t(\"operation.star\"),\n        icon: isStarred ? <StarCuteFiIcon /> : <StarCuteReIcon />,\n        iconIOS: {\n          name: isStarred ? \"star.fill\" : \"star\",\n          paletteColors: isStarred ? [\"#facc15\"] : undefined,\n        },\n        onPress: handleToggleStar,\n        active: isStarred,\n        iconColor: isStarred ? \"#facc15\" : undefined,\n      },\n    !showReadabilitySetting && {\n      key: \"ShowReadability\",\n      title: \"Show Readability\",\n      icon: <DocmentCuteReIcon />,\n      iconIOS: { name: \"doc.text\" },\n      onPress: toggleReadability,\n      active: showReadability,\n      isCheckbox: true,\n      // inMenu: true,\n    },\n    isLoggedIn &&\n      !showAITranslationSetting && {\n        key: \"ShowTranslation\",\n        title: \"Show Translation\",\n        icon: <Translate2CuteReIcon />,\n        iconIOS: { name: \"globe\" },\n        onPress: toggleAITranslation,\n        active: showTranslation,\n        isCheckbox: true,\n        inMenu: true,\n      },\n    {\n      key: \"Share\",\n      title: t(\"operation.share\"),\n      icon: <ShareForwardCuteReIcon />,\n      iconIOS: { name: \"square.and.arrow.up\" },\n      onPress: handleShare,\n    },\n    {\n      key: \"CopyLink\",\n      title: t(\"operation.copy_which\", { which: t(\"operation.copy.link\") }),\n      iconIOS: { name: \"link\" },\n      onPress: handleCopyLink,\n      inMenu: true,\n    },\n    {\n      key: \"OpenInBrowser\",\n      title: \"Open in Browser\",\n      iconIOS: { name: \"safari\" },\n      onPress: handleOpenInBrowser,\n      inMenu: true,\n    },\n  ].filter(Boolean) as ActionItem[]\n\n  return (\n    <View className=\"relative flex-row gap-4\">\n      {!isHeaderTitleVisible && (\n        <View style={{ width: extraActionContainerWidth }} pointerEvents=\"none\" />\n      )}\n\n      <Animated.View\n        onLayout={(e) => setExtraActionContainerWidth(e.nativeEvent.layout.width)}\n        style={useAnimatedStyle(() => ({\n          opacity: interpolate(titleOpacityShareValue.value, [0, 1], [1, 0]),\n        }))}\n        className=\"absolute right-[32px] z-10 flex-row gap-2\"\n      >\n        {actionItems\n          .filter((item) => !item.inMenu)\n          .map(\n            (item) =>\n              item && (\n                <ActionBarItem\n                  key={item.key}\n                  onPress={item.onPress}\n                  label={item.title}\n                  active={item.active}\n                  iconColor={item.iconColor}\n                >\n                  {item.icon}\n                </ActionBarItem>\n              ),\n          )}\n      </Animated.View>\n\n      <DropdownMenu.Root>\n        <DropdownMenu.Trigger>\n          <Pressable hitSlop={10} accessibilityLabel=\"More Actions\">\n            <More1CuteReIcon color={labelColor} />\n          </Pressable>\n        </DropdownMenu.Trigger>\n\n        <DropdownMenu.Content>\n          {actionItems\n            .filter((item) => (isHeaderTitleVisible ? true : item?.inMenu))\n            .map(\n              (item) =>\n                item &&\n                (item.isCheckbox ? (\n                  <DropdownMenu.CheckboxItem\n                    key={item.key}\n                    value={item.active!}\n                    onSelect={item.onPress}\n                  >\n                    <DropdownMenu.ItemTitle>{item.title}</DropdownMenu.ItemTitle>\n                    <DropdownMenu.ItemIcon ios={item.iconIOS} />\n                  </DropdownMenu.CheckboxItem>\n                ) : (\n                  <DropdownMenu.Item key={item.key} onSelect={item.onPress}>\n                    <DropdownMenu.ItemTitle>{item.title}</DropdownMenu.ItemTitle>\n                    <DropdownMenu.ItemIcon ios={item.iconIOS} />\n                  </DropdownMenu.Item>\n                )),\n            )}\n        </DropdownMenu.Content>\n      </DropdownMenu.Root>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/EntryGridFooter.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { RelativeDateTime } from \"@/src/components/ui/datetime/RelativeDateTime\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport { EntryTranslation } from \"../entry-list/templates/EntryTranslation\"\n\nexport const EntryGridFooter = ({\n  entryId,\n  descriptionClassName,\n  view,\n}: {\n  entryId: string\n  descriptionClassName?: string\n  view: FeedViewType\n}) => {\n  const entry = useEntry(entryId, (state) => ({\n    title: state.title,\n    feedId: state.feedId,\n    publishedAt: state.publishedAt,\n    read: state.read,\n    translation: state.settings?.translation,\n  }))\n  const enableTranslation = useGeneralSettingKey(\"translation\")\n  const actionLanguage = useActionLanguage()\n  const translation = useEntryTranslation({\n    entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n  const feed = useFeedById(entry?.feedId || \"\")\n  if (!entry) return null\n  return (\n    <View className=\"gap-2 px-1 py-2\">\n      <View className=\"flex-row gap-1\">\n        {!entry.read && <View className=\"mt-1.5 inline-block size-2 rounded-full bg-red\" />}\n        {entry.title && (\n          <EntryTranslation\n            numberOfLines={2}\n            className={cn(\n              \"shrink text-xs font-medium text-label\",\n              view === FeedViewType.Videos && \"min-h-10\",\n              descriptionClassName,\n            )}\n            source={entry.title}\n            target={translation?.title}\n            showTranslation={!!entry.translation}\n            inline\n          />\n        )}\n      </View>\n      <View className=\"flex-row items-center gap-1.5\">\n        <FeedIcon fallback feed={feed} size={14} />\n        <Text numberOfLines={1} className=\"shrink text-xs font-medium text-secondary-label\">\n          {feed?.title}\n        </Text>\n        <RelativeDateTime className=\"text-xs text-tertiary-label\" date={entry.publishedAt} />\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/EntryNavigationHeader.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useAtomValue } from \"jotai\"\nimport type { FC } from \"react\"\nimport { use, useState } from \"react\"\nimport { useWindowDimensions, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, {\n  interpolate,\n  runOnJS,\n  useAnimatedReaction,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\"\n\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport { DefaultHeaderBackButton } from \"@/src/components/layouts/header/NavigationHeader\"\nimport { NavigationBlurEffectHeaderView } from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\n\nimport { useHeaderHeight } from \"../screen/hooks/useHeaderHeight\"\nimport { EntryContentContext } from \"./ctx\"\nimport { EntryContentHeaderRightActions } from \"./EntryContentHeaderRightActions\"\nimport { EntryReadHistory } from \"./EntryReadHistory\"\n\nexport const EntryNavigationHeader: FC<{\n  entryId: string\n}> = ({ entryId }) => {\n  const opacityAnimatedValue = useSharedValue(0)\n\n  const headerHeight = useHeaderHeight()\n\n  const title = useEntry(entryId, (entry) => {\n    return entry.title\n  })\n\n  const [isHeaderTitleVisible, setIsHeaderTitleVisible] = useState(true)\n\n  const reanimatedScrollY = use(ScreenItemContext).reAnimatedScrollY\n\n  const ctxValue = use(EntryContentContext)\n  const titleHeight = useAtomValue(ctxValue.titleHeightAtom)\n  useAnimatedReaction(\n    () => reanimatedScrollY.value,\n    (value) => {\n      if (value > titleHeight + headerHeight) {\n        opacityAnimatedValue.value = withTiming(1, { duration: 100 })\n        runOnJS(setIsHeaderTitleVisible)(true)\n      } else {\n        opacityAnimatedValue.value = withTiming(0, { duration: 100 })\n        runOnJS(setIsHeaderTitleVisible)(false)\n      }\n    },\n  )\n  const headerBarWidth = useWindowDimensions().width\n\n  return (\n    <NavigationBlurEffectHeaderView\n      headerTitleAbsolute\n      headerLeft={useTypeScriptHappyCallback(\n        ({ canGoBack }) => (\n          <EntryLeftGroup\n            canGoBack={canGoBack ?? false}\n            entryId={entryId}\n            titleOpacityShareValue={opacityAnimatedValue}\n          />\n        ),\n        [entryId],\n      )}\n      headerRight={\n        <EntryContentContext value={ctxValue}>\n          <EntryContentHeaderRightActions\n            entryId={entryId}\n            titleOpacityShareValue={opacityAnimatedValue}\n            isHeaderTitleVisible={isHeaderTitleVisible}\n          />\n        </EntryContentContext>\n      }\n      headerTitle={\n        <View\n          className=\"flex-row items-center justify-center\"\n          pointerEvents=\"none\"\n          style={{ width: headerBarWidth - 80 }}\n        >\n          <Animated.Text\n            className={\"text-center text-[17px] font-semibold text-label\"}\n            numberOfLines={1}\n            style={{ opacity: opacityAnimatedValue }}\n          >\n            {title}\n          </Animated.Text>\n        </View>\n      }\n    />\n  )\n}\ninterface EntryLeftGroupProps {\n  canGoBack: boolean\n  entryId: string\n  titleOpacityShareValue: SharedValue<number>\n}\n\nconst EntryLeftGroup = ({ canGoBack, entryId, titleOpacityShareValue }: EntryLeftGroupProps) => {\n  const hideRecentReader = useUISettingKey(\"hideRecentReader\")\n  const animatedOpacity = useAnimatedStyle(() => {\n    return {\n      opacity: interpolate(titleOpacityShareValue.value, [0, 1], [1, 0]),\n    }\n  })\n  return (\n    <View className=\"flex-row items-center justify-center\">\n      <DefaultHeaderBackButton canGoBack={canGoBack} canDismiss={false} />\n\n      {!hideRecentReader && (\n        <Animated.View style={animatedOpacity} className=\"absolute left-[32px] z-10 flex-row gap-2\">\n          <EntryReadHistory entryId={entryId} />\n        </Animated.View>\n      )}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/EntryReadHistory.tsx",
    "content": "import { useEntryReadHistory } from \"@follow/store/entry/hooks\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { View } from \"react-native\"\n\nimport { UserAvatar } from \"@/src/components/ui/avatar/UserAvatar\"\nimport { NativePressable } from \"@/src/components/ui/pressable/NativePressable\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { ProfileScreen } from \"@/src/screens/(modal)/ProfileScreen\"\n\nexport const EntryReadHistory = ({ entryId }: { entryId: string }) => {\n  const isLoggedIn = useIsLoggedIn()\n  const data = useEntryReadHistory(entryId, 6, isLoggedIn)\n  const navigation = useNavigation()\n  if (!isLoggedIn || !data?.entryReadHistories) return null\n  return (\n    <View className=\"flex-row items-center justify-center\">\n      {data?.entryReadHistories.userIds.map((userId, index) => {\n        const user = data.users[userId]\n        if (!user) return null\n        return (\n          <NativePressable\n            onPress={() => {\n              navigation.presentControllerView(ProfileScreen, {\n                userId: user.id,\n              })\n            }}\n            className=\"overflow-hidden rounded-full border-2 border-system-background bg-tertiary-system-background\"\n            key={userId}\n            style={{\n              transform: [\n                {\n                  translateX: index * -10,\n                },\n              ],\n            }}\n          >\n            <UserAvatar\n              preview={false}\n              size={25}\n              name={user.name!}\n              image={user.image}\n              className=\"border border-secondary-system-fill\"\n            />\n          </NativePressable>\n        )\n      })}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/EntryTitle.tsx",
    "content": "import { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useAtomValue, useSetAtom } from \"jotai\"\nimport { use } from \"react\"\nimport { View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { UserAvatar } from \"@/src/components/ui/avatar/UserAvatar\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { EntryContentContext, useEntryContentContext } from \"@/src/modules/entry-content/ctx\"\n\nimport { EntryTranslation } from \"../entry-list/templates/EntryTranslation\"\n\nexport const EntryTitle = ({ title, entryId }: { title: string; entryId: string }) => {\n  const { showAITranslationAtom } = useEntryContentContext()\n  const showTranslationOnce = useAtomValue(showAITranslationAtom)\n  const enableTranslation = useGeneralSettingKey(\"translation\") || showTranslationOnce\n  const actionLanguage = useActionLanguage()\n  const translation = useEntryTranslation({\n    entryId,\n    language: actionLanguage,\n    enabled: enableTranslation,\n  })\n  const { titleHeightAtom } = use(EntryContentContext)\n  const setTitleHeight = useSetAtom(titleHeightAtom)\n  return (\n    <View\n      onLayout={(e) => {\n        setTitleHeight(e.nativeEvent.layout.height)\n      }}\n    >\n      <EntryTranslation\n        className=\"text-title2 font-bold leading-snug text-label\"\n        source={title}\n        target={translation?.title}\n        bilingual\n      />\n    </View>\n  )\n}\nexport const EntrySocialTitle = ({ entryId }: { entryId: string }) => {\n  const entry = useEntry(entryId, (entry) => {\n    return {\n      authorAvatar: entry.authorAvatar,\n      author: entry.author,\n      feedId: entry.feedId,\n    }\n  })\n  const feed = useFeedById(entry?.feedId as string)\n  return (\n    <View className=\"flex-row items-center gap-3 px-4\">\n      {entry?.authorAvatar ? (\n        <UserAvatar size={28} name={entry?.author || \"\"} image={entry?.authorAvatar} />\n      ) : (\n        feed && <FeedIcon feed={feed} size={28} />\n      )}\n      <Text className=\"text-[15px] font-semibold text-label\">\n        {entry?.author || feed?.title || \"\"}\n      </Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/ctx.ts",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext, use } from \"react\"\n\ninterface EntryContentContextType {\n  showAISummaryAtom: PrimitiveAtom<boolean>\n  showAITranslationAtom: PrimitiveAtom<boolean>\n  showReadabilityAtom: PrimitiveAtom<boolean>\n  showSourceContentAtom: PrimitiveAtom<boolean>\n  titleHeightAtom: PrimitiveAtom<number>\n}\nexport const EntryContentContext = createContext<EntryContentContextType>(null!)\nexport const useEntryContentContext = () => {\n  const context = use(EntryContentContext)\n  if (!context) {\n    throw new Error(\"useEntryContentContext must be used within a EntryContentContext\")\n  }\n  return context\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorAndroid.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport Animated, { useAnimatedStyle, withTiming } from \"react-native-reanimated\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { ArrowLeftCuteReIcon } from \"@/src/icons/arrow_left_cute_re\"\n\nimport type { UsePullUpToNextReturn } from \"./types\"\n\n/**\n * Component that handles pulling up to navigate to the next unread entry for Android\n */\nexport const PullUpIndicatorAndroid: UsePullUpToNextReturn[\"EntryPullUpToNext\"] = ({\n  active,\n  hide = false,\n  translateY,\n}) => {\n  const { t } = useTranslation()\n  const textColor = useColor(\"secondaryLabel\")\n  const iconColor = useColor(\"label\")\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    transform: [{ translateY: -translateY.value }],\n    // paddingBottom: insets.bottom,\n    opacity: withTiming(hide ? 0 : 1, { duration: 100 }),\n  }))\n\n  return (\n    <Animated.View\n      className=\"bottom-0 flex w-full flex-row items-center justify-center gap-2 pt-16\"\n      style={animatedStyle}\n    >\n      <View\n        className={cn(\n          \"flex flex-row items-center gap-2 transition-all duration-200\",\n          active ? \"opacity-50\" : \"opacity-80\",\n        )}\n      >\n        <View\n          className={cn(\n            \"rotate-90 transition-all duration-200\",\n            active ? \"opacity-0\" : \"opacity-100\",\n          )}\n        >\n          <ArrowLeftCuteReIcon width={16} height={16} color={iconColor} />\n        </View>\n        <Text style={{ color: textColor }}>\n          {active ? t(\"entry.release_to_next_entry\") : t(\"entry.pull_up_to_next_entry\")}\n        </Text>\n      </View>\n    </Animated.View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorIos.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { ArrowLeftCuteReIcon } from \"@/src/icons/arrow_left_cute_re\"\n\nimport type { UsePullUpToNextReturn } from \"./types\"\n\n/**\n * Component that handles pulling up to navigate to the next unread entry\n */\nexport const PullUpIndicatorIos: UsePullUpToNextReturn[\"EntryPullUpToNext\"] = ({\n  active,\n  hide = false,\n}) => {\n  const { t } = useTranslation()\n  const insets = useSafeAreaInsets()\n  const textColor = useColor(\"secondaryLabel\")\n  const iconColor = useColor(\"label\")\n\n  return (\n    <View\n      className={cn(\n        \"absolute bottom-0 flex w-full translate-y-full flex-row items-center justify-center gap-2 pt-4 transition-all duration-200\",\n        hide ? \"opacity-0\" : \"opacity-100\",\n      )}\n      style={{ paddingBottom: insets.bottom + 20 }}\n    >\n      <View\n        className={cn(\n          \"flex flex-row items-center gap-2 transition-all duration-200\",\n          active ? \"opacity-50\" : \"opacity-80\",\n        )}\n      >\n        <View\n          className={cn(\n            \"rotate-90 transition-all duration-200\",\n            active ? \"opacity-0\" : \"opacity-100\",\n          )}\n        >\n          <ArrowLeftCuteReIcon width={16} height={16} color={iconColor} />\n        </View>\n        <Text style={{ color: textColor }}>\n          {active ? t(\"entry.release_to_next_entry\") : t(\"entry.pull_up_to_next_entry\")}\n        </Text>\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts",
    "content": "import type { FC, PropsWithChildren } from \"react\"\nimport type { NativeScrollEvent, NativeSyntheticEvent } from \"react-native\"\nimport type { ComposedGesture, GestureType } from \"react-native-gesture-handler\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport type { ReanimatedScrollEvent } from \"react-native-reanimated/lib/typescript/hook/commonTypes\"\n\ninterface EntryPullUpToNextProps {\n  active: boolean\n  hide?: boolean\n  translateY: SharedValue<number>\n}\n\nexport interface UsePullUpToNextProps {\n  enabled?: boolean\n  onRefresh?: (() => void) | undefined\n  progressViewOffset?: number\n}\n\ninterface GestureWrapperProps {\n  enabled?: boolean\n  gesture?: ComposedGesture | GestureType\n}\n\nexport interface UsePullUpToNextReturn {\n  pullUpViewProps: EntryPullUpToNextProps\n  scrollViewEventHandlers: {\n    onScroll?: (e: ReanimatedScrollEvent) => void\n    onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void\n    onScrollEndDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void\n  }\n  EntryPullUpToNext: FC<EntryPullUpToNextProps>\n  gestureWrapperProps: GestureWrapperProps\n  GestureWrapper: FC<PropsWithChildren<GestureWrapperProps>>\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.android.tsx",
    "content": "import * as Haptics from \"expo-haptics\"\nimport { useCallback, useRef, useState } from \"react\"\nimport { View } from \"react-native\"\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\nimport { runOnJS, useSharedValue, withSpring } from \"react-native-reanimated\"\nimport type { ReanimatedScrollEvent } from \"react-native-reanimated/lib/typescript/hook/commonTypes\"\n\nimport { useScrollViewProgress } from \"@/src/lib/navigation/ScreenItemContext\"\n\nimport { PullUpIndicatorAndroid } from \"./PullUpIndicatorAndroid\"\nimport type { UsePullUpToNextProps, UsePullUpToNextReturn } from \"./types\"\n\nconst THRESHOLD = 70 // The threshold in pixels to trigger the next entry\nconst FEEDBACK_THRESHOLD = 0.5 // When to give haptic feedback (50% of the way to the threshold)\n\nconst GestureWrapper: UsePullUpToNextReturn[\"GestureWrapper\"] = ({\n  gesture,\n  enabled,\n  children,\n}) => {\n  if (!enabled || !gesture) {\n    return <>{children}</>\n  }\n\n  return (\n    <GestureDetector gesture={gesture}>\n      <View className=\"flex flex-1\">{children}</View>\n    </GestureDetector>\n  )\n}\n\nexport const usePullUpToNext = ({\n  enabled = true,\n  onRefresh,\n}: UsePullUpToNextProps): UsePullUpToNextReturn => {\n  const scrollViewProgress = useScrollViewProgress()\n  const isAtEnd = useSharedValue(false)\n  const [refreshing, setRefreshing] = useState(false)\n  const [dragState, setDragState] = useState(false)\n  const feedbackGiven = useSharedValue(false)\n  const translateY = useSharedValue(0)\n  const gestureState = useSharedValue(false)\n\n  const initialTouchLocation = useSharedValue<{ x: number; y: number } | null>(null)\n  const panGesture = Gesture.Pan()\n    .enabled(enabled)\n    .manualActivation(true)\n    .maxPointers(1)\n    .onBegin((event) => {\n      initialTouchLocation.value = { x: event.x, y: event.y }\n    })\n    .onTouchesMove((evt, state) => {\n      const isShortContent = scrollViewProgress.value === 1\n      // Make sure we only process gestures when at end of content\n      if (!isAtEnd.value && !isShortContent) {\n        state.fail()\n        return\n      }\n      const changedTouch = evt.changedTouches.at(0)\n      if (!initialTouchLocation.value || !changedTouch) {\n        state.fail()\n        return\n      }\n\n      const yDiff = changedTouch.y - initialTouchLocation.value.y\n      const isPullUpPanning = yDiff < 0\n\n      if (!isPullUpPanning && !gestureState.value) {\n        state.fail()\n        return\n      }\n      state.activate()\n      runOnJS(setDragState)(true)\n      gestureState.value = true\n    })\n    .onUpdate((event) => {\n      // Only process upward gestures when at the end of the content\n      if (event.translationY >= 0) {\n        return\n      }\n      // Apply a damping effect to make the pull feel more natural\n      const pullDistance = Math.min(Math.abs(event.translationY) * 0.7, THRESHOLD * 1.5) / 2\n      translateY.value = pullDistance\n\n      // Ratio used to determine when to deactivate the pulling threshold\n      const thresholdRatio = 0.95\n      // Provide haptic feedback when crossing the threshold\n      if (pullDistance > THRESHOLD * FEEDBACK_THRESHOLD && !feedbackGiven.value) {\n        runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Heavy)\n        runOnJS(setRefreshing)(true)\n        feedbackGiven.value = true\n      } else if (\n        pullDistance < THRESHOLD * FEEDBACK_THRESHOLD * thresholdRatio &&\n        feedbackGiven.value\n      ) {\n        runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Soft)\n        runOnJS(setRefreshing)(false)\n        feedbackGiven.value = false\n      }\n    })\n    .onEnd(() => {\n      if (feedbackGiven.value) {\n        if (onRefresh) {\n          runOnJS(onRefresh)()\n        }\n      } else {\n        // Not enough pull or not at the end, reset with a nice spring animation\n        translateY.value = withSpring(0, {\n          damping: 16,\n          mass: 1,\n          stiffness: 200,\n        })\n      }\n      gestureState.value = false\n      feedbackGiven.value = false\n      runOnJS(setDragState)(false)\n      if (refreshing) {\n        runOnJS(setRefreshing)(false)\n      }\n    })\n\n  // Track whether the scroll view is at the end\n  const lastScrollY = useRef(0)\n  const handleScroll = useCallback(\n    (event: ReanimatedScrollEvent) => {\n      const { contentOffset, contentSize, layoutMeasurement } = event\n      if (Math.abs(contentOffset.y - lastScrollY.current) < 5) {\n        return\n      }\n      lastScrollY.current = contentOffset.y\n      // Check if we're near the bottom of the scroll view with a slightly larger buffer\n      const isEnd =\n        contentOffset.y >= contentSize.height - layoutMeasurement.height - 20 &&\n        contentSize.height > layoutMeasurement.height\n      if (isEnd !== isAtEnd.value) {\n        isAtEnd.value = isEnd\n      }\n    },\n    [isAtEnd],\n  )\n\n  if (!enabled) {\n    // Return empty implementation for non-Android platforms\n    return {\n      scrollViewEventHandlers: {},\n      pullUpViewProps: {\n        active: false,\n        hide: true,\n        translateY,\n      } satisfies UsePullUpToNextReturn[\"pullUpViewProps\"],\n      EntryPullUpToNext: () => null,\n      GestureWrapper,\n      gestureWrapperProps: {\n        enabled: false,\n      },\n    }\n  }\n\n  return {\n    scrollViewEventHandlers: {\n      onScroll: handleScroll,\n    },\n    pullUpViewProps: {\n      active: refreshing,\n      hide: !dragState,\n      translateY,\n    },\n\n    EntryPullUpToNext: PullUpIndicatorAndroid,\n    GestureWrapper,\n    gestureWrapperProps: {\n      gesture: panGesture,\n      enabled,\n    },\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.tsx",
    "content": "import * as Haptics from \"expo-haptics\"\nimport { useCallback, useRef, useState } from \"react\"\nimport type { NativeScrollEvent, NativeSyntheticEvent } from \"react-native\"\nimport { useSharedValue } from \"react-native-reanimated\"\nimport type { ReanimatedScrollEvent } from \"react-native-reanimated/lib/typescript/hook/commonTypes\"\n\nimport { PullUpIndicatorIos } from \"./PullUpIndicatorIos\"\nimport type { UsePullUpToNextProps, UsePullUpToNextReturn } from \"./types\"\n\n// eslint-disable-next-line react-refresh/only-export-components\nconst EmptyGestureWrapper: UsePullUpToNextReturn[\"GestureWrapper\"] = ({\n  children,\n}: {\n  children?: React.ReactNode\n}) => children\n\nexport const usePullUpToNext = ({\n  enabled = true,\n  onRefresh,\n  progressViewOffset = 70,\n}: UsePullUpToNextProps = {}): UsePullUpToNextReturn => {\n  const dragging = useRef(false)\n  const isOverThreshold = useRef(false)\n  const [dragState, setDragState] = useState(false)\n  const [refreshing, setRefreshing] = useState(false)\n  const onScroll = useCallback(\n    (e: ReanimatedScrollEvent) => {\n      if (!dragging.current) return\n      const overOffset = e.contentOffset.y - e.contentSize.height + e.layoutMeasurement.height\n\n      // Ratio used to determine when to deactivate the pulling threshold\n      const thresholdRatio = 0.95\n      if (overOffset > progressViewOffset) {\n        if (!isOverThreshold.current && onRefresh) {\n          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)\n        }\n        isOverThreshold.current = true\n        setRefreshing(true)\n      } else if (overOffset < progressViewOffset * thresholdRatio) {\n        if (isOverThreshold.current && onRefresh) {\n          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Soft)\n        }\n        isOverThreshold.current = false\n        setRefreshing(false)\n      }\n      return\n    },\n    [dragging, onRefresh, progressViewOffset],\n  )\n  const onScrollBeginDrag = useCallback(\n    (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n      const beginOffset =\n        e.nativeEvent.contentOffset.y -\n        e.nativeEvent.contentSize.height +\n        e.nativeEvent.layoutMeasurement.height\n      if (beginOffset < -50) {\n        // Maybe user is pulling down fast for overview\n        return\n      }\n      dragging.current = true\n      setDragState(true)\n    },\n    [dragging],\n  )\n  const onScrollEndDrag = useCallback(\n    (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n      dragging.current = false\n      setDragState(false)\n      const velocity = event.nativeEvent.velocity?.y || 0\n      if (isOverThreshold.current && velocity < 3) {\n        onRefresh?.()\n      }\n      isOverThreshold.current = false\n      setRefreshing(false)\n    },\n    [dragging, onRefresh],\n  )\n  const translateY = useSharedValue(0)\n  if (!enabled) {\n    return {\n      scrollViewEventHandlers: {},\n      pullUpViewProps: {\n        active: false,\n        hide: dragState,\n        translateY,\n      } satisfies UsePullUpToNextReturn[\"pullUpViewProps\"],\n      EntryPullUpToNext: () => null,\n      GestureWrapper: EmptyGestureWrapper,\n      gestureWrapperProps: {\n        enabled: false,\n      },\n    }\n  }\n  return {\n    scrollViewEventHandlers: {\n      onScroll,\n      onScrollBeginDrag,\n      onScrollEndDrag,\n    },\n    pullUpViewProps: {\n      active: refreshing,\n      hide: !dragState,\n      translateY,\n    } satisfies UsePullUpToNextReturn[\"pullUpViewProps\"],\n    EntryPullUpToNext: PullUpIndicatorIos,\n    GestureWrapper: EmptyGestureWrapper,\n    gestureWrapperProps: {\n      enabled: false,\n    },\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { UserRole } from \"@follow/constants\"\nimport { usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { FlashListRef, ListRenderItemInfo } from \"@shopify/flash-list\"\nimport type { ElementRef } from \"react\"\nimport { useCallback, useImperativeHandle, useMemo, useRef } from \"react\"\nimport { View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { useBottomTabBarHeight } from \"@/src/components/layouts/tabbar/hooks\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { useHeaderHeight } from \"@/src/modules/screen/hooks/useHeaderHeight\"\n\nimport { useEntries } from \"../screen/atoms\"\nimport { TimelineSelectorList } from \"../screen/TimelineSelectorList\"\nimport { EntryListFooter } from \"./EntryListFooter\"\nimport { useOnViewableItemsChanged } from \"./hooks\"\nimport { EntryNormalItem } from \"./templates/EntryNormalItem\"\nimport type { EntryExtraData } from \"./types\"\n\nconst ARTICLE_SKELETON_KEYS = [\n  \"article-skeleton-1\",\n  \"article-skeleton-2\",\n  \"article-skeleton-3\",\n  \"article-skeleton-4\",\n  \"article-skeleton-5\",\n  \"article-skeleton-6\",\n  \"article-skeleton-7\",\n] as const\n\nexport const EntryListContentArticle = ({\n  ref: forwardRef,\n  entryIds,\n  active,\n  view,\n}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & {\n  ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | null>\n}) => {\n  const extraData: EntryExtraData = useMemo(() => ({ entryIds }), [entryIds])\n  const readableItemStyle = useReadableContainerStyle(860, 16)\n\n  const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage, fetchedTime, isReady } =\n    useEntries({ viewId: view, active })\n\n  const renderItem = useCallback(\n    ({ item: id, extraData, index }: ListRenderItemInfo<string>) => (\n      <View style={readableItemStyle}>\n        <EntryNormalItem\n          entryId={id}\n          extraData={extraData as EntryExtraData}\n          view={view}\n          hasTopSeparator={index > 0}\n          testID={index === 0 ? \"timeline-entry-first\" : undefined}\n        />\n      </View>\n    ),\n    [readableItemStyle, view],\n  )\n\n  const ListFooterComponent = useMemo(\n    () => (hasNextPage ? <EntryItemSkeleton /> : <EntryListFooter fetchedTime={fetchedTime} />),\n    [hasNextPage, fetchedTime],\n  )\n\n  const ref = useRef<FlashListRef<any>>(null)\n\n  const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({\n    disabled: active === false || isFetching,\n  })\n\n  useImperativeHandle(forwardRef, () => ref.current!)\n\n  const translation = useGeneralSettingKey(\"translation\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const actionLanguage = useActionLanguage()\n  const userRole = useUserRole()\n  const translationPrefetchEnabled =\n    translation && (userRole == null || (userRole !== UserRole.Free && userRole !== UserRole.Trial))\n\n  usePrefetchEntryTranslation({\n    entryIds: active ? viewableItems.map((item) => item.key) : [],\n    language: actionLanguage,\n    enabled: translationPrefetchEnabled,\n    mode: translationMode,\n  })\n\n  const headerHeight = useHeaderHeight()\n  const tabBarHeight = useBottomTabBarHeight()\n\n  // Show loading skeleton when entries are not ready and no data yet\n  if (!isReady && (!entryIds || entryIds.length === 0)) {\n    return (\n      <View className=\"flex-1\" style={{ paddingTop: headerHeight, paddingBottom: tabBarHeight }}>\n        {ARTICLE_SKELETON_KEYS.map((key) => (\n          <View key={key} style={readableItemStyle}>\n            <EntryItemSkeleton />\n          </View>\n        ))}\n      </View>\n    )\n  }\n\n  return (\n    <TimelineSelectorList\n      ref={ref}\n      onRefresh={refetch}\n      isRefetching={isRefetching}\n      data={entryIds}\n      extraData={extraData}\n      keyExtractor={defaultKeyExtractor}\n      renderItem={renderItem}\n      onEndReached={fetchNextPage}\n      onScroll={onScroll}\n      onViewableItemsChanged={onViewableItemsChanged}\n      ListFooterComponent={ListFooterComponent}\n    />\n  )\n}\n\nconst defaultKeyExtractor = (id: string) => id\n\nexport function EntryItemSkeleton() {\n  return (\n    <View className=\"flex flex-row items-center bg-system-background p-4\">\n      <View className=\"flex flex-1 flex-col gap-2\">\n        <View className=\"flex flex-row gap-2\">\n          {/* Icon skeleton */}\n          <View className=\"size-4 animate-pulse rounded-full bg-system-fill\" />\n          <View className=\"h-4 w-1/4 animate-pulse rounded-md bg-system-fill\" />\n        </View>\n\n        {/* Title skeleton */}\n        <View className=\"h-4 w-3/4 animate-pulse rounded-md bg-system-fill\" />\n        {/* Description skeleton */}\n        <View className=\"w-full flex-1 animate-pulse rounded-md bg-system-fill\" />\n      </View>\n\n      {/* Image skeleton */}\n      <View className=\"ml-2 size-20 animate-pulse rounded-md bg-system-fill\" />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { UserRole } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { FlashListProps, FlashListRef } from \"@shopify/flash-list\"\nimport type { ElementRef } from \"react\"\nimport { useImperativeHandle, useRef } from \"react\"\nimport { StyleSheet, View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { useBottomTabBarHeight } from \"@/src/components/layouts/tabbar/hooks\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { useIsTabletLayout } from \"@/src/lib/responsive\"\nimport { useEntries } from \"@/src/modules/screen/atoms\"\nimport { useHeaderHeight } from \"@/src/modules/screen/hooks/useHeaderHeight\"\n\nimport { TimelineSelectorMasonryList } from \"../screen/TimelineSelectorList\"\nimport { GridEntryListFooter } from \"./EntryListFooter\"\nimport { useOnViewableItemsChanged } from \"./hooks\"\n// import type { MasonryItem } from \"./templates/EntryGridItem\"\nimport { EntryPictureItem } from \"./templates/EntryPictureItem\"\n\nconst PICTURE_SKELETON_COLUMN_KEYS = [\n  \"picture-skeleton-1\",\n  \"picture-skeleton-2\",\n  \"picture-skeleton-3\",\n  \"picture-skeleton-4\",\n] as const\n\nexport const EntryListContentPicture = ({\n  ref: forwardRef,\n  entryIds,\n  active,\n  view,\n  ...rest\n}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & Omit<\n  FlashListProps<string>,\n  \"data\" | \"renderItem\"\n> & { ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null> }) => {\n  const ref = useRef<FlashListRef<any>>(null)\n  const isTablet = useIsTabletLayout()\n\n  useImperativeHandle(forwardRef, () => ref.current!)\n  const { fetchNextPage, refetch, isRefetching, hasNextPage, isFetching, isReady } = useEntries({\n    viewId: view,\n    active,\n  })\n  const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({\n    disabled: active === false || isFetching,\n  })\n  const translation = useGeneralSettingKey(\"translation\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const actionLanguage = useActionLanguage()\n  const userRole = useUserRole()\n  const translationPrefetchEnabled =\n    translation && (userRole == null || (userRole !== UserRole.Free && userRole !== UserRole.Trial))\n  usePrefetchEntryTranslation({\n    entryIds: active ? viewableItems.map((item) => item.key) : [],\n    language: actionLanguage,\n    enabled: translationPrefetchEnabled,\n    mode: translationMode,\n  })\n\n  const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => {\n    return <EntryPictureItem id={item} />\n  }, [])\n\n  const headerHeight = useHeaderHeight()\n  const tabBarHeight = useBottomTabBarHeight()\n\n  // Show loading skeleton when entries are not ready and no data yet\n  if (!isReady && (!entryIds || entryIds.length === 0)) {\n    return (\n      <View\n        className=\"flex-1 px-2\"\n        style={{ paddingTop: headerHeight, paddingBottom: tabBarHeight }}\n      >\n        <View className=\"flex-row\">\n          <View className=\"mr-1 flex-1\">\n            {PICTURE_SKELETON_COLUMN_KEYS.map((key, index) => (\n              <EntryPictureItemSkeleton key={`left-${key}`} variantIndex={index} />\n            ))}\n          </View>\n          <View className=\"ml-1 flex-1\">\n            {PICTURE_SKELETON_COLUMN_KEYS.map((key, index) => (\n              <EntryPictureItemSkeleton\n                key={`right-${key}`}\n                variantIndex={index + PICTURE_SKELETON_COLUMN_KEYS.length}\n              />\n            ))}\n          </View>\n        </View>\n      </View>\n    )\n  }\n\n  return (\n    <TimelineSelectorMasonryList\n      ref={ref}\n      isRefetching={isRefetching}\n      data={entryIds}\n      renderItem={renderItem}\n      keyExtractor={defaultKeyExtractor}\n      onViewableItemsChanged={onViewableItemsChanged}\n      onScroll={onScroll}\n      onEndReached={fetchNextPage}\n      numColumns={isTablet ? 3 : 2}\n      contentContainerStyle={isTablet ? styles.tabletContentContainer : styles.contentContainer}\n      ListFooterComponent={\n        hasNextPage ? (\n          <View className=\"h-20 items-center justify-center\">\n            <PlatformActivityIndicator />\n          </View>\n        ) : (\n          <GridEntryListFooter />\n        )\n      }\n      {...rest}\n      onRefresh={refetch}\n    />\n  )\n}\n\nfunction EntryPictureItemSkeleton({ variantIndex }: { variantIndex: number }) {\n  const imageHeightStyle =\n    PICTURE_SKELETON_HEIGHT_STYLES[variantIndex % PICTURE_SKELETON_HEIGHT_STYLES.length]\n\n  return (\n    <View className=\"mx-1 mb-2\">\n      {/* Image placeholder */}\n      <View className=\"animate-pulse rounded-md bg-system-fill\" style={imageHeightStyle} />\n\n      {/* Footer content */}\n      <View className=\"gap-2 px-1 py-2\">\n        {/* Title lines */}\n        <View className=\"gap-1\">\n          <View\n            className=\"h-3 animate-pulse rounded-md bg-system-fill\"\n            style={styles.titleLinePrimary}\n          />\n          <View\n            className=\"h-3 animate-pulse rounded-md bg-system-fill\"\n            style={styles.titleLineSecondary}\n          />\n        </View>\n\n        {/* Feed info */}\n        <View className=\"flex-row items-center gap-1.5\">\n          <View className=\"size-3.5 animate-pulse rounded-full bg-system-fill\" />\n          <View\n            className=\"h-3 animate-pulse rounded-md bg-system-fill\"\n            style={styles.feedLinePrimary}\n          />\n          <View\n            className=\"h-3 animate-pulse rounded-md bg-system-fill\"\n            style={styles.feedLineSecondary}\n          />\n        </View>\n      </View>\n    </View>\n  )\n}\n\nconst styles = StyleSheet.create({\n  contentContainer: {\n    paddingHorizontal: 8,\n  },\n  tabletContentContainer: {\n    paddingHorizontal: 16,\n  },\n  skeletonHeight120: {\n    height: 120,\n  },\n  skeletonHeight160: {\n    height: 160,\n  },\n  skeletonHeight140: {\n    height: 140,\n  },\n  skeletonHeight180: {\n    height: 180,\n  },\n  skeletonHeight100: {\n    height: 100,\n  },\n  skeletonHeight200: {\n    height: 200,\n  },\n  titleLinePrimary: {\n    width: \"90%\",\n  },\n  titleLineSecondary: {\n    width: \"70%\",\n  },\n  feedLinePrimary: {\n    width: \"40%\",\n  },\n  feedLineSecondary: {\n    width: \"30%\",\n  },\n})\n\nconst PICTURE_SKELETON_HEIGHT_STYLES = [\n  styles.skeletonHeight120,\n  styles.skeletonHeight160,\n  styles.skeletonHeight140,\n  styles.skeletonHeight180,\n  styles.skeletonHeight100,\n  styles.skeletonHeight200,\n] as const\n\nconst defaultKeyExtractor = (item: string) => {\n  return item\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { UserRole } from \"@follow/constants\"\nimport { usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { FlashListRef, ListRenderItemInfo } from \"@shopify/flash-list\"\nimport type { ElementRef } from \"react\"\nimport { useCallback, useImperativeHandle, useMemo, useRef } from \"react\"\nimport { View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\n\nimport { useEntries } from \"../screen/atoms\"\nimport { TimelineSelectorList } from \"../screen/TimelineSelectorList\"\nimport { EntryListFooter } from \"./EntryListFooter\"\nimport { useOnViewableItemsChanged } from \"./hooks\"\nimport { ItemSeparatorFullWidth } from \"./ItemSeparator\"\nimport { EntrySocialItem } from \"./templates/EntrySocialItem\"\nimport type { EntryExtraData } from \"./types\"\n\nexport const EntryListContentSocial = ({\n  ref: forwardRef,\n  entryIds,\n  active,\n  view,\n}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & {\n  ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | null>\n}) => {\n  const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage, isReady } = useEntries({\n    viewId: view,\n    active,\n  })\n  const extraData: EntryExtraData = useMemo(() => ({ entryIds }), [entryIds])\n\n  const ref = useRef<FlashListRef<any>>(null)\n  useImperativeHandle(forwardRef, () => ref.current!)\n  // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback\n  const renderItem = useCallback(\n    ({ item: id, extraData }: ListRenderItemInfo<string>) => (\n      <EntrySocialItem entryId={id} extraData={extraData as EntryExtraData} />\n    ),\n    [],\n  )\n\n  const ListFooterComponent = useMemo(\n    () => (hasNextPage ? <EntryItemSkeleton /> : <EntryListFooter />),\n    [hasNextPage],\n  )\n\n  const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({\n    disabled: active === false || isFetching,\n  })\n\n  const translation = useGeneralSettingKey(\"translation\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const actionLanguage = useActionLanguage()\n  const userRole = useUserRole()\n  const translationPrefetchEnabled =\n    translation && (userRole == null || (userRole !== UserRole.Free && userRole !== UserRole.Trial))\n  usePrefetchEntryTranslation({\n    entryIds: active ? viewableItems.map((item) => item.key) : [],\n    language: actionLanguage,\n    enabled: translationPrefetchEnabled,\n    mode: translationMode,\n  })\n\n  // Show loading skeleton when entries are not ready and no data yet\n  if (!isReady && (!entryIds || entryIds.length === 0)) {\n    return (\n      <TimelineSelectorList\n        onRefresh={() => {}}\n        isRefetching={false}\n        data={Array.from({ length: 5 }).map((_, index) => `skeleton-${index}`)}\n        keyExtractor={(id) => id}\n        renderItem={EntryItemSkeleton}\n        ItemSeparatorComponent={ItemSeparatorFullWidth}\n      />\n    )\n  }\n\n  return (\n    <TimelineSelectorList\n      ref={ref}\n      onRefresh={() => {\n        refetch()\n      }}\n      isRefetching={isRefetching}\n      data={entryIds}\n      extraData={extraData}\n      keyExtractor={(id) => id}\n      renderItem={renderItem}\n      onEndReached={fetchNextPage}\n      onViewableItemsChanged={onViewableItemsChanged}\n      onScroll={onScroll}\n      ItemSeparatorComponent={ItemSeparatorFullWidth}\n      ListFooterComponent={ListFooterComponent}\n    />\n  )\n}\n\nexport function EntryItemSkeleton() {\n  return (\n    <View className=\"flex flex-col gap-2 p-4\">\n      {/* Header row with avatar, author, and date */}\n      <View className=\"flex flex-1 flex-row items-center gap-2\">\n        <View className=\"size-8 animate-pulse rounded-full bg-system-fill\" />\n        <View className=\"h-4 w-24 animate-pulse rounded-md bg-system-fill\" />\n        <View className=\"h-3 w-20 animate-pulse rounded-md bg-system-fill\" />\n      </View>\n\n      {/* Description area */}\n      <View className=\"ml-10 space-y-2\">\n        <View className=\"h-4 w-full animate-pulse rounded-md rounded-bl-none bg-system-fill\" />\n        <View className=\"h-4 w-3/4 animate-pulse rounded-md rounded-tl-none bg-system-fill\" />\n      </View>\n\n      {/* Media preview area */}\n      <View className=\"ml-10 flex flex-row gap-2\">\n        <View className=\"size-20 animate-pulse rounded-md bg-system-fill\" />\n        <View className=\"size-20 animate-pulse rounded-md bg-system-fill\" />\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { UserRole } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport type { FlashListProps, FlashListRef } from \"@shopify/flash-list\"\nimport type { ElementRef } from \"react\"\nimport { useImperativeHandle, useMemo, useRef } from \"react\"\nimport { StyleSheet, View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { useBottomTabBarHeight } from \"@/src/components/layouts/tabbar/hooks\"\nimport { useIsTabletLayout } from \"@/src/lib/responsive\"\nimport { useEntries } from \"@/src/modules/screen/atoms\"\nimport { useHeaderHeight } from \"@/src/modules/screen/hooks/useHeaderHeight\"\n\nimport { TimelineSelectorMasonryList } from \"../screen/TimelineSelectorList\"\nimport { GridEntryListFooter } from \"./EntryListFooter\"\nimport { useOnViewableItemsChanged } from \"./hooks\"\nimport { EntryVideoItem } from \"./templates/EntryVideoItem\"\n\nconst VIDEO_SKELETON_KEYS = [\"video-skeleton-1\", \"video-skeleton-2\", \"video-skeleton-3\"] as const\n\nexport const EntryListContentVideo = ({\n  ref: forwardRef,\n  entryIds,\n  active,\n  view,\n  ...rest\n}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & Omit<\n  FlashListProps<string>,\n  \"data\" | \"renderItem\"\n> & { ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null> }) => {\n  const ref = useRef<FlashListRef<any>>(null)\n  useImperativeHandle(forwardRef, () => ref.current!)\n  const isTablet = useIsTabletLayout()\n  const { fetchNextPage, refetch, isRefetching, isFetching, hasNextPage, isReady } = useEntries({\n    viewId: view,\n    active,\n  })\n  const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({\n    disabled: active === false || isFetching,\n  })\n\n  const translation = useGeneralSettingKey(\"translation\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const actionLanguage = useActionLanguage()\n  const userRole = useUserRole()\n  const translationPrefetchEnabled =\n    translation && (userRole == null || (userRole !== UserRole.Free && userRole !== UserRole.Trial))\n  usePrefetchEntryTranslation({\n    entryIds: active ? viewableItems.map((item) => item.key) : [],\n    language: actionLanguage,\n    enabled: translationPrefetchEnabled,\n    mode: translationMode,\n  })\n\n  const ListFooterComponent = useMemo(\n    () =>\n      hasNextPage ? (\n        <View className=\"flex flex-row justify-between\">\n          <EntryItemSkeleton />\n          <EntryItemSkeleton />\n        </View>\n      ) : (\n        <GridEntryListFooter />\n      ),\n    [hasNextPage],\n  )\n\n  const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => {\n    return <EntryVideoItem id={item} />\n  }, [])\n\n  const headerHeight = useHeaderHeight()\n  const tabBarHeight = useBottomTabBarHeight()\n\n  // Show loading skeleton when entries are not ready and no data yet\n  if (!isReady && (!entryIds || entryIds.length === 0)) {\n    return (\n      <View\n        className=\"flex-1 px-2\"\n        style={{ paddingTop: headerHeight, paddingBottom: tabBarHeight }}\n      >\n        <View className=\"flex-row\">\n          <View className=\"mr-1 flex-1\">\n            {VIDEO_SKELETON_KEYS.map((key) => (\n              <EntryVideoItemSkeleton key={`left-${key}`} />\n            ))}\n          </View>\n          <View className=\"ml-1 flex-1\">\n            {VIDEO_SKELETON_KEYS.map((key) => (\n              <EntryVideoItemSkeleton key={`right-${key}`} />\n            ))}\n          </View>\n        </View>\n      </View>\n    )\n  }\n\n  return (\n    <TimelineSelectorMasonryList\n      ref={ref}\n      isRefetching={isRefetching}\n      data={entryIds}\n      renderItem={renderItem}\n      keyExtractor={defaultKeyExtractor}\n      onViewableItemsChanged={onViewableItemsChanged}\n      onScroll={onScroll}\n      onEndReached={fetchNextPage}\n      numColumns={isTablet ? 3 : 2}\n      ListFooterComponent={ListFooterComponent}\n      {...rest}\n      onRefresh={refetch}\n      contentContainerStyle={isTablet ? styles.tabletContentContainer : styles.contentContainer}\n    />\n  )\n}\n\nconst styles = StyleSheet.create({\n  contentContainer: {\n    paddingHorizontal: 8,\n  },\n  tabletContentContainer: {\n    paddingHorizontal: 16,\n  },\n})\n\nconst defaultKeyExtractor = (item: string) => {\n  return item\n}\n\nfunction EntryVideoItemSkeleton() {\n  return (\n    <View className=\"m-1 overflow-hidden rounded-md\">\n      {/* Video thumbnail */}\n      <View\n        className=\"h-32 w-full animate-pulse rounded-md bg-system-fill\"\n        style={{ aspectRatio: 16 / 9 }}\n      />\n\n      {/* Description and footer */}\n      <View className=\"my-2 px-2\">\n        {/* Description */}\n        <View className=\"mb-1 h-4 w-full animate-pulse rounded-md bg-system-fill\" />\n        <View className=\"mb-3 h-4 w-3/4 animate-pulse rounded-md bg-system-fill\" />\n\n        {/* Footer with feed icon and metadata */}\n        <View className=\"flex-row items-center gap-1\">\n          <View className=\"size-4 animate-pulse rounded-full bg-system-fill\" />\n          <View className=\"h-3 w-24 animate-pulse rounded-md bg-system-fill\" />\n          <View className=\"h-3 w-20 animate-pulse rounded-md bg-system-fill\" />\n        </View>\n      </View>\n    </View>\n  )\n}\n\nexport function EntryItemSkeleton() {\n  return (\n    <View className=\"m-1 overflow-hidden rounded-md\">\n      {/* Video thumbnail */}\n      <View\n        className=\"h-32 w-full animate-pulse rounded-md bg-system-fill\"\n        style={{ aspectRatio: 16 / 9 }}\n      />\n\n      {/* Description and footer */}\n      <View className=\"my-2 px-2\">\n        {/* Description */}\n        <View className=\"mb-1 h-4 w-full animate-pulse rounded-md bg-system-fill\" />\n        <View className=\"mb-3 h-4 w-3/4 animate-pulse rounded-md bg-system-fill\" />\n\n        {/* Footer with feed icon and metadata */}\n        <View className=\"flex-row items-center gap-1\">\n          <View className=\"size-4 animate-pulse rounded-full bg-system-fill\" />\n          <View className=\"h-3 w-24 animate-pulse rounded-md bg-system-fill\" />\n          <View className=\"h-3 w-20 animate-pulse rounded-md bg-system-fill\" />\n        </View>\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListContext.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { createContext, use } from \"react\"\n\nexport const EntryListContextViewContext = createContext<FeedViewType>(null!)\n\nexport const useEntryListContextView = () => {\n  return use(EntryListContextViewContext)\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListEmpty.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CelebrateCuteReIcon } from \"@/src/icons/celebrate_cute_re\"\nimport { useColor } from \"@/src/theme/colors\"\n\nexport const EntryListEmpty = () => {\n  const { t } = useTranslation()\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const color = useColor(\"secondaryLabel\")\n  return (\n    <View className=\"flex-1 items-center justify-center gap-2\">\n      {unreadOnly ? (\n        <>\n          <CelebrateCuteReIcon height={30} width={30} color={color} />\n          <Text className=\"text-lg font-medium text-secondary-label\">\n            {t(\"entry_list.zero_unread\")}\n          </Text>\n        </>\n      ) : (\n        <Text className=\"text-secondary-label\">\n          {t(\"search.empty.no_results\", { ns: \"common\" })}\n        </Text>\n      )}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListFooter.tsx",
    "content": "import { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable } from \"react-native\"\n\nimport { getHideAllReadSubscriptions } from \"@/src/atoms/settings/general\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckCircleCuteReIcon } from \"@/src/icons/check_circle_cute_re\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { getFetchEntryPayload, useSelectedFeed, useSelectedView } from \"../screen/atoms\"\nimport { ItemSeparator } from \"./ItemSeparator\"\n\nexport const EntryListFooter = ({ fetchedTime }: { fetchedTime?: number }) => {\n  const { t } = useTranslation()\n  const selectedView = useSelectedView()\n  const selectedFeed = useSelectedFeed()\n  const labelColor = useColor(\"label\")\n  return (\n    <>\n      <ItemSeparator />\n      <Pressable\n        className=\"flex-row items-center justify-center gap-1.5 py-6 pl-6\"\n        onPress={() => {\n          if (typeof selectedView === \"number\") {\n            const payload = getFetchEntryPayload(selectedFeed, selectedView)\n            unreadSyncService.markBatchAsRead({\n              view: selectedView,\n              filter: payload,\n              time: fetchedTime\n                ? {\n                    insertedBefore: fetchedTime,\n                  }\n                : undefined,\n              excludePrivate: getHideAllReadSubscriptions(),\n            })\n          }\n        }}\n      >\n        <CheckCircleCuteReIcon height={16} width={16} color={labelColor} />\n        <Text className=\"ml-2 font-bold text-label\">\n          {t(\"operation.mark_all_as_read_which\", {\n            which: t(\"operation.mark_all_as_read_which_above\"),\n          })}\n        </Text>\n      </Pressable>\n    </>\n  )\n}\nexport const GridEntryListFooter = ({ fetchedTime }: { fetchedTime?: number }) => {\n  const { t } = useTranslation()\n  const selectedView = useSelectedView()\n  const selectedFeed = useSelectedFeed()\n  return (\n    <Pressable\n      className=\"flex-row items-center justify-center gap-1.5 py-6\"\n      onPress={() => {\n        if (typeof selectedView === \"number\") {\n          const payload = getFetchEntryPayload(selectedFeed, selectedView)\n          unreadSyncService.markBatchAsRead({\n            view: selectedView,\n            filter: payload,\n            time: fetchedTime\n              ? {\n                  insertedBefore: fetchedTime,\n                }\n              : undefined,\n            excludePrivate: getHideAllReadSubscriptions(),\n          })\n        }\n      }}\n    >\n      <CheckCircleCuteReIcon height={16} width={16} />\n      <Text className=\"font-bold text-label\">\n        {t(\"operation.mark_all_as_read_which\", {\n          which: t(\"operation.mark_all_as_read_which_above\"),\n        })}\n      </Text>\n    </Pressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/EntryListSelector.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport type { FlashListRef } from \"@shopify/flash-list\"\nimport type { RefObject } from \"react\"\nimport { useEffect, useRef } from \"react\"\n\nimport { useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { withErrorBoundary } from \"@/src/components/common/ErrorBoundary\"\nimport { NoLoginInfo } from \"@/src/components/common/NoLoginInfo\"\nimport { ListErrorView } from \"@/src/components/errors/ListErrorView\"\nimport { useRegisterNavigationScrollView } from \"@/src/components/layouts/tabbar/hooks\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { EntryListContentPicture } from \"@/src/modules/entry-list/EntryListContentPicture\"\nimport { EntryDetailScreen } from \"@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen\"\n\nimport { useEntries, useEntryListContext } from \"../screen/atoms\"\nimport { EntryListContentArticle } from \"./EntryListContentArticle\"\nimport { EntryListContentSocial } from \"./EntryListContentSocial\"\nimport { EntryListContentVideo } from \"./EntryListContentVideo\"\n\nconst NoLoginGuard = ({ children }: { children: React.ReactNode }) => {\n  const whoami = useWhoami()\n  const screenType = useEntryListContext().type\n\n  if (whoami || screenType !== \"subscriptions\") {\n    return children\n  }\n\n  return <NoLoginInfo target=\"subscriptions\" />\n}\n\ntype EntryListSelectorProps = {\n  entryIds: string[] | null\n  viewId: FeedViewType\n  active?: boolean\n}\n\nfunction EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSelectorProps) {\n  const ref = useRegisterNavigationScrollView<FlashListRef<any>>(active)\n\n  let ContentComponent:\n    | typeof EntryListContentSocial\n    | typeof EntryListContentPicture\n    | typeof EntryListContentVideo\n    | typeof EntryListContentArticle = EntryListContentArticle\n  switch (viewId) {\n    case FeedViewType.SocialMedia: {\n      ContentComponent = EntryListContentSocial\n      break\n    }\n    case FeedViewType.Pictures: {\n      ContentComponent = EntryListContentPicture\n      break\n    }\n    case FeedViewType.Videos: {\n      ContentComponent = EntryListContentVideo\n      break\n    }\n    case FeedViewType.Articles: {\n      ContentComponent = EntryListContentArticle\n      break\n    }\n  }\n\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  useEffect(() => {\n    ref?.current?.scrollToOffset({\n      offset: 0,\n      animated: false,\n    })\n  }, [unreadOnly, ref])\n\n  const { isReady } = useEntries({ viewId, active })\n  const hasResetAfterReadyRef = useRef(false)\n  useEffect(() => {\n    if (!active) return\n    if (!isReady) {\n      hasResetAfterReadyRef.current = false\n      return\n    }\n    if (!entryIds?.length) return\n    if (hasResetAfterReadyRef.current) return\n\n    const frameId = requestAnimationFrame(() => {\n      ref?.current?.scrollToOffset({\n        offset: 0,\n        animated: false,\n      })\n    })\n    hasResetAfterReadyRef.current = true\n\n    return () => {\n      cancelAnimationFrame(frameId)\n    }\n  }, [active, entryIds, isReady, ref, viewId])\n\n  useEffect(() => {\n    if (!active) return\n\n    const frameId = requestAnimationFrame(() => {\n      ref?.current?.scrollToOffset({\n        offset: 0,\n        animated: false,\n      })\n    })\n\n    return () => {\n      cancelAnimationFrame(frameId)\n    }\n  }, [active, ref, viewId])\n\n  useAutoScrollToEntryAfterPullUpToNext(ref, entryIds || [])\n\n  return <ContentComponent ref={ref} entryIds={entryIds} active={active} view={viewId} />\n}\n\nexport const EntryListSelector = withErrorBoundary(\n  ({ entryIds, viewId, active }: EntryListSelectorProps) => {\n    return (\n      <NoLoginGuard>\n        <EntryListSelectorImpl entryIds={entryIds} viewId={viewId} active={active} />\n      </NoLoginGuard>\n    )\n  },\n  ListErrorView,\n)\n\nconst useAutoScrollToEntryAfterPullUpToNext = (\n  ref: RefObject<FlashListRef<any> | null>,\n  entryIds: string[],\n) => {\n  const navigation = useNavigation()\n  useEffect(() => {\n    return navigation.on(\"screenChange\", (payload) => {\n      if (!payload.route) return\n      if (payload.type !== \"appear\") return\n      if (payload.route.Component !== EntryDetailScreen) return\n      if (payload.route.screenOptions?.stackAnimation !== \"fade_from_bottom\") return\n      const nextEntryId =\n        payload.route.props &&\n        typeof payload.route.props === \"object\" &&\n        \"entryId\" in payload.route.props &&\n        typeof payload.route.props.entryId === \"string\"\n          ? payload.route.props.entryId\n          : undefined\n      const idx = nextEntryId ? (entryIds?.indexOf(nextEntryId || \"\") ?? -1) : -1\n      if (idx === -1) return\n      ref?.current?.scrollToIndex({\n        index: idx,\n        animated: false,\n        viewOffset: 70,\n      })\n    })\n  }, [entryIds, navigation, ref])\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/ItemSeparator.tsx",
    "content": "import { View } from \"react-native\"\n\nexport const ItemSeparator = () => {\n  return (\n    <View className=\"bg-system-background\">\n      <View className=\"ml-4 h-px bg-opaque-separator/70\" collapsable={false} />\n    </View>\n  )\n}\n\nexport const ItemSeparatorFullWidth = () => {\n  return (\n    <View className=\"bg-system-background\">\n      <View className=\"h-px w-full bg-opaque-separator/70\" collapsable={false} />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/hooks.ts",
    "content": "import { debouncedFetchEntryContentByStream } from \"@follow/store/entry/store\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport type { ViewToken } from \"@shopify/flash-list\"\nimport { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState } from \"react\"\nimport type { NativeScrollEvent, NativeSyntheticEvent } from \"react-native\"\n\nimport { useGeneralSettingKey } from \"@/src/atoms/settings/general\"\n\nconst defaultIdExtractor = (item: ViewToken<string>) => item.key\nexport function useOnViewableItemsChanged({\n  disabled,\n  idExtractor = defaultIdExtractor,\n  onScroll: onScrollProp,\n}: {\n  disabled?: boolean\n  idExtractor?: (item: ViewToken<string>) => string\n  onScroll?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void\n} = {}) {\n  const orientation = useRef<\"down\" | \"up\">(\"down\")\n  const lastOffset = useRef(0)\n  const isLoggedIn = useIsLoggedIn()\n\n  const markAsReadWhenScrolling = useGeneralSettingKey(\"scrollMarkUnread\")\n  const markAsReadWhenRendering = useGeneralSettingKey(\"renderMarkUnread\")\n  const [viewableItems, setViewableItems] = useState<ViewToken<string>[]>([])\n  const [lastViewableItems, setLastViewableItems] = useState<ViewToken<string>[] | null>()\n  const [lastRemovedItems, setLastRemovedItems] = useState<ViewToken<string>[] | null>(null)\n\n  const [stableIdExtractor] = useState(() => idExtractor)\n\n  const onViewableItemsChanged: (info: {\n    viewableItems: ViewToken<string>[]\n    changed: ViewToken<string>[]\n  }) => void = useNonReactiveCallback(({ viewableItems, changed }) => {\n    setViewableItems(viewableItems)\n\n    debouncedFetchEntryContentByStream(viewableItems.map((item) => stableIdExtractor(item)))\n    const removed = changed.filter((item) => !item.isViewable)\n\n    // Only when the scroll direction is down and the current offset is a positive number, is it marked as read.\n    // This can avoid misjudgment during the rebound of the pull-to-refresh (because the offset will change from negative to zero during the rebound).\n    if (orientation.current === \"down\" && lastOffset.current > 0) {\n      setLastViewableItems(viewableItems)\n      if (removed.length > 0) {\n        setLastRemovedItems((prev) => {\n          if (prev) {\n            return prev.concat(removed)\n          } else {\n            return removed\n          }\n        })\n      }\n    } else {\n      setLastRemovedItems(null)\n      setLastViewableItems(null)\n    }\n  })\n\n  useEffect(() => {\n    if (disabled) return\n\n    if (isLoggedIn && markAsReadWhenScrolling && lastRemovedItems) {\n      lastRemovedItems.forEach((item) => {\n        unreadSyncService.markEntryAsRead(stableIdExtractor(item)).then(() => {\n          setLastRemovedItems((prev) => {\n            if (prev) {\n              return prev.filter((prevItem) => prevItem.key !== item.key)\n            } else {\n              return null\n            }\n          })\n        })\n      })\n    }\n\n    if (isLoggedIn && markAsReadWhenRendering && lastViewableItems) {\n      lastViewableItems.forEach((item) => {\n        unreadSyncService.markEntryAsRead(stableIdExtractor(item))\n      })\n    }\n  }, [\n    disabled,\n    isLoggedIn,\n    lastRemovedItems,\n    lastViewableItems,\n    markAsReadWhenRendering,\n    markAsReadWhenScrolling,\n    stableIdExtractor,\n  ])\n\n  const onScroll = useCallback(\n    (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n      const currentOffset = e.nativeEvent.contentOffset.y\n      const currentOrientation = currentOffset > lastOffset.current ? \"down\" : \"up\"\n      orientation.current = currentOrientation\n      lastOffset.current = currentOffset\n      onScrollProp?.(e)\n    },\n    [onScrollProp],\n  )\n\n  return useMemo(\n    () => ({ onViewableItemsChanged, onScroll, viewableItems }),\n    [onScroll, onViewableItemsChanged, viewableItems],\n  )\n}\n\nfunction useNonReactiveCallback<T extends (...args: any[]) => any>(fn: T): T {\n  const ref = useRef(fn)\n  useInsertionEffect(() => {\n    ref.current = fn\n  }, [fn])\n  return useCallback(\n    (...args: any) => {\n      const latestFn = ref.current\n      return latestFn(...args)\n    },\n    [ref],\n  ) as unknown as T\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/index.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { memo, useMemo } from \"react\"\n\nimport { useEntries, useSelectedFeed, useSelectedView } from \"@/src/modules/screen/atoms\"\nimport { PagerList } from \"@/src/modules/screen/PagerList\"\nimport { TimelineHeader } from \"@/src/modules/screen/TimelineSelectorProvider\"\n\nimport { EntryListSelector } from \"./EntryListSelector\"\n\nconst renderViewItem = (view: FeedViewType, active: boolean) => (\n  <ViewEntryList key={`${view}-${active ? \"active\" : \"inactive\"}`} viewId={view} active={active} />\n)\nexport function EntryList() {\n  const selectedFeed = useSelectedFeed()\n  const view = useSelectedView() ?? FeedViewType.Articles\n  const Content = useMemo(() => {\n    if (!selectedFeed) return null\n    switch (selectedFeed.type) {\n      case \"view\": {\n        return <PagerList renderItem={renderViewItem} />\n      }\n      default: {\n        return <NormalEntryList viewId={view} />\n      }\n    }\n  }, [selectedFeed, view])\n  if (!Content) return null\n\n  return (\n    <>\n      <TimelineHeader />\n      {Content}\n    </>\n  )\n}\n\nconst ViewEntryList = memo(({ viewId, active }: { viewId: FeedViewType; active: boolean }) => {\n  const { entriesIds } = useEntries({ viewId, active })\n  return <EntryListSelector entryIds={entriesIds} viewId={viewId} active={active} />\n})\n\nconst NormalEntryList = memo(({ viewId }: { viewId: FeedViewType }) => {\n  const { entriesIds } = useEntries()\n  return <EntryListSelector entryIds={entriesIds} viewId={viewId} />\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { getEntry } from \"@follow/store/entry/getter\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { getInboxFrom } from \"@follow/store/entry/utils\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { cn, formatEstimatedMins, formatTimeToSeconds } from \"@follow/utils\"\nimport { useVideoPlayer, VideoView } from \"expo-video\"\nimport { memo, useCallback, useMemo, useRef, useState } from \"react\"\nimport type { ImageErrorEventData } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport { WebViewManager } from \"@/src/components/native/webview/webview-manager\"\nimport { RelativeDateTime } from \"@/src/components/ui/datetime/RelativeDateTime\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { PlayerAction } from \"@/src/components/ui/video/PlayerAction\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { isIOS } from \"@/src/lib/platform\"\nimport { player, useAudioPlayState } from \"@/src/lib/player\"\nimport { toast } from \"@/src/lib/toast\"\nimport { EntryDetailScreen } from \"@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen\"\n\nimport { EntryItemContextMenu } from \"../../context-menu/entry\"\nimport { EntryItemSkeleton } from \"../EntryListContentArticle\"\nimport type { EntryExtraData } from \"../types\"\nimport { EntryTranslation } from \"./EntryTranslation\"\n\nexport const EntryNormalItem = memo(\n  ({\n    entryId,\n    extraData,\n    view,\n    hasTopSeparator = false,\n    testID,\n  }: {\n    entryId: string\n    extraData: EntryExtraData\n    view: FeedViewType\n    hasTopSeparator?: boolean\n    testID?: string\n  }) => {\n    const entry = useEntry(entryId, (state) => ({\n      id: state.id,\n      feedId: state.feedId,\n      inboxHandle: state.inboxHandle,\n      authorUrl: state.authorUrl,\n      attachments: state.attachments,\n      read: state.read,\n      publishedAt: state.publishedAt,\n      translation: state.settings?.translation,\n      title: state.title,\n      description: state.description,\n    }))\n    const enableTranslation = useGeneralSettingKey(\"translation\")\n    const actionLanguage = useActionLanguage()\n    const translation = useEntryTranslation({\n      entryId,\n      language: actionLanguage,\n      enabled: enableTranslation,\n    })\n    const from = getInboxFrom(entry)\n    const feed = useFeedById(entry?.feedId as string)\n    const navigation = useNavigation()\n    const handlePress = useCallback(() => {\n      if (entry) {\n        const fullEntry = getEntry(entryId)\n        WebViewManager.setEntry(fullEntry)\n        tracker.navigateEntry({\n          feedId: entry.feedId!,\n          entryId: entry.id,\n        })\n        navigation.pushControllerView(EntryDetailScreen, {\n          entryId,\n          entryIds: extraData.entryIds ?? [],\n          view,\n        })\n      }\n    }, [entry, navigation, entryId, extraData.entryIds, view])\n    const audioOrVideo = entry?.attachments?.find(\n      (attachment) =>\n        attachment.mime_type?.startsWith(\"audio/\") || attachment.mime_type?.startsWith(\"video/\"),\n    )\n    const estimatedMins = useMemo(() => {\n      const durationInSeconds = formatTimeToSeconds(audioOrVideo?.duration_in_seconds)\n      return durationInSeconds && Math.floor(durationInSeconds / 60)\n    }, [audioOrVideo?.duration_in_seconds])\n    if (!entry) return <EntryItemSkeleton />\n    return (\n      <EntryItemContextMenu id={entryId} view={view}>\n        <ItemPressable\n          testID={testID ?? `entry-item-${entryId}`}\n          itemStyle={ItemPressableStyle.Plain}\n          className={cn(\n            view === FeedViewType.Notifications ? \"p-2\" : \"p-4\",\n            \"flex flex-row items-center pl-6\",\n          )}\n          onPress={handlePress}\n        >\n          {hasTopSeparator && (\n            <View\n              className=\"absolute left-4 right-0 top-0 h-px bg-opaque-separator/70\"\n              style={{ transform: [{ scaleY: 0.5 }] }}\n              pointerEvents=\"none\"\n            />\n          )}\n          {!entry.read && (\n            <View\n              className={cn(\n                \"absolute left-2 size-2 rounded-full bg-red\",\n                view === FeedViewType.Notifications ? \"top-[32]\" : \"top-[40]\",\n              )}\n            />\n          )}\n          <View className=\"flex-1 space-y-2 self-start\">\n            <View className=\"mb-1 flex-row items-center gap-1.5 pr-2\">\n              <FeedIcon fallback feed={feed} size={view === FeedViewType.Notifications ? 14 : 16} />\n              <Text numberOfLines={1} className=\"shrink text-xs font-medium text-secondary-label\">\n                {feed?.title || from || \"Unknown feed\"}\n              </Text>\n              <Text className=\"text-xs font-medium text-tertiary-label\">·</Text>\n              {estimatedMins ? (\n                <>\n                  <Text className=\"text-xs font-medium text-secondary-label\">\n                    {formatEstimatedMins(estimatedMins)}\n                  </Text>\n                  <Text className=\"text-xs font-medium text-tertiary-label\">·</Text>\n                </>\n              ) : null}\n              <RelativeDateTime\n                date={entry.publishedAt}\n                className=\"text-xs font-medium text-tertiary-label\"\n              />\n            </View>\n            {!!entry.title && (\n              <EntryTranslation\n                numberOfLines={2}\n                className={cn(\n                  view === FeedViewType.Notifications ? \"text-sm\" : \"text-base\",\n                  \"font-semibold text-label\",\n                )}\n                source={entry.title}\n                target={translation?.title}\n                showTranslation={!!entry.translation}\n                inline\n              />\n            )}\n            {view !== FeedViewType.Notifications && !!entry.description && (\n              <EntryTranslation\n                numberOfLines={2}\n                className=\"my-0 text-subheadline text-secondary-label\"\n                source={entry.description}\n                target={translation?.description}\n                showTranslation={!!entry.translation}\n                inline\n              />\n            )}\n          </View>\n          {view !== FeedViewType.Notifications && <ThumbnailImage entryId={entryId} />}\n        </ItemPressable>\n      </EntryItemContextMenu>\n    )\n  },\n)\nEntryNormalItem.displayName = \"EntryNormalItem\"\nconst ThumbnailImage = ({ entryId }: { entryId: string }) => {\n  const entry = useEntry(entryId, (state) => ({\n    feedId: state.feedId,\n    media: state.media,\n    attachments: state.attachments,\n    title: state.title,\n  }))\n  const feed = useFeedById(entry?.feedId as string)\n  const thumbnailRatio = useUISettingKey(\"thumbnailRatio\")\n  const mediaModel = entry?.media?.find(\n    (media) => media.type === \"photo\" || (media.type === \"video\" && media.preview_image_url),\n  )\n  const image = mediaModel?.type === \"photo\" ? mediaModel?.url : null // mediaModel?.preview_image_url\n  const blurhash = mediaModel?.blurhash\n  const audio = entry?.attachments?.find((attachment) => attachment.mime_type?.startsWith(\"audio/\"))\n  const audioState = useAudioPlayState(audio?.url)\n  const video = mediaModel?.type === \"video\" ? mediaModel : null\n  const videoViewRef = useRef<null | VideoView>(null)\n  const videoPlayer = useVideoPlayer(video?.url ?? \"\")\n  const [showVideoNativeControlsForAndroid, setShowVideoNativeControlsForAndroid] = useState(false)\n  const handlePressPlay = useCallback(() => {\n    if (video) {\n      setShowVideoNativeControlsForAndroid(true)\n      // Ensure the nativeControls is ready before entering fullscreen for Android\n      setTimeout(() => {\n        videoViewRef.current?.enterFullscreen()\n      }, 0)\n      if (videoPlayer.playing) {\n        videoPlayer.pause()\n      } else {\n        videoPlayer.play()\n      }\n      return\n    }\n    if (!audio) return\n    if (audioState !== \"paused\") {\n      player.pause()\n      return\n    }\n    try {\n      player.play({\n        url: audio.url,\n        title: entry?.title,\n        artist: feed?.title,\n        artwork: image,\n      })\n    } catch (error) {\n      console.error(\"Error playing audio:\", error)\n      toast.error(\"Failed to play audio\")\n    }\n  }, [audio, audioState, entry?.title, feed?.title, image, video, videoPlayer])\n  const [imageError, setImageError] = useState(audio && !image)\n  const handleImageError = useCallback(() => {\n    setImageError(true)\n  }, [])\n  if (!image && !audio && !video) return null\n  const isSquare = thumbnailRatio === \"square\"\n  return (\n    <View\n      className={cn(\"relative ml-4 flex h-full w-24 justify-center overflow-hidden rounded-lg\")}\n    >\n      {image &&\n        !imageError &&\n        (isSquare ? (\n          <SquareImage image={image} blurhash={blurhash} onError={handleImageError} />\n        ) : (\n          <AspectRatioImage\n            blurhash={blurhash}\n            image={image}\n            height={mediaModel?.height}\n            width={mediaModel?.width}\n            onError={handleImageError}\n          />\n        ))}\n\n      {video && (\n        <View className=\"flex size-full items-center justify-center\">\n          <VideoView\n            ref={videoViewRef}\n            className={cn(\"overflow-hidden rounded-lg\", isSquare ? \"size-24\" : \"\")}\n            // eslint-disable-next-line react-native/no-inline-styles -- VideoView requires explicit width and height\n            style={{\n              width: \"100%\",\n              height: \"100%\",\n              aspectRatio: isSquare ? 1 : undefined,\n            }}\n            contentFit={isSquare ? \"cover\" : \"contain\"}\n            player={videoPlayer}\n            // The Android native controls will be shown when the video is paused\n            nativeControls={isIOS || showVideoNativeControlsForAndroid}\n            accessible={false}\n            allowsFullscreen={false}\n            allowsVideoFrameAnalysis={false}\n            onFullscreenExit={() => {\n              videoPlayer.pause()\n              setShowVideoNativeControlsForAndroid(false)\n            }}\n          />\n        </View>\n      )}\n\n      {/* Show feed icon if no image but audio is present */}\n      {imageError && <FeedIcon feed={feed} size={96} />}\n\n      {(video || audio) && <PlayerAction mediaState={audioState} onPress={handlePressPlay} />}\n    </View>\n  )\n}\nconst AspectRatioImage = ({\n  image,\n  blurhash,\n  height = 96,\n  width = 96,\n  onError,\n}: {\n  image: string\n  blurhash?: string\n  height?: number\n  width?: number\n  onError?: (event: ImageErrorEventData) => void\n}) => {\n  if (height === width || !height || !width) {\n    return <SquareImage image={image} blurhash={blurhash} onError={onError} />\n  }\n  // Calculate aspect ratio and determine dimensions\n  // Ensure the larger dimension is capped at 96px while maintaining aspect ratio\n\n  const aspect = height / width\n  let scaledWidth, scaledHeight\n  if (aspect > 1) {\n    // Image is taller than wide\n    scaledHeight = 96\n    scaledWidth = scaledHeight / aspect\n  } else {\n    // Image is wider than tall or square\n    scaledWidth = 96\n    scaledHeight = scaledWidth * aspect\n  }\n  return (\n    <View className=\"flex max-w-full items-center justify-center overflow-hidden rounded-lg bg-tertiary-system-background\">\n      <Image\n        proxy={{\n          width: 96,\n        }}\n        source={{\n          uri: image,\n        }}\n        style={{\n          width: scaledWidth,\n          height: scaledHeight,\n        }}\n        transition={100}\n        blurhash={blurhash}\n        contentFit=\"cover\"\n        hideOnError\n        onError={onError}\n      />\n    </View>\n  )\n}\nconst SquareImage = ({\n  image,\n  blurhash,\n  onError,\n}: {\n  image: string\n  blurhash?: string\n  onError?: (event: ImageErrorEventData) => void\n}) => {\n  return (\n    <View className=\"size-24 overflow-hidden rounded-lg\">\n      <Image\n        proxy={{\n          width: 96,\n          height: 96,\n        }}\n        className=\"size-24\"\n        transition={100}\n        source={{\n          uri: image,\n        }}\n        blurhash={blurhash}\n        onError={onError}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport type { MediaModel } from \"@follow/database/schemas/types\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { getFeedById } from \"@follow/store/feed/getter\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { uniqBy } from \"es-toolkit/compat\"\nimport type { ImageSource } from \"expo-image\"\nimport type { Ref } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport { measure, runOnJS, runOnUI, useAnimatedRef } from \"react-native-reanimated\"\n\nimport { MediaCarousel } from \"@/src/components/ui/carousel/MediaCarousel\"\nimport { useLightboxControls } from \"@/src/components/ui/lightbox/lightboxState\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport function EntryPictureItem({ id }: { id: string }) {\n  const { t } = useTranslation()\n  const { openLightbox } = useLightboxControls()\n  const isLoggedIn = useIsLoggedIn()\n  const aviRef = useAnimatedRef<View>()\n  const item = useEntry(id, (state) => ({\n    media: state.media,\n    feedId: state.feedId,\n    publishedAt: state.publishedAt,\n    author: state.author,\n  }))\n  if (!item || !item.media) {\n    return null\n  }\n  const hasMedia = item.media.length > 0\n  if (!hasMedia) {\n    return (\n      <View\n        className=\"w-full items-center justify-center\"\n        style={{\n          aspectRatio: 16 / 9,\n        }}\n      >\n        <Text className=\"text-center text-label\">{t(\"entry_content.no_content\")}</Text>\n      </View>\n    )\n  }\n  return (\n    <View className=\"m-1\">\n      <MediaItems\n        ref={aviRef}\n        media={item.media}\n        entryId={id}\n        onPreview={(index, placeholder) => {\n          const feed = getFeedById(item.feedId!)\n          if (!feed) {\n            return\n          }\n          tracker.navigateEntry({\n            feedId: item.feedId!,\n            entryId: id,\n          })\n          runOnUI(() => {\n            \"worklet\"\n\n            const rect = measure(aviRef)\n            runOnJS(openLightbox)({\n              images: (item.media ?? []).map((media) => ({\n                uri: media.url,\n                thumbUri: placeholder ?? {\n                  uri: media.url,\n                },\n                thumbDimensions: null,\n                thumbRect: rect,\n                dimensions: rect\n                  ? {\n                      height: rect.height,\n                      width: rect.width,\n                    }\n                  : null,\n                type: \"image\",\n              })),\n              index,\n            })\n          })()\n\n          if (isLoggedIn) {\n            unreadSyncService.markEntryAsRead(id)\n          }\n        }}\n      />\n    </View>\n  )\n}\nEntryPictureItem.displayName = \"EntryPictureItem\"\nconst MediaItems = ({\n  ref,\n  media,\n  entryId,\n  onPreview,\n  aspectRatio,\n}: {\n  ref?: Ref<View>\n  media: MediaModel[]\n  entryId: string\n  onPreview?: (index: number, placeholder: ImageSource | undefined) => void\n  aspectRatio?: number\n}) => {\n  const firstMedia = media[0]\n  const uniqMedia = useMemo(() => {\n    return uniqBy(media, \"url\")\n  }, [media])\n  if (!firstMedia) {\n    return null\n  }\n  const { height } = firstMedia\n  const { width } = firstMedia\n  const realAspectRatio = aspectRatio || (width && height ? width / height : 1)\n  return (\n    <MediaCarousel\n      ref={ref}\n      view={FeedViewType.Pictures}\n      entryId={entryId}\n      media={uniqMedia}\n      onPreview={onPreview}\n      aspectRatio={realAspectRatio}\n    />\n  )\n}\n\n// const EntryGridItemAccessory = ({ id }: { id: string }) => {\n//   const entry = useEntry(id)\n//   const feed = useFeed(entry?.feedId || \"\")\n//   const insets = useSafeAreaInsets()\n\n//   const opacityValue = useSharedValue(0)\n//   useEffect(() => {\n//     opacityValue.value = withTiming(1, { duration: 1000 })\n//   }, [opacityValue])\n//   if (!entry) {\n//     return null\n//   }\n\n//   return (\n//     <Animated.View style={{ opacity: opacityValue }} className=\"absolute inset-x-0 bottom-0\">\n//       <LinearGradient colors={[\"transparent\", \"#000\"]} locations={[0.1, 1]} className=\"flex-1\">\n//         <View className=\"flex-row items-center gap-2\">\n//           <View className=\"border-non-opaque-separator overflow-hidden rounded-full border\">\n//             <FeedIcon fallback feed={feed} size={40} />\n//           </View>\n//           <View>\n//             <Text className=\"text-label text-lg font-medium\">{entry.author}</Text>\n//             <RelativeDateTime className=\"text-secondary-label\" date={entry.publishedAt} />\n//           </View>\n//         </View>\n\n//         <ScrollView\n//           className=\"mt-2 max-h-48\"\n//           contentContainerStyle={{ paddingBottom: insets.bottom }}\n//         >\n//           <EntryContentWebView entry={entry} noMedia />\n//         </ScrollView>\n//       </LinearGradient>\n//     </Animated.View>\n//   )\n// }\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport type { MediaModel } from \"@follow/database/schemas/types\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport type { ImageSource } from \"expo-image\"\nimport { memo, useCallback } from \"react\"\nimport { Pressable, View } from \"react-native\"\nimport type { MeasuredDimensions } from \"react-native-reanimated\"\nimport Animated, { measure, runOnJS, runOnUI, useAnimatedRef } from \"react-native-reanimated\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { UserAvatar } from \"@/src/components/ui/avatar/UserAvatar\"\nimport { RelativeDateTime } from \"@/src/components/ui/datetime/RelativeDateTime\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { getAllSources } from \"@/src/components/ui/image/utils\"\nimport type { LightboxImageSource } from \"@/src/components/ui/lightbox/ImageViewing/@types\"\nimport { useLightboxControls } from \"@/src/components/ui/lightbox/lightboxState\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { NativePressable } from \"@/src/components/ui/pressable/NativePressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { VideoPlayer } from \"@/src/components/ui/video/VideoPlayer\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { EntryDetailScreen } from \"@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { EntryItemContextMenu } from \"../../context-menu/entry\"\nimport { EntryItemSkeleton } from \"../EntryListContentSocial\"\nimport type { EntryExtraData } from \"../types\"\nimport { EntryTranslation } from \"./EntryTranslation\"\n\nexport const EntrySocialItem = memo(\n  ({ entryId, extraData }: { entryId: string; extraData: EntryExtraData }) => {\n    const entry = useEntry(entryId, (state) => ({\n      feedId: state.feedId,\n      media: state.media,\n      description: state.description,\n      publishedAt: state.publishedAt,\n      read: state.read,\n      authorAvatar: state.authorAvatar,\n      author: state.author,\n      translation: state.settings?.translation,\n    }))\n    const enableTranslation = useGeneralSettingKey(\"translation\")\n    const actionLanguage = useActionLanguage()\n    const translation = useEntryTranslation({\n      entryId,\n      language: actionLanguage,\n      enabled: enableTranslation,\n    })\n    const { openLightbox } = useLightboxControls()\n    const feed = useFeedById(entry?.feedId || \"\")\n    const isLoggedIn = useIsLoggedIn()\n    const navigation = useNavigation()\n    const handlePress = useCallback(() => {\n      if (isLoggedIn) {\n        unreadSyncService.markEntryAsRead(entryId)\n      }\n      tracker.navigateEntry({\n        feedId: entry?.feedId ?? \"\",\n        entryId,\n      })\n      navigation.pushControllerView(EntryDetailScreen, {\n        entryId,\n        entryIds: extraData.entryIds ?? [],\n        view: FeedViewType.SocialMedia,\n      })\n    }, [entry?.feedId, entryId, extraData.entryIds, isLoggedIn, navigation])\n    const autoExpandLongSocialMedia = useGeneralSettingKey(\"autoExpandLongSocialMedia\")\n    const navigationToFeedEntryList = useCallback(() => {\n      if (!entry?.feedId) return\n      navigation.pushControllerView(FeedScreen, {\n        feedId: entry.feedId,\n      })\n    }, [entry?.feedId, navigation])\n    const onPreviewImage = useCallback(\n      (index: number, rect: MeasuredDimensions | null, placeholder: ImageSource | undefined) => {\n        \"worklet\"\n\n        runOnJS(openLightbox)({\n          images: (entry?.media ?? [])\n            .map((mediaItem) => {\n              const imageUrl =\n                mediaItem.type === \"video\"\n                  ? mediaItem.preview_image_url\n                  : mediaItem.type === \"photo\"\n                    ? mediaItem.url\n                    : undefined\n              return {\n                uri: imageUrl ?? \"\",\n                dimensions: {\n                  width: mediaItem.width ?? 0,\n                  height: mediaItem.height ?? 0,\n                },\n                thumbUri: placeholder ?? {\n                  uri: imageUrl,\n                },\n                thumbDimensions: null,\n                thumbRect: rect,\n                type: \"image\" as const,\n              } satisfies LightboxImageSource\n            })\n            .filter((i) => !!i.uri),\n          index,\n        })\n      },\n      [entry?.media, openLightbox],\n    )\n    if (!entry) return <EntryItemSkeleton />\n    const { description, publishedAt, media } = entry\n    return (\n      <EntryItemContextMenu id={entryId} view={FeedViewType.SocialMedia}>\n        <ItemPressable\n          itemStyle={ItemPressableStyle.Plain}\n          className=\"flex flex-col gap-2 p-4 pl-6\"\n          onPress={handlePress}\n        >\n          {!entry.read && (\n            <View className=\"absolute left-1.5 top-[25] size-2 rounded-full bg-red\" />\n          )}\n\n          <View className=\"flex flex-1 flex-row items-start gap-4\">\n            <NativePressable hitSlop={10} onPress={navigationToFeedEntryList}>\n              {entry.authorAvatar ? (\n                <UserAvatar\n                  preview={false}\n                  size={28}\n                  name={entry.author ?? \"\"}\n                  image={entry.authorAvatar}\n                />\n              ) : (\n                feed && <FeedIcon feed={feed} size={28} />\n              )}\n            </NativePressable>\n\n            <View className=\"flex-1 flex-row items-center gap-1.5\">\n              <NativePressable hitSlop={10} onPress={navigationToFeedEntryList}>\n                <Text numberOfLines={1} className=\"shrink text-sm font-semibold text-label\">\n                  {entry.author || feed?.title}\n                </Text>\n              </NativePressable>\n              <Text className=\"text-xs text-tertiary-label\">·</Text>\n              <RelativeDateTime date={publishedAt} className=\"text-xs text-tertiary-label\" />\n            </View>\n          </View>\n\n          <View className=\"relative -mt-4\">\n            <EntryTranslation\n              numberOfLines={autoExpandLongSocialMedia ? undefined : 7}\n              className=\"ml-12 text-[15px] leading-[22px] text-label\"\n              source={description}\n              target={translation?.description}\n              showTranslation={!!entry?.translation}\n            />\n          </View>\n\n          {media && media.length > 0 && (\n            <View className=\"ml-10 flex flex-row flex-wrap justify-between\">\n              <>\n                {media.map((mediaItem, index) => (\n                  <EntryMediaItem\n                    key={`${entryId}-${mediaItem.url}`}\n                    index={index}\n                    mediaItem={mediaItem}\n                    fullWidth={index === media.length - 1 && media.length % 2 === 1}\n                    entryId={entryId}\n                    onPreviewImage={onPreviewImage}\n                  />\n                ))}\n              </>\n            </View>\n          )}\n        </ItemPressable>\n      </EntryItemContextMenu>\n    )\n  },\n)\nEntrySocialItem.displayName = \"EntrySocialItem\"\ninterface EntryMediaItemProps {\n  index: number\n  entryId: string\n  mediaItem: MediaModel\n  fullWidth: boolean\n  onPreviewImage: (\n    index: number,\n    rect: MeasuredDimensions | null,\n    placeholder: ImageSource | undefined,\n  ) => void\n}\nconst EntryMediaItem = memo(\n  ({ mediaItem, index, fullWidth, entryId, onPreviewImage }: EntryMediaItemProps) => {\n    const aviRef = useAnimatedRef<View>()\n    const imageUrl =\n      mediaItem.type === \"video\"\n        ? mediaItem.preview_image_url\n        : mediaItem.type === \"photo\"\n          ? mediaItem.url\n          : undefined\n    if (!imageUrl) return null\n    const proxy = {\n      width: fullWidth ? 400 : 200,\n    }\n    const ImageItem = (\n      <Animated.View ref={aviRef} collapsable={false}>\n        <NativePressable\n          onPress={() => {\n            const [placeholder] = getAllSources(\n              {\n                uri: imageUrl,\n              },\n              proxy,\n            )\n            runOnUI(() => {\n              \"worklet\"\n\n              const rect = measure(aviRef)\n              onPreviewImage(index, rect, {\n                blurhash: mediaItem.blurhash,\n                ...placeholder,\n              })\n            })()\n          }}\n        >\n          <Image\n            proxy={proxy}\n            source={{\n              uri: imageUrl,\n            }}\n            blurhash={mediaItem.blurhash}\n            className=\"w-full rounded-lg border border-secondary-system-background\"\n            aspectRatio={\n              fullWidth && mediaItem.width && mediaItem.height\n                ? mediaItem.width / mediaItem.height\n                : 1\n            }\n          />\n        </NativePressable>\n      </Animated.View>\n    )\n    if (mediaItem.type === \"video\") {\n      return (\n        <View key={`${entryId}-${mediaItem.url}`} className=\"w-full\">\n          <VideoPlayer\n            source={{\n              uri: mediaItem.url,\n            }}\n            height={mediaItem.height}\n            width={mediaItem.width}\n            placeholder={ImageItem}\n            view={FeedViewType.SocialMedia}\n          />\n        </View>\n      )\n    }\n    return <Pressable className={fullWidth ? \"w-full\" : \"w-1/2 p-0.5\"}>{ImageItem}</Pressable>\n  },\n)\nEntryMediaItem.displayName = \"EntryMediaItem\"\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/templates/EntryTranslation.tsx",
    "content": "import { useMemo } from \"react\"\nimport type { TextProps } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const EntryTranslation = ({\n  source,\n  target,\n  className,\n  inline,\n  showTranslation,\n  bilingual,\n  ...props\n}: {\n  source?: string | null\n  target?: string | null\n  className?: string\n  inline?: boolean\n  showTranslation?: boolean\n  bilingual?: boolean\n} & TextProps) => {\n  const bilingualFinal = useGeneralSettingKey(\"translationMode\") === \"bilingual\" || bilingual\n  const nextSource = useMemo(() => {\n    if (!source) {\n      return \"\"\n    }\n    return source.trim()\n  }, [source])\n  const nextTarget = useMemo(() => {\n    if (!target || nextSource.replaceAll(/\\s/g, \"\") === target.replaceAll(/\\s/g, \"\")) {\n      return \"\"\n    }\n    return target.trim()\n  }, [nextSource, target])\n  if (!bilingualFinal) {\n    return (\n      <Text {...props} className={className}>\n        {nextTarget || nextSource}\n      </Text>\n    )\n  }\n  if (inline) {\n    return (\n      <Text {...props} className={className}>\n        {`${nextTarget ? `${nextTarget}   ⇋   ` : \"\"}${nextSource}`}\n      </Text>\n    )\n  }\n  return (\n    <View>\n      <Text {...props} className={className}>\n        {nextSource}\n      </Text>\n      {nextTarget && (\n        <Text {...props} className={className}>\n          {nextTarget}\n        </Text>\n      )}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useEntry } from \"@follow/store/entry/hooks\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { formatDuration, transformVideoUrl } from \"@follow/utils\"\nimport { memo, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Linking, View } from \"react-native\"\n\nimport { getGeneralSettings } from \"@/src/atoms/settings/general\"\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { openLink } from \"@/src/lib/native\"\nimport { toast } from \"@/src/lib/toast\"\n\nimport { VideoContextMenu } from \"../../context-menu/video\"\nimport { EntryGridFooter } from \"../../entry-content/EntryGridFooter\"\n\nexport const EntryVideoItem = memo(({ id }: { id: string }) => {\n  const { t } = useTranslation()\n  const isLoggedIn = useIsLoggedIn()\n  const item = useEntry(id, (state) => ({\n    attachments: state.attachments,\n    media: state.media,\n    feedId: state.feedId,\n    url: state.url,\n  }))\n  const duration = useMemo(() => {\n    const seconds = item?.attachments?.find(\n      (attachment) => attachment.duration_in_seconds,\n    )?.duration_in_seconds\n    if (seconds) {\n      return formatDuration(Number.parseInt(seconds.toString()))\n    }\n    return 0\n  }, [item?.attachments])\n  if (!item) {\n    return null\n  }\n  const imageUrl = item.media?.at(0)?.url\n  return (\n    <View className=\"m-1\">\n      <VideoContextMenu entryId={id}>\n        <ItemPressable\n          itemStyle={ItemPressableStyle.Plain}\n          onPress={() => {\n            if (isLoggedIn) {\n              unreadSyncService.markEntryAsRead(id)\n            }\n            tracker.navigateEntry({\n              feedId: item.feedId!,\n              entryId: id,\n            })\n            if (!item.url) {\n              toast.error(t(\"entry_content.no_video_url\"))\n              return\n            }\n            openVideo(item.url)\n          }}\n        >\n          <View className=\"relative\">\n            {imageUrl ? (\n              <Image\n                source={{\n                  uri: imageUrl,\n                }}\n                aspectRatio={16 / 9}\n                className=\"w-full rounded-lg\"\n                proxy={{\n                  width: 200,\n                }}\n              />\n            ) : (\n              <FallbackMedia text={t(\"entry_content.no_content\")} />\n            )}\n            {!!duration && (\n              <Text className=\"absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white\">\n                {duration}\n              </Text>\n            )}\n          </View>\n          <EntryGridFooter entryId={id} view={FeedViewType.Videos} />\n        </ItemPressable>\n      </VideoContextMenu>\n    </View>\n  )\n})\nEntryVideoItem.displayName = \"EntryVideoItem\"\nconst FallbackMedia = ({ text }: { text: string }) => (\n  <View\n    className=\"w-full items-center justify-center rounded-lg bg-tertiary-system-fill\"\n    style={{\n      aspectRatio: 16 / 9,\n    }}\n  >\n    <Text className=\"text-center text-label\">{text}</Text>\n  </View>\n)\nconst parseSchemeLink = (url: string) => {\n  let urlObject: URL\n  try {\n    urlObject = new URL(url)\n  } catch {\n    return null\n  }\n  switch (urlObject.hostname) {\n    case \"www.bilibili.com\": {\n      // bilibili://video/{av}or{bv}\n      const bvid = urlObject.pathname.match(/video\\/(BV\\w+)/)?.[1]\n      return bvid ? `bilibili://video/${bvid}` : null\n    }\n    case \"t.bilibili.com\": {\n      const id = urlObject.pathname.match(/\\d+/)?.[0]\n      return id ? `bilibili://following/detail/${id}` : null\n    }\n    case \"www.youtube.com\": {\n      // youtube://watch?v=xxx\n      const videoId = urlObject.searchParams.get(\"v\")\n      return videoId ? `youtube://watch?v=${videoId}` : null\n    }\n    default: {\n      return null\n    }\n  }\n}\nconst openVideo = async (url: string) => {\n  const { openLinksInExternalApp } = getGeneralSettings()\n  if (openLinksInExternalApp) {\n    const schemeLink = parseSchemeLink(url)\n    try {\n      if (schemeLink) {\n        await Linking.openURL(schemeLink)\n        return\n      }\n    } catch {\n      // Ignore error\n    }\n  }\n\n  // Fallback to opening in in-app browser\n  const formattedUrl = openLinksInExternalApp\n    ? url\n    : transformVideoUrl({\n        url,\n      }) || url\n  openLink(formattedUrl)\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/entry-list/types.ts",
    "content": "export type EntryExtraData = {\n  entryIds: string[] | null\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/feed/FollowFeed.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useFeedById, usePrefetchFeed, usePrefetchFeedByUrl } from \"@follow/store/feed/hooks\"\nimport { useSubscriptionByFeedId } from \"@follow/store/subscription/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport type { SubscriptionForm } from \"@follow/store/subscription/types\"\nimport { formatNumber } from \"@follow/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useEffect, useMemo, useState } from \"react\"\nimport { Controller, useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { Alert, View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { z } from \"zod\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { RelativeDateTime } from \"@/src/components/ui/datetime/RelativeDateTime\"\nimport { FormProvider } from \"@/src/components/ui/form/FormProvider\"\nimport { FormLabel } from \"@/src/components/ui/form/Label\"\nimport { FormSwitch } from \"@/src/components/ui/form/Switch\"\nimport { TextField } from \"@/src/components/ui/form/TextField\"\nimport { GroupedInsetListCard } from \"@/src/components/ui/grouped/GroupedList\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { SafeAlertCuteReIcon } from \"@/src/icons/safe_alert_cute_re\"\nimport { SafetyCertificateCuteReIcon } from \"@/src/icons/safety_certificate_cute_re\"\nimport { User3CuteReIcon } from \"@/src/icons/user_3_cute_re\"\nimport { toastFetchError } from \"@/src/lib/error-parser\"\nimport { useCanDismiss, useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useSetModalScreenOptions } from \"@/src/lib/navigation/ScreenOptionsContext\"\nimport { toast } from \"@/src/lib/toast\"\nimport { FeedSummary } from \"@/src/modules/discover/FeedSummary\"\nimport { FeedViewSelector } from \"@/src/modules/feed/view-selector\"\nimport { useColor } from \"@/src/theme/colors\"\n\nconst formSchema = z.object({\n  view: z.coerce.number(),\n  category: z.string().nullable().optional(),\n  isPrivate: z.boolean().optional(),\n  hideFromTimeline: z.boolean().optional(),\n  title: z.string().optional(),\n})\nexport function FollowFeed(props: { id: string }) {\n  const { id } = props\n  const feed = useFeedById(id as string)\n  const { data } = usePrefetchFeed(id as string)\n  if (!feed) {\n    return (\n      <View className=\"mt-24 flex-1 flex-row items-start justify-center\">\n        <PlatformActivityIndicator />\n      </View>\n    )\n  }\n  return <FollowImpl feedId={id} defaultView={data?.analytics?.view ?? undefined} />\n}\nexport function FollowUrl(props: { url: string }) {\n  const { url } = props\n  const { isLoading, data, error } = usePrefetchFeedByUrl(url)\n  if (isLoading) {\n    return (\n      <View className=\"mt-24 flex-1 flex-row items-start justify-center\">\n        <PlatformActivityIndicator />\n      </View>\n    )\n  }\n  if (!data) {\n    return <Text className=\"text-label\">{error?.message}</Text>\n  }\n  return (\n    <FollowImpl\n      feedId={data.feed.id}\n      defaultView={data.responseData?.analytics?.view ?? undefined}\n    />\n  )\n}\nfunction FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) {\n  const { t } = useTranslation()\n  const { t: tCommon } = useTranslation(\"common\")\n  const textLabelColor = useColor(\"label\")\n  const { feedId: id, defaultView = FeedViewType.Articles } = props\n  const feed = useFeedById(id)\n  const subscription = useSubscriptionByFeedId(feed?.id)\n  const isSubscribed = !!subscription\n  const defaultFormValues = useMemo(() => {\n    return {\n      category: subscription?.category ?? undefined,\n      isPrivate: subscription?.isPrivate ?? undefined,\n      hideFromTimeline: subscription?.hideFromTimeline ?? undefined,\n      title: subscription?.title ?? undefined,\n      view: subscription?.view ?? defaultView,\n    }\n  }, [subscription, defaultView])\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: defaultFormValues,\n  })\n  useEffect(() => {\n    form.reset(defaultFormValues, {\n      keepDirtyValues: true,\n    })\n  }, [defaultFormValues, form])\n  const [isLoading, setIsLoading] = useState(false)\n  const navigate = useNavigation()\n  const canDismiss = useCanDismiss()\n  const submit = async () => {\n    if (isLoading) return\n    setIsLoading(true)\n    const values = form.getValues()\n    const body: SubscriptionForm = {\n      url: feed?.url,\n      view: values.view,\n      category: values.category ?? \"\",\n      isPrivate: values.isPrivate ?? false,\n      hideFromTimeline: values.hideFromTimeline,\n      title: values.title ?? \"\",\n      feedId: feed?.id,\n      listId: undefined,\n    }\n    try {\n      if (isSubscribed) {\n        await subscriptionSyncService.edit({\n          ...subscription,\n          ...body,\n        })\n      } else {\n        await subscriptionSyncService.subscribe(body)\n      }\n      toast.success(t(isSubscribed ? \"feed.follow.update_success\" : \"feed.follow.success\"))\n      if (canDismiss) {\n        navigate.dismiss()\n      } else {\n        navigate.back()\n      }\n    } catch (error) {\n      toastFetchError(error as Error)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  const handleUnfollow = () => {\n    if (!subscription?.feedId || isLoading) return\n\n    Alert.alert(t(\"feed.unfollow.confirm_title\"), t(\"feed.unfollow.confirm_description\"), [\n      {\n        text: tCommon(\"words.cancel\"),\n        style: \"cancel\",\n      },\n      {\n        text: t(\"operation.unfollow\"),\n        style: \"destructive\",\n        onPress: async () => {\n          try {\n            setIsLoading(true)\n            await subscriptionSyncService.unsubscribe(subscription.feedId)\n            toast.success(t(\"feed.unfollow.success\"))\n            if (canDismiss) {\n              navigate.dismiss()\n            } else {\n              navigate.back()\n            }\n          } catch (error) {\n            toastFetchError(error as Error)\n          } finally {\n            setIsLoading(false)\n          }\n        },\n      },\n    ])\n  }\n\n  const insets = useSafeAreaInsets()\n  const { isValid, isDirty } = form.formState\n  const setScreenOptions = useSetModalScreenOptions()\n  useEffect(() => {\n    setScreenOptions({\n      preventNativeDismiss: isDirty,\n    })\n  }, [isDirty, setScreenOptions])\n  if (!feed?.id) {\n    return <Text className=\"text-label\">{t(\"feed.not_found\", { id })}</Text>\n  }\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      contentViewClassName=\"gap-y-4 mt-2\"\n      contentContainerStyle={{\n        paddingBottom: insets.bottom,\n      }}\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={`${isSubscribed ? tCommon(\"words.edit\") : tCommon(\"words.follow\")} - ${feed?.title}`}\n          headerRight={() => (\n            <HeaderSubmitTextButton\n              isValid={isValid}\n              onPress={form.handleSubmit(submit)}\n              isLoading={isLoading}\n              label={isSubscribed ? tCommon(\"words.save\") : tCommon(\"words.follow\")}\n              testID=\"follow-submit\"\n            />\n          )}\n        />\n      }\n    >\n      {/* Group 1 */}\n      <GroupedInsetListCard>\n        <FeedSummary className=\"px-5 py-4\" feed={feed}>\n          <View className=\"ml-11 mt-2 flex-row items-center gap-3 opacity-60\">\n            <View className=\"flex-row items-center gap-1\">\n              <User3CuteReIcon color={textLabelColor} width={12} height={12} />\n              <Text className=\"text-sm text-text\">\n                {typeof feed.subscriptionCount === \"number\" ? (\n                  formatNumber(feed.subscriptionCount || 0)\n                ) : (\n                  <>?</>\n                )}{\" \"}\n                {tCommon(\"feed.follower\", {\n                  count: feed.subscriptionCount ?? 0,\n                })}\n              </Text>\n            </View>\n            {feed.updatesPerWeek ? (\n              <View className=\"flex-row items-center gap-1\">\n                <SafetyCertificateCuteReIcon color={textLabelColor} width={12} height={12} />\n                <Text className=\"text-sm text-text\">\n                  {tCommon(\"feed.entry_week\", {\n                    count: feed.updatesPerWeek,\n                  })}\n                </Text>\n              </View>\n            ) : feed.latestEntryPublishedAt ? (\n              <View className=\"flex-row items-center gap-1\">\n                <SafeAlertCuteReIcon color={textLabelColor} width={12} height={12} />\n                <Text className=\"text-sm text-text\">{tCommon(\"feed.updated_at\")}</Text>\n                <RelativeDateTime\n                  className=\"text-sm text-text\"\n                  date={feed.latestEntryPublishedAt}\n                />\n              </View>\n            ) : null}\n          </View>\n        </FeedSummary>\n      </GroupedInsetListCard>\n      {isSubscribed && (\n        <GroupedInsetListCard className=\"p-4\">\n          <View className=\"items-start\">\n            <Text\n              className=\"text-base font-medium text-red\"\n              testID=\"follow-unfollow\"\n              onPress={handleUnfollow}\n            >\n              {t(\"operation.unfollow\")}\n            </Text>\n          </View>\n        </GroupedInsetListCard>\n      )}\n      {/* Group 2 */}\n      <GroupedInsetListCard className=\"gap-y-4 p-4\">\n        <FormProvider form={form}>\n          <View>\n            <Controller\n              name=\"title\"\n              control={form.control}\n              render={({ field: { onChange, ref, value } }) => (\n                <TextField\n                  label={t(\"subscription_form.title\")}\n                  description={t(\"subscription_form.title_description\")}\n                  onChangeText={onChange}\n                  value={value}\n                  ref={ref}\n                />\n              )}\n            />\n          </View>\n\n          <View>\n            <Controller\n              name=\"category\"\n              control={form.control}\n              render={({ field: { onChange, ref, value } }) => (\n                <TextField\n                  label={t(\"subscription_form.category\")}\n                  description={t(\"subscription_form.category_description\")}\n                  onChangeText={onChange}\n                  value={value || \"\"}\n                  ref={ref}\n                />\n              )}\n            />\n          </View>\n\n          <View>\n            <Controller\n              name=\"isPrivate\"\n              control={form.control}\n              render={({ field: { onChange, value } }) => (\n                <FormSwitch\n                  size=\"sm\"\n                  value={value}\n                  label={t(\"subscription_form.private_follow\")}\n                  description={t(\"subscription_form.private_follow_description\")}\n                  onValueChange={onChange}\n                />\n              )}\n            />\n          </View>\n\n          <View>\n            <Controller\n              name=\"hideFromTimeline\"\n              control={form.control}\n              render={({ field: { onChange, value } }) => (\n                <FormSwitch\n                  size=\"sm\"\n                  value={value}\n                  label={t(\"subscription_form.hide_from_timeline\")}\n                  description={t(\"subscription_form.hide_from_timeline_description\")}\n                  onValueChange={onChange}\n                />\n              )}\n            />\n          </View>\n\n          <View className=\"-mx-3\">\n            <FormLabel className=\"mb-4 pl-4\" label={t(\"subscription_form.view\")} optional />\n\n            <Controller\n              name=\"view\"\n              control={form.control}\n              render={({ field: { onChange, value } }) => (\n                <FeedViewSelector value={value} onChange={onChange} />\n              )}\n            />\n          </View>\n        </FormProvider>\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/feed/view-selector.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\n\nimport { Grid } from \"@/src/components/ui/grid\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { views } from \"@/src/constants/views\"\nimport { useColor } from \"@/src/theme/colors\"\n\ninterface Props {\n  value: FeedViewType\n  onChange?: (value: FeedViewType) => void\n  className?: string\n  readOnly?: boolean\n}\nexport const FeedViewSelector = ({ value, onChange, className, readOnly }: Props) => {\n  const { t } = useTranslation(\"common\")\n  const secondaryLabelColor = useColor(\"secondaryLabel\")\n  return (\n    <Grid columns={views.length} gap={5} className={className}>\n      {views.map((view) => {\n        const isSelected = +value === +view.view\n        return (\n          <Pressable\n            key={view.name}\n            onPress={() => onChange?.(view.view)}\n            disabled={readOnly}\n            className={readOnly ? \"opacity-50\" : undefined}\n          >\n            <View className=\"flex-1 items-center\">\n              <view.icon\n                color={isSelected ? view.activeColor : secondaryLabelColor}\n                height={18}\n                width={18}\n              />\n              <Text\n                className={\"mt-1 whitespace-nowrap text-[8px] font-medium\"}\n                style={{\n                  color: isSelected ? view.activeColor : secondaryLabelColor,\n                }}\n              >\n                {t(view.name)}\n              </Text>\n            </View>\n          </Pressable>\n        )\n      })}\n    </Grid>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/list/FollowList.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport { listSyncServices } from \"@follow/store/list/store\"\nimport { useSubscriptionByListId } from \"@follow/store/subscription/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useEffect, useState } from \"react\"\nimport { Controller, useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { StyleSheet, View } from \"react-native\"\nimport { z } from \"zod\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { FormProvider } from \"@/src/components/ui/form/FormProvider\"\nimport { FormLabel } from \"@/src/components/ui/form/Label\"\nimport { FormSwitch } from \"@/src/components/ui/form/Switch\"\nimport { TextField } from \"@/src/components/ui/form/TextField\"\nimport { GroupedInsetListCard } from \"@/src/components/ui/grouped/GroupedList\"\nimport { IconWithFallback } from \"@/src/components/ui/icon/fallback-icon\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { toastFetchError } from \"@/src/lib/error-parser\"\nimport { useNavigation, useScreenIsInSheetModal } from \"@/src/lib/navigation/hooks\"\nimport { useSetModalScreenOptions } from \"@/src/lib/navigation/ScreenOptionsContext\"\nimport { toast } from \"@/src/lib/toast\"\n\nimport { FeedViewSelector } from \"../feed/view-selector\"\n\nexport const FollowList = (props: { id: string }) => {\n  const { id } = props\n  const list = useListById(id)\n  const { isLoading } = useQuery({\n    queryKey: [\"list\", id],\n    queryFn: () =>\n      listSyncServices.fetchListById({\n        id,\n      }),\n    enabled: !list,\n  })\n  if (isLoading) {\n    return (\n      <View className=\"mt-24 flex-1 flex-row items-start justify-center\">\n        <PlatformActivityIndicator />\n      </View>\n    )\n  }\n  return <Impl id={id} />\n}\nconst formSchema = z.object({\n  view: z.number(),\n  isPrivate: z.boolean(),\n  hideFromTimeline: z.boolean().optional(),\n  title: z.string().optional(),\n})\nconst Impl = (props: { id: string }) => {\n  const { t } = useTranslation()\n  const { t: tCommon } = useTranslation(\"common\")\n  const { id } = props\n  const list = useListById(id)\n  const subscription = useSubscriptionByListId(id)\n  const isSubscribed = !!subscription\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      view: list?.view ?? FeedViewType.Articles,\n      isPrivate: subscription?.isPrivate ?? false,\n      hideFromTimeline: subscription?.hideFromTimeline ?? undefined,\n      title: subscription?.title ?? undefined,\n    },\n  })\n  const { isValid, isDirty } = form.formState\n  const isModal = useScreenIsInSheetModal()\n  const navigation = useNavigation()\n  const [isLoading, setIsLoading] = useState(false)\n  const submit = async () => {\n    if (!list) return\n    if (isLoading) return\n    setIsLoading(true)\n    const payload = form.getValues()\n    try {\n      const body = {\n        listId: list.id,\n        view: list.view,\n        isPrivate: payload.isPrivate,\n        title: payload.title,\n        hideFromTimeline: payload.hideFromTimeline,\n        url: undefined,\n        category: undefined,\n        feedId: undefined,\n      }\n      if (isSubscribed) {\n        await subscriptionSyncService.edit({\n          ...subscription,\n          ...body,\n        })\n      } else {\n        await subscriptionSyncService.subscribe(body)\n      }\n      toast.success(isSubscribed ? \"List updated\" : \"List followed\")\n      if (isModal) {\n        navigation.dismiss()\n      } else {\n        navigation.back()\n      }\n    } catch (error) {\n      toastFetchError(error as Error)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n  const setModalOptions = useSetModalScreenOptions()\n  useEffect(() => {\n    setModalOptions({\n      gestureEnabled: !isDirty,\n    })\n  }, [isDirty, setModalOptions])\n  if (!list) {\n    return null\n  }\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      contentViewClassName=\"gap-y-4 mt-2\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={`${isSubscribed ? tCommon(\"words.edit\") : tCommon(\"words.follow\")} - ${list?.title}`}\n          headerRight={\n            <HeaderSubmitTextButton\n              isValid={isValid}\n              onPress={form.handleSubmit(submit)}\n              isLoading={isLoading}\n              label={isSubscribed ? tCommon(\"words.save\") : tCommon(\"words.follow\")}\n            />\n          }\n        />\n      }\n    >\n      <GroupedInsetListCard className=\"px-5 py-4\">\n        <View className=\"flex flex-row gap-4\">\n          <View className=\"size-[50px] overflow-hidden rounded-lg\">\n            <IconWithFallback\n              url={list?.image}\n              title={list?.title}\n              size={50}\n              textClassName=\"font-semibold\"\n              textStyle={styles.title}\n            />\n          </View>\n          <View className=\"flex-1 flex-col gap-y-1\">\n            <Text className=\"text-lg font-semibold text-text\">{list?.title}</Text>\n            <Text className=\"text-sm text-secondary-label\">{list?.description}</Text>\n          </View>\n        </View>\n      </GroupedInsetListCard>\n\n      <GroupedInsetListCard className=\"gap-y-6 px-5 py-4\">\n        <FormProvider form={form}>\n          <View className=\"-mx-4\">\n            <FormLabel className=\"mb-4 pl-4\" label={t(\"subscription_form.view\")} optional />\n\n            <FeedViewSelector readOnly value={list.view} />\n          </View>\n\n          <View className=\"-mx-2.5\">\n            <Controller\n              name=\"title\"\n              control={form.control}\n              render={({ field: { onChange, ref, value } }) => (\n                <TextField\n                  label={t(\"subscription_form.title\")}\n                  description={t(\"subscription_form.title_description\")}\n                  onChangeText={onChange}\n                  value={value}\n                  ref={ref}\n                  wrapperClassName=\"ml-2.5\"\n                />\n              )}\n            />\n          </View>\n\n          <View className=\"-mx-1\">\n            <Controller\n              name=\"isPrivate\"\n              control={form.control}\n              render={({ field: { onChange, value } }) => (\n                <FormSwitch\n                  value={value}\n                  label={t(\"subscription_form.private_follow\")}\n                  description={t(\"subscription_form.private_follow_description\")}\n                  onValueChange={onChange}\n                  size=\"sm\"\n                />\n              )}\n            />\n          </View>\n\n          <View className=\"-mx-1\">\n            <Controller\n              name=\"hideFromTimeline\"\n              control={form.control}\n              render={({ field: { onChange, value } }) => (\n                <FormSwitch\n                  value={value}\n                  label={t(\"subscription_form.hide_from_timeline\")}\n                  description={t(\"subscription_form.hide_from_timeline_description\")}\n                  onValueChange={onChange}\n                  size=\"sm\"\n                />\n              )}\n            />\n          </View>\n        </FormProvider>\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\nconst styles = StyleSheet.create({\n  title: {\n    fontSize: 24,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/login/email.tsx",
    "content": "import { userSyncService } from \"@follow/store/user/store\"\nimport { tracker } from \"@follow/tracker\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport i18next from \"i18next\"\nimport { useCallback, useState } from \"react\"\nimport type { Control } from \"react-hook-form\"\nimport { useController, useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport type { TextInputProps } from \"react-native\"\nimport { Alert, Pressable, View } from \"react-native\"\nimport { KeyboardController } from \"react-native-keyboard-controller\"\nimport { z } from \"zod\"\n\nimport { SubmitButton } from \"@/src/components/common/SubmitButton\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { getCookie, signIn, signUp } from \"@/src/lib/auth\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport { toast } from \"@/src/lib/toast\"\nimport { getTokenHeaders } from \"@/src/lib/token\"\nimport { ForgetPasswordScreen } from \"@/src/screens/(modal)/ForgetPasswordScreen\"\nimport { TwoFactorAuthScreen } from \"@/src/screens/(modal)/TwoFactorAuthScreen\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nconst formSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(8).max(128),\n})\ntype FormValue = z.infer<typeof formSchema>\n\nasync function onSubmit(values: FormValue) {\n  return signInWithEmail(values)\n}\n\nasync function signInWithEmail(\n  values: FormValue,\n  options: {\n    trackLogin?: boolean\n  } = {},\n) {\n  const { trackLogin = true } = options\n  const result = formSchema.safeParse(values)\n  if (!result.success) {\n    const issue = result.error.issues[0]\n    Alert.alert(i18next.t(\"login.invalid_email_or_password\"), issue?.message)\n    return false\n  }\n\n  try {\n    const res = await signIn.email(\n      {\n        email: result.data.email,\n        password: result.data.password,\n      },\n      {\n        headers: await getTokenHeaders(),\n      },\n    )\n\n    if (res.error) {\n      throw new Error(res.error.message)\n    }\n\n    // @ts-expect-error better-auth response type omits twoFactorRedirect\n    if (res.data?.twoFactorRedirect) {\n      Navigation.rootNavigation.presentControllerView(TwoFactorAuthScreen)\n      return false\n    }\n  } catch (error) {\n    Alert.alert(error instanceof Error ? error.message : \"Unable to sign in\")\n    return false\n  }\n\n  await userSyncService.whoami()\n\n  if (trackLogin) {\n    tracker.userLogin({\n      type: \"email\",\n    })\n  }\n  return true\n}\n\nexport function EmailLogin() {\n  const { t } = useTranslation()\n  const [emailValue, setEmailValue] = useState(\"\")\n  const [passwordValue, setPasswordValue] = useState(\"\")\n  const submitMutation = useMutation({\n    mutationFn: onSubmit,\n  })\n  const onLogin = useCallback(() => {\n    submitMutation.mutate({\n      email: emailValue,\n      password: passwordValue,\n    })\n  }, [emailValue, passwordValue, submitMutation])\n  const navigation = useNavigation()\n\n  return (\n    <View className=\"mx-auto flex w-full max-w-sm\">\n      <View className=\"gap-4 rounded-2xl bg-secondary-system-background px-6 py-4\">\n        <View className=\"flex-row\">\n          <PlainTextField\n            testID=\"login-email-input\"\n            value={emailValue}\n            onChangeText={setEmailValue}\n            selectionColor={accentColor}\n            hitSlop={20}\n            autoCapitalize=\"none\"\n            autoCorrect={false}\n            keyboardType=\"email-address\"\n            autoComplete=\"email\"\n            textContentType=\"emailAddress\"\n            importantForAutofill=\"auto\"\n            placeholder={t(\"login.email\")}\n            className=\"flex-1 text-text\"\n            returnKeyType=\"next\"\n            onSubmitEditing={() => {\n              KeyboardController.setFocusTo(\"next\")\n            }}\n          />\n        </View>\n        <View className=\"border-b-hairline border-b-opaque-separator\" />\n        <View className=\"flex-row\">\n          <PlainTextField\n            testID=\"login-password-input\"\n            value={passwordValue}\n            onChangeText={setPasswordValue}\n            selectionColor={accentColor}\n            hitSlop={20}\n            autoCapitalize=\"none\"\n            autoCorrect={false}\n            autoComplete=\"current-password\"\n            textContentType=\"password\"\n            importantForAutofill=\"auto\"\n            placeholder={t(\"login.password\")}\n            className=\"flex-1 text-text\"\n            secureTextEntry\n            returnKeyType=\"go\"\n            onSubmitEditing={onLogin}\n          />\n        </View>\n      </View>\n\n      <Pressable\n        className=\"mx-auto my-5\"\n        onPress={() => navigation.presentControllerView(ForgetPasswordScreen)}\n      >\n        <Text className=\"text-sm text-secondary-label\">{t(\"login.forget_password.note\")}</Text>\n      </Pressable>\n      <SubmitButton\n        isLoading={submitMutation.isPending}\n        testID=\"login-submit\"\n        onPress={onLogin}\n        title={t(\"login.submit\")}\n      />\n    </View>\n  )\n}\n\n// Signup\n\nconst signupFormSchema = z\n  .object({\n    email: z.string().email(),\n    password: z.string().min(8).max(128),\n    confirmPassword: z.string(),\n  })\n  .refine((data) => data.password === data.confirmPassword, {\n    message: i18next.t(\"login.passwords_do_not_match\"),\n    path: [\"confirmPassword\"],\n  })\ntype SignupFormValue = z.infer<typeof signupFormSchema>\nfunction SignupInput({\n  control,\n  name,\n  ...rest\n}: TextInputProps & {\n  control: Control<SignupFormValue>\n  name: keyof SignupFormValue\n}) {\n  const { field } = useController({\n    control,\n    name,\n  })\n  return (\n    <PlainTextField\n      selectionColor={accentColor}\n      {...rest}\n      value={field.value}\n      onChangeText={field.onChange}\n    />\n  )\n}\nexport function EmailSignUp() {\n  const { t } = useTranslation()\n  const { control, handleSubmit } = useForm<SignupFormValue>({\n    resolver: zodResolver(signupFormSchema),\n    mode: \"onChange\",\n    reValidateMode: \"onChange\",\n    defaultValues: {\n      email: \"\",\n      password: \"\",\n      confirmPassword: \"\",\n    },\n  })\n  const submitMutation = useMutation({\n    mutationFn: async (values: SignupFormValue) => {\n      const res = await signUp.email(\n        {\n          email: values.email,\n          password: values.password,\n          name: values.email.split(\"@\")[0] ?? \"\",\n        },\n        {\n          headers: await getTokenHeaders(),\n        },\n      )\n\n      if (res.error?.message) {\n        toast.error(res.error.message)\n        return\n      }\n\n      if (!getCookie()) {\n        const signedIn = await signInWithEmail(\n          {\n            email: values.email,\n            password: values.password,\n          },\n          {\n            trackLogin: false,\n          },\n        )\n\n        if (!signedIn) {\n          return\n        }\n      }\n\n      toast.success(i18next.t(\"login.sign_up_successful\"))\n      tracker.register({\n        type: \"email\",\n      })\n      Navigation.rootNavigation.back()\n    },\n  })\n  const signup = handleSubmit((values) => {\n    submitMutation.mutate(values)\n  })\n\n  return (\n    <View className=\"mx-auto flex w-full max-w-sm\">\n      <View className=\"gap-4 rounded-2xl bg-secondary-system-background px-6 py-4\">\n        <View className=\"flex-row\">\n          <SignupInput\n            testID=\"register-email-input\"\n            hitSlop={20}\n            autoCapitalize=\"none\"\n            autoCorrect={false}\n            keyboardType=\"email-address\"\n            autoComplete=\"email\"\n            textContentType=\"emailAddress\"\n            importantForAutofill=\"auto\"\n            control={control}\n            name=\"email\"\n            placeholder={t(\"login.email\")}\n            className=\"flex-1 text-text\"\n            returnKeyType=\"next\"\n            onSubmitEditing={() => {\n              KeyboardController.setFocusTo(\"next\")\n            }}\n          />\n        </View>\n        <View className=\"border-b-hairline border-b-opaque-separator\" />\n        <View className=\"flex-row\">\n          <SignupInput\n            testID=\"register-password-input\"\n            hitSlop={20}\n            autoCapitalize=\"none\"\n            autoCorrect={false}\n            autoComplete=\"password-new\"\n            textContentType=\"newPassword\"\n            importantForAutofill=\"auto\"\n            control={control}\n            name=\"password\"\n            placeholder={t(\"login.password\")}\n            className=\"flex-1 text-text\"\n            secureTextEntry\n            returnKeyType=\"next\"\n          />\n        </View>\n        <View className=\"border-b-hairline border-b-opaque-separator\" />\n        <View className=\"flex-row\">\n          <SignupInput\n            testID=\"register-confirm-password-input\"\n            hitSlop={20}\n            autoCapitalize=\"none\"\n            autoCorrect={false}\n            autoComplete=\"password-new\"\n            textContentType=\"newPassword\"\n            importantForAutofill=\"auto\"\n            control={control}\n            name=\"confirmPassword\"\n            placeholder={t(\"login.confirm_password.label\")}\n            className=\"flex-1 text-text\"\n            secureTextEntry\n            returnKeyType=\"go\"\n            onSubmitEditing={() => {\n              signup()\n            }}\n          />\n        </View>\n      </View>\n      <SubmitButton\n        disabled={submitMutation.isPending}\n        isLoading={submitMutation.isPending}\n        testID=\"register-submit\"\n        onPress={signup}\n        title={t(\"login.submit\")}\n        className=\"mt-8\"\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/login/index.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useState } from \"react\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { Linking, Pressable, TouchableWithoutFeedback, View } from \"react-native\"\nimport { KeyboardAvoidingView, KeyboardController } from \"react-native-keyboard-controller\"\nimport Animated, { useAnimatedStyle, useSharedValue } from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { Logo } from \"@/src/components/ui/logo\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useIsTabletLayout, useReadableContainerStyle, useScaleHeight } from \"@/src/lib/responsive\"\n\nimport { EmailLogin, EmailSignUp } from \"./email\"\nimport { SocialLogin } from \"./social\"\n\nexport function Login() {\n  const insets = useSafeAreaInsets()\n  const scaledHeight = useScaleHeight()\n  const isTablet = useIsTabletLayout()\n  const contentWidthStyle = useReadableContainerStyle(480)\n  const logoSize = scaledHeight(80)\n  const gapSize = scaledHeight(28)\n  const fontSize = scaledHeight(28)\n  const lineHeight = scaledHeight(32)\n  const { t } = useTranslation()\n  const [isRegister, setIsRegister] = useState(true)\n  const [isEmail, setIsEmail] = useState(false)\n  return (\n    <View\n      testID=\"login-screen\"\n      className={cn(\"pb-safe-or-2 flex-1\", isTablet ? \"justify-center px-6\" : \"justify-between\")}\n      style={{\n        paddingTop: insets.top + (isTablet ? 36 : 56),\n        paddingBottom: insets.bottom + (isTablet ? 32 : 0),\n      }}\n    >\n      <KeyboardAvoidingView behavior={\"position\"} style={contentWidthStyle}>\n        <TouchableWithoutFeedback\n          onPress={() => {\n            KeyboardController.dismiss()\n          }}\n          accessible={false}\n        >\n          <View\n            className=\"items-center\"\n            style={{\n              gap: gapSize,\n            }}\n          >\n            <Logo\n              style={{\n                width: logoSize,\n                height: logoSize,\n              }}\n            />\n            <Text\n              style={{\n                fontSize,\n                lineHeight,\n              }}\n            >\n              <Text className=\"text-3xl font-semibold\">{`${isRegister ? t(\"signin.sign_up_to\") : t(\"signin.sign_in_to\")} `}</Text>\n              <Text className=\"text-3xl font-bold\">Folo</Text>\n            </Text>\n            {isEmail ? (\n              isRegister ? (\n                <EmailSignUp />\n              ) : (\n                <EmailLogin />\n              )\n            ) : (\n              <SocialLogin onPressEmail={() => setIsEmail(true)} isRegister={isRegister} />\n            )}\n          </View>\n        </TouchableWithoutFeedback>\n        {!isTablet && (\n          <>\n            <TermsCheckBox />\n            <View className=\"mt-14\">\n              {isEmail ? (\n                <Text\n                  className=\"pb-2 text-center text-lg font-medium text-label\"\n                  testID=\"auth-back\"\n                  onPress={() => setIsEmail(false)}\n                >\n                  {t(\"login.back\")}\n                </Text>\n              ) : (\n                <Pressable testID=\"auth-toggle-mode\" onPress={() => setIsRegister(!isRegister)}>\n                  <Text className=\"pb-2 text-center text-lg font-medium text-label\">\n                    <Trans\n                      t={t}\n                      i18nKey={isRegister ? \"login.have_account\" : \"login.no_account\"}\n                      components={{\n                        strong: <Text className=\"text-accent\" />,\n                      }}\n                    />\n                  </Text>\n                </Pressable>\n              )}\n            </View>\n          </>\n        )}\n      </KeyboardAvoidingView>\n      {isTablet && (\n        <View style={contentWidthStyle}>\n          <TermsCheckBox />\n          <View className=\"mt-8\">\n            {isEmail ? (\n              <Text\n                className=\"pb-2 text-center text-lg font-medium text-label\"\n                testID=\"auth-back\"\n                onPress={() => setIsEmail(false)}\n              >\n                {t(\"login.back\")}\n              </Text>\n            ) : (\n              <Pressable testID=\"auth-toggle-mode\" onPress={() => setIsRegister(!isRegister)}>\n                <Text className=\"pb-2 text-center text-lg font-medium text-label\">\n                  <Trans\n                    t={t}\n                    i18nKey={isRegister ? \"login.have_account\" : \"login.no_account\"}\n                    components={{\n                      strong: <Text className=\"text-accent\" />,\n                    }}\n                  />\n                </Text>\n              </Pressable>\n            )}\n          </View>\n        </View>\n      )}\n    </View>\n  )\n}\nconst TermsCheckBox = () => {\n  const shakeSharedValue = useSharedValue(0)\n  const shakeStyle = useAnimatedStyle(() => ({\n    transform: [\n      {\n        translateX: shakeSharedValue.value,\n      },\n    ],\n  }))\n  return (\n    <Animated.View\n      className=\"mt-4 w-full flex-row items-center justify-center gap-2 px-8\"\n      style={shakeStyle}\n    >\n      <TermsText />\n    </Animated.View>\n  )\n}\nconst TermsText = () => {\n  const { t } = useTranslation()\n  return (\n    <View>\n      <Text className=\"text-center text-sm text-secondary-label\">{t(\"login.agree_to\")} </Text>\n      <View className=\"flex-row items-center\">\n        <Pressable\n          onPress={() => Linking.openURL(\"https://folo.is/terms-of-service\")}\n          className=\"text-secondary-label\"\n        >\n          <Text className=\"font-semibold\">{t(\"login.terms\")}</Text>\n        </Pressable>\n        <Text className=\"text-secondary-label\">&nbsp;&&nbsp;</Text>\n        <Pressable\n          onPress={() => Linking.openURL(\"https://folo.is/privacy-policy\")}\n          className=\"text-secondary-label\"\n        >\n          <Text className=\"font-semibold\">{t(\"login.privacy\")}</Text>\n        </Pressable>\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/login/social.tsx",
    "content": "import { tracker } from \"@follow/tracker\"\nimport * as AppleAuthentication from \"expo-apple-authentication\"\nimport { useColorScheme } from \"nativewind\"\nimport { useTranslation } from \"react-i18next\"\nimport { Platform, Pressable, View } from \"react-native\"\nimport DeviceInfo from \"react-native-device-info\"\n\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { signIn, useAuthProviders } from \"@/src/lib/auth\"\n\nexport function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmail: () => void }) {\n  const { data: authProviders, isLoading } = useAuthProviders()\n  const { colorScheme } = useColorScheme()\n  const providers = Object.entries(authProviders || {})\n  const credentialProvider = providers.find(([, provider]) => provider.id === \"credential\")?.[1]\n  const socialProviders = providers.filter(([, provider]) => {\n    if (provider.id === \"credential\") return false\n    if (Platform.OS === \"ios\" && DeviceInfo.isEmulatorSync() && provider.id === \"apple\") {\n      return false\n    }\n    return true\n  })\n  const { t } = useTranslation()\n\n  return (\n    <View className=\"flex w-full items-center justify-center gap-4\">\n      <Pressable\n        testID=\"login-provider-credential\"\n        hitSlop={20}\n        className=\"border-hairline flex w-full flex-row items-center justify-center gap-2 rounded-xl border-opaque-separator py-4 pl-5\"\n        onPress={onPressEmail}\n      >\n        {!!credentialProvider?.icon64 && (\n          <Image\n            source={{\n              uri:\n                colorScheme === \"dark\"\n                  ? credentialProvider.iconDark64 || credentialProvider.icon64\n                  : credentialProvider.icon64,\n            }}\n            className=\"absolute left-6 size-6\"\n            contentFit=\"contain\"\n          />\n        )}\n        <Text className=\"text-lg font-semibold text-label\">\n          {t(\"login.continueWith\", {\n            provider: credentialProvider?.name ?? t(\"login.email\"),\n          })}\n        </Text>\n      </Pressable>\n\n      {isLoading ? (\n        <View className=\"flex h-16 w-full items-center justify-center\">\n          <PlatformActivityIndicator />\n        </View>\n      ) : null}\n\n      {socialProviders.map(([key, provider]) => {\n        return (\n          <Pressable\n            key={key}\n            testID={`login-provider-${provider.id}`}\n            hitSlop={20}\n            className=\"border-hairline flex w-full flex-row items-center justify-center gap-2 rounded-xl border-opaque-separator py-4 pl-5\"\n            onPress={async () => {\n              if (provider.id === \"apple\") {\n                try {\n                  const credential = await AppleAuthentication.signInAsync({\n                    requestedScopes: [\n                      AppleAuthentication.AppleAuthenticationScope.FULL_NAME,\n                      AppleAuthentication.AppleAuthenticationScope.EMAIL,\n                    ],\n                  })\n                  if (credential.identityToken) {\n                    await signIn.social({\n                      provider: \"apple\",\n                      idToken: {\n                        token: credential.identityToken,\n                      },\n                    })\n                    tracker.userLogin({\n                      type: \"social\",\n                    })\n                  } else {\n                    throw new Error(\"No identityToken.\")\n                  }\n                } catch (e) {\n                  console.error(e)\n                  // handle errors\n                }\n                return\n              }\n              await signIn.social({\n                provider: provider.id as any,\n                callbackURL: \"/\",\n              })\n              tracker.userLogin({\n                type: \"social\",\n              })\n            }}\n          >\n            <Image\n              source={{\n                uri:\n                  colorScheme === \"dark\" ? provider.iconDark64 || provider.icon64 : provider.icon64,\n              }}\n              className=\"absolute left-6 size-6\"\n              contentFit=\"contain\"\n            />\n            <Text className=\"text-lg font-semibold text-label\">\n              {t(\"login.continueWith\", {\n                provider: provider.name,\n              })}\n            </Text>\n          </Pressable>\n        )\n      })}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/feeds-english.json",
    "content": "[\n  {\n    \"feedId\": \"43255084704601095\",\n    \"title\": \"a16z Podcast\",\n    \"url\": \"https://feeds.simplecast.com/JGE3yC0V\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"83007794771225600\",\n    \"title\": \"Andrew Huberman - YouTube\",\n    \"url\": \"rsshub://youtube/user/@hubermanlab\",\n    \"language\": \"English\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"62128288597853189\",\n    \"title\": \"Cal Newport\",\n    \"url\": \"https://calnewport.com/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"43881675446885376\",\n    \"title\": \"Collab Fund\",\n    \"url\": \"http://feeds.feedburner.com/collabfund\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41359648684677175\",\n    \"title\": \"Derek Sivers blog\",\n    \"url\": \"https://sive.rs/en.atom\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"52325519371718656\",\n    \"title\": \"Hacker News\",\n    \"url\": \"rsshub://hackernews\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41381007770949636\",\n    \"title\": \"IGN Articles\",\n    \"url\": \"https://www.ign.com/rss/articles/feed?tags=games\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"59461847827447808\",\n    \"title\": \"International homepage\",\n    \"url\": \"https://www.ft.com/rss/home\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"52333046926856207\",\n    \"title\": \"Kotaku\",\n    \"url\": \"https://kotaku.com/rss\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41768239731545104\",\n    \"title\": \"LessWrong\",\n    \"url\": \"https://www.lesswrong.com/feed.xml?view=curated-rss\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41375850878492672\",\n    \"title\": \"Lex Fridman - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40lexfridman\",\n    \"language\": \"English\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"55258726035912721\",\n    \"title\": \"Marginal REVOLUTION\",\n    \"url\": \"https://marginalrevolution.com/feed\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"42109057149046784\",\n    \"title\": \"Marques Brownlee - YouTube\",\n    \"url\": \"rsshub://youtube/user/@mkbhd\",\n    \"language\": \"English\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"41768239731545089\",\n    \"title\": \"MIT Technology Review\",\n    \"url\": \"https://www.technologyreview.com/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"42127302309630072\",\n    \"title\": \"Mr. Money Mustache\",\n    \"url\": \"https://feeds.feedburner.com/mrmoneymustache\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41356263889737728\",\n    \"title\": \"NASA Astronomy Picture of the Day\",\n    \"url\": \"rsshub://nasa/apod\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"55130722692595739\",\n    \"title\": \"Nassim Taleb\",\n    \"url\": \"https://nassimtaleb.org/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41699925856588800\",\n    \"title\": \"Nat Geo Photo of the Day\",\n    \"url\": \"rsshub://natgeo/dailyphoto\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"42127302309630013\",\n    \"title\": \"Nautilus\",\n    \"url\": \"https://nautil.us/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41147805276726370\",\n    \"title\": \"Obsidian Changelog\",\n    \"url\": \"https://obsidian.md/changelog.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41468521403732992\",\n    \"title\": \"Paul Graham - Essays\",\n    \"url\": \"rsshub://paulgraham/articles\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"55130722692595736\",\n    \"title\": \"Peter Attia\",\n    \"url\": \"https://peterattiamd.com/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41382261184290816\",\n    \"title\": \"Product Hunt — The best new products, every day\",\n    \"url\": \"https://www.producthunt.com/feed\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"43254816999905282\",\n    \"title\": \"Research & Insights\",\n    \"url\": \"https://www.bridgewater.com/research-and-insights.rss\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41147805276726402\",\n    \"title\": \"RSSHub has new routes\",\n    \"url\": \"rsshub://rsshub/routes\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"66860831563739166\",\n    \"title\": \"Sabine Hossenfelder\",\n    \"url\": \"https://www.youtube.com/feeds/videos.xml?channel_id=UC1yNl2E66ZzKApQdRuTQ4tw\",\n    \"language\": \"English\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"49470377330653207\",\n    \"title\": \"Seth's Blog\",\n    \"url\": \"https://seths.blog/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41795937311945747\",\n    \"title\": \"Stratechery by Ben Thompson\",\n    \"url\": \"https://stratechery.com/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"59299066394742784\",\n    \"title\": \"TechCrunch\",\n    \"url\": \"https://techcrunch.com/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"49470377330653193\",\n    \"title\": \"The Blog of Author Tim Ferriss\",\n    \"url\": \"https://tim.blog/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"57993144602143744\",\n    \"title\": \"The Memo by Howard Marks\",\n    \"url\": \"https://rss.art19.com/the-memo-by-howard-marks\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"100080366298125312\",\n    \"title\": \"The Verge\",\n    \"url\": \"https://www.theverge.com/rss/index.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41572238278099968\",\n    \"title\": \"The world in brief | The Economist\",\n    \"url\": \"rsshub://economist/espresso\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41368476124603392\",\n    \"title\": \"Trending repositories on GitHub this week · GitHub\",\n    \"url\": \"rsshub://github/trending/weekly/any\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41461870197170196\",\n    \"title\": \"Trending repositories on GitHub today · GitHub\",\n    \"url\": \"rsshub://github/trending/daily/any\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"87076619508029440\",\n    \"title\": \"Twitter @Balaji\",\n    \"url\": \"rsshub://twitter/user/balajis\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"59795737540541440\",\n    \"title\": \"Twitter @David Sinclair\",\n    \"url\": \"rsshub://twitter/user/davidasinclair\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"100411504863520768\",\n    \"title\": \"Twitter @Elon Musk\",\n    \"url\": \"rsshub://twitter/user/elonmusk\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"41215396143077382\",\n    \"title\": \"Twitter @Follow\",\n    \"url\": \"rsshub://twitter/user/folo_is\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"55866936513101824\",\n    \"title\": \"Twitter @James Clear\",\n    \"url\": \"rsshub://twitter/user/JamesClear\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"41342818712721408\",\n    \"title\": \"Wait But Why\",\n    \"url\": \"https://waitbutwhy.com/feed?code=c6f56c4214621ab98b86acbcae6b4405\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41965184796582014\",\n    \"title\": \"WIRED\",\n    \"url\": \"https://www.wired.com/feed/rss\",\n    \"language\": \"English\"\n  }\n]\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/feeds.json",
    "content": "[\n  {\n    \"feedId\": \"41358761177015296\",\n    \"title\": \"知乎热榜 - 全站\",\n    \"url\": \"rsshub://zhihu/hot/total\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805268337669\",\n    \"title\": \"V2EX-最热主题\",\n    \"url\": \"rsshub://v2ex/topics/hot\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805276726272\",\n    \"title\": \"少数派\",\n    \"url\": \"rsshub://sspai/index\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41374278075966464\",\n    \"title\": \"V2EX-最新主题\",\n    \"url\": \"rsshub://v2ex/topics/latest\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"64124473013636098\",\n    \"title\": \"Followin\",\n    \"url\": \"rsshub://followin/news\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"78806242632741888\",\n    \"title\": \"bilibili 排行榜-全站\",\n    \"url\": \"rsshub://bilibili/ranking/0\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"41358830592746496\",\n    \"title\": \"微博热搜榜\",\n    \"url\": \"rsshub://weibo/search/hot\",\n    \"language\": \"Chinese\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"41719081557593134\",\n    \"title\": \"小众软件\",\n    \"url\": \"https://feeds.appinn.com/appinns/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41443203209057309\",\n    \"title\": \"财新网 - 最新文章\",\n    \"url\": \"rsshub://caixin/latest\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100411504863520768\",\n    \"title\": \"Twitter @Elon Musk\",\n    \"url\": \"rsshub://twitter/user/elonmusk\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"100020530265058357\",\n    \"title\": \"阮一峰的网络日志\",\n    \"url\": \"https://feeds.feedburner.com/ruanyifeng\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41324816676184077\",\n    \"title\": \"Twitter @DIŸgöd ☀️\",\n    \"url\": \"rsshub://twitter/user/DIYgod\",\n    \"language\": \"Chinese\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"41147805276726311\",\n    \"title\": \"Twitter @Joshua Meng 🟠\",\n    \"url\": \"rsshub://twitter/user/JoshuaRSS3\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"41147805276726275\",\n    \"title\": \"潮流周刊\",\n    \"url\": \"https://weekly.tw93.fun/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41223694984583170\",\n    \"title\": \"Hi, DIYgod\",\n    \"url\": \"https://diygod.cc/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"56445572623398912\",\n    \"title\": \"简书首页\",\n    \"url\": \"rsshub://jianshu/home\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"60338304723722240\",\n    \"title\": \"实时财经快讯 - FastBull\",\n    \"url\": \"rsshub://fastbull/express-news\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55611390687386624\",\n    \"title\": \"格隆汇快讯-7x24小时市场快讯-财经市场热点\",\n    \"url\": \"rsshub://gelonghui/live\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"49375919416104960\",\n    \"title\": \"深潮TechFlow - 快讯\",\n    \"url\": \"rsshub://techflowpost/express\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55982073122828305\",\n    \"title\": \"TED Talks Daily\",\n    \"url\": \"https://feeds.acast.com/public/shows/67587e77c705e441797aff96\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"72541715399995392\",\n    \"title\": \"TheBlockBeats - 快讯\",\n    \"url\": \"rsshub://theblockbeats/newsflash/0\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100184911354754055\",\n    \"title\": \"小Lin说\",\n    \"url\": \"https://www.youtube.com/feeds/videos.xml?channel_id=UCilwQlk62k1z7aUEZPOB6yw\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100185810923910148\",\n    \"title\": \"张小珺Jùn｜商业访谈录\",\n    \"url\": \"https://feed.xyzfm.space/dk4yh3pkpjp3\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"56584656988676096\",\n    \"title\": \"迷因电波\",\n    \"url\": \"rsshub://xiaoyuzhou/podcast/61d52b3bee197a3aac3dac44\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"76051724651752448\",\n    \"title\": \"极致音乐汇 的 bilibili 空间\",\n    \"url\": \"rsshub://bilibili/user/video/1691501735\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"56141546151433216\",\n    \"title\": \"胡子观币 - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40huziguanbi\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"66701376672681984\",\n    \"title\": \"AP Top News - AP News\",\n    \"url\": \"rsshub://apnews/api/apf-topnews\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"44366244616936448\",\n    \"title\": \"金十数据\",\n    \"url\": \"rsshub://jin10\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"52325519371718656\",\n    \"title\": \"Hacker News\",\n    \"url\": \"rsshub://hackernews\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41359648684677132\",\n    \"title\": \"介绍 on SuperTechFans\",\n    \"url\": \"https://www.supertechfans.com/cn/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805276726279\",\n    \"title\": \"律动BlockBeats\",\n    \"url\": \"https://api.theblockbeats.news/v1/open-api/home-xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54390728350522368\",\n    \"title\": \"New Cryptocurrency Listing\",\n    \"url\": \"rsshub://binance/announcement/new-cryptocurrency-listing\",\n    \"language\": \"\"\n  },\n  {\n    \"feedId\": \"41572238278099968\",\n    \"title\": \"The world in brief | The Economist\",\n    \"url\": \"rsshub://economist/espresso\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"72635895363612672\",\n    \"title\": \"Stock Edge\",\n    \"url\": \"rsshub://stockedge/daily-updates/news\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"72541421314282496\",\n    \"title\": \"Bloomberg - News\",\n    \"url\": \"rsshub://bloomberg/%2F\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"74739941830489088\",\n    \"title\": \"少数派\",\n    \"url\": \"https://sspai.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41489882518602759\",\n    \"title\": \"36氪 - 24小时热榜\",\n    \"url\": \"rsshub://36kr/hot-list\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41356263889737728\",\n    \"title\": \"NASA Astronomy Picture of the Day\",\n    \"url\": \"rsshub://nasa/apod\",\n    \"language\": \"English\",\n    \"view\": 2\n  },\n  {\n    \"feedId\": \"41719081557593132\",\n    \"title\": \"异次元软件世界\",\n    \"url\": \"https://feed.iplaysoft.com/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55304291112288259\",\n    \"title\": \"每日一图-北京天文馆\",\n    \"url\": \"rsshub://bjp/apod\",\n    \"language\": \"Chinese\",\n    \"view\": 2\n  },\n  {\n    \"feedId\": \"100011185145959424\",\n    \"title\": \"知乎每日精选\",\n    \"url\": \"https://www.zhihu.com/rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"60275763819153427\",\n    \"title\": \"爱范儿\",\n    \"url\": \"https://www.ifanr.com/feed\",\n    \"language\": \"\"\n  },\n  {\n    \"feedId\": \"41147805276726402\",\n    \"title\": \"RSSHub has new routes\",\n    \"url\": \"rsshub://rsshub/routes\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"63585517712903168\",\n    \"title\": \"AInvest - Latest News\",\n    \"url\": \"rsshub://ainvest/news\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41374973344769024\",\n    \"title\": \"阮一峰的网络日志\",\n    \"url\": \"https://www.ruanyifeng.com/blog/atom.xml?code=2eb8b88b9919b38e2ce2269980fba393\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100372081228582912\",\n    \"title\": \"极客公园\",\n    \"url\": \"https://www.geekpark.net/rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41215011978385465\",\n    \"title\": \"阮一峰的网络日志\",\n    \"url\": \"http://www.ruanyifeng.com/blog/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41443203209057308\",\n    \"title\": \"纽约时报中文网\",\n    \"url\": \"rsshub://nytimes\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41572238273905680\",\n    \"title\": \"IT之家\",\n    \"url\": \"https://www.ithome.com/rss/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"42520977153904661\",\n    \"title\": \"今日热门-什么值得买好文\",\n    \"url\": \"rsshub://smzdm/haowen/1\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41486365723425792\",\n    \"title\": \"信息差——独立开发者出海周刊\",\n    \"url\": \"https://gapis.money/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55653085540614144\",\n    \"title\": \"影视飓风 的 bilibili 空间\",\n    \"url\": \"rsshub://bilibili/user/video/946974\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"41147805276726276\",\n    \"title\": \"美团技术团队\",\n    \"url\": \"https://tech.meituan.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818708527117\",\n    \"title\": \"宝玉的分享\",\n    \"url\": \"https://s.baoyu.io/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41215011978385440\",\n    \"title\": \"Pseudoyu\",\n    \"url\": \"https://www.pseudoyu.com/zh/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41477724771147777\",\n    \"title\": \"老胡的周刊\",\n    \"url\": \"https://weekly.howie6879.com/rss/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805268337688\",\n    \"title\": \"Owen的博客\",\n    \"url\": \"https://www.owenyoung.com/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"56535849521479680\",\n    \"title\": \"有知有行 - 全部\",\n    \"url\": \"rsshub://youzhiyouxing/materials/0\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"58477260865774592\",\n    \"title\": \"奇客的资讯，重要的东西\",\n    \"url\": \"rsshub://solidot/www\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41503779521380352\",\n    \"title\": \"Epic Games Store - Free Games\",\n    \"url\": \"rsshub://epicgames/freegames/en-US/US\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41397727810093057\",\n    \"title\": \"奇客Solidot–传递最新科技情报\",\n    \"url\": \"https://www.solidot.org/index.rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818712721411\",\n    \"title\": \"月光博客\",\n    \"url\": \"https://www.williamlong.info/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41461870197170196\",\n    \"title\": \"Trending repositories on GitHub today · GitHub\",\n    \"url\": \"rsshub://github/trending/daily/any\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"73553160421921792\",\n    \"title\": \"少数派 - 派早报\",\n    \"url\": \"https://feeds.feedburner.com/sspai/paizaobao\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"72485769266542592\",\n    \"title\": \"资讯列表 - 人人影视\",\n    \"url\": \"rsshub://yyets/article/all\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41377721131229184\",\n    \"title\": \"小Lin说 - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40xiao_lin_shuo\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"41572238273905683\",\n    \"title\": \"InfoQ 推荐\",\n    \"url\": \"rsshub://infoq/recommend\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818708527107\",\n    \"title\": \"虹线\",\n    \"url\": \"https://1q43.blog/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41492096674907154\",\n    \"title\": \"科技圈🎗在花频道📮 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/TestFlightCN\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"56531023179226112\",\n    \"title\": \"HelloGitHub 月刊\",\n    \"url\": \"https://hellogithub.com/rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"58463916731079680\",\n    \"title\": \"技术爬爬虾 的 bilibili 空间\",\n    \"url\": \"rsshub://bilibili/user/video/316183842\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"59403591690626048\",\n    \"title\": \"阮一峰的网络日志\",\n    \"url\": \"http://feeds.feedburner.com/ruanyifeng\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818704332814\",\n    \"title\": \" 太隐 \",\n    \"url\": \"https://wangyurui.com/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41373653871256591\",\n    \"title\": \"竹新社 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/tnews365\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"43254215382531115\",\n    \"title\": \"Decohack\",\n    \"url\": \"https://decohack.com/feed/\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41147805272531976\",\n    \"title\": \"Tw93 Blog\",\n    \"url\": \"https://tw93.fun/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805272531968\",\n    \"title\": \"Randy's Blog\",\n    \"url\": \"https://lutaonan.com/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"59241875117740032\",\n    \"title\": \"分享创造日报\",\n    \"url\": \"https://v2ex-create.nexmm.com/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41364963690045440\",\n    \"title\": \"王志安 - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40wangzhian\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"71830193505483776\",\n    \"title\": \"McKinsey Greater China - 洞见\",\n    \"url\": \"rsshub://mckinsey/cn/25\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"56701589104355328\",\n    \"title\": \"金色财经 - 全部\",\n    \"url\": \"rsshub://jinse/lives/0\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100344100469080064\",\n    \"title\": \"机核\",\n    \"url\": \"https://www.gcores.com/rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41370691926515712\",\n    \"title\": \"小众软件\",\n    \"url\": \"https://www.appinn.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"43301307059705856\",\n    \"title\": \"站酷总榜设计_创意作品榜_第411期-站酷ZCOOL\",\n    \"url\": \"rsshub://zcool/top/design\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058356\",\n    \"title\": \"让小产品的独立变现更简单 - ezindie.com\",\n    \"url\": \"https://www.ezindie.com/feed/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41707595233790976\",\n    \"title\": \"司机社综合周排行榜\",\n    \"url\": \"rsshub://xsijishe/rank/weekly\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"57030789598275584\",\n    \"title\": \"专题报告 - AI量化知识库 - BigQuant\",\n    \"url\": \"rsshub://bigquant/collections\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41795937307751551\",\n    \"title\": \"AIGC Weekly\",\n    \"url\": \"https://quaily.com/op7418/feed/atom\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818708527124\",\n    \"title\": \"槿呈Goidea\",\n    \"url\": \"https://justgoidea.com/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41382542902990938\",\n    \"title\": \"云风的 BLOG\",\n    \"url\": \"https://blog.codingnow.com/atom.xml\",\n    \"language\": \"\"\n  },\n  {\n    \"feedId\": \"41368476124603392\",\n    \"title\": \"Trending repositories on GitHub this week · GitHub\",\n    \"url\": \"rsshub://github/trending/weekly/any\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41427688948323328\",\n    \"title\": \"pixiv 日排行\",\n    \"url\": \"rsshub://pixiv/ranking/day\",\n    \"language\": \"Japanese\",\n    \"view\": 2\n  },\n  {\n    \"feedId\": \"41423034778090522\",\n    \"title\": \"周热门-什么值得买好文\",\n    \"url\": \"rsshub://smzdm/haowen/7\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"52347176714948614\",\n    \"title\": \"Readhub - 每日早报 - Readhub\",\n    \"url\": \"rsshub://readhub/daily\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805272531983\",\n    \"title\": \"印记\",\n    \"url\": \"https://yinji.org/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"57678974871415816\",\n    \"title\": \"7x24小时快讯\",\n    \"url\": \"rsshub://fx678/kx\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41343619752158231\",\n    \"title\": \"酷 壳 – CoolShell\",\n    \"url\": \"https://coolshell.cn/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"53014658920611840\",\n    \"title\": \"V2EX\",\n    \"url\": \"https://www.v2ex.com/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41470051648495616\",\n    \"title\": \"ahhhhfs｜A姐分享 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/abskoop\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058354\",\n    \"title\": \"理想生活实验室\",\n    \"url\": \"https://www.toodaylab.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41343619752158230\",\n    \"title\": \"BMPI\",\n    \"url\": \"https://www.bmpi.dev/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"53352050212694016\",\n    \"title\": \"小声逼逼· 软件|资讯|抽奖 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/me888888888888\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41470869403557888\",\n    \"title\": \"LINUX DO - 最新话题\",\n    \"url\": \"https://linux.do/latest.rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55086756782700544\",\n    \"title\": \"阿里、夸克、百度等网盘4K影视资源 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/Aliyun_4K_Movies\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"61661363869599744\",\n    \"title\": \"社群 - 韭研公社-研究共享，茁壮成长（原韭菜公社）\",\n    \"url\": \"rsshub://jiuyangongshe/community\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818704332815\",\n    \"title\": \"Another Dayu\",\n    \"url\": \"https://anotherdayu.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"53033422584152064\",\n    \"title\": \"热帖 - 雪球\",\n    \"url\": \"rsshub://xueqiu/hots\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805272531984\",\n    \"title\": \"张鑫旭-鑫空间-鑫生活\",\n    \"url\": \"https://www.zhangxinxu.com/wordpress/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058315\",\n    \"title\": \"发现频道 - 小众软件官方论坛\",\n    \"url\": \"https://meta.appinn.net/c/faxian/10.rss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"42855045334971393\",\n    \"title\": \"知乎日报\",\n    \"url\": \"https://feedx.net/rss/zhihudaily.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54799935373253632\",\n    \"title\": \"阿里云盘吧 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/Q66Share\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41381007770949637\",\n    \"title\": \"Mac玩儿法\",\n    \"url\": \"https://www.waerfa.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54410158488493056\",\n    \"title\": \"零度解说 - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40%E9%9B%B6%E5%BA%A6%E8%A7%A3%E8%AF%B4\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"52340201851637826\",\n    \"title\": \"1Link.Fun 科技周刊 | 每天获取一条高质量的链接\",\n    \"url\": \"https://1link.fun/rss/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41358489461613605\",\n    \"title\": \"CatCoding\",\n    \"url\": \"https://catcoding.me/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"52324311483129856\",\n    \"title\": \"阿里云盘发布频道 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/shareAliyun\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"42176727619514397\",\n    \"title\": \"晚点 - 最新报道\",\n    \"url\": \"rsshub://latepost\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41501088760162311\",\n    \"title\": \"一觉醒来发生了什么 - 即刻圈子\",\n    \"url\": \"rsshub://jike/topic/553870e8e4b0cafb0a1bef68\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41511702474276884\",\n    \"title\": \"司机社综合月排行榜\",\n    \"url\": \"rsshub://xsijishe/rank/monthly\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"42177758872391680\",\n    \"title\": \"Mediastorm影视飓风 - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40mediastorm6801\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"41359648684677181\",\n    \"title\": \"编程随想的博客\",\n    \"url\": \"https://feeds2.feedburner.com/programthink\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41215396143077382\",\n    \"title\": \"Twitter @Follow\",\n    \"url\": \"rsshub://twitter/user/folo_is\",\n    \"language\": \"English\",\n    \"view\": 1\n  },\n  {\n    \"feedId\": \"41492096674907156\",\n    \"title\": \"即刻精选 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/jike_collection\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"44920595085335552\",\n    \"title\": \"虎嗅网\",\n    \"url\": \"https://www.huxiu.com/rss/0.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41382542902990947\",\n    \"title\": \"土木坛子\",\n    \"url\": \"https://tumutanzi.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55765163242101760\",\n    \"title\": \"cnBeta.COM - 中文业界资讯站\",\n    \"url\": \"rsshub://cnbeta\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41719104290720768\",\n    \"title\": \"[今日主题] 技術討論區 | 草榴社區 - t66y.com\",\n    \"url\": \"rsshub://t66y/7\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41472267692906513\",\n    \"title\": \"二丫讲梵\",\n    \"url\": \"https://wiki.eryajf.net/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805272531986\",\n    \"title\": \"椒盐豆豉\",\n    \"url\": \"https://blog.douchi.space/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"57995444932781056\",\n    \"title\": \"GQ\",\n    \"url\": \"rsshub://gq/news\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41383550788723712\",\n    \"title\": \" Airing 的博客 \",\n    \"url\": \"https://blog.ursb.me/feed.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41572238273905689\",\n    \"title\": \"澎湃新闻 - 首页头条\",\n    \"url\": \"rsshub://thepaper/featured\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058329\",\n    \"title\": \"卡瓦邦噶！\",\n    \"url\": \"https://www.kawabangga.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55216086191994902\",\n    \"title\": \"有用经验\",\n    \"url\": \"https://yyjingyan.com/index.php/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41343619752158241\",\n    \"title\": \" 61’s life \",\n    \"url\": \"https://61.life/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"65017927657675779\",\n    \"title\": \"36氪\",\n    \"url\": \"https://36kr.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54851744068228138\",\n    \"title\": \"极客湾Geekerwan 的 bilibili 空间\",\n    \"url\": \"rsshub://bilibili/user/video/25876945\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"67804472989767680\",\n    \"title\": \"36氪资讯热榜\",\n    \"url\": \"https://feeds.feedburner.com/36kr/hot-list\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359836954400775\",\n    \"title\": \"胡涂说\",\n    \"url\": \"https://hutusi.com/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41572238273905693\",\n    \"title\": \"纽约时报中文网 - 中英对照版\",\n    \"url\": \"rsshub://nytimes/dual\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"57966370728127498\",\n    \"title\": \"Breaking News Headlines | Latest Views | Reuters\",\n    \"url\": \"rsshub://reuters/breakingviews\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"54737464283059214\",\n    \"title\": \"精品MAC应用分享\",\n    \"url\": \"https://xclient.info/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41425168656712704\",\n    \"title\": \"RSSHub 有新路由啦\",\n    \"url\": \"rsshub://rsshub/routes/zh\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41374014364620801\",\n    \"title\": \"槽边往事\",\n    \"url\": \"https://www.hecaitou.com/feeds/posts/default\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"55160895115655188\",\n    \"title\": \"人民日报\",\n    \"url\": \"https://plink.anyfeeder.com/people-daily\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"58488203296243712\",\n    \"title\": \"人人影视-今日播出\",\n    \"url\": \"rsshub://yyets/today\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54772566650461198\",\n    \"title\": \"CnGal - 每周速报\",\n    \"url\": \"rsshub://cngal/weekly\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"84458689264416768\",\n    \"title\": \" 虎嗅 \",\n    \"url\": \"https://rss.huxiu.com/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359690177602678\",\n    \"title\": \"MacTalk-池建强的随想录\",\n    \"url\": \"https://macshuo.com/?feed=rss2\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"48039983835900989\",\n    \"title\": \"電腦玩物\",\n    \"url\": \"http://feeds.feedburner.com/playpc\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41397727810093071\",\n    \"title\": \"见字如面\",\n    \"url\": \"https://hiwannz.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359648680482843\",\n    \"title\": \"拾月\",\n    \"url\": \"https://www.skyue.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55116179974345728\",\n    \"title\": \"彭博社最新报道\",\n    \"url\": \"https://bloombergnew.buzzing.cc/feed.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"64999354512147456\",\n    \"title\": \"财新周刊 Caixin Weekly\",\n    \"url\": \"https://the.bi/s/rawatssj2a2mog\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058314\",\n    \"title\": \"#UNTAG\",\n    \"url\": \"https://rss.utgd.net/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41374113210459144\",\n    \"title\": \"Twitter @宝玉\",\n    \"url\": \"rsshub://twitter/user/dotey\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"42331815237783605\",\n    \"title\": \"莫比乌斯\",\n    \"url\": \"https://onojyun.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41699925856588800\",\n    \"title\": \"Nat Geo Photo of the Day\",\n    \"url\": \"rsshub://natgeo/dailyphoto\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"42864851888759808\",\n    \"title\": \"积薪 - 文章\",\n    \"url\": \"https://darmau.co/zh/article/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"52347176714948645\",\n    \"title\": \"如有乐享\",\n    \"url\": \"https://51.ruyo.net/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805276726317\",\n    \"title\": \"pixiv 周排行\",\n    \"url\": \"rsshub://pixiv/ranking/week\",\n    \"language\": \"Yoruba\",\n    \"view\": 2\n  },\n  {\n    \"feedId\": \"41359836954400804\",\n    \"title\": \"王登科-DK博客\",\n    \"url\": \"https://greatdk.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058337\",\n    \"title\": \"离别歌\",\n    \"url\": \"https://www.leavesongs.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"43236301826954240\",\n    \"title\": \"不良林 - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40bulianglin\",\n    \"language\": \"Chinese\",\n    \"view\": 3\n  },\n  {\n    \"feedId\": \"41719081557593137\",\n    \"title\": \"我不是咕咕鸽\",\n    \"url\": \"https://blog.laoda.de/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"42006425715388416\",\n    \"title\": \"pinlei榜-11-3小时\",\n    \"url\": \"rsshub://smzdm/ranking/pinlei/11/3\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41373653871256582\",\n    \"title\": \"风向旗参考快讯 - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/xhqcankao\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54349807700270080\",\n    \"title\": \"知行小酒馆\",\n    \"url\": \"rsshub://xiaoyuzhou/podcast/6013f9f58e2f7ee375cf4216\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41343619752158234\",\n    \"title\": \"大破进击\",\n    \"url\": \"https://jesor.me/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41424303016727552\",\n    \"title\": \"老高與小茉 Mr & Mrs Gao - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40laogao\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55064361156653076\",\n    \"title\": \"子舒的博客\",\n    \"url\": \"https://zishu.me/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359690177602681\",\n    \"title\": \"奔跑中的奶酪\",\n    \"url\": \"https://www.runningcheese.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"42579624844251167\",\n    \"title\": \"东西智库 – 专注中国制造业高质量发展\",\n    \"url\": \"rsshub://dx2025\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"100020530265058338\",\n    \"title\": \"Sam Altman\",\n    \"url\": \"https://blog.samaltman.com/posts.atom\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41343619752158254\",\n    \"title\": \"OneV's Den\",\n    \"url\": \"https://onevcat.com/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359648684677164\",\n    \"title\": \"KAIX.IN\",\n    \"url\": \"https://kaix.in/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"60954952175832064\",\n    \"title\": \"每日一拍\",\n    \"url\": \"rsshub://500px/tribe/set/302261e93f0441c9a5323a565279b0e3\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41461870201364486\",\n    \"title\": \"少数派 -- Matrix\",\n    \"url\": \"rsshub://sspai/matrix\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818704332817\",\n    \"title\": \"Limboy's Essays\",\n    \"url\": \"https://limboy.me/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41440449356332064\",\n    \"title\": \"Blog | Phodal - A Growth Engineer\",\n    \"url\": \"https://www.phodal.com/blog/feeds/rss/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"57164874420868096\",\n    \"title\": \"小Lin说 的 bilibili 空间\",\n    \"url\": \"rsshub://bilibili/user/video/520819684\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41343619752158232\",\n    \"title\": \"罗磊的独立博客\",\n    \"url\": \"https://luolei.org/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41423034778090515\",\n    \"title\": \"四火的唠叨\",\n    \"url\": \"https://www.raychase.net/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41465715829593091\",\n    \"title\": \"华尔街日报\",\n    \"url\": \"https://feedx.net/rss/wsj.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818716915713\",\n    \"title\": \"0x01 byte\",\n    \"url\": \"https://1byte.io/articles/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"52508301310328842\",\n    \"title\": \"南方周末-新闻\",\n    \"url\": \"rsshub://infzm/2\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41358489461613601\",\n    \"title\": \"可能吧\",\n    \"url\": \"https://feeds.feedburner.com/kenengbarss\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41388765730464778\",\n    \"title\": \"GeekPlux\",\n    \"url\": \"https://geekplux.com/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41223694984583197\",\n    \"title\": \"静かな森\",\n    \"url\": \"https://innei.in/feed\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41461870201364482\",\n    \"title\": \"《联合早报》-中港台-即时\",\n    \"url\": \"rsshub://zaobao/realtime/china\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41391604968812544\",\n    \"title\": \"纵横四海\",\n    \"url\": \"rsshub://xiaoyuzhou/podcast/62694abdb221dd5908417d1e\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55855418052542483\",\n    \"title\": \"Elmagnifico's Blog\",\n    \"url\": \"https://elmagnifico.tech/feed.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41459996870678548\",\n    \"title\": \"掘金本周最热\",\n    \"url\": \"https://rsshub.bestblogs.dev/juejin/trending/all/weekly\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805268337670\",\n    \"title\": \"小米有品众筹\",\n    \"url\": \"rsshub://xiaomiyoupin/crowdfunding\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"48039983835900987\",\n    \"title\": \"分享创造\",\n    \"url\": \"https://www.v2ex.com/feed/create.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100191225529340928\",\n    \"title\": \"游研社\",\n    \"url\": \"https://www.yystv.cn/rss/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41380827636851712\",\n    \"title\": \"声动早咖啡\",\n    \"url\": \"rsshub://xiaoyuzhou/podcast/60de7c003dd577b40d5a40f3\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41667838436032513\",\n    \"title\": \"小球飞鱼\",\n    \"url\": \"https://mantyke.icu/index.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"61937382761420802\",\n    \"title\": \"蓝点网\",\n    \"url\": \"https://www.landiannews.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"48039983835900988\",\n    \"title\": \"书伴\",\n    \"url\": \"https://feeds.feedburner.com/bookfere\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55311155740901376\",\n    \"title\": \"有知有行 - 全部\",\n    \"url\": \"rsshub://youzhiyouxing/materials\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100157598308965376\",\n    \"title\": \"V2EX - 创意\",\n    \"url\": \"https://www.v2ex.com/feed/tab/creative.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41798923170845776\",\n    \"title\": \"初之音\",\n    \"url\": \"https://www.himiku.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"57419814936869901\",\n    \"title\": \"不死鸟 - 分享为王官网\",\n    \"url\": \"https://iui.su/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"70126938108248064\",\n    \"title\": \"财联社快讯\",\n    \"url\": \"https://feeds.crabpi.com/cls-telegraph\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41461870197170197\",\n    \"title\": \"豌豆花下猫\",\n    \"url\": \"https://pythoncat.top/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"54083984224404480\",\n    \"title\": \"酷安图文 - 编辑精选\",\n    \"url\": \"rsshub://coolapk/tuwen\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359690177602682\",\n    \"title\": \"唐巧的博客\",\n    \"url\": \"https://blog.devtang.com/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"69670759328198656\",\n    \"title\": \"IMDb Top 250 Movies\",\n    \"url\": \"rsshub://imdb/chart/top\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"100020530265058320\",\n    \"title\": \"卢昌海个人主页\",\n    \"url\": \"https://www.changhai.org/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100080366298125312\",\n    \"title\": \"The Verge\",\n    \"url\": \"https://www.theverge.com/rss/index.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41527687227405342\",\n    \"title\": \"吾爱破解论坛\",\n    \"url\": \"https://wechat2rss.xlab.app/feed/90c827b8290310a96ef80a13df9dbcc06ab69892.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"54945423970185247\",\n    \"title\": \"晚晴幽草轩\",\n    \"url\": \"https://www.jeffjade.com/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"67048226833723428\",\n    \"title\": \"ahhhhfs\",\n    \"url\": \"https://www.ahhhhfs.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"42331815237783583\",\n    \"title\": \"木木木木木\",\n    \"url\": \"https://immmmm.com/atom.xml\",\n    \"language\": \"English\"\n  },\n  {\n    \"feedId\": \"41768239731545103\",\n    \"title\": \"laike9m's blog\",\n    \"url\": \"https://laike9m.com/blog/rss/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55576111518416909\",\n    \"title\": \"豆瓣电影本周口碑榜\",\n    \"url\": \"https://feedx.net/rss/doubanmvweek.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41147805272531974\",\n    \"title\": \"t9t.io\",\n    \"url\": \"https://blog.t9t.io/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359836954400809\",\n    \"title\": \"月球背面\",\n    \"url\": \"https://moonvy.com/blog/rss.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41459996870678529\",\n    \"title\": \"量子位\",\n    \"url\": \"https://www.qbitai.com/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41376358301079552\",\n    \"title\": \"三联生活周刊\",\n    \"url\": \"https://plink.anyfeeder.com/weixin/lifeweek\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41768239731545124\",\n    \"title\": \"依云's Blog\",\n    \"url\": \"https://blog.lilydjwg.me/feed?code=c6f56c4214621ab98b86acbcae6b4405\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359648684677153\",\n    \"title\": \"海德沙龙（HeadSalon）\",\n    \"url\": \"https://headsalon.org/feed\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41667283719128072\",\n    \"title\": \"技术小黑屋\",\n    \"url\": \"https://droidyue.com/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41342818704332811\",\n    \"title\": \"陈仓颉\",\n    \"url\": \"https://imzm.im/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55130722692595719\",\n    \"title\": \"笨方法学写作\",\n    \"url\": \"https://www.cnfeat.com/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55877082660306949\",\n    \"title\": \"数据发布 - 国家统计局\",\n    \"url\": \"rsshub://gov/stats/sj/zxfb\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"43789642870889493\",\n    \"title\": \"Tony Bai\",\n    \"url\": \"https://tonybai.com/feed/\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"55157116408461312\",\n    \"title\": \"Macin\",\n    \"url\": \"https://macin.org/atom.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100020530265058312\",\n    \"title\": \"歸藏的AI工具箱\",\n    \"url\": \"https://werss.bestblogs.dev/feeds/MP_WXS_3540975510.atom\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41324816676184075\",\n    \"title\": \"Yu’s Life - Telegram Channel\",\n    \"url\": \"rsshub://telegram/channel/pseudoyulife\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"43374760408291328\",\n    \"title\": \"Epic Games Store - Free Games\",\n    \"url\": \"rsshub://epicgames/freegames/zh-CN/CN\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"100127086160845862\",\n    \"title\": \"一天一篇经济学人(双语)\",\n    \"url\": \"https://plink.anyfeeder.com/weixin/Economist_fans\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"56587971459683328\",\n    \"title\": \"技术爬爬虾 TechShrimp - YouTube\",\n    \"url\": \"rsshub://youtube/user/%40Tech_Shrimp\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41359648684677157\",\n    \"title\": \"阳志平的网志\",\n    \"url\": \"https://www.yangzhiping.com/feed.xml\",\n    \"language\": \"Chinese\"\n  },\n  {\n    \"feedId\": \"41459996870678583\",\n    \"title\": \"机器之心\",\n    \"url\": \"https://www.jiqizhixin.com/rss\",\n    \"language\": \"Chinese\"\n  }\n]\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/hooks/use-reading-behavior.ts",
    "content": "import { setGeneralSetting, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\n\ntype ReadingBehavior = \"radical\" | \"balanced\" | \"conservative\"\n\nexport const useReadingBehavior = () => {\n  const markAsReadWhenScrolling = useGeneralSettingKey(\"scrollMarkUnread\")\n  const markAsReadWhenInView = useGeneralSettingKey(\"renderMarkUnread\")\n\n  const behavior: ReadingBehavior =\n    markAsReadWhenInView && markAsReadWhenScrolling\n      ? \"radical\"\n      : !markAsReadWhenInView && !markAsReadWhenScrolling\n        ? \"conservative\"\n        : \"balanced\"\n\n  const updateSettings = (behavior: ReadingBehavior) => {\n    switch (behavior) {\n      case \"radical\": {\n        setGeneralSetting(\"scrollMarkUnread\", true)\n        setGeneralSetting(\"renderMarkUnread\", true)\n        break\n      }\n      case \"balanced\": {\n        setGeneralSetting(\"scrollMarkUnread\", true)\n        setGeneralSetting(\"renderMarkUnread\", false)\n        break\n      }\n      case \"conservative\": {\n        setGeneralSetting(\"scrollMarkUnread\", false)\n        setGeneralSetting(\"renderMarkUnread\", false)\n        break\n      }\n    }\n  }\n  return {\n    behavior,\n    markAsReadWhenScrolling,\n    markAsReadWhenInView,\n    updateSettings,\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/preset.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\n\nimport feeds from \"./feeds.json\"\nimport englishFeeds from \"./feeds-english.json\"\n\nexport type PresetFeedConfig = {\n  title: string\n  feedId: string\n  url: string\n  view: FeedViewType\n}\n\nexport const englishPresetFeeds: PresetFeedConfig[] = englishFeeds.map((feed) => ({\n  view: FeedViewType.Articles,\n  ...feed,\n}))\n\nexport const otherPresetFeeds: PresetFeedConfig[] = feeds\n  // .filter((feed) => feed.language === \"Chinese\")\n  .map((feed) => ({\n    view: FeedViewType.Articles,\n    ...feed,\n  }))\n\nexport const getPresetFeeds = (isEnglishUser: boolean) =>\n  isEnglishUser ? englishPresetFeeds : otherPresetFeeds\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/shared.tsx",
    "content": "import { ScrollView, View } from \"react-native\"\n\nimport { useReadableContainerStyle, useScaleHeight } from \"@/src/lib/responsive\"\n\nexport const OnboardingSectionScreenContainer = ({ children }: { children: React.ReactNode }) => {\n  const height = useScaleHeight()(50)\n  const readableContentStyle = useReadableContainerStyle(680)\n  return (\n    <ScrollView\n      className=\"flex-1\"\n      contentContainerClassName=\"items-center\"\n      style={{ marginTop: height }}\n    >\n      <View style={readableContentStyle}>{children}</View>\n    </ScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/step-finished.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { Logo } from \"@/src/components/ui/logo\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const StepFinished = () => {\n  const { t } = useTranslation()\n  return (\n    <View className=\"flex-1 items-center justify-center\">\n      <Logo width={80} height={80} />\n      <Text className=\"my-4 text-3xl font-bold text-text\">{t(\"onboarding.finished_title\")}</Text>\n      <Text className=\"mb-8 px-6 text-center text-lg text-label\">\n        {t(\"onboarding.finished_description\")}\n      </Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/step-interests.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { Search3CuteReIcon } from \"@/src/icons/search_3_cute_re\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { Trending } from \"../discover/Trending\"\nimport { OnboardingSectionScreenContainer } from \"./shared\"\n\nexport const StepInterests = () => {\n  const { t } = useTranslation()\n  return (\n    <OnboardingSectionScreenContainer>\n      <View className=\"flex items-center gap-4\">\n        <Search3CuteReIcon height={80} width={80} color={accentColor} />\n        <Text className=\"mt-2 text-2xl font-bold text-text\">{t(\"onboarding.interests_title\")}</Text>\n        <Text className=\"mb-8 px-6 text-center text-lg text-label\">\n          {t(\"onboarding.interests_description\")}\n        </Text>\n      </View>\n\n      <Trending className=\"mb-4 w-full\" />\n    </OnboardingSectionScreenContainer>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/step-preferences.tsx",
    "content": "import type { PropsWithChildren } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { GroupedInsetListNavigationLinkIcon } from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { DocmentCuteReIcon } from \"@/src/icons/docment_cute_re\"\nimport { FileImportCuteReIcon } from \"@/src/icons/file_import_cute_re\"\nimport { ListCheck2CuteReIcon } from \"@/src/icons/list_check_2_cute_re\"\nimport { MingcuteRightLine } from \"@/src/icons/mingcute_right_line\"\nimport { Settings1CuteReIcon } from \"@/src/icons/settings_1_cute_re\"\nimport { Translate2CuteReIcon } from \"@/src/icons/translate_2_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { SelectReadingModeScreen } from \"@/src/screens/(modal)/onboarding/SelectReadingModeScreen\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { EditProfileScreen } from \"../settings/routes/EditProfile\"\nimport { LanguageSelect } from \"../settings/routes/General\"\nimport { importOpml } from \"../settings/utils\"\nimport { useReadingBehavior } from \"./hooks/use-reading-behavior\"\nimport { OnboardingSectionScreenContainer } from \"./shared\"\n\nexport const StepPreferences = () => {\n  const { t } = useTranslation()\n  const { behavior } = useReadingBehavior()\n  const navigation = useNavigation()\n  return (\n    <OnboardingSectionScreenContainer>\n      <View className=\"mb-10 flex items-center gap-4\">\n        <ListCheck2CuteReIcon height={80} width={80} color={accentColor} />\n        <Text className=\"mt-2 text-center text-xl font-bold text-text\">\n          {t(\"onboarding.preferences_title\")}\n        </Text>\n        <Text className=\"text-center text-base text-label\">\n          {t(\"onboarding.preferences_description\")}\n        </Text>\n      </View>\n\n      <View className=\"mb-6 w-full flex-1 gap-4\">\n        <PreferenceCard\n          showRightArrow={false}\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#FCA5A5\">\n              <Translate2CuteReIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n        >\n          <View className=\"flex flex-row items-center justify-between\">\n            <Text className=\"text-base font-medium text-text\">\n              {t(\"general.language.title\", {\n                ns: \"settings\",\n              })}\n            </Text>\n            <View className=\"w-[150px]\">\n              <LanguageSelect settingKey=\"language\" />\n            </View>\n          </View>\n          <Text className=\"text-sm text-secondary-label\">\n            {t(\"onboarding.language_description\")}\n          </Text>\n        </PreferenceCard>\n\n        {/* Import Card */}\n        <PreferenceCard\n          title={t(\"onboarding.import_content\")}\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#CBAD6D\">\n              <FileImportCuteReIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n          onPress={importOpml}\n        >\n          <View className=\"flex-row\">\n            <Text className=\"flex-1 text-secondary-label\">\n              {t(\"onboarding.import_description\")}\n            </Text>\n          </View>\n        </PreferenceCard>\n\n        <PreferenceCard\n          title={t(\"onboarding.edit_profile\")}\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#34D399\">\n              <Settings1CuteReIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n          onPress={() => {\n            navigation.pushControllerView(EditProfileScreen)\n          }}\n        >\n          <Text className=\"text-sm text-secondary-label\">\n            {t(\"onboarding.edit_profile_description\")}\n          </Text>\n        </PreferenceCard>\n\n        {/* Reading Preferences Card */}\n        <PreferenceCard\n          title={t(\"onboarding.reading_preferences\")}\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#F59E0B\">\n              <DocmentCuteReIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n          onPress={() => {\n            navigation.pushControllerView(SelectReadingModeScreen)\n          }}\n        >\n          {behavior === \"radical\" && (\n            <Text className=\"text-sm text-secondary-label\">\n              {t(\"onboarding.reading_radical_description\")}\n            </Text>\n          )}\n          {behavior === \"balanced\" && (\n            <Text className=\"text-sm text-secondary-label\">\n              {t(\"onboarding.reading_balanced_description\")}\n            </Text>\n          )}\n          {behavior === \"conservative\" && (\n            <Text className=\"text-sm text-secondary-label\">\n              {t(\"onboarding.reading_conservative_description\")}\n            </Text>\n          )}\n        </PreferenceCard>\n      </View>\n    </OnboardingSectionScreenContainer>\n  )\n}\ntype PreferenceCardProps = PropsWithChildren<{\n  title?: string\n  icon?: React.ReactNode\n  showRightArrow?: boolean\n  onPress?: () => void\n}>\nconst PreferenceCard = ({\n  title,\n  children,\n  onPress,\n  icon,\n  showRightArrow = true,\n}: PreferenceCardProps) => {\n  const rightIconColor = useColor(\"tertiaryLabel\")\n  return (\n    <Pressable\n      className=\"flex flex-row items-center gap-2 rounded-xl bg-secondary-system-grouped-background p-4\"\n      onPress={onPress}\n    >\n      {icon}\n      <View className=\"flex flex-1 flex-col gap-2\">\n        {title ? <Text className=\"text-base font-medium text-text\">{title}</Text> : null}\n        {children}\n      </View>\n      {showRightArrow && <MingcuteRightLine height={18} width={18} color={rightIconColor} />}\n    </Pressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/onboarding/step-welcome.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { Logo } from \"@/src/components/ui/logo\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const StepWelcome = () => {\n  const { t } = useTranslation()\n  return (\n    <View className=\"flex-1 items-center justify-center\">\n      <Logo width={80} height={80} />\n      <Text className=\"my-4 text-3xl font-bold text-text\">{t(\"onboarding.welcome_title\")}</Text>\n      <Text className=\"mb-8 px-6 text-center text-lg text-label\">\n        {t(\"onboarding.welcome_guide\")}\n      </Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/player/GlassPlayerTabBar.tsx",
    "content": "import { clsx } from \"@follow/utils\"\nimport { GlassView } from \"expo-glass-effect\"\nimport { useAtomValue } from \"jotai\"\nimport { use } from \"react\"\nimport { Pressable, StyleSheet, View } from \"react-native\"\n\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { BottomTabContext } from \"@/src/lib/navigation/bottom-tab/BottomTabContext\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useActiveTrack } from \"@/src/lib/player\"\nimport { PlayerScreen } from \"@/src/screens/PlayerScreen\"\nimport { usePrefetchImageColors } from \"@/src/store/image/hooks\"\n\nimport { PlayPauseButton, SeekButton } from \"./control\"\n\nconst allowedTabIdentifiers = new Set([\"IndexTabScreen\", \"SubscriptionsTabScreen\"])\nexport function GlassPlayerTabBar({ className }: { className?: string }) {\n  const activeTrack = useActiveTrack()\n  const tabRootCtx = use(BottomTabContext)\n  const tabScreens = useAtomValue(tabRootCtx.tabScreensAtom)\n  const currentIndex = useAtomValue(tabRootCtx.currentIndexAtom)\n  const currentTabProps = tabScreens.find((tabScreen) => tabScreen.tabScreenIndex === currentIndex)\n  const identifier = currentTabProps?.identifier\n  const isVisible = !!activeTrack && identifier && allowedTabIdentifiers.has(identifier)\n\n  usePrefetchImageColors(activeTrack?.artwork)\n  const navigation = useNavigation()\n\n  if (!isVisible) return null\n\n  return (\n    <View className={clsx(\"mx-6\", className)}>\n      <View className=\"my-6 h-[56px] flex-1\">\n        <GlassView style={styles.glass} glassEffectStyle=\"regular\" />\n        <Pressable\n          onPress={() => {\n            navigation.presentControllerView(PlayerScreen, void 0, \"transparentModal\")\n          }}\n        >\n          <View className=\"flex flex-row items-center gap-4 overflow-hidden rounded-2xl p-2 px-3\">\n            <Image\n              source={{\n                uri: activeTrack?.artwork ?? \"\",\n              }}\n              className=\"size-12 rounded-full\"\n            />\n            <View className=\"flex-1 overflow-hidden\">\n              <Text className=\"text-lg font-semibold text-label\" numberOfLines={1}>\n                {activeTrack?.title ?? \"\"}\n              </Text>\n            </View>\n            <View className=\"mr-2 flex flex-row gap-4\">\n              <PlayPauseButton />\n              <SeekButton />\n            </View>\n          </View>\n        </Pressable>\n      </View>\n    </View>\n  )\n}\n\nconst styles = StyleSheet.create({\n  glass: {\n    borderRadius: 99,\n    ...StyleSheet.absoluteFillObject,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/player/PlayerTabBar.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { useAtomValue } from \"jotai\"\nimport { use, useEffect } from \"react\"\nimport { Pressable, View } from \"react-native\"\nimport Animated, {\n  interpolate,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\"\n\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { BottomTabContext } from \"@/src/lib/navigation/bottom-tab/BottomTabContext\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useActiveTrack } from \"@/src/lib/player\"\nimport { PlayerScreen } from \"@/src/screens/PlayerScreen\"\nimport { usePrefetchImageColors } from \"@/src/store/image/hooks\"\n\nimport { PlayPauseButton, SeekButton } from \"./control\"\n\nconst allowedTabIdentifiers = new Set([\"IndexTabScreen\", \"SubscriptionsTabScreen\"])\nexport function PlayerTabBar({ className }: { className?: string }) {\n  const activeTrack = useActiveTrack()\n  const tabRootCtx = use(BottomTabContext)\n  const tabScreens = useAtomValue(tabRootCtx.tabScreensAtom)\n  const currentIndex = useAtomValue(tabRootCtx.currentIndexAtom)\n  const currentTabProps = tabScreens.find((tabScreen) => tabScreen.tabScreenIndex === currentIndex)\n  const identifier = currentTabProps?.identifier\n  const isVisible = !!activeTrack && identifier && allowedTabIdentifiers.has(identifier)\n  const isVisibleSV = useSharedValue(isVisible ? 1 : 0)\n  useEffect(() => {\n    isVisibleSV.value = withTiming(isVisible ? 1 : 0)\n  }, [isVisible, isVisibleSV])\n  const animatedStyle = useAnimatedStyle(() => {\n    return {\n      opacity: isVisibleSV.value,\n      height: interpolate(isVisibleSV.value, [0, 1], [0, 56]),\n      overflow: \"hidden\",\n    }\n  })\n  usePrefetchImageColors(activeTrack?.artwork)\n  const navigation = useNavigation()\n  return (\n    <Animated.View\n      style={animatedStyle}\n      className={cn(\"border-b-hairline border-opaque-separator/50 px-2\", className)}\n    >\n      <Pressable\n        onPress={() => {\n          navigation.presentControllerView(PlayerScreen, void 0, \"transparentModal\")\n        }}\n      >\n        <View className=\"flex flex-row items-center gap-4 overflow-hidden rounded-2xl p-2\">\n          <Image\n            source={{\n              uri: activeTrack?.artwork ?? \"\",\n            }}\n            className=\"size-12 rounded-lg\"\n          />\n          <View className=\"flex-1 overflow-hidden\">\n            <Text className=\"text-lg font-semibold text-label\" numberOfLines={1}>\n              {activeTrack?.title ?? \"\"}\n            </Text>\n          </View>\n          <View className=\"mr-2 flex flex-row gap-4\">\n            <PlayPauseButton />\n            <SeekButton />\n          </View>\n        </View>\n      </Pressable>\n    </Animated.View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/player/context.ts",
    "content": "import { createContext, use } from \"react\"\n\nexport const PlayerScreenContext = createContext<PlayerScreenContextValue | null>(null)\nexport const usePlayerScreenContext = () => {\n  const context = use(PlayerScreenContext)\n  if (!context) {\n    throw new Error(\"usePlayerScreenContext must be used within a PlayerScreenContextProvider\")\n  }\n  return context\n}\n\ninterface PlayerScreenContextValue {\n  isBackgroundLight: boolean\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/player/control.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { Pressable, StyleSheet, View } from \"react-native\"\nimport { Slider } from \"react-native-awesome-slider\"\nimport { FadeOut, useDerivedValue, useSharedValue, ZoomIn } from \"react-native-reanimated\"\nimport * as DropdownMenu from \"zeego/dropdown-menu\"\n\nimport { ReAnimatedPressable } from \"@/src/components/common/AnimatedComponents\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { Back2CuteReIcon } from \"@/src/icons/back_2_cute_re\"\nimport { Forward2CuteReIcon } from \"@/src/icons/forward_2_cute_re\"\nimport { PauseCuteFiIcon } from \"@/src/icons/pause_cute_fi\"\nimport { PlayCuteFiIcon } from \"@/src/icons/play_cute_fi\"\nimport { RewindBackward15CuteReIcon } from \"@/src/icons/rewind_backward_15_cute_re\"\nimport { RewindForward30CuteReIcon } from \"@/src/icons/rewind_forward_30_cute_re\"\nimport { StopCircleCuteFiIcon } from \"@/src/icons/stop_circle_cute_fi\"\nimport { VolumeCuteReIcon } from \"@/src/icons/volume_cute_re\"\nimport { VolumeOffCuteReIcon } from \"@/src/icons/volume_off_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { allowedRate, player, useIsPlaying, useProgress, useRate } from \"@/src/lib/player\"\nimport { useVolume } from \"@/src/lib/volume\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { usePlayerScreenContext } from \"./context\"\n\ntype ControlButtonProps = {\n  size?: number\n  className?: string\n  color?: string\n}\nexport function PlayPauseButton({ size = 24, className, color }: ControlButtonProps) {\n  const { playing } = useIsPlaying()\n  const label = useColor(\"label\")\n  return (\n    <View className={className}>\n      <ReAnimatedPressable\n        entering={ZoomIn.springify()}\n        exiting={FadeOut}\n        key={playing ? \"pause\" : \"play\"}\n        onPress={() => {\n          playing ? player.pause() : player.play()\n        }}\n      >\n        {playing ? (\n          <PauseCuteFiIcon color={color ?? label} width={size} height={size} />\n        ) : (\n          <PlayCuteFiIcon color={color ?? label} width={size} height={size} />\n        )}\n      </ReAnimatedPressable>\n    </View>\n  )\n}\nexport function SeekButton({\n  size = 24,\n  className,\n  color,\n  offset = 30,\n}: ControlButtonProps & {\n  offset?: number\n}) {\n  const label = useColor(\"label\")\n  return (\n    <View className={className}>\n      <Pressable\n        onPress={() => {\n          player.seekBy(offset)\n        }}\n      >\n        {offset === 30 ? (\n          <RewindForward30CuteReIcon color={color ?? label} width={size} height={size} />\n        ) : offset === -15 ? (\n          <RewindBackward15CuteReIcon color={color ?? label} width={size} height={size} />\n        ) : offset > 0 ? (\n          <Forward2CuteReIcon color={color ?? label} width={size} height={size} />\n        ) : (\n          <Back2CuteReIcon color={color ?? label} width={size} height={size} />\n        )}\n      </Pressable>\n    </View>\n  )\n}\nexport function RateSelector() {\n  const { isBackgroundLight } = usePlayerScreenContext()\n  const [currentRate, setCurrentRate] = useRate()\n  return (\n    <View className=\"flex-row items-center justify-center\">\n      <DropdownMenu.Root>\n        <DropdownMenu.Trigger>\n          <Text\n            className={cn(\n              \"w-[43] text-lg font-bold\",\n              isBackgroundLight ? \"text-black/70\" : \"text-white/70\",\n            )}\n          >\n            {currentRate}x\n          </Text>\n        </DropdownMenu.Trigger>\n        <DropdownMenu.Content>\n          {allowedRate.map((rate) => (\n            <DropdownMenu.CheckboxItem\n              value={rate === currentRate}\n              key={`${rate}`}\n              onSelect={() => setCurrentRate(rate)}\n            >\n              <DropdownMenu.ItemTitle>{`${rate}x`}</DropdownMenu.ItemTitle>\n            </DropdownMenu.CheckboxItem>\n          ))}\n        </DropdownMenu.Content>\n      </DropdownMenu.Root>\n    </View>\n  )\n}\nexport function StopButton({ size = 24, className, color }: ControlButtonProps) {\n  const label = useColor(\"label\")\n  const navigation = useNavigation()\n  return (\n    <Pressable\n      className={className}\n      onPress={() => {\n        player.reset()\n        navigation.back()\n      }}\n    >\n      <StopCircleCuteFiIcon color={color ?? label} width={size} height={size} />\n    </Pressable>\n  )\n}\nexport function ControlGroup() {\n  const { isBackgroundLight } = usePlayerScreenContext()\n  const buttonColor = isBackgroundLight ? \"black\" : \"white\"\n  return (\n    <View className=\"flex-row items-center justify-between\">\n      <RateSelector />\n      <SeekButton size={35} offset={-15} color={buttonColor} />\n      <PlayPauseButton size={50} color={buttonColor} />\n      <SeekButton size={35} offset={30} color={buttonColor} />\n      <View className=\"w-[43] flex-row justify-end\">\n        <StopButton color={buttonColor} />\n      </View>\n    </View>\n  )\n}\nconst formatSecondsToMinutes = (seconds: number) => {\n  const minutes = Math.floor(seconds / 60)\n  const remainingSeconds = Math.floor(seconds % 60)\n  const formattedMinutes = String(minutes).padStart(2, \"0\")\n  const formattedSeconds = String(remainingSeconds).padStart(2, \"0\")\n  return `${formattedMinutes}:${formattedSeconds}`\n}\nexport function ProgressBar() {\n  const { isBackgroundLight } = usePlayerScreenContext()\n  const { duration, position } = useProgress(250)\n  const isSliding = useSharedValue(false)\n  const progress = useDerivedValue(() => {\n    return duration > 0 ? position / duration : 0\n  })\n  const min = useSharedValue(0)\n  const max = useSharedValue(1)\n  const trackElapsedTime = formatSecondsToMinutes(position)\n  const trackRemainingTime = formatSecondsToMinutes(duration - position)\n  return (\n    <View className=\"my-6\">\n      <Slider\n        progress={progress}\n        minimumValue={min}\n        maximumValue={max}\n        thumbWidth={0}\n        containerStyle={styles.sliderTrack}\n        renderBubble={() => null}\n        theme={{\n          minimumTrackTintColor: \"rgba(255,255,255,0.6)\",\n          maximumTrackTintColor: \"rgba(255,255,255,0.4)\",\n        }}\n        onSlidingStart={() => (isSliding.value = true)}\n        onValueChange={async (value) => {\n          await player.seekTo(value * duration)\n        }}\n        onSlidingComplete={async (value) => {\n          if (!isSliding.value) return\n          isSliding.value = false\n          await player.seekTo(value * duration)\n        }}\n      />\n\n      <View className=\"mt-3 flex-row justify-between\">\n        <Text\n          style={styles.text}\n          className={cn(\n            \"font-mono text-xs font-medium opacity-75\",\n            isBackgroundLight ? \"text-black\" : \"text-white\",\n          )}\n        >\n          {trackElapsedTime}\n        </Text>\n\n        <Text\n          style={styles.text}\n          className={cn(\n            \"font-mono text-xs font-medium opacity-75\",\n            isBackgroundLight ? \"text-black\" : \"text-white\",\n          )}\n        >\n          {\"-\"}\n          {trackRemainingTime}\n        </Text>\n      </View>\n    </View>\n  )\n}\nexport function VolumeBar() {\n  const { isBackgroundLight } = usePlayerScreenContext()\n  const buttonColor = isBackgroundLight ? \"black\" : \"white\"\n  const { volume, updateVolume } = useVolume()\n  const progress = useSharedValue(0)\n  const min = useSharedValue(0)\n  const max = useSharedValue(1)\n  progress.value = volume ?? 0\n  return (\n    <View className=\"mb-10\">\n      <View className=\"flex-row items-center justify-between\">\n        <VolumeOffCuteReIcon height={15} width={15} color={buttonColor} />\n        <View className=\"flex-1 flex-row px-4\">\n          <Slider\n            progress={progress}\n            minimumValue={min}\n            containerStyle={styles.sliderTrack}\n            onValueChange={(value) => {\n              updateVolume(value)\n            }}\n            renderBubble={() => null}\n            theme={{\n              maximumTrackTintColor: \"rgba(255,255,255,0.4)\",\n              minimumTrackTintColor: \"rgba(255,255,255,0.6)\",\n            }}\n            thumbWidth={0}\n            maximumValue={max}\n          />\n        </View>\n        <VolumeCuteReIcon height={15} width={15} color={buttonColor} />\n      </View>\n    </View>\n  )\n}\nconst styles = StyleSheet.create({\n  text: {\n    fontVariant: [\"tabular-nums\"],\n  },\n  sliderTrack: {\n    height: 7,\n    borderRadius: 16,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/player/hooks.ts",
    "content": "import { useImageColors } from \"@follow/store/image/hooks\"\nimport { getLuminance, shadeColor } from \"@follow/utils\"\nimport { useMemo } from \"react\"\n\nconst defaultBackgroundColor = \"#000000\"\n\nexport function useCoverGradient(url?: string) {\n  const imageColors = useImageColors(url)\n\n  const backgroundColor = useMemo(() => {\n    if (imageColors?.platform === \"ios\") {\n      return imageColors.background\n    } else if (imageColors?.platform === \"android\") {\n      return imageColors.average\n    }\n    return defaultBackgroundColor\n  }, [imageColors])\n\n  const gradientColors = useMemo(() => {\n    const shadedColor = shadeColor(backgroundColor, -51)\n    return [shadedColor, shadedColor] as const\n  }, [backgroundColor])\n\n  const isGradientLight = useMemo(() => {\n    return getLuminance(gradientColors[0]) > 0.5\n  }, [gradientColors])\n\n  return { isGradientLight, gradientColors }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/review-prompt/debug.ts",
    "content": "let triggerReviewPromptDebug: (() => Promise<void>) | null = null\nlet resetReviewPromptDebug: (() => void) | null = null\n\nexport const setMobileReviewPromptDebugAction = (callback: (() => Promise<void>) | null) => {\n  triggerReviewPromptDebug = callback\n}\n\nexport const openMobileReviewPromptDebug = async () => {\n  await triggerReviewPromptDebug?.()\n}\n\nexport const setMobileReviewPromptResetAction = (callback: (() => void) | null) => {\n  resetReviewPromptDebug = callback\n}\n\nexport const resetMobileReviewPromptDebug = () => {\n  resetReviewPromptDebug?.()\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/review-prompt/provider.tsx",
    "content": "import { UserRole } from \"@follow/constants\"\nimport {\n  getReviewPromptEligibility,\n  recordReviewPromptActiveDay,\n  recordReviewPromptEntryOpen,\n  recordReviewPromptPaidConversion,\n  recordReviewPromptSubscriptionAdded,\n  syncReviewPromptSubscriptionCount,\n} from \"@follow/shared/review-prompt\"\nimport { useAllFeedSubscription, useAllListSubscription } from \"@follow/store/subscription/hooks\"\nimport { useUserRole } from \"@follow/store/user/hooks\"\nimport { tracker, TrackerMapper, trackManager } from \"@follow/tracker\"\nimport { nativeApplicationVersion } from \"expo-application\"\nimport { useAtomValue } from \"jotai\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport type { AppStateStatus } from \"react-native\"\nimport { AppState, InteractionManager } from \"react-native\"\n\nimport { dialogCountAtom } from \"@/src/lib/dialog-state\"\nimport { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport { PlanScreen } from \"@/src/modules/settings/routes/Plan\"\nimport { LoginScreen } from \"@/src/screens/(modal)/LoginScreen\"\nimport { OnboardingScreen } from \"@/src/screens/OnboardingScreen\"\n\nimport { setMobileReviewPromptDebugAction, setMobileReviewPromptResetAction } from \"./debug\"\nimport { useMobileReviewPromptState } from \"./use-review-prompt-state\"\nimport {\n  clearMobileReviewPromptState,\n  isMobileNativeReviewAvailable,\n  readMobileReviewPromptState,\n  requestMobileNativeReview,\n  REVIEW_PROMPT_QUIET_WINDOW_MS,\n  writeMobileReviewPromptState,\n} from \"./utils\"\n\nconst { routesAtom } = Navigation.rootNavigation.__dangerous_getCtxValue()\n\nexport const ReviewPromptProvider = () => {\n  const { currentState } = AppState\n  const role = useUserRole()\n  const routes = useAtomValue(routesAtom)\n  const dialogCount = useAtomValue(dialogCountAtom)\n  const feedSubscriptions = useAllFeedSubscription()\n  const listSubscriptions = useAllListSubscription()\n\n  const {\n    distribution,\n    getLatestReviewState,\n    platform,\n    reviewState,\n    storageKey,\n    updateReviewState,\n    userId,\n  } = useMobileReviewPromptState()\n\n  const [appState, setAppState] = useState(currentState)\n  const hasAttemptedInSessionRef = useRef(false)\n  const isHandlingPromptRef = useRef(false)\n  const roleRef = useRef(role)\n  const subscriptionCountRef = useRef(feedSubscriptions.length + listSubscriptions.length)\n  const appStateRef = useRef(appState)\n  const dialogCountRef = useRef(dialogCount)\n  const routesRef = useRef(routes)\n\n  subscriptionCountRef.current = feedSubscriptions.length + listSubscriptions.length\n  appStateRef.current = appState\n  dialogCountRef.current = dialogCount\n  routesRef.current = routes\n\n  useEffect(() => {\n    hasAttemptedInSessionRef.current = false\n    isHandlingPromptRef.current = false\n  }, [storageKey])\n\n  useEffect(() => {\n    if (!userId) {\n      return\n    }\n\n    const recordActiveDay = () => {\n      updateReviewState((state) => recordReviewPromptActiveDay(state, new Date()))\n    }\n\n    const handleAppStateChange = (nextAppState: AppStateStatus) => {\n      setAppState(nextAppState)\n      if (nextAppState === \"active\") {\n        recordActiveDay()\n      }\n    }\n\n    recordActiveDay()\n\n    // React Native AppState subscriptions are cleaned up with subscription.remove().\n    // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener\n    const subscription = AppState.addEventListener(\"change\", handleAppStateChange)\n\n    return () => {\n      subscription.remove()\n    }\n  }, [updateReviewState, userId])\n\n  useEffect(() => {\n    if (!userId) {\n      roleRef.current = role\n      return\n    }\n\n    const wasPaidUser = roleRef.current === UserRole.Pro || roleRef.current === UserRole.Plus\n    const isPaidUser = role === UserRole.Pro || role === UserRole.Plus\n    if (isPaidUser && !wasPaidUser) {\n      updateReviewState((state) => recordReviewPromptPaidConversion(state, new Date()))\n    }\n\n    roleRef.current = role\n  }, [role, updateReviewState, userId])\n\n  useEffect(() => {\n    if (!userId) {\n      return\n    }\n\n    updateReviewState((state) =>\n      syncReviewPromptSubscriptionCount(state, subscriptionCountRef.current),\n    )\n  }, [feedSubscriptions.length, listSubscriptions.length, updateReviewState, userId])\n\n  useEffect(() => {\n    if (!userId) {\n      return\n    }\n\n    return trackManager.setTrackFn((code) => {\n      switch (code) {\n        case TrackerMapper.NavigateEntry: {\n          updateReviewState((state) => recordReviewPromptEntryOpen(state))\n          break\n        }\n        case TrackerMapper.Subscribe: {\n          updateReviewState((state) =>\n            recordReviewPromptSubscriptionAdded(state, subscriptionCountRef.current),\n          )\n          break\n        }\n      }\n\n      return Promise.resolve()\n    })\n  }, [updateReviewState, userId])\n\n  const activeRoute = routes.at(-1) ?? null\n  const hasPresentedRoute = routes.some((route) => route.type !== \"push\")\n  const isBlockedRoute =\n    activeRoute?.Component === LoginScreen ||\n    activeRoute?.Component === OnboardingScreen ||\n    activeRoute?.Component === PlanScreen\n\n  const isPaidUser = role === UserRole.Pro || role === UserRole.Plus\n  const isInQuietWindow =\n    appState === \"active\" && !hasPresentedRoute && dialogCount === 0 && !isBlockedRoute\n  const isPlatformSupported = distribution !== \"unsupported\"\n\n  useEffect(() => {\n    if (!storageKey) {\n      setMobileReviewPromptDebugAction(null)\n      setMobileReviewPromptResetAction(null)\n      return\n    }\n\n    setMobileReviewPromptDebugAction(async () => {\n      const latestState = readMobileReviewPromptState(storageKey)\n      if (!(await isMobileNativeReviewAvailable(distribution))) {\n        return\n      }\n\n      tracker.reviewPromptShown({ distribution, platform, source: \"manual\" })\n      const nextState = await requestMobileNativeReview({\n        appVersion: nativeApplicationVersion ?? \"unknown\",\n        distribution,\n        platform,\n        source: \"manual\",\n        state: latestState,\n        storageKey,\n        trackPositive: true,\n      })\n      writeMobileReviewPromptState(storageKey, nextState)\n      updateReviewState(() => nextState)\n    })\n    setMobileReviewPromptResetAction(() => {\n      clearMobileReviewPromptState(storageKey)\n      hasAttemptedInSessionRef.current = false\n      isHandlingPromptRef.current = false\n      updateReviewState(() => readMobileReviewPromptState(storageKey))\n    })\n\n    return () => {\n      setMobileReviewPromptDebugAction(null)\n      setMobileReviewPromptResetAction(null)\n    }\n  }, [distribution, platform, storageKey, updateReviewState])\n\n  const eligibility = useMemo(\n    () =>\n      getReviewPromptEligibility({\n        appVersion: nativeApplicationVersion ?? \"unknown\",\n        isLoggedIn: !!userId,\n        isInQuietWindow,\n        isPaidUser,\n        isPlatformSupported,\n        now: new Date(),\n        state: reviewState,\n      }),\n    [isInQuietWindow, isPaidUser, isPlatformSupported, reviewState, userId],\n  )\n\n  useEffect(() => {\n    if (\n      !userId ||\n      hasAttemptedInSessionRef.current ||\n      isHandlingPromptRef.current ||\n      !eligibility.allowed\n    ) {\n      return\n    }\n\n    const timeoutId = setTimeout(() => {\n      if (hasAttemptedInSessionRef.current || isHandlingPromptRef.current) {\n        return\n      }\n\n      isHandlingPromptRef.current = true\n\n      void InteractionManager.runAfterInteractions(async () => {\n        const latestState = getLatestReviewState()\n        const latestEligibility = getReviewPromptEligibility({\n          appVersion: nativeApplicationVersion ?? \"unknown\",\n          isLoggedIn: !!userId,\n          isInQuietWindow:\n            appStateRef.current === \"active\" &&\n            !routesRef.current.some((route) => route.type !== \"push\") &&\n            dialogCountRef.current === 0 &&\n            !isBlockedRoute,\n          isPaidUser: roleRef.current === UserRole.Pro || roleRef.current === UserRole.Plus,\n          isPlatformSupported: distribution !== \"unsupported\",\n          now: new Date(),\n          state: latestState,\n        })\n\n        if (!latestEligibility.allowed) {\n          isHandlingPromptRef.current = false\n          return\n        }\n\n        const nativeAvailable = await isMobileNativeReviewAvailable(distribution)\n        if (!nativeAvailable) {\n          isHandlingPromptRef.current = false\n          return\n        }\n\n        tracker.reviewPromptEligible({\n          distribution,\n          platform,\n          score: latestEligibility.score,\n          source: \"auto\",\n        })\n        tracker.reviewPromptShown({\n          distribution,\n          platform,\n          score: latestEligibility.score,\n          source: \"auto\",\n        })\n\n        const nextState = await requestMobileNativeReview({\n          appVersion: nativeApplicationVersion ?? \"unknown\",\n          distribution,\n          platform,\n          score: latestEligibility.score,\n          source: \"auto\",\n          state: latestState,\n          storageKey,\n        })\n\n        updateReviewState(() => nextState)\n        hasAttemptedInSessionRef.current = true\n        isHandlingPromptRef.current = false\n      })\n    }, REVIEW_PROMPT_QUIET_WINDOW_MS)\n\n    return () => {\n      clearTimeout(timeoutId)\n    }\n  }, [\n    dialogCount,\n    distribution,\n    eligibility.allowed,\n    getLatestReviewState,\n    isBlockedRoute,\n    platform,\n    routes,\n    storageKey,\n    updateReviewState,\n    userId,\n  ])\n\n  return null\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/review-prompt/use-review-prompt-state.ts",
    "content": "import type { ReviewPromptState } from \"@follow/shared/review-prompt\"\nimport { normalizeReviewPromptState } from \"@follow/shared/review-prompt\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\n\nimport {\n  getMobileReviewDistribution,\n  getMobileReviewPlatform,\n  getMobileReviewRateTarget,\n  getMobileReviewStorageKey,\n  readMobileReviewPromptState,\n  writeMobileReviewPromptState,\n} from \"./utils\"\n\nexport const useMobileReviewPromptState = () => {\n  const user = useWhoami()\n  const distribution = getMobileReviewDistribution()\n  const platform = getMobileReviewPlatform()\n  const rateTarget = getMobileReviewRateTarget()\n\n  const storageKey = useMemo(() => {\n    if (!user?.id) {\n      return null\n    }\n\n    return getMobileReviewStorageKey(user.id, distribution)\n  }, [distribution, user?.id])\n\n  const [reviewState, setReviewState] = useState(() => readMobileReviewPromptState(storageKey))\n\n  useEffect(() => {\n    setReviewState(readMobileReviewPromptState(storageKey))\n  }, [storageKey])\n\n  const getLatestReviewState = useCallback(\n    () => readMobileReviewPromptState(storageKey),\n    [storageKey],\n  )\n\n  const updateReviewState = useCallback(\n    (updater: (state: ReviewPromptState) => ReviewPromptState) => {\n      const nextState = normalizeReviewPromptState(updater(getLatestReviewState()))\n      writeMobileReviewPromptState(storageKey, nextState)\n      setReviewState(nextState)\n      return nextState\n    },\n    [getLatestReviewState, storageKey],\n  )\n\n  return {\n    distribution,\n    getLatestReviewState,\n    platform,\n    rateTarget,\n    reviewState,\n    storageKey,\n    updateReviewState,\n    userId: user?.id ?? null,\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/review-prompt/utils.ts",
    "content": "import type { ReviewPromptState } from \"@follow/shared/review-prompt\"\nimport { normalizeReviewPromptState, recordReviewPromptOutcome } from \"@follow/shared/review-prompt\"\nimport { tracker } from \"@follow/tracker\"\nimport { nativeApplicationVersion, nativeBuildVersion } from \"expo-application\"\nimport * as StoreReview from \"expo-store-review\"\nimport { Linking } from \"react-native\"\n\nimport { kv } from \"@/src/lib/kv\"\nimport { isAndroidApkInstall } from \"@/src/lib/payment\"\nimport { isAndroid, isIOS } from \"@/src/lib/platform\"\n\nexport const REVIEW_PROMPT_QUIET_WINDOW_MS = 5000\n\nexport type MobileReviewDistribution = \"ios_app_store\" | \"google_play\" | \"unsupported\"\nexport type MobileReviewRateTarget = \"ios_app_store\" | \"google_play\" | null\n\nconst APPLE_REVIEW_URL =\n  \"https://apps.apple.com/us/app/folo-follow-everything/id6739802604?action=write-review\"\nconst GOOGLE_PLAY_REVIEW_URI = \"market://details?id=is.follow&showAllReviews=true\"\nconst GOOGLE_PLAY_REVIEW_URL = \"https://play.google.com/store/apps/details?id=is.follow\"\nconst SUPPORT_EMAIL = \"support@folo.is\"\nconst REVIEW_PROMPT_STORAGE_PREFIX = \"follow-rn-review-prompt\"\n\nexport const getMobileReviewPlatform = () => (isIOS ? \"ios\" : isAndroid ? \"android\" : \"mobile\")\n\nexport const getMobileReviewDistribution = (): MobileReviewDistribution => {\n  if (isIOS) {\n    return \"ios_app_store\"\n  }\n\n  if (isAndroid && !isAndroidApkInstall()) {\n    return \"google_play\"\n  }\n\n  return \"unsupported\"\n}\n\nexport const getMobileReviewRateTarget = (): MobileReviewRateTarget => {\n  if (isIOS) {\n    return \"ios_app_store\"\n  }\n\n  if (isAndroid) {\n    return \"google_play\"\n  }\n\n  return null\n}\n\nexport const getMobileReviewStorageKey = (userId: string, distribution: MobileReviewDistribution) =>\n  `${REVIEW_PROMPT_STORAGE_PREFIX}:${distribution}:${userId}`\n\nexport const readMobileReviewPromptState = (storageKey: string | null): ReviewPromptState => {\n  if (!storageKey) {\n    return normalizeReviewPromptState(null)\n  }\n\n  try {\n    const raw = kv.getSync(storageKey)\n    if (!raw) {\n      return normalizeReviewPromptState(null)\n    }\n    return normalizeReviewPromptState(JSON.parse(raw) as Partial<ReviewPromptState>)\n  } catch {\n    return normalizeReviewPromptState(null)\n  }\n}\n\nexport const writeMobileReviewPromptState = (\n  storageKey: string | null,\n  state: ReviewPromptState,\n) => {\n  if (!storageKey) {\n    return\n  }\n\n  kv.setSync(storageKey, JSON.stringify(state))\n}\n\nexport const clearMobileReviewPromptState = (storageKey: string | null) => {\n  if (!storageKey) {\n    return\n  }\n\n  kv.delete(storageKey)\n}\n\nconst openStoreUrl = async (target: MobileReviewRateTarget) => {\n  switch (target) {\n    case \"ios_app_store\": {\n      await Linking.openURL(APPLE_REVIEW_URL)\n      return\n    }\n    case \"google_play\": {\n      try {\n        await Linking.openURL(GOOGLE_PLAY_REVIEW_URI)\n      } catch {\n        await Linking.openURL(GOOGLE_PLAY_REVIEW_URL)\n      }\n      return\n    }\n  }\n}\n\nexport const openMobileFeedbackEmail = async ({\n  distribution,\n  userId,\n}: {\n  distribution: MobileReviewDistribution\n  userId: string | null\n}) => {\n  const subject = \"Folo feedback\"\n  const body = [\n    \"Hi Folo team,\",\n    \"\",\n    \"Here is my feedback:\",\n    \"\",\n    `Platform: ${getMobileReviewPlatform()}`,\n    `Distribution: ${distribution}`,\n    `Version: ${nativeApplicationVersion ?? \"unknown\"}`,\n    `Build: ${nativeBuildVersion ?? \"unknown\"}`,\n    `User ID: ${userId ?? \"anonymous\"}`,\n  ].join(\"\\n\")\n\n  await Linking.openURL(\n    `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`,\n  )\n}\n\nexport const isMobileNativeReviewAvailable = async (distribution: MobileReviewDistribution) => {\n  if (distribution === \"unsupported\") {\n    return false\n  }\n\n  try {\n    return await StoreReview.isAvailableAsync()\n  } catch {\n    return false\n  }\n}\n\nexport const requestMobileNativeReview = async ({\n  appVersion,\n  distribution,\n  platform,\n  score,\n  source,\n  state,\n  storageKey,\n  trackPositive = false,\n}: {\n  appVersion: string\n  distribution: MobileReviewDistribution\n  platform: string\n  score?: number\n  source: \"auto\" | \"manual\"\n  state: ReviewPromptState\n  storageKey: string | null\n  trackPositive?: boolean\n}) => {\n  const nextState = recordReviewPromptOutcome(state, \"native_request\", new Date(), appVersion)\n  writeMobileReviewPromptState(storageKey, nextState)\n\n  if (trackPositive) {\n    tracker.reviewPromptPositive({ distribution, platform, source })\n  }\n  tracker.reviewPromptNativeRequested({ distribution, platform, score, source })\n  await StoreReview.requestReview()\n  return nextState\n}\n\nexport const openMobileStoreReview = async ({\n  appVersion,\n  distribution,\n  platform,\n  source,\n  state,\n  storageKey,\n  target,\n}: {\n  appVersion: string\n  distribution: MobileReviewDistribution\n  platform: string\n  source: \"auto\" | \"manual\"\n  state: ReviewPromptState\n  storageKey: string | null\n  target: MobileReviewRateTarget\n}) => {\n  if (!target) {\n    return null\n  }\n\n  const nextState = recordReviewPromptOutcome(\n    state,\n    \"positive_store_redirect\",\n    new Date(),\n    appVersion,\n  )\n  writeMobileReviewPromptState(storageKey, nextState)\n\n  tracker.reviewPromptPositive({ distribution, platform, source })\n  tracker.reviewPromptStoreOpened({ distribution, platform, source })\n  await openStoreUrl(target)\n\n  return nextState\n}\n\nexport const persistMobileNegativeFeedback = ({\n  appVersion,\n  distribution,\n  platform,\n  source,\n  state,\n  storageKey,\n}: {\n  appVersion: string\n  distribution: MobileReviewDistribution\n  platform: string\n  source: \"auto\" | \"manual\"\n  state: ReviewPromptState\n  storageKey: string | null\n}) => {\n  const nextState = recordReviewPromptOutcome(state, \"negative_feedback\", new Date(), appVersion)\n  writeMobileReviewPromptState(storageKey, nextState)\n\n  tracker.reviewPromptNegative({ distribution, platform, source })\n  tracker.reviewPromptFeedbackOpened({ distribution, platform, source })\n\n  return nextState\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/rsshub/preview-url.tsx",
    "content": "import { cn, regexpPathToPath } from \"@follow/utils\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\nimport type { UseFormReturn } from \"react-hook-form\"\nimport { Clipboard, ScrollView, View } from \"react-native\"\n\nimport { CopyButton } from \"@/src/components/common/CopyButton\"\nimport { MonoText } from \"@/src/components/ui/typography/MonoText\"\n\nexport const PreviewUrl: FC<{\n  watch: UseFormReturn<any>[\"watch\"]\n  path: string\n  routePrefix: string\n  className?: string\n}> = ({ watch, path, routePrefix, className }) => {\n  const data = watch()\n\n  const fullPath = useMemo(() => {\n    try {\n      return regexpPathToPath(path, data)\n    } catch (err: unknown) {\n      console.info((err as Error).message)\n      return path\n    }\n  }, [path, data])\n\n  const renderedPath = `rsshub://${routePrefix}${fullPath}`\n  return (\n    <View className={cn(\"relative min-w-0 rounded-lg bg-gray-2/20 px-4 py-3\", className)}>\n      <ScrollView\n        horizontal\n        showsHorizontalScrollIndicator={false}\n        contentContainerClassName=\"pr-12\"\n      >\n        <MonoText\n          className=\"w-full whitespace-nowrap break-words text-sm text-text/80\"\n          numberOfLines={1}\n        >\n          {renderedPath}\n        </MonoText>\n      </ScrollView>\n      <CopyButton\n        size=\"tiny\"\n        onCopy={() => {\n          Clipboard.setString(renderedPath)\n        }}\n        className=\"absolute right-1.5 top-2\"\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/PagerList.ios.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { useViewWithSubscription } from \"@follow/store/subscription/hooks\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport * as Haptics from \"expo-haptics\"\nimport { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { Freeze } from \"react-freeze\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { clamp, withTiming } from \"react-native-reanimated\"\n\nimport { PagerView } from \"@/src/components/native/PagerView\"\nimport type { PagerRef } from \"@/src/components/native/PagerView/specs\"\n\nimport { selectTimeline, useSelectedFeed, useTimelineSelectorDragProgress } from \"./atoms\"\nimport { PagerListVisibleContext, PagerListWillVisibleContext } from \"./PagerListContext\"\n\nexport function PagerList({\n  renderItem,\n  style,\n}: {\n  renderItem: (view: FeedViewType, active: boolean) => React.ReactNode\n  style?: StyleProp<ViewStyle> | undefined\n}) {\n  const selectedFeed = useSelectedFeed()\n  const viewId = selectedFeed?.type === \"view\" ? selectedFeed.viewId : undefined\n\n  const activeViews = useViewWithSubscription()\n  const activeViewIndex = useMemo(\n    () => activeViews.indexOf(viewId as FeedViewType),\n    [activeViews, viewId],\n  )\n  const [initialPageIndex] = useState(activeViewIndex)\n  const pagerRef = useRef<PagerRef>(null)\n  const lastProgressRef = useRef(activeViewIndex)\n  const rid = useId()\n  const dragProgress = useTimelineSelectorDragProgress()\n\n  useLayoutEffect(() => {\n    return EventBus.subscribe(\"SELECT_TIMELINE\", (data) => {\n      if (data.target !== rid) {\n        const targetIndex = activeViews.indexOf(data.view.viewId)\n        pagerRef.current?.setPage(targetIndex)\n        lastProgressRef.current = targetIndex\n        dragProgress.set(withTiming(targetIndex))\n      }\n    })\n  }, [activeViews, dragProgress, pagerRef, rid])\n  const [dragging, setDragging] = useState(false)\n  const updateDragProgress = useCallback(\n    (nextProgress: number) => {\n      if (Math.abs(nextProgress - lastProgressRef.current) < 0.001) {\n        return\n      }\n      lastProgressRef.current = nextProgress\n      dragProgress.set(nextProgress)\n    },\n    [dragProgress],\n  )\n\n  return (\n    <PagerView\n      ref={pagerRef}\n      initialPageIndex={initialPageIndex}\n      onScroll={(percent, direction, position) => {\n        if (!dragging) return\n        const progress = clamp(\n          percent * (direction === \"left\" ? -1 : direction === \"right\" ? 1 : 0) + position,\n          0,\n          activeViews.length - 1,\n        )\n        updateDragProgress(progress)\n      }}\n      onScrollBegin={useCallback(() => {\n        setDragging(true)\n        lastProgressRef.current = activeViewIndex\n      }, [activeViewIndex])}\n      onScrollEnd={useCallback(\n        (index: number) => {\n          setDragging(false)\n          lastProgressRef.current = index\n          dragProgress.set(withTiming(index))\n        },\n        [dragProgress],\n      )}\n      pageContainerClassName=\"flex-1\"\n      containerClassName=\"flex-1 absolute inset-0\"\n      containerStyle={style}\n      onPageChange={useTypeScriptHappyCallback(\n        (targetIndex) => {\n          selectTimeline({ type: \"view\", viewId: activeViews[targetIndex]! }, rid)\n          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)\n        },\n        [activeViews, rid],\n      )}\n      renderPage={useTypeScriptHappyCallback(\n        (index) => {\n          const isActive = index === activeViewIndex\n          const willVisible =\n            (index === activeViewIndex + 1 || index === activeViewIndex - 1) && dragging\n          const freeze = !(isActive || willVisible)\n          return (\n            <PagerListVisibleContext value={isActive} key={activeViews[index]!}>\n              <PagerListWillVisibleContext value={willVisible}>\n                <Freeze freeze={freeze}>{renderItem(activeViews[index]!, isActive)}</Freeze>\n              </PagerListWillVisibleContext>\n            </PagerListVisibleContext>\n          )\n        },\n\n        [activeViews, activeViewIndex, dragging, renderItem],\n      )}\n      pageTotal={activeViews.length}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/PagerList.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useViewWithSubscription } from \"@follow/store/subscription/hooks\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport * as Haptics from \"expo-haptics\"\nimport { useCallback, useEffect, useId, useMemo, useRef } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { StyleSheet, View } from \"react-native\"\nimport type {\n  PagerViewOnPageScrollEventData,\n  PagerViewOnPageSelectedEventData,\n  PageScrollStateChangedNativeEventData,\n} from \"react-native-pager-view\"\nimport PagerView from \"react-native-pager-view\"\nimport Animated, { runOnJS, useEvent, useHandler } from \"react-native-reanimated\"\n\nimport { selectTimeline, useSelectedFeed, useTimelineSelectorDragProgress } from \"./atoms\"\nimport { PagerListVisibleContext, PagerListWillVisibleContext } from \"./PagerListContext\"\n\nconst AnimatedPagerView = Animated.createAnimatedComponent(PagerView)\n\nconst handlePageSelected = () => {\n  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)\n}\n\nexport function PagerList({\n  renderItem,\n  style,\n}: {\n  renderItem: (view: FeedViewType, active: boolean) => React.ReactNode\n  style?: StyleProp<ViewStyle> | undefined\n}) {\n  const selectedFeed = useSelectedFeed()\n  const viewId = selectedFeed?.type === \"view\" ? selectedFeed.viewId : undefined\n\n  const activeViews = useViewWithSubscription()\n\n  const activeViewIndex = useMemo(\n    () => activeViews.indexOf(viewId as FeedViewType),\n    [activeViews, viewId],\n  )\n  const dragProgress = useTimelineSelectorDragProgress()\n\n  const pagerRef = useRef<PagerView>(null)\n\n  const rid = useId()\n  useEffect(() => {\n    return EventBus.subscribe(\"SELECT_TIMELINE\", (data) => {\n      if (data.target !== rid) {\n        pagerRef.current?.setPage(activeViews.indexOf(data.view.viewId))\n      }\n    })\n  }, [activeViews, pagerRef, rid])\n\n  const onPageSelectedJS = useCallback(\n    (view: FeedViewType) => {\n      selectTimeline({ type: \"view\", viewId: view }, rid)\n    },\n    [rid],\n  )\n\n  const handlePageScroll = usePagerHandlers(\n    {\n      onPageScroll(e: PagerViewOnPageScrollEventData) {\n        \"worklet\"\n        const { position, offset } = e\n        dragProgress.set(offset + position)\n      },\n      onPageSelected(e: PagerViewOnPageSelectedEventData) {\n        \"worklet\"\n        runOnJS(onPageSelectedJS)(activeViews[e.position]!)\n      },\n    },\n    [],\n  )\n\n  return (\n    <AnimatedPagerView\n      testID=\"pager-view\"\n      ref={pagerRef}\n      style={[styles.PagerView, style]}\n      initialPage={activeViewIndex}\n      layoutDirection=\"ltr\"\n      offscreenPageLimit={1}\n      overdrag\n      onPageScroll={handlePageScroll}\n      onPageSelected={handlePageSelected}\n      orientation=\"horizontal\"\n    >\n      {useMemo(\n        () =>\n          activeViews.map((view, index) => {\n            const isActive = index === activeViewIndex\n            const willVisible = index === activeViewIndex + 1 || index === activeViewIndex - 1\n            if (!isActive && !willVisible) {\n              return <View key={view} />\n            }\n            return (\n              <PagerListVisibleContext value={isActive} key={view}>\n                <PagerListWillVisibleContext value={willVisible}>\n                  {renderItem(view, isActive)}\n                </PagerListWillVisibleContext>\n              </PagerListVisibleContext>\n            )\n          }),\n        [activeViews, activeViewIndex, renderItem],\n      )}\n    </AnimatedPagerView>\n  )\n}\n\n/**\n * Ported from bluesky-social/social-app\n * https://github.com/bluesky-social/social-app/blob/bf95345b333c56876cabf4c5b8516c431cc8ce9b/src/view/com/pager/Pager.tsx#L159-L190\n */\nfunction usePagerHandlers(\n  handlers: {\n    onPageScroll: (e: PagerViewOnPageScrollEventData) => void\n    onPageScrollStateChanged?: (e: PageScrollStateChangedNativeEventData) => void\n    onPageSelected?: (e: PagerViewOnPageSelectedEventData) => void\n  },\n  dependencies: unknown[],\n) {\n  const { doDependenciesDiffer } = useHandler(handlers as any, dependencies)\n  const subscribeForEvents = [\"onPageScroll\", \"onPageScrollStateChanged\", \"onPageSelected\"]\n  return useEvent(\n    (event) => {\n      \"worklet\"\n      const { onPageScroll, onPageScrollStateChanged, onPageSelected } = handlers\n      if (event.eventName.endsWith(\"onPageScroll\")) {\n        onPageScroll(event as any as PagerViewOnPageScrollEventData)\n      } else if (event.eventName.endsWith(\"onPageScrollStateChanged\")) {\n        onPageScrollStateChanged?.(event as any as PageScrollStateChangedNativeEventData)\n      } else if (event.eventName.endsWith(\"onPageSelected\")) {\n        onPageSelected?.(event as any as PagerViewOnPageSelectedEventData)\n      }\n    },\n    subscribeForEvents,\n    doDependenciesDiffer,\n  )\n}\n\nconst styles = StyleSheet.create({\n  container: {\n    flex: 1,\n  },\n  PagerView: {\n    flex: 1,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/PagerListContext.ts",
    "content": "import { createContext } from \"react\"\n\nexport const PagerListVisibleContext = createContext(true)\nexport const PagerListWillVisibleContext = createContext(false)\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/TimelineSelectorList.tsx",
    "content": "import { useTypeScriptHappyCallback } from \"@follow/hooks\"\nimport { usePrefetchSubscription } from \"@follow/store/subscription/hooks\"\nimport { usePrefetchUnread } from \"@follow/store/unread/hooks\"\nimport { nextFrame } from \"@follow/utils\"\nimport type { FlashListProps, FlashListRef } from \"@shopify/flash-list\"\nimport { FlashList } from \"@shopify/flash-list\"\nimport * as Haptics from \"expo-haptics\"\nimport { use, useCallback, useImperativeHandle, useRef } from \"react\"\nimport type { NativeScrollEvent, NativeSyntheticEvent } from \"react-native\"\nimport { RefreshControl, View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { useBottomTabBarHeight } from \"@/src/components/layouts/tabbar/hooks\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\nimport { useHeaderHeight } from \"@/src/modules/screen/hooks/useHeaderHeight\"\n\nimport { EntryListEmpty } from \"../entry-list/EntryListEmpty\"\n\ntype Props = {\n  onRefresh: () => void\n  isRefetching: boolean\n}\n\nexport const TimelineSelectorList = ({\n  ref: forwardedRef,\n  onRefresh,\n  isRefetching,\n  ...props\n}: Props &\n  Omit<FlashListProps<any>, \"onRefresh\"> & { ref?: React.Ref<FlashListRef<any> | null> }) => {\n  const ref = useRef<FlashListRef<any>>(null)\n  useImperativeHandle(forwardedRef, () => ref.current!)\n  const { refetch: unreadRefetch } = usePrefetchUnread()\n  const { refetch: subscriptionRefetch } = usePrefetchSubscription()\n\n  const headerHeight = useHeaderHeight()\n  const { scrollViewHeight, scrollViewContentHeight, reAnimatedScrollY } = use(ScreenItemContext)!\n\n  const tabBarHeight = useBottomTabBarHeight()\n  const onScroll = useCallback(\n    (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n      props.onScroll?.(e)\n\n      reAnimatedScrollY.value = e.nativeEvent.contentOffset.y\n    },\n    [props, reAnimatedScrollY],\n  )\n  const systemFill = useColor(\"secondaryLabel\")\n\n  const onLayout = useTypeScriptHappyCallback(\n    (e) => {\n      scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight - tabBarHeight\n    },\n    [scrollViewHeight],\n  ) as FlashListProps<any>[\"onLayout\"]\n\n  const onContentSizeChange = useTypeScriptHappyCallback(\n    (w, h) => {\n      scrollViewContentHeight.value = h\n    },\n    [scrollViewContentHeight],\n  ) as FlashListProps<any>[\"onContentSizeChange\"]\n\n  if (props.data?.length === 0) {\n    return <EntryListEmpty />\n  }\n\n  return (\n    <View style={props.style} className=\"flex-1\">\n      <FlashList\n        automaticallyAdjustsScrollIndicatorInsets={false}\n        automaticallyAdjustContentInsets={false}\n        ref={ref}\n        onLayout={onLayout}\n        onContentSizeChange={onContentSizeChange}\n        refreshControl={\n          <RefreshControl\n            progressViewOffset={headerHeight}\n            // // FIXME: not sure why we need set tintColor manually here, otherwise we can not see the refresh indicator\n            tintColor={systemFill}\n            onRefresh={() => {\n              unreadRefetch()\n              subscriptionRefetch()\n              Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)\n              onRefresh()\n            }}\n            refreshing={isRefetching}\n          />\n        }\n        scrollIndicatorInsets={{\n          top: headerHeight,\n          bottom: tabBarHeight,\n        }}\n        contentContainerStyle={{\n          paddingTop: headerHeight,\n          paddingBottom: tabBarHeight,\n        }}\n        {...props}\n        onScroll={onScroll}\n        onEndReached={() => {\n          nextFrame(() => {\n            props.onEndReached?.()\n          })\n        }}\n      />\n    </View>\n  )\n}\n\nexport const TimelineSelectorMasonryList = ({\n  ref,\n  onRefresh,\n  isRefetching,\n  ...props\n}: Props &\n  Omit<FlashListProps<any>, \"onRefresh\"> & {\n    ref?: React.Ref<FlashListRef<any> | null>\n  }) => {\n  const { refetch: unreadRefetch } = usePrefetchUnread()\n  const { refetch: subscriptionRefetch } = usePrefetchSubscription()\n\n  const insets = useSafeAreaInsets()\n\n  const headerHeight = useHeaderHeight()\n\n  const { reAnimatedScrollY } = use(ScreenItemContext)!\n\n  const onScroll = useCallback(\n    (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n      props.onScroll?.(e)\n      reAnimatedScrollY.value = e.nativeEvent.contentOffset.y\n    },\n    [props, reAnimatedScrollY],\n  )\n\n  const tabBarHeight = useBottomTabBarHeight()\n\n  const systemFill = useColor(\"secondaryLabel\")\n\n  if (props.data?.length === 0) {\n    return <EntryListEmpty />\n  }\n\n  return (\n    <FlashList\n      ref={ref}\n      masonry\n      refreshControl={\n        <RefreshControl\n          progressViewOffset={headerHeight}\n          // // FIXME: not sure why we need set tintColor manually here, otherwise we can not see the refresh indicator\n          tintColor={systemFill}\n          onRefresh={() => {\n            unreadRefetch()\n            subscriptionRefetch()\n            onRefresh()\n          }}\n          refreshing={isRefetching}\n        />\n      }\n      {...props}\n      contentContainerStyle={[\n        {\n          paddingTop: headerHeight,\n          paddingBottom: tabBarHeight,\n        },\n        props.contentContainerStyle,\n      ]}\n      scrollIndicatorInsets={{\n        top: headerHeight - insets.top,\n        bottom: tabBarHeight ? tabBarHeight - insets.bottom : undefined,\n        ...props.scrollIndicatorInsets,\n      }}\n      onScroll={onScroll}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx",
    "content": "import { useIsLoggedIn } from \"@follow/store/user/hooks\"\nimport type { FC } from \"react\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport { View } from \"react-native\"\nimport Animated, {\n  runOnJS,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\n\nimport { FakeNativeHeaderTitle } from \"@/src/components/layouts/header/FakeNativeHeaderTitle\"\nimport { DefaultHeaderBackButton } from \"@/src/components/layouts/header/NavigationHeader\"\nimport { NavigationBlurEffectHeader } from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { gentleSpringPreset } from \"@/src/constants/spring\"\nimport { TIMELINE_VIEW_SELECTOR_HEIGHT } from \"@/src/constants/ui\"\nimport { useIsTabletLayout } from \"@/src/lib/responsive\"\nimport {\n  ActionGroup,\n  FeedShareActionButton,\n  HomeLeftAction,\n  MarkAllAsReadActionButton,\n  UnreadOnlyActionButton,\n} from \"@/src/modules/screen/action\"\nimport { TimelineViewSelector } from \"@/src/modules/screen/TimelineViewSelector\"\n\nimport { useEntries, useEntryListContext, useSelectedFeedTitle } from \"./atoms\"\n\nexport function TimelineHeader({ feedId }: { feedId?: string }) {\n  const viewTitle = useSelectedFeedTitle()\n  const screenType = useEntryListContext().type\n  const isLoggedIn = useIsLoggedIn()\n\n  const isFeed = screenType === \"feed\"\n  const isTimeline = screenType === \"timeline\"\n  const isSubscriptions = screenType === \"subscriptions\"\n  const isTablet = useIsTabletLayout()\n\n  const { isFetching } = useEntries()\n  const shouldHideDuplicatedTitle = isTablet && (isTimeline || isSubscriptions)\n\n  return (\n    <NavigationBlurEffectHeader\n      headerTitle={shouldHideDuplicatedTitle ? undefined : <AnimatedTitle title={viewTitle} />}\n      isLoading={(isFeed || isTimeline) && isFetching}\n      headerLeft={useMemo(\n        () =>\n          isTimeline || isSubscriptions\n            ? () => <HomeLeftAction />\n            : () => <DefaultHeaderBackButton canDismiss={false} canGoBack={true} />,\n        [isTimeline, isSubscriptions],\n      )}\n      headerRight={useMemo(() => {\n        return () => (\n          <View className=\"flex-row items-center justify-end\">\n            <ActionGroup>\n              {isLoggedIn && <UnreadOnlyActionButton />}\n              {isLoggedIn && <MarkAllAsReadActionButton />}\n              <FeedShareActionButton feedId={feedId} />\n            </ActionGroup>\n          </View>\n        )\n      }, [feedId, isLoggedIn])}\n      headerHideableBottom={isTimeline || isSubscriptions ? TimelineViewSelector : undefined}\n      headerHideableBottomHeight={TIMELINE_VIEW_SELECTOR_HEIGHT}\n    />\n  )\n}\n\nconst AnimatedTitle: FC<{\n  title: string | undefined\n}> = ({ title }) => {\n  // Track titles and animation state\n  const [displayedTitle, setDisplayedTitle] = useState(title)\n  const [oldTitle, setOldTitle] = useState<string | undefined>()\n  const [isAnimating, setIsAnimating] = useState(false)\n  const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)\n\n  // Animation values - only opacity for cross-fade effect\n  const newTitleOpacity = useSharedValue(1)\n  const oldTitleOpacity = useSharedValue(0)\n\n  // Handle title changes with cross-fade animation\n  useEffect(() => {\n    if (title !== displayedTitle && title !== undefined && !isAnimating) {\n      setIsAnimating(true)\n      setOldTitle(displayedTitle)\n\n      // Start cross-fade animation\n      oldTitleOpacity.value = 1\n      newTitleOpacity.value = 0\n\n      // Fade out old title and fade in new title\n      oldTitleOpacity.value = withSpring(0, gentleSpringPreset)\n\n      // Update displayed title and fade in\n      timeoutRef.current = setTimeout(() => {\n        setDisplayedTitle(title)\n        newTitleOpacity.value = withSpring(1, gentleSpringPreset, () => {\n          runOnJS(setIsAnimating)(false)\n          runOnJS(setOldTitle)(void 0)\n        })\n      }, 150)\n    }\n  }, [title, displayedTitle, isAnimating, newTitleOpacity, oldTitleOpacity])\n\n  // Cleanup timeout on unmount\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current)\n      }\n    }\n  }, [])\n\n  // Initialize displayed title\n  useEffect(() => {\n    if (displayedTitle === undefined && title !== undefined) {\n      setDisplayedTitle(title)\n    }\n  }, [title, displayedTitle])\n\n  const newTitleStyle = useAnimatedStyle(() => ({\n    opacity: newTitleOpacity.value,\n  }))\n\n  const oldTitleStyle = useAnimatedStyle(() => ({\n    opacity: oldTitleOpacity.value,\n  }))\n\n  return (\n    <View className=\"relative\">\n      {/* Current/New Title */}\n      <Animated.View style={newTitleStyle}>\n        <FakeNativeHeaderTitle>{displayedTitle}</FakeNativeHeaderTitle>\n      </Animated.View>\n\n      {/* Old Title (during transition) */}\n      {oldTitle && isAnimating && (\n        <Animated.View className=\"absolute inset-0\" style={oldTitleStyle}>\n          <FakeNativeHeaderTitle>{oldTitle}</FakeNativeHeaderTitle>\n        </Animated.View>\n      )}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/TimelineViewSelector.tsx",
    "content": "import { useViewWithSubscription } from \"@follow/store/subscription/hooks\"\nimport { useUnreadByView } from \"@follow/store/unread/hooks\"\nimport { cn } from \"@follow/utils\"\nimport * as React from \"react\"\nimport { useEffect } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { ScrollView, StyleSheet, Text, useWindowDimensions, View } from \"react-native\"\nimport Animated, { interpolate, interpolateColor, useAnimatedStyle } from \"react-native-reanimated\"\n\nimport { ReAnimatedPressable } from \"@/src/components/common/AnimatedComponents\"\nimport { TIMELINE_VIEW_SELECTOR_HEIGHT } from \"@/src/constants/ui\"\nimport type { ViewDefinition } from \"@/src/constants/views\"\nimport { views } from \"@/src/constants/views\"\nimport { useIsTabletLayout, useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport {\n  selectTimeline,\n  useSelectedFeed,\n  useTimelineSelectorDragProgress,\n} from \"@/src/modules/screen/atoms\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { UnreadCount } from \"../subscription/items/UnreadCount\"\nimport { TimelineViewSelectorContextMenu } from \"./TimelineViewSelectorContextMenu\"\n\nconst ACTIVE_WIDTH = 180\nconst INACTIVE_WIDTH = 48\nconst ACTIVE_TEXT_WIDTH = 100\nconst MAX_TABLET_ACTIVE_WIDTH = 280\nconst styles = StyleSheet.create({\n  scrollView: {\n    width: \"100%\",\n  },\n})\nexport function TimelineViewSelector() {\n  const activeViews = useViewWithSubscription()\n  const scrollViewRef = React.useRef<ScrollView | null>(null)\n  const selectedFeed = useSelectedFeed()\n  const readableContainerStyle = useReadableContainerStyle(760, 12)\n  const activeViewCount = activeViews.length\n  return (\n    <View\n      className=\"flex items-center justify-between py-2\"\n      style={{\n        height: TIMELINE_VIEW_SELECTOR_HEIGHT,\n      }}\n    >\n      <View style={readableContainerStyle}>\n        <ScrollView\n          ref={scrollViewRef}\n          style={styles.scrollView}\n          horizontal\n          scrollsToTop={false}\n          contentContainerClassName=\"flex-row items-center px-3\"\n          contentContainerStyle={\n            activeViewCount > 0\n              ? {\n                  minWidth: \"100%\",\n                  justifyContent: \"center\",\n                  gap: 12,\n                }\n              : undefined\n          }\n          showsHorizontalScrollIndicator={false}\n        >\n          {activeViews.map((v, index) => {\n            const view = views.find((view) => view.view === v)\n            if (!view) return null\n            return (\n              <ViewItem\n                key={view.name}\n                index={index}\n                view={view}\n                scrollViewRef={scrollViewRef}\n                isActive={selectedFeed?.type === \"view\" && selectedFeed.viewId === view.view}\n              />\n            )\n          })}\n        </ScrollView>\n      </View>\n    </View>\n  )\n}\nfunction ItemWrapper({\n  index,\n  activeColor,\n  children,\n  onPress,\n  style,\n  className,\n  testID,\n}: {\n  children: React.ReactNode\n  index: number\n  isActive: boolean\n  activeColor: string\n  onPress: () => void\n  className?: string\n  style?: Exclude<StyleProp<ViewStyle>, number>\n  testID?: string\n}) {\n  const { width: windowWidth } = useWindowDimensions()\n  const activeViews = useViewWithSubscription()\n  const dragProgress = useTimelineSelectorDragProgress()\n  const isTablet = useIsTabletLayout()\n  const activeWidth = Math.max(\n    windowWidth - (INACTIVE_WIDTH + 12) * (activeViews.length - 1) - 8 * 2,\n    ACTIVE_WIDTH,\n  )\n  const resolvedActiveWidth = isTablet\n    ? Math.min(activeWidth, MAX_TABLET_ACTIVE_WIDTH)\n    : activeWidth\n  const bgColor = useColor(\"gray5\")\n  return (\n    <ReAnimatedPressable\n      testID={testID}\n      className={cn(\n        \"relative flex h-12 flex-row items-center justify-center gap-2 overflow-hidden rounded-[1.2rem] pl-2\",\n        className,\n      )}\n      onPress={onPress}\n      style={useAnimatedStyle(() => ({\n        backgroundColor: interpolateColor(\n          dragProgress.get(),\n          [index - 1, index, index + 1],\n          [bgColor, activeColor, bgColor],\n        ),\n        width: interpolate(\n          dragProgress.get(),\n          [index - 1, index, index + 1],\n          [INACTIVE_WIDTH, Math.max(resolvedActiveWidth, INACTIVE_WIDTH), INACTIVE_WIDTH],\n          \"clamp\",\n        ),\n        ...style,\n      }))}\n    >\n      {children}\n    </ReAnimatedPressable>\n  )\n}\nfunction ViewItem({\n  view,\n  index,\n  scrollViewRef,\n  isActive,\n}: {\n  view: ViewDefinition\n  // The notification or audio view will be hidden in some cases, so we need to pass the index\n  index: number\n  scrollViewRef: React.RefObject<ScrollView | null>\n  isActive: boolean\n}) {\n  const textColor = useColor(\"gray\")\n  const unreadCount = useUnreadByView(view.view)\n  const borderColor = useColor(\"gray5\")\n  const { t } = useTranslation(\"common\")\n  const itemRef = React.useRef<View>(null)\n  const { width: windowWidth } = useWindowDimensions()\n  const dragProgress = useTimelineSelectorDragProgress()\n\n  // Scroll to center the active item when it becomes active\n  useEffect(() => {\n    let timeout: NodeJS.Timeout | null = null\n    if (isActive && scrollViewRef.current && itemRef.current) {\n      // Give time for animation to start\n      timeout = setTimeout(() => {\n        itemRef.current?.measureInWindow((x, y, width) => {\n          const scrollX = x - windowWidth / 2 + width / 2\n          scrollViewRef.current?.scrollTo({\n            x: Math.max(0, scrollX),\n            animated: true,\n          })\n        })\n      }, 50)\n    }\n    return () => {\n      if (timeout) {\n        clearTimeout(timeout)\n      }\n    }\n  }, [isActive, scrollViewRef, windowWidth])\n  return (\n    <TimelineViewSelectorContextMenu type=\"view\" viewId={view.view}>\n      <View ref={itemRef}>\n        <ItemWrapper\n          isActive={isActive}\n          index={index}\n          activeColor={view.activeColor}\n          testID={`timeline-view-${view.name.replace(\"feed_view_type.\", \"\").replaceAll(\"_\", \"-\")}`}\n          onPress={() =>\n            selectTimeline({\n              type: \"view\",\n              viewId: view.view,\n            })\n          }\n        >\n          <View className=\"relative\">\n            <Animated.View\n              style={useAnimatedStyle(() => ({\n                opacity: interpolate(dragProgress.get(), [index - 1, index, index + 1], [0, 1, 0]),\n              }))}\n            >\n              <view.icon color=\"#fff\" height={21} width={21} />\n            </Animated.View>\n            <Animated.View\n              className=\"absolute\"\n              style={useAnimatedStyle(() => ({\n                opacity: interpolate(dragProgress.get(), [index - 1, index, index + 1], [1, 0, 1]),\n              }))}\n            >\n              <view.icon color={textColor} height={21} width={21} />\n            </Animated.View>\n          </View>\n\n          <Animated.View\n            className=\"flex flex-row items-center justify-center gap-2 overflow-hidden\"\n            style={useAnimatedStyle(() => ({\n              width: interpolate(\n                dragProgress.get(),\n                [index - 1, index, index + 1],\n                [0, ACTIVE_TEXT_WIDTH, 0],\n                \"clamp\",\n              ),\n            }))}\n          >\n            <Text\n              allowFontScaling={false}\n              key={view.name}\n              className=\"text-[14px] font-semibold text-white\"\n              numberOfLines={1}\n              ellipsizeMode=\"clip\"\n            >\n              {t(view.name)}\n            </Text>\n\n            <UnreadCount\n              max={99}\n              unread={unreadCount}\n              dotClassName=\"size-1.5 rounded-full bg-white\"\n              textClassName=\"text-white font-bold flex-1\"\n            />\n          </Animated.View>\n\n          {/* Unread indicator for inactive items */}\n          <Animated.View\n            className=\"absolute size-2 rounded-full border\"\n            style={useAnimatedStyle(() => ({\n              left: 30,\n              top: 10,\n              backgroundColor: textColor,\n              borderColor,\n              display: unreadCount ? \"flex\" : \"none\",\n              opacity: interpolate(\n                dragProgress.get(),\n                [index - 1, index, index + 1],\n                [1, 0, 1],\n                \"clamp\",\n              ),\n            }))}\n          />\n        </ItemWrapper>\n      </View>\n    </TimelineViewSelectorContextMenu>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/TimelineViewSelectorContextMenu.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { unreadSyncService } from \"@follow/store/unread/store\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { getHideAllReadSubscriptions } from \"@/src/atoms/settings/general\"\nimport { ContextMenu } from \"@/src/components/ui/context-menu\"\n\nexport const TimelineViewSelectorContextMenu: FC<\n  PropsWithChildren<{ type: string | undefined; viewId: FeedViewType | undefined }>\n> = ({ children, type, viewId }) => {\n  const { t } = useTranslation()\n  if (type !== \"view\" || viewId === undefined) return children\n\n  return (\n    <ContextMenu.Root>\n      <ContextMenu.Trigger>{children}</ContextMenu.Trigger>\n      <ContextMenu.Content>\n        <ContextMenu.Item\n          key=\"MarkAsRead\"\n          onSelect={() => {\n            unreadSyncService.markViewAsRead(viewId, getHideAllReadSubscriptions())\n          }}\n        >\n          <ContextMenu.ItemTitle>{t(\"operation.mark_as_read\")}</ContextMenu.ItemTitle>\n          <ContextMenu.ItemIcon\n            ios={{\n              name: \"checkmark.circle\",\n            }}\n          />\n        </ContextMenu.Item>\n      </ContextMenu.Content>\n    </ContextMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/action.tsx",
    "content": "import { getFeedById } from \"@follow/store/feed/getter\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils\"\nimport * as Haptics from \"expo-haptics\"\nimport type { PropsWithChildren } from \"react\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, Share, View } from \"react-native\"\n\nimport { setGeneralSetting, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { UserAvatar } from \"@/src/components/ui/avatar/UserAvatar\"\nimport { UIBarButton } from \"@/src/components/ui/button/UIBarButton\"\nimport { CheckCircleCuteReIcon } from \"@/src/icons/check_circle_cute_re\"\nimport { RoundCuteFiIcon } from \"@/src/icons/round_cute_fi\"\nimport { RoundCuteReIcon } from \"@/src/icons/round_cute_re\"\nimport { ShareForwardCuteReIcon } from \"@/src/icons/share_forward_cute_re\"\nimport { Dialog } from \"@/src/lib/dialog\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { proxyEnv } from \"@/src/lib/proxy-env\"\nimport { toast } from \"@/src/lib/toast\"\nimport { LoginScreen } from \"@/src/screens/(modal)/LoginScreen\"\nimport { ProfileScreen } from \"@/src/screens/(modal)/ProfileScreen\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nimport { MarkAllAsReadDialog } from \"../dialogs/MarkAllAsReadDialog\"\n\nexport const ActionGroup = ({ children, className }: PropsWithChildren<{ className?: string }>) => {\n  return <View className={cn(\"flex flex-row items-center gap-1\", className)}>{children}</View>\n}\n\nexport function HomeLeftAction() {\n  const user = useWhoami()\n\n  const navigation = useNavigation()\n  const handlePress = useCallback(() => {\n    if (user) {\n      navigation.presentControllerView(ProfileScreen, { userId: user.id })\n    } else {\n      navigation.presentControllerView(LoginScreen)\n    }\n  }, [navigation, user])\n\n  return (\n    <ActionGroup className=\"ml-2\">\n      <Pressable testID=\"home-avatar-trigger\" onPress={handlePress}>\n        <UserAvatar\n          image={user?.image}\n          name={user?.name}\n          className=\"rounded-full\"\n          color={accentColor}\n          preview={false}\n        />\n      </Pressable>\n    </ActionGroup>\n  )\n}\n\ninterface HeaderActionButtonProps {\n  variant?: \"primary\" | \"secondary\"\n}\n\nexport const MarkAllAsReadActionButton = ({ variant = \"secondary\" }: HeaderActionButtonProps) => {\n  const { t } = useTranslation()\n  const { size, color } = useButtonVariant({ variant })\n\n  return (\n    <UIBarButton\n      label={t(\"operation.mark_all_as_read\")}\n      normalIcon={<CheckCircleCuteReIcon height={size} width={size} color={color} />}\n      onPress={() => {\n        Dialog.show(MarkAllAsReadDialog)\n      }}\n    />\n  )\n}\n\nconst useButtonVariant = ({ variant = \"primary\" }: HeaderActionButtonProps) => {\n  const label = useColor(\"label\")\n  const size = 20\n  const color = variant === \"primary\" ? accentColor : label\n  return { size, color }\n}\nexport const UnreadOnlyActionButton = ({ variant = \"secondary\" }: HeaderActionButtonProps) => {\n  const { t } = useTranslation()\n  const unreadOnly = useGeneralSettingKey(\"unreadOnly\")\n  const { size, color } = useButtonVariant({ variant })\n  return (\n    <UIBarButton\n      label={\n        unreadOnly\n          ? t(\"operation.toggle_unread_only.show_all.label\")\n          : t(\"operation.toggle_unread_only.show_unread_only.label\")\n      }\n      normalIcon={<RoundCuteReIcon height={size} width={size} color={color} />}\n      selectedIcon={<RoundCuteFiIcon height={size} width={size} color={color} />}\n      onPress={() => {\n        Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)\n        setGeneralSetting(\"unreadOnly\", !unreadOnly)\n        toast.success(\n          unreadOnly\n            ? t(\"operation.toggle_unread_only.show_all.success\")\n            : t(\"operation.toggle_unread_only.show_unread_only.success\"),\n        )\n      }}\n      selected={unreadOnly}\n      overlay={false}\n    />\n  )\n}\n\nexport const FeedShareActionButton = ({\n  feedId,\n  variant = \"secondary\",\n}: { feedId?: string } & HeaderActionButtonProps) => {\n  const { t } = useTranslation()\n  const { size, color } = useButtonVariant({ variant })\n\n  if (!feedId) return null\n  return (\n    <UIBarButton\n      label={t(\"operation.share\")}\n      normalIcon={<ShareForwardCuteReIcon height={size} width={size} color={color} />}\n      onPress={() => {\n        const feed = getFeedById(feedId)\n        if (!feed) return\n        const url = `${proxyEnv.WEB_URL}/share/feeds/${feedId}`\n        Share.share({\n          message: `Check out ${feed.title} on Folo: ${url}`,\n          title: feed.title!,\n          url,\n        })\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/atoms.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useCollectionEntryList } from \"@follow/store/collection/hooks\"\nimport { FEED_COLLECTION_LIST } from \"@follow/store/constants/app\"\nimport {\n  useEntriesQuery,\n  useEntryIdsByFeedId,\n  useEntryIdsByFeedIds,\n  useEntryIdsByInboxId,\n  useEntryIdsByListId,\n  useEntryIdsByView,\n} from \"@follow/store/entry/hooks\"\nimport { useEntryStore } from \"@follow/store/entry/store\"\nimport type {\n  FetchEntriesProps,\n  UseEntriesProps,\n  UseEntriesReturn,\n} from \"@follow/store/entry/types\"\nimport { fallbackReturn } from \"@follow/store/entry/utils\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useInboxById } from \"@follow/store/inbox/hooks\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport { getSubscriptionByCategory } from \"@follow/store/subscription/getter\"\nimport { useSubscriptionByFeedId, useViewWithSubscription } from \"@follow/store/subscription/hooks\"\nimport { jotaiStore } from \"@follow/utils\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { debounce } from \"es-toolkit\"\nimport { atom, useAtomValue } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport type { ReactNode } from \"react\"\nimport { createContext, createElement, use, useCallback, useEffect, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport { makeMutable, useSharedValue } from \"react-native-reanimated\"\n\nimport { useFetchEntriesSettings } from \"@/src/atoms/settings/general\"\nimport { views } from \"@/src/constants/views\"\n\nexport type SelectedTimeline = {\n  type: \"view\"\n  viewId: FeedViewType\n}\n\nexport type SelectedFeed =\n  | {\n      type: \"feed\"\n      feedId: string\n    }\n  | {\n      type: \"category\"\n      categoryName: string\n    }\n  | {\n      type: \"list\"\n      listId: string\n    }\n  | {\n      type: \"inbox\"\n      inboxId: string\n    }\n  | null\n\nconst selectedTimelineAtom = atom<SelectedTimeline>({\n  type: \"view\",\n  viewId: FeedViewType.Articles,\n})\n\nconst selectedFeedAtom = atom<SelectedFeed>(null)\n\nexport const EntryListContext = createContext<{ type: \"timeline\" | \"feed\" | \"subscriptions\" }>({\n  type: \"timeline\",\n})\nexport const useEntryListContext = () => {\n  return use(EntryListContext)\n}\n\nexport function useSelectedView() {\n  return useAtomValue(useMemo(() => selectAtom(selectedTimelineAtom, (state) => state.viewId), []))\n}\n\nexport const getSelectedView = () => {\n  return jotaiStore.get(selectedTimelineAtom).viewId\n}\n\nexport function useSelectedFeed(): SelectedTimeline | SelectedFeed\nexport function useSelectedFeed<T>(\n  selector?: (selectedFeed: SelectedTimeline | SelectedFeed) => T,\n): T | null\nexport function useSelectedFeed<T>(\n  selector?: (selectedFeed: SelectedTimeline | SelectedFeed) => T,\n) {\n  const entryListContext = useEntryListContext()\n\n  const [stableSelector] = useState(() => selector)\n  return useAtomValue(\n    useMemo(\n      () =>\n        atom((get) => {\n          const selectedTimeline = get(selectedTimelineAtom)\n          const selectedFeed = get(selectedFeedAtom)\n          const result = entryListContext.type === \"feed\" ? selectedFeed : selectedTimeline\n          if (stableSelector) {\n            return stableSelector(result)\n          }\n          return result\n        }),\n      [entryListContext, stableSelector],\n    ),\n  )\n}\n\nexport const getFetchEntryPayload = (\n  selectedFeed: SelectedTimeline | SelectedFeed,\n  view: FeedViewType = FeedViewType.Articles,\n): FetchEntriesProps | null => {\n  if (!selectedFeed) {\n    return null\n  }\n\n  let payload: FetchEntriesProps = {}\n  switch (selectedFeed.type) {\n    case \"view\": {\n      payload = { view: selectedFeed.viewId }\n      break\n    }\n    case \"feed\": {\n      payload = { feedId: selectedFeed.feedId }\n      break\n    }\n    case \"category\": {\n      payload = {\n        feedIdList: getSubscriptionByCategory({ category: selectedFeed.categoryName, view }),\n      }\n      break\n    }\n    case \"list\": {\n      payload = { listId: selectedFeed.listId }\n      break\n    }\n    case \"inbox\": {\n      payload = { inboxId: selectedFeed.inboxId }\n      break\n    }\n    // No default\n  }\n  const isCollection =\n    selectedFeed && selectedFeed.type === \"feed\" && selectedFeed?.feedId === FEED_COLLECTION_LIST\n  if (isCollection) {\n    payload.view = view\n    payload.isCollection = true\n  }\n\n  return payload\n}\n\nfunction useRemoteEntries(props?: UseEntriesProps): UseEntriesReturn {\n  const selectedFeed = useSelectedFeed()\n  const selectedView = useSelectedView()\n  const view = props?.viewId ?? selectedView\n  const payload = getFetchEntryPayload(\n    selectedFeed?.type === \"view\"\n      ? {\n          type: \"view\",\n          viewId: view,\n        }\n      : selectedFeed,\n    view,\n  )\n  const options = useFetchEntriesSettings()\n\n  const query = useEntriesQuery(props?.active ? { ...payload, ...options } : undefined)\n\n  const [fetchedTime, setFetchedTime] = useState<number>()\n  useEffect(() => {\n    if (!query.isFetching) {\n      setFetchedTime(Date.now())\n    }\n  }, [query.isFetching])\n\n  const refetch = useCallback(async () => void query.refetch(), [query])\n  const fetchNextPage = useCallback(async () => void query.fetchNextPage(), [query])\n\n  if (!query.data || query.isLoading) {\n    return fallbackReturn\n  }\n\n  return {\n    entriesIds: query.entriesIds,\n    hasNext: query.hasNextPage,\n    refetch,\n    fetchNextPage,\n    isLoading: query.isFetching,\n    isRefetching: query.isRefetching,\n    isReady: query.isSuccess,\n    isFetchingNextPage: query.isFetchingNextPage,\n    isFetching: query.isFetching,\n    hasNextPage: query.hasNextPage,\n    error: query.isError ? query.error : null,\n    fetchedTime,\n  }\n}\n\nfunction useLocalEntries(props?: UseEntriesProps): UseEntriesReturn {\n  const selectedFeed = useSelectedFeed()\n  const selectedView = useSelectedView()\n  const view = props?.viewId ?? selectedView\n  const payload = getFetchEntryPayload(\n    selectedFeed?.type === \"view\"\n      ? {\n          type: \"view\",\n          viewId: view,\n        }\n      : selectedFeed,\n    view,\n  )\n  const options = useFetchEntriesSettings()\n\n  const { feedId, feedIdList, listId, inboxId, isCollection } = payload || {}\n  const { hidePrivateSubscriptionsInTimeline, unreadOnly } = options\n\n  const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline)\n  const entryIdsByCollections = useCollectionEntryList(view)\n  const entryIdsByFeedId = useEntryIdsByFeedId(feedId)\n  const entryIdsByCategory = useEntryIdsByFeedIds(feedIdList)\n  const entryIdsByListId = useEntryIdsByListId(listId)\n  const entryIdsByInboxId = useEntryIdsByInboxId(inboxId)\n\n  const showEntriesByView =\n    !feedId && (!feedIdList || feedIdList?.length === 0) && !isCollection && !inboxId && !listId\n\n  const allEntries = useEntryStore(\n    useCallback(\n      (state) => {\n        const ids = isCollection\n          ? entryIdsByCollections\n          : showEntriesByView\n            ? (entryIdsByView ?? [])\n            : (getEntryIdsFromMultiplePlace(\n                entryIdsByFeedId,\n                entryIdsByCategory,\n                entryIdsByListId,\n                entryIdsByInboxId,\n              ) ?? [])\n\n        return ids\n          .map((id) => {\n            const entry = state.data[id]\n            if (!entry) return null\n            if (unreadOnly && entry.read) {\n              return null\n            }\n            return entry.id\n          })\n          .filter((id) => typeof id === \"string\")\n      },\n      [\n        entryIdsByCategory,\n        entryIdsByCollections,\n        entryIdsByFeedId,\n        entryIdsByInboxId,\n        entryIdsByListId,\n        entryIdsByView,\n        showEntriesByView,\n        unreadOnly,\n      ],\n    ),\n  )\n\n  const [page, setPage] = useState(0)\n  const pageSize = 30\n  const totalPage = useMemo(\n    () => (allEntries ? Math.ceil(allEntries.length / pageSize) : 0),\n    [allEntries],\n  )\n\n  const entries = useMemo(() => {\n    return allEntries?.slice(0, (page + 1) * pageSize) || []\n  }, [allEntries, page, pageSize])\n\n  const hasNext = useMemo(() => {\n    return entries.length < (allEntries?.length || 0)\n  }, [entries.length, allEntries])\n\n  const refetch = useCallback(async () => {\n    setPage(0)\n  }, [])\n\n  const fetchNextPage = useCallback(\n    debounce(async () => {\n      setPage(page + 1)\n    }, 300),\n    [page],\n  )\n\n  useEffect(() => {\n    setPage(0)\n  }, [view, feedId])\n\n  return {\n    entriesIds: entries,\n    hasNext,\n    refetch,\n    fetchNextPage,\n    isLoading: false,\n    isRefetching: false,\n    isReady: true,\n    isFetchingNextPage: false,\n    isFetching: false,\n    hasNextPage: page < totalPage,\n    error: null,\n  }\n}\n\nfunction getEntryIdsFromMultiplePlace(...entryIds: Array<string[] | undefined | null>) {\n  return entryIds.find((ids) => ids?.length) ?? []\n}\n\nexport function useEntries(props?: UseEntriesProps): UseEntriesReturn {\n  const { viewId, active = true } = props || {}\n  const remoteQuery = useRemoteEntries({ viewId, active })\n  const localQuery = useLocalEntries({ viewId, active })\n  const entryListContext = useEntryListContext()\n  const selectedFeed = useSelectedFeed()\n\n  const isTimelineViewQuery = entryListContext.type === \"timeline\" && selectedFeed?.type === \"view\"\n\n  if (isTimelineViewQuery) {\n    if (remoteQuery.isReady) {\n      return remoteQuery\n    }\n\n    if (remoteQuery.error) {\n      return {\n        ...localQuery,\n        error: remoteQuery.error,\n        isReady: true,\n      }\n    }\n\n    return {\n      ...fallbackReturn,\n      refetch: remoteQuery.refetch,\n      fetchNextPage: remoteQuery.fetchNextPage,\n      isLoading: remoteQuery.isLoading,\n      isFetching: remoteQuery.isFetching,\n      isRefetching: remoteQuery.isRefetching,\n    }\n  }\n\n  const query = remoteQuery.isReady ? remoteQuery : localQuery\n  return {\n    ...query,\n    isReady: remoteQuery.isReady,\n  }\n}\n\nexport const useSelectedFeedTitle = () => {\n  const selectedFeed = useSelectedFeed()\n\n  const viewDef = useViewDefinition(\n    selectedFeed && selectedFeed.type === \"view\" ? selectedFeed.viewId : undefined,\n  )\n  const feed = useFeedById(selectedFeed && selectedFeed.type === \"feed\" ? selectedFeed.feedId : \"\")\n  const feedSubscription = useSubscriptionByFeedId(\n    selectedFeed && selectedFeed.type === \"feed\" ? selectedFeed.feedId : \"\",\n  )\n  const list = useListById(selectedFeed && selectedFeed.type === \"list\" ? selectedFeed.listId : \"\")\n  const inbox = useInboxById(\n    selectedFeed && selectedFeed.type === \"inbox\" ? selectedFeed.inboxId : \"\",\n  )\n  const { t } = useTranslation(\"common\")\n\n  if (!selectedFeed) {\n    return \"\"\n  }\n\n  switch (selectedFeed.type) {\n    case \"view\": {\n      return viewDef?.name ? t(viewDef.name) : \"\"\n    }\n    case \"feed\": {\n      return selectedFeed.feedId === FEED_COLLECTION_LIST\n        ? t(\"words.starred\")\n        : feedSubscription?.title || feed?.title || \"\"\n    }\n    case \"category\": {\n      return selectedFeed.categoryName\n    }\n    case \"list\": {\n      return list?.title\n    }\n    case \"inbox\": {\n      return inbox?.title ?? t(\"words.inbox\")\n    }\n  }\n}\n\ndeclare module \"@follow/utils/event-bus\" {\n  export interface CustomEvent {\n    SELECT_TIMELINE: {\n      view: SelectedTimeline\n      target: string | undefined\n    }\n  }\n}\n\nexport const selectTimeline = (state: SelectedTimeline, target?: string) => {\n  jotaiStore.set(selectedTimelineAtom, state)\n  EventBus.dispatch(\"SELECT_TIMELINE\", {\n    view: state,\n    target,\n  })\n}\n\nexport const selectFeed = (state: SelectedFeed) => {\n  jotaiStore.set(selectedFeedAtom, state)\n}\n\nexport const useViewDefinition = (view?: FeedViewType) => {\n  const viewDef = useMemo(() => views.find((v) => v.view === view), [view])\n  return viewDef\n}\n\nconst TimelineSelectorDragProgressContext = createContext<SharedValue<number> | null>(null)\n\nexport const TimelineSelectorDragProgressProvider = ({ children }: { children: ReactNode }) => {\n  const selectedFeed = useSelectedFeed()\n  const viewId = selectedFeed?.type === \"view\" ? selectedFeed.viewId : undefined\n\n  const activeViews = useViewWithSubscription()\n\n  const activeViewIndex = useMemo(\n    () => activeViews.indexOf(viewId as FeedViewType),\n    [activeViews, viewId],\n  )\n  const initialPage = activeViewIndex\n  const dragProgress = useSharedValue(initialPage)\n\n  return createElement(\n    TimelineSelectorDragProgressContext,\n    {\n      value: dragProgress,\n    },\n    children,\n  )\n}\n\nexport const useTimelineSelectorDragProgress = () => {\n  const dragProgress = use(TimelineSelectorDragProgressContext)\n  if (!dragProgress) {\n    console.error(\n      \"useTimelineSelectorDragProgress must be used within TimelineSelectorDragProgressProvider\",\n    )\n    return makeMutable(0)\n  }\n  return dragProgress\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/screen/hooks/useHeaderHeight.tsx",
    "content": "import { useDefaultHeaderHeight } from \"@/src/hooks/useDefaultHeaderHeight\"\n\nimport { useEntryListContext } from \"../atoms\"\n\nexport const headerHideableBottomHeight = 58\n\nexport const useHeaderHeight = () => {\n  const screenType = useEntryListContext().type\n  const originalDefaultHeaderHeight = useDefaultHeaderHeight()\n  const headerHeight =\n    screenType === \"timeline\" || screenType === \"subscriptions\"\n      ? originalDefaultHeaderHeight + headerHideableBottomHeight\n      : originalDefaultHeaderHeight\n\n  return headerHeight\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/SettingsList.tsx",
    "content": "import { UserRole } from \"@follow/constants\"\nimport { useUserRole, useWhoami } from \"@follow/store/user/hooks\"\nimport type { StatusConfigs as ServerConfigs } from \"@follow-app/client-sdk\"\nimport i18next from \"i18next\"\nimport type { FC } from \"react\"\nimport { Fragment, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { PixelRatio, View } from \"react-native\"\n\nimport { getIsPaymentEnabled, useServerConfigs } from \"@/src/atoms/server-configs\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListNavigationLink,\n  GroupedInsetListNavigationLinkIcon,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CertificateCuteFiIcon } from \"@/src/icons/certificate_cute_fi\"\nimport { DatabaseIcon } from \"@/src/icons/database\"\nimport { ExitCuteFiIcon } from \"@/src/icons/exit_cute_fi\"\nimport { Magic2CuteFiIcon } from \"@/src/icons/magic_2_cute_fi\"\nimport { NotificationCuteReIcon } from \"@/src/icons/notification_cute_re\"\nimport { PaletteCuteFiIcon } from \"@/src/icons/palette_cute_fi\"\nimport { PowerOutlineIcon } from \"@/src/icons/power_outline\"\nimport { RadaCuteFiIcon } from \"@/src/icons/rada_cute_fi\"\nimport { SafeLockFilledIcon } from \"@/src/icons/safe_lock_filled\"\nimport { Settings1CuteFiIcon } from \"@/src/icons/settings_1_cute_fi\"\nimport { StarCuteFiIcon } from \"@/src/icons/star_cute_fi\"\nimport { UserSettingCuteFiIcon } from \"@/src/icons/user_setting_cute_fi\"\nimport { signOut } from \"@/src/lib/auth\"\nimport { Dialog } from \"@/src/lib/dialog\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport { isPaymentFeatureEnabled } from \"@/src/lib/payment\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { AboutScreen } from \"./routes/About\"\nimport { AccountScreen } from \"./routes/Account\"\nimport { ActionsScreen } from \"./routes/Actions\"\nimport { AppearanceScreen } from \"./routes/Appearance\"\nimport { DataScreen } from \"./routes/Data\"\nimport { FeedsScreen } from \"./routes/Feeds\"\nimport { GeneralScreen } from \"./routes/General\"\nimport { ListsScreen } from \"./routes/Lists\"\nimport { NotificationsScreen } from \"./routes/Notifications\"\nimport { PlanScreen } from \"./routes/Plan\"\nimport { PrivacyScreen } from \"./routes/Privacy\"\n\ntype SettingsNavigationTranslationKey =\n  | \"titles.general\"\n  | \"titles.notifications\"\n  | \"titles.appearance\"\n  | \"titles.data_control\"\n  | \"titles.account\"\n  | \"titles.subscription.short\"\n  | \"titles.actions\"\n  | \"titles.feeds\"\n  | \"titles.lists\"\n  | \"titles.privacy\"\n  | \"titles.about\"\n  | \"titles.sign_out\"\n\ninterface GroupNavigationLinkBase {\n  icon: React.ElementType\n  onPress: (data: { navigation: Navigation }) => void\n  iconBackgroundColor: string\n  trialNotAllowed?: boolean\n  anonymous?: boolean\n  todo?: boolean\n  hideIf?: (serverConfigs?: ServerConfigs | null) => boolean\n  testID?: string\n}\n\ntype GroupNavigationLink =\n  | (GroupNavigationLinkBase & {\n      translationKey: SettingsNavigationTranslationKey\n      label?: never\n    })\n  | (GroupNavigationLinkBase & {\n      label: string\n      translationKey?: never\n    })\n\nconst SettingGroupNavigationLinks: GroupNavigationLink[] = [\n  {\n    translationKey: \"titles.general\",\n    icon: Settings1CuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(GeneralScreen)\n    },\n    iconBackgroundColor: \"#F43F5E\",\n    testID: \"settings-general-link\",\n  },\n  {\n    translationKey: \"titles.notifications\",\n    icon: NotificationCuteReIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(NotificationsScreen)\n    },\n    iconBackgroundColor: \"#EF4444\",\n    todo: true,\n    anonymous: false,\n  },\n  {\n    translationKey: \"titles.appearance\",\n    icon: PaletteCuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(AppearanceScreen)\n    },\n    iconBackgroundColor: \"#8B5CF6\",\n  },\n  {\n    translationKey: \"titles.data_control\",\n    icon: DatabaseIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(DataScreen)\n    },\n    iconBackgroundColor: \"#3B82F6\",\n    anonymous: false,\n  },\n  {\n    translationKey: \"titles.account\",\n    icon: UserSettingCuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(AccountScreen)\n    },\n    iconBackgroundColor: \"#F97316\",\n    anonymous: false,\n    testID: \"settings-account-link\",\n  },\n]\n\nconst SubscriptionGroupNavigationLinks: GroupNavigationLink[] = [\n  {\n    translationKey: \"titles.subscription.short\",\n    icon: PowerOutlineIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(PlanScreen)\n    },\n    iconBackgroundColor: accentColor,\n    anonymous: false,\n    hideIf: (serverConfigs) => !isPaymentFeatureEnabled(serverConfigs?.PAYMENT_ENABLED),\n  },\n]\n\nconst DataGroupNavigationLinks: GroupNavigationLink[] = [\n  {\n    translationKey: \"titles.actions\",\n    icon: Magic2CuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(ActionsScreen)\n    },\n    iconBackgroundColor: \"#9333EA\",\n    anonymous: false,\n    trialNotAllowed: true,\n  },\n\n  {\n    translationKey: \"titles.feeds\",\n    icon: CertificateCuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(FeedsScreen)\n    },\n    iconBackgroundColor: \"#EAB308\",\n    todo: true,\n    anonymous: false,\n    trialNotAllowed: true,\n    testID: \"settings-feeds-link\",\n  },\n  {\n    translationKey: \"titles.lists\",\n    icon: RadaCuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(ListsScreen)\n    },\n    iconBackgroundColor: \"#0EA5E9\",\n    anonymous: false,\n    trialNotAllowed: true,\n  },\n]\n\nconst PrivacyGroupNavigationLinks: GroupNavigationLink[] = [\n  {\n    translationKey: \"titles.privacy\",\n    icon: SafeLockFilledIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(PrivacyScreen)\n    },\n    iconBackgroundColor: \"#6366F1\",\n  },\n  {\n    translationKey: \"titles.about\",\n    icon: StarCuteFiIcon,\n    onPress: ({ navigation }) => {\n      navigation.pushControllerView(AboutScreen)\n    },\n    iconBackgroundColor: \"#EAB308\",\n  },\n]\n\nconst ActionGroupNavigationLinks: GroupNavigationLink[] = [\n  {\n    translationKey: \"titles.sign_out\",\n    icon: ExitCuteFiIcon,\n    onPress: () => {\n      Dialog.show({\n        id: \"settings-sign-out-dialog\",\n        title: i18next.t(\"profile.sign_out.confirm_title\", { ns: \"settings\" }),\n        content: (\n          <View>\n            <Text className=\"text-label\">\n              {i18next.t(\"profile.sign_out.confirm_message\", { ns: \"settings\" })}\n            </Text>\n          </View>\n        ),\n        variant: \"destructive\",\n        confirmText: i18next.t(\"titles.sign_out\", { ns: \"settings\" }),\n        cancelText: i18next.t(\"words.cancel\", { ns: \"common\" }),\n        onConfirm: async () => {\n          await signOut()\n        },\n      })\n    },\n    iconBackgroundColor: \"#DC2626\",\n    anonymous: false,\n    testID: \"settings-sign-out\",\n  },\n]\n\nconst NavigationLinkGroup: FC<{\n  links: GroupNavigationLink[]\n}> = ({ links }) => {\n  const navigation = useNavigation()\n  const role = useUserRole()\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <GroupedInsetListCard>\n      {links\n        .filter((link) => !link.todo)\n        .map((link) => {\n          const label = link.translationKey ? String(t(link.translationKey)) : link.label\n          const key = link.testID ?? link.translationKey ?? link.label\n\n          return (\n            <GroupedInsetListNavigationLink\n              key={key}\n              label={label}\n              icon={\n                <GroupedInsetListNavigationLinkIcon backgroundColor={link.iconBackgroundColor}>\n                  <link.icon height={18} width={18} color=\"#fff\" />\n                </GroupedInsetListNavigationLinkIcon>\n              }\n              testID={link.testID}\n              onPress={() => {\n                if (\n                  link.trialNotAllowed &&\n                  (role === UserRole.Free || role === UserRole.Trial) &&\n                  getIsPaymentEnabled()\n                ) {\n                  navigation.presentControllerView(PlanScreen)\n                } else {\n                  link.onPress({ navigation })\n                }\n              }}\n            />\n          )\n        })}\n    </GroupedInsetListCard>\n  )\n}\n\nconst navigationGroups = [\n  SettingGroupNavigationLinks,\n  DataGroupNavigationLinks,\n  SubscriptionGroupNavigationLinks,\n  PrivacyGroupNavigationLinks,\n  ActionGroupNavigationLinks,\n] as const\n\nexport const SettingsList: FC = () => {\n  const whoami = useWhoami()\n  const serverConfigs = useServerConfigs()\n\n  const filteredNavigationGroups = useMemo(() => {\n    return navigationGroups\n      .map((group) => {\n        const filteredGroup = group\n          .filter((link) => link.anonymous !== !!whoami)\n          .filter((link) => !link.hideIf?.(serverConfigs))\n\n        if (filteredGroup.length === 0) return false\n        return filteredGroup\n      })\n      .filter((group): group is GroupNavigationLink[] => group !== false)\n  }, [serverConfigs, whoami])\n\n  const pixelRatio = PixelRatio.get()\n  const groupGap = 100 / pixelRatio\n  const marginTop = 44 / pixelRatio\n\n  return (\n    <View className=\"flex-1 bg-system-grouped-background pb-4\" style={{ marginTop }}>\n      {filteredNavigationGroups.map((group, index) => {\n        const groupKey = group.map((link) => link.label).join(\"-\")\n        return (\n          <Fragment key={groupKey}>\n            <NavigationLinkGroup links={group} />\n            {index < filteredNavigationGroups.length - 1 && <View style={{ height: groupGap }} />}\n          </Fragment>\n        )\n      })}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/UserHeaderBanner.tsx",
    "content": "import { UserRole, UserRoleName } from \"@follow/constants\"\nimport { useImageColors } from \"@follow/store/image/hooks\"\nimport { useUserById, useUserRole } from \"@follow/store/user/hooks\"\nimport { cn, getLuminance } from \"@follow/utils\"\nimport { LinearGradient } from \"expo-linear-gradient\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Linking, Pressable, StyleSheet, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport ReAnimated, { FadeIn, FadeOut, interpolate, useAnimatedStyle } from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { useServerConfigs } from \"@/src/atoms/server-configs\"\nimport { UserAvatar } from \"@/src/components/ui/avatar/UserAvatar\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { DiscordCuteFiIcon } from \"@/src/icons/discord_cute_fi\"\nimport { FacebookCuteFiIcon } from \"@/src/icons/facebook_cute_fi\"\nimport { GithubCuteFiIcon } from \"@/src/icons/github_cute_fi\"\nimport { InstagramCuteFiIcon } from \"@/src/icons/instagram_cute_fi\"\nimport { LinkCuteReIcon } from \"@/src/icons/link_cute_re\"\nimport { PowerIcon } from \"@/src/icons/power\"\nimport { TwitterCuteFiIcon } from \"@/src/icons/twitter_cute_fi\"\nimport { WebCuteReIcon } from \"@/src/icons/web_cute_re\"\nimport { YoutubeCuteFiIcon } from \"@/src/icons/youtube_cute_fi\"\nimport { useNavigation, useScreenIsInSheetModal } from \"@/src/lib/navigation/hooks\"\nimport { LoginScreen } from \"@/src/screens/(modal)/LoginScreen\"\nimport { usePrefetchImageColors } from \"@/src/store/image/hooks\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nconst defaultGradientColors = [\"#000\", \"#100\", \"#200\"]\nconst PlatformInfoMap: Record<\n  string,\n  {\n    component: FC<any>\n    color: {\n      light: string\n      dark: string\n    }\n  }\n> = {\n  github: {\n    component: GithubCuteFiIcon,\n    color: {\n      light: \"#181717\",\n      dark: \"#FFFFFF\",\n    },\n  },\n  twitter: {\n    component: TwitterCuteFiIcon,\n    color: {\n      light: \"#1DA1F2\",\n      dark: \"#1DA1F2\",\n    },\n  },\n  youtube: {\n    component: YoutubeCuteFiIcon,\n    color: {\n      light: \"#FF0000\",\n      dark: \"#FF0000\",\n    },\n  },\n  discord: {\n    component: DiscordCuteFiIcon,\n    color: {\n      light: \"#5865F2\",\n      dark: \"#5865F2\",\n    },\n  },\n  instagram: {\n    component: InstagramCuteFiIcon,\n    color: {\n      light: \"#C13584\",\n      dark: \"#C13584\",\n    },\n  },\n  facebook: {\n    component: FacebookCuteFiIcon,\n    color: {\n      light: \"#1877F2\",\n      dark: \"#1877F2\",\n    },\n  },\n}\nexport const UserHeaderBanner = ({\n  scrollY,\n  userId,\n  showRoleBadge,\n}: {\n  scrollY: SharedValue<number>\n  userId?: string\n  showRoleBadge?: boolean\n}) => {\n  const { t } = useTranslation()\n  const serverConfigs = useServerConfigs()\n  const bgColor = useColor(\"systemGroupedBackground\")\n  const avatarIconColor = useColor(\"secondaryLabel\")\n  const user = useUserById(userId)\n  const role = useUserRole()\n  usePrefetchImageColors(user?.image)\n  const insets = useSafeAreaInsets()\n  const MAX_PULL = 100\n  const SCALE_FACTOR = 1.8\n  const imageColors = useImageColors(user?.image)\n  const gradientColors = useMemo(() => {\n    if (!imageColors || imageColors.platform === \"web\")\n      return user ? defaultGradientColors : [bgColor, bgColor, bgColor]\n    if (imageColors.platform === \"android\") {\n      return [\n        imageColors.dominant,\n        imageColors.average || imageColors.vibrant,\n        imageColors.vibrant || imageColors.dominant,\n      ]\n    }\n    return [imageColors.primary, imageColors.secondary, imageColors.background]\n  }, [bgColor, imageColors, user])\n  const socialLinks = useMemo(() => {\n    if (!user?.socialLinks) {\n      return []\n    }\n    return Object.entries(user.socialLinks)\n      .filter(([, value]) => !!value)\n      .map(([platform, link]) => ({\n        platform,\n        link: link!,\n      }))\n  }, [user?.socialLinks])\n  const gradientLight = useMemo(() => {\n    if (!imageColors) return false\n    if (imageColors.platform === \"web\") return false\n    const dominantLuminance = getLuminance(\n      imageColors.platform === \"android\" ? imageColors.dominant : imageColors.primary,\n    )\n    return dominantLuminance > 0.5\n  }, [imageColors])\n  const styles = useAnimatedStyle(() => {\n    const scaleValue = interpolate(scrollY.value, [-MAX_PULL, 0], [SCALE_FACTOR, 1], {\n      extrapolateLeft: \"extend\",\n      extrapolateRight: \"clamp\",\n    })\n    return {\n      transform: [\n        {\n          scale: scaleValue,\n        },\n      ],\n    }\n  })\n\n  // Add animated style for avatar\n  const avatarStyles = useAnimatedStyle(() => {\n    // Scale avatar when pulling down\n    const avatarScale = interpolate(scrollY.value, [-MAX_PULL, 0], [1.3, 1], {\n      extrapolateLeft: \"extend\",\n      extrapolateRight: \"clamp\",\n    })\n\n    // Move avatar up when pulling down\n    const avatarTranslateY = interpolate(scrollY.value, [-MAX_PULL, 0], [-20, 0], {\n      extrapolateLeft: \"extend\",\n      extrapolateRight: \"clamp\",\n    })\n    return {\n      transform: [\n        {\n          scale: avatarScale,\n        },\n        {\n          translateY: avatarTranslateY,\n        },\n      ],\n    }\n  })\n  const navigation = useNavigation()\n\n  const sheetModal = useScreenIsInSheetModal()\n  const bannerContainerStyle = useMemo(\n    () => ({\n      marginTop: sheetModal ? 0 : -insets.top - 22,\n      paddingTop: sheetModal ? 48 : 22,\n    }),\n    [insets.top, sheetModal],\n  )\n  const bannerContentStyle = useMemo(\n    () => ({\n      paddingTop: insets.top,\n    }),\n    [insets.top],\n  )\n  return (\n    <View className=\"relative items-center justify-center\" style={bannerContainerStyle}>\n      <ReAnimated.View entering={FadeIn} className=\"absolute inset-0\" style={styles}>\n        <LinearGradient\n          colors={defaultGradientColors as [string, string, ...string[]]}\n          start={{\n            x: 0,\n            y: 0,\n          }}\n          end={{\n            x: 1,\n            y: 1,\n          }}\n          style={StyleSheet.absoluteFillObject}\n        />\n        {gradientColors && (\n          <ReAnimated.View\n            style={StyleSheet.absoluteFillObject}\n            entering={FadeIn}\n            exiting={FadeOut}\n          >\n            <LinearGradient\n              colors={gradientColors as [string, string, ...string[]]}\n              start={{\n                x: 0,\n                y: 0,\n              }}\n              end={{\n                x: 1,\n                y: 1,\n              }}\n              style={StyleSheet.absoluteFillObject}\n            />\n          </ReAnimated.View>\n        )}\n      </ReAnimated.View>\n      <View className=\"items-center px-4 pb-[24px]\" style={bannerContentStyle}>\n        <ReAnimated.View style={avatarStyles} className=\"rounded-full bg-system-background\">\n          <UserAvatar\n            image={user?.image}\n            name={user?.name}\n            role={showRoleBadge && serverConfigs?.REFERRAL_ENABLED ? role : undefined}\n            size={60}\n            className={!user?.name ? \"bg-system-grouped-background\" : \"\"}\n            color={avatarIconColor}\n          />\n        </ReAnimated.View>\n\n        <View className=\"mt-2 items-center\">\n          {user?.name ? (\n            <Text\n              numberOfLines={2}\n              className={cn(\n                \"px-8 text-center text-xl font-bold\",\n                gradientLight ? \"text-black\" : \"text-white/95\",\n              )}\n            >\n              {user.name}\n            </Text>\n          ) : (\n            <Text className=\"text-xl font-bold text-text\">Folo Account</Text>\n          )}\n\n          {!!role && serverConfigs?.REFERRAL_ENABLED && (\n            <View className=\"my-1 flex flex-row items-center gap-2\">\n              <PowerIcon\n                color={\n                  role === UserRole.Trial || role === UserRole.Free\n                    ? gradientLight\n                      ? \"rgba(0,0,0,0.7)\"\n                      : \"rgba(255,255,255,0.7)\"\n                    : accentColor\n                }\n                width={16}\n                height={16}\n              />\n              <Text\n                className={cn(\n                  role === UserRole.Trial || role === UserRole.Free\n                    ? gradientLight\n                      ? \"text-black/70\"\n                      : \"text-white/70\"\n                    : \"text-accent\",\n                  \"text-sm font-semibold\",\n                )}\n              >\n                {UserRoleName[role]}\n              </Text>\n            </View>\n          )}\n\n          {user?.handle ? (\n            <Text className={cn(\"text-sm\", gradientLight ? \"text-black/70\" : \"text-white/70\")}>\n              @{user.handle}\n            </Text>\n          ) : !user ? (\n            <Pressable\n              className=\"mx-auto\"\n              testID=\"settings-sign-in\"\n              onPress={() => navigation.presentControllerView(LoginScreen)}\n            >\n              <Text className=\"m-[6] text-sm text-accent\">{t(\"settings.sign_in_cta\")}</Text>\n            </Pressable>\n          ) : null}\n        </View>\n        {user?.bio ? (\n          <Text\n            numberOfLines={3}\n            className={cn(\n              \"mt-2 px-8 text-center text-sm\",\n              gradientLight ? \"text-black/80\" : \"text-white/80\",\n            )}\n          >\n            {user.bio}\n          </Text>\n        ) : null}\n        <View className=\"mt-4 flex-row flex-wrap items-center justify-center gap-x-6 gap-y-2 px-8\">\n          {user?.website && (\n            <Pressable\n              className=\"flex-row items-center gap-1\"\n              onPress={() => {\n                if (user.website) {\n                  void Linking.openURL(user.website)\n                }\n              }}\n            >\n              <WebCuteReIcon\n                height={16}\n                width={16}\n                color={gradientLight ? \"rgba(0,0,0,0.7)\" : \"rgba(255,255,255,0.7)\"}\n              />\n              <Text\n                className={cn(\n                  \"text-sm font-semibold\",\n                  gradientLight ? \"text-black/70\" : \"text-white/70\",\n                )}\n              >\n                {user.website.replace(/^(https?:\\/\\/)?(www\\.)?/, \"\")}\n              </Text>\n            </Pressable>\n          )}\n          {socialLinks.map(({ platform, link }) => {\n            const platformInfo = PlatformInfoMap[platform as keyof typeof PlatformInfoMap]\n            const IconComponent = platformInfo ? platformInfo.component : LinkCuteReIcon\n            const color = platformInfo\n              ? gradientLight\n                ? platformInfo.color.light\n                : platformInfo.color.dark\n              : gradientLight\n                ? \"rgba(0,0,0,0.8)\"\n                : \"rgba(255,255,255,0.8)\"\n            return (\n              <Pressable\n                key={platform}\n                onPress={() => {\n                  void Linking.openURL(link)\n                }}\n              >\n                <IconComponent height={22} width={22} color={color} />\n              </Pressable>\n            )\n          })}\n        </View>\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/components/OTPWindow.tsx",
    "content": "import { useMutation } from \"@tanstack/react-query\"\nimport { useEffect, useRef, useState } from \"react\"\nimport {\n  Animated,\n  Keyboard,\n  StyleSheet,\n  useAnimatedValue,\n  useWindowDimensions,\n  View,\n} from \"react-native\"\nimport type { OtpInputRef } from \"react-native-otp-entry\"\nimport { OtpInput } from \"react-native-otp-entry\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { FullWindowOverlay } from \"@/src/components/common/FullWindowOverlay\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { isAuthCodeValid } from \"@/src/lib/auth\"\nimport { toast } from \"@/src/lib/toast\"\nimport { accentColor } from \"@/src/theme/colors\"\n\ntype OTPWindowProps<T> = {\n  onSuccess: (data: T) => void\n  verifyFn: (code: string) => Promise<T>\n  onDismiss: () => void\n}\nexport const OTPWindow = <T,>({ onSuccess, verifyFn, onDismiss }: OTPWindowProps<T>) => {\n  const otpInputRef = useRef<OtpInputRef>(null)\n  const label = useColor(\"label\")\n  const tertiaryLabel = useColor(\"tertiaryLabel\")\n  const secondaryBackground = useColor(\"gray5\")\n  const tertiaryBackground = useColor(\"gray6\")\n  const submitMutation = useMutation({\n    onError(error) {\n      toast.error(`Failed to verify: ${error.message}`)\n    },\n    onSuccess(data) {\n      onSuccess(data)\n    },\n    onSettled() {\n      setComponentRender(false)\n    },\n    mutationFn: ({ code }: { code: string }) => verifyFn(code),\n  })\n  const windowScale = useAnimatedValue(1.1)\n  const windowOpacity = useAnimatedValue(0)\n  const [uiShow, setUiShow] = useState(false)\n  const [componentRender, setComponentRender] = useState(true)\n  useEffect(() => {\n    setUiShow(true)\n  }, [])\n  const stableDismiss = useEventCallback(() => {\n    onDismiss()\n  })\n  useEffect(() => {\n    let timer: any\n    if (uiShow) {\n      Animated.parallel([\n        Animated.spring(windowScale, {\n          toValue: 1,\n          damping: 80,\n          stiffness: 500,\n          mass: 1,\n          velocity: 10,\n          useNativeDriver: true,\n        }),\n        Animated.timing(windowOpacity, {\n          toValue: 1,\n          duration: 200,\n          useNativeDriver: true,\n        }),\n      ]).start()\n    } else {\n      Animated.timing(windowOpacity, {\n        toValue: 0,\n        duration: 500,\n        useNativeDriver: true,\n      }).start()\n      timer = setTimeout(() => {\n        setComponentRender(false)\n        stableDismiss()\n      }, 500)\n    }\n    return () => clearTimeout(timer)\n  }, [stableDismiss, uiShow, windowOpacity, windowScale])\n  const { height } = useWindowDimensions()\n  const insets = useSafeAreaInsets()\n  const [nextHeight, setNextHeight] = useState(height - insets.top)\n  useEffect(() => {\n    const sub = [\n      Keyboard.addListener(\"keyboardDidShow\", () => {\n        const metrics = Keyboard.metrics()\n        if (!metrics) return\n        setNextHeight(height - metrics.height)\n      }),\n      Keyboard.addListener(\"keyboardWillHide\", () => {\n        setNextHeight(height - insets.top)\n      }),\n    ]\n    return () => sub.forEach((listener) => listener.remove())\n  }, [height, insets.top])\n  if (!componentRender) return null\n  return (\n    <FullWindowOverlay>\n      <Animated.View\n        style={[\n          StyleSheet.absoluteFillObject,\n          {\n            opacity: windowOpacity,\n          },\n        ]}\n      >\n        <View className={\"flex-1 bg-black/50\"} />\n      </Animated.View>\n      <View className={\"flex-1\"}>\n        {/* Window */}\n\n        <View\n          style={{\n            height: nextHeight,\n          }}\n          className=\"pt-safe items-center justify-center\"\n        >\n          <Animated.View\n            className=\"mx-5 overflow-hidden rounded-3xl bg-system-background\"\n            style={[\n              styles.window,\n              {\n                transform: [\n                  {\n                    scale: windowScale,\n                  },\n                ],\n                opacity: windowOpacity,\n              },\n            ]}\n          >\n            <View className=\"px-6 pb-1 pt-6\">\n              <Text className=\"mb-1 text-center text-lg font-medium text-label\">\n                Verification Required\n              </Text>\n              <Text className=\"text-center text-base text-secondary-label\">\n                Please enter the code from your authenticator app.\n              </Text>\n            </View>\n\n            <View className=\"px-4 py-5\">\n              <OtpInput\n                disabled={submitMutation.isPending}\n                ref={otpInputRef}\n                numberOfDigits={6}\n                autoFocus\n                focusColor={\"#00000000\"}\n                theme={{\n                  containerStyle: {\n                    marginVertical: 8,\n                  },\n                  pinCodeTextStyle: {\n                    color: label,\n                    fontSize: 22,\n                    fontWeight: \"500\",\n                  },\n                  placeholderTextStyle: {\n                    color: tertiaryLabel,\n                  },\n                  filledPinCodeContainerStyle: {\n                    borderColor: \"transparent\",\n                    backgroundColor: secondaryBackground,\n                  },\n                  pinCodeContainerStyle: {\n                    borderColor: \"transparent\",\n                    backgroundColor: tertiaryBackground,\n                    borderRadius: 12,\n                    aspectRatio: 1,\n                    width: 45,\n                    marginHorizontal: 4,\n                  },\n                  focusedPinCodeContainerStyle: {\n                    borderColor: accentColor,\n                    borderWidth: 2,\n                  },\n                }}\n                onFilled={(code) => {\n                  if (isAuthCodeValid(code)) {\n                    submitMutation.mutate({\n                      code,\n                    })\n                  }\n                }}\n              />\n            </View>\n\n            <View className=\"border-t-hairline flex-row border-non-opaque-separator px-4 py-2\">\n              <View className=\"flex-1 items-center\">\n                <Text\n                  className=\"px-5 py-2 text-base font-medium text-accent\"\n                  onPress={() => {\n                    setUiShow(false)\n                  }}\n                  suppressHighlighting\n                >\n                  Cancel\n                </Text>\n              </View>\n            </View>\n          </Animated.View>\n        </View>\n      </View>\n    </FullWindowOverlay>\n  )\n}\nconst styles = StyleSheet.create({\n  window: {\n    shadowColor: \"#000\",\n    shadowOffset: {\n      width: 0,\n      height: 2,\n    },\n    shadowOpacity: 0.15,\n    shadowRadius: 10,\n    elevation: 5,\n    maxWidth: 400,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/hooks/useShareSubscription.tsx",
    "content": "import type { UseQueryResult } from \"@tanstack/react-query\"\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport { useCallback, useMemo } from \"react\"\n\nimport { followClient } from \"@/src/lib/api-client\"\n\n// eslint-disable-next-line unused-imports/no-unused-vars\ntype ExtractQueryData<T> = T extends UseQueryResult<infer T> ? T : never\nexport const useShareSubscription = ({ userId }: { userId: string }) => {\n  const queryKey = useMemo(() => [\"public\", \"subscription\", userId], [userId])\n  const query = useQuery({\n    queryKey,\n    queryFn: async () => {\n      const subscriptions = await followClient.api.subscriptions.get({\n        userId,\n      })\n\n      return subscriptions\n    },\n  })\n  const queryClient = useQueryClient()\n  return {\n    ...query,\n    removeItemById: useCallback(\n      (id: string) => {\n        type QueryData = ExtractQueryData<typeof query>\n        queryClient.cancelQueries({ queryKey })\n        queryClient.setQueryData<QueryData>(queryKey, (data) => {\n          if (!data) return\n          return {\n            ...data,\n            data: data?.data.filter((item) => item.feedId !== id),\n          }\n        })\n      },\n      [queryClient, queryKey],\n    ),\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/hooks/useTOTPModalWrapper.tsx",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport { useCallback } from \"react\"\nimport Siblings from \"react-native-root-siblings\"\n\nimport { getFetchErrorInfo } from \"@/src/lib/error-parser\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\nimport { TwoFactorAuthScreen } from \"@/src/screens/(modal)/TwoFactorAuthScreen\"\n\nimport { OTPWindow } from \"../components/OTPWindow\"\n\nexport const useTOTPModalWrapper = <T extends { TOTPCode?: string }>(\n  callback: (input: T) => Promise<any>,\n  options?: { force?: boolean; dismiss?: () => any },\n) => {\n  const user = useWhoami()\n  const navigation = useNavigation()\n  return useCallback(\n    async (input: T) => {\n      const presentTOTPModal = () => {\n        options?.dismiss?.()\n        if (!user?.twoFactorEnabled) {\n          toast.error(\"You need to enable two-factor authentication to perform this action.\")\n\n          navigation.pushControllerView(TwoFactorAuthScreen)\n\n          return\n        }\n\n        const root = new Siblings(\n          <OTPWindow\n            verifyFn={async (TOTPCode) => {\n              await callback({\n                ...input,\n                TOTPCode,\n              })\n\n              root.destroy()\n            }}\n            onDismiss={() => {\n              root.destroy()\n            }}\n            onSuccess={async () => {\n              root.destroy()\n            }}\n          />,\n        )\n      }\n\n      if (options?.force) {\n        presentTOTPModal()\n        return\n      }\n\n      try {\n        await callback(input)\n      } catch (error) {\n        const { code } = getFetchErrorInfo(error as Error)\n        if (code === 4008) {\n          presentTOTPModal()\n        }\n      }\n    },\n    [callback, navigation, options, user?.twoFactorEnabled],\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/2FASetting.tsx",
    "content": "import { whoamiQueryKey } from \"@follow/store/user/hooks\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { KeyboardAvoidingView, View } from \"react-native\"\nimport type { OtpInputRef } from \"react-native-otp-entry\"\nimport { OtpInput } from \"react-native-otp-entry\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { QRCode } from \"@/src/components/ui/qrcode/QRCode\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { twoFactor } from \"@/src/lib/auth\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { queryClient } from \"@/src/lib/query-client\"\nimport { toast } from \"@/src/lib/toast\"\nimport { accentColor, useColor } from \"@/src/theme/colors\"\n\nconst isAuthCodeValid = (code: string) => {\n  return code.length === 6 && Number(code) > 0\n}\nexport const TwoFASetting: NavigationControllerView<{\n  totpURI: string\n}> = ({ totpURI }) => {\n  const { t } = useTranslation(\"settings\")\n  const label = useColor(\"label\")\n  const tertiaryLabel = useColor(\"tertiaryLabel\")\n  const navigation = useNavigation()\n  const submitMutation = useMutation({\n    mutationFn: async (value: string) => {\n      const res = await twoFactor.verifyTotp({\n        code: value,\n      })\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n      await queryClient.invalidateQueries({\n        queryKey: whoamiQueryKey,\n      })\n    },\n    onError(error) {\n      toast.error(`${t(\"profile.two_factor.verify_failed\")}: ${error.message}`)\n    },\n    onSuccess() {\n      navigation.back()\n      toast.success(t(\"profile.two_factor.enabled\"))\n    },\n  })\n  const otpInputRef = useRef<OtpInputRef>(null)\n  return (\n    <KeyboardAvoidingView behavior=\"padding\">\n      <SafeNavigationScrollView\n        Header={<NavigationBlurEffectHeaderView title={t(\"profile.two_factor.setup.title\")} />}\n      >\n        <View className=\"my-8 items-center justify-center\">\n          <QRCode\n            data={totpURI}\n            padding={20}\n            pieceSize={7}\n            pieceBorderRadius={4}\n            isPiecesGlued\n            color={label}\n            preserveAspectRatio=\"none\"\n          />\n        </View>\n\n        <View className=\"mx-12\">\n          <Text className=\"mb-8 text-sm text-secondary-label\">\n            {t(\"profile.two_factor.setup.description\")}\n          </Text>\n        </View>\n\n        <OtpInput\n          disabled={submitMutation.isPending}\n          ref={otpInputRef}\n          numberOfDigits={6}\n          autoFocus\n          focusColor={accentColor}\n          theme={{\n            containerStyle: {\n              paddingHorizontal: 20,\n            },\n            pinCodeTextStyle: {\n              color: label,\n            },\n            placeholderTextStyle: {\n              color: tertiaryLabel,\n            },\n            filledPinCodeContainerStyle: {\n              borderColor: label,\n            },\n            pinCodeContainerStyle: {\n              borderColor: tertiaryLabel,\n              aspectRatio: 1,\n              width: 50,\n            },\n            focusedPinCodeContainerStyle: {\n              borderColor: accentColor,\n            },\n          }}\n          onFilled={(code) => {\n            if (isAuthCodeValid(code)) {\n              submitMutation.mutate(code)\n            }\n          }}\n        />\n      </SafeNavigationScrollView>\n    </KeyboardAvoidingView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/About.tsx",
    "content": "import { nativeApplicationVersion, nativeBuildVersion } from \"expo-application\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { Linking, View } from \"react-native\"\n\nimport { Link } from \"@/src/components/common/Link\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport {\n  GroupedInsetListBaseCell,\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListNavigationLink,\n  GroupedInsetListNavigationLinkIcon,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Logo } from \"@/src/components/ui/logo\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { DiscordCuteFiIcon } from \"@/src/icons/discord_cute_fi\"\nimport { GithubCuteFiIcon } from \"@/src/icons/github_cute_fi\"\nimport { SocialXCuteReIcon } from \"@/src/icons/social_x_cute_re\"\nimport { useMobileReviewPromptState } from \"@/src/modules/review-prompt/use-review-prompt-state\"\nimport {\n  isMobileNativeReviewAvailable,\n  openMobileFeedbackEmail,\n  openMobileStoreReview,\n  persistMobileNegativeFeedback,\n  readMobileReviewPromptState,\n  requestMobileNativeReview,\n} from \"@/src/modules/review-prompt/utils\"\n\nconst links = [\n  {\n    title: \"GitHub\",\n    icon: GithubCuteFiIcon,\n    url: \"https://github.com/RSSNext/Folo\",\n    iconBackgroundColor: \"#000000\",\n    iconColor: \"#FFFFFF\",\n  },\n  {\n    title: \"X\",\n    icon: SocialXCuteReIcon,\n    url: \"https://x.com/intent/follow?screen_name=folo_is\",\n    iconBackgroundColor: \"#000000\",\n    iconColor: \"#FFFFFF\",\n  },\n  {\n    title: \"Discord\",\n    icon: DiscordCuteFiIcon,\n    url: \"https://discord.gg/AwWcAQ7euc\",\n    iconBackgroundColor: \"#5865F2\",\n    iconColor: \"#FFFFFF\",\n  },\n]\nexport const AboutScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  const buildId = nativeBuildVersion\n  const appVersion = nativeApplicationVersion\n  const { distribution, platform, rateTarget, storageKey, userId } = useMobileReviewPromptState()\n\n  const handleRateFolo = async () => {\n    const latestState = readMobileReviewPromptState(storageKey)\n\n    if (await isMobileNativeReviewAvailable(distribution)) {\n      await requestMobileNativeReview({\n        appVersion: appVersion ?? \"unknown\",\n        distribution,\n        platform,\n        source: \"manual\",\n        state: latestState,\n        storageKey,\n        trackPositive: true,\n      })\n      return\n    }\n\n    await openMobileStoreReview({\n      appVersion: appVersion ?? \"unknown\",\n      distribution,\n      platform,\n      source: \"manual\",\n      state: latestState,\n      storageKey,\n      target: rateTarget,\n    })\n  }\n\n  const handleSendFeedback = async () => {\n    persistMobileNegativeFeedback({\n      appVersion: appVersion ?? \"unknown\",\n      distribution,\n      platform,\n      source: \"manual\",\n      state: readMobileReviewPromptState(storageKey),\n      storageKey,\n    })\n    await openMobileFeedbackEmail({ distribution, userId })\n  }\n\n  return (\n    <SafeNavigationScrollView\n      Header={<NavigationBlurEffectHeaderView title={t(\"titles.about\")} />}\n      className=\"bg-system-grouped-background\"\n      contentViewClassName=\"pt-6\"\n    >\n      <GroupedInsetListCard>\n        <GroupedInsetListBaseCell className=\"flex-col py-6\">\n          <View className=\"flex-1 items-center justify-center\">\n            <Logo height={80} width={80} />\n            <Text className=\"mt-4 text-2xl font-semibold text-label\">Folo</Text>\n            <Text className=\"font-mono text-sm text-tertiary-label\">\n              {appVersion} ({buildId})\n            </Text>\n          </View>\n          <View className=\"mt-6 flex-1\">\n            <Trans\n              ns=\"settings\"\n              i18nKey=\"about.feedbackInfo\"\n              parent={({ children }: { children: React.ReactNode }) => (\n                <Text className=\"text-[15px] text-label\">{children}</Text>\n              )}\n              values={{\n                appName: \"Folo\",\n                commitSha: `${appVersion}-${buildId}`,\n              }}\n              components={{\n                OpenIssueLink: (\n                  <Link className=\"text-accent\" href=\"https://github.com/RSSNext/follow\" />\n                ),\n                ExternalLinkIcon: <View />,\n              }}\n            />\n\n            <Trans\n              ns=\"settings\"\n              i18nKey=\"about.iconLibrary\"\n              parent={({ children }: { children: React.ReactNode }) => (\n                <Text className=\"mt-4 text-[15px] text-label\">{children}</Text>\n              )}\n              components={{\n                IconLibraryLink: (\n                  <Link className=\"text-accent\" href=\"https://mgc.mingcute.com/\">\n                    https://mgc.mingcute.com/\n                  </Link>\n                ),\n                ExternalLinkIcon: <View />,\n              }}\n            />\n\n            <Trans\n              ns=\"settings\"\n              i18nKey=\"about.licenseInfo\"\n              parent={({ children }: { children: React.ReactNode }) => (\n                <Text className=\"mt-4 text-[15px] text-label\">{children}</Text>\n              )}\n              values={{\n                currentYear: new Date().getFullYear(),\n                appName: \"Folo\",\n              }}\n            />\n          </View>\n        </GroupedInsetListBaseCell>\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader label={t(\"about.support\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"about.rateFolo\")}\n          description={t(\"about.rateFoloDescription\")}\n          onPress={() => {\n            void handleRateFolo()\n          }}\n        />\n        <GroupedInsetListCell\n          label={t(\"about.sendFeedback\")}\n          description={t(\"about.sendFeedbackDescription\")}\n          onPress={() => {\n            void handleSendFeedback()\n          }}\n        />\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader label={t(\"about.socialMedia\")} />\n      <GroupedInsetListCard>\n        {links.map((link) => (\n          <GroupedInsetListNavigationLink\n            key={link.title}\n            label={link.title}\n            icon={\n              <GroupedInsetListNavigationLinkIcon backgroundColor={link.iconBackgroundColor}>\n                <link.icon color={link.iconColor} height={18} width={18} />\n              </GroupedInsetListNavigationLinkIcon>\n            }\n            onPress={() => Linking.openURL(link.url)}\n          />\n        ))}\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Account.tsx",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Alert, View } from \"react-native\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { GroupedInsetListCardItemStyle } from \"@/src/components/ui/grouped/GroupedInsetListCardItemStyle\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListNavigationLink,\n  GroupedInsetListNavigationLinkIcon,\n  GroupedInsetListSectionHeader,\n  GroupedPlainButtonCell,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { AppleCuteFiIcon } from \"@/src/icons/apple_cute_fi\"\nimport { GithubCuteFiIcon } from \"@/src/icons/github_cute_fi\"\nimport { GoogleCuteFiIcon } from \"@/src/icons/google_cute_fi\"\nimport type { AuthProvider } from \"@/src/lib/auth\"\nimport {\n  deleteUser,\n  forgetPassword,\n  getAccountInfo,\n  getProviders,\n  linkSocial,\n  unlinkAccount,\n} from \"@/src/lib/auth\"\nimport { Dialog } from \"@/src/lib/dialog\"\nimport { loading } from \"@/src/lib/loading\"\nimport { openLink } from \"@/src/lib/native\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\n\nimport { ConfirmPasswordDialog } from \"../../dialogs/ConfirmPasswordDialog\"\nimport { ConfirmTOTPCodeDialog } from \"../../dialogs/ConfirmTOTPCodeDialog\"\nimport { TwoFASetting } from \"./2FASetting\"\nimport { ResetPassword } from \"./ResetPassword\"\n\ntype Account = {\n  id: string\n  accountId?: string\n  provider: string\n  profile:\n    | {\n        id: string | number\n        name?: string\n        email?: string | null\n        image?: string\n        emailVerified: boolean\n      }\n    | null\n    | undefined\n}\nconst accountInfoKey = [\"account-info\"]\nconst userProviderKey = [\"providers\"]\nconst useAccount = () => {\n  return useQuery({\n    queryKey: accountInfoKey,\n    queryFn: () => getAccountInfo(),\n  })\n}\nexport const AccountScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"titles.account\")} />}\n    >\n      <AuthenticationSection />\n      <SecuritySection />\n    </SafeNavigationScrollView>\n  )\n}\nconst provider2IconMap = {\n  google: (\n    <GroupedInsetListNavigationLinkIcon backgroundColor=\"#4081EC\">\n      <GoogleCuteFiIcon height={24} width={24} color=\"#fff\" />\n    </GroupedInsetListNavigationLinkIcon>\n  ),\n  github: (\n    <GroupedInsetListNavigationLinkIcon backgroundColor=\"#000\">\n      <GithubCuteFiIcon height={24} width={24} color=\"#fff\" />\n    </GroupedInsetListNavigationLinkIcon>\n  ),\n  apple: (\n    <GroupedInsetListNavigationLinkIcon backgroundColor=\"#000\">\n      <AppleCuteFiIcon height={24} width={24} color=\"#fff\" />\n    </GroupedInsetListNavigationLinkIcon>\n  ),\n}\nconst provider2LabelMap = {\n  google: \"Google\",\n  github: \"GitHub\",\n  apple: \"Apple\",\n}\nconst AccountLinker: FC<{\n  provider: keyof typeof provider2IconMap\n  account?: Account\n}> = ({ provider, account }) => {\n  const { t } = useTranslation([\"settings\", \"common\"])\n  const queryClient = useQueryClient()\n  const unlinkAccountMutation = useMutation({\n    mutationFn: async () => {\n      if (!account) throw new Error(\"Account not found\")\n      const res = await unlinkAccount({\n        providerId: provider,\n        accountId: account.accountId,\n      })\n      if (res.error) throw new Error(res.error.message)\n    },\n    onSuccess: () => {\n      toast.success(t(\"profile.link_social.unlink.success\"))\n      queryClient.invalidateQueries({\n        queryKey: accountInfoKey,\n      })\n      queryClient.invalidateQueries({\n        queryKey: userProviderKey,\n      })\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n  })\n  if (!provider2LabelMap[provider]) return null\n  return (\n    <GroupedInsetListNavigationLink\n      label={provider2LabelMap[provider]}\n      icon={provider2IconMap[provider]}\n      postfix={\n        <Text\n          ellipsizeMode=\"tail\"\n          numberOfLines={1}\n          className=\"mr-1 max-w-[150px] text-secondary-label\"\n        >\n          {account?.profile?.email || account?.profile?.name || \"\"}\n        </Text>\n      }\n      onPress={() => {\n        if (!account) {\n          linkSocial({ provider: provider as any })\n            .then((res) => {\n              if (!res.data?.url) {\n                toast.error(t(\"profile.link_social.link_failed\"))\n                return\n              }\n              openLink(res.data.url, () => {\n                queryClient.invalidateQueries({\n                  queryKey: accountInfoKey,\n                })\n                queryClient.invalidateQueries({\n                  queryKey: userProviderKey,\n                })\n              })\n            })\n            .catch((error) => {\n              toast.error(\n                error instanceof Error ? error.message : t(\"profile.link_social.link_failed\"),\n              )\n            })\n          return\n        }\n        Alert.alert(\n          t(\"profile.link_social.unlink.title\"),\n          t(\"profile.link_social.unlink.confirm\"),\n          [\n            {\n              text: t(\"words.cancel\", { ns: \"common\" }),\n              style: \"cancel\",\n            },\n            {\n              text: t(\"profile.link_social.unlink.action\"),\n              style: \"destructive\",\n              onPress: () => unlinkAccountMutation.mutate(),\n            },\n          ],\n        )\n      }}\n    />\n  )\n}\n;(AccountLinker as any).itemStyle = GroupedInsetListCardItemStyle.NavigationLink\nconst AuthenticationSection = () => {\n  const { t } = useTranslation(\"settings\")\n  const { data: accounts } = useAccount()\n  const { data: providers, isLoading } = useQuery({\n    queryKey: userProviderKey,\n    queryFn: async () => (await getProviders()).data as Record<string, AuthProvider>,\n  })\n  const providerToAccountMap = useMemo(() => {\n    const providerMap: Record<string, Account | undefined> = {}\n    for (const provider of Object.keys(providers || {})) {\n      providerMap[provider] = accounts?.data?.find((account) => account.provider === provider)\n    }\n    return providerMap\n  }, [accounts?.data, providers])\n  return (\n    <>\n      <GroupedInsetListSectionHeader label={t(\"profile.link_social.authentication\")} />\n      <GroupedInsetListCard>\n        {providers ? (\n          Object.keys(providers).map((provider) => (\n            <AccountLinker\n              key={provider}\n              provider={provider as any}\n              account={providerToAccountMap[provider]}\n            />\n          ))\n        ) : isLoading ? (\n          <View className=\"flex h-12 flex-1 items-center justify-center\">\n            <PlatformActivityIndicator />\n          </View>\n        ) : null}\n      </GroupedInsetListCard>\n    </>\n  )\n}\nconst SecuritySection = () => {\n  const { t } = useTranslation([\"settings\", \"common\"])\n  const { data: account } = useAccount()\n  const hasPassword = account?.data?.find((account) => account.provider === \"credential\")\n  const whoAmI = useWhoami()\n  const twoFactorEnabled = whoAmI?.twoFactorEnabled\n  const navigation = useNavigation()\n  return (\n    <>\n      <GroupedInsetListSectionHeader label={t(\"profile.security\")} />\n      <GroupedInsetListCard>\n        <GroupedPlainButtonCell\n          textClassName=\"text-left\"\n          label={t(\"profile.change_password.label\")}\n          onPress={() => {\n            const email = whoAmI?.email || \"\"\n            if (!email) {\n              toast.error(t(\"profile.change_password.email_required\"))\n              return\n            }\n            if (!hasPassword) {\n              forgetPassword({\n                email,\n              })\n              toast.success(t(\"profile.reset_password_mail_sent\"))\n            } else {\n              navigation.pushControllerView(ResetPassword)\n            }\n          }}\n        />\n        <GroupedPlainButtonCell\n          textClassName=\"text-left\"\n          label={\n            twoFactorEnabled ? t(\"profile.two_factor.disable\") : t(\"profile.two_factor.enable\")\n          }\n          onPress={() => {\n            Dialog.show(ConfirmPasswordDialog, {\n              override: {\n                async onConfirm(ctx) {\n                  ctx.dismiss()\n                  const { done } = loading.start()\n                  if (twoFactorEnabled) {\n                    const res = await userSyncService\n                      .updateTwoFactor(false, ctx.password)\n                      .finally(() => done())\n                    if (res.error?.message) {\n                      toast.error(t(\"profile.two_factor.invalid_password\"))\n                      return\n                    }\n                    toast.success(t(\"profile.two_factor.disabled\"))\n                    return\n                  }\n                  const { password } = ctx\n                  const res = await userSyncService\n                    .updateTwoFactor(true, password)\n                    .finally(() => done())\n                  if (res.error?.message) {\n                    toast.error(t(\"profile.two_factor.invalid_password\"))\n                    return\n                  }\n                  if (res.data && \"totpURI\" in res.data) {\n                    navigation.pushControllerView(TwoFASetting, {\n                      totpURI: res.data.totpURI,\n                    })\n                  } else {\n                    toast.error(t(\"profile.two_factor.enable_failed\"))\n                  }\n                },\n              },\n            })\n          }}\n        />\n        <GroupedPlainButtonCell\n          label={t(\"profile.delete_account.label\")}\n          textClassName=\"text-red text-left\"\n          onPress={async () => {\n            Alert.alert(\n              t(\"profile.delete_account.confirm_title\"),\n              t(\"profile.delete_account.confirm_description\"),\n              [\n                {\n                  text: t(\"words.cancel\", { ns: \"common\" }),\n                  style: \"cancel\",\n                },\n                {\n                  text: t(\"words.delete\", { ns: \"common\" }),\n                  style: \"destructive\",\n                  onPress: async () => {\n                    // await signOut()\n                    Dialog.show(ConfirmTOTPCodeDialog, {\n                      override: {\n                        async onConfirm(ctx) {\n                          ctx.dismiss()\n                          await deleteUser({\n                            TOTPCode: ctx.totpCode,\n                          })\n                        },\n                      },\n                    })\n                  },\n                },\n              ],\n            )\n          }}\n        />\n      </GroupedInsetListCard>\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Achievement.tsx",
    "content": "import { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const AchievementScreen = () => {\n  return (\n    <View className=\"flex-1 items-center justify-center\">\n      <Text>Achievement Screen</Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Actions.tsx",
    "content": "import {\n  useActionRules,\n  useIsActionDataDirty,\n  usePrefetchActions,\n  useUpdateActionsMutation,\n} from \"@follow/store/action/hooks\"\nimport type { ActionModel } from \"@follow/store/action/store\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport { useCallback } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { ListRenderItem } from \"react-native\"\nimport { StyleSheet, View } from \"react-native\"\nimport Animated, { LinearTransition } from \"react-native-reanimated\"\nimport { useColors } from \"react-native-uikit-colors\"\n\nimport { Link } from \"@/src/components/common/Link\"\nimport { SwipeableGroupProvider, SwipeableItem } from \"@/src/components/common/SwipeableItem\"\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport {\n  GroupedInformationCell,\n  GroupedInsetListCard,\n  GroupedPlainButtonCell,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Switch } from \"@/src/components/ui/switch/Switch\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { Book6CuteReIcon } from \"@/src/icons/book_6_cute_re\"\nimport { Magic2CuteFiIcon } from \"@/src/icons/magic_2_cute_fi\"\nimport { toastFetchError } from \"@/src/lib/error-parser\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { EditRuleScreen } from \"./EditRule\"\n\nexport const ActionsScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  const { t: tCommon } = useTranslation(\"common\")\n  const { isLoading } = usePrefetchActions()\n  const rules = useActionRules()\n  const isDirty = useIsActionDataDirty()\n  return (\n    <SafeNavigationScrollView\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={t(\"titles.actions\")}\n          headerRight={useCallback(\n            () => (\n              <SaveRuleButton disabled={!isDirty} />\n            ),\n            [isDirty],\n          )}\n          promptBeforeLeave={isDirty}\n        />\n      }\n      nestedScrollEnabled\n      className=\"bg-system-grouped-background\"\n    >\n      <View className=\"mt-6\">\n        <GroupedInsetListCard>\n          <GroupedInformationCell\n            title={t(\"titles.actions\")}\n            description={t(\"actions.info\")}\n            icon={<Magic2CuteFiIcon height={40} width={40} color=\"#fff\" />}\n            iconBackgroundColor=\"#9333EA\"\n          >\n            <Link\n              className=\"center mt-4 w-44 rounded-full border border-accent py-0.5 text-accent\"\n              href=\"https://github.com/RSSNext/Folo/wiki/Actions\"\n            >\n              <View className=\"flex w-full flex-row items-center justify-center gap-1\">\n                <Book6CuteReIcon color={accentColor} width={16} height={16} />\n                <Text className=\"text-accent\">{tCommon(\"words.documentation\")}</Text>\n              </View>\n            </Link>\n          </GroupedInformationCell>\n        </GroupedInsetListCard>\n      </View>\n\n      <View className=\"mt-6\">\n        <GroupedInsetListCard>\n          {rules.length > 0 ? (\n            <SwipeableGroupProvider>\n              <Animated.FlatList\n                keyExtractor={keyExtractor}\n                itemLayoutAnimation={LinearTransition}\n                scrollEnabled={false}\n                data={rules}\n                renderItem={ListItemCell}\n                ItemSeparatorComponent={ItemSeparatorComponent}\n              />\n            </SwipeableGroupProvider>\n          ) : isLoading && rules.length === 0 ? (\n            <View className=\"my-4\">\n              <PlatformActivityIndicator />\n            </View>\n          ) : null}\n        </GroupedInsetListCard>\n        <NewRuleButton />\n      </View>\n    </SafeNavigationScrollView>\n  )\n}\nconst NewRuleButton = () => {\n  const { t } = useTranslation(\"settings\")\n  return (\n    <GroupedInsetListCard className=\"mt-6\">\n      <GroupedPlainButtonCell\n        label={t(\"actions.newRule\")}\n        onPress={() => {\n          actionActions.addRule((number) =>\n            t(\"actions.actionName\", {\n              number,\n            }),\n          )\n        }}\n      />\n    </GroupedInsetListCard>\n  )\n}\nconst SaveRuleButton = ({ disabled }: { disabled?: boolean }) => {\n  const navigation = useNavigation()\n  const { mutate, isPending } = useUpdateActionsMutation({\n    onSuccess() {\n      navigation.back()\n      toast.success(\"Actions saved\")\n    },\n    onError(error) {\n      toastFetchError(error)\n    },\n  })\n  return (\n    <HeaderSubmitTextButton\n      label=\"Save\"\n      isValid={!disabled}\n      onPress={mutate}\n      isLoading={isPending}\n    />\n  )\n}\nconst ItemSeparatorComponent = () => {\n  return (\n    <View\n      className=\"ml-24 flex-1 bg-opaque-separator/70\"\n      collapsable={false}\n      style={styles.itemSeparator}\n    />\n  )\n}\nconst keyExtractor = (item: ActionModel) => item.index.toString()\nconst ListItemCell: ListRenderItem<ActionModel> = (props) => {\n  return <ListItemCellImpl {...props} />\n}\nconst ListItemCellImpl: ListRenderItem<ActionModel> = ({ item: rule }) => {\n  const { t } = useTranslation(\"common\")\n  const navigation = useNavigation()\n  const colors = useColors()\n  return (\n    <SwipeableItem\n      swipeRightToCallAction\n      rightActions={[\n        {\n          label: t(\"words.delete\"),\n          onPress: () => {\n            actionActions.deleteRule(rule.index)\n          },\n          backgroundColor: colors.red,\n        },\n        {\n          label: t(\"words.edit\"),\n          onPress: () => {\n            navigation.pushControllerView(EditRuleScreen, {\n              index: rule.index,\n            })\n          },\n          backgroundColor: colors.blue,\n        },\n      ]}\n    >\n      <ItemPressable\n        className=\"flex flex-row justify-between p-4\"\n        onPress={() =>\n          navigation.pushControllerView(EditRuleScreen, {\n            index: rule.index,\n          })\n        }\n      >\n        <Text className=\"text-base text-label\">{rule.name}</Text>\n        <Switch\n          size=\"sm\"\n          value={!rule.result.disabled}\n          onValueChange={() => {\n            actionActions.patchRule(rule.index, {\n              result: {\n                disabled: !rule.result.disabled,\n              },\n            })\n          }}\n        />\n      </ItemPressable>\n    </SwipeableItem>\n  )\n}\n\nconst styles = StyleSheet.create({\n  itemSeparator: {\n    height: StyleSheet.hairlineWidth,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Appearance.tsx",
    "content": "import { getUnreadAll } from \"@follow/store/unread/getters\"\nimport { themeNames } from \"@shikijs/themes\"\nimport { useTranslation } from \"react-i18next\"\nimport { useColorScheme } from \"react-native\"\n\nimport { setUISetting, useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { Select } from \"@/src/components/ui/form/Select\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Switch } from \"@/src/components/ui/switch/Switch\"\n// Font size presets\nimport { setBadgeCountAsyncWithPermission } from \"@/src/lib/permission\"\n\nconst fontSizePresets = [\n  {\n    value: 0.8,\n    key: \"xs\",\n  },\n  {\n    value: 0.9,\n    key: \"s\",\n  },\n  {\n    value: 1,\n    key: \"m\",\n  },\n  {\n    value: 1.2,\n    key: \"l\",\n  },\n  {\n    value: 1.5,\n    key: \"xl\",\n  },\n] as const\n\n// Content font size presets (in px)\nconst contentFontSizePresets = [\n  {\n    value: 12,\n    key: \"xs\",\n  },\n  {\n    value: 14,\n    key: \"s\",\n  },\n  {\n    value: 16,\n    key: \"m\",\n  },\n  {\n    value: 18,\n    key: \"l\",\n  },\n  {\n    value: 20,\n    key: \"xl\",\n  },\n] as const\n\nexport const AppearanceScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  const showUnreadCountViewAndSubscriptionMobile = useUISettingKey(\n    \"showUnreadCountViewAndSubscriptionMobile\",\n  )\n  const showUnreadCountBadgeMobile = useUISettingKey(\"showUnreadCountBadgeMobile\")\n  const hideExtraBadge = useUISettingKey(\"hideExtraBadge\")\n  const thumbnailRatio = useUISettingKey(\"thumbnailRatio\")\n  const codeThemeLight = useUISettingKey(\"codeHighlightThemeLight\")\n  const codeThemeDark = useUISettingKey(\"codeHighlightThemeDark\")\n  const colorScheme = useColorScheme()\n  const readerRenderInlineStyle = useUISettingKey(\"readerRenderInlineStyle\")\n  const hideRecentReader = useUISettingKey(\"hideRecentReader\")\n\n  // Font scaling settings\n  const fontScale = useUISettingKey(\"fontScale\")\n  const useSystemFontScaling = useUISettingKey(\"useSystemFontScaling\")\n  const useDifferentFontSizeForContent = useUISettingKey(\"useDifferentFontSizeForContent\")\n  const mobileContentFontSize = useUISettingKey(\"mobileContentFontSize\")\n  const selectWrapperClassName = \"w-auto min-w-[96px] max-w-[44vw] shrink-0\"\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"appearance.title\")} />}\n    >\n      <GroupedInsetListSectionHeader\n        label={t(\"appearance.unread_count.label\")}\n        marginSize=\"small\"\n      />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"appearance.unread_count.badge.label\")}\n          description={t(\"appearance.unread_count.badge.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={showUnreadCountBadgeMobile}\n            onValueChange={(val) => {\n              setUISetting(\"showUnreadCountBadgeMobile\", val)\n              setBadgeCountAsyncWithPermission(val ? getUnreadAll() : 0, true)\n            }}\n          />\n        </GroupedInsetListCell>\n        <GroupedInsetListCell\n          label={t(\"appearance.unread_count.view_and_subscription.label\")}\n          description={t(\"appearance.unread_count.view_and_subscription.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={showUnreadCountViewAndSubscriptionMobile}\n            onValueChange={(val) => {\n              setUISetting(\"showUnreadCountViewAndSubscriptionMobile\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader label={t(\"appearance.subscriptions\")} marginSize=\"small\" />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"appearance.hide_extra_badge.label\")}\n          description={t(\"appearance.hide_extra_badge.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={hideExtraBadge}\n            onValueChange={(val) => {\n              setUISetting(\"hideExtraBadge\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n        <GroupedInsetListCell\n          label={t(\"appearance.thumbnail_ratio.title\")}\n          description={t(\"appearance.thumbnail_ratio.description\")}\n        >\n          <Select\n            wrapperClassName={selectWrapperClassName}\n            options={[\n              {\n                label: t(\"appearance.thumbnail_ratio.square\"),\n                value: \"square\",\n              },\n              {\n                label: t(\"appearance.thumbnail_ratio.original\"),\n                value: \"original\",\n              },\n            ]}\n            value={thumbnailRatio}\n            onValueChange={(val) => {\n              setUISetting(\"thumbnailRatio\", val as \"square\" | \"original\")\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader label={t(\"appearance.font_scaling.title\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"appearance.font_scaling.system.label\")}\n          description={t(\"appearance.font_scaling.system.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={useSystemFontScaling}\n            onValueChange={(val) => {\n              setUISetting(\"useSystemFontScaling\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n        <GroupedInsetListCell\n          label={t(\"appearance.font_scaling.scale.label\")}\n          description={t(\"appearance.font_scaling.scale.description\")}\n        >\n          <Select\n            wrapperClassName={selectWrapperClassName}\n            options={fontSizePresets.map((preset) => ({\n              label: t(`appearance.font_scaling.size.${preset.key}`),\n              value: preset.value.toString(),\n            }))}\n            value={fontScale.toString()}\n            onValueChange={(val) => {\n              setUISetting(\"fontScale\", Number.parseFloat(val))\n            }}\n            disabled={useSystemFontScaling}\n          />\n        </GroupedInsetListCell>\n        <GroupedInsetListCell\n          label={t(\"appearance.font_scaling.content_different.label\")}\n          description={t(\"appearance.font_scaling.content_different.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={useDifferentFontSizeForContent}\n            onValueChange={(val) => {\n              setUISetting(\"useDifferentFontSizeForContent\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n        {useDifferentFontSizeForContent && (\n          <GroupedInsetListCell\n            label={t(\"appearance.font_scaling.content_size.label\")}\n            description={t(\"appearance.font_scaling.content_size.description\")}\n          >\n            <Select\n              wrapperClassName={selectWrapperClassName}\n              options={contentFontSizePresets.map((preset) => ({\n                label: t(`appearance.font_scaling.content_size.${preset.key}`),\n                value: preset.value.toString(),\n              }))}\n              value={mobileContentFontSize.toString()}\n              onValueChange={(val) => {\n                setUISetting(\"mobileContentFontSize\", Number.parseInt(val))\n              }}\n            />\n          </GroupedInsetListCell>\n        )}\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader label={t(\"appearance.content\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell label={t(\"appearance.code_highlight_theme.label\")}>\n          <Select\n            wrapperClassName={selectWrapperClassName}\n            options={themeNames.map((theme) => ({\n              label: theme,\n              value: theme,\n            }))}\n            value={colorScheme === \"dark\" ? codeThemeDark : codeThemeLight}\n            onValueChange={(val) => {\n              setUISetting(`codeHighlightTheme${colorScheme === \"dark\" ? \"Dark\" : \"Light\"}`, val)\n            }}\n          />\n        </GroupedInsetListCell>\n\n        <GroupedInsetListCell\n          label={t(\"appearance.reader_render_inline_style.label\")}\n          description={t(\"appearance.reader_render_inline_style.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={readerRenderInlineStyle}\n            onValueChange={(val) => {\n              setUISetting(\"readerRenderInlineStyle\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n\n        <GroupedInsetListCell\n          label={t(\"appearance.hide_recent_reader.label\")}\n          description={t(\"appearance.hide_recent_reader.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={hideRecentReader}\n            onValueChange={(val) => {\n              setUISetting(\"hideRecentReader\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Data.tsx",
    "content": "import * as FileSystem from \"expo-file-system/legacy\"\nimport { useTranslation } from \"react-i18next\"\nimport { Alert } from \"react-native\"\n\nimport { setDataSetting, useDataSettingKey } from \"@/src/atoms/settings/data\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport {\n  GroupedInsetListActionCell,\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Switch } from \"@/src/components/ui/switch/Switch\"\nimport { getDbPath } from \"@/src/database\"\nimport { toast } from \"@/src/lib/toast\"\n\nimport { exportLocalDatabase, importOpml } from \"../utils\"\n\nexport const DataScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  const sendAnonymousData = useDataSettingKey(\"sendAnonymousData\")\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"titles.data_control\")} />}\n    >\n      <GroupedInsetListSectionHeader label={t(\"general.privacy\")} marginSize=\"small\" />\n\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"general.send_anonymous_data.label\")}\n          description={t(\"general.send_anonymous_data.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={sendAnonymousData}\n            onValueChange={(val) => {\n              setDataSetting(\"sendAnonymousData\", val)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n\n      {/* Data Sources */}\n\n      <GroupedInsetListSectionHeader label={t(\"data_control.data_sources\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListActionCell\n          onPress={importOpml}\n          label={t(\"data_control.import_opml.label\")}\n        />\n\n        <GroupedInsetListActionCell\n          onPress={exportLocalDatabase}\n          label={t(\"data_control.export_local_database.label\")}\n        />\n      </GroupedInsetListCard>\n\n      {/* Utils */}\n\n      <GroupedInsetListSectionHeader label={t(\"data_control.utils\")} />\n\n      <GroupedInsetListCard>\n        <GroupedInsetListActionCell\n          onPress={() => {\n            Alert.alert(\n              t(\"general.rebuild_database.title\"),\n              t(\"general.rebuild_database.warning.line1\"),\n              [\n                {\n                  text: t(\"general.rebuild_database.cancel\"),\n                  style: \"cancel\",\n                },\n                {\n                  text: t(\"general.rebuild_database.button\"),\n                  style: \"destructive\",\n                  onPress: async () => {\n                    const dbPath = getDbPath()\n                    await FileSystem.deleteAsync(dbPath)\n                    await expo.reloadAppAsync(\"Clear Sqlite Data\")\n                  },\n                },\n              ],\n            )\n          }}\n          label={t(\"general.rebuild_database.label\")}\n          description={t(\"general.rebuild_database.description\")}\n        />\n\n        <GroupedInsetListActionCell\n          onPress={() => {\n            Alert.alert(\n              t(\"data_control.clean_cache.button\"),\n              t(\"data_control.clean_cache.description\"),\n              [\n                {\n                  text: t(\"data_control.clean_cache.cancel\"),\n                  style: \"cancel\",\n                },\n                {\n                  text: t(\"data_control.clean_cache.clear\"),\n                  isPreferred: true,\n                  onPress: async () => {\n                    const cacheDir = FileSystem.cacheDirectory\n                    if (cacheDir) {\n                      await FileSystem.deleteAsync(cacheDir, { idempotent: true })\n                    }\n                    toast.success(\"Cache cleared\")\n                  },\n                },\n              ],\n            )\n          }}\n          label={t(\"data_control.clean_cache.button\")}\n          description={t(\"data_control.clean_cache.description\")}\n        />\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/EditCondition.tsx",
    "content": "import { filterFieldOptions, filterOperatorOptions } from \"@follow/store/action/constant\"\nimport { useActionRuleCondition } from \"@follow/store/action/hooks\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport type { ActionConditionIndex } from \"@follow-app/client-sdk\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { Select } from \"@/src/components/ui/form/Select\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetListBaseCell,\n  GroupedInsetListCard,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { views } from \"@/src/constants/views\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nexport const EditConditionScreen: NavigationControllerView<{\n  ruleIndex: number\n  groupIndex: number\n  conditionIndex: number\n}> = (params) => {\n  const { t } = useTranslation(\"settings\")\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"actions.edit_condition\")} />}\n    >\n      <ConditionForm index={params} />\n    </SafeNavigationScrollView>\n  )\n}\nfunction ConditionForm({ index }: { index: ActionConditionIndex }) {\n  const { t } = useTranslation(\"settings\")\n  const item = useActionRuleCondition(index)!\n  const currentField = filterFieldOptions.find((field) => field.value === item.field)\n  const currentOperator = filterOperatorOptions.find((field) => field.value === item.operator)\n  const currentView =\n    currentField?.type === \"view\"\n      ? views.find((view) => view.view === Number(item.value))\n      : undefined\n  const operatorOptions = useMemo(() => {\n    return filterOperatorOptions\n      .map((i) => ({\n        ...i,\n        label: t(i.label),\n      }))\n      .filter((operator) => operator.types.includes(currentField?.type ?? \"text\"))\n  }, [t, currentField])\n  if (operatorOptions.length === 1 && currentOperator?.value !== operatorOptions[0]!.value) {\n    actionActions.pathCondition(index, {\n      operator: operatorOptions[0]!.value as any,\n    })\n  }\n  return (\n    <>\n      <GroupedInsetListSectionHeader label={t(\"actions.condition\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListBaseCell className=\"flex flex-row justify-between\">\n          <Text className=\"text-label\">{t(\"actions.action_card.field\")}</Text>\n          <Select\n            options={filterFieldOptions.map((i) => ({\n              ...i,\n              label: t(i.label),\n            }))}\n            value={currentField?.value}\n            onValueChange={(value) => {\n              actionActions.pathCondition(index, {\n                field: value as any,\n              })\n            }}\n            wrapperClassName=\"min-w-48\"\n          />\n        </GroupedInsetListBaseCell>\n\n        <GroupedInsetListBaseCell className=\"flex flex-row justify-between\">\n          <Text className=\"text-label\">{t(\"actions.action_card.operator\")}</Text>\n          <Select\n            options={operatorOptions}\n            value={currentOperator?.value}\n            onValueChange={(value) => {\n              actionActions.pathCondition(index, {\n                operator: value as any,\n              })\n            }}\n            wrapperClassName=\"min-w-44\"\n          />\n        </GroupedInsetListBaseCell>\n\n        <GroupedInsetListBaseCell className=\"flex flex-row justify-between\">\n          <Text className=\"text-label\">{t(\"actions.action_card.value\")}</Text>\n          <ValueField\n            type={currentField?.type ?? \"text\"}\n            value={\n              currentField?.type === \"view\"\n                ? currentView?.view !== undefined\n                  ? String(currentView.view)\n                  : undefined\n                : item.value\n            }\n            onChange={(value) => {\n              actionActions.pathCondition(index, {\n                value,\n              })\n            }}\n          />\n        </GroupedInsetListBaseCell>\n      </GroupedInsetListCard>\n      {__DEV__ && (\n        <View className=\"m-5\">\n          <Text className=\"text-label\">{JSON.stringify(item)}</Text>\n        </View>\n      )}\n    </>\n  )\n}\nfunction ValueField({\n  type,\n  value,\n  onChange,\n}: {\n  type: string\n  value?: string\n  onChange: (value: string | undefined) => void\n}) {\n  const { t } = useTranslation(\"common\")\n  switch (type) {\n    case \"view\": {\n      return (\n        <Select\n          options={views.map((field) => ({\n            label: t(field.name),\n            value: String(field.view),\n          }))}\n          value={value}\n          onValueChange={(val) => {\n            onChange(val)\n          }}\n          wrapperClassName=\"min-w-40\"\n        />\n      )\n    }\n    case \"status\": {\n      if (value === undefined) {\n        onChange(\"collected\")\n      }\n      return (\n        <Select\n          options={[\n            {\n              label: t(\"words.starred\"),\n              value: \"collected\",\n            },\n          ]}\n          value={value}\n          onValueChange={(val) => {\n            onChange(val as string)\n          }}\n          wrapperClassName=\"min-w-40\"\n        />\n      )\n    }\n    default: {\n      return (\n        <PlainTextField\n          className=\"w-full flex-1 text-right\"\n          value={value}\n          onChangeText={(value) => {\n            onChange(value)\n          }}\n          hitSlop={10}\n          selectionColor={accentColor}\n          placeholder=\"Enter value\"\n        />\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/EditProfile.tsx",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport type { MeModel } from \"@follow/store/user/store\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport type { UserProfileEditable } from \"@follow/store/user/types\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport type { FC } from \"react\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport {\n  KeyboardAvoidingView,\n  Pressable,\n  TextInput,\n  TouchableWithoutFeedback,\n  View,\n} from \"react-native\"\nimport { KeyboardController } from \"react-native-keyboard-controller\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { UserAvatar } from \"@/src/components/ui/avatar/UserAvatar\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListNavigationLink,\n  GroupedInsetListSectionHeader,\n  GroupedOutlineDescription,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckCircleCuteReIcon } from \"@/src/icons/check_circle_cute_re\"\nimport { CloseCircleFillIcon } from \"@/src/icons/close_circle_fill\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\nimport { EditEmailScreen } from \"@/src/screens/(modal)/EditEmailScreen\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { setAvatar } from \"../utils\"\n\nexport const EditProfileScreen = () => {\n  const whoami = useWhoami()\n  const { t } = useTranslation(\"settings\")\n  const [dirtyFields, setDirtyFields] = useState<Partial<UserProfileEditable>>({})\n  const { mutateAsync: updateProfile, isPending } = useMutation({\n    mutationFn: async () => {\n      await userSyncService.updateProfile(dirtyFields)\n    },\n    onSuccess: () => {\n      toast.success(t(\"profile.updateSuccess\"))\n      setDirtyFields({})\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n  })\n  if (!whoami) {\n    return (\n      <View className=\"flex-1 items-center justify-center\">\n        <PlatformActivityIndicator />\n      </View>\n    )\n  }\n  return (\n    <KeyboardAvoidingView behavior=\"padding\" className=\"flex-1\">\n      <SafeNavigationScrollView\n        keyboardShouldPersistTaps=\"handled\"\n        Header={\n          <NavigationBlurEffectHeaderView\n            headerRight={\n              <HeaderSubmitTextButton\n                label={t(\"words.save\", {\n                  ns: \"common\",\n                })}\n                isValid={Object.keys(dirtyFields).length > 0}\n                isLoading={isPending}\n                onPress={() => {\n                  updateProfile()\n                }}\n              />\n            }\n            title={t(\"profile.edit_profile\")}\n          />\n        }\n        className=\"bg-system-grouped-background\"\n      >\n        <AvatarSection whoami={whoami} />\n        <ProfileForm whoami={whoami} dirtyFields={dirtyFields} setDirtyFields={setDirtyFields} />\n      </SafeNavigationScrollView>\n    </KeyboardAvoidingView>\n  )\n}\nconst AvatarSection: FC<{\n  whoami: MeModel\n}> = ({ whoami }) => {\n  const { t } = useTranslation(\"settings\")\n  return (\n    <View className=\"mt-6 items-center justify-center\">\n      <UserAvatar\n        image={whoami?.image}\n        name={whoami?.name}\n        size={80}\n        className={!whoami?.name || !whoami.image ? \"bg-system-background\" : \"\"}\n      />\n\n      <Pressable className=\"mt-2\" hitSlop={10} onPress={setAvatar}>\n        <Text className=\"text-lg text-accent\">{t(\"profile.set_avatar\")}</Text>\n      </Pressable>\n    </View>\n  )\n}\nconst ProfileForm: FC<{\n  whoami: MeModel\n  dirtyFields: Partial<UserProfileEditable>\n  setDirtyFields: (dirtyFields: Partial<UserProfileEditable>) => void\n}> = ({ whoami, dirtyFields, setDirtyFields }) => {\n  const { t } = useTranslation(\"settings\")\n  const navigation = useNavigation()\n  const socialLinkFields: (keyof NonNullable<MeModel[\"socialLinks\"]>)[] = [\n    \"twitter\",\n    \"github\",\n    \"instagram\",\n    \"facebook\",\n    \"youtube\",\n    // @ts-expect-error adding discord\n    \"discord\",\n  ]\n  const socialCopyMap = {\n    twitter: t(\"profile.social.twitter\", \"Twitter\"),\n    github: t(\"profile.social.github\", \"GitHub\"),\n    instagram: t(\"profile.social.instagram\", \"Instagram\"),\n    facebook: t(\"profile.social.facebook\", \"Facebook\"),\n    youtube: t(\"profile.social.youtube\", \"YouTube\"),\n    discord: t(\"profile.social.discord\", \"Discord\"),\n  }\n  return (\n    <View className=\"mt-4\">\n      <TouchableWithoutFeedback\n        onPress={() => {\n          KeyboardController.dismiss()\n        }}\n      >\n        <View className=\"w-full\">\n          <GroupedInsetListCard>\n            <GroupedInsetListCell\n              label={t(\"profile.name.label\")}\n              leftClassName=\"flex-none\"\n              rightClassName=\"flex-1\"\n            >\n              <View className=\"flex-1\">\n                <PlainTextField\n                  className=\"w-full flex-1 text-right text-secondary-label\"\n                  value={dirtyFields.name ?? whoami?.name ?? \"\"}\n                  hitSlop={10}\n                  selectionColor={accentColor}\n                  onChangeText={(text) => {\n                    setDirtyFields({\n                      ...dirtyFields,\n                      name: text,\n                    })\n                  }}\n                />\n              </View>\n            </GroupedInsetListCell>\n          </GroupedInsetListCard>\n          <GroupedOutlineDescription description={t(\"profile.name.description\")} />\n\n          {/* User name */}\n          <GroupedInsetListCard className=\"mt-4\">\n            <GroupedInsetListCell\n              label={t(\"profile.handle.label\")}\n              leftClassName=\"flex-none\"\n              rightClassName=\"flex-1\"\n            >\n              <View className=\"flex-1\">\n                <PlainTextField\n                  className=\"w-full flex-1 text-right text-secondary-label\"\n                  value={dirtyFields.handle ?? whoami?.handle ?? \"\"}\n                  hitSlop={10}\n                  selectionColor={accentColor}\n                  onChangeText={(text) => {\n                    setDirtyFields({\n                      ...dirtyFields,\n                      handle: text,\n                    })\n                  }}\n                />\n              </View>\n            </GroupedInsetListCell>\n          </GroupedInsetListCard>\n          {/* Email */}\n          <GroupedInsetListCard className=\"mt-4\">\n            <GroupedInsetListNavigationLink\n              label={t(\"profile.email.label\")}\n              onPress={() => {\n                navigation.presentControllerView(EditEmailScreen)\n              }}\n              leftClassName=\"flex-none\"\n              rightClassName=\"flex-1\"\n              postfix={\n                <View className=\"ml-auto flex-row gap-2\">\n                  <Text className=\"text-secondary-label\">{whoami.email}</Text>\n                  {whoami.emailVerified ? (\n                    <CheckCircleCuteReIcon height={18} width={18} color={\"#00C75F\"} />\n                  ) : (\n                    <CloseCircleFillIcon height={18} width={18} color={\"#FF3B30\"} />\n                  )}\n                </View>\n              }\n            />\n          </GroupedInsetListCard>\n          <GroupedOutlineDescription description={t(\"profile.handle.description\")} />\n\n          <GroupedInsetListSectionHeader label={t(\"profile.bio.label\", \"Bio\")} />\n          <GroupedInsetListCard>\n            <View className=\"flex-1\">\n              <TextInput\n                clearButtonMode=\"always\"\n                className=\"h-[100px] w-full flex-1 px-4 py-3 text-label\"\n                value={dirtyFields.bio ?? whoami?.bio ?? \"\"}\n                hitSlop={10}\n                multiline\n                selectionColor={accentColor}\n                onChangeText={(text) => {\n                  setDirtyFields({\n                    ...dirtyFields,\n                    bio: text,\n                  })\n                }}\n                textAlignVertical=\"top\"\n                placeholder={t(\"profile.bio.placeholder\", \"Tell us about yourself\")}\n              />\n            </View>\n          </GroupedInsetListCard>\n\n          {/* Website */}\n          <GroupedInsetListCard className=\"mt-4\">\n            <GroupedInsetListCell\n              label={t(\"profile.website.label\", \"Website\")}\n              leftClassName=\"flex-none\"\n              rightClassName=\"flex-1\"\n            >\n              <View className=\"flex-1\">\n                <PlainTextField\n                  className=\"w-full flex-1 text-right text-secondary-label\"\n                  value={dirtyFields.website ?? whoami?.website ?? \"\"}\n                  hitSlop={10}\n                  selectionColor={accentColor}\n                  onChangeText={(text) => {\n                    setDirtyFields({\n                      ...dirtyFields,\n                      website: text,\n                    })\n                  }}\n                  autoCapitalize=\"none\"\n                  autoCorrect={false}\n                  keyboardType=\"url\"\n                  placeholder=\"https://example.com\"\n                />\n              </View>\n            </GroupedInsetListCell>\n          </GroupedInsetListCard>\n\n          {/* Social Links */}\n          <GroupedInsetListSectionHeader\n            label={t(\"profile.social.title\", \"Social Media Handles\")}\n          />\n          <GroupedInsetListCard>\n            {socialLinkFields.map((social) => (\n              <GroupedInsetListCell\n                key={social}\n                label={socialCopyMap[social]}\n                leftClassName=\"flex-none\"\n                rightClassName=\"flex-1\"\n              >\n                <View className=\"flex-1\">\n                  <PlainTextField\n                    className=\"w-full flex-1 text-right text-secondary-label\"\n                    value={dirtyFields.socialLinks?.[social] ?? whoami?.socialLinks?.[social] ?? \"\"}\n                    hitSlop={10}\n                    selectionColor={accentColor}\n                    onChangeText={(text) => {\n                      setDirtyFields({\n                        ...dirtyFields,\n                        socialLinks: {\n                          ...whoami?.socialLinks,\n                          ...dirtyFields.socialLinks,\n                          [social]: text,\n                        },\n                      })\n                    }}\n                    autoCapitalize=\"none\"\n                    autoCorrect={false}\n                  />\n                </View>\n              </GroupedInsetListCell>\n            ))}\n          </GroupedInsetListCard>\n        </View>\n      </TouchableWithoutFeedback>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx",
    "content": "import { useActionRule } from \"@follow/store/action/hooks\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetListBaseCell,\n  GroupedInsetListCard,\n  GroupedInsetListSectionHeader,\n  GroupedPlainButtonCell,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\n\nexport const EditRewriteRulesScreen: NavigationControllerView<{\n  index: number\n}> = ({ index }) => {\n  const { t } = useTranslation(\"settings\")\n  const rule = useActionRule(index)\n  const rewriteRules =\n    rule?.result.rewriteRules?.map((rewriteRule, rewriteRuleIndex) => ({\n      rewriteRule,\n      rewriteRuleIndex,\n      rowKey: `rewrite-rule-${rewriteRuleIndex}`,\n    })) ?? []\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"actions.edit_rewrite_rule\")} />}\n    >\n      <GroupedInsetListSectionHeader\n        label={t(\"actions.action_card.rewrite_rules\")}\n        marginSize=\"small\"\n      />\n      {rewriteRules.map(({ rewriteRule, rewriteRuleIndex, rowKey }) => {\n        return (\n          <GroupedInsetListCard key={rowKey} className=\"mb-4\">\n            <GroupedInsetListBaseCell className=\"flex-row\">\n              <Text className=\"text-label\">{t(\"actions.action_card.from\")}</Text>\n              <PlainTextField\n                className=\"w-full flex-1 text-right\"\n                value={rewriteRule.from}\n                onChangeText={(value) => {\n                  actionActions.updateRewriteRule({\n                    index,\n                    rewriteRuleIndex,\n                    key: \"from\",\n                    value,\n                  })\n                }}\n              />\n            </GroupedInsetListBaseCell>\n            <GroupedInsetListBaseCell className=\"flex-row\">\n              <Text className=\"text-label\">{t(\"actions.action_card.to\")}</Text>\n              <PlainTextField\n                className=\"w-full flex-1 text-right\"\n                value={rewriteRule.to}\n                onChangeText={(value) => {\n                  actionActions.updateRewriteRule({\n                    index,\n                    rewriteRuleIndex,\n                    key: \"to\",\n                    value,\n                  })\n                }}\n              />\n            </GroupedInsetListBaseCell>\n          </GroupedInsetListCard>\n        )\n      })}\n      <GroupedInsetListCard>\n        <GroupedPlainButtonCell\n          label={t(\"actions.action_card.add\")}\n          onPress={() => {\n            actionActions.addRewriteRule(index)\n          }}\n        />\n      </GroupedInsetListCard>\n      {__DEV__ && <Text>{JSON.stringify(rule?.result.rewriteRules, null, 2)}</Text>}\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/EditRule.tsx",
    "content": "import type { ActionAction } from \"@follow/store/action/constant\"\nimport {\n  availableActionMap,\n  filterFieldOptions,\n  filterOperatorOptions,\n} from \"@follow/store/action/constant\"\nimport { useActionRule } from \"@follow/store/action/hooks\"\nimport type { ActionModel } from \"@follow/store/action/store\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport type { ActionFilterItem, ActionId } from \"@follow-app/client-sdk\"\nimport { merge } from \"es-toolkit/compat\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport * as DropdownMenu from \"zeego/dropdown-menu\"\n\nimport { SwipeableItem } from \"@/src/components/common/SwipeableItem\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetListActionCell,\n  GroupedInsetListActionCellRadio,\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListSectionHeader,\n  GroupedPlainButtonCell,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { views } from \"@/src/constants/views\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { accentColor, useColors } from \"@/src/theme/colors\"\n\nimport { EditConditionScreen } from \"./EditCondition\"\nimport { EditRewriteRulesScreen } from \"./EditRewriteRules\"\nimport { EditWebhooksScreen } from \"./EditWebhooks\"\n\nconst createConditionKey = (item: ActionFilterItem) =>\n  `${String(item.field)}-${String(item.operator)}-${String(item.value)}`\n\nexport const EditRuleScreen: NavigationControllerView<{\n  index: number\n}> = ({ index }) => {\n  const { t } = useTranslation(\"settings\")\n  const rule = useActionRule(index)\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      contentContainerClassName=\"mt-6\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={`${t(\"actions.edit_rule\")}${rule?.name ? ` - ${rule.name}` : \"\"}`}\n        />\n      }\n    >\n      <RuleImpl index={index} />\n    </SafeNavigationScrollView>\n  )\n}\nconst RuleImpl: React.FC<{\n  index: number\n}> = ({ index }) => {\n  const rule = useActionRule(index)\n  if (!rule) {\n    return <Text>No rule available</Text>\n  }\n  return (\n    <View className=\"gap-6\">\n      <NameSection rule={rule} />\n      <FilterSection rule={rule} index={index} />\n      <ConditionSection filter={rule.condition as any} index={index} />\n      <ActionSection rule={rule} index={index} />\n      {__DEV__ && (\n        <View className=\"mx-6\">\n          <Text className=\"text-label\">{JSON.stringify(rule, null, 2)}</Text>\n        </View>\n      )}\n    </View>\n  )\n}\nconst NameSection: React.FC<{\n  rule: ActionModel\n}> = ({ rule }) => {\n  const { t } = useTranslation(\"settings\")\n  return (\n    <GroupedInsetListCard>\n      <GroupedInsetListCell\n        label={t(\"actions.action_card.name\")}\n        leftClassName=\"flex-none\"\n        rightClassName=\"flex-1\"\n      >\n        <View className=\"flex-1\">\n          <PlainTextField\n            className=\"w-full flex-1 text-right text-secondary-label\"\n            value={rule.name}\n            hitSlop={10}\n            selectionColor={accentColor}\n            onChangeText={(text) => {\n              actionActions.patchRule((rule as any).index ?? 0, {\n                name: text,\n              })\n            }}\n          />\n        </View>\n      </GroupedInsetListCell>\n    </GroupedInsetListCard>\n  )\n}\nconst FilterSection: React.FC<{\n  rule: ActionModel\n  index: number\n}> = ({ rule, index }) => {\n  const { t } = useTranslation(\"settings\")\n  const hasCustomFilters = rule.condition.length > 0\n  return (\n    <View>\n      <GroupedInsetListSectionHeader\n        label={t(\"actions.action_card.when_feeds_match\")}\n        marginSize=\"small\"\n      />\n      <GroupedInsetListCard>\n        <GroupedInsetListActionCellRadio\n          label={t(\"actions.action_card.all\")}\n          selected={!hasCustomFilters}\n          onPress={() => {\n            actionActions.toggleRuleFilter(index)\n          }}\n        />\n        <GroupedInsetListActionCellRadio\n          label={t(\"actions.action_card.custom_filters\")}\n          selected={hasCustomFilters}\n          onPress={() => {\n            actionActions.toggleRuleFilter(index)\n          }}\n        />\n      </GroupedInsetListCard>\n    </View>\n  )\n}\nconst ConditionSection: React.FC<{\n  filter: ActionFilterItem[]\n  index: number\n}> = ({ filter, index }) => {\n  const { t } = useTranslation(\"settings\")\n  const { t: tCommon } = useTranslation(\"common\")\n  const navigation = useNavigation()\n  const colors = useColors()\n  if (filter.length === 0) return null\n  return (\n    <View>\n      <GroupedInsetListSectionHeader label={t(\"actions.conditions\")} marginSize=\"small\" />\n\n      {(filter as (ActionFilterItem | ActionFilterItem[])[]).map((group, groupIndex) => {\n        const groupItems = Array.isArray(group) ? group : [group]\n        const groupKey = groupItems.map(createConditionKey).join(\"|\") || \"group-empty\"\n        const keyCounter = new Map<string, number>()\n        return (\n          <GroupedInsetListCard key={groupKey} className=\"mb-6\">\n            {groupItems.map((item, itemIndex) => {\n              const itemBaseKey = createConditionKey(item)\n              const itemDuplicateCount = (keyCounter.get(itemBaseKey) ?? 0) + 1\n              keyCounter.set(itemBaseKey, itemDuplicateCount)\n              const itemKey = `${itemBaseKey}-${itemDuplicateCount}`\n              const currentField = filterFieldOptions.find((field) => field.value === item.field)\n              const currentOperator = filterOperatorOptions.find(\n                (field) => field.value === item.operator,\n              )\n              const currentView = views.find((view) => view.view === Number(item.value))\n              const currentValue =\n                currentField?.type === \"view\"\n                  ? currentView?.name\n                    ? tCommon(currentView.name)\n                    : String(item.value ?? \"\")\n                  : item.value\n              return (\n                <SwipeableItem\n                  key={itemKey}\n                  swipeRightToCallAction\n                  rightActions={[\n                    {\n                      label: tCommon(\"words.delete\"),\n                      onPress: () => {\n                        actionActions.deleteConditionItem({\n                          ruleIndex: index,\n                          groupIndex,\n                          conditionIndex: itemIndex,\n                        })\n                      },\n                      backgroundColor: colors.red,\n                    },\n                    {\n                      label: tCommon(\"words.edit\"),\n                      onPress: () => {\n                        navigation.pushControllerView(EditConditionScreen, {\n                          ruleIndex: index,\n                          groupIndex,\n                          conditionIndex: itemIndex,\n                        })\n                      },\n                      backgroundColor: colors.blue,\n                    },\n                  ]}\n                >\n                  <GroupedInsetListActionCell\n                    label={\n                      [\n                        currentField?.label ? t(currentField.label) : \"\",\n                        currentOperator?.label ? t(currentOperator.label) : \"\",\n                        currentValue,\n                      ]\n                        .filter(Boolean)\n                        .join(\" \") || \"Unknown\"\n                    }\n                    onPress={() => {\n                      navigation.pushControllerView(EditConditionScreen, {\n                        ruleIndex: index,\n                        groupIndex,\n                        conditionIndex: itemIndex,\n                      })\n                    }}\n                  />\n                </SwipeableItem>\n              )\n            })}\n            <GroupedPlainButtonCell\n              label={t(\"actions.action_card.and\")}\n              onPress={() => {\n                actionActions.addConditionItem({\n                  ruleIndex: index,\n                  groupIndex,\n                })\n                setTimeout(() => {\n                  navigation.pushControllerView(EditConditionScreen, {\n                    ruleIndex: index,\n                    groupIndex,\n                    conditionIndex: groupItems.length,\n                  })\n                }, 0)\n              }}\n            />\n          </GroupedInsetListCard>\n        )\n      })}\n      <GroupedInsetListCard>\n        <GroupedPlainButtonCell\n          label={t(\"actions.action_card.or\")}\n          onPress={() => {\n            actionActions.addConditionGroup({\n              ruleIndex: index,\n            })\n          }}\n        />\n      </GroupedInsetListCard>\n    </View>\n  )\n}\nconst extendedAvailableActionList = Object.values(\n  merge(availableActionMap, {\n    rewriteRules: {\n      onNavigate: (router: Navigation, index: number) => {\n        router.pushControllerView(EditRewriteRulesScreen, {\n          index,\n        })\n      },\n    },\n    webhooks: {\n      onNavigate: (router: Navigation, index: number) => {\n        router.pushControllerView(EditWebhooksScreen, {\n          index,\n        })\n      },\n    },\n  }),\n) as (ActionAction & {\n  onNavigate?: (router: Navigation, index: number) => void\n})[]\nconst ActionSection: React.FC<{\n  rule: ActionModel\n  index: number\n}> = ({ rule, index }) => {\n  const { t } = useTranslation(\"settings\")\n  const enabledActions = extendedAvailableActionList.filter(\n    (action) =>\n      (rule.result as Record<string, unknown>)[action.value as unknown as string] !== undefined,\n  )\n  const notEnabledActions = extendedAvailableActionList.filter(\n    (action) =>\n      (rule.result as Record<string, unknown>)[action.value as unknown as string] === undefined,\n  )\n  const navigation = useNavigation()\n  const colors = useColors()\n  return (\n    <View>\n      <GroupedInsetListSectionHeader label={t(\"actions.action_card.then_do\")} marginSize=\"small\" />\n      <GroupedInsetListCard>\n        {enabledActions.map((action) => (\n          <SwipeableItem\n            key={String(action.value)}\n            rightActions={[\n              {\n                label: t(\"words.delete\", {\n                  ns: \"common\",\n                }),\n                onPress: () => {\n                  actionActions.deleteRuleAction(index, action.value as ActionId)\n                },\n                backgroundColor: colors.red,\n              },\n            ]}\n          >\n            <View className=\"flex-row items-center gap-2\">\n              {action.onNavigate ? (\n                <GroupedInsetListActionCell\n                  label={t(action.label)}\n                  icon={action.icon}\n                  onPress={() => action.onNavigate?.(navigation, index)}\n                />\n              ) : (\n                <GroupedInsetListCell\n                  label={t(action.label)}\n                  icon={action.icon}\n                  leftClassName=\"flex-none\"\n                  rightClassName=\"flex-1 flex-row justify-end\"\n                />\n              )}\n            </View>\n          </SwipeableItem>\n        ))}\n        {notEnabledActions.length > 0 && (\n          <DropdownMenu.Root>\n            <DropdownMenu.Trigger asChild>\n              <GroupedPlainButtonCell label={t(\"actions.action_card.add\")} />\n            </DropdownMenu.Trigger>\n            <DropdownMenu.Content>\n              {notEnabledActions.map((action) => (\n                <DropdownMenu.Item\n                  key={String(action.value)}\n                  onSelect={() => {\n                    if (action.onEnable) {\n                      action.onEnable(index)\n                    } else {\n                      actionActions.patchRule(index, {\n                        result: {\n                          [action.value]: true,\n                        },\n                      })\n                    }\n                  }}\n                >\n                  <DropdownMenu.ItemIcon\n                    ios={{\n                      name: action.icon,\n                    }}\n                  />\n                  <DropdownMenu.ItemTitle>{t(action.label)}</DropdownMenu.ItemTitle>\n                </DropdownMenu.Item>\n              ))}\n            </DropdownMenu.Content>\n          </DropdownMenu.Root>\n        )}\n      </GroupedInsetListCard>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/EditWebhooks.tsx",
    "content": "import { useActionRule } from \"@follow/store/action/hooks\"\nimport { actionActions } from \"@follow/store/action/store\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetButtonCell,\n  GroupedInsetListBaseCell,\n  GroupedInsetListCard,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\n\nexport const EditWebhooksScreen: NavigationControllerView<{\n  index: number\n}> = ({ index }) => {\n  const { t } = useTranslation(\"settings\")\n  const rule = useActionRule(index)\n  const webhooks =\n    rule?.result.webhooks?.map((webhook, webhookIndex) => ({\n      webhook,\n      webhookIndex,\n      rowKey: `webhook-${webhookIndex}`,\n    })) ?? []\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"actions.edit_webhook\")} />}\n    >\n      <GroupedInsetListSectionHeader label={t(\"actions.action_card.webhooks\")} marginSize=\"small\" />\n      <GroupedInsetListCard>\n        {webhooks.map(({ webhook, webhookIndex, rowKey }) => {\n          return (\n            <GroupedInsetListBaseCell className=\"flex-row\" key={rowKey}>\n              <PlainTextField\n                placeholder=\"https://\"\n                inputMode=\"url\"\n                value={webhook}\n                onChangeText={(value) => {\n                  actionActions.updateWebhook({\n                    index,\n                    webhookIndex,\n                    value,\n                  })\n                }}\n              />\n            </GroupedInsetListBaseCell>\n          )\n        })}\n        <GroupedInsetButtonCell\n          label={t(\"actions.action_card.add\")}\n          onPress={() => {\n            actionActions.addWebhook(index)\n          }}\n        />\n      </GroupedInsetListCard>\n      {__DEV__ && <Text>{JSON.stringify(rule?.result.webhooks, null, 2)}</Text>}\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Feeds.tsx",
    "content": "import { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const FeedsScreen = () => {\n  return (\n    <View className=\"flex-1 items-center justify-center\">\n      <Text>Feeds Settings</Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/General.tsx",
    "content": "import { ACTION_LANGUAGE_KEYS } from \"@follow/shared\"\nimport i18next from \"i18next\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { MobileSupportedLanguages } from \"@/src/@types/constants\"\nimport { currentSupportedLanguages } from \"@/src/@types/constants\"\nimport { defaultResources } from \"@/src/@types/default-resource\"\nimport { setGeneralSetting, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { Select } from \"@/src/components/ui/form/Select\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { Switch } from \"@/src/components/ui/switch/Switch\"\nimport { updateDayjsLocale } from \"@/src/lib/i18n\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\n\nconst settingSelectWrapperClassName = \"w-[200px]\"\n\nexport function LanguageSelect({ settingKey }: { settingKey: \"language\" | \"actionLanguage\" }) {\n  const { t } = useTranslation(\"settings\")\n  const languageMapWithTranslation = useMemo(() => {\n    const languageKeys =\n      settingKey === \"language\"\n        ? (currentSupportedLanguages as MobileSupportedLanguages[])\n        : ACTION_LANGUAGE_KEYS.filter(\n            (key): key is MobileSupportedLanguages => key in defaultResources,\n          ).sort(\n            (a, b) => currentSupportedLanguages.indexOf(a) - currentSupportedLanguages.indexOf(b),\n          )\n\n    return [\n      settingKey === \"actionLanguage\" && {\n        label: t(\"general.action_language.default\"),\n        value: \"default\",\n      },\n      ...languageKeys.map((key) => ({\n        label: defaultResources[key].lang.name,\n        value: key,\n      })),\n    ].filter((i) => typeof i !== \"boolean\")\n  }, [settingKey, t])\n  const language = useGeneralSettingKey(settingKey) as MobileSupportedLanguages | \"default\"\n\n  return (\n    <Select\n      value={language}\n      onValueChange={(value) => {\n        setGeneralSetting(settingKey, value)\n        if (settingKey === \"language\") {\n          i18next.changeLanguage(value)\n          updateDayjsLocale(value)\n        }\n      }}\n      displayValue={\n        language === \"default\"\n          ? t(`general.action_language.default`)\n          : defaultResources[language]?.lang.name\n      }\n      options={languageMapWithTranslation}\n    />\n  )\n}\n\nfunction LanguageSetting({ settingKey }: { settingKey: \"language\" | \"actionLanguage\" }) {\n  const { t } = useTranslation(\"settings\")\n\n  return (\n    <GroupedInsetListCell\n      label={\n        settingKey === \"language\" ? t(\"general.language.title\") : t(\"general.action_language.label\")\n      }\n      description={\n        settingKey === \"language\"\n          ? t(\"general.language.description\")\n          : t(\"general.action_language.description\")\n      }\n      rightClassName={settingSelectWrapperClassName}\n    >\n      <LanguageSelect settingKey={settingKey} />\n    </GroupedInsetListCell>\n  )\n}\n\nfunction TranslationModeSetting() {\n  const { t } = useTranslation(\"settings\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n\n  return (\n    <GroupedInsetListCell\n      label={t(\"general.translation_mode.label\")}\n      description={t(\"general.translation_mode.description\")}\n      rightClassName={settingSelectWrapperClassName}\n    >\n      <Select\n        value={translationMode}\n        onValueChange={(value) => {\n          setGeneralSetting(\"translationMode\", value as \"bilingual\" | \"translation-only\")\n        }}\n        options={[\n          { label: t(\"general.translation_mode.bilingual\"), value: \"bilingual\" },\n          { label: t(\"general.translation_mode.translation-only\"), value: \"translation-only\" },\n        ]}\n      />\n    </GroupedInsetListCell>\n  )\n}\n\nexport const GeneralScreen: NavigationControllerView = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const translation = useGeneralSettingKey(\"translation\")\n  const summary = useGeneralSettingKey(\"summary\")\n  const autoGroup = useGeneralSettingKey(\"autoGroup\")\n  const hideAllReadSubscriptions = useGeneralSettingKey(\"hideAllReadSubscriptions\")\n  const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(\n    \"hidePrivateSubscriptionsInTimeline\",\n  )\n  const showUnreadOnLaunch = useGeneralSettingKey(\"unreadOnly\")\n  // const groupByDate = useGeneralSettingKey(\"groupByDate\")\n  const expandLongSocialMedia = useGeneralSettingKey(\"autoExpandLongSocialMedia\")\n  const markAsReadWhenScrolling = useGeneralSettingKey(\"scrollMarkUnread\")\n  const markAsReadWhenInView = useGeneralSettingKey(\"renderMarkUnread\")\n  const openLinksInExternalApp = useGeneralSettingKey(\"openLinksInExternalApp\")\n\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"titles.general\")} />}\n    >\n      {/* Language */}\n\n      <GroupedInsetListSectionHeader label={t(\"general.language.title\")} marginSize=\"small\" />\n      <GroupedInsetListCard>\n        <LanguageSetting settingKey=\"language\" />\n      </GroupedInsetListCard>\n\n      {/* Content Behavior */}\n      <GroupedInsetListSectionHeader label={t(\"general.action.title\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"general.action.summary.label\")}\n          description={t(\"general.action.summary.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            testID=\"general-ai-summary-switch\"\n            value={summary}\n            onValueChange={(value) => {\n              setGeneralSetting(\"summary\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n        <GroupedInsetListCell\n          label={t(\"general.action.translation.label\")}\n          description={t(\"general.action.translation.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={translation}\n            onValueChange={(value) => {\n              setGeneralSetting(\"translation\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n        <TranslationModeSetting />\n        <LanguageSetting settingKey=\"actionLanguage\" />\n      </GroupedInsetListCard>\n\n      {/* Subscriptions */}\n\n      <GroupedInsetListSectionHeader label={t(\"general.subscriptions\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"general.auto_group.label\")}\n          description={t(\"general.auto_group.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={autoGroup}\n            onValueChange={(value) => {\n              setGeneralSetting(\"autoGroup\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n\n        <GroupedInsetListCell\n          label={t(\"general.hide_all_read_subscriptions.label\")}\n          description={t(\"general.hide_all_read_subscriptions.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={hideAllReadSubscriptions}\n            onValueChange={(value) => {\n              setGeneralSetting(\"hideAllReadSubscriptions\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n\n        <GroupedInsetListCell\n          label={t(\"general.hide_private_subscriptions_in_timeline.label\")}\n          description={t(\"general.hide_private_subscriptions_in_timeline.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={hidePrivateSubscriptionsInTimeline}\n            onValueChange={(value) => {\n              setGeneralSetting(\"hidePrivateSubscriptionsInTimeline\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n\n      {/* Timeline */}\n\n      <GroupedInsetListSectionHeader label={t(\"general.timeline\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"general.show_unread_on_launch.label\")}\n          description={t(\"general.show_unread_on_launch.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={showUnreadOnLaunch}\n            onValueChange={(value) => {\n              setGeneralSetting(\"unreadOnly\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n\n        {/* <GroupedInsetListCell label=\"Group by date\" description=\"Group entries by date.\">\n              <Switch\n                size=\"sm\"\n                value={groupByDate}\n                onValueChange={(value) => {\n                  setGeneralSetting(\"groupByDate\", value)\n                }}\n              />\n            </GroupedInsetListCell> */}\n\n        <GroupedInsetListCell\n          label={t(\"general.auto_expand_long_social_media.label\")}\n          description={t(\"general.auto_expand_long_social_media.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={expandLongSocialMedia}\n            onValueChange={(value) => {\n              setGeneralSetting(\"autoExpandLongSocialMedia\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n\n      {/* Unread */}\n\n      <GroupedInsetListSectionHeader label={t(\"general.mark_as_read.title\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell\n          label={t(\"general.mark_as_read.scroll.label\")}\n          description={t(\"general.mark_as_read.scroll.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={markAsReadWhenScrolling}\n            onValueChange={(value) => {\n              setGeneralSetting(\"scrollMarkUnread\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n\n        <GroupedInsetListCell\n          label={t(\"general.mark_as_read.render.label\")}\n          description={t(\"general.mark_as_read.render.description\")}\n        >\n          <Switch\n            size=\"sm\"\n            value={markAsReadWhenInView}\n            onValueChange={(value) => {\n              setGeneralSetting(\"renderMarkUnread\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n\n      {/* Content Behavior */}\n\n      <GroupedInsetListSectionHeader label={t(\"general.content\")} />\n      <GroupedInsetListCard>\n        <GroupedInsetListCell label={t(\"general.open_links_in_external_app.label\")}>\n          <Switch\n            size=\"sm\"\n            value={openLinksInExternalApp}\n            onValueChange={(value) => {\n              setGeneralSetting(\"openLinksInExternalApp\", value)\n            }}\n          />\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Lists.tsx",
    "content": "import { useOwnedLists, usePrefetchLists } from \"@follow/store/list/hooks\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { Image as ExpoImage } from \"expo-image\"\nimport { createContext, createElement, use, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { ListRenderItem } from \"react-native\"\nimport { StyleSheet, View } from \"react-native\"\nimport Animated, { LinearTransition } from \"react-native-reanimated\"\nimport { useColor, useColors } from \"react-native-uikit-colors\"\n\nimport { UINavigationHeaderActionButton } from \"@/src/components/layouts/header/NavigationHeader\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport {\n  GroupedInformationCell,\n  GroupedInsetListCard,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { FallbackIcon } from \"@/src/components/ui/icon/fallback-icon\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { views } from \"@/src/constants/views\"\nimport { AddCuteReIcon } from \"@/src/icons/add_cute_re\"\nimport { RadaCuteFiIcon } from \"@/src/icons/rada_cute_fi\"\nimport { UserAdd2CuteFiIcon } from \"@/src/icons/user_add_2_cute_fi\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { ListScreen } from \"@/src/screens/(modal)/ListScreen\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nimport { SwipeableGroupProvider, SwipeableItem } from \"../../../components/common/SwipeableItem\"\nimport { ManageListScreen } from \"./ManageList\"\n\nconst ListContext = createContext({} as Record<string, ListModel>)\nexport const ListsScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  const { isLoading, data } = usePrefetchLists()\n  const lists = useOwnedLists()\n  return (\n    <SafeNavigationScrollView\n      nestedScrollEnabled\n      className=\"bg-system-grouped-background\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={t(\"titles.lists\")}\n          headerRight={() => <AddListButton />}\n        />\n      }\n    >\n      <View className=\"mt-6\">\n        <GroupedInsetListCard>\n          <GroupedInformationCell\n            title={t(\"titles.lists\")}\n            description={t(\"lists.info\")}\n            icon={<RadaCuteFiIcon height={40} width={40} color=\"#fff\" />}\n            iconBackgroundColor=\"#7DD3FC\"\n          />\n        </GroupedInsetListCard>\n      </View>\n      <ListContext\n        value={useMemo(\n          () =>\n            data?.reduce(\n              (acc, list) => {\n                acc[list.id] = list\n                return acc\n              },\n              {} as Record<string, ListModel>,\n            ) ?? {},\n          [data],\n        )}\n      >\n        <View className=\"mt-6\">\n          <GroupedInsetListCard showSeparator={false}>\n            {lists.length > 0 && (\n              <SwipeableGroupProvider>\n                <Animated.FlatList\n                  keyExtractor={keyExtractor}\n                  itemLayoutAnimation={LinearTransition}\n                  scrollEnabled={false}\n                  data={lists}\n                  renderItem={ListItemCell}\n                  ItemSeparatorComponent={ItemSeparatorComponent}\n                />\n              </SwipeableGroupProvider>\n            )}\n            {isLoading && lists.length === 0 && (\n              <View className=\"mt-1\">\n                <PlatformActivityIndicator />\n              </View>\n            )}\n          </GroupedInsetListCard>\n        </View>\n      </ListContext>\n    </SafeNavigationScrollView>\n  )\n}\nconst AddListButton = () => {\n  const labelColor = useColor(\"label\")\n  const navigation = useNavigation()\n  return (\n    <UINavigationHeaderActionButton\n      onPress={() => {\n        navigation.presentControllerView(ListScreen)\n      }}\n    >\n      <AddCuteReIcon height={20} width={20} color={labelColor} />\n    </UINavigationHeaderActionButton>\n  )\n}\nconst ItemSeparatorComponent = () => {\n  return (\n    <View\n      className=\"ml-24 flex-1 bg-opaque-separator/50\"\n      collapsable={false}\n      style={styles.itemSeparator}\n    />\n  )\n}\nconst keyExtractor = (item: ListModel) => item.id\nconst ListItemCell: ListRenderItem<ListModel> = (props) => {\n  return <ListItemCellImpl {...props} />\n}\nconst ListItemCellImpl: ListRenderItem<ListModel> = ({ item: list }) => {\n  const { t } = useTranslation(\"common\")\n  const { title, description } = list\n  const listData = use(ListContext)[list.id]\n  const navigation = useNavigation()\n  const colors = useColors()\n  const viewConfig = useMemo(() => views.find((view) => view.view === list.view), [list.view])\n  const subscriptionCount = listData?.subscriptionCount ?? list.subscriptionCount ?? 0\n  return (\n    <SwipeableItem\n      swipeRightToCallAction\n      rightActions={[\n        {\n          label: t(\"words.manage\"),\n          onPress: () => {\n            navigation.pushControllerView(ManageListScreen, {\n              id: list.id,\n            })\n          },\n          backgroundColor: accentColor,\n        },\n        {\n          label: t(\"words.edit\"),\n          onPress: () => {\n            navigation.presentControllerView(ListScreen, {\n              listId: list.id,\n            })\n          },\n          backgroundColor: colors.blue,\n        },\n      ]}\n    >\n      <ItemPressable\n        className=\"flex-row p-4\"\n        onPress={() =>\n          navigation.pushControllerView(ManageListScreen, {\n            id: list.id,\n          })\n        }\n      >\n        <View className=\"size-16 overflow-hidden rounded-lg\">\n          {list.image ? (\n            <ExpoImage\n              source={{\n                uri: list.image,\n              }}\n              contentFit=\"cover\"\n              className=\"size-full\"\n            />\n          ) : (\n            <FallbackIcon title={list.title || \"\"} size=\"100%\" textStyle={styles.title} />\n          )}\n        </View>\n        <View className=\"ml-4 flex-1\">\n          <Text\n            className=\"text-lg font-semibold leading-tight text-label\"\n            numberOfLines={1}\n            ellipsizeMode=\"middle\"\n          >\n            {title}\n          </Text>\n          {!!description && (\n            <Text className=\"text-base text-secondary-label\" numberOfLines={4}>\n              {description}\n            </Text>\n          )}\n          <View className=\"flex-row items-center gap-1\">\n            {!!viewConfig?.icon &&\n              createElement(viewConfig.icon, {\n                color: viewConfig.activeColor,\n                height: 16,\n                width: 16,\n              })}\n            {!!viewConfig?.name && (\n              <Text className=\"text-base text-secondary-label\">{t(viewConfig.name)}</Text>\n            )}\n            <View className=\"ml-1 flex-row items-center gap-1\">\n              <UserAdd2CuteFiIcon height={16} width={16} color={accentColor} />\n              <Text className=\"text-sm text-secondary-label\">{subscriptionCount}</Text>\n            </View>\n          </View>\n        </View>\n      </ItemPressable>\n    </SwipeableItem>\n  )\n}\nconst styles = StyleSheet.create({\n  title: {\n    fontSize: 20,\n    fontWeight: \"semibold\",\n  },\n  itemSeparator: {\n    height: StyleSheet.hairlineWidth,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/ManageList.tsx",
    "content": "import { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useListById, usePrefetchLists } from \"@follow/store/list/hooks\"\nimport { listSyncServices } from \"@follow/store/list/store\"\nimport {\n  useFeedSubscriptionIdsByView,\n  usePrefetchSubscription,\n  useSortedFeedSubscriptionByAlphabet,\n} from \"@follow/store/subscription/hooks\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport type { MutableRefObject } from \"react\"\nimport { createContext, use, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { PixelRatio, StyleSheet, View } from \"react-native\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport {\n  GroupedInsetListBaseCell,\n  GroupedInsetListCard,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckLineIcon } from \"@/src/icons/check_line\"\nimport { toastFetchError } from \"@/src/lib/error-parser\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nconst ManageListContext = createContext<{\n  nextSelectedFeedIdRef: MutableRefObject<Set<string>>\n}>(null!)\nexport const ManageListScreen: NavigationControllerView<{\n  id: string\n}> = ({ id }) => {\n  usePrefetchLists()\n  const list = useListById(id)\n  const { t } = useTranslation(\"settings\")\n  const nextSelectedFeedIdRef = useRef(new Set<string>())\n  const ctxValue = useMemo(\n    () => ({\n      nextSelectedFeedIdRef,\n    }),\n    [nextSelectedFeedIdRef],\n  )\n  const initOnceRef = useRef(false)\n  useEffect(() => {\n    if (initOnceRef.current) return\n    initOnceRef.current = true\n    nextSelectedFeedIdRef.current = new Set(list?.feedIds ?? [])\n  }, [list?.feedIds])\n  const addFeedsToFeedListMutation = useMutation({\n    mutationFn: () =>\n      listSyncServices.addFeedsToFeedList({\n        listId: id,\n        feedIds: Array.from(nextSelectedFeedIdRef.current),\n      }),\n  })\n  const navigation = useNavigation()\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={`${t(\"lists.manage_list\")} - ${list?.title}`}\n          headerRight={() => (\n            <HeaderSubmitTextButton\n              label={t(\"words.save\", {\n                ns: \"common\",\n              })}\n              isLoading={addFeedsToFeedListMutation.isPending}\n              isValid\n              onPress={() => {\n                addFeedsToFeedListMutation\n                  .mutateAsync()\n                  .then(() => {\n                    navigation.back()\n                  })\n                  .catch((error) => {\n                    toastFetchError(error as Error)\n                    console.error(error)\n                  })\n              }}\n            />\n          )}\n        />\n      }\n    >\n      {!!list && (\n        <ManageListContext value={ctxValue}>\n          <ListImpl id={list.id} />\n        </ManageListContext>\n      )}\n    </SafeNavigationScrollView>\n  )\n}\nconst ListImpl: React.FC<{\n  id: string\n}> = ({ id }) => {\n  const { t } = useTranslation(\"settings\")\n  const list = useListById(id)!\n  usePrefetchSubscription(list.view)\n  const subscriptionIds = useFeedSubscriptionIdsByView(list.view)\n  const sortedSubscriptionIds = useSortedFeedSubscriptionByAlphabet(subscriptionIds)\n  return (\n    <>\n      <GroupedInsetListSectionHeader label={t(\"lists.select_feeds\")} />\n      <GroupedInsetListCard SeparatorComponent={SeparatorComponent}>\n        {sortedSubscriptionIds.map((id) => (\n          <FeedCell key={id} feedId={id} isSelected={list.feedIds.includes(id)} />\n        ))}\n      </GroupedInsetListCard>\n    </>\n  )\n}\nconst SeparatorComponent = () => {\n  return (\n    <View\n      className=\"ml-16 bg-opaque-separator/70\"\n      style={{\n        height: StyleSheet.hairlineWidth,\n      }}\n      collapsable={false}\n    />\n  )\n}\nconst FeedCell = (props: { feedId: string; isSelected: boolean }) => {\n  const feed = useFeedById(props.feedId)\n  const { nextSelectedFeedIdRef } = use(ManageListContext)\n  const [currentSelected, setCurrentSelected] = useState(props.isSelected)\n  const iconMariginRight = 36 / PixelRatio.get()\n  if (!feed) return null\n  return (\n    <ItemPressable\n      onPress={() => {\n        const has = nextSelectedFeedIdRef.current.has(feed.id)\n        if (has) {\n          nextSelectedFeedIdRef.current.delete(feed.id)\n        } else {\n          nextSelectedFeedIdRef.current.add(feed.id)\n        }\n        setCurrentSelected(!has)\n      }}\n    >\n      <GroupedInsetListBaseCell>\n        <View className=\"flex-1 flex-row items-center gap-4\">\n          <View\n            className=\"size-4 items-center justify-center\"\n            style={{\n              marginRight: iconMariginRight,\n            }}\n          >\n            <View className=\"overflow-hidden rounded-lg\">\n              <FeedIcon feed={feed} size={24} />\n            </View>\n          </View>\n          <Text className=\"flex-1 text-label\" ellipsizeMode=\"middle\" numberOfLines={1}>\n            {feed?.title || \"Untitled Feed\"}\n          </Text>\n        </View>\n\n        <View className=\"ml-2 flex size-4 shrink-0 items-center justify-center\">\n          {currentSelected && <CheckLineIcon color={accentColor} height={18} width={18} />}\n        </View>\n      </GroupedInsetListBaseCell>\n    </ItemPressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Notifications.tsx",
    "content": "import { View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nexport const NotificationsScreen = () => {\n  return (\n    <View className=\"flex-1 items-center justify-center\">\n      <Text>Notifications Settings</Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Plan.tsx",
    "content": "import { UserRole, UserRoleName } from \"@follow/constants\"\nimport { useRoleEndAt, useUserRole, useWhoami } from \"@follow/store/user/hooks\"\nimport { cn } from \"@follow/utils\"\nimport type { StatusConfigs } from \"@follow-app/client-sdk\"\nimport { useMutation, useQuery } from \"@tanstack/react-query\"\nimport dayjs from \"dayjs\"\nimport type { SubscriptionProduct } from \"expo-iap\"\nimport { openURL } from \"expo-linking\"\nimport type { TFunction } from \"i18next\"\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { LayoutChangeEvent } from \"react-native\"\nimport {\n  ActivityIndicator,\n  Animated,\n  Easing,\n  Platform,\n  Pressable,\n  StyleSheet,\n  View,\n} from \"react-native\"\n\nimport { useIsPaymentEnabled, useServerConfigs } from \"@/src/atoms/server-configs\"\nimport { DefaultHeaderBackButton } from \"@/src/components/layouts/header/NavigationHeader\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CheckLineIcon } from \"@/src/icons/check_line\"\nimport { followClient } from \"@/src/lib/api-client\"\nimport { authClient } from \"@/src/lib/auth\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { proxyEnv } from \"@/src/lib/proxy-env\"\nimport { toast } from \"@/src/lib/toast\"\nimport { useAppleIAP } from \"@/src/providers/AppleIAPProvider\"\nimport { useColor } from \"@/src/theme/colors\"\n\ntype PaymentPlan = NonNullable<StatusConfigs[\"PAYMENT_PLAN_LIST\"]>[number]\ntype PaymentFeature = PaymentPlan[\"limit\"]\ntype BillingPeriod = \"monthly\" | \"yearly\"\n\nconst AI_MODEL_SELECTION_VALUE_LABELS = {\n  none: {\n    translationKey: \"plan.featureValues.AI_MODEL_SELECTION.none\",\n    fallback: \"—\",\n  },\n  curated: {\n    translationKey: \"plan.featureValues.AI_MODEL_SELECTION.curated\",\n    fallback: \"Curated models\",\n  },\n  high_performance: {\n    translationKey: \"plan.featureValues.AI_MODEL_SELECTION.high_performance\",\n    fallback: \"All high-end models\",\n  },\n} as const\n\nconst PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [\n  \"MAX_SUBSCRIPTIONS\",\n  \"MAX_LISTS\",\n  \"MAX_INBOXES\",\n  \"MAX_ACTIONS\",\n  \"MAX_AI_ENTRY_SUMMARY_PER_DAY\",\n  \"MAX_AI_ENTRY_TRANSLATION_PER_DAY\",\n  \"MAX_AI_TEXT_TO_SPEECH_PER_DAY\",\n  \"AI_MODEL_SELECTION\",\n  \"AI_BRING_YOUR_OWN_KEY\",\n  \"BOOSTS\",\n  \"PRIORITY_SUPPORT\",\n  \"PRIVATE_SUBSCRIPTION\",\n  \"MAX_RSSHUB_SUBSCRIPTIONS\",\n  \"SECURE_IMAGE_PROXY\",\n  \"INTEGRATION_SUPPORTED\",\n  \"AI_CREDIT\",\n  \"MAX_AI_TASKS\",\n]\n\nconst BILLING_SEGMENTS: BillingPeriod[] = [\"monthly\", \"yearly\"]\n\ntype SegmentLayout = {\n  width: number\n  x: number\n}\n\nconst PlanHeaderBackButton = ({ canGoBack }: { canGoBack: boolean }) => (\n  <DefaultHeaderBackButton canGoBack={canGoBack} canDismiss={false} />\n)\n\nconst styles = StyleSheet.create({\n  billingSegmentIndicator: {\n    position: \"absolute\",\n    top: 2,\n    bottom: 2,\n    borderRadius: 9999,\n  },\n})\n\ntype UpgradeVariables = {\n  planId: string\n  annual: boolean\n}\n\ntype ActiveSubscription = {\n  source: \"stripe\" | \"apple\" | null\n  plan: string | null\n  status: string | null\n  productId: string | null\n  periodEnd: string | null\n  trialEnd: string | null\n  canManage: boolean\n}\n\nconst currencyFormatter = (() => {\n  try {\n    return new Intl.NumberFormat(\"en-US\", {\n      style: \"currency\",\n      currency: \"USD\",\n      trailingZeroDisplay: \"stripIfInteger\",\n    })\n  } catch {\n    return new Intl.NumberFormat(\"en-US\", {\n      style: \"currency\",\n      currency: \"USD\",\n      minimumFractionDigits: 0,\n      maximumFractionDigits: 2,\n    })\n  }\n})()\n\nconst formatCurrency = (value: number) => currencyFormatter.format(value)\n\nconst formatFeatureValue = (\n  key: keyof PaymentFeature,\n  value: PaymentFeature[keyof PaymentFeature] | undefined | null,\n  t?: TFunction<\"settings\">,\n): string => {\n  if (value == null) {\n    return \"—\"\n  }\n\n  if (key === \"AI_MODEL_SELECTION\" && typeof value === \"string\") {\n    const selectionValue =\n      AI_MODEL_SELECTION_VALUE_LABELS[value as keyof typeof AI_MODEL_SELECTION_VALUE_LABELS]\n\n    if (selectionValue) {\n      return t?.(selectionValue.translationKey) ?? selectionValue.fallback\n    }\n  }\n\n  if (typeof value === \"boolean\") {\n    return value ? \"✓\" : \"—\"\n  }\n\n  if (key === \"PRIORITY_SUPPORT\" && typeof value === \"number\") {\n    return \"⭐️\".repeat(value)\n  }\n\n  if (value === Number.MAX_SAFE_INTEGER) {\n    return \"Unlimited\"\n  }\n\n  if (typeof value === \"number\") {\n    return new Intl.NumberFormat(\"en\", {\n      notation: \"compact\",\n      compactDisplay: \"short\",\n      maximumFractionDigits: 1,\n    }).format(value)\n  }\n\n  if (Array.isArray(value)) {\n    return value.length > 0 ? value.join(\" \") : \"—\"\n  }\n\n  return value\n}\n\nconst isFeatureValueVisible = (value: PaymentFeature[keyof PaymentFeature] | null | undefined) => {\n  if (value == null) {\n    return false\n  }\n\n  if (typeof value === \"boolean\") {\n    return value\n  }\n\n  if (typeof value === \"number\") {\n    return value > 0\n  }\n\n  return true\n}\n\nconst isAppleProductId = (value: string | undefined): value is string => typeof value === \"string\"\n\nconst BILLING_PERIOD_RANK: Record<BillingPeriod, number> = {\n  monthly: 0,\n  yearly: 1,\n}\n\ntype PlanActionType = \"current\" | \"upgrade\" | \"new\" | \"coming-soon\" | null\n\nconst matchesPlanIdentifier = (\n  plan: PaymentPlan,\n  identifier: string | null | undefined,\n): boolean => {\n  if (!identifier) {\n    return false\n  }\n\n  return plan.planID === identifier || plan.role === identifier\n}\n\nconst getAppleProductIdForBillingPeriod = (\n  plan: PaymentPlan,\n  billingPeriod: BillingPeriod,\n): string | null => {\n  const productId =\n    billingPeriod === \"yearly\" ? plan.appleProductIdentifierAnnual : plan.appleProductIdentifier\n\n  return productId ?? null\n}\n\nconst inferBillingPeriodFromProductId = (\n  productId: string | null | undefined,\n  plan: PaymentPlan,\n): BillingPeriod | null => {\n  if (!productId) {\n    return null\n  }\n\n  if (productId === plan.appleProductIdentifierAnnual) {\n    return \"yearly\"\n  }\n\n  if (productId === plan.appleProductIdentifier) {\n    return \"monthly\"\n  }\n\n  const normalizedProductId = productId.toLowerCase()\n\n  if (normalizedProductId.includes(\"annual\") || normalizedProductId.includes(\"year\")) {\n    return \"yearly\"\n  }\n\n  if (normalizedProductId.includes(\"month\")) {\n    return \"monthly\"\n  }\n\n  return null\n}\n\nconst getPlanActionType = ({\n  plan,\n  billingPeriod,\n  currentPlan,\n  activeSubscription,\n  hasExistingSubscription,\n}: {\n  plan: PaymentPlan\n  billingPeriod: BillingPeriod\n  currentPlan: PaymentPlan | null\n  activeSubscription?: ActiveSubscription\n  hasExistingSubscription: boolean\n}): PlanActionType => {\n  if (plan.isComingSoon) {\n    return \"coming-soon\"\n  }\n\n  const hasCheckout = !!plan.planID\n  const targetTier = plan.tier ?? 0\n  const currentTier = currentPlan?.tier ?? 0\n  const isSamePlan =\n    matchesPlanIdentifier(plan, activeSubscription?.plan) ||\n    (!!currentPlan && plan.role === currentPlan.role)\n\n  if (isSamePlan) {\n    if (!hasExistingSubscription) {\n      return \"current\"\n    }\n\n    const currentBillingPeriod = inferBillingPeriodFromProductId(\n      activeSubscription?.productId,\n      plan,\n    )\n    if (\n      currentBillingPeriod &&\n      BILLING_PERIOD_RANK[billingPeriod] > BILLING_PERIOD_RANK[currentBillingPeriod]\n    ) {\n      return \"upgrade\"\n    }\n\n    return \"current\"\n  }\n\n  if (!hasCheckout) {\n    return null\n  }\n\n  if (!hasExistingSubscription) {\n    return currentTier === 0 ? \"new\" : targetTier > currentTier ? \"upgrade\" : null\n  }\n\n  return targetTier > currentTier ? \"upgrade\" : null\n}\n\nexport const PlanScreen: NavigationControllerView = () => {\n  const { t } = useTranslation(\"settings\")\n  const serverConfigs = useServerConfigs()\n  const isPaymentEnabled = useIsPaymentEnabled()\n  const role = useUserRole()\n  const roleEndAt = useRoleEndAt()\n  const whoami = useWhoami()\n  const {\n    subscriptions: appleSubscriptions,\n    isProcessingPurchase,\n    isPurchasing,\n    isRestoring,\n    loadSubscriptions,\n    openSubscriptionManagement,\n    requestSubscriptionPurchase,\n    restoreSubscriptionPurchases,\n  } = useAppleIAP()\n\n  const plans = useMemo(() => serverConfigs?.PAYMENT_PLAN_LIST ?? [], [serverConfigs])\n  const appleProductIds = useMemo(\n    () =>\n      plans\n        .flatMap((plan) => [plan.appleProductIdentifier, plan.appleProductIdentifierAnnual])\n        .filter(isAppleProductId),\n    [plans],\n  )\n  const appleProductsById = useMemo(\n    () =>\n      new Map<string, SubscriptionProduct>(\n        appleSubscriptions.map((product: SubscriptionProduct) => [product.id, product]),\n      ),\n    [appleSubscriptions],\n  )\n\n  const defaultBillingPeriod: BillingPeriod = \"yearly\"\n  const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>(defaultBillingPeriod)\n\n  useEffect(() => {\n    if (Platform.OS !== \"ios\" || appleProductIds.length === 0) {\n      return\n    }\n\n    void loadSubscriptions(appleProductIds)\n  }, [appleProductIds, loadSubscriptions])\n\n  const sortedPlans = useMemo(() => {\n    return [...plans].sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0))\n  }, [plans])\n\n  const currentPlan = useMemo(() => {\n    return sortedPlans.find((plan) => plan.role === role) ?? null\n  }, [sortedPlans, role])\n\n  const billingSubscriptionQuery = useQuery({\n    queryKey: [\"billingSubscription\"],\n    queryFn: async () => {\n      const response = await followClient.request<{ code: number; data: ActiveSubscription }>(\n        \"/billing/subscription\",\n      )\n      return response.data\n    },\n    enabled: !!whoami?.id,\n  })\n\n  const activeSubscription = billingSubscriptionQuery.data\n  const subscribedPlan = useMemo(() => {\n    if (!activeSubscription?.plan) {\n      return currentPlan\n    }\n\n    return (\n      sortedPlans.find((plan) => matchesPlanIdentifier(plan, activeSubscription.plan)) ??\n      currentPlan\n    )\n  }, [activeSubscription?.plan, currentPlan, sortedPlans])\n\n  const hasExistingSubscription = useMemo(() => {\n    if (activeSubscription) {\n      return Boolean(\n        activeSubscription.plan ||\n        activeSubscription.source ||\n        activeSubscription.productId ||\n        activeSubscription.status,\n      )\n    }\n\n    return role != null && role !== UserRole.Free\n  }, [activeSubscription, role])\n\n  const daysLeft = useMemo(() => {\n    if (!roleEndAt) {\n      return null\n    }\n\n    const difference = dayjs(roleEndAt).diff(dayjs(), \"day\")\n    return Math.max(difference, 0)\n  }, [roleEndAt])\n\n  const averageSavings = useMemo(() => {\n    const paidPlans = sortedPlans.filter(\n      (plan) => (plan.priceInDollars ?? 0) > 0 && (plan.priceInDollarsAnnual ?? 0) > 0,\n    )\n\n    if (paidPlans.length === 0) {\n      return 0\n    }\n\n    const total = paidPlans.reduce((acc, plan) => {\n      const monthlyTotal = (plan.priceInDollars ?? 0) * 12\n      const yearlyTotal = plan.priceInDollarsAnnual ?? 0\n      if (monthlyTotal === 0) {\n        return acc\n      }\n      const savings = ((monthlyTotal - yearlyTotal) / monthlyTotal) * 100\n      return acc + savings\n    }, 0)\n\n    return Math.round(total / paidPlans.length)\n  }, [sortedPlans])\n\n  const upgradeMutation = useMutation<void, Error, UpgradeVariables>({\n    mutationFn: async ({ planId, annual }) => {\n      const selectedPlan = plans.find((plan) => plan.planID === planId)\n\n      if (Platform.OS === \"ios\" && activeSubscription?.source !== \"stripe\") {\n        const productId = annual\n          ? selectedPlan?.appleProductIdentifierAnnual\n          : selectedPlan?.appleProductIdentifier\n\n        if (!productId) {\n          throw new Error(t(\"subscription.actions.upgrade_error\"))\n        }\n\n        await requestSubscriptionPurchase({\n          sku: productId,\n          appAccountToken: whoami?.appleAppAccountToken,\n        })\n        return\n      }\n\n      const response = await authClient.subscription.upgrade({\n        plan: planId,\n        annual,\n        successUrl: \"folo://refresh\",\n        cancelUrl: proxyEnv.WEB_URL,\n        disableRedirect: true,\n      })\n\n      const redirectUrl =\n        typeof response === \"object\" && response && \"data\" in response && response.data\n          ? (response.data as { url?: string }).url\n          : undefined\n\n      if (redirectUrl) {\n        await openURL(redirectUrl)\n      }\n    },\n    onError: (error) => {\n      const message = error.message?.trim() || t(\"subscription.actions.upgrade_error\")\n      toast.error(message)\n    },\n  })\n\n  const billingPortalMutation = useMutation({\n    mutationFn: async () => {\n      const data = await followClient.request<{ code: number; data?: { url: string } }>(\n        \"/billing/portal\",\n        {\n          method: \"POST\",\n          body: { returnUrl: proxyEnv.WEB_URL },\n        },\n      )\n      if (data.code === 0 && data.data?.url) {\n        await openURL(data.data.url)\n      }\n    },\n    onError: () => {\n      toast.error(t(\"subscription.actions.manage_error\"))\n    },\n  })\n\n  const handleManageSubscription = useCallback(() => {\n    if (activeSubscription?.source === \"apple\") {\n      void openSubscriptionManagement().catch(() => {\n        toast.error(t(\"subscription.actions.manage_error\"))\n      })\n      return\n    }\n\n    billingPortalMutation.mutate()\n  }, [activeSubscription?.source, billingPortalMutation, openSubscriptionManagement, t])\n\n  if (!isPaymentEnabled || sortedPlans.length === 0) {\n    return (\n      <SafeNavigationScrollView\n        className=\"bg-system-grouped-background\"\n        Header={\n          <NavigationBlurEffectHeaderView\n            title={t(\"titles.subscription.long\")}\n            headerLeft={PlanHeaderBackButton}\n          />\n        }\n      >\n        <View className=\"flex-1 items-center justify-center px-6 py-12\">\n          <Text className=\"text-center text-base text-secondary-label\">\n            {t(\"subscription.unavailable\")}\n          </Text>\n        </View>\n      </SafeNavigationScrollView>\n    )\n  }\n\n  const summaryPlan = subscribedPlan ?? currentPlan\n  const summaryTitle = summaryPlan\n    ? t(\"subscription.summary.current\", { plan: summaryPlan.name })\n    : t(\"subscription.summary.free\")\n\n  let summarySubtitle = t(\"subscription.summary.free_description\")\n  if (daysLeft && daysLeft > 0 && role && role !== UserRole.Free) {\n    summarySubtitle = t(\"subscription.summary.trial_expiring\", {\n      date: dayjs(roleEndAt).format(\"MMMM D, YYYY\"),\n      days: daysLeft,\n    })\n  } else if (summaryPlan && summaryPlan.role !== UserRole.Free) {\n    summarySubtitle = t(\"subscription.summary.active\")\n  }\n\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={t(\"titles.subscription.long\")}\n          headerLeft={PlanHeaderBackButton}\n        />\n      }\n    >\n      <View className=\"gap-6 px-4 pb-10 pt-6\">\n        <View className=\"rounded-3xl bg-secondary-system-grouped-background p-5 shadow-sm\">\n          <Text className=\"text-xs font-semibold uppercase text-secondary-label\">\n            {t(\"subscription.summary.title\")}\n          </Text>\n          <Text className=\"mt-2 text-2xl font-semibold text-label\">{summaryTitle}</Text>\n          <Text className=\"mt-2 text-sm leading-relaxed text-secondary-label\">\n            {summarySubtitle}\n          </Text>\n        </View>\n\n        <BillingToggle\n          value={billingPeriod}\n          onChange={setBillingPeriod}\n          averageSavings={averageSavings}\n        />\n\n        <View className=\"gap-4\">\n          {sortedPlans.map((plan) => {\n            const isCurrentPlan =\n              matchesPlanIdentifier(plan, activeSubscription?.plan) || plan.role === role\n            const isProcessing =\n              upgradeMutation.isPending && upgradeMutation.variables?.planId === plan.planID\n            const actionType = getPlanActionType({\n              plan,\n              billingPeriod,\n              currentPlan: subscribedPlan ?? currentPlan,\n              activeSubscription,\n              hasExistingSubscription,\n            })\n\n            return (\n              <PlanCard\n                key={plan.planID ?? plan.name}\n                plan={plan}\n                billingPeriod={billingPeriod}\n                actionType={actionType}\n                isCurrentPlan={isCurrentPlan}\n                storeProduct={\n                  Platform.OS === \"ios\"\n                    ? appleProductsById.get(\n                        getAppleProductIdForBillingPeriod(plan, billingPeriod) ?? \"\",\n                      )\n                    : undefined\n                }\n                onUpgrade={\n                  plan.planID &&\n                  actionType &&\n                  actionType !== \"current\" &&\n                  actionType !== \"coming-soon\" &&\n                  (!hasExistingSubscription || billingSubscriptionQuery.isFetched)\n                    ? () =>\n                        upgradeMutation.mutate({\n                          planId: plan.planID as string,\n                          annual: billingPeriod === \"yearly\",\n                        })\n                    : undefined\n                }\n                onManageSubscription={handleManageSubscription}\n                isProcessing={isProcessing || isPurchasing || isProcessingPurchase}\n                isManaging={billingPortalMutation.isPending}\n                activeSubscription={billingSubscriptionQuery.data}\n                upgradeButtonText={plan.upgradeButtonText}\n              />\n            )\n          })}\n        </View>\n\n        {Platform.OS === \"ios\" && (\n          <Pressable\n            accessibilityRole=\"button\"\n            onPress={() => void restoreSubscriptionPurchases()}\n            disabled={isRestoring}\n            className=\"h-11 items-center justify-center rounded-full border border-opaque-separator/60 bg-secondary-system-grouped-background disabled:opacity-60\"\n          >\n            {isRestoring ? (\n              <ActivityIndicator />\n            ) : (\n              <Text className=\"text-base font-medium text-label\">\n                {t(\"subscription.actions.restore\")}\n              </Text>\n            )}\n          </Pressable>\n        )}\n      </View>\n    </SafeNavigationScrollView>\n  )\n}\n\nconst BillingToggle = ({\n  value,\n  onChange,\n  averageSavings,\n}: {\n  value: BillingPeriod\n  onChange: (value: BillingPeriod) => void\n  averageSavings: number\n}) => {\n  const { t } = useTranslation(\"settings\")\n  const activeBackground = useColor(\"quaternarySystemFill\")\n  const indicatorTranslate = useRef(new Animated.Value(0)).current\n  const indicatorWidth = useRef(new Animated.Value(0)).current\n  const segmentLayouts = useRef<Partial<Record<BillingPeriod, SegmentLayout>>>({})\n  const [indicatorReady, setIndicatorReady] = useState(false)\n\n  const animateIndicator = useCallback(\n    (period: BillingPeriod, animated: boolean) => {\n      const layout = segmentLayouts.current[period]\n      if (!layout) return\n\n      if (!animated) {\n        indicatorTranslate.setValue(layout.x)\n        indicatorWidth.setValue(layout.width)\n        return\n      }\n\n      Animated.spring(indicatorTranslate, {\n        toValue: layout.x,\n        useNativeDriver: false,\n        damping: 18,\n        stiffness: 180,\n      }).start()\n\n      Animated.timing(indicatorWidth, {\n        toValue: layout.width,\n        duration: 180,\n        easing: Easing.out(Easing.cubic),\n        useNativeDriver: false,\n      }).start()\n    },\n    [indicatorTranslate, indicatorWidth],\n  )\n\n  const handleLayout = useCallback(\n    (period: BillingPeriod) => (event: LayoutChangeEvent) => {\n      const { width, x } = event.nativeEvent.layout\n      segmentLayouts.current[period] = { width, x }\n      const measuredAll = BILLING_SEGMENTS.every((option) => segmentLayouts.current[option])\n      if (measuredAll && !indicatorReady) {\n        setIndicatorReady(true)\n        animateIndicator(value, false)\n      }\n    },\n    [animateIndicator, indicatorReady, value],\n  )\n\n  useEffect(() => {\n    if (indicatorReady) {\n      animateIndicator(value, true)\n    }\n  }, [animateIndicator, indicatorReady, value])\n\n  return (\n    <View className=\"relative flex-row rounded-full bg-secondary-system-grouped-background p-1\">\n      {indicatorReady ? (\n        <Animated.View\n          pointerEvents=\"none\"\n          style={[\n            styles.billingSegmentIndicator,\n            {\n              backgroundColor: activeBackground,\n              transform: [{ translateX: indicatorTranslate }],\n              width: indicatorWidth,\n            },\n          ]}\n        />\n      ) : null}\n      {BILLING_SEGMENTS.map((option) => {\n        const selected = value === option\n        const showSavings = option === \"yearly\" && averageSavings > 0\n        const savingsLabel = t(\"subscription.billing.yearly_savings\", { value: averageSavings })\n\n        return (\n          <Pressable\n            key={option}\n            accessibilityRole=\"button\"\n            onPress={() => {\n              if (!selected) {\n                onChange(option)\n              }\n            }}\n            onLayout={handleLayout(option)}\n            className=\"flex-1 items-center justify-center rounded-full px-4 py-3\"\n          >\n            <View className=\"items-center\">\n              <Text\n                className={cn(\n                  \"text-sm font-medium\",\n                  selected ? \"text-label\" : \"text-secondary-label\",\n                )}\n              >\n                <Text>\n                  {option === \"monthly\"\n                    ? t(\"subscription.billing.monthly\")\n                    : t(\"subscription.billing.yearly\")}\n                </Text>\n                <Text\n                  className={cn(\n                    \"text-center text-xs font-semibold text-green\",\n                    !showSavings && \"opacity-0\",\n                  )}\n                >\n                  {\" \"}\n                  {showSavings ? savingsLabel : \" \"}\n                </Text>\n              </Text>\n            </View>\n          </Pressable>\n        )\n      })}\n    </View>\n  )\n}\n\nconst PlanCard = ({\n  plan,\n  billingPeriod,\n  actionType,\n  isCurrentPlan,\n  storeProduct,\n  onUpgrade,\n  onManageSubscription,\n  isProcessing,\n  isManaging,\n  activeSubscription,\n  upgradeButtonText,\n}: {\n  plan: PaymentPlan\n  billingPeriod: BillingPeriod\n  actionType: PlanActionType\n  isCurrentPlan: boolean\n  storeProduct?: { displayPrice?: string } | null\n  onUpgrade?: () => void\n  onManageSubscription?: () => void\n  isProcessing?: boolean\n  isManaging?: boolean\n  activeSubscription?: ActiveSubscription\n  upgradeButtonText?: string\n}) => {\n  const { t } = useTranslation(\"settings\")\n\n  const isPaidPlan = plan.role !== UserRole.Free\n\n  const regularPrice = isPaidPlan\n    ? billingPeriod === \"yearly\"\n      ? (plan.priceInDollarsAnnual ?? 0) / 12\n      : (plan.priceInDollars ?? 0)\n    : 0\n\n  const discountPrice = isPaidPlan\n    ? billingPeriod === \"yearly\"\n      ? (plan.priceInDollarsInDiscountAnnual ?? 0) / 12\n      : (plan.priceInDollarsInDiscount ?? 0)\n    : 0\n\n  const hasDiscount = isPaidPlan && discountPrice > 0 && discountPrice < regularPrice\n\n  const displayPrice = !isPaidPlan ? 0 : hasDiscount ? discountPrice : regularPrice\n  const formattedPrice = isPaidPlan\n    ? storeProduct?.displayPrice || formatCurrency(displayPrice)\n    : t(\"subscription.price.free\")\n\n  const formattedRegularPrice =\n    hasDiscount && regularPrice > 0 ? formatCurrency(regularPrice) : undefined\n  const discountPercentage = hasDiscount\n    ? Math.round(((regularPrice - discountPrice) / regularPrice) * 100)\n    : 0\n  const discountLabel =\n    discountPercentage > 0\n      ? t(\"subscription.discount.tag\", { value: discountPercentage })\n      : undefined\n\n  const priceAnimation = useRef(new Animated.Value(1)).current\n\n  useEffect(() => {\n    priceAnimation.setValue(0)\n    Animated.spring(priceAnimation, {\n      toValue: 1,\n      useNativeDriver: false,\n      damping: 14,\n      stiffness: 180,\n    }).start()\n  }, [priceAnimation, displayPrice])\n\n  const priceScale = priceAnimation.interpolate({\n    inputRange: [0, 1],\n    outputRange: [0.95, 1],\n  })\n\n  const priceTranslateY = priceAnimation.interpolate({\n    inputRange: [0, 1],\n    outputRange: [6, 0],\n  })\n\n  const periodLabel = !isPaidPlan\n    ? \"\"\n    : storeProduct?.displayPrice && billingPeriod === \"yearly\"\n      ? t(\"subscription.price.per_year\")\n      : billingPeriod === \"yearly\"\n        ? t(\"subscription.price.per_month_billed_yearly\")\n        : t(\"subscription.price.per_month\")\n\n  const planDescription = t(`plan.descriptions.${plan.role}` as const, { defaultValue: \"\" })\n\n  const features = useMemo(() => {\n    const fallbackFeatureOrder = Object.keys(plan.limit || {}) as Array<keyof PaymentFeature>\n    const orderedFeatureKeys = [\n      ...PLAN_FEATURE_ORDER,\n      ...fallbackFeatureOrder.filter((featureKey) => !PLAN_FEATURE_ORDER.includes(featureKey)),\n    ]\n\n    return orderedFeatureKeys\n      .map((featureKey) => [featureKey, plan.limit?.[featureKey]] as const)\n      .filter(([, value]) => isFeatureValueVisible(value))\n      .slice(0, 6)\n  }, [plan.limit])\n\n  const planNameFallback = plan.name || (plan.role ? UserRoleName[plan.role as UserRole] : \"\")\n\n  return (\n    <View\n      className={cn(\n        \"rounded-3xl bg-secondary-system-grouped-background p-5\",\n        isCurrentPlan && \"border border-accent/50 shadow-lg\",\n        plan.isComingSoon && \"opacity-70\",\n      )}\n    >\n      <View className=\"flex-row items-start justify-between\">\n        <View className=\"flex-1\">\n          <Text className=\"text-xl font-semibold text-label\">{planNameFallback}</Text>\n          {planDescription ? (\n            <Text className=\"mt-1 text-sm leading-snug text-secondary-label\">\n              {planDescription}\n            </Text>\n          ) : null}\n        </View>\n        {plan.isPopular ? (\n          <View className=\"rounded-full bg-accent px-2 py-1\">\n            <Text className=\"text-xs font-semibold text-white\">\n              {t(\"subscription.badge.popular\")}\n            </Text>\n          </View>\n        ) : null}\n      </View>\n\n      <View className=\"mt-4\">\n        <View className=\"flex-row items-baseline gap-2\">\n          <Animated.View\n            style={{\n              opacity: priceAnimation,\n              transform: [{ scale: priceScale }, { translateY: priceTranslateY }],\n            }}\n          >\n            <Text className=\"text-2xl font-bold text-label\">{formattedPrice}</Text>\n          </Animated.View>\n          {periodLabel ? <Text className=\"text-xs text-secondary-label\">{periodLabel}</Text> : null}\n        </View>\n        {formattedRegularPrice ? (\n          <Text className=\"mt-1 text-xs text-secondary-label line-through\">\n            {formattedRegularPrice}\n          </Text>\n        ) : null}\n        {discountLabel ? (\n          <Text className=\"mt-1 text-xs font-semibold text-green\">{discountLabel}</Text>\n        ) : null}\n      </View>\n\n      <View className=\"mt-4 gap-2\">\n        {features.map(([featureKey, value]) => {\n          const formattedValue = formatFeatureValue(featureKey, value, t)\n          const showValue = !(typeof value === \"boolean\" && value)\n          return (\n            <View key={featureKey as string} className=\"flex-row items-center gap-3\">\n              <View className=\"rounded-full bg-green/15 p-1\">\n                <CheckLineIcon width={14} height={14} color=\"rgb(40, 205, 65)\" />\n              </View>\n              <Text className=\"flex-1 text-sm text-label\">\n                {t(`plan.features.${featureKey}` as const, { defaultValue: featureKey })}\n              </Text>\n              {showValue ? (\n                <Text\n                  className={cn(\n                    \"text-sm font-medium\",\n                    formattedValue === \"Unlimited\" ? \"text-accent\" : \"text-secondary-label\",\n                  )}\n                >\n                  {formattedValue === \"✓\" ? t(\"subscription.feature.included\") : formattedValue}\n                </Text>\n              ) : (\n                <Text className=\"text-sm font-medium text-secondary-label\">\n                  {t(\"subscription.feature.included\")}\n                </Text>\n              )}\n            </View>\n          )\n        })}\n      </View>\n\n      <PlanAction\n        actionType={actionType}\n        onUpgrade={onUpgrade}\n        onManageSubscription={onManageSubscription}\n        isProcessing={isProcessing}\n        isManaging={isManaging}\n        activeSubscription={activeSubscription}\n        upgradeButtonText={upgradeButtonText}\n      />\n    </View>\n  )\n}\n\nconst PlanAction = ({\n  actionType,\n  onUpgrade,\n  onManageSubscription,\n  isProcessing,\n  isManaging,\n  activeSubscription,\n  upgradeButtonText,\n}: {\n  actionType: \"current\" | \"upgrade\" | \"new\" | \"coming-soon\" | null\n  onUpgrade?: () => void\n  onManageSubscription?: () => void\n  isProcessing?: boolean\n  isManaging?: boolean\n  activeSubscription?: ActiveSubscription\n  upgradeButtonText?: string\n}) => {\n  const { t } = useTranslation(\"settings\")\n\n  const canManageSubscription = !!activeSubscription?.canManage\n  const isCanceled = activeSubscription?.status === \"canceled\"\n  const periodEnd = activeSubscription?.trialEnd ?? activeSubscription?.periodEnd\n  const effectivePeriodEnd = periodEnd ? new Date(periodEnd) : null\n\n  if (actionType === \"coming-soon\") {\n    return (\n      <Text className=\"mt-5 rounded-full border border-opaque-separator/60 px-4 py-2 text-center text-sm text-secondary-label\">\n        {t(\"subscription.actions.comingSoon\")}\n      </Text>\n    )\n  }\n\n  if (actionType === \"current\") {\n    return (\n      <View className=\"mt-5 gap-1.5\">\n        {canManageSubscription && (\n          <View className=\"flex-row items-center justify-center gap-1.5\">\n            <View\n              className={cn(\n                \"size-1.5 rounded-full\",\n                isCanceled\n                  ? \"bg-yellow\"\n                  : activeSubscription?.status === \"trialing\"\n                    ? \"bg-blue\"\n                    : \"bg-green\",\n              )}\n            />\n            <Text className=\"text-xs text-secondary-label\">\n              {isCanceled\n                ? t(\"plan.canceled_expires\", {\n                    date: effectivePeriodEnd?.toLocaleDateString(undefined, {\n                      year: \"numeric\",\n                      month: \"short\",\n                      day: \"numeric\",\n                    }),\n                  })\n                : activeSubscription?.status === \"trialing\"\n                  ? t(\"plan.trial_ends\", {\n                      date: effectivePeriodEnd?.toLocaleDateString(undefined, {\n                        year: \"numeric\",\n                        month: \"short\",\n                        day: \"numeric\",\n                      }),\n                    })\n                  : t(\"plan.renews\", {\n                      date: effectivePeriodEnd?.toLocaleDateString(undefined, {\n                        year: \"numeric\",\n                        month: \"short\",\n                        day: \"numeric\",\n                      }),\n                    })}\n            </Text>\n          </View>\n        )}\n        <Pressable\n          accessibilityRole=\"button\"\n          onPress={onManageSubscription}\n          disabled={!canManageSubscription || isManaging}\n          className={cn(\n            \"h-11 items-center justify-center rounded-full border border-opaque-separator/60\",\n            !canManageSubscription && \"opacity-50\",\n          )}\n        >\n          {isManaging ? (\n            <ActivityIndicator />\n          ) : (\n            <Text className=\"text-base font-medium text-label\">\n              {canManageSubscription ? t(\"plan.manage_subscription\") : t(\"plan.current_plan\")}\n            </Text>\n          )}\n        </Pressable>\n      </View>\n    )\n  }\n\n  if ((actionType === \"upgrade\" || actionType === \"new\") && onUpgrade) {\n    return (\n      <Pressable\n        accessibilityRole=\"button\"\n        onPress={onUpgrade}\n        disabled={isProcessing}\n        className=\"mt-5 h-11 items-center justify-center rounded-full bg-accent disabled:opacity-70\"\n      >\n        {isProcessing ? (\n          <ActivityIndicator color=\"white\" />\n        ) : (\n          <Text className=\"text-base font-semibold text-white\">\n            {actionType === \"new\"\n              ? (upgradeButtonText ?? t(\"subscription.actions.upgrade\"))\n              : t(\"subscription.actions.upgrade\")}\n          </Text>\n        )}\n      </Pressable>\n    )\n  }\n\n  return null\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/Privacy.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { Linking } from \"react-native\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListNavigationLink,\n} from \"@/src/components/ui/grouped/GroupedList\"\n\nexport const PrivacyScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"titles.privacy\")} />}\n    >\n      <GroupedInsetListCard className=\"mt-4\">\n        <GroupedInsetListNavigationLink\n          label={t(\"privacy.terms\")}\n          onPress={() => {\n            Linking.openURL(\"https://folo.is/terms-of-service\")\n          }}\n        />\n        <GroupedInsetListNavigationLink\n          label={t(\"privacy.privacy\")}\n          onPress={() => {\n            Linking.openURL(\"https://folo.is/privacy-policy\")\n          }}\n        />\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/ResetPassword.tsx",
    "content": "import { useMutation } from \"@tanstack/react-query\"\nimport { useCallback, useState } from \"react\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { UIBarButton } from \"@/src/components/ui/button/UIBarButton\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetListBaseCell,\n  GroupedInsetListCard,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { CheckLineIcon } from \"@/src/icons/check_line\"\nimport { changePassword } from \"@/src/lib/auth\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { toast } from \"@/src/lib/toast\"\n\nexport const ResetPassword = () => {\n  const labelColor = useColor(\"label\")\n  const navigation = useNavigation()\n\n  const [currentPassword, setCurrentPassword] = useState(\"\")\n  const [newPassword, setNewPassword] = useState(\"\")\n  const [confirmNewPassword, setConfirmNewPassword] = useState(\"\")\n\n  const isFormValid =\n    !!currentPassword && !!newPassword && !!confirmNewPassword && newPassword === confirmNewPassword\n\n  const { mutate: submitChangePassword, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await changePassword({\n        currentPassword,\n        newPassword,\n        revokeOtherSessions: true,\n      })\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n    },\n    onSuccess: () => {\n      toast.success(\"Password updated\")\n      navigation.back()\n    },\n    onError: (error) => {\n      toast.error(error instanceof Error ? error.message : \"Failed to update password\")\n    },\n  })\n\n  const handleSave = useCallback(() => {\n    if (isPending) return\n    if (!isFormValid) {\n      if (newPassword && confirmNewPassword && newPassword !== confirmNewPassword) {\n        toast.error(\"New passwords do not match\")\n      }\n      return\n    }\n    submitChangePassword()\n  }, [confirmNewPassword, isFormValid, isPending, newPassword, submitChangePassword])\n\n  return (\n    <SafeNavigationScrollView\n      className=\"flex-1 bg-system-grouped-background\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title=\"Reset Password\"\n          headerRight={useCallback(\n            () => (\n              <UIBarButton\n                label=\"Save\"\n                normalIcon={\n                  isPending ? (\n                    <PlatformActivityIndicator size=\"small\" color={labelColor} />\n                  ) : (\n                    <CheckLineIcon height={18} width={18} color={labelColor} />\n                  )\n                }\n                disabled={!isFormValid || isPending}\n                onPress={handleSave}\n              />\n            ),\n            [handleSave, isFormValid, isPending, labelColor],\n          )}\n        />\n      }\n    >\n      <GroupedInsetListSectionHeader label=\"Current Password\" />\n      <GroupedInsetListCard>\n        <GroupedInsetListBaseCell className=\"py-3\">\n          <PlainTextField\n            autoFocus\n            className=\"w-full\"\n            hitSlop={10}\n            secureTextEntry={true}\n            keyboardType=\"visible-password\"\n            placeholder=\"Enter your current password\"\n            value={currentPassword}\n            onChangeText={setCurrentPassword}\n          />\n        </GroupedInsetListBaseCell>\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader marginSize=\"small\" label=\"New Password\" />\n      <GroupedInsetListCard>\n        <GroupedInsetListBaseCell className=\"py-3\">\n          <PlainTextField\n            className=\"w-full\"\n            keyboardType=\"visible-password\"\n            secureTextEntry={true}\n            hitSlop={10}\n            placeholder=\"Enter your new password\"\n            value={newPassword}\n            onChangeText={setNewPassword}\n          />\n        </GroupedInsetListBaseCell>\n      </GroupedInsetListCard>\n\n      <GroupedInsetListSectionHeader marginSize=\"small\" label=\"Confirm New Password\" />\n      <GroupedInsetListCard>\n        <GroupedInsetListBaseCell className=\"py-3\">\n          <PlainTextField\n            className=\"w-full\"\n            keyboardType=\"visible-password\"\n            secureTextEntry={true}\n            hitSlop={10}\n            placeholder=\"Enter your new password again\"\n            value={confirmNewPassword}\n            onChangeText={setConfirmNewPassword}\n            returnKeyType=\"done\"\n            onSubmitEditing={handleSave}\n          />\n        </GroupedInsetListBaseCell>\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/routes/navigateToPlanScreen.ts",
    "content": "import { getIsPaymentEnabled } from \"@/src/atoms/server-configs\"\nimport { Navigation } from \"@/src/lib/navigation/Navigation\"\n\nexport const navigateToPlanScreen = () => {\n  if (!getIsPaymentEnabled()) {\n    return Promise.resolve()\n  }\n\n  return import(\"./Plan\")\n    .then(({ PlanScreen }) => {\n      Navigation.rootNavigation.pushControllerView(PlanScreen)\n    })\n    .catch((error) => {\n      if (__DEV__) {\n        console.error(\"Failed to open plan screen\", error)\n      }\n      throw error\n    })\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/sync-queue.ts",
    "content": "import type { GeneralSettings, UISettings } from \"@follow/shared/settings/interface\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { tracker } from \"@follow/tracker\"\nimport { isEmptyObject, jotaiStore, sleep } from \"@follow/utils\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport type { SettingsTab } from \"@follow-app/client-sdk\"\nimport { FollowAPIError } from \"@follow-app/client-sdk\"\nimport type { PrimitiveAtom } from \"jotai\"\n\nimport {\n  __generalSettingAtom,\n  generalServerSyncWhiteListKeys,\n  getGeneralSettings,\n} from \"@/src/atoms/settings/general\"\nimport { __uiSettingAtom, getUISettings, uiServerSyncWhiteListKeys } from \"@/src/atoms/settings/ui\"\nimport { followClient } from \"@/src/lib/api-client\"\nimport { kv } from \"@/src/lib/kv\"\n\ntype SettingMapping = {\n  appearance: UISettings\n  general: GeneralSettings\n}\n\nconst pickSyncPayload = <T extends object>(payload: T, keys: readonly (keyof T | string)[]) => {\n  const nextPayload = {} as Partial<T>\n  const record = payload as Record<string, unknown>\n\n  for (const key of keys) {\n    if (Object.prototype.hasOwnProperty.call(record, key)) {\n      nextPayload[key as keyof T] = record[key as string] as T[keyof T]\n    }\n  }\n\n  return nextPayload\n}\n\nconst localSettingGetterMap = {\n  appearance: () => getUISettings(),\n  general: () => getGeneralSettings(),\n}\n\nconst createInternalSetter =\n  <T>(atom: PrimitiveAtom<T>) =>\n  (payload: Partial<T>) => {\n    const current = jotaiStore.get(atom)\n    jotaiStore.set(atom, { ...current, ...payload })\n  }\n\nconst localSettingSetterMap = {\n  appearance: createInternalSetter(__uiSettingAtom),\n  general: createInternalSetter(__generalSettingAtom),\n}\nconst settingWhiteListMap = {\n  appearance: uiServerSyncWhiteListKeys,\n  general: generalServerSyncWhiteListKeys,\n}\n\nconst bizSettingKeyToTabMapping = {\n  ui: \"appearance\",\n  general: \"general\",\n}\n\nconst isUnauthorizedError = (error: unknown) => {\n  if (error instanceof FollowAPIError) {\n    return error.status === 401\n  }\n\n  if (error && typeof error === \"object\" && \"status\" in error) {\n    return Number((error as { status?: unknown }).status) === 401\n  }\n\n  return false\n}\n\nexport type SettingSyncTab = keyof SettingMapping\nexport interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {\n  tab: T\n  payload: Partial<SettingMapping[T]>\n  date: number\n}\n\ninterface PersistedSettingSyncQueue {\n  ownerUserId: string | null\n  queue: SettingSyncQueueItem[]\n}\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface CustomEvent {\n    SETTING_CHANGE_EVENT: {\n      key: keyof typeof bizSettingKeyToTabMapping\n      payload: any\n    }\n  }\n}\n\nclass SettingSyncQueue {\n  queue: SettingSyncQueueItem[] = []\n  private ownerUserId: string | null = null\n\n  private getCurrentUserId() {\n    return whoami()?.id ?? null\n  }\n\n  private bindQueueOwner(currentUserId: string) {\n    if (this.ownerUserId === null) {\n      this.ownerUserId = currentUserId\n      return\n    }\n\n    if (this.ownerUserId !== currentUserId) {\n      this.ownerUserId = currentUserId\n      this.queue = []\n    }\n  }\n\n  private reportSyncError(stage: \"flush\" | \"syncLocal\", error: unknown) {\n    void tracker.manager.captureException(error, {\n      module: \"setting_sync\",\n      stage,\n    })\n  }\n\n  private async clearQueueAndPersist(ownerUserId: string | null) {\n    this.queue = []\n    this.ownerUserId = ownerUserId\n    await kv.delete(this.storageKey)\n  }\n\n  private disposers: (() => void)[] = []\n  async init() {\n    this.teardown()\n\n    const loadPromise = this.load()\n\n    const d1 = EventBus.subscribe(\"SETTING_CHANGE_EVENT\", (data) => {\n      const currentUserId = this.getCurrentUserId()\n      if (!currentUserId) return\n\n      this.bindQueueOwner(currentUserId)\n\n      const tab = bizSettingKeyToTabMapping[data.key] as SettingSyncTab\n      if (!tab) return\n\n      const nextPayload = pickSyncPayload(data.payload, settingWhiteListMap[tab])\n      if (isEmptyObject(nextPayload)) return\n      this.enqueue(tab, nextPayload)\n\n      void this.persist()\n    })\n\n    this.disposers.push(d1)\n\n    await loadPromise\n  }\n\n  teardown() {\n    for (const disposer of this.disposers) {\n      disposer()\n    }\n    this.queue = []\n    this.ownerUserId = null\n  }\n\n  private readonly storageKey = \"setting_sync_queue\"\n  private async persist() {\n    if (this.queue.length === 0) {\n      kv.delete(this.storageKey)\n      return\n    }\n\n    const payload: PersistedSettingSyncQueue = {\n      ownerUserId: this.ownerUserId,\n      queue: this.queue,\n    }\n    kv.set(this.storageKey, JSON.stringify(payload))\n  }\n\n  private async load() {\n    const queue = await kv.get(this.storageKey)\n    await kv.delete(this.storageKey)\n    if (!queue) {\n      return\n    }\n\n    const currentUserId = this.getCurrentUserId()\n    let nextQueue: SettingSyncQueueItem[] = []\n    let nextOwnerUserId: string | null = null\n\n    try {\n      const parsed = JSON.parse(queue) as unknown\n      if (Array.isArray(parsed)) {\n        // Backward compatibility: legacy versions persisted the queue array directly.\n        nextQueue = parsed\n        nextOwnerUserId = currentUserId\n      } else if (!parsed || typeof parsed !== \"object\") {\n        return\n      } else {\n        const payload = parsed as Partial<PersistedSettingSyncQueue>\n        nextQueue = Array.isArray(payload.queue) ? payload.queue : []\n        if (typeof payload.ownerUserId === \"string\" || payload.ownerUserId === null) {\n          nextOwnerUserId = payload.ownerUserId\n        } else {\n          // Backward compatibility for payloads without owner information.\n          nextOwnerUserId = currentUserId\n        }\n      }\n    } catch {\n      return\n    }\n\n    // If queue state has already changed after init starts, keep the newer in-memory state.\n    if (this.queue.length > 0 || this.ownerUserId !== null) {\n      return\n    }\n\n    this.queue = nextQueue\n    this.ownerUserId = nextOwnerUserId\n\n    if (!currentUserId) {\n      return\n    }\n\n    this.bindQueueOwner(currentUserId)\n  }\n\n  private chain = Promise.resolve()\n\n  private threshold = 1000\n  private flushScheduled = false\n\n  async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) {\n      return\n    }\n\n    this.bindQueueOwner(currentUserId)\n\n    const now = Date.now()\n    if (isEmptyObject(payload)) {\n      return\n    }\n    this.queue.push({\n      tab,\n      payload,\n      date: now,\n    })\n\n    if (this.flushScheduled) {\n      return\n    }\n\n    this.flushScheduled = true\n    this.chain = this.chain\n      .finally(() => sleep(this.threshold))\n      .finally(async () => {\n        try {\n          await this.flush()\n        } finally {\n          this.flushScheduled = false\n        }\n      })\n  }\n\n  private async flush() {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) {\n      return\n    }\n\n    this.bindQueueOwner(currentUserId)\n\n    if (navigator.onLine === false) {\n      return\n    }\n\n    const groupedTab = {} as Record<SettingSyncTab, any>\n\n    const referenceMap = {} as Record<SettingSyncTab, Set<SettingSyncQueueItem>>\n    for (const item of this.queue) {\n      if (!groupedTab[item.tab]) {\n        groupedTab[item.tab] = {}\n      }\n\n      referenceMap[item.tab] ||= new Set()\n      referenceMap[item.tab].add(item)\n\n      groupedTab[item.tab] = {\n        ...groupedTab[item.tab],\n        ...item.payload,\n      }\n    }\n\n    const promises = [] as Promise<any>[]\n    for (const tab in groupedTab) {\n      const json = pickSyncPayload(\n        groupedTab[tab as SettingSyncTab],\n        settingWhiteListMap[tab as SettingSyncTab],\n      )\n\n      if (isEmptyObject(json)) {\n        continue\n      }\n\n      const promise = followClient.api.settings\n        .update({\n          tab: tab as SettingsTab,\n          ...json,\n        })\n        .then(() => {\n          // remove from queue\n          for (const item of referenceMap[tab as SettingSyncTab]) {\n            const index = this.queue.indexOf(item)\n            if (index !== -1) {\n              this.queue.splice(index, 1)\n            }\n          }\n        })\n      // TODO rollback or retry\n      promises.push(promise)\n    }\n\n    try {\n      await Promise.all(promises)\n    } catch (error) {\n      if (isUnauthorizedError(error)) {\n        await this.clearQueueAndPersist(currentUserId)\n        return\n      }\n\n      this.reportSyncError(\"flush\", error)\n    }\n  }\n\n  replaceRemote(tab?: SettingSyncTab) {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) {\n      return this.chain\n    }\n\n    this.bindQueueOwner(currentUserId)\n\n    if (!tab) {\n      const promises = [] as Promise<any>[]\n      for (const tab in localSettingGetterMap) {\n        const payload = pickSyncPayload(\n          localSettingGetterMap[tab as SettingSyncTab](),\n          settingWhiteListMap[tab as SettingSyncTab],\n        )\n\n        const promise = followClient.api.settings.update({\n          tab: tab as SettingsTab,\n          ...payload,\n        })\n        promises.push(promise)\n      }\n\n      this.chain = this.chain.finally(() => Promise.all(promises))\n      return this.chain\n    } else {\n      const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])\n\n      this.chain = this.chain.finally(() =>\n        followClient.api.settings.update({\n          tab: tab as SettingsTab,\n          ...payload,\n        }),\n      )\n\n      return this.chain\n    }\n  }\n\n  private pendingPromise: Promise<{\n    code: 0\n    settings: Record<string, any>\n    updated: Record<string, string>\n  }> | null = null\n\n  private fetchSettingRemote() {\n    if (this.pendingPromise) {\n      return this.pendingPromise\n    }\n    const promise = followClient.api.settings.get()\n    this.pendingPromise = promise.finally(() => {\n      this.pendingPromise = null\n    })\n    return promise\n  }\n  async syncLocal() {\n    const currentUserId = this.getCurrentUserId()\n    if (!currentUserId) return\n\n    this.bindQueueOwner(currentUserId)\n\n    let remoteSettings: Awaited<ReturnType<typeof this.fetchSettingRemote>> | null = null\n    try {\n      remoteSettings = await this.fetchSettingRemote()\n    } catch (error) {\n      if (isUnauthorizedError(error)) {\n        await this.clearQueueAndPersist(currentUserId)\n        return\n      }\n\n      this.reportSyncError(\"syncLocal\", error)\n      return\n    }\n\n    if (!remoteSettings) return\n    if (__DEV__) {\n      // eslint-disable-next-line no-console\n      console.log(\"remote settings:\", remoteSettings)\n    }\n\n    if (isEmptyObject(remoteSettings.settings)) return\n\n    for (const tab in remoteSettings.settings) {\n      const settingTab = tab as SettingSyncTab\n      const remoteSettingPayload = remoteSettings.settings[tab as SettingsTab]\n      const updated = remoteSettings.updated[tab as SettingsTab]\n\n      if (!updated) {\n        continue\n      }\n\n      const remoteUpdatedDate = new Date(updated).getTime()\n\n      const localSettings = localSettingGetterMap[settingTab]()\n      const localSettingsUpdated =\n        \"updated\" in localSettings && typeof localSettings.updated === \"number\"\n          ? localSettings.updated\n          : undefined\n\n      if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) {\n        // Use remote and update local\n        const nextPayload = pickSyncPayload(remoteSettingPayload, settingWhiteListMap[settingTab])\n\n        if (isEmptyObject(nextPayload)) {\n          continue\n        }\n\n        const setter = localSettingSetterMap[settingTab]\n\n        setter({\n          ...nextPayload,\n          updated: remoteUpdatedDate,\n        } as Partial<SettingMapping[typeof settingTab]>)\n      }\n    }\n  }\n}\n\nexport const settingSyncQueue = new SettingSyncQueue()\n"
  },
  {
    "path": "apps/mobile/src/modules/settings/utils.ts",
    "content": "import { userSyncService } from \"@follow/store/user/store\"\nimport * as DocumentPicker from \"expo-document-picker\"\nimport * as FileSystem from \"expo-file-system/legacy\"\nimport * as Sharing from \"expo-sharing\"\n\nimport { getDbPath } from \"@/src/database\"\nimport { followApi } from \"@/src/lib/api-client\"\nimport { toastFetchError } from \"@/src/lib/error-parser\"\nimport { pickImage } from \"@/src/lib/native/picker\"\nimport { toast } from \"@/src/lib/toast\"\n\nexport const setAvatar = async () => {\n  const result = await pickImage({\n    fileName: \"avatar.jpg\",\n    maxSizeKB: 290,\n  })\n\n  if (!result) return\n  const { formData } = result\n  const { url } = await followApi.upload\n    .uploadAvatar({\n      file: formData.get(\"file\") as any,\n    } as any)\n    .catch((err) => {\n      toastFetchError(err)\n      throw err\n    })\n\n  userSyncService\n    .updateProfile({\n      image: url,\n    })\n    .then(() => {\n      toast.success(\"Avatar updated\")\n    })\n    .catch((err) => {\n      toastFetchError(err)\n    })\n}\n\ntype FileUpload = {\n  uri: string\n  name: string\n  type: string\n}\n\nexport const importOpml = async () => {\n  const result = await DocumentPicker.getDocumentAsync({\n    type: [\"application/octet-stream\", \"text/x-opml\"],\n  })\n  if (result.canceled) {\n    return\n  }\n\n  try {\n    const formData = new FormData()\n    const file = result.assets[0]\n\n    if (!file) {\n      toast.error(\"No file selected\")\n      return\n    }\n\n    formData.append(\"file\", {\n      uri: file.uri,\n      type: file.mimeType || \"application/octet-stream\",\n      name: file.name,\n    } as FileUpload as any)\n\n    const { data } = await followApi.subscriptions.import(formData)\n\n    const { successfulItems, conflictItems, parsedErrorItems } = data\n    toast.success(\n      `Import successful, ${successfulItems.length} feeds were imported, ${conflictItems.length} feeds were already subscribed, and ${parsedErrorItems.length} feeds failed to import.`,\n    )\n  } catch (error) {\n    toastFetchError(error as Error)\n    console.error(error)\n  }\n}\n\nexport const exportLocalDatabase = async () => {\n  const dbPath = getDbPath()\n  try {\n    const destinationUri = `${FileSystem.documentDirectory}follow.db`\n    await FileSystem.copyAsync({\n      from: dbPath,\n      to: destinationUri,\n    })\n\n    await FileSystem.getInfoAsync(destinationUri)\n    await Sharing.shareAsync(destinationUri, {\n      UTI: \"public.database\",\n      mimeType: \"application/x-sqlite3\",\n      dialogTitle: \"Export Database\",\n    })\n  } catch (error) {\n    console.error(error)\n    toast.error(\"Failed to export database\")\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/CategoryGrouped.tsx",
    "content": "import { useUnreadByIds } from \"@follow/store/unread/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { memo, useState } from \"react\"\nimport { View } from \"react-native\"\nimport Animated, { useAnimatedStyle, useSharedValue, withSpring } from \"react-native-reanimated\"\n\nimport { GROUPED_LIST_MARGIN } from \"@/src/components/ui/grouped/constants\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { NativePressable } from \"@/src/components/ui/pressable/NativePressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { RightCuteFiIcon } from \"@/src/icons/right_cute_fi\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { selectFeed } from \"@/src/modules/screen/atoms\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { SubscriptionFeedCategoryContextMenu } from \"../context-menu/feeds\"\nimport { GroupedContext } from \"./ctx\"\nimport { UnreadCount } from \"./items/UnreadCount\"\nimport { ItemSeparator } from \"./ItemSeparator\"\nimport { UnGroupedList } from \"./UnGroupedList\"\n\nexport const CategoryGrouped = memo(\n  ({\n    category,\n    subscriptionIds,\n    isFirst,\n    isLast,\n  }: {\n    category: string\n    subscriptionIds: string[]\n    isFirst: boolean\n    isLast: boolean\n  }) => {\n    const unreadCounts = useUnreadByIds(subscriptionIds)\n    const [expanded, setExpanded] = useState(false)\n    const rotateSharedValue = useSharedValue(0)\n    const rotateStyle = useAnimatedStyle(() => {\n      return {\n        transform: [\n          {\n            rotate: `${rotateSharedValue.value}deg`,\n          },\n        ],\n      }\n    }, [rotateSharedValue])\n    const secondaryLabelColor = useColor(\"label\")\n    const navigation = useNavigation()\n    const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n    return (\n      <>\n        <View\n          style={[\n            readableContainerStyle,\n            {\n              marginHorizontal: GROUPED_LIST_MARGIN,\n            },\n          ]}\n        >\n          <SubscriptionFeedCategoryContextMenu\n            feedIds={subscriptionIds}\n            category={category}\n            asChild\n          >\n            <ItemPressable\n              itemStyle={ItemPressableStyle.Grouped}\n              onPress={() => {\n                selectFeed({\n                  type: \"category\",\n                  categoryName: category,\n                })\n                navigation.pushControllerView(FeedScreen, {\n                  feedId: category,\n                })\n              }}\n              className={cn(\"h-12 flex-row items-center px-3\", {\n                \"rounded-t-[10px]\": isFirst,\n                \"rounded-b-[10px]\": isLast && !expanded,\n              })}\n            >\n              <NativePressable\n                hitSlop={10}\n                onPress={() => {\n                  rotateSharedValue.value = withSpring(expanded ? 0 : 90, {})\n                  setExpanded(!expanded)\n                }}\n                className=\"size-5 flex-row items-center justify-center\"\n              >\n                <Animated.View style={rotateStyle} className=\"ml-2\">\n                  <RightCuteFiIcon color={secondaryLabelColor} height={14} width={14} />\n                </Animated.View>\n              </NativePressable>\n              <Text className=\"ml-4 text-sm font-medium text-text\">{category}</Text>\n              <UnreadCount unread={unreadCounts} className=\"ml-auto text-xs text-secondary-label\" />\n            </ItemPressable>\n          </SubscriptionFeedCategoryContextMenu>\n        </View>\n\n        {/* FIXME: This separator is not visible when expanded and will add a unexpected space under grouped list */}\n        {!isLast && !expanded && <ItemSeparator />}\n        {expanded && (\n          <GroupedContext value={category}>\n            <UnGroupedList subscriptionIds={subscriptionIds} isLastGroup={isLast} />\n          </GroupedContext>\n        )}\n      </>\n    )\n  },\n)\nCategoryGrouped.displayName = \"CategoryGrouped\"\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/ItemSeparator.tsx",
    "content": "import { View } from \"react-native\"\n\nimport { GROUPED_LIST_MARGIN } from \"@/src/components/ui/grouped/constants\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\n\nexport const ItemSeparator = () => {\n  const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n  return (\n    <View\n      className=\"bg-secondary-system-grouped-background\"\n      style={[readableContainerStyle, { marginHorizontal: GROUPED_LIST_MARGIN }]}\n    >\n      <View\n        className=\"ml-12 h-px flex-1 bg-opaque-separator/70\"\n        collapsable={false}\n        style={{ transform: [{ scaleY: 0.5 }] }}\n      />\n    </View>\n  )\n}\n\nexport const SecondaryItemSeparator = () => {\n  const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n  return (\n    <View\n      className=\"bg-secondary-system-grouped-background\"\n      style={[readableContainerStyle, { marginHorizontal: GROUPED_LIST_MARGIN }]}\n    >\n      <View\n        className=\"ml-16 h-px flex-1 bg-opaque-separator/70\"\n        collapsable={false}\n        style={{ transform: [{ scaleY: 0.5 }] }}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/SubscriptionLists.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { FEED_COLLECTION_LIST } from \"@follow/store/constants/app\"\nimport { useInboxList } from \"@follow/store/inbox/hooks\"\nimport {\n  useGroupedSubscription,\n  useListSubscriptionIds,\n  useSortedGroupedSubscription,\n  useSortedListSubscription,\n  useSortedUngroupedSubscription,\n} from \"@follow/store/subscription/hooks\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport type { FlashListRef } from \"@shopify/flash-list\"\nimport type { ParseKeys } from \"i18next\"\nimport { memo, useCallback, useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useGeneralSettingKey, useHideAllReadSubscriptions } from \"@/src/atoms/settings/general\"\nimport { useRegisterNavigationScrollView } from \"@/src/components/layouts/tabbar/hooks\"\nimport {\n  GROUPED_ICON_TEXT_GAP,\n  GROUPED_LIST_ITEM_PADDING,\n  GROUPED_LIST_MARGIN,\n  GROUPED_SECTION_BOTTOM_MARGIN,\n  GROUPED_SECTION_TOP_MARGIN,\n} from \"@/src/components/ui/grouped/constants\"\nimport { GroupedInsetListCard } from \"@/src/components/ui/grouped/GroupedList\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { StarCuteFiIcon } from \"@/src/icons/star_cute_fi\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { selectFeed } from \"@/src/modules/screen/atoms\"\nimport { TimelineSelectorList } from \"@/src/modules/screen/TimelineSelectorList\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { useFeedListSortMethod, useFeedListSortOrder } from \"./atoms\"\nimport { CategoryGrouped } from \"./CategoryGrouped\"\nimport { InboxItem } from \"./items/InboxItem\"\nimport { ListSubscriptionItem } from \"./items/ListSubscriptionItem\"\nimport { SubscriptionItem } from \"./items/SubscriptionItem\"\n\nconst keyExtractor = (\n  item:\n    | string\n    | {\n        category: string\n        subscriptionIds: string[]\n      },\n) => {\n  if (typeof item === \"string\") {\n    return item\n  }\n  return item.category\n}\nconst SubscriptionListImpl = ({\n  view,\n  active = true,\n}: {\n  view: FeedViewType\n  active?: boolean\n}) => {\n  const hideAllReadSubscriptions = useHideAllReadSubscriptions()\n  const autoGroup = useGeneralSettingKey(\"autoGroup\")\n  const listIds = useListSubscriptionIds(view)\n  const sortedListIds = useSortedListSubscription({\n    ids: listIds,\n    sortBy: \"alphabet\",\n    hideAllReadSubscriptions,\n  })\n  const inboxes = useInboxList(\n    useCallback(\n      (inboxes) => (view === FeedViewType.Articles ? inboxes.map((inbox) => inbox.id) : []),\n      [view],\n    ),\n  )\n  const { grouped, unGrouped } = useGroupedSubscription({\n    view,\n    autoGroup,\n  })\n  const sortBy = useFeedListSortMethod()\n  const sortOrder = useFeedListSortOrder()\n  const sortedGrouped = useSortedGroupedSubscription({\n    view,\n    grouped,\n    sortBy,\n    sortOrder,\n    hideAllReadSubscriptions,\n  })\n  const sortedUnGrouped = useSortedUngroupedSubscription({\n    ids: unGrouped,\n    sortBy,\n    sortOrder,\n    hideAllReadSubscriptions,\n  })\n  const data = useMemo(\n    () => [\n      \"words.starred\",\n      \"words.lists\",\n      ...sortedListIds,\n      \"words.inbox\",\n      ...inboxes,\n      \"words.feeds\",\n      ...sortedGrouped,\n      ...sortedUnGrouped,\n    ],\n    [inboxes, sortedListIds, sortedGrouped, sortedUnGrouped],\n  )\n  const extraData = useMemo(() => {\n    const listsIndexStart = 2\n    const listsIndexEnd = listsIndexStart + sortedListIds.length - 1\n    const inboxIndexStart = listsIndexEnd + 2\n    const inboxIndexEnd = inboxIndexStart + inboxes.length - 1\n    const feedsIndexStart = inboxIndexEnd + 2\n    const feedsIndexEnd = feedsIndexStart + sortedGrouped.length + sortedUnGrouped.length - 1\n    const groupedIndexStart = inboxIndexEnd + 2\n    const groupedIndexEnd = groupedIndexStart + sortedGrouped.length - 1\n    return {\n      inboxIndexRange: [inboxIndexStart, inboxIndexEnd],\n      feedsIndexRange: [feedsIndexStart, feedsIndexEnd],\n      listsIndexRange: [listsIndexStart, listsIndexEnd],\n      groupedIndexRange: [groupedIndexStart, groupedIndexEnd],\n    }\n  }, [inboxes.length, sortedGrouped.length, sortedListIds.length, sortedUnGrouped.length])\n  const [refreshing, setRefreshing] = useState(false)\n  const onRefresh = useEventCallback(() => {\n    return subscriptionSyncService.fetch(view)\n  })\n  const scrollViewRef = useRegisterNavigationScrollView<FlashListRef<any> | null>(active)\n\n  return (\n    <TimelineSelectorList\n      contentContainerClassName=\"pb-6\"\n      ref={scrollViewRef}\n      onRefresh={() => {\n        setRefreshing(true)\n        onRefresh().finally(() => {\n          setRefreshing(false)\n        })\n      }}\n      isRefetching={refreshing}\n      data={data}\n      renderItem={ItemRender}\n      keyExtractor={keyExtractor}\n      extraData={extraData}\n    />\n  )\n}\nconst ItemRender = ({\n  item,\n  index,\n  extraData,\n}: {\n  item:\n    | string\n    | {\n        category: string\n        subscriptionIds: string[]\n      }\n  index: number\n  extraData?: {\n    inboxIndexRange: [number, number]\n    feedsIndexRange: [number, number]\n    listsIndexRange: [number, number]\n    groupedIndexRange: [number, number]\n  }\n}) => {\n  if (typeof item === \"string\") {\n    switch (item) {\n      case \"words.starred\": {\n        return <StarItem />\n      }\n      case \"words.inbox\": {\n        if (!extraData) return null\n        const { inboxIndexRange } = extraData\n        if (inboxIndexRange[0] > inboxIndexRange[1]) return null\n        return <SectionTitle transKey={item} />\n      }\n      case \"words.lists\": {\n        if (!extraData) return null\n        const { listsIndexRange } = extraData\n        if (listsIndexRange[0] > listsIndexRange[1]) return null\n        return <SectionTitle transKey={item} />\n      }\n      case \"words.feeds\": {\n        if (!extraData) return null\n        const { feedsIndexRange } = extraData\n        if (feedsIndexRange[0] > feedsIndexRange[1]) return null\n        return <SectionTitle transKey={item} />\n      }\n      default: {\n        if (!extraData) return null\n        const { inboxIndexRange, feedsIndexRange, listsIndexRange } = extraData\n        if (listsIndexRange[0] <= index && index <= listsIndexRange[1]) {\n          const isFirst = index === listsIndexRange[0]\n          const isLast = index === listsIndexRange[1]\n          return <ListSubscriptionItem id={item} isFirst={isFirst} isLast={isLast} />\n        }\n        if (inboxIndexRange[0] <= index && index <= inboxIndexRange[1]) {\n          const isFirst = index === inboxIndexRange[0]\n          const isLast = index === inboxIndexRange[1]\n          return <InboxItem id={item} isFirst={isFirst} isLast={isLast} />\n        }\n        if (feedsIndexRange[0] <= index && index <= feedsIndexRange[1]) {\n          const isFirst = index === feedsIndexRange[0]\n          const isLast = index === feedsIndexRange[1]\n          return <SubscriptionItem id={item} isFirst={isFirst} isLast={isLast} />\n        }\n        return null\n      }\n    }\n  }\n  const { category, subscriptionIds } = item\n  if (!extraData) return null\n  const { feedsIndexRange } = extraData\n  const isFirst = index === feedsIndexRange[0]\n  const isLast = index === feedsIndexRange[1]\n  return (\n    <CategoryGrouped\n      category={category}\n      subscriptionIds={subscriptionIds}\n      isFirst={isFirst}\n      isLast={isLast}\n    />\n  )\n}\nconst SectionTitle = ({ transKey }: { transKey: ParseKeys<\"common\"> }) => {\n  const { t } = useTranslation(\"common\")\n  const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n  return (\n    <View\n      style={[\n        readableContainerStyle,\n        {\n          marginHorizontal: GROUPED_LIST_MARGIN,\n          marginTop: GROUPED_SECTION_TOP_MARGIN,\n          marginBottom: GROUPED_SECTION_BOTTOM_MARGIN,\n          paddingHorizontal: GROUPED_LIST_ITEM_PADDING,\n        },\n      ]}\n    >\n      <Text className=\"text-secondary-label\" ellipsizeMode=\"tail\" numberOfLines={1}>\n        {t(transKey)}\n      </Text>\n    </View>\n  )\n}\nconst StarItem = () => {\n  const navigation = useNavigation()\n  const { t } = useTranslation(\"common\")\n  return (\n    <GroupedInsetListCard showSeparator={false} className=\"mt-4\">\n      <ItemPressable\n        itemStyle={ItemPressableStyle.Grouped}\n        onPress={() => {\n          selectFeed({\n            type: \"feed\",\n            feedId: FEED_COLLECTION_LIST,\n          })\n          navigation.pushControllerView(FeedScreen, {\n            feedId: FEED_COLLECTION_LIST,\n          })\n        }}\n        className=\"h-12 w-full flex-row items-center px-3\"\n      >\n        <StarCuteFiIcon color=\"rgb(245, 158, 11)\" height={20} width={20} />\n        <Text\n          className=\"ml-2 font-medium text-text\"\n          style={{\n            marginLeft: GROUPED_ICON_TEXT_GAP,\n          }}\n        >\n          {t(\"words.starred\")}\n        </Text>\n      </ItemPressable>\n    </GroupedInsetListCard>\n  )\n}\nexport const SubscriptionList = memo(SubscriptionListImpl)\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/UnGroupedList.tsx",
    "content": "import { useSortedUngroupedSubscription } from \"@follow/store/subscription/hooks\"\nimport type { FC } from \"react\"\n\nimport { useHideAllReadSubscriptions } from \"@/src/atoms/settings/general\"\n\nimport { useFeedListSortMethod, useFeedListSortOrder } from \"./atoms\"\nimport { SubscriptionItem } from \"./items/SubscriptionItem\"\n\nexport const UnGroupedList: FC<{\n  subscriptionIds: string[]\n  isLastGroup?: boolean\n}> = ({ subscriptionIds, isLastGroup }) => {\n  const sortBy = useFeedListSortMethod()\n  const sortOrder = useFeedListSortOrder()\n  const hideAllReadSubscriptions = useHideAllReadSubscriptions()\n  const sortedSubscriptionIds = useSortedUngroupedSubscription({\n    ids: subscriptionIds,\n    sortBy,\n    sortOrder,\n    hideAllReadSubscriptions,\n  })\n\n  return sortedSubscriptionIds.map((id, index) => (\n    <SubscriptionItem\n      key={id}\n      id={id}\n      isFirst={false}\n      isLast={!!isLastGroup && index === sortedSubscriptionIds.length - 1}\n    />\n  ))\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/atoms.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { createAtomHooks, jotaiStore } from \"@follow/utils\"\nimport { atom, useAtomValue } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\nimport { useMemo } from \"react\"\n\nimport { views } from \"@/src/constants/views\"\nimport { JotaiPersistSyncStorage } from \"@/src/lib/jotai\"\n\nexport const viewAtom = atom<FeedViewType>(FeedViewType.Articles)\n\nexport const useCurrentView = () => {\n  return useAtomValue(viewAtom)\n}\n\nexport const useCurrentViewDefinition = () => {\n  const view = useCurrentView()\n  const viewDef = useMemo(() => views.find((v) => v.view === view), [view])\n  if (!viewDef) {\n    throw new Error(`View ${view} not found`)\n  }\n  return viewDef\n}\n\nexport const offsetAtom = atom<number>(0)\n\nexport const setCurrentView = (view: FeedViewType) => {\n  jotaiStore.set(viewAtom, view)\n}\n\nexport const [\n  ,\n  ,\n  useFeedListSortMethod,\n  useSetFeedListSortMethod,\n  getFeedListSortMethod,\n  setFeedListSortMethod,\n] = createAtomHooks(\n  atomWithStorage<\"alphabet\" | \"count\">(\"listSortMethod\", \"alphabet\", JotaiPersistSyncStorage, {\n    getOnInit: true,\n  }),\n)\n\nexport const [\n  ,\n  ,\n  useFeedListSortOrder,\n  useSetFeedListSortOrder,\n  getFeedListSortOrder,\n  setFeedListSortOrder,\n] = createAtomHooks(\n  atomWithStorage<\"asc\" | \"desc\">(\"listSortOrder\", \"asc\", JotaiPersistSyncStorage, {\n    getOnInit: true,\n  }),\n)\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/constants.ts",
    "content": "export const ViewTabHeight = 35\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/ctx.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { createContext } from \"react\"\n\n// TODO: remove this context\nconst ViewPageCurrentViewContext = createContext<FeedViewType>(null!)\nexport const ViewPageCurrentViewProvider = ViewPageCurrentViewContext.Provider\nexport const GroupedContext = createContext<string | null>(null)\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/header-actions.tsx",
    "content": "import { useMemo } from \"react\"\nimport { Pressable } from \"react-native\"\nimport * as DropdownMenu from \"zeego/dropdown-menu\"\n\nimport { ListExpansionCuteReIcon } from \"@/src/icons/list_expansion_cute_re\"\n\nimport {\n  setFeedListSortMethod,\n  setFeedListSortOrder,\n  useFeedListSortMethod,\n  useFeedListSortOrder,\n} from \"./atoms\"\n\nexport const SortActionButton = () => {\n  const sortMethod = useFeedListSortMethod()\n  const sortOrder = useFeedListSortOrder()\n\n  const actions = useMemo(() => {\n    const alphabetOrderActions = [\n      {\n        title: \"Ascending\",\n        selected: sortMethod === \"alphabet\" && sortOrder === \"asc\",\n        onSelect: () => {\n          setFeedListSortMethod(\"alphabet\")\n          setFeedListSortOrder(\"asc\")\n        },\n      },\n      {\n        title: \"Descending\",\n        selected: sortMethod === \"alphabet\" && sortOrder === \"desc\",\n        onSelect: () => {\n          setFeedListSortMethod(\"alphabet\")\n          setFeedListSortOrder(\"desc\")\n        },\n      },\n    ]\n\n    const countOrderActions = [\n      {\n        title: \"Ascending\",\n        selected: sortMethod === \"count\" && sortOrder === \"asc\",\n        onSelect: () => {\n          setFeedListSortMethod(\"count\")\n          setFeedListSortOrder(\"asc\")\n        },\n      },\n      {\n        title: \"Descending\",\n        selected: sortMethod === \"count\" && sortOrder === \"desc\",\n        onSelect: () => {\n          setFeedListSortMethod(\"count\")\n          setFeedListSortOrder(\"desc\")\n        },\n      },\n    ]\n\n    return [\n      {\n        title: \"Sort by Alphabet\",\n        actions: alphabetOrderActions,\n        selected: sortMethod === \"alphabet\",\n      },\n      {\n        title: \"Sort by Unread Count\",\n        actions: countOrderActions,\n        selected: sortMethod === \"count\",\n      },\n    ]\n  }, [sortMethod, sortOrder])\n\n  return (\n    <DropdownMenu.Root>\n      <DropdownMenu.Trigger asChild>\n        <Pressable className=\"size-5 rounded-full\">\n          <ListExpansionCuteReIcon width={20} height={20} />\n        </Pressable>\n      </DropdownMenu.Trigger>\n\n      <DropdownMenu.Content>\n        {actions.map((action) => {\n          const subActions = action.actions\n          return (\n            <DropdownMenu.Sub key={`Sub/${action.title}`}>\n              <DropdownMenu.SubTrigger key={`SubTrigger/${action.title}`}>\n                <DropdownMenu.ItemTitle>{action.title}</DropdownMenu.ItemTitle>\n              </DropdownMenu.SubTrigger>\n\n              <DropdownMenu.SubContent>\n                {subActions.map((subAction) => {\n                  const isSelected = subAction.selected\n                  return (\n                    <DropdownMenu.CheckboxItem\n                      key={`SubContent/${action.title}/${subAction.title}`}\n                      value={isSelected}\n                      onSelect={subAction.onSelect}\n                    >\n                      <DropdownMenu.ItemTitle>{subAction.title}</DropdownMenu.ItemTitle>\n                    </DropdownMenu.CheckboxItem>\n                  )\n                })}\n              </DropdownMenu.SubContent>\n            </DropdownMenu.Sub>\n          )\n        })}\n      </DropdownMenu.Content>\n    </DropdownMenu.Root>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/items/InboxItem.tsx",
    "content": "import { useSubscriptionById } from \"@follow/store/subscription/hooks\"\nimport { getInboxStoreId } from \"@follow/store/subscription/utils\"\nimport { useUnreadById } from \"@follow/store/unread/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { useColorScheme } from \"nativewind\"\nimport { memo } from \"react\"\nimport { View } from \"react-native\"\nimport Animated, { FadeOutUp } from \"react-native-reanimated\"\n\nimport { GROUPED_ICON_TEXT_GAP, GROUPED_LIST_MARGIN } from \"@/src/components/ui/grouped/constants\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { InboxCuteFiIcon } from \"@/src/icons/inbox_cute_fi\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { selectFeed } from \"@/src/modules/screen/atoms\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { InboxContextMenu } from \"../../context-menu/inbox\"\nimport type { SubscriptionItemBaseProps } from \"./types\"\nimport { UnreadCount } from \"./UnreadCount\"\n\nexport const InboxItem = memo(({ id, isFirst, isLast }: SubscriptionItemBaseProps) => {\n  const subscription = useSubscriptionById(getInboxStoreId(id))\n  const unreadCount = useUnreadById(id)\n  const { colorScheme } = useColorScheme()\n  const navigation = useNavigation()\n  const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n  if (!subscription) return null\n  return (\n    <Animated.View\n      exiting={FadeOutUp}\n      style={[\n        readableContainerStyle,\n        {\n          marginHorizontal: GROUPED_LIST_MARGIN,\n        },\n      ]}\n      className={cn(\"overflow-hidden\", {\n        \"rounded-t-[10px]\": isFirst,\n        \"rounded-b-[10px]\": isLast,\n      })}\n    >\n      <InboxContextMenu inboxId={id}>\n        <ItemPressable\n          itemStyle={ItemPressableStyle.Grouped}\n          className=\"h-12 flex-row items-center px-3\"\n          onPress={() => {\n            selectFeed({\n              type: \"inbox\",\n              inboxId: id,\n            })\n            navigation.pushControllerView(FeedScreen, {\n              feedId: id,\n            })\n          }}\n        >\n          <View className=\"ml-0.5 overflow-hidden rounded\">\n            <InboxCuteFiIcon\n              height={20}\n              width={20}\n              color={colorScheme === \"dark\" ? \"white\" : \"black\"}\n            />\n          </View>\n\n          <Text\n            className=\"text-sm font-medium text-label\"\n            style={{\n              marginLeft: GROUPED_ICON_TEXT_GAP,\n            }}\n          >\n            {subscription.title}\n          </Text>\n          <UnreadCount unread={unreadCount} className=\"ml-auto\" />\n        </ItemPressable>\n      </InboxContextMenu>\n    </Animated.View>\n  )\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx",
    "content": "import { useListById } from \"@follow/store/list/hooks\"\nimport { useSubscriptionById } from \"@follow/store/subscription/hooks\"\nimport { useUnreadByListId } from \"@follow/store/unread/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { memo } from \"react\"\nimport { StyleSheet, View } from \"react-native\"\nimport Animated, { FadeOutUp } from \"react-native-reanimated\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { OouiUserAnonymous } from \"@/src/components/icons/OouiUserAnonymous\"\nimport { GROUPED_ICON_TEXT_GAP, GROUPED_LIST_MARGIN } from \"@/src/components/ui/grouped/constants\"\nimport { FallbackIcon } from \"@/src/components/ui/icon/fallback-icon\"\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { SubscriptionListItemContextMenu } from \"../../context-menu/lists\"\nimport { selectFeed } from \"../../screen/atoms\"\nimport { ItemSeparator } from \"../ItemSeparator\"\nimport type { SubscriptionItemBaseProps } from \"./types\"\nimport { UnreadCount } from \"./UnreadCount\"\n\ninterface ListSubscriptionItemProps extends SubscriptionItemBaseProps {}\nexport const ListSubscriptionItem = memo(({ id, isFirst, isLast }: ListSubscriptionItemProps) => {\n  const colorLabel = useColor(\"label\")\n  const list = useListById(id)\n  const subscription = useSubscriptionById(id)\n  const unreadCount = useUnreadByListId(id)\n  const navigation = useNavigation()\n  const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n  if (!list) return null\n  return (\n    <>\n      <Animated.View\n        exiting={FadeOutUp}\n        style={[styles.container, readableContainerStyle]}\n        className={cn(\"overflow-hidden\", {\n          \"rounded-t-[10px]\": isFirst,\n          \"rounded-b-[10px]\": isLast,\n        })}\n      >\n        <SubscriptionListItemContextMenu id={id}>\n          <ItemPressable\n            itemStyle={ItemPressableStyle.Grouped}\n            className=\"h-12 flex-row items-center px-3\"\n            testID={`subscription-list-${id}`}\n            onPress={() => {\n              selectFeed({\n                type: \"list\",\n                listId: id,\n              })\n              navigation.pushControllerView(FeedScreen, {\n                feedId: id,\n              })\n            }}\n          >\n            <View className=\"ml-1 overflow-hidden rounded\">\n              {!!list.image && (\n                <Image\n                  proxy={{\n                    width: 20,\n                    height: 20,\n                  }}\n                  style={styles.listImage}\n                  source={{\n                    uri: list.image,\n                  }}\n                />\n              )}\n              {!list.image && <FallbackIcon title={list.title} size={20} />}\n            </View>\n\n            <View className=\"flex-1 flex-row items-center gap-2\">\n              <Text\n                numberOfLines={1}\n                className=\"shrink text-sm font-medium text-text\"\n                style={styles.titleSpacing}\n              >\n                {subscription?.title || list.title}\n              </Text>\n\n              {!!subscription?.isPrivate && (\n                <OouiUserAnonymous color={colorLabel} height={18} width={18} />\n              )}\n            </View>\n\n            <UnreadCount unread={unreadCount} className=\"ml-auto\" />\n          </ItemPressable>\n        </SubscriptionListItemContextMenu>\n      </Animated.View>\n      {!isLast && <ItemSeparator />}\n    </>\n  )\n})\n\nconst styles = StyleSheet.create({\n  container: {\n    marginHorizontal: GROUPED_LIST_MARGIN,\n  },\n  listImage: {\n    height: 20,\n    width: 20,\n  },\n  titleSpacing: {\n    marginLeft: GROUPED_ICON_TEXT_GAP,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx",
    "content": "import { useFeedById, usePrefetchFeed } from \"@follow/store/feed/hooks\"\nimport { useSubscriptionById } from \"@follow/store/subscription/hooks\"\nimport { useUnreadById } from \"@follow/store/unread/hooks\"\nimport { cn } from \"@follow/utils\"\nimport { memo, use } from \"react\"\nimport { View } from \"react-native\"\nimport Animated, { FadeOutUp } from \"react-native-reanimated\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { OouiUserAnonymous } from \"@/src/components/icons/OouiUserAnonymous\"\nimport { GROUPED_ICON_TEXT_GAP, GROUPED_LIST_MARGIN } from \"@/src/components/ui/grouped/constants\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { WifiOffCuteReIcon } from \"@/src/icons/wifi_off_cute_re\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\nimport { selectFeed } from \"@/src/modules/screen/atoms\"\nimport { FeedScreen } from \"@/src/screens/(stack)/feeds/[feedId]/FeedScreen\"\n\nimport { SubscriptionFeedItemContextMenu } from \"../../context-menu/feeds\"\nimport { GroupedContext } from \"../ctx\"\nimport { ItemSeparator, SecondaryItemSeparator } from \"../ItemSeparator\"\nimport type { SubscriptionItemBaseProps } from \"./types\"\nimport { UnreadCount } from \"./UnreadCount\"\n\nexport const SubscriptionItem = memo(\n  ({ id, isFirst, isLast, className }: SubscriptionItemBaseProps) => {\n    const red = useColor(\"red\")\n    const colorLabel = useColor(\"label\")\n    const subscription = useSubscriptionById(id)\n    const unreadCount = useUnreadById(id)\n    const feed = useFeedById(id)!\n    const inGrouped = !!use(GroupedContext)\n    const { isLoading } = usePrefetchFeed(id, {\n      enabled: !subscription && !feed,\n    })\n    const navigation = useNavigation()\n    const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN)\n    const feedTestID = feed?.url\n      ? `subscription-feed-url-${feed.url\n          .replaceAll(/[^a-z0-9]+/gi, \"-\")\n          .replaceAll(/^-+|-+$/g, \"\")\n          .toLowerCase()}`\n      : `subscription-feed-${id}`\n    if (isLoading) {\n      return (\n        <View className=\"mt-24 flex-1 flex-row items-start justify-center\">\n          <PlatformActivityIndicator />\n        </View>\n      )\n    }\n    if (!subscription && !feed) return null\n    return (\n      <>\n        <Animated.View\n          exiting={FadeOutUp}\n          style={[\n            readableContainerStyle,\n            {\n              marginHorizontal: GROUPED_LIST_MARGIN,\n            },\n          ]}\n          className={cn(\"overflow-hidden\", {\n            \"rounded-t-[10px]\": isFirst,\n            \"rounded-b-[10px]\": isLast,\n          })}\n        >\n          <SubscriptionFeedItemContextMenu id={id}>\n            <ItemPressable\n              itemStyle={ItemPressableStyle.Grouped}\n              className={cn(\n                \"flex h-12 flex-row items-center\",\n                inGrouped ? \"pl-8 pr-4\" : \"px-4\",\n                className,\n              )}\n              testID={feedTestID}\n              onPress={() => {\n                selectFeed({\n                  type: \"feed\",\n                  feedId: id,\n                })\n                navigation.pushControllerView(FeedScreen, {\n                  feedId: id,\n                })\n              }}\n            >\n              <View className=\"size-5 items-center justify-center overflow-hidden rounded border border-transparent dark:border-tertiary-system-background dark:bg-[#222]\">\n                <FeedIcon feed={feed} />\n              </View>\n              <View className=\"flex-1 flex-row items-center gap-2\">\n                <Text\n                  numberOfLines={1}\n                  className={cn(\"shrink text-sm font-medium text-text\", feed.errorAt && \"text-red\")}\n                  style={{\n                    marginLeft: GROUPED_ICON_TEXT_GAP,\n                  }}\n                >\n                  {subscription?.title || feed.title}\n                </Text>\n                {!!feed.errorAt && <WifiOffCuteReIcon color={red} height={18} width={18} />}\n                {!!subscription?.isPrivate && (\n                  <OouiUserAnonymous color={colorLabel} height={18} width={18} />\n                )}\n              </View>\n              <UnreadCount unread={unreadCount} className=\"ml-auto\" />\n            </ItemPressable>\n          </SubscriptionFeedItemContextMenu>\n        </Animated.View>\n        {!isLast && (inGrouped ? <SecondaryItemSeparator /> : <ItemSeparator />)}\n      </>\n    )\n  },\n)\nSubscriptionItem.displayName = \"SubscriptionItem\"\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/items/UnreadCount.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { Text, View } from \"react-native\"\nimport type { AnimatedProps } from \"react-native-reanimated\"\n\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\n\nexport function UnreadCount({\n  unread,\n  className,\n  textClassName,\n  dotClassName,\n  max = Infinity,\n  ...rest\n}: {\n  unread?: number\n  className?: string\n  textClassName?: string\n  dotClassName?: string\n  max?: number\n} & AnimatedProps<object>) {\n  const showUnreadCount = useUISettingKey(\"showUnreadCountViewAndSubscriptionMobile\")\n  if (!unread) return null\n  return showUnreadCount ? (\n    <Text\n      allowFontScaling={false}\n      numberOfLines={1}\n      ellipsizeMode=\"clip\"\n      className={cn(\"text-[12px] text-tertiary-label\", className, textClassName)}\n      {...rest}\n    >\n      {unread > max ? `${max}+` : unread}\n    </Text>\n  ) : (\n    <View\n      className={cn(\"size-1 rounded-full bg-tertiary-label\", className, dotClassName)}\n      {...rest}\n    />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/modules/subscription/items/types.tsx",
    "content": "export type SubscriptionItemBaseProps = {\n  isFirst: boolean\n  isLast: boolean\n  id: string\n  className?: string\n}\n"
  },
  {
    "path": "apps/mobile/src/polyfill/index.ts",
    "content": "// import \"core-js/proposals/promise-with-resolvers\";\nimport \"./promise-with-resolvers\"\n"
  },
  {
    "path": "apps/mobile/src/polyfill/promise-with-resolvers.ts",
    "content": "// this polyfill can be removed once we drop support for Firefox v121 (after\n// June 24 2025) Chrome v119 (after November 14, 2025).\n\nif (Promise.withResolvers === undefined) {\n  Promise.withResolvers = function withResolvers<T>() {\n    let resolve!: (value: T | PromiseLike<T>) => void\n    let reject!: (reason?: unknown) => void\n    const promise = new this<T>((res, rej) => {\n      resolve = res\n      reject = rej\n    })\n    return { promise, resolve, reject }\n  }\n}\n\nexport {}\n"
  },
  {
    "path": "apps/mobile/src/providers/AppleIAPProvider.tsx",
    "content": "import { whoamiQueryKey } from \"@follow/store/user/hooks\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport { requireNativeModule } from \"expo\"\nimport type { ProductPurchase, SubscriptionProduct } from \"expo-iap\"\nimport { getTransactionJws, showManageSubscriptions, useIAP } from \"expo-iap\"\nimport { openURL } from \"expo-linking\"\nimport type { PropsWithChildren } from \"react\"\nimport { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Platform } from \"react-native\"\n\nimport { useServerConfigs } from \"@/src/atoms/server-configs\"\nimport { followClient } from \"@/src/lib/api-client\"\nimport { proxyEnv } from \"@/src/lib/proxy-env\"\nimport { queryClient } from \"@/src/lib/query-client\"\nimport { toast } from \"@/src/lib/toast\"\n\nconst APPLE_SUBSCRIPTION_MANAGEMENT_URL = \"https://apps.apple.com/account/subscriptions\"\nconst billingSubscriptionQueryKey = [\"billingSubscription\"]\n\ntype BillingSubscriptionResponse = {\n  source: \"stripe\" | \"apple\" | null\n  plan: string | null\n  status: string | null\n  productId: string | null\n  periodEnd: string | null\n  trialEnd: string | null\n  canManage: boolean\n}\n\ntype AppleIAPContextValue = {\n  connected: boolean\n  subscriptions: SubscriptionProduct[]\n  isPurchasing: boolean\n  isProcessingPurchase: boolean\n  isRestoring: boolean\n  loadSubscriptions: (skus: string[]) => Promise<void>\n  requestSubscriptionPurchase: (input: {\n    sku: string\n    appAccountToken?: string | null\n  }) => Promise<void>\n  restoreSubscriptionPurchases: () => Promise<void>\n  openSubscriptionManagement: () => Promise<void>\n}\n\nconst AppleIAPContext = createContext<AppleIAPContextValue | null>(null)\n\nconst noopAsync = async () => {}\n\nexport const AppleIAPProvider = ({ children }: PropsWithChildren) => {\n  const { t } = useTranslation(\"settings\")\n  const serverConfigs = useServerConfigs()\n  const knownSubscriptionIds = useMemo(() => {\n    const ids = new Set<string>()\n    for (const plan of serverConfigs?.PAYMENT_PLAN_LIST ?? []) {\n      if (plan.appleProductIdentifier) {\n        ids.add(plan.appleProductIdentifier)\n      }\n      if (plan.appleProductIdentifierAnnual) {\n        ids.add(plan.appleProductIdentifierAnnual)\n      }\n    }\n    return ids\n  }, [serverConfigs?.PAYMENT_PLAN_LIST])\n\n  const availablePurchasesRef = useRef<ProductPurchase[]>([])\n  const processedTransactionsRef = useRef(new Set<string>())\n  const [isPurchasing, setIsPurchasing] = useState(false)\n  const [isProcessingPurchase, setIsProcessingPurchase] = useState(false)\n  const [isRestoring, setIsRestoring] = useState(false)\n\n  const storeKitTestHelper = useMemo(() => {\n    if (Platform.OS !== \"ios\" || !proxyEnv.API_URL.startsWith(\"http://localhost\")) {\n      return null\n    }\n\n    try {\n      return requireNativeModule(\"StoreKitTestHelper\") as {\n        prepareLocalSubscriptions?: () => Promise<unknown>\n        buyProduct?: (productId: string) => Promise<{ jwsRepresentation?: string }>\n      }\n    } catch {\n      return null\n    }\n  }, [])\n\n  useEffect(() => {\n    void storeKitTestHelper?.prepareLocalSubscriptions?.().catch(() => {})\n  }, [storeKitTestHelper])\n\n  const {\n    connected,\n    subscriptions,\n    availablePurchases,\n    currentPurchase,\n    currentPurchaseError,\n    finishTransaction,\n    getSubscriptions,\n    requestPurchase,\n    restorePurchases,\n    validateReceipt,\n  } = useIAP({\n    shouldAutoSyncPurchases: false,\n  })\n\n  useEffect(() => {\n    availablePurchasesRef.current = availablePurchases\n  }, [availablePurchases])\n\n  const refreshBillingState = useCallback(async () => {\n    await Promise.allSettled([\n      userSyncService.whoami(),\n      queryClient.invalidateQueries({ queryKey: whoamiQueryKey }),\n      queryClient.invalidateQueries({ queryKey: billingSubscriptionQueryKey }),\n    ])\n  }, [])\n\n  const verifyPurchase = useCallback(\n    async (purchase: ProductPurchase) => {\n      const productId = purchase.id\n      const signedTransactionInfoFromPurchase =\n        \"jwsRepresentationIos\" in purchase ? purchase.jwsRepresentationIos : undefined\n      const jwsRepresentation =\n        signedTransactionInfoFromPurchase ||\n        (await getTransactionJws(productId).catch(() => null)) ||\n        (await validateReceipt(productId)\n          .then((result) => result?.jwsRepresentation as string | undefined)\n          .catch(() => {}))\n\n      if (!jwsRepresentation) {\n        throw new Error(t(\"subscription.actions.upgrade_error\"))\n      }\n\n      const response = await followClient.request<{\n        code: number\n        data: BillingSubscriptionResponse\n      }>(\"/billing/apple/verify\", {\n        method: \"POST\",\n        body: {\n          signedTransactionInfo: jwsRepresentation,\n        },\n      })\n\n      if (response.code !== 0) {\n        throw new Error(\"Failed to verify Apple subscription\")\n      }\n    },\n    [t, validateReceipt],\n  )\n\n  useEffect(() => {\n    if (\n      Platform.OS !== \"ios\" ||\n      !currentPurchase ||\n      !knownSubscriptionIds.has(currentPurchase.id)\n    ) {\n      return\n    }\n\n    const transactionKey =\n      currentPurchase.transactionId ||\n      (\"originalTransactionIdentifierIos\" in currentPurchase\n        ? currentPurchase.originalTransactionIdentifierIos\n        : undefined) ||\n      `${currentPurchase.id}:${currentPurchase.transactionDate}`\n\n    if (processedTransactionsRef.current.has(transactionKey)) {\n      return\n    }\n\n    processedTransactionsRef.current.add(transactionKey)\n    setIsProcessingPurchase(true)\n    setIsPurchasing(false)\n\n    void (async () => {\n      try {\n        await verifyPurchase(currentPurchase)\n        await finishTransaction({ purchase: currentPurchase })\n        await refreshBillingState()\n      } catch (error) {\n        processedTransactionsRef.current.delete(transactionKey)\n        toast.error(\n          error instanceof Error ? error.message : t(\"subscription.actions.upgrade_error\"),\n        )\n      } finally {\n        setIsProcessingPurchase(false)\n      }\n    })()\n  }, [\n    currentPurchase,\n    finishTransaction,\n    knownSubscriptionIds,\n    refreshBillingState,\n    t,\n    verifyPurchase,\n  ])\n\n  useEffect(() => {\n    if (!currentPurchaseError) {\n      return\n    }\n\n    setIsPurchasing(false)\n    setIsProcessingPurchase(false)\n\n    if (currentPurchaseError.code === \"E_USER_CANCELLED\") {\n      return\n    }\n\n    toast.error(currentPurchaseError.message || t(\"subscription.actions.upgrade_error\"))\n  }, [currentPurchaseError, t])\n\n  const loadSubscriptions = useCallback(\n    async (skus: string[]) => {\n      if (Platform.OS !== \"ios\" || skus.length === 0) {\n        return\n      }\n\n      await getSubscriptions(skus)\n    },\n    [getSubscriptions],\n  )\n\n  const requestSubscriptionPurchase = useCallback(\n    async ({ sku, appAccountToken }: { sku: string; appAccountToken?: string | null }) => {\n      if (Platform.OS !== \"ios\") {\n        return\n      }\n\n      setIsPurchasing(true)\n      try {\n        if (storeKitTestHelper?.buyProduct) {\n          setIsProcessingPurchase(true)\n          try {\n            const result = await storeKitTestHelper.buyProduct(sku)\n            await followClient.request<{ code: number; data: BillingSubscriptionResponse }>(\n              \"/billing/apple/verify\",\n              {\n                method: \"POST\",\n                body: result?.jwsRepresentation\n                  ? {\n                      signedTransactionInfo: result.jwsRepresentation,\n                    }\n                  : {\n                      productId: sku,\n                    },\n              },\n            )\n            await refreshBillingState()\n            return\n          } finally {\n            setIsProcessingPurchase(false)\n            setIsPurchasing(false)\n          }\n        }\n\n        await requestPurchase({\n          type: \"subs\",\n          request: {\n            sku,\n            appAccountToken: appAccountToken ?? undefined,\n            andDangerouslyFinishTransactionAutomaticallyIOS: false,\n          },\n        })\n      } catch (error) {\n        setIsPurchasing(false)\n        throw error\n      }\n    },\n    [refreshBillingState, requestPurchase, storeKitTestHelper],\n  )\n\n  const restoreSubscriptionPurchases = useCallback(async () => {\n    if (Platform.OS !== \"ios\") {\n      return\n    }\n\n    setIsRestoring(true)\n    try {\n      await restorePurchases()\n      await new Promise((resolve) => setTimeout(resolve, 300))\n\n      const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>\n        knownSubscriptionIds.has(purchase.id),\n      )\n\n      if (restoredPurchases.length === 0) {\n        throw new Error(t(\"subscription.actions.restore_not_found\"))\n      }\n\n      const sortedPurchases = [...restoredPurchases].sort((left, right) => {\n        const leftExpires =\n          (\"expirationDateIos\" in left ? left.expirationDateIos : undefined) ??\n          left.transactionDate ??\n          0\n        const rightExpires =\n          (\"expirationDateIos\" in right ? right.expirationDateIos : undefined) ??\n          right.transactionDate ??\n          0\n        return rightExpires - leftExpires\n      })\n\n      let restored = false\n      for (const purchase of sortedPurchases) {\n        try {\n          await verifyPurchase(purchase)\n          restored = true\n          break\n        } catch {\n          continue\n        }\n      }\n\n      if (!restored) {\n        throw new Error(t(\"subscription.actions.restore_error\"))\n      }\n\n      await refreshBillingState()\n      toast.success(t(\"subscription.actions.restore_success\"))\n    } catch (error) {\n      toast.error(error instanceof Error ? error.message : t(\"subscription.actions.restore_error\"))\n    } finally {\n      setIsRestoring(false)\n    }\n  }, [knownSubscriptionIds, refreshBillingState, restorePurchases, t, verifyPurchase])\n\n  const openSubscriptionManagement = useCallback(async () => {\n    if (Platform.OS !== \"ios\") {\n      await noopAsync()\n      return\n    }\n\n    try {\n      await showManageSubscriptions()\n    } catch {\n      await openURL(APPLE_SUBSCRIPTION_MANAGEMENT_URL)\n    }\n  }, [])\n\n  const contextValue = useMemo<AppleIAPContextValue>(\n    () => ({\n      connected,\n      subscriptions,\n      isPurchasing,\n      isProcessingPurchase,\n      isRestoring,\n      loadSubscriptions,\n      requestSubscriptionPurchase,\n      restoreSubscriptionPurchases,\n      openSubscriptionManagement,\n    }),\n    [\n      connected,\n      subscriptions,\n      isPurchasing,\n      isProcessingPurchase,\n      isRestoring,\n      loadSubscriptions,\n      requestSubscriptionPurchase,\n      restoreSubscriptionPurchases,\n      openSubscriptionManagement,\n    ],\n  )\n\n  return <AppleIAPContext value={contextValue}>{children}</AppleIAPContext>\n}\n\nexport const useAppleIAP = () => {\n  const context = use(AppleIAPContext)\n  if (!context) {\n    throw new Error(\"useAppleIAP must be used within AppleIAPProvider\")\n  }\n  return context\n}\n"
  },
  {
    "path": "apps/mobile/src/providers/FontScalingProvider.tsx",
    "content": "import { vars } from \"nativewind\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { useMemo } from \"react\"\nimport type { StyleProp, ViewStyle } from \"react-native\"\nimport { View } from \"react-native\"\n\nimport { useUISettingKey, useUISettingSelector } from \"../atoms/settings/ui\"\nimport { typography } from \"../spec/typography\"\n// https://grok.com/share/bGVnYWN5_0c5669bf-6abb-4f22-bc1a-9cf0a27254c5\nconst createFontScalingInjectStyles = (scale = 1) => {\n  const cssVars = Object.entries(typography).reduce(\n    (acc, [key, [fontSize, lineHeight]]) => {\n      const kebabKey = key.replaceAll(/([A-Z])/g, \"-$1\").toLowerCase()\n      const nextFontSize = Math.round(fontSize * scale)\n      const originalRatio = lineHeight / fontSize\n      // Ensure line height is at least 1.2x font size for small fonts, 1.0x for large fonts\n      const minRatio = fontSize >= 36 ? 1 : 1.2\n      const scaledLineHeight = Math.round(\n        Math.max(nextFontSize * Math.max(originalRatio, minRatio), lineHeight * scale),\n      )\n\n      acc[`--text-${kebabKey}`] = nextFontSize\n      acc[`--text-${kebabKey}-line-height`] = scaledLineHeight\n      return acc\n    },\n    {} as Record<string, number>,\n  )\n\n  return vars(cssVars) as StyleProp<ViewStyle>\n}\nexport const FontScalingProvider: FC<PropsWithChildren> = ({ children }) => {\n  const fontScale = useUISettingSelector((state) => state.fontScale)\n  const systemFontScaling = useUISettingKey(\"useSystemFontScaling\")\n\n  const styles = useMemo(\n    () => createFontScalingInjectStyles(systemFontScaling ? 1 : fontScale),\n    [fontScale, systemFontScaling],\n  )\n\n  return (\n    <View className=\"flex-1\" style={styles}>\n      {children}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/providers/ServerConfigsLoader.tsx",
    "content": "import { useQuery } from \"@tanstack/react-query\"\nimport { useEffect } from \"react\"\n\nimport { setServerConfigs } from \"@/src/atoms/server-configs\"\n\nimport { followClient } from \"../lib/api-client\"\n\nexport const ServerConfigsLoader = () => {\n  const serverConfigs = useServerConfigsQuery()\n\n  useEffect(() => {\n    if (!serverConfigs) return\n    setServerConfigs(serverConfigs)\n  }, [serverConfigs])\n\n  return null\n}\n\nconst useServerConfigsQuery = () => {\n  const { data } = useQuery({\n    queryKey: [\"server-configs\"],\n    queryFn: () => followClient.api.status.getConfigs(),\n  })\n  return data?.data\n}\n"
  },
  {
    "path": "apps/mobile/src/providers/index.tsx",
    "content": "import { ActionSheetProvider } from \"@expo/react-native-action-sheet\"\nimport { sqlite } from \"@follow/database/db\"\nimport { jotaiStore } from \"@follow/utils\"\nimport { PortalProvider } from \"@gorhom/portal\"\nimport { QueryClientProvider } from \"@tanstack/react-query\"\nimport { useDrizzleStudio } from \"expo-drizzle-studio-plugin\"\nimport { ComposeContextProvider } from \"foxact/compose-context-provider\"\nimport { Provider } from \"jotai\"\nimport type { ReactNode } from \"react\"\nimport { View } from \"react-native\"\nimport { GestureHandlerRootView } from \"react-native-gesture-handler\"\nimport { KeyboardProvider } from \"react-native-keyboard-controller\"\nimport { SafeAreaProvider } from \"react-native-safe-area-context\"\nimport { SheetProvider } from \"react-native-sheet-transitions\"\nimport { useCurrentColorsVariants } from \"react-native-uikit-colors\"\n\nimport { ErrorBoundary } from \"../components/common/ErrorBoundary\"\nimport { GlobalErrorScreen } from \"../components/errors/GlobalErrorScreen\"\nimport { LightboxStateProvider } from \"../components/ui/lightbox/lightboxState\"\nimport { queryClient } from \"../lib/query-client\"\nimport { TimelineSelectorDragProgressProvider } from \"../modules/screen/atoms\"\nimport { AppleIAPProvider } from \"./AppleIAPProvider\"\nimport { FontScalingProvider } from \"./FontScalingProvider\"\nimport { MigrationProvider } from \"./migration\"\nimport { ServerConfigsLoader } from \"./ServerConfigsLoader\"\n\n/* eslint-disable @eslint-react/no-missing-key */\nconst contexts = [\n  <MigrationProvider children={null} />,\n  <Provider store={jotaiStore} />,\n  <ErrorBoundary fallbackRender={GlobalErrorScreen} children={null} />,\n  <KeyboardProvider children={null} />,\n  <QueryClientProvider client={queryClient} />,\n  <AppleIAPProvider children={null} />,\n  <GestureHandlerRootView />,\n  <SheetProvider children={null} />,\n  <ActionSheetProvider children={null} />,\n  <LightboxStateProvider children={null} />,\n  <TimelineSelectorDragProgressProvider children={null} />,\n  <PortalProvider children={null} />,\n  <SafeAreaProvider />,\n  <FontScalingProvider />,\n]\n\nexport const RootProviders = ({ children }: { children: ReactNode }) => {\n  useDrizzleStudio(sqlite as any)\n\n  const currentThemeColors = useCurrentColorsVariants()\n\n  return (\n    <View style={[flexStyle, currentThemeColors]}>\n      {/* Learn more https://foxact.skk.moe/compose-context-provider/ */}\n      <ComposeContextProvider contexts={contexts}>\n        {children}\n        <ServerConfigsLoader />\n      </ComposeContextProvider>\n    </View>\n  )\n}\n\nconst flexStyle = { flex: 1 }\n"
  },
  {
    "path": "apps/mobile/src/providers/migration.tsx",
    "content": "import { deleteAsync } from \"expo-file-system\"\nimport type { ReactNode } from \"react\"\nimport { Button, View } from \"react-native\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\n\nimport { PlatformActivityIndicator } from \"../components/ui/loading/PlatformActivityIndicator\"\nimport { getDbPath } from \"../database\"\nimport { BugCuteReIcon } from \"../icons/bug_cute_re\"\nimport { useDatabaseMigration } from \"../initialize/migration\"\n\nexport const MigrationProvider = ({ children }: { children: ReactNode }) => {\n  const { success, error } = useDatabaseMigration()\n  if (error) {\n    return (\n      <View className=\"flex-1 items-center justify-center\">\n        <BugCuteReIcon color=\"#ff0000\" height={48} width={48} />\n        <Text className=\"mt-5 text-text\">Oops, something went wrong...</Text>\n        <View className=\"mt-2 rounded-md bg-system-background p-2\">\n          <Text className=\"font-mono text-text\">{error.message}</Text>\n        </View>\n\n        <Button\n          title=\"Reset Database\"\n          onPress={async () => {\n            const dbPath = getDbPath()\n            await deleteAsync(dbPath)\n            // Reload the app\n            await expo.reloadAppAsync(\"Clear Sqlite Data\")\n          }}\n        />\n      </View>\n    )\n  }\n  if (!success) {\n    return (\n      <View className=\"flex-1 items-center justify-center\">\n        <PlatformActivityIndicator />\n        <Text className=\"mt-4 text-label\">Database Migrations...</Text>\n      </View>\n    )\n  }\n  return children\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(headless)/(debug)/markdown.tsx",
    "content": "import { useMemo } from \"react\"\nimport { ScrollView } from \"react-native\"\n\nimport { renderMarkdown } from \"@/src/lib/markdown\"\n\nexport const MarkdownScreen = () => {\n  const element = useMemo(\n    () =>\n      renderMarkdown(\n        '---\\n__Advertisement :)__\\n\\n- __[pica](https://nodeca.github.io/pica/demo/)__ - high quality and fast image\\n  resize in browser.\\n- __[babelfish](https://github.com/nodeca/babelfish/)__ - developer friendly\\n  i18n with plurals support and easy syntax.\\n\\nYou will like those projects!\\n\\n---\\n\\n# h1 Heading 8-)\\n## h2 Heading\\n### h3 Heading\\n#### h4 Heading\\n##### h5 Heading\\n###### h6 Heading\\n\\n\\n## Horizontal Rules\\n\\n___\\n\\n---\\n\\n***\\n\\n\\n## Typographic replacements\\n\\nEnable typographer option to see result.\\n\\n(c) (C) (r) (R) (tm) (TM) (p) (P) +-\\n\\ntest.. test... test..... test?..... test!....\\n\\n!!!!!! ???? ,,  -- ---\\n\\n\"Smartypants, double quotes\" and \\'single quotes\\'\\n\\n\\n## Emphasis\\n\\n**This is bold text**\\n\\n__This is bold text__\\n\\n*This is italic text*\\n\\n_This is italic text_\\n\\n~~Strikethrough~~\\n\\n\\n## Blockquotes\\n\\n\\n> Blockquotes can also be nested...\\n>> ...by using additional greater-than signs right next to each other...\\n> > > ...or with spaces between arrows.\\n\\n\\n## Lists\\n\\nUnordered\\n\\n+ Create a list by starting a line with `+`, `-`, or `*`\\n+ Sub-lists are made by indenting 2 spaces:\\n  - Marker character change forces new list start:\\n    * Ac tristique libero volutpat at\\n    + Facilisis in pretium nisl aliquet\\n    - Nulla volutpat aliquam velit\\n+ Very easy!\\n\\nOrdered\\n\\n1. Lorem ipsum dolor sit amet\\n2. Consectetur adipiscing elit\\n3. Integer molestie lorem at massa\\n\\n\\n1. You can use sequential numbers...\\n1. ...or keep all the numbers as `1.`\\n\\nStart numbering with offset:\\n\\n57. foo\\n1. bar\\n\\n\\n## Code\\n\\nInline `code`\\n\\nIndented code\\n\\n    // Some comments\\n    line 1 of code\\n    line 2 of code\\n    line 3 of code\\n\\n\\nBlock code \"fences\"\\n\\n```\\nSample text here...\\n```\\n\\nSyntax highlighting\\n\\n``` js\\nvar foo = function (bar) {\\n  return bar++;\\n};\\n\\nconsole.log(foo(5));\\n```\\n\\n## Tables\\n\\n| Option | Description |\\n| ------ | ----------- |\\n| data   | path to data files to supply the data that will be passed into templates. |\\n| engine | engine to be used for processing templates. Handlebars is the default. |\\n| ext    | extension to be used for dest files. |\\n\\nRight aligned columns\\n\\n| Option | Description |\\n| ------:| -----------:|\\n| data   | path to data files to supply the data that will be passed into templates. |\\n| engine | engine to be used for processing templates. Handlebars is the default. |\\n| ext    | extension to be used for dest files. |\\n\\n\\n## Links\\n\\n[link text](http://dev.nodeca.com)\\n\\n[link with title](http://nodeca.github.io/pica/demo/ \"title text!\")\\n\\nAutoconverted link https://github.com/nodeca/pica (enable linkify to see)\\n\\n\\n## Images\\n\\n![Minion](https://octodex.github.com/images/minion.png)\\n![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg \"The Stormtroopocat\")\\n\\nLike links, Images also have a footnote style syntax\\n\\n![Alt text][id]\\n\\nWith a reference later in the document defining the URL location:\\n\\n[id]: https://octodex.github.com/images/dojocat.jpg  \"The Dojocat\"\\n\\n\\n## Plugins\\n\\nThe killer feature of `markdown-it` is very effective support of\\n[syntax plugins](https://www.npmjs.org/browse/keyword/markdown-it-plugin).\\n\\n\\n### [Emojies](https://github.com/markdown-it/markdown-it-emoji)\\n\\n> Classic markup: :wink: :cry: :laughing: :yum:\\n>\\n> Shortcuts (emoticons): :-) :-( 8-) ;)\\n\\nsee [how to change output](https://github.com/markdown-it/markdown-it-emoji#change-output) with twemoji.\\n\\n\\n### [Subscript](https://github.com/markdown-it/markdown-it-sub) / [Superscript](https://github.com/markdown-it/markdown-it-sup)\\n\\n- 19^th^\\n- H~2~O\\n\\n\\n### [\\\\<ins>](https://github.com/markdown-it/markdown-it-ins)\\n\\n++Inserted text++\\n\\n\\n### [\\\\<mark>](https://github.com/markdown-it/markdown-it-mark)\\n\\n==Marked text==\\n\\n\\n### [Footnotes](https://github.com/markdown-it/markdown-it-footnote)\\n\\nFootnote 1 link[^first].\\n\\nFootnote 2 link[^second].\\n\\nInline footnote^[Text of inline footnote] definition.\\n\\nDuplicated footnote reference[^second].\\n\\n[^first]: Footnote **can have markup**\\n\\n    and multiple paragraphs.\\n\\n[^second]: Footnote text.\\n\\n\\n### [Definition lists](https://github.com/markdown-it/markdown-it-deflist)\\n\\nTerm 1\\n\\n:   Definition 1\\nwith lazy continuation.\\n\\nTerm 2 with *inline markup*\\n\\n:   Definition 2\\n\\n        { some code, part of Definition 2 }\\n\\n    Third paragraph of definition 2.\\n\\n_Compact style:_\\n\\nTerm 1\\n  ~ Definition 1\\n\\nTerm 2\\n  ~ Definition 2a\\n  ~ Definition 2b\\n\\n\\n### [Abbreviations](https://github.com/markdown-it/markdown-it-abbr)\\n\\nThis is HTML abbreviation example.\\n\\nIt converts \"HTML\", but keep intact partial entries like \"xxxHTMLyyy\" and so on.\\n\\n*[HTML]: Hyper Text Markup Language\\n\\n### [Custom containers](https://github.com/markdown-it/markdown-it-container)\\n\\n::: warning\\n*here be dragons*\\n:::\\n',\n      ),\n    [],\n  )\n\n  return <ScrollView contentContainerClassName=\"p-4\">{element}</ScrollView>\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(headless)/(debug)/text.tsx",
    "content": "import { SafeAreaView, StyleSheet, Switch, Text as RNText, View } from \"react-native\"\n\nimport { setUISetting, useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport { Slider } from \"@/src/components/ui/slider\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\n\nexport const TextDebugScreen: NavigationControllerView = () => {\n  const systemFontScaling = useUISettingKey(\"useSystemFontScaling\")\n  const fontScaling = useUISettingKey(\"fontScale\")\n  return (\n    <SafeAreaView>\n      <View className=\"flex flex-row items-center justify-between p-4\">\n        <RNText className=\"font-medium text-label\" allowFontScaling={false}>\n          Enable system font scaling:{\" \"}\n        </RNText>\n        <Switch\n          value={systemFontScaling}\n          onValueChange={() => {\n            setUISetting(\"useSystemFontScaling\", !systemFontScaling)\n          }}\n        />\n      </View>\n\n      <View className=\"flex p-4\">\n        <View className=\"flex flex-row items-center justify-between\">\n          <RNText className=\"font-medium text-label\" allowFontScaling={false}>\n            Setting font scaling:{\" \"}\n          </RNText>\n\n          <RNText className=\"font-medium text-label\">{fontScaling.toFixed(2)}</RNText>\n        </View>\n        <Slider\n          style={styles.slider}\n          maximumValue={1.5}\n          minimumValue={0.8}\n          value={fontScaling}\n          onValueChange={(value) => {\n            setUISetting(\"fontScale\", value)\n          }}\n        />\n      </View>\n\n      <View>\n        <RNText allowFontScaling={false} className=\"text-label\">\n          Use React Native Text:\n        </RNText>\n        <RNText className=\"text-body text-label\">\n          Sint eveniet facilis. Occaecati labore temporibus. Nihil qui fuga fugiat provident dolores\n          sed. Sed ipsum vel alias. Incidunt iste voluptatibus. Consequatur corrupti deserunt hic\n          accusamus.\n        </RNText>\n      </View>\n\n      <View>\n        <RNText allowFontScaling={false} className=\"text-label\">\n          Use Text:\n        </RNText>\n        <Text className=\"text-body text-label\">\n          Sint eveniet facilis. Occaecati labore temporibus. Nihil qui fuga fugiat provident dolores\n          sed. Sed ipsum vel alias. Incidunt iste voluptatibus. Consequatur corrupti deserunt hic\n          accusamus.\n        </Text>\n      </View>\n    </SafeAreaView>\n  )\n}\n\nconst styles = StyleSheet.create({\n  slider: {\n    marginTop: 16,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/screens/(headless)/DebugScreen.tsx",
    "content": "import type { envProfileMap } from \"@follow/shared/env.rn\"\nimport { whoami } from \"@follow/store/user/getters\"\nimport { sleep } from \"@follow/utils\"\nimport { requireNativeModule } from \"expo\"\nimport * as Clipboard from \"expo-clipboard\"\nimport * as FileSystem from \"expo-file-system/legacy\"\nimport * as SecureStore from \"expo-secure-store\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { useRef, useState } from \"react\"\nimport {\n  Alert,\n  findNodeHandle,\n  Pressable,\n  ScrollView,\n  StyleSheet,\n  TextInput,\n  View,\n} from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { Select } from \"@/src/components/ui/form/Select\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { getDbPath } from \"@/src/database\"\nimport { cookieKey, getCookie, sessionTokenKey, signOut } from \"@/src/lib/auth\"\nimport { loading } from \"@/src/lib/loading\"\nimport { DebugButtonGroup } from \"@/src/lib/navigation/debug/DebugButtonGroup\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { NavigationSitemapRegistry } from \"@/src/lib/navigation/sitemap/registry\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { setEnvProfile, useEnvProfile } from \"@/src/lib/proxy-env\"\nimport { toast } from \"@/src/lib/toast\"\nimport { showUpgradeRequiredDialog } from \"@/src/modules/dialogs/UpgradeRequiredDialog\"\nimport {\n  openMobileReviewPromptDebug,\n  resetMobileReviewPromptDebug,\n} from \"@/src/modules/review-prompt/debug\"\n\nimport { ProfileScreen } from \"../(modal)/ProfileScreen\"\nimport { MarkdownScreen } from \"./(debug)/markdown\"\nimport { TextDebugScreen } from \"./(debug)/text\"\n\ninterface MenuSection {\n  title: string\n  items: (MenuItem | FC)[]\n}\ninterface MenuItem {\n  title: string\n  onPress: () => Promise<void> | void\n  textClassName?: string\n}\nexport const DebugScreen: NavigationControllerView = () => {\n  const insets = useSafeAreaInsets()\n  const envProfile = useEnvProfile()\n  const menuSections: MenuSection[] = [\n    {\n      title: \"Users\",\n      items: [\n        UserSessionSetting,\n        {\n          title: \"Get Current Session Token\",\n          onPress: async () => {\n            const token = getCookie()\n            Alert.alert(`Current Session Token: ${token}`)\n          },\n        },\n        {\n          title: \"Clear Session Token\",\n          onPress: async () => {\n            await signOut()\n            Alert.alert(\"Session Token Cleared\")\n          },\n        },\n      ],\n    },\n    {\n      title: \"Data Control\",\n      items: [\n        {\n          title: \"Copy Sqlite File Location\",\n          onPress: async () => {\n            const dbPath = getDbPath()\n            await Clipboard.setStringAsync(dbPath)\n          },\n        },\n        {\n          title: \"Copy Cache Directory\",\n          onPress: async () => {\n            const { cacheDirectory } = FileSystem\n            if (!cacheDirectory) {\n              return\n            }\n            await Clipboard.setStringAsync(cacheDirectory)\n          },\n        },\n        {\n          title: \"Clear Sqlite Data\",\n          textClassName: \"!text-red\",\n          onPress: async () => {\n            Alert.alert(\"Clear Sqlite Data?\", \"This will delete all your data\", [\n              {\n                text: \"Cancel\",\n                style: \"cancel\",\n              },\n              {\n                text: \"Clear\",\n                style: \"destructive\",\n                async onPress() {\n                  const dbPath = getDbPath()\n                  await FileSystem.deleteAsync(dbPath)\n                  await expo.reloadAppAsync(\"Clear Sqlite Data\")\n                },\n              },\n            ])\n          },\n        },\n      ],\n    },\n    {\n      title: \"Debug\",\n      items: [\n        {\n          title: \"Reload App\",\n          onPress: () => expo.reloadAppAsync(\"Reload App\"),\n        },\n        {\n          title: \"Loading\",\n          onPress: () => {\n            loading.start(sleep(2000))\n          },\n        },\n        {\n          title: \"Toast\",\n          onPress: () => {\n            toast.success(\"Hello, world!\".repeat(3))\n          },\n        },\n        {\n          title: \"Glow Effect\",\n          onPress: () => {\n            requireNativeModule(\"AppleIntelligenceGlowEffect\").show()\n          },\n        },\n        {\n          title: \"Hide Glow Effect\",\n          onPress: () => {\n            requireNativeModule(\"AppleIntelligenceGlowEffect\").hide()\n          },\n        },\n        {\n          title: \"Testing Native Scroll To Top\",\n          onPress: async () => {\n            await requireNativeModule(\"Helper\").scrollToTop(findNodeHandle(ref.current))\n          },\n        },\n        {\n          title: \"Test navigation\",\n          onPress: () => {\n            navigation.pushControllerView(DebugButtonGroup)\n          },\n        },\n        {\n          title: \"Markdown\",\n          onPress: () => {\n            navigation.pushControllerView(MarkdownScreen)\n          },\n        },\n        {\n          title: \"TextSizeView\",\n          onPress: () => {\n            navigation.pushControllerView(TextDebugScreen)\n          },\n        },\n        {\n          title: \"Present Profile Modal\",\n          onPress: () => {\n            navigation.presentControllerView(ProfileScreen, {\n              userId: whoami()!.id,\n            })\n          },\n        },\n        {\n          title: \"Upgrade Required Dialog\",\n          onPress: () => {\n            showUpgradeRequiredDialog({\n              title: \"Upgrade Required\",\n              message: \"Please upgrade to continue\",\n            })\n          },\n        },\n        {\n          title: \"Trigger Review Prompt\",\n          onPress: async () => {\n            await openMobileReviewPromptDebug()\n          },\n        },\n        {\n          title: \"Reset Review Prompt State\",\n          onPress: () => {\n            resetMobileReviewPromptDebug()\n            Alert.alert(\"Review prompt state reset\")\n          },\n        },\n      ],\n    },\n  ]\n  const ref = useRef<ScrollView>(null)\n  const navigation = useNavigation()\n  return (\n    <ScrollView\n      ref={ref}\n      className=\"flex-1 bg-black\"\n      style={{\n        paddingTop: insets.top,\n      }}\n    >\n      <View className=\"flex-row items-center justify-between px-8\">\n        <Text className=\"text-2xl font-medium text-white\">Current Env Profile: {envProfile}</Text>\n        <Select\n          options={[\n            {\n              label: \"Dev\",\n              value: \"dev\",\n            },\n            {\n              label: \"Prod\",\n              value: \"prod\",\n            },\n            {\n              label: \"Staging\",\n              value: \"staging\",\n            },\n            {\n              label: \"Local\",\n              value: \"local\",\n            },\n          ]}\n          value={envProfile}\n          onValueChange={(value) => {\n            setEnvProfile(value as keyof typeof envProfileMap)\n          }}\n        />\n      </View>\n      {menuSections.map((section) => {\n        const functionItemKeyCounter = new Map<string, number>()\n        return (\n          <View key={section.title}>\n            <Text className=\"mt-4 px-8 text-2xl font-medium text-white\">{section.title}</Text>\n            <View style={styles.container}>\n              <View style={styles.itemContainer}>\n                {section.items.map((item) => {\n                  if (typeof item === \"function\") {\n                    const baseKey = item.displayName || item.name || \"anonymous-debug-item\"\n                    const duplicateCount = (functionItemKeyCounter.get(baseKey) ?? 0) + 1\n                    functionItemKeyCounter.set(baseKey, duplicateCount)\n                    return React.createElement(item, {\n                      key: `${baseKey}-${duplicateCount}`,\n                    })\n                  }\n                  return (\n                    <Pressable key={item.title} style={styles.itemPressable} onPress={item.onPress}>\n                      <Text style={styles.filename} className={item.textClassName}>\n                        {item.title}\n                      </Text>\n                    </Pressable>\n                  )\n                })}\n              </View>\n            </View>\n          </View>\n        )\n      })}\n\n      <Text className=\"mt-4 px-8 text-2xl font-medium text-white\">Sitemap</Text>\n      <View style={styles.container}>\n        <View style={styles.itemContainer}>\n          {NavigationSitemapRegistry.entries().map(([title, register]) => {\n            return (\n              <Pressable\n                key={title}\n                style={styles.itemPressable}\n                onPress={() => {\n                  const { Component, props, stackPresentation } = register\n                  if (stackPresentation === \"push\") {\n                    navigation.pushControllerView(Component, props)\n                  } else {\n                    navigation.presentControllerView(Component, props, stackPresentation)\n                  }\n                }}\n              >\n                <Text style={styles.filename}>{title}</Text>\n              </Pressable>\n            )\n          })}\n        </View>\n      </View>\n    </ScrollView>\n  )\n}\nconst UserSessionSetting = () => {\n  const [input, setInput] = useState(\"\")\n  const inputRef = useRef<TextInput>(null)\n  return (\n    <Pressable\n      style={styles.itemPressable}\n      className=\"flex-row justify-between\"\n      onPress={() => {\n        inputRef.current?.focus()\n      }}\n    >\n      <TextInput\n        autoCapitalize=\"none\"\n        autoCorrect={false}\n        className=\"w-0 flex-1 text-white\"\n        ref={inputRef}\n        placeholder=\"Session Token\"\n        value={input}\n        onChangeText={setInput}\n      />\n      <Pressable\n        className=\"ml-2\"\n        onPress={() => {\n          SecureStore.setItem(\n            cookieKey,\n            JSON.stringify({\n              [sessionTokenKey]: {\n                value: input,\n              },\n            }),\n          )\n          Alert.alert(\"Session Token Saved\")\n        }}\n      >\n        <Text className=\"font-medium text-white\">Save</Text>\n      </Pressable>\n    </Pressable>\n  )\n}\nconst styles = StyleSheet.create({\n  container: {\n    paddingHorizontal: \"5%\",\n    paddingVertical: 16,\n  },\n  itemContainer: {\n    borderWidth: 1,\n    borderColor: \"#313538\",\n    backgroundColor: \"#151718\",\n    borderRadius: 12,\n    marginBottom: 12,\n    overflow: \"hidden\",\n  },\n  itemPressable: {\n    paddingHorizontal: 16,\n    paddingVertical: 16,\n    flexDirection: \"row\",\n    justifyContent: \"space-between\",\n    alignItems: \"center\",\n  },\n  filename: {\n    color: \"white\",\n    fontSize: 20,\n    marginLeft: 12,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/DiscoverSettingsScreen.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { setUISetting, useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { Select } from \"@/src/components/ui/form/Select\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\n\nexport const DiscoverSettingsScreen = () => {\n  const { t } = useTranslation(\"settings\")\n  const { t: tCommon } = useTranslation(\"common\")\n  const discoverLanguage = useUISettingKey(\"discoverLanguage\")\n\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"discoverFilters.title\")} />}\n    >\n      <GroupedInsetListSectionHeader label={t(\"discoverFilters.filters\")} marginSize=\"small\" />\n      <GroupedInsetListCard className=\"flex-row\">\n        <GroupedInsetListCell label={t(\"discoverFilters.language\")}>\n          <View className=\"w-[120px]\">\n            <Select\n              options={[\n                { label: tCommon(\"words.all\"), value: \"all\" },\n                { label: tCommon(\"words.english\"), value: \"eng\" },\n                { label: tCommon(\"words.chinese\"), value: \"cmn\" },\n                { label: tCommon(\"words.french\"), value: \"fra\" },\n              ]}\n              value={discoverLanguage}\n              onValueChange={(val) => {\n                setUISetting(\"discoverLanguage\", val as \"all\" | \"eng\" | \"cmn\" | \"fra\")\n              }}\n            />\n          </View>\n        </GroupedInsetListCell>\n      </GroupedInsetListCard>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/EditEmailScreen.tsx",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport { userSyncService } from \"@follow/store/user/store\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useMemo, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListCell,\n  GroupedOutlineDescription,\n  GroupedPlainButtonCell,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { toast } from \"@/src/lib/toast\"\n\nexport const EditEmailScreen: NavigationControllerView = () => {\n  const { t } = useTranslation(\"settings\")\n\n  const whoami = useWhoami()\n\n  const [email, setEmail] = useState(whoami?.email ?? \"\")\n\n  const [isDirty, setIsDirty] = useState(false)\n  const isValidate = whoami?.emailVerified\n\n  const newEmailIsValid = useMemo(() => {\n    return email.match(/^[^\\s@]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/)\n  }, [email])\n\n  const navigation = useNavigation()\n  const { mutate: updateEmail, isPending } = useMutation({\n    mutationFn: async () => {\n      await userSyncService.updateEmail(email)\n    },\n    onError: (error) => {\n      toast.error(error instanceof Error ? error.message : \"Failed to update email\")\n    },\n    onSuccess: () => {\n      toast.info(\"Please check your email inbox to verify your new email\")\n      navigation.dismiss()\n    },\n  })\n\n  const [isSendingVerificationEmail, setIsSendingVerificationEmail] = useState(false)\n  const [hasSentVerificationEmail, setHasSentVerificationEmail] = useState(false)\n\n  return (\n    <SafeNavigationScrollView\n      keyboardShouldPersistTaps=\"handled\"\n      Header={\n        <NavigationBlurEffectHeaderView\n          title={t(\"profile.edit_email\")}\n          headerRight={\n            <HeaderSubmitTextButton\n              isLoading={isPending}\n              isValid={!!(email && newEmailIsValid && isDirty)}\n              onPress={() => {\n                updateEmail()\n              }}\n            />\n          }\n        />\n      }\n      className=\"bg-system-grouped-background\"\n    >\n      <View className=\"mt-4 w-full\">\n        <GroupedInsetListCard>\n          <GroupedInsetListCell\n            label={t(\"profile.email.label\")}\n            rightClassName=\"flex-1\"\n            leftClassName=\"flex-none\"\n          >\n            <PlainTextField\n              autoCapitalize=\"none\"\n              value={email}\n              onChangeText={(text) => {\n                setEmail(text)\n                setIsDirty(true)\n              }}\n              placeholder=\"Enter your email\"\n              className=\"w-full flex-1 text-left text-secondary-label\"\n            />\n          </GroupedInsetListCell>\n        </GroupedInsetListCard>\n        <GroupedOutlineDescription\n          description={`${t(\"profile.email.verify_status\", {\n            status: isValidate ? t(\"profile.email.verified\") : t(\"profile.email.unverified\"),\n          })}\\n\\n${t(\"profile.email.change_note\")}`}\n        />\n\n        {/* Buttons */}\n\n        {!isValidate && (\n          <GroupedInsetListCard className=\"mt-6\">\n            <GroupedPlainButtonCell\n              disabled={isSendingVerificationEmail}\n              label={\n                isSendingVerificationEmail\n                  ? \"Sending Verification Email...\"\n                  : hasSentVerificationEmail\n                    ? \"Verification Email Sent\"\n                    : \"Send Verification Email\"\n              }\n              onPress={() => {\n                setIsSendingVerificationEmail(true)\n                userSyncService\n                  .sendVerificationEmail()\n                  .then(() => {\n                    setHasSentVerificationEmail(true)\n                    toast.success(\"Verification email sent\")\n                  })\n                  .catch((error) => {\n                    toast.error(\n                      error instanceof Error ? error.message : \"Failed to send verification email\",\n                    )\n                  })\n                  .finally(() => {\n                    setIsSendingVerificationEmail(false)\n                  })\n              }}\n            />\n          </GroupedInsetListCard>\n        )}\n      </View>\n    </SafeNavigationScrollView>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/FollowScreen.tsx",
    "content": "import type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { FollowFeed, FollowUrl } from \"@/src/modules/feed/FollowFeed\"\nimport { FollowList } from \"@/src/modules/list/FollowList\"\n\nexport const FollowScreen: NavigationControllerView<{\n  id?: string\n  type: \"feed\" | \"list\" | \"url\"\n  url?: string\n}> = ({ id, type, url }) => {\n  switch (type) {\n    case \"feed\": {\n      return <FollowFeed id={id as string} />\n    }\n    case \"list\": {\n      return <FollowList id={id as string} />\n    }\n    case \"url\": {\n      return <FollowUrl url={url as string} />\n    }\n    default: {\n      return <FollowFeed id={id as string} />\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx",
    "content": "import { useMutation } from \"@tanstack/react-query\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { ScrollView, StyleSheet, View } from \"react-native\"\nimport { KeyboardAvoidingView, KeyboardController } from \"react-native-keyboard-controller\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { SubmitButton } from \"@/src/components/common/SubmitButton\"\nimport { HeaderCloseOnly } from \"@/src/components/layouts/header/HeaderElements\"\nimport { PlainTextField } from \"@/src/components/ui/form/TextField\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { forgetPassword } from \"@/src/lib/auth\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { toast } from \"@/src/lib/toast\"\nimport { getTokenHeaders } from \"@/src/lib/token\"\n\nexport const ForgetPasswordScreen: NavigationControllerView = () => {\n  const insets = useSafeAreaInsets()\n  const { t } = useTranslation()\n  const [email, setEmail] = useState(\"\")\n  const navigation = useNavigation()\n  const contentContainerStyle = {\n    flexGrow: 1,\n    justifyContent: \"space-between\" as const,\n    paddingTop: insets.top + 56,\n    paddingBottom: insets.bottom + 24,\n  }\n  const forgetPasswordMutation = useMutation({\n    mutationFn: async (email: string) => {\n      const res = await forgetPassword(\n        {\n          email,\n        },\n        {\n          headers: await getTokenHeaders(),\n        },\n      )\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n    onSuccess: () => {\n      toast.success(t(\"login.forgot_password.success\"))\n      navigation.back()\n    },\n  })\n  return (\n    <KeyboardAvoidingView behavior=\"padding\" style={styles.keyboardContainer}>\n      <HeaderCloseOnly />\n      <ScrollView\n        className=\"flex-1 px-5\"\n        keyboardDismissMode=\"on-drag\"\n        keyboardShouldPersistTaps=\"handled\"\n        contentContainerStyle={contentContainerStyle}\n        onScrollBeginDrag={() => {\n          KeyboardController.dismiss()\n        }}\n      >\n        <View className=\"items-center\">\n          <Text className=\"text-center text-4xl font-bold text-text\">\n            {t(\"login.forgot_password.title\")}\n          </Text>\n          <Text className=\"mx-10 mt-6 text-center text-lg text-text\">\n            {t(\"login.forgot_password.description\")}\n          </Text>\n\n          <View className=\"mt-6 gap-4 self-stretch rounded-2xl bg-secondary-system-background px-6 py-4\">\n            <View className=\"flex-row\">\n              <PlainTextField\n                autoCapitalize=\"none\"\n                autoCorrect={false}\n                keyboardType=\"email-address\"\n                autoComplete=\"email\"\n                placeholder={t(\"login.email\")}\n                className=\"flex-1 text-text\"\n                value={email}\n                onChangeText={setEmail}\n                onSubmitEditing={() => forgetPasswordMutation.mutate(email)}\n              />\n            </View>\n          </View>\n        </View>\n\n        <SubmitButton\n          title={t(\"login.forgot_password.continue\")}\n          className=\"mt-8 self-stretch\"\n          disabled={!email || forgetPasswordMutation.isPending}\n          isLoading={forgetPasswordMutation.isPending}\n          onPress={() => forgetPasswordMutation.mutate(email)}\n        />\n      </ScrollView>\n    </KeyboardAvoidingView>\n  )\n}\n\nconst styles = StyleSheet.create({\n  keyboardContainer: {\n    flex: 1,\n  },\n})\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/ListScreen.tsx",
    "content": "import { useActionSheet } from \"@expo/react-native-action-sheet\"\nimport { FeedViewType } from \"@follow/constants\"\nimport { getListById } from \"@follow/store/list/getters\"\nimport { useListById } from \"@follow/store/list/hooks\"\nimport { listSyncServices } from \"@follow/store/list/store\"\nimport type { CreateListModel } from \"@follow/store/list/types\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { memo, useEffect, useState } from \"react\"\nimport { Controller, useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport { z } from \"zod\"\n\nimport { HeaderSubmitButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeader,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { FormProvider, useFormContext } from \"@/src/components/ui/form/FormProvider\"\nimport { FormLabel } from \"@/src/components/ui/form/Label\"\nimport { TextField } from \"@/src/components/ui/form/TextField\"\nimport {\n  GroupedInsetButtonCell,\n  GroupedInsetListCard,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { toastFetchError } from \"@/src/lib/error-parser\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useSetModalScreenOptions } from \"@/src/lib/navigation/ScreenOptionsContext\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { toast } from \"@/src/lib/toast\"\nimport { FeedViewSelector } from \"@/src/modules/feed/view-selector\"\n\nconst listSchema = z.object({\n  title: z.string().min(1),\n  description: z.string().nullable().optional(),\n  image: z\n    .string()\n    .url()\n    .nullable()\n    .optional()\n    .transform((val) => (val === \"\" ? null : val)),\n  view: z.number().int(),\n})\n\nconst defaultValues = {\n  title: \"\",\n  description: null,\n  image: null,\n  view: FeedViewType.Articles,\n} satisfies z.infer<typeof listSchema>\nexport const ListScreen: NavigationControllerView<{\n  listId?: string\n}> = ({ listId }) => {\n  const { t } = useTranslation(\"settings\")\n  const list = useListById(listId || \"\")\n  const form = useForm<z.infer<typeof listSchema>>({\n    defaultValues: list\n      ? {\n          title: list.title ?? \"\",\n          description: list.description ?? null,\n          image: list.image ?? null,\n          view: list.view ?? FeedViewType.Articles,\n        }\n      : defaultValues,\n    resolver: zodResolver(listSchema),\n    mode: \"all\",\n  })\n  const isEditing = !!listId\n  const { showActionSheetWithOptions } = useActionSheet()\n  const navigation = useNavigation()\n  return (\n    <FormProvider form={form}>\n      <SafeNavigationScrollView className=\"pb-safe flex-1 bg-system-grouped-background\">\n        <ScreenOptions title={list?.title} listId={listId} />\n\n        <GroupedInsetListCard showSeparator={false} className=\"mt-2 px-3 py-6\">\n          <Controller\n            name=\"title\"\n            control={form.control}\n            rules={{\n              required: true,\n            }}\n            render={({ field: { onChange, onBlur, ref, value } }) => (\n              <TextField\n                label={t(\"lists.title\")}\n                required={true}\n                wrapperClassName=\"mt-2\"\n                placeholder=\"\"\n                onBlur={onBlur}\n                onChangeText={onChange}\n                defaultValue={list?.title ?? \"\"}\n                value={value ?? \"\"}\n                ref={ref}\n              />\n            )}\n          />\n\n          <View className=\"mt-4\">\n            <Controller\n              name=\"description\"\n              control={form.control}\n              render={({ field: { onChange, onBlur, ref, value } }) => (\n                <TextField\n                  label={t(\"lists.description\")}\n                  wrapperClassName=\"mt-2\"\n                  placeholder=\"\"\n                  onBlur={onBlur}\n                  onChangeText={onChange}\n                  defaultValue={list?.description ?? \"\"}\n                  value={value ?? \"\"}\n                  ref={ref}\n                />\n              )}\n            />\n          </View>\n\n          <View className=\"mt-4\">\n            <Controller\n              name=\"image\"\n              control={form.control}\n              render={({ field: { onChange, onBlur, ref, value } }) => (\n                <TextField\n                  autoCapitalize=\"none\"\n                  label={t(\"lists.image\")}\n                  wrapperClassName=\"mt-2\"\n                  placeholder=\"https://\"\n                  onBlur={onBlur}\n                  onChangeText={(val) => {\n                    onChange(val === \"\" ? null : val)\n                  }}\n                  defaultValue={list?.image ?? \"\"}\n                  value={value ?? \"\"}\n                  ref={ref}\n                />\n              )}\n            />\n          </View>\n\n          <View className=\"mt-4\">\n            <FormLabel label={t(\"lists.view\")} className=\"mb-4 pl-2.5\" optional />\n            <Controller\n              name=\"view\"\n              control={form.control}\n              render={({ field: { onChange, value } }) => (\n                <FeedViewSelector value={value as any as FeedViewType} onChange={onChange} />\n              )}\n            />\n          </View>\n        </GroupedInsetListCard>\n\n        {isEditing && (\n          <GroupedInsetListCard className=\"mt-6\">\n            <GroupedInsetButtonCell\n              label={t(\"words.delete\", { ns: \"common\" })}\n              style=\"destructive\"\n              onPress={() => {\n                showActionSheetWithOptions(\n                  {\n                    options: [\n                      t(\"words.delete\", { ns: \"common\" }),\n                      t(\"words.cancel\", { ns: \"common\" }),\n                    ],\n                    cancelButtonIndex: 1,\n                    destructiveButtonIndex: 0,\n                  },\n                  async (index) => {\n                    if (index === 0) {\n                      await listSyncServices.deleteList(listId)\n                      navigation.dismiss()\n                    }\n                  },\n                )\n              }}\n            />\n          </GroupedInsetListCard>\n        )}\n      </SafeNavigationScrollView>\n    </FormProvider>\n  )\n}\n\ninterface ScreenOptionsProps {\n  title?: string\n  listId?: string\n}\nconst ScreenOptions = memo(({ title, listId }: ScreenOptionsProps) => {\n  const { t } = useTranslation(\"settings\")\n  const form = useFormContext()\n\n  const { isValid, isDirty } = form.formState\n\n  const isEditing = !!listId\n  const [isLoading, setIsLoading] = useState(false)\n\n  const setModalOptions = useSetModalScreenOptions()\n  useEffect(() => {\n    setModalOptions({\n      gestureEnabled: !isDirty,\n    })\n  }, [isDirty, setModalOptions])\n  const navigation = useNavigation()\n\n  return (\n    <NavigationBlurEffectHeader\n      title={title ? `${t(\"lists.edit.label\")} - ${title}` : t(\"lists.create\")}\n      headerRight={\n        <FormProvider form={form}>\n          <HeaderSubmitButton\n            isValid={isValid}\n            isLoading={isLoading}\n            onPress={form.handleSubmit(async (values) => {\n              if (!isEditing) {\n                setIsLoading(true)\n                try {\n                  await listSyncServices.createList({\n                    list: values as CreateListModel,\n                  })\n                  toast.success(\"List created\")\n                  navigation.dismiss()\n                } catch (error) {\n                  toastFetchError(error as Error)\n                  console.error(error)\n                } finally {\n                  setIsLoading(false)\n                }\n                return\n              }\n              const list = getListById(listId!)\n              if (!list) return\n              setIsLoading(true)\n              try {\n                await listSyncServices.updateList({\n                  listId: listId!,\n                  list: {\n                    ...list,\n                    ...values,\n                  },\n                })\n                toast.success(\"List updated\")\n                navigation.dismiss()\n              } catch (error) {\n                toastFetchError(error as Error)\n                console.error(error)\n              } finally {\n                setIsLoading(false)\n              }\n            })}\n          />\n        </FormProvider>\n      }\n    />\n  )\n})\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/LoginScreen.tsx",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport { useCallback, useEffect } from \"react\"\nimport { ScrollView } from \"react-native\"\n\nimport { HeaderCloseOnly } from \"@/src/components/layouts/header/HeaderElements\"\nimport { useSwitchTab } from \"@/src/lib/navigation/bottom-tab/hooks\"\nimport { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { useIsiPad } from \"@/src/lib/platform\"\nimport { Login } from \"@/src/modules/login\"\n\nexport const LoginScreen: NavigationControllerView = () => {\n  const whoami = useWhoami()\n  const switchTab = useSwitchTab()\n\n  const exit = useCallback(() => {\n    Navigation.rootNavigation.popToRoot()\n  }, [])\n\n  useEffect(() => {\n    if (!whoami?.id) {\n      return\n    }\n\n    switchTab(0)\n    const timer = setTimeout(() => {\n      exit()\n    }, 300)\n\n    return () => {\n      clearTimeout(timer)\n    }\n  }, [exit, switchTab, whoami?.id])\n  const isiPad = useIsiPad()\n  return (\n    <>\n      {isiPad ? (\n        <ScrollView\n          contentContainerStyle={tabletScrollViewContentStyle}\n          keyboardShouldPersistTaps=\"handled\"\n        >\n          <Login />\n        </ScrollView>\n      ) : (\n        <Login />\n      )}\n      <HeaderCloseOnly />\n    </>\n  )\n}\nLoginScreen.sheetGrabberVisible = false\n\nconst tabletScrollViewContentStyle = {\n  flexGrow: 1,\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/ProfileScreen.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { FeedModel } from \"@follow/store/feed/types\"\nimport type { ListModel } from \"@follow/store/list/types\"\nimport { getSubscriptionById } from \"@follow/store/subscription/getter\"\nimport { subscriptionSyncService } from \"@follow/store/subscription/store\"\nimport { usePrefetchUser, useUserById, useWhoami } from \"@follow/store/user/hooks\"\nimport { Image as ExpoImage } from \"expo-image\"\nimport { createContext, Fragment, use, useCallback, useEffect, useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Alert, FlatList, Pressable, Share, View } from \"react-native\"\nimport Animated, {\n  interpolate,\n  useAnimatedScrollHandler,\n  useAnimatedStyle,\n  useSharedValue,\n} from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { ReAnimatedScrollView } from \"@/src/components/common/AnimatedComponents\"\nimport { BlurEffect } from \"@/src/components/common/BlurEffect\"\nimport { SwipeableItem } from \"@/src/components/common/SwipeableItem\"\nimport {\n  InternalNavigationHeader,\n  UINavigationHeaderActionButton,\n} from \"@/src/components/layouts/header/NavigationHeader\"\nimport {\n  GROUPED_ICON_TEXT_GAP,\n  GROUPED_LIST_ITEM_PADDING,\n  GROUPED_LIST_MARGIN,\n} from \"@/src/components/ui/grouped/constants\"\nimport {\n  GroupedInsetListCard,\n  GroupedInsetListSectionHeader,\n} from \"@/src/components/ui/grouped/GroupedList\"\nimport { FallbackIcon } from \"@/src/components/ui/icon/fallback-icon\"\nimport type { FeedIconRequiredFeed } from \"@/src/components/ui/icon/feed-icon\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { PlatformActivityIndicator } from \"@/src/components/ui/loading/PlatformActivityIndicator\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { ShareForwardCuteReIcon } from \"@/src/icons/share_forward_cute_re\"\nimport type { followClient } from \"@/src/lib/api-client\"\nimport { Navigation } from \"@/src/lib/navigation/Navigation\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { toast } from \"@/src/lib/toast\"\nimport { useShareSubscription } from \"@/src/modules/settings/hooks/useShareSubscription\"\nimport { UserHeaderBanner } from \"@/src/modules/settings/UserHeaderBanner\"\nimport { ItemSeparator } from \"@/src/modules/subscription/ItemSeparator\"\nimport { useColor } from \"@/src/theme/colors\"\n\nimport { FeedScreen } from \"../(stack)/feeds/[feedId]/FeedScreen\"\nimport { FollowScreen } from \"./FollowScreen\"\n\ntype Subscription = Awaited<ReturnType<typeof followClient.api.subscriptions.get>>[\"data\"][number]\nexport const ProfileScreen: NavigationControllerView<{\n  userId: string\n}> = ({ userId }) => {\n  const whoami = useWhoami()\n  if (!whoami) {\n    return null\n  }\n  return <ProfileScreenImpl userId={userId || whoami?.id} />\n}\nconst IsMyProfileContext = createContext<boolean>(false)\nconst ActionContext = createContext<{\n  removeItemById: (id: string) => void\n}>({\n  removeItemById: () => {},\n})\nfunction ProfileScreenImpl(props: { userId: string }) {\n  const { t } = useTranslation()\n  const scrollY = useSharedValue(0)\n  const {\n    data: subscriptions,\n    isLoading,\n    isError,\n    removeItemById,\n  } = useShareSubscription({\n    userId: props.userId,\n  })\n  const headerOpacity = useSharedValue(0)\n  const scrollHandler = useAnimatedScrollHandler((event) => {\n    scrollY.value = event.contentOffset.y\n    headerOpacity.value = scrollY.value / 100\n  })\n  usePrefetchUser(props.userId)\n  const user = useUserById(props.userId)\n  useEffect(() => {\n    if (isError) {\n      toast.error(\"Failed to fetch subscriptions\")\n    }\n  }, [isError])\n  const insets = useSafeAreaInsets()\n  const textLabelColor = useColor(\"label\")\n  const openShareUrl = useCallback(() => {\n    if (!user?.id) return\n    const shareUrl = `https://app.folo.is/share/users/${user.id}`\n    Share.share({\n      message: shareUrl,\n      url: shareUrl,\n      title: `Folo | ${user.name}'s Profile`,\n    })\n  }, [user?.id, user?.name])\n\n  const whoami = useWhoami()\n  const isMyProfile = user?.id === whoami?.id\n  const actionCtx = useMemo(\n    () => ({\n      removeItemById,\n    }),\n    [removeItemById],\n  )\n\n  return (\n    <View className=\"flex-1 bg-system-grouped-background\">\n      <Animated.View\n        pointerEvents=\"box-none\"\n        className=\"border-hairline absolute inset-x-0 top-0 z-[99] border-system-fill\"\n        style={{\n          opacity: headerOpacity,\n        }}\n      >\n        <BlurEffect />\n\n        <InternalNavigationHeader\n          title={t(\"profile.title\", {\n            name: user?.name || user?.handle,\n          })}\n          headerRight={\n            <UINavigationHeaderActionButton onPress={openShareUrl}>\n              <ShareForwardCuteReIcon color={textLabelColor} />\n            </UINavigationHeaderActionButton>\n          }\n        />\n      </Animated.View>\n      <ReAnimatedScrollView\n        nestedScrollEnabled\n        onScroll={scrollHandler}\n        contentContainerStyle={{\n          paddingBottom: insets.bottom + 24,\n        }}\n      >\n        <UserHeaderBanner scrollY={scrollY} userId={props.userId} />\n\n        {isLoading && <PlatformActivityIndicator className=\"mt-24\" size={28} />}\n        <IsMyProfileContext value={isMyProfile}>\n          <ActionContext value={actionCtx}>\n            {!isLoading && subscriptions ? (\n              <SubscriptionList subscriptions={subscriptions.data} />\n            ) : null}\n          </ActionContext>\n        </IsMyProfileContext>\n      </ReAnimatedScrollView>\n\n      <Animated.View\n        style={useAnimatedStyle(() => ({\n          opacity: interpolate(headerOpacity.value, [0, 1], [1, 0]),\n          top: insets.top + 17,\n        }))}\n        className=\"absolute flex w-full flex-row items-center justify-between px-4\"\n      >\n        <View />\n        <Pressable onPress={openShareUrl}>\n          <ShareForwardCuteReIcon color=\"#fff\" />\n        </Pressable>\n      </Animated.View>\n    </View>\n  )\n}\ntype PickedListModel = Pick<ListModel, \"id\" | \"title\" | \"image\" | \"description\" | \"view\"> & {\n  customTitle?: string | null\n}\ntype PickedFeedModel = Pick<\n  FeedModel,\n  \"id\" | \"title\" | \"description\" | \"siteUrl\" | \"url\" | \"image\"\n> & {\n  customTitle?: string | null\n  view: FeedViewType\n}\nconst SubscriptionList = ({ subscriptions }: { subscriptions: Subscription[] }) => {\n  const { t: tCommon } = useTranslation(\"common\")\n  const { t } = useTranslation()\n  const { lists, feeds, groupedFeeds } = useMemo(() => {\n    const lists = [] as PickedListModel[]\n    const feeds = [] as PickedFeedModel[]\n    const groupedFeeds = {} as Record<string, PickedFeedModel[]>\n    for (const subscription of subscriptions) {\n      if (\"listId\" in subscription) {\n        lists.push({\n          id: subscription.listId,\n          title: subscription.lists.title!,\n          image: subscription.lists.image!,\n          description: subscription.lists.description!,\n          view: subscription.lists.view,\n          customTitle: subscription.title,\n        })\n        continue\n      }\n      if (\"feedId\" in subscription && \"feeds\" in subscription) {\n        const feed = {\n          id: subscription.feedId,\n          title: subscription.feeds.title!,\n          image: subscription.feeds.image!,\n          description: subscription.feeds.description!,\n          siteUrl: subscription.feeds.siteUrl!,\n          url: subscription.feeds.url!,\n          view: subscription.view as FeedViewType,\n          customTitle: subscription.title,\n        }\n        if (subscription.category) {\n          groupedFeeds[subscription.category] = [\n            ...(groupedFeeds[subscription.category] || []),\n            feed,\n          ]\n        } else {\n          feeds.push(feed)\n        }\n      }\n    }\n    return {\n      lists,\n      feeds,\n      groupedFeeds,\n    }\n  }, [subscriptions])\n  const hasFeeds = Object.keys(groupedFeeds).length > 0 || feeds.length > 0\n  return (\n    <View>\n      {lists.length > 0 && (\n        <Fragment>\n          <SectionHeader title={tCommon(\"words.lists\")} />\n\n          <GroupedInsetListCard>\n            <FlatList\n              scrollEnabled={false}\n              data={lists}\n              renderItem={renderListItems}\n              ItemSeparatorComponent={ItemSeparator}\n            />\n          </GroupedInsetListCard>\n        </Fragment>\n      )}\n      {hasFeeds && (\n        <View className=\"mt-4\">\n          <SectionHeader title={tCommon(\"words.feeds\")} />\n          {Object.entries(groupedFeeds).map(([category, feeds]) => (\n            <Fragment key={category}>\n              <GroupedInsetListSectionHeader label={category} marginSize=\"small\" />\n              <GroupedInsetListCard>\n                <FlatList\n                  scrollEnabled={false}\n                  data={feeds}\n                  renderItem={renderFeedItems}\n                  ItemSeparatorComponent={ItemSeparator}\n                />\n              </GroupedInsetListCard>\n            </Fragment>\n          ))}\n\n          {feeds.length > 0 && (\n            <>\n              <GroupedInsetListSectionHeader\n                label={t(\"profile.uncategorized_feeds\")}\n                marginSize=\"small\"\n              />\n              <GroupedInsetListCard>\n                <FlatList\n                  scrollEnabled={false}\n                  data={feeds}\n                  renderItem={renderFeedItems}\n                  ItemSeparatorComponent={ItemSeparator}\n                />\n              </GroupedInsetListCard>\n            </>\n          )}\n        </View>\n      )}\n    </View>\n  )\n}\nconst renderListItems = ({ item }: { item: PickedListModel }) => (\n  <ItemPressable\n    className=\"flex h-12 flex-row items-center bg-secondary-system-grouped-background\"\n    style={{\n      paddingHorizontal: GROUPED_LIST_ITEM_PADDING,\n    }}\n    onPress={() => {\n      if (getSubscriptionById(item.id))\n        Navigation.rootNavigation.pushControllerView(FeedScreen, {\n          feedId: item.id,\n        })\n      else {\n        Navigation.rootNavigation.pushControllerView(FollowScreen, {\n          type: \"list\",\n          id: item.id,\n        })\n      }\n    }}\n  >\n    <View className=\"overflow-hidden rounded\">\n      {!!item.image && (\n        <ExpoImage\n          source={{\n            uri: item.image,\n          }}\n          contentFit=\"cover\"\n          className=\"size-6\"\n        />\n      )}\n      {!item.image && <FallbackIcon title={item.title} size={24} />}\n    </View>\n\n    <Text\n      className=\"mr-4 text-text\"\n      numberOfLines={1}\n      style={{\n        marginLeft: GROUPED_ICON_TEXT_GAP,\n      }}\n    >\n      {item.title}\n    </Text>\n  </ItemPressable>\n)\nconst renderFeedItems = ({ item }: { item: PickedFeedModel }) => (\n  <MaybeSwipeable id={item.id}>\n    <ItemPressable\n      className=\"flex h-12 flex-row items-center bg-secondary-system-grouped-background\"\n      style={{\n        paddingHorizontal: GROUPED_LIST_ITEM_PADDING,\n      }}\n      onPress={() => {\n        if (getSubscriptionById(item.id))\n          Navigation.rootNavigation.pushControllerView(FeedScreen, {\n            feedId: item.id,\n          })\n        else {\n          Navigation.rootNavigation.pushControllerView(FollowScreen, {\n            type: \"feed\",\n            id: item.id,\n          })\n        }\n      }}\n    >\n      <View className=\"overflow-hidden rounded\">\n        <FeedIcon\n          feed={\n            {\n              id: item.id,\n              title: item.title,\n              url: item.url,\n              image: item.image,\n              type: item.view,\n              siteUrl: item.siteUrl || \"\",\n            } as FeedIconRequiredFeed\n          }\n          size={24}\n        />\n      </View>\n      <Text\n        className=\"mr-4 text-text\"\n        numberOfLines={1}\n        style={{\n          marginLeft: GROUPED_ICON_TEXT_GAP,\n        }}\n      >\n        {item.title}\n      </Text>\n    </ItemPressable>\n  </MaybeSwipeable>\n)\nconst MaybeSwipeable = ({ id, children }: { id: string; children: React.ReactNode }) => {\n  const isMyProfile = use(IsMyProfileContext)\n  const { t } = useTranslation()\n  const { removeItemById } = use(ActionContext)\n  if (!isMyProfile) {\n    return children\n  }\n  return (\n    <SwipeableItem\n      rightActions={[\n        {\n          onPress: () => {\n            // unsubscribe\n            Alert.alert(t(\"feed.unfollow.confirm_title\"), t(\"feed.unfollow.confirm_description\"), [\n              {\n                text: t(\"words.cancel\", { ns: \"common\" }),\n                style: \"cancel\",\n              },\n              {\n                text: t(\"operation.unfollow\"),\n                style: \"destructive\",\n                onPress: () => {\n                  subscriptionSyncService.unsubscribe(id)\n                  removeItemById(id)\n                },\n              },\n            ])\n          },\n          backgroundColor: \"red\",\n          label: t(\"operation.unfollow\"),\n        },\n      ]}\n    >\n      {children}\n    </SwipeableItem>\n  )\n}\nconst SectionHeader = ({ title }: { title: string }) => (\n  <View\n    className=\"mb-2 mt-5\"\n    style={{\n      marginHorizontal: GROUPED_LIST_MARGIN,\n    }}\n  >\n    <Text\n      className=\"text-xl font-medium text-label\"\n      style={{\n        marginLeft: GROUPED_LIST_ITEM_PADDING,\n      }}\n    >\n      {title}\n    </Text>\n  </View>\n)\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/RsshubFormScreen.tsx",
    "content": "import type { RSSHubParameter, RSSHubParameterObject, RSSHubRoute } from \"@follow/models/rsshub\"\nimport { feedSyncServices } from \"@follow/store/feed/store\"\nimport {\n  MissingOptionalParamError,\n  parseFullPathParams,\n  parseRegexpPathParams,\n  regexpPathToPath,\n} from \"@follow/utils\"\nimport { PortalProvider } from \"@gorhom/portal\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { memo, useEffect, useMemo, useState } from \"react\"\nimport type { FieldErrors } from \"react-hook-form\"\nimport { Controller, useForm } from \"react-hook-form\"\nimport { KeyboardAvoidingView, Linking, Pressable, View } from \"react-native\"\nimport { z } from \"zod\"\n\nimport { HeaderSubmitTextButton } from \"@/src/components/layouts/header/HeaderElements\"\nimport {\n  NavigationBlurEffectHeader,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { FormProvider, useFormContext } from \"@/src/components/ui/form/FormProvider\"\nimport { Select } from \"@/src/components/ui/form/Select\"\nimport { TextField } from \"@/src/components/ui/form/TextField\"\nimport { MarkdownNative } from \"@/src/components/ui/typography/MarkdownNative\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { useSetModalScreenOptions } from \"@/src/lib/navigation/ScreenOptionsContext\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { toast } from \"@/src/lib/toast\"\nimport { FeedSummary } from \"@/src/modules/discover/FeedSummary\"\n\nimport { FollowScreen } from \"./FollowScreen\"\n\ninterface RsshubFormParams {\n  route: RSSHubRoute\n  routePrefix: string\n  name: string\n}\nexport const RsshubFormScreen: NavigationControllerView<RsshubFormParams> = ({\n  route,\n  routePrefix,\n  name,\n}) => {\n  const parsedRoute = useMemo(() => {\n    if (!route) return null\n    try {\n      return typeof route === \"string\" ? JSON.parse(route) : route\n    } catch {\n      return null\n    }\n  }, [route])\n  const navigation = useNavigation()\n  const canBack = navigation.canGoBack()\n  useEffect(() => {\n    if (!parsedRoute && canBack) {\n      navigation.back()\n    }\n  }, [canBack, navigation, parsedRoute])\n  if (!parsedRoute || !routePrefix) {\n    return null\n  }\n  return <FormImpl route={parsedRoute} routePrefix={routePrefix as string} name={name!} />\n}\nfunction FormImpl({ route, routePrefix, name }: RsshubFormParams) {\n  const { name: routeName, topFeeds } = route\n  const keys = useMemo(() => parseRegexpPathParams(route.path), [route.path])\n  const formPlaceholder = useMemo<Record<string, string>>(() => {\n    if (!route.example) return {}\n    return parseFullPathParams(route.example.replace(`/${routePrefix}`, \"\"), route.path)\n  }, [route.example, route.path, routePrefix])\n  const dynamicFormSchema = useMemo(\n    () =>\n      z.object({\n        ...Object.fromEntries(\n          keys.map((keyItem) => [\n            keyItem.name,\n            keyItem.optional ? z.string().optional().nullable() : z.string().min(1),\n          ]),\n        ),\n      }),\n    [keys],\n  )\n  const defaultValue = useMemo(() => {\n    const ret = {} as Record<string, string | null>\n    if (!route.parameters) return ret\n    for (const key in route.parameters) {\n      const params = normalizeRSSHubParameters(route.parameters[key]!)\n      if (!params) continue\n      ret[key] = params.default\n    }\n    return ret\n  }, [route.parameters])\n  const form = useForm<z.infer<typeof dynamicFormSchema>>({\n    resolver: zodResolver(dynamicFormSchema),\n    defaultValues: defaultValue,\n    mode: \"all\",\n  })\n\n  // eslint-disable-next-line unicorn/prefer-structured-clone\n  const nextErrors = JSON.parse(JSON.stringify(form.formState.errors))\n  const data = form.watch() as Record<string, string | undefined>\n  const fullPath = useMemo(() => {\n    try {\n      return regexpPathToPath(route.path, data)\n    } catch (err: unknown) {\n      console.info((err as Error).message)\n      return route.path\n    }\n  }, [route.path, data])\n  return (\n    <FormProvider form={form}>\n      <PortalProvider>\n        <KeyboardAvoidingView className=\"flex-1\" behavior=\"padding\">\n          <SafeNavigationScrollView className=\"bg-system-grouped-background\">\n            <ScreenOptions\n              name={name}\n              routeName={routeName}\n              route={route.path}\n              routePrefix={routePrefix}\n              errors={nextErrors}\n            />\n            <Text className=\"mx-4 mt-2 text-center text-sm text-secondary-label\">\n              {`rsshub://${routePrefix}${fullPath}`}\n            </Text>\n            {keys.length === 0 && (\n              <View className=\"mx-2 mt-4 gap-4 rounded-lg bg-secondary-system-grouped-background p-3\">\n                <Text className=\"text-center text-base text-label\">\n                  This feed has no parameters.\n                </Text>\n              </View>\n            )}\n            {keys.length > 0 && (\n              <View className=\"mx-4 mt-4 gap-5 rounded-[10px] bg-secondary-system-grouped-background px-4 py-5\">\n                {keys.map((keyItem) => {\n                  const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name]!)\n                  return (\n                    <View key={keyItem.name}>\n                      {!parameters?.options && (\n                        <Controller\n                          name={keyItem.name}\n                          control={form.control}\n                          rules={{\n                            required: !keyItem.optional,\n                            // validate: (value) => {\n                            //   return dynamicFormSchema.safeParse({\n                            //     [keyItem.name]: value,\n                            //   }).success\n                            // },\n                          }}\n                          render={({ field: { onChange, onBlur, ref, value } }) => (\n                            <KeyboardAvoidingView behavior=\"padding\">\n                              <TextField\n                                label={keyItem.name}\n                                required={!keyItem.optional}\n                                wrapperClassName=\"mt-2\"\n                                placeholder={formPlaceholder[keyItem.name]}\n                                onBlur={onBlur}\n                                onChangeText={onChange}\n                                defaultValue={defaultValue[keyItem.name] ?? \"\"}\n                                value={value ?? \"\"}\n                                ref={ref}\n                              />\n                            </KeyboardAvoidingView>\n                          )}\n                        />\n                      )}\n\n                      {!!parameters?.options && (\n                        <Controller\n                          name={keyItem.name}\n                          control={form.control}\n                          render={({ field: { onChange, value } }) => (\n                            <Select\n                              label={keyItem.name}\n                              options={parameters.options ?? []}\n                              value={value}\n                              onValueChange={onChange}\n                            />\n                          )}\n                        />\n                      )}\n\n                      {!!parameters && (\n                        <Text className=\"ml-2 mt-1 text-xs text-secondary-label\">\n                          {parameters.description}\n                        </Text>\n                      )}\n                    </View>\n                  )\n                })}\n              </View>\n            )}\n            {!!topFeeds?.length && (\n              <View className=\"mx-4 mt-4 rounded-[10px] bg-secondary-system-grouped-background py-1\">\n                {topFeeds.map((feed) => (\n                  <FeedSummary key={feed.id} feed={feed} simple className=\"px-4 py-2\" />\n                ))}\n              </View>\n            )}\n            <Maintainers maintainers={route.maintainers} />\n\n            {!!route.description && (\n              <View className=\"mt-6 flex-1 px-8\">\n                <MarkdownNative value={route.description.replaceAll(\":::\", \"\")} />\n              </View>\n            )}\n          </SafeNavigationScrollView>\n        </KeyboardAvoidingView>\n      </PortalProvider>\n    </FormProvider>\n  )\n}\nconst Maintainers = ({ maintainers }: { maintainers?: string[] }) => {\n  if (!maintainers || maintainers.length === 0) {\n    return null\n  }\n  return (\n    <View className=\"mx-8 mt-4 flex flex-row flex-wrap gap-x-1 text-sm text-tertiary-label\">\n      <Text className=\"text-xs text-secondary-label\">\n        This feed is provided by RSSHub, with credit to{\" \"}\n      </Text>\n      {maintainers.map((m) => (\n        <Pressable key={m} onPress={() => Linking.openURL(`https://github.com/${m}`)}>\n          <Text className=\"text-xs text-accent/90\">@{m}</Text>\n        </Pressable>\n      ))}\n    </View>\n  )\n}\nconst normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameterObject | null =>\n  parameters\n    ? typeof parameters === \"string\"\n      ? {\n          description: parameters,\n          default: null,\n        }\n      : parameters\n    : null\ntype ScreenOptionsProps = {\n  name: string\n  routeName: string\n  route: string\n  routePrefix: string\n  errors: FieldErrors\n}\nconst ScreenOptions = memo(\n  ({ name, routeName, route, routePrefix, errors }: ScreenOptionsProps) => {\n    const form = useFormContext()\n    const setScreenOptions = useSetModalScreenOptions()\n    useEffect(() => {\n      setScreenOptions({\n        preventNativeDismiss: form.formState.isDirty,\n        gestureEnabled: !form.formState.isDirty,\n      })\n    }, [form.formState.isDirty, setScreenOptions])\n    return (\n      <NavigationBlurEffectHeader\n        title={`${name} - ${routeName}`}\n        headerRight={\n          <FormProvider form={form}>\n            <ModalHeaderSubmitButtonImpl errors={errors} routePrefix={routePrefix} route={route} />\n          </FormProvider>\n        }\n      />\n    )\n  },\n)\nconst routeParamsKeyPrefix = \"route-params-\"\nconst ModalHeaderSubmitButtonImpl = ({\n  routePrefix,\n  route,\n  errors,\n}: {\n  routePrefix: string\n  route: string\n  errors: FieldErrors\n}) => {\n  const form = useFormContext()\n  const isValid = Object.keys(errors).length === 0\n  const navigation = useNavigation()\n  const [isLoading, setIsLoading] = useState(false)\n  const submit = form.handleSubmit((_data) => {\n    setIsLoading(true)\n    const data = Object.fromEntries(\n      Object.entries(_data).filter(([key]) => !key.startsWith(routeParamsKeyPrefix)),\n    )\n    try {\n      const routeParamsPath = encodeURIComponent(\n        Object.entries(_data)\n          .filter(([key, value]) => key.startsWith(routeParamsKeyPrefix) && value)\n          .map(([key, value]) => [key.slice(routeParamsKeyPrefix.length), value])\n          .map(([key, value]) => `${key}=${value}`)\n          .join(\"&\"),\n      )\n      const fillRegexpPath = regexpPathToPath(\n        routeParamsPath ? route.slice(0, route.indexOf(\"/:routeParams\")) : route,\n        data,\n      )\n      const url = `rsshub://${routePrefix}${fillRegexpPath}`\n      const finalUrl = routeParamsPath ? `${url}/${routeParamsPath}` : url\n      feedSyncServices\n        .fetchFeedById({\n          url: finalUrl,\n        })\n        .then((feed) => {\n          navigation.pushControllerView(FollowScreen, {\n            id: feed?.id,\n            type: \"url\" as const,\n            url: finalUrl,\n          })\n        })\n        .catch(() => {\n          toast.error(\"Failed to fetch feed\")\n        })\n        .finally(() => {\n          setIsLoading(false)\n        })\n    } catch (err: unknown) {\n      if (err instanceof MissingOptionalParamError) {\n        toast.error(err.message)\n        // const idx = keys.findIndex((item) => item.name === err.param)\n        // form.setFocus(keys[idx === 0 ? 0 : idx - 1].name, {\n        //   shouldSelect: true,\n        // })\n      }\n    }\n  })\n  return (\n    <HeaderSubmitTextButton isLoading={isLoading} isValid={isValid} onPress={submit} label=\"Next\" />\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/TwoFactorAuthScreen.tsx",
    "content": "import { whoamiQueryKey } from \"@follow/store/user/hooks\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport { useRef } from \"react\"\nimport { TouchableWithoutFeedback, View } from \"react-native\"\nimport { KeyboardController } from \"react-native-keyboard-controller\"\nimport type { OtpInputRef } from \"react-native-otp-entry\"\nimport { OtpInput } from \"react-native-otp-entry\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { HeaderCloseOnly } from \"@/src/components/layouts/header/HeaderElements\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { isAuthCodeValid, twoFactor } from \"@/src/lib/auth\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { queryClient } from \"@/src/lib/query-client\"\nimport { toast } from \"@/src/lib/toast\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nexport const TwoFactorAuthScreen: NavigationControllerView = () => {\n  const insets = useSafeAreaInsets()\n  const label = useColor(\"label\")\n  const tertiaryLabel = useColor(\"tertiaryLabel\")\n  const otpInputRef = useRef<OtpInputRef>(null)\n  const navigation = useNavigation()\n  const submitMutation = useMutation({\n    mutationFn: async (value: string) => {\n      const res = await twoFactor.verifyTotp({\n        code: value,\n      })\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n      await queryClient.invalidateQueries({\n        queryKey: whoamiQueryKey,\n      })\n    },\n    onError(error) {\n      toast.error(`Failed to verify: ${error.message}`)\n    },\n    onSuccess() {\n      navigation.popToRoot()\n    },\n  })\n  return (\n    <View\n      className=\"flex-1\"\n      style={{\n        paddingTop: insets.top + 56,\n      }}\n    >\n      <HeaderCloseOnly />\n      <TouchableWithoutFeedback\n        onPress={() => {\n          KeyboardController.dismiss()\n        }}\n        accessible={false}\n      >\n        <View className=\"mt-20 flex-1 pb-10\">\n          <View className=\"mb-10 flex-row items-center justify-center\">\n            <Text className=\"w-72 text-center text-3xl font-bold text-label\" numberOfLines={2}>\n              Verify with your authenticator app\n            </Text>\n          </View>\n\n          <OtpInput\n            disabled={submitMutation.isPending}\n            ref={otpInputRef}\n            numberOfDigits={6}\n            autoFocus\n            focusColor={accentColor}\n            theme={{\n              containerStyle: {\n                paddingHorizontal: 20,\n              },\n              pinCodeTextStyle: {\n                color: label,\n              },\n              placeholderTextStyle: {\n                color: tertiaryLabel,\n              },\n              filledPinCodeContainerStyle: {\n                borderColor: label,\n              },\n              pinCodeContainerStyle: {\n                borderColor: tertiaryLabel,\n                aspectRatio: 1,\n                width: 50,\n              },\n              focusedPinCodeContainerStyle: {\n                borderColor: accentColor,\n              },\n            }}\n            onFilled={(code) => {\n              if (isAuthCodeValid(code)) {\n                submitMutation.mutate(code)\n              }\n            }}\n          />\n        </View>\n      </TouchableWithoutFeedback>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/onboarding/EditProfileScreen.tsx",
    "content": "export { EditProfileScreen as default } from \"@/src/modules/settings/routes/EditProfile\"\n"
  },
  {
    "path": "apps/mobile/src/screens/(modal)/onboarding/SelectReadingModeScreen.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { PropsWithChildren } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\n\nimport {\n  NavigationBlurEffectHeaderView,\n  SafeNavigationScrollView,\n} from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { GroupedInsetListNavigationLinkIcon } from \"@/src/components/ui/grouped/GroupedList\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { Eye2CuteReIcon } from \"@/src/icons/eye_2_cute_re\"\nimport { Grid2CuteReIcon } from \"@/src/icons/grid_2_cute_re\"\nimport { PowerIcon } from \"@/src/icons/power\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { useReadingBehavior } from \"@/src/modules/onboarding/hooks/use-reading-behavior\"\n\nexport const SelectReadingModeScreen: NavigationControllerView = () => {\n  const { t } = useTranslation()\n  const { behavior, updateSettings } = useReadingBehavior()\n  return (\n    <SafeNavigationScrollView\n      className=\"bg-system-grouped-background\"\n      Header={<NavigationBlurEffectHeaderView title={t(\"onboarding.reading_preferences\")} />}\n    >\n      <View className=\"mt-8 flex w-full gap-4\">\n        <Card\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#F87181\">\n              <PowerIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n          isSelected={behavior === \"radical\"}\n          onPress={() => {\n            updateSettings(\"radical\")\n          }}\n        >\n          <Text className=\"text-label\">{t(\"onboarding.reading_radical\")}</Text>\n        </Card>\n\n        <Card\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#34D399\">\n              <Grid2CuteReIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n          isSelected={behavior === \"balanced\"}\n          onPress={() => {\n            updateSettings(\"balanced\")\n          }}\n        >\n          <Text className=\"text-label\">{t(\"onboarding.reading_balanced\")}</Text>\n        </Card>\n\n        <Card\n          icon={\n            <GroupedInsetListNavigationLinkIcon backgroundColor=\"#CBAD6D\">\n              <Eye2CuteReIcon color=\"#fff\" width={40} height={40} />\n            </GroupedInsetListNavigationLinkIcon>\n          }\n          isSelected={behavior === \"conservative\"}\n          onPress={() => {\n            updateSettings(\"conservative\")\n          }}\n        >\n          <Text className=\"text-label\">{t(\"onboarding.reading_conservative\")}</Text>\n        </Card>\n      </View>\n    </SafeNavigationScrollView>\n  )\n}\nconst Card = ({\n  children,\n  onPress,\n  icon,\n  isSelected,\n}: PropsWithChildren<{\n  icon?: React.ReactNode\n  onPress?: () => void\n  isSelected?: boolean\n}>) => {\n  return (\n    <Pressable\n      className={cn(\n        \"mx-4 flex flex-row items-center gap-2 rounded-xl bg-secondary-system-grouped-background p-4\",\n        \"border-2 border-transparent\",\n        isSelected && \"border-2 border-accent\",\n      )}\n      onPress={onPress}\n    >\n      {icon}\n      <View className=\"flex flex-1 flex-col gap-2\">{children}</View>\n    </Pressable>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/(tabs)/discover.tsx",
    "content": "import { View } from \"react-native\"\n\nimport { SafeNavigationScrollView } from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport type { TabScreenComponent } from \"@/src/lib/navigation/bottom-tab/types\"\nimport Content from \"@/src/modules/discover/Content\"\nimport {\n  SearchPageProvider,\n  SearchPageScrollContainerAnimatedXProvider,\n} from \"@/src/modules/discover/ctx\"\nimport { DiscoverHeader } from \"@/src/modules/discover/search\"\n\nexport default function Discover() {\n  return (\n    <SearchPageScrollContainerAnimatedXProvider>\n      <SearchPageProvider>\n        <SafeNavigationScrollView\n          Header={\n            <View className=\"absolute top-0 z-10 w-full\">\n              <DiscoverHeader />\n            </View>\n          }\n        >\n          <Content />\n        </SafeNavigationScrollView>\n      </SearchPageProvider>\n    </SearchPageScrollContainerAnimatedXProvider>\n  )\n}\n\nexport const DiscoverTabScreen: TabScreenComponent = Discover\n\nDiscoverTabScreen.lazy = true\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/(tabs)/index.tsx",
    "content": "import { useResetTabOpacityWhenFocused } from \"@/src/components/layouts/tabbar/hooks\"\nimport { usePrepareEntryRenderWebView } from \"@/src/components/native/webview/hooks\"\nimport type { TabScreenComponent } from \"@/src/lib/navigation/bottom-tab/types\"\nimport { EntryList } from \"@/src/modules/entry-list\"\n\nexport const IndexTabScreen: TabScreenComponent = () => {\n  usePrepareEntryRenderWebView()\n  useResetTabOpacityWhenFocused()\n\n  return <EntryList />\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/(tabs)/settings.tsx",
    "content": "import { useWhoami } from \"@follow/store/user/hooks\"\nimport { use } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport type { ScrollView } from \"react-native\"\nimport { Pressable, View } from \"react-native\"\nimport type { SharedValue } from \"react-native-reanimated\"\nimport Animated, { useAnimatedStyle } from \"react-native-reanimated\"\nimport { useSafeAreaFrame, useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { BlurEffect } from \"@/src/components/common/BlurEffect\"\nimport { useRegisterNavigationScrollView } from \"@/src/components/layouts/tabbar/hooks\"\nimport { getDefaultHeaderHeight } from \"@/src/components/layouts/utils\"\nimport { SafeNavigationScrollView } from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport type { TabScreenComponent } from \"@/src/lib/navigation/bottom-tab/types\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\nimport { EditProfileScreen } from \"@/src/modules/settings/routes/EditProfile\"\nimport { SettingsList } from \"@/src/modules/settings/SettingsList\"\nimport { UserHeaderBanner } from \"@/src/modules/settings/UserHeaderBanner\"\n\nexport function Settings() {\n  const insets = useSafeAreaInsets()\n  const screenContext = use(ScreenItemContext)\n  const whoami = useWhoami()\n  const scrollViewRef = useRegisterNavigationScrollView<ScrollView>()\n  return (\n    <>\n      <SafeNavigationScrollView\n        ref={scrollViewRef}\n        style={{\n          paddingTop: insets.top,\n        }}\n        className=\"flex-1 bg-system-grouped-background\"\n        contentViewClassName=\"-mt-24 pb-8\"\n      >\n        <UserHeaderBanner\n          scrollY={screenContext.reAnimatedScrollY}\n          userId={whoami?.id}\n          showRoleBadge\n        />\n\n        <SettingsList />\n      </SafeNavigationScrollView>\n      <SettingHeader scrollY={screenContext.reAnimatedScrollY} />\n    </>\n  )\n}\nconst SettingHeader = ({ scrollY }: { scrollY: SharedValue<number> }) => {\n  const { t } = useTranslation()\n  const frame = useSafeAreaFrame()\n  const insets = useSafeAreaInsets()\n  const headerHeight = getDefaultHeaderHeight({\n    landscape: frame.width > frame.height,\n    modalPresentation: false,\n    topInset: insets.top,\n  })\n  const styles = useAnimatedStyle(() => {\n    return {\n      opacity: scrollY.value / 100,\n      height: headerHeight,\n      paddingTop: insets.top,\n    }\n  })\n  const whoami = useWhoami()\n  return (\n    <View\n      className=\"pt-safe absolute inset-x-0 top-0\"\n      style={{\n        height: headerHeight,\n      }}\n    >\n      <Animated.View\n        pointerEvents=\"none\"\n        className=\"border-b-hairline absolute inset-x-0 top-0 flex-row items-center border-opaque-separator px-4 pb-2\"\n        style={styles}\n      >\n        <BlurEffect />\n        <Text className=\"flex-1 text-center text-[17px] font-semibold text-label\">\n          {t(\"tabs.settings\")}\n        </Text>\n      </Animated.View>\n      {!!whoami?.id && <EditProfileButton />}\n    </View>\n  )\n}\nconst EditProfileButton = () => {\n  const { t } = useTranslation(\"common\")\n  const navigation = useNavigation()\n  return (\n    <Pressable\n      className=\"absolute bottom-2 right-4 overflow-hidden rounded-full px-3 py-1.5\"\n      onPress={() => navigation.pushControllerView(EditProfileScreen)}\n    >\n      <BlurEffect />\n      <Text className=\"text-xs font-medium text-label\">{t(\"words.edit\")}</Text>\n    </Pressable>\n  )\n}\nexport const SettingsTabScreen: TabScreenComponent = Settings\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/(tabs)/subscriptions.tsx",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useWhoami } from \"@follow/store/user/hooks\"\nimport { useMemo } from \"react\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { ErrorBoundary } from \"@/src/components/common/ErrorBoundary\"\nimport { NoLoginInfo } from \"@/src/components/common/NoLoginInfo\"\nimport { ListErrorView } from \"@/src/components/errors/ListErrorView\"\nimport { useResetTabOpacityWhenFocused } from \"@/src/components/layouts/tabbar/hooks\"\nimport type { TabScreenComponent } from \"@/src/lib/navigation/bottom-tab/types\"\nimport { EntryListContext } from \"@/src/modules/screen/atoms\"\nimport { PagerList } from \"@/src/modules/screen/PagerList\"\nimport { TimelineHeader } from \"@/src/modules/screen/TimelineSelectorProvider\"\nimport { SubscriptionList } from \"@/src/modules/subscription/SubscriptionLists\"\n\nexport default function Subscriptions() {\n  const whoami = useWhoami()\n  const systemGroupedBackground = useColor(\"systemGroupedBackground\")\n  useResetTabOpacityWhenFocused()\n  return (\n    <EntryListContext value={useMemo(() => ({ type: \"subscriptions\" }), [])}>\n      <TimelineHeader />\n      {whoami ? (\n        <PagerList\n          renderItem={renderItem}\n          style={{\n            backgroundColor: systemGroupedBackground,\n          }}\n        />\n      ) : (\n        <NoLoginInfo target=\"subscriptions\" />\n      )}\n    </EntryListContext>\n  )\n}\n\nexport const SubscriptionsTabScreen: TabScreenComponent = Subscriptions\n\nconst renderItem = (view: FeedViewType, active: boolean) => (\n  <ErrorBoundary fallbackRender={ListErrorView} key={view}>\n    <SubscriptionList view={view} active={active} />\n  </ErrorBoundary>\n)\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx",
    "content": "import { FeedViewType, UserRole } from \"@follow/constants\"\nimport { useEntry, useEntryReadHistory, usePrefetchEntryDetail } from \"@follow/store/entry/hooks\"\nimport { entrySyncServices } from \"@follow/store/entry/store\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { usePrefetchEntryTranslation } from \"@follow/store/translation/hooks\"\nimport { useAutoMarkAsRead } from \"@follow/store/unread/hooks\"\nimport { useIsLoggedIn, useUserRole } from \"@follow/store/user/hooks\"\nimport { PortalProvider } from \"@gorhom/portal\"\nimport * as WebBrowser from \"expo-web-browser\"\nimport { atom, useAtomValue, useSetAtom } from \"jotai\"\nimport { useCallback, useEffect, useMemo } from \"react\"\nimport { View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\nimport { useColor } from \"react-native-uikit-colors\"\n\nimport { useActionLanguage, useGeneralSettingKey } from \"@/src/atoms/settings/general\"\nimport { useUISettingKey } from \"@/src/atoms/settings/ui\"\nimport { BottomTabBarHeightContext } from \"@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext\"\nimport { SafeNavigationScrollView } from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { EntryContentWebView } from \"@/src/components/native/webview/EntryContentWebView\"\nimport { RelativeDateTime } from \"@/src/components/ui/datetime/RelativeDateTime\"\nimport { FeedIcon } from \"@/src/components/ui/icon/feed-icon\"\nimport { ItemPressableStyle } from \"@/src/components/ui/pressable/enum\"\nimport { ItemPressable } from \"@/src/components/ui/pressable/ItemPressable\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { CalendarTimeAddCuteReIcon } from \"@/src/icons/calendar_time_add_cute_re\"\nimport { Eye2CuteReIcon } from \"@/src/icons/eye_2_cute_re\"\nimport { openLink } from \"@/src/lib/native\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { EntryContentContext, useEntryContentContext } from \"@/src/modules/entry-content/ctx\"\nimport { EntryAISummary } from \"@/src/modules/entry-content/EntryAISummary\"\nimport { EntryNavigationHeader } from \"@/src/modules/entry-content/EntryNavigationHeader\"\nimport { usePullUpToNext } from \"@/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation\"\n\nimport { EntrySocialTitle, EntryTitle } from \"../../../../modules/entry-content/EntryTitle\"\n\nexport const EntryDetailScreen: NavigationControllerView<{\n  entryId: string\n  entryIds?: string[]\n  view: FeedViewType\n  isInbox?: boolean\n}> = ({ entryId, entryIds, view: viewType, isInbox }) => {\n  usePrefetchEntryDetail(entryId, isInbox)\n  const entry = useEntry(entryId, (state) => ({\n    title: state.title,\n    url: state.url,\n    summary: state.settings?.summary,\n    translation: state.settings?.translation,\n    readability: state.settings?.readability,\n    sourceContent: state.settings?.sourceContent,\n  }))\n  const isLoggedIn = useIsLoggedIn()\n  useAutoMarkAsRead(entryId, !!entry && isLoggedIn)\n  const insets = useSafeAreaInsets()\n  const ctxValue = useMemo(\n    () => ({\n      showAISummaryAtom: atom(entry?.summary || false),\n      showAITranslationAtom: atom(!!entry?.translation || false),\n      showReadabilityAtom: atom(entry?.readability || false),\n      showSourceContentAtom: atom(entry?.sourceContent || false),\n      titleHeightAtom: atom(0),\n    }),\n    [entry?.readability, entry?.sourceContent, entry?.summary, entry?.translation],\n  )\n  const navigation = useNavigation()\n  const nextEntryId = useMemo(() => {\n    if (!entryIds) return\n    const currentEntryIdx = entryIds.indexOf(entryId)\n    return entryIds[currentEntryIdx + 1]\n  }, [entryId, entryIds])\n  const {\n    EntryPullUpToNext,\n    scrollViewEventHandlers,\n    pullUpViewProps,\n    GestureWrapper,\n    gestureWrapperProps,\n  } = usePullUpToNext({\n    enabled: !!nextEntryId,\n    onRefresh: useCallback(() => {\n      if (!nextEntryId) return\n      navigation.replaceControllerView(\n        EntryDetailScreen,\n        {\n          entryId: nextEntryId,\n          entryIds,\n          view: viewType,\n        },\n        {\n          // Ensure that the replace animation is used\n          stackAnimation: \"fade_from_bottom\",\n          transitionDuration: 300,\n        },\n      )\n    }, [entryIds, navigation, nextEntryId, viewType]),\n  })\n  return (\n    <EntryContentContext value={ctxValue}>\n      <PortalProvider>\n        <BottomTabBarHeightContext value={insets.bottom}>\n          <GestureWrapper {...gestureWrapperProps}>\n            <SafeNavigationScrollView\n              Header={<EntryNavigationHeader entryId={entryId} />}\n              ScrollViewBottom={<EntryPullUpToNext {...pullUpViewProps} />}\n              automaticallyAdjustContentInsets={false}\n              contentContainerMaxWidth={680}\n              contentContainerClassName=\"flex min-h-full pb-16\"\n              {...scrollViewEventHandlers}\n            >\n              <ItemPressable\n                itemStyle={ItemPressableStyle.UnStyled}\n                onPress={() => entry?.url && openLink(entry.url)}\n                className=\"rounded-xl px-5 py-4\"\n              >\n                {viewType === FeedViewType.SocialMedia ? (\n                  <EntrySocialTitle entryId={entryId} />\n                ) : (\n                  <>\n                    <EntryTitle title={entry?.title || \"\"} entryId={entryId} />\n                    <EntryInfo entryId={entryId} />\n                  </>\n                )}\n              </ItemPressable>\n              <View className=\"px-5\">\n                <EntryAISummary entryId={entryId} />\n              </View>\n              {entry && (\n                <View className=\"mt-3 w-full px-5\">\n                  <EntryContentWebViewWithContext entryId={entryId} />\n                </View>\n              )}\n              {viewType === FeedViewType.SocialMedia && (\n                <View className=\"mt-2 px-5\">\n                  <EntryInfoSocial entryId={entryId} />\n                </View>\n              )}\n            </SafeNavigationScrollView>\n          </GestureWrapper>\n        </BottomTabBarHeightContext>\n      </PortalProvider>\n    </EntryContentContext>\n  )\n}\nconst EntryContentWebViewWithContext = ({ entryId }: { entryId: string }) => {\n  const { showReadabilityAtom, showAITranslationAtom, showSourceContentAtom } =\n    useEntryContentContext()\n  const showReadabilityOnce = useAtomValue(showReadabilityAtom)\n  const translationSetting = useGeneralSettingKey(\"translation\")\n  const translationMode = useGeneralSettingKey(\"translationMode\")\n  const showTranslationOnce = useAtomValue(showAITranslationAtom)\n  const actionLanguage = useActionLanguage()\n  const userRole = useUserRole()\n  const showTranslation = translationSetting || showTranslationOnce\n  const translationPrefetchEnabled =\n    showTranslation &&\n    (userRole == null || (userRole !== UserRole.Free && userRole !== UserRole.Trial))\n  const entry = useEntry(entryId, (state) => ({\n    content: state.content,\n    readabilityContent: state.readabilityContent,\n    url: state.url,\n  }))\n  usePrefetchEntryTranslation({\n    entryIds: [entryId],\n    withContent: true,\n    target: showReadabilityOnce && entry?.readabilityContent ? \"readabilityContent\" : \"content\",\n    language: actionLanguage,\n    enabled: translationPrefetchEnabled,\n    mode: translationMode,\n  })\n\n  // Auto toggle readability when content is empty\n  const setShowReadability = useSetAtom(showReadabilityAtom)\n  const { isPending } = usePrefetchEntryDetail(entryId)\n  useEffect(() => {\n    if (!isPending && !entry?.content) {\n      setShowReadability(true)\n    }\n  }, [isPending, entry?.content, setShowReadability])\n  useEffect(() => {\n    if (showReadabilityOnce) {\n      entrySyncServices.fetchEntryReadabilityContent(entryId)\n    }\n  }, [showReadabilityOnce, entryId])\n\n  const showSourceContent = useAtomValue(showSourceContentAtom)\n  useEffect(() => {\n    if (showSourceContent && entry?.url) {\n      WebBrowser.openBrowserAsync(entry?.url)\n    }\n  }, [entry?.url, showSourceContent])\n\n  return (\n    <EntryContentWebView\n      entryId={entryId}\n      showReadability={showReadabilityOnce}\n      showTranslation={showTranslation}\n    />\n  )\n}\nconst EntryInfo = ({ entryId }: { entryId: string }) => {\n  const entry = useEntry(entryId, (state) => ({\n    publishedAt: state.publishedAt,\n    feedId: state.feedId,\n  }))\n  const isLoggedIn = useIsLoggedIn()\n  const feed = useFeedById(entry?.feedId)\n  const secondaryLabelColor = useColor(\"secondaryLabel\")\n  const readCount = useEntryReadHistory(entryId, 20, isLoggedIn)?.entryReadHistories?.readCount ?? 0\n  const hideRecentReader = useUISettingKey(\"hideRecentReader\")\n  if (!entry) return null\n  const { publishedAt } = entry\n  return (\n    <View className=\"mt-4 flex flex-row items-center gap-4\">\n      {feed && (\n        <View className=\"flex shrink flex-row items-center gap-2\">\n          <FeedIcon feed={feed} />\n          <Text className=\"shrink text-xs font-medium leading-tight text-label\" numberOfLines={1}>\n            {feed.title?.trim()}\n          </Text>\n        </View>\n      )}\n      <View className=\"flex flex-row items-center gap-1\">\n        <CalendarTimeAddCuteReIcon width={16} height={16} color={secondaryLabelColor} />\n        <RelativeDateTime\n          date={publishedAt}\n          className=\"text-xs leading-tight text-secondary-label\"\n        />\n      </View>\n      {isLoggedIn && !hideRecentReader && (\n        <View className=\"flex flex-row items-center gap-1\">\n          <Eye2CuteReIcon width={16} height={16} color={secondaryLabelColor} />\n          <Text className=\"text-xs leading-tight text-secondary-label\">{readCount}</Text>\n        </View>\n      )}\n    </View>\n  )\n}\nconst EntryInfoSocial = ({ entryId }: { entryId: string }) => {\n  const entry = useEntry(entryId, (state) => ({\n    publishedAt: state.publishedAt,\n  }))\n  if (!entry) return null\n  return (\n    <View className=\"mt-3\">\n      <Text className=\"text-sm text-secondary-label\">\n        {entry.publishedAt.toLocaleString(undefined, {\n          dateStyle: \"medium\",\n          timeStyle: \"short\",\n        })}\n      </Text>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { useFeedById } from \"@follow/store/feed/hooks\"\nimport { useIsSubscribed } from \"@follow/store/subscription/hooks\"\nimport { isBizId, withOpacity } from \"@follow/utils\"\nimport { useMemo } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, StyleSheet } from \"react-native\"\nimport { RootSiblingParent } from \"react-native-root-siblings\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { ThemedBlurView } from \"@/src/components/common/ThemedBlurView\"\nimport { BottomTabBarHeightContext } from \"@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { EntryListSelector } from \"@/src/modules/entry-list/EntryListSelector\"\nimport { EntryListContext, useEntries, useSelectedView } from \"@/src/modules/screen/atoms\"\nimport { TimelineHeader } from \"@/src/modules/screen/TimelineSelectorProvider\"\nimport { FollowScreen } from \"@/src/screens/(modal)/FollowScreen\"\nimport { accentColor } from \"@/src/theme/colors\"\n\nexport const FeedScreen: NavigationControllerView<{\n  feedId: string\n}> = ({ feedId: feedIdentifier }) => {\n  const insets = useSafeAreaInsets()\n  const feed = useFeedById(feedIdentifier)\n  const navigation = useNavigation()\n  const isSubscribed = useIsSubscribed(feedIdentifier)\n  const { t } = useTranslation(\"common\")\n\n  return (\n    <EntryListContext value={useMemo(() => ({ type: \"feed\" }), [])}>\n      <RootSiblingParent>\n        <BottomTabBarHeightContext value={insets.bottom}>\n          <TimelineHeader feedId={feed?.id} />\n          <FeedScreenEntryList />\n          {!isSubscribed && isBizId(feedIdentifier) && (\n            <Pressable\n              className=\"absolute left-1/2 z-10 min-w-[112px] -translate-x-1/2 items-center justify-center overflow-hidden rounded-full bg-accent px-5 py-3\"\n              hitSlop={12}\n              style={{\n                bottom: Math.max(20, insets.bottom + 12),\n              }}\n              onPress={() => {\n                navigation.presentControllerView(FollowScreen, {\n                  id: feedIdentifier,\n                  type: \"feed\",\n                })\n              }}\n            >\n              <ThemedBlurView\n                useGlass\n                style={StyleSheet.absoluteFillObject}\n                tintColor={withOpacity(accentColor, 0.6)}\n              />\n              <Text className=\"font-bold text-white\">{t(\"words.follow\")}</Text>\n            </Pressable>\n          )}\n        </BottomTabBarHeightContext>\n      </RootSiblingParent>\n    </EntryListContext>\n  )\n}\n\nfunction FeedScreenEntryList() {\n  const { entriesIds } = useEntries()\n  const view = useSelectedView() ?? FeedViewType.Articles\n  return <EntryListSelector viewId={view} entryIds={entriesIds} />\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/(stack)/recommendation/RecommendationCategoryScreen.tsx",
    "content": "import { use } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { View } from \"react-native\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { NavigationBlurEffectHeaderView } from \"@/src/components/layouts/views/SafeNavigationScrollView\"\nimport { useDefaultHeaderHeight } from \"@/src/hooks/useDefaultHeaderHeight\"\nimport { ScreenItemContext } from \"@/src/lib/navigation/ScreenItemContext\"\nimport type { NavigationControllerView } from \"@/src/lib/navigation/types\"\nimport { RecommendationTab } from \"@/src/modules/discover/Recommendations\"\n\nexport const RecommendationCategoryScreen: NavigationControllerView<{\n  category: string\n}> = ({ category }) => {\n  const { t } = useTranslation(\"common\")\n  const { reAnimatedScrollY } = use(ScreenItemContext)!\n  const defaultHeaderHeight = useDefaultHeaderHeight()\n  const insets = useSafeAreaInsets()\n  return (\n    <View className=\"flex-1\">\n      <NavigationBlurEffectHeaderView\n        title={t(`discover.category.${category}` as any)}\n        headerTitleAbsolute={false}\n      />\n\n      <RecommendationTab\n        isSelected\n        insets={{ top: defaultHeaderHeight }}\n        reanimatedScrollY={reAnimatedScrollY}\n        contentContainerStyle={{\n          paddingTop: defaultHeaderHeight,\n          paddingBottom: insets.bottom,\n        }}\n        tab={{\n          name: t(`discover.category.${category}` as any),\n          value: category,\n        }}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/+native-intent.tsx",
    "content": "// Redirects to the home screen when the app is opened with a deep link.\n// Test this by running the following command in the terminal:\n// pnpx uri-scheme open 'follow://add?id=1' --ios\n//\n// See https://docs.expo.dev/router/advanced/native-intent/#rewrite-incoming-native-deep-links\n\nimport { resetIntentUrl } from \"../hooks/useIntentHandler\"\n\nexport function redirectSystemPath(_options: { path: string; initial: boolean }) {\n  resetIntentUrl()\n  return \"/\"\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/OnboardingScreen.tsx",
    "content": "import { isNewUserQueryKey } from \"@follow/store/user/constants\"\nimport { tracker } from \"@follow/tracker\"\nimport { useCallback, useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { Pressable, View } from \"react-native\"\nimport Animated, { FadeInRight, FadeOutLeft } from \"react-native-reanimated\"\nimport { useSafeAreaInsets } from \"react-native-safe-area-context\"\n\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useReadableContainerStyle } from \"@/src/lib/responsive\"\n\nimport { useNavigation } from \"../lib/navigation/hooks\"\nimport type { NavigationControllerView } from \"../lib/navigation/types\"\nimport { markOnboardingFinished } from \"../lib/onboarding\"\nimport { queryClient } from \"../lib/query-client\"\nimport { StepFinished } from \"../modules/onboarding/step-finished\"\nimport { StepInterests } from \"../modules/onboarding/step-interests\"\nimport { StepPreferences } from \"../modules/onboarding/step-preferences\"\nimport { StepWelcome } from \"../modules/onboarding/step-welcome\"\n\nconst ONBOARDING_STEPS = [1, 2, 3, 4]\n\nexport const OnboardingScreen: NavigationControllerView = () => {\n  const { t } = useTranslation(\"common\")\n  const insets = useSafeAreaInsets()\n  const readableContentStyle = useReadableContainerStyle(680)\n  const [currentStep, setCurrentStep] = useState(1)\n  const totalSteps = ONBOARDING_STEPS.length\n  const navigation = useNavigation()\n  const handleNext = useCallback(() => {\n    if (currentStep < totalSteps) {\n      setCurrentStep(currentStep + 1)\n      tracker.onBoarding({\n        step: currentStep,\n        done: false,\n      })\n    } else {\n      // Complete onboarding\n      tracker.onBoarding({\n        step: currentStep,\n        done: true,\n      })\n      void markOnboardingFinished()\n      queryClient\n        .invalidateQueries({\n          queryKey: isNewUserQueryKey,\n        })\n        .then(() => {\n          navigation.back()\n        })\n    }\n  }, [currentStep, navigation, totalSteps])\n  useEffect(() => {\n    tracker.onBoarding({\n      step: 0,\n      done: false,\n    })\n  }, [])\n  return (\n    <View\n      className=\"flex-1 bg-system-grouped-background px-6\"\n      style={{\n        paddingTop: insets.top,\n        paddingBottom: insets.bottom,\n      }}\n    >\n      <ProgressIndicator\n        currentStep={currentStep}\n        steps={ONBOARDING_STEPS}\n        setCurrentStep={setCurrentStep}\n      />\n\n      <Animated.View\n        className={\"flex-1\"}\n        key={`step-${currentStep}`}\n        exiting={FadeOutLeft}\n        entering={FadeInRight}\n        style={readableContentStyle}\n      >\n        {/* Content */}\n        {currentStep === 1 && <StepWelcome />}\n        {currentStep === 2 && <StepPreferences />}\n        {currentStep === 3 && <StepInterests />}\n        {currentStep === 4 && <StepFinished />}\n      </Animated.View>\n\n      {/* Navigation buttons */}\n      <View className=\"mb-6 px-6\" style={readableContentStyle}>\n        <Pressable\n          testID=\"onboarding-next\"\n          onPress={handleNext}\n          className=\"w-full items-center rounded-xl bg-accent py-4\"\n        >\n          <Text className=\"text-lg font-bold text-white\">\n            {currentStep < totalSteps - 1\n              ? t(\"words.next\")\n              : currentStep === totalSteps - 1\n                ? t(\"words.finishSetup\")\n                : t(\"words.letsGo\")}\n          </Text>\n        </Pressable>\n      </View>\n    </View>\n  )\n}\nfunction ProgressIndicator({\n  currentStep,\n  steps,\n  setCurrentStep,\n}: {\n  currentStep: number\n  steps: number[]\n  setCurrentStep: (step: number) => void\n}) {\n  return (\n    <View className=\"mb-6 mt-4 flex flex-row justify-center gap-2\">\n      {steps.map((step) => (\n        <Pressable\n          key={`step-${step}-indicator`}\n          onPress={() => {\n            setCurrentStep(step)\n          }}\n        >\n          <View\n            className={`mx-1 h-2 w-10 rounded-full ${currentStep >= step ? \"bg-accent\" : \"bg-tertiary-system-fill\"}`}\n          />\n        </Pressable>\n      ))}\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/screens/PlayerScreen.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport { LinearGradient } from \"expo-linear-gradient\"\nimport { useEffect, useMemo } from \"react\"\nimport { SafeAreaView, StyleSheet, View } from \"react-native\"\nimport Reanimated, {\n  cancelAnimation,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\"\nimport { SheetScreen } from \"react-native-sheet-transitions\"\n\nimport { Image } from \"@/src/components/ui/image/Image\"\nimport { Text } from \"@/src/components/ui/typography/Text\"\nimport { useNavigation } from \"@/src/lib/navigation/hooks\"\n\nimport { gentleSpringPreset } from \"../constants/spring\"\nimport type { NavigationControllerView } from \"../lib/navigation/types\"\nimport { useActiveTrack, useIsPlaying } from \"../lib/player\"\nimport { PlayerScreenContext, usePlayerScreenContext } from \"../modules/player/context\"\nimport { ControlGroup, ProgressBar, VolumeBar } from \"../modules/player/control\"\nimport { useCoverGradient } from \"../modules/player/hooks\"\nimport { usePrefetchImageColors } from \"../store/image/hooks\"\n\nfunction CoverArt({ cover }: { cover?: string }) {\n  const scale = useSharedValue(1)\n  const { playing } = useIsPlaying()\n  useEffect(() => {\n    cancelAnimation(scale)\n    scale.value = withSpring(playing ? 1 : 0.7, gentleSpringPreset)\n  }, [playing, scale])\n  const animatedStyle = useAnimatedStyle(() => {\n    return {\n      transform: [\n        {\n          scale: scale.value,\n        },\n      ],\n    }\n  })\n  return (\n    <Reanimated.View className=\"mx-auto my-12 aspect-square w-[87%] shadow\" style={[animatedStyle]}>\n      <Image\n        source={{\n          uri: cover ?? \"\",\n        }}\n        className=\"size-full rounded-lg\"\n      />\n    </Reanimated.View>\n  )\n}\nexport const PlayerScreen: NavigationControllerView = () => {\n  const activeTrack = useActiveTrack()\n  usePrefetchImageColors(activeTrack?.artwork)\n  const { gradientColors, isGradientLight } = useCoverGradient(activeTrack?.artwork)\n  const playerScreenContextValue = useMemo(\n    () => ({\n      isBackgroundLight: isGradientLight,\n    }),\n    [isGradientLight],\n  )\n  const navigation = useNavigation()\n  if (!activeTrack) {\n    return null\n  }\n  return (\n    <SheetScreen onClose={() => navigation.dismiss()}>\n      <PlayerScreenContext value={playerScreenContextValue}>\n        <LinearGradient\n          style={StyleSheet.absoluteFill}\n          colors={gradientColors}\n          start={{\n            x: 0,\n            y: 0,\n          }}\n          end={{\n            x: 1,\n            y: 0,\n          }}\n        />\n        <SafeAreaView className=\"flex-1\">\n          <View className=\"flex-1\">\n            <DismissIndicator />\n            <CoverArt cover={activeTrack.artwork} />\n            <View className=\"mx-10 flex-1\">\n              <Text\n                className={cn(\n                  \"text-xl font-bold opacity-90\",\n                  isGradientLight ? \"text-black\" : \"text-white\",\n                )}\n                numberOfLines={1}\n              >\n                {activeTrack.title}\n              </Text>\n              <Text\n                className={cn(\n                  \"mt-2 text-xl font-semibold opacity-60\",\n                  isGradientLight ? \"text-black\" : \"text-white\",\n                )}\n                numberOfLines={1}\n              >\n                {activeTrack.artist}\n              </Text>\n              <ProgressBar />\n              <ControlGroup />\n              <View className=\"flex-1\" />\n              <VolumeBar />\n            </View>\n          </View>\n        </SafeAreaView>\n      </PlayerScreenContext>\n    </SheetScreen>\n  )\n}\nPlayerScreen.transparent = true\nfunction DismissIndicator() {\n  const { isBackgroundLight } = usePlayerScreenContext()\n  return (\n    <View className=\"absolute inset-x-0 top-2 flex items-center justify-center\">\n      <View\n        className={cn(\n          \"h-[5] w-[40] rounded-full\",\n          isBackgroundLight ? \"bg-black/60\" : \"bg-white/60\",\n        )}\n      />\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/sitemap.tsx",
    "content": "import { ActivityIndicator, Alert, View } from \"react-native\"\n\nimport { RotateableLoading } from \"./components/common/RotateableLoading\"\nimport { GlobalErrorScreen } from \"./components/errors/GlobalErrorScreen\"\nimport { Navigation } from \"./lib/navigation/Navigation\"\nimport { NavigationSitemapRegistry } from \"./lib/navigation/sitemap/registry\"\nimport type { NavigationControllerView } from \"./lib/navigation/types\"\nimport { OTPWindow } from \"./modules/settings/components/OTPWindow\"\nimport { ForgetPasswordScreen } from \"./screens/(modal)/ForgetPasswordScreen\"\nimport { LoginScreen } from \"./screens/(modal)/LoginScreen\"\nimport { TwoFactorAuthScreen } from \"./screens/(modal)/TwoFactorAuthScreen\"\nimport { OnboardingScreen } from \"./screens/OnboardingScreen\"\n\nexport function registerSitemap() {\n  ;[LoginScreen, ForgetPasswordScreen, TwoFactorAuthScreen].forEach((Component) => {\n    NavigationSitemapRegistry.registerByComponent(Component, void 0, {\n      stackPresentation: \"modal\",\n    })\n  })\n  ;[OnboardingScreen].forEach((Component) => {\n    NavigationSitemapRegistry.registerByComponent(Component, void 0, {\n      stackPresentation: \"fullScreenModal\",\n    })\n  })\n  ;[\n    /// Other\n    screenHoC(OTPWindow, {\n      onDismiss() {\n        Navigation.rootNavigation.back()\n      },\n      onSuccess() {\n        Navigation.rootNavigation.back()\n      },\n      verifyFn(code) {\n        Alert.alert(code)\n        return Promise.resolve()\n      },\n    }),\n    LoadingComponentDemo,\n  ].forEach((Component) => {\n    NavigationSitemapRegistry.registerByComponent(Component, void 0, {\n      stackPresentation: \"push\",\n    })\n  })\n\n  // Error Boundary Template\n  NavigationSitemapRegistry.registerByComponent(\n    GlobalErrorScreen,\n    {\n      error: new Error(\"Test Error\"),\n      resetError: () => {},\n    },\n    {\n      stackPresentation: \"push\",\n    },\n  )\n}\n\nfunction screenHoC<P extends object>(Component: React.ComponentType<P>, props: P) {\n  const Wrapper: NavigationControllerView<P> = function () {\n    return (\n      <View className=\"flex-1 bg-system-background\">\n        <Component {...props} />\n      </View>\n    )\n  }\n  Wrapper.id = `ScreenHoC(${Component.name})`\n  Wrapper.displayName = `ScreenHoC(${Component.name})`\n  return Wrapper\n}\n\nconst LoadingComponentDemo = () => {\n  return (\n    <View className=\"flex flex-1 items-center justify-center\">\n      <View className=\"flex flex-row gap-2\">\n        <ActivityIndicator size={\"small\"} />\n        <RotateableLoading size={20} />\n      </View>\n\n      <View className=\"flex flex-row gap-2\">\n        <ActivityIndicator size={\"small\"} color=\"red\" />\n        <RotateableLoading size={20} color=\"red\" />\n      </View>\n\n      <View className=\"flex flex-row gap-2\">\n        <ActivityIndicator size={\"large\"} color=\"red\" />\n        <RotateableLoading size={36} color=\"red\" />\n      </View>\n    </View>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/src/spec/typography.ts",
    "content": "export const typography = {\n  largeTitle: [32, 36], // 32px\n  title1: [28, 32], // 28px\n  title2: [24, 28], // 24px\n  title3: [20, 24], // 20px\n  headline: [18, 26], // 18px\n  body: [16, 24], // 16px\n  callout: [14, 20], // 14px\n  subheadline: [13, 18], // 13px\n  footnote: [12, 16], // 12px\n  caption: [11, 14], // 11px\n\n  // Tailwind CSS typography sizes (converted to px with 16px base)\n  xs: [12, 16], // 0.75rem -> 12px, 1rem -> 16px\n  sm: [14, 20], // 0.875rem -> 14px, 1.25rem -> 20px\n  base: [16, 24], // 1rem -> 16px, 1.5rem -> 24px\n  lg: [18, 28], // 1.125rem -> 18px, 1.75rem -> 28px\n  xl: [20, 28], // 1.25rem -> 20px, 1.75rem -> 28px\n  \"2xl\": [24, 32], // 1.5rem -> 24px, 2rem -> 32px\n  \"3xl\": [30, 36], // 1.875rem -> 30px, 2.25rem -> 36px\n  \"4xl\": [36, 40], // 2.25rem -> 36px, 2.5rem -> 40px\n  \"5xl\": [48, 48], // 3rem -> 48px, lineHeight: 1\n  \"6xl\": [60, 60], // 3.75rem -> 60px, lineHeight: 1\n  \"7xl\": [72, 72], // 4.5rem -> 72px, lineHeight: 1\n  \"8xl\": [96, 96], // 6rem -> 96px, lineHeight: 1\n  \"9xl\": [128, 128], // 8rem -> 128px, lineHeight: 1\n} as const\n"
  },
  {
    "path": "apps/mobile/src/store/image/hooks.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\"\n\nimport { imageSyncService } from \"./store\"\n\nexport const usePrefetchImageColors = (url?: string | null) => {\n  useQuery({\n    queryKey: [\"image\", \"colors\", url],\n    queryFn: () => imageSyncService.getColors(url),\n    enabled: !!url,\n  })\n}\n"
  },
  {
    "path": "apps/mobile/src/store/image/store.ts",
    "content": "import { getImageInfo } from \"@follow/store/image/getters\"\nimport { imageActions } from \"@follow/store/image/store\"\nimport ImageColors from \"react-native-image-colors\"\n\nclass ImageSyncService {\n  async getColors(url?: string | null) {\n    if (!url) {\n      return\n    }\n    const existing = getImageInfo(url)?.colors\n    if (existing) {\n      return existing\n    }\n\n    const result = await ImageColors.getColors(url, { cache: true })\n    await imageActions.upsertMany([{ url, colors: result }])\n    return result\n  }\n}\n\nexport const imageSyncService = new ImageSyncService()\n"
  },
  {
    "path": "apps/mobile/src/theme/colors.ts",
    "content": "export const accentColor = \"#FF5C00\"\n\nexport * from \"react-native-uikit-colors\"\n"
  },
  {
    "path": "apps/mobile/src/theme/utils.ts",
    "content": "export { getCurrentColors, getSystemBackgroundColor } from \"react-native-uikit-colors\"\n"
  },
  {
    "path": "apps/mobile/src/theme/web.ts",
    "content": "export { useCSSInjection } from \"react-native-uikit-colors/web\"\n"
  },
  {
    "path": "apps/mobile/tailwind.config.ts",
    "content": "/** @type {import('tailwindcss').Config} */\nimport { withUIKit } from \"react-native-uikit-colors/tailwind\"\n\nexport default withUIKit({\n  darkMode: \"class\",\n  content: [\"./src/**/*.{js,jsx,ts,tsx}\"],\n  presets: [require(\"nativewind/preset\")],\n\n  plugins: [require(\"@tailwindcss/typography\")],\n  theme: {\n    extend: {\n      fontSize: {\n        // Override Tailwind's default fontSize presets\n        xs: [\"var(--text-xs)\", \"var(--text-xs-line-height)\"], // 12px, 16px\n        sm: [\"var(--text-sm)\", \"var(--text-sm-line-height)\"], // 14px, 20px\n        base: [\"var(--text-base)\", \"var(--text-base-line-height)\"], // 16px, 24px\n        lg: [\"var(--text-lg)\", \"var(--text-lg-line-height)\"], // 18px, 28px\n        xl: [\"var(--text-xl)\", \"var(--text-xl-line-height)\"], // 20px, 28px\n        \"2xl\": [\"var(--text-2xl)\", \"var(--text-2xl-line-height)\"], // 24px, 32px\n        \"3xl\": [\"var(--text-3xl)\", \"var(--text-3xl-line-height)\"], // 30px, 36px\n        \"4xl\": [\"var(--text-4xl)\", \"var(--text-4xl-line-height)\"], // 36px, 40px\n        \"5xl\": [\"var(--text-5xl)\", \"var(--text-5xl-line-height)\"], // 48px, 48px\n        \"6xl\": [\"var(--text-6xl)\", \"var(--text-6xl-line-height)\"], // 60px, 60px\n        \"7xl\": [\"var(--text-7xl)\", \"var(--text-7xl-line-height)\"], // 72px, 72px\n        \"8xl\": [\"var(--text-8xl)\", \"var(--text-8xl-line-height)\"], // 96px, 96px\n        \"9xl\": [\"var(--text-9xl)\", \"var(--text-9xl-line-height)\"], // 128px, 128px\n\n        // Custom font sizes\n        largeTitle: [\"var(--text-large-title)\", \"var(--text-large-title-line-height)\"], // 32px\n        title1: [\"var(--text-title1)\", \"var(--text-title1-line-height)\"], // 28px\n        title2: [\"var(--text-title2)\", \"var(--text-title2-line-height)\"], // 24px\n        title3: [\"var(--text-title3)\", \"var(--text-title3-line-height)\"], // 20px\n        headline: [\"var(--text-headline)\", \"var(--text-headline-line-height)\"], // 18px\n        body: [\"var(--text-body)\", \"var(--text-body-line-height)\"], // 16px\n        callout: [\"var(--text-callout)\", \"var(--text-callout-line-height)\"], // 14px\n        subheadline: [\"var(--text-subheadline)\", \"var(--text-subheadline-line-height)\"], // 13px\n        footnote: [\"var(--text-footnote)\", \"var(--text-footnote-line-height)\"], // 12px\n        caption: [\"var(--text-caption)\", \"var(--text-caption-line-height)\"], // 11px\n      },\n      fontFamily: {\n        mono: \"monospace\",\n      },\n      colors: {\n        accent: \"#FF5C00\",\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "apps/mobile/tailwind.dom.config.ts",
    "content": "import { extendConfig } from \"@follow/configs/tailwindcss/web\"\n\nexport default extendConfig({\n  darkMode: \"media\",\n  content: [\"../../packages/**/*.{ts,tsx}\"],\n})\n"
  },
  {
    "path": "apps/mobile/tsconfig.json",
    "content": "{\n  \"extends\": \"expo/tsconfig.base\",\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"skipLibCheck\": true,\n    \"noImplicitOverride\": true,\n    \"allowImportingTsExtensions\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./*\"],\n      \"@locales/*\": [\"../../locales/*\"]\n    },\n    \"jsx\": \"preserve\",\n    \"types\": [\"@follow/types/global\", \"vite/client\"]\n  },\n  \"include\": [\"**/*.ts\", \"**/*.tsx\", \"nativewind-env.d.ts\"],\n  \"exclude\": [\"./native\", \"./web-app\", \"../../node_modules\"]\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/global.d.ts",
    "content": "import \"vite/client\"\nimport \"../../../../packages/types/react-global\"\nimport \"../../../../packages/types/global\"\n\ninterface Bridge {\n  measure: () => void\n  setContentHeight: (height: number) => void\n  previewImage: (data: { imageUrls: string[]; index: number }) => void\n  seekAudio: (time: number) => void\n}\n\ndeclare global {\n  export const bridge: Bridge\n}\n\nexport {}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script src=\"./src/index.ts\" type=\"module\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/package.json",
    "content": "{\n  \"name\": \"@follow/rn-micro-web-app-html-renderer\",\n  \"type\": \"module\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"dev\": \"vite\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@emotion/is-prop-valid\": \"1.3.1\",\n    \"@follow/components\": \"workspace:*\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"clsx\": \"2.1.1\",\n    \"foxact\": \"0.2.52\",\n    \"jotai\": \"2.17.1\",\n    \"motion\": \"12.34.0\",\n    \"react\": \"19.0.0\",\n    \"react-blurhash\": \"0.3.0\"\n  }\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/postcss.config.cjs",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {\n      config: \"./tailwind.config.ts\",\n    },\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/App.tsx",
    "content": "import { createStore, Provider, useAtomValue } from \"jotai\"\n\nimport { entryAtom, noMediaAtom, readerRenderInlineStyleAtom } from \"./atoms\"\nimport { HTML } from \"./HTML\"\nimport { WebViewBridgeManager } from \"./managers/webview-bridge\"\n\nconst store = createStore()\n\n// Initialize and expose WebView bridge functions\nconst bridgeManager = new WebViewBridgeManager(store)\nbridgeManager.exposeToWindow()\n\nexport const App = () => {\n  const entry = useAtomValue(entryAtom, { store })\n  const readerRenderInlineStyle = useAtomValue(readerRenderInlineStyleAtom, { store })\n  const noMedia = useAtomValue(noMediaAtom, { store })\n\n  return (\n    <Provider store={store}>\n      <HTML renderInlineStyle={readerRenderInlineStyle} noMedia={noMedia}>\n        {entry?.content}\n      </HTML>\n    </Provider>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/HTML.tsx",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.js\"\nimport { clsx } from \"clsx\"\nimport katexStyle from \"katex/dist/katex.min.css?raw\"\nimport * as React from \"react\"\nimport { createElement, Fragment, useEffect, useMemo, useState } from \"react\"\n\nimport { WrappedElementProvider } from \"./common/WrappedElementProvider\"\nimport { MarkdownRenderContainerRefContext } from \"./components/__internal/ctx\"\nimport { parseHtml } from \"./parser\"\n\nexport type HTMLProps<A extends keyof React.JSX.IntrinsicElements = \"div\"> = {\n  children: string | null | undefined\n  as?: A\n\n  accessory?: React.ReactNode\n  noMedia?: boolean\n} & React.JSX.IntrinsicElements[A] &\n  Partial<{\n    renderInlineStyle: boolean\n  }>\nexport const HTML = <A extends keyof React.JSX.IntrinsicElements = \"div\">(props: HTMLProps<A>) => {\n  const {\n    children,\n    renderInlineStyle,\n    as = \"article\",\n    accessory,\n    noMedia,\n\n    ...rest\n  } = props\n  const [remarkOptions, setRemarkOptions] = useState({\n    renderInlineStyle,\n    noMedia,\n  })\n  const [shouldForceReMountKey, setShouldForceReMountKey] = useState(0)\n\n  useEffect(() => {\n    setRemarkOptions((options) => {\n      if (JSON.stringify(options) === JSON.stringify({ renderInlineStyle, noMedia })) {\n        return options\n      }\n\n      setShouldForceReMountKey((key) => key + 1)\n      return { ...options, renderInlineStyle, noMedia }\n    })\n  }, [renderInlineStyle, noMedia])\n\n  const [refElement, setRefElement] = useState<HTMLDivElement | null>(null)\n\n  const markdownElement = useMemo(\n    () =>\n      children &&\n      parseHtml(children, {\n        ...remarkOptions,\n      }).toContent(),\n    [children, remarkOptions],\n  )\n\n  if (!markdownElement) return <div className=\"h-px\" />\n  return (\n    <MarkdownRenderContainerRefContext value={refElement}>\n      <MemoedDangerousHTMLStyle>{katexStyle}</MemoedDangerousHTMLStyle>\n      <WrappedElementProvider>\n        {createElement(\n          as,\n          {\n            ...rest,\n            ref: setRefElement,\n            style: {\n              width: \"100%\",\n              maxWidth: \"100%\",\n              ...rest.style,\n            },\n            className: clsx(\n              \"prose max-w-none mx-auto pb-8 [text-autospace:normal]\",\n              \"dark:prose-invert\",\n            ),\n          },\n          markdownElement,\n        )}\n      </WrappedElementProvider>\n\n      {!!accessory && <Fragment key={shouldForceReMountKey}>{accessory}</Fragment>}\n    </MarkdownRenderContainerRefContext>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/atoms/index.ts",
    "content": "import { atom } from \"jotai\"\n\nimport type { EntryModel } from \"../../types\"\n\nexport const entryAtom = atom<EntryModel | null>(null)\n\nexport const codeThemeLightAtom = atom<string | null>(null)\nexport const codeThemeDarkAtom = atom<string | null>(null)\nexport const readerRenderInlineStyleAtom = atom<boolean>(false)\nexport const noMediaAtom = atom<boolean>(false)\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/common/ProviderComposer.tsx",
    "content": "import type { JSX } from \"react\"\nimport * as React from \"react\"\n\nexport const ProviderComposer: Component<{\n  contexts: JSX.Element[]\n}> = ({ contexts, children }) => {\n  return contexts.reduceRight((kids: any, parent: any) => {\n    return React.cloneElement(parent, { children: kids })\n  }, children)\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { createContextState } from \"foxact/create-context-state\"\nimport { useIsomorphicLayoutEffect } from \"foxact/use-isomorphic-layout-effect\"\nimport type * as React from \"react\"\nimport { memo } from \"react\"\n\nimport { ProviderComposer } from \"./ProviderComposer\"\n\nconst [WrappedElementProviderInternal, useWrappedElement, useSetWrappedElement] =\n  createContextState<HTMLDivElement | null>(undefined as any)\n\nconst [ElementSizeProviderInternal, useWrappedElementSize, useSetWrappedElementSize] =\n  createContextState({\n    h: 0,\n    w: 0,\n  })\n\nconst [ElementPositionProviderInternal, useWrappedElementPosition, useSetElementPosition] =\n  createContextState({\n    x: 0,\n    y: 0,\n  })\n\nconst Providers = [\n  <WrappedElementProviderInternal key=\"ArticleElementProviderInternal\" />,\n  <ElementSizeProviderInternal key=\"ElementSizeProviderInternal\" />,\n  <ElementPositionProviderInternal key=\"ElementPositionProviderInternal\" />,\n]\n\ninterface WrappedElementProviderProps {\n  as?: keyof React.JSX.IntrinsicElements\n}\n\nexport const WrappedElementProvider: Component<WrappedElementProviderProps> = ({\n  children,\n  className,\n  ...props\n}) => (\n  <ProviderComposer contexts={Providers}>\n    <ElementResizeObserver />\n    <Content {...props} className={className}>\n      {children}\n    </Content>\n  </ProviderComposer>\n)\nconst ElementResizeObserver = () => {\n  const setSize = useSetWrappedElementSize()\n  const setPos = useSetElementPosition()\n  const $element = useWrappedElement()\n  useIsomorphicLayoutEffect(() => {\n    if (!$element) return\n    const { height, width, left, top } = $element.getBoundingClientRect()\n    setSize({ h: height, w: width })\n\n    const pageX = window.scrollX + left\n    const pageY = window.scrollY + top\n    setPos({ x: pageX, y: pageY })\n\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[0]\n\n      if (!entry) return\n\n      const { height, width } = entry.contentRect\n      const { left, top } = $element.getBoundingClientRect()\n      const pageX = window.scrollX + left\n      const pageY = window.scrollY + top\n\n      setSize((size) => {\n        if (size.h === height && size.w === width) return size\n        return { h: height, w: width }\n      })\n      setPos((pos) => {\n        if (pos.x === pageX && pos.y === pageY) return pos\n        return { x: pageX, y: pageY }\n      })\n    })\n    observer.observe($element)\n    return () => {\n      observer.unobserve($element)\n      observer.disconnect()\n    }\n  }, [$element])\n\n  return null\n}\n\nconst Content: Component<WrappedElementProviderProps> = memo(\n  ({ children, className, as = \"div\" }) => {\n    const setElement = useSetWrappedElement()\n\n    const As = as as any\n    return (\n      <As className={cn(\"relative w-full max-w-full\", className)} ref={setElement}>\n        {children}\n      </As>\n    )\n  },\n)\n\nContent.displayName = \"ArticleElementProviderContent\"\n\nexport { useSetWrappedElement, useWrappedElement, useWrappedElementPosition, useWrappedElementSize }\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/__internal/calculateDimensions.tsx",
    "content": "export const calculateDimensions = ({\n  width,\n  height,\n  max,\n}: {\n  width: number | undefined\n  height: number | undefined\n  max: { width: number; height: number }\n}) => {\n  if (!width || !height) return { width: undefined, height: undefined }\n\n  const { width: maxW, height: maxH } = max\n\n  const wRatio = maxW / width || 1\n  const hRatio = maxH / height || 1\n\n  const ratio = Math.min(wRatio, hRatio, 1)\n\n  return {\n    width: width * ratio,\n    height: height * ratio,\n  }\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/__internal/ctx.ts",
    "content": "import { createContext } from \"react\"\n\nexport const IsInParagraphContext = createContext(false)\n\nexport const MarkdownRenderContainerRefContext = createContext<HTMLDivElement | null>(null)\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/heading.tsx",
    "content": "import clsx from \"clsx\"\nimport { useId, useRef } from \"react\"\n\nconst size = {\n  1: \"text-[1.6em]\",\n  2: \"text-[1.5em]\",\n  3: \"text-[1.3em]\",\n  4: \"text-[1.1em]\",\n  5: \"text-[1.05em]\",\n  6: \"text-[1em]\",\n}\nexport const createHeadingRenderer =\n  (level: number) =>\n  (\n    props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>,\n  ) => {\n    const rid = useId()\n\n    const As = `h${level}` as any\n    const { node, ...rest } = props as any\n\n    const ref = useRef<HTMLHeadingElement>(null)\n\n    return (\n      <As\n        ref={ref}\n        {...rest}\n        data-rid={rid}\n        className={clsx(rest.className, \"group relative\", size[level as keyof typeof size])}\n      >\n        {rest.children}\n      </As>\n    )\n  }\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/image.tsx",
    "content": "import clsx from \"clsx\"\nimport { useAtomValue } from \"jotai\"\nimport { useMemo, useRef, useState } from \"react\"\nimport { Blurhash } from \"react-blurhash\"\n\nimport { entryAtom } from \"~/atoms\"\nimport { useWrappedElementSize } from \"~/common/WrappedElementProvider\"\nimport type { HTMLProps } from \"~/HTML\"\n\nimport { calculateDimensions } from \"./__internal/calculateDimensions\"\n\nexport const MarkdownImage = (props: HTMLProps<\"img\">) => {\n  const { src, ...rest } = props\n\n  const imageRef = useRef<HTMLImageElement>(null)\n\n  const entry = useAtomValue(entryAtom)\n\n  const [isLoading, setIsLoading] = useState(true)\n\n  const image = entry?.media?.find((media) => media.url === src)\n\n  const { w } = useWrappedElementSize()\n  const { height: scaleHeight, width: scaleWidth } = useMemo(\n    () =>\n      calculateDimensions({\n        width: image?.width,\n        height: image?.height,\n        max: {\n          width: w,\n          height: Infinity,\n        },\n      }),\n    [image?.width, image?.height, w],\n  )\n\n  return (\n    <button\n      type=\"button\"\n      className=\"relative mx-auto block overflow-hidden bg-gray-300 dark:bg-neutral-800\"\n      style={{\n        width: scaleWidth || undefined,\n        height: scaleHeight || undefined,\n        marginLeft: \"auto\",\n        marginRight: \"auto\",\n        maxWidth: \"100%\",\n      }}\n      data-image-height={image?.height}\n      data-image-width={image?.width}\n      data-container-width={w}\n      onClick={() => {\n        if (!src) return\n        bridge.previewImage({\n          imageUrls: [src],\n          index: 0,\n        })\n      }}\n    >\n      {image?.blurhash && scaleWidth && scaleHeight && (\n        <Blurhash\n          hash={image.blurhash}\n          width={scaleWidth}\n          height={scaleHeight}\n          resolutionX={32}\n          resolutionY={32}\n          punch={1}\n          className=\"pointer-events-none absolute inset-0 z-0\"\n        />\n      )}\n      <img\n        {...rest}\n        onLoad={() => setIsLoading(false)}\n        style={{\n          width: scaleWidth || undefined,\n          height: scaleHeight || undefined,\n        }}\n        loading=\"lazy\"\n        className={clsx(\n          \"!my-0 transition-opacity duration-500\",\n          isLoading && \"opacity-0\",\n          scaleWidth && scaleHeight && \"absolute inset-0\",\n        )}\n        crossOrigin=\"anonymous\"\n        src={src}\n        ref={imageRef}\n      />\n    </button>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/index.ts",
    "content": "export * from \"./heading\"\nexport * from \"./link\"\nexport * from \"./p\"\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/link.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { timeStringToSeconds } from \"@follow/utils/utils\"\nimport { useCallback } from \"react\"\n\nexport interface LinkProps {\n  href: string\n  title: string\n  children: React.ReactNode\n  target: string\n}\n\nexport const MarkdownLink = (props: LinkProps) => {\n  // TODO should populate the href with the populatedFullHref\n\n  const populatedFullHref = props.href\n\n  const childrenText = typeof props.children === \"string\" ? props.children : null\n  const time = childrenText ? timeStringToSeconds(childrenText) : null\n\n  const handleTimestampClick = useCallback(() => {\n    if (time === null) return\n    bridge.seekAudio(time)\n  }, [time])\n\n  if (time !== null) {\n    return (\n      <button className=\"text-accent underline\" onClick={handleTimestampClick} type=\"button\">\n        {props.children}\n      </button>\n    )\n  }\n\n  return (\n    <Tooltip delayDuration={0}>\n      <TooltipTrigger asChild>\n        <a\n          draggable=\"false\"\n          className=\"font-semibold text-text no-underline\"\n          href={populatedFullHref}\n          title={props.title}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n        >\n          {props.children}\n\n          {typeof props.children === \"string\" && (\n            <i className=\"i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70\" />\n          )}\n        </a>\n      </TooltipTrigger>\n      {!!props.href && (\n        <TooltipPortal>\n          <TooltipContent align=\"start\" className=\"break-all\" side=\"bottom\">\n            {populatedFullHref}\n          </TooltipContent>\n        </TooltipPortal>\n      )}\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/math.tsx",
    "content": "import { LazyKateX } from \"@follow/components/ui/katex/lazy.js\"\nimport { createElement, use } from \"react\"\n\nimport { IsInParagraphContext } from \"./__internal/ctx\"\n\nexport const Math = ({ node }: { node: any }) => {\n  const annotation = node.children.at(-1)\n\n  const isInParagraph = use(IsInParagraphContext)\n  if (!annotation) return null\n  const latex = annotation.value\n\n  return createElement(LazyKateX, {\n    children: latex,\n    mode: isInParagraph ? \"inline\" : \"display\",\n  })\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/p.tsx",
    "content": "import * as React from \"react\"\n\nimport { IsInParagraphContext } from \"./__internal/ctx\"\n\nexport const MarkdownP: Component<\n  React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>\n> = ({ children, ...props }) => {\n  return (\n    <p {...props}>\n      <IsInParagraphContext value={true}>{children}</IsInParagraphContext>\n    </p>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/shiki/Shiki.tsx",
    "content": "import clsx from \"clsx\"\nimport { useIsomorphicLayoutEffect } from \"foxact/use-isomorphic-layout-effect\"\nimport { useAtomValue } from \"jotai\"\nimport type { FC } from \"react\"\nimport { useMemo, useRef, useState } from \"react\"\nimport type {\n  BundledLanguage,\n  BundledTheme,\n  DynamicImportLanguageRegistration,\n  DynamicImportThemeRegistration,\n} from \"shiki\"\nimport { useMediaQuery } from \"usehooks-ts\"\n\nimport { codeThemeDarkAtom, codeThemeLightAtom } from \"~/atoms\"\n\nimport { shiki, shikiTransformers } from \"./shared\"\nimport styles from \"./shiki.module.css\"\n\nexport interface ShikiProps {\n  language: string | undefined\n  code: string\n\n  attrs?: string\n  className?: string\n\n  transparent?: boolean\n\n  theme?: string\n}\n\nconst useIsDark = () => {\n  const isDark = useMediaQuery(\"(prefers-color-scheme: dark)\")\n  return isDark\n}\n\nlet langModule: Record<BundledLanguage, DynamicImportLanguageRegistration> | null = null\nlet themeModule: Record<BundledTheme, DynamicImportThemeRegistration> | null = null\n\nexport const ShikiHighLighter: FC<ShikiProps> = (props) => {\n  const { code, language, className } = props\n  const [currentLanguage] = useState(language || \"plaintext\")\n\n  const loadThemesRef = useRef([] as string[])\n  const loadLanguagesRef = useRef([] as string[])\n\n  const [loaded, setLoaded] = useState(false)\n\n  const isDark = useIsDark()\n  const codeThemeLight = useAtomValue(codeThemeLightAtom) || \"github-dark\"\n  const codeThemeDark = useAtomValue(codeThemeDarkAtom) || \"github-dark\"\n  const codeTheme = isDark ? codeThemeDark : codeThemeLight\n\n  useIsomorphicLayoutEffect(() => {\n    let isMounted = true\n    setLoaded(false)\n\n    async function loadShikiLanguage(language: string, languageModule: any) {\n      if (!shiki) return\n      if (!shiki.getLoadedLanguages().includes(language)) {\n        await shiki.loadLanguage(await languageModule())\n      }\n    }\n    async function loadShikiTheme(theme: string, themeModule: any) {\n      if (!shiki) return\n      if (!shiki.getLoadedThemes().includes(theme)) {\n        await shiki.loadTheme(await themeModule())\n      }\n    }\n\n    async function register() {\n      if (!currentLanguage || !codeTheme) return\n\n      const [{ bundledLanguages }, { bundledThemes }] =\n        langModule && themeModule\n          ? [\n              {\n                bundledLanguages: langModule,\n              },\n              { bundledThemes: themeModule },\n            ]\n          : await Promise.all([import(\"shiki/langs\"), import(\"shiki/themes\")])\n\n      langModule = bundledLanguages\n      themeModule = bundledThemes\n\n      if (\n        currentLanguage &&\n        loadLanguagesRef.current.includes(currentLanguage) &&\n        codeTheme &&\n        loadThemesRef.current.includes(codeTheme)\n      ) {\n        return\n      }\n      return Promise.all([\n        (async () => {\n          if (currentLanguage) {\n            const importFn = (bundledLanguages as any)[currentLanguage]\n            if (!importFn) return\n            await loadShikiLanguage(currentLanguage || \"\", importFn)\n            loadLanguagesRef.current.push(currentLanguage)\n          }\n        })(),\n        (async () => {\n          if (codeTheme) {\n            const importFn = (bundledThemes as any)[codeTheme]\n            if (!importFn) return\n            await loadShikiTheme(codeTheme || \"\", importFn)\n            loadThemesRef.current.push(codeTheme)\n          }\n        })(),\n      ])\n    }\n    register().then(() => {\n      if (isMounted) {\n        setLoaded(true)\n      }\n    })\n    return () => {\n      isMounted = false\n    }\n  }, [codeTheme, currentLanguage])\n\n  if (!loaded) {\n    return (\n      <pre className={clsx(\"bg-transparent\", className)}>\n        <code>{code}</code>\n      </pre>\n    )\n  }\n  return <ShikiCode {...props} language={currentLanguage} codeTheme={codeTheme} />\n}\n\nconst ShikiCode: FC<\n  ShikiProps & {\n    codeTheme: string\n  }\n> = ({ code, language, codeTheme, className, transparent }) => {\n  const rendered = useMemo(() => {\n    try {\n      return shiki.codeToHtml(code, {\n        lang: language!,\n        themes: {\n          dark: codeTheme,\n          light: codeTheme,\n        },\n        transformers: shikiTransformers,\n      })\n    } catch {\n      return null\n    }\n  }, [code, language, codeTheme])\n\n  if (!rendered) {\n    return (\n      <pre className={className}>\n        <code>{code}</code>\n      </pre>\n    )\n  }\n\n  return (\n    <div\n      className={clsx(\n        \"group relative -mx-3 my-4\",\n        styles[\"shiki-wrapper\"],\n        transparent ? styles[\"transparent\"] : null,\n        className,\n      )}\n    >\n      <div dangerouslySetInnerHTML={{ __html: rendered }} data-language={language} />\n\n      {language !== \"plaintext\" && (\n        <span className=\"center absolute bottom-2 right-2 flex gap-1 text-xs uppercase opacity-80 dark:text-white\">\n          <span>{language}</span>\n        </span>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/shiki/hooks.ts",
    "content": "import { useIsDark } from \"@follow/hooks\"\n\nexport const useShikiDefaultTheme = () => {\n  const isDark = useIsDark()\n\n  return isDark ? \"github-dark\" : \"github-light\"\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/shiki/index.ts",
    "content": "export * from \"./Shiki\"\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/shiki/shared.ts",
    "content": "import {\n  transformerMetaHighlight,\n  transformerNotationDiff,\n  transformerNotationHighlight,\n} from \"@shikijs/transformers\"\nimport type { ShikiTransformer } from \"shiki\"\nimport { createHighlighterCoreSync, createJavaScriptRegexEngine } from \"shiki\"\n\nexport const shikiTransformers: ShikiTransformer[] = [\n  transformerMetaHighlight(),\n  transformerNotationDiff({ matchAlgorithm: \"v3\" }),\n  transformerNotationHighlight({ matchAlgorithm: \"v3\" }),\n]\n\nconst js = createJavaScriptRegexEngine()\nexport const shiki = createHighlighterCoreSync({\n  themes: [],\n  langs: [],\n  engine: js,\n})\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/components/shiki/shiki.module.css",
    "content": ".shiki-wrapper {\n  @apply overflow-hidden rounded-md;\n\n  pre {\n    @apply bg-transparent;\n  }\n\n  &.transparent {\n    :global {\n      .shiki,\n      code {\n        @apply !bg-transparent;\n      }\n    }\n  }\n\n  :global {\n    .shiki {\n      @apply !m-0 !px-0;\n\n      font-family:\n        \"OperatorMonoSSmLig Nerd Font\",\n        \"Cascadia Code PL\",\n        \"FantasqueSansMono Nerd Font\",\n        \"Operator Mono\",\n        JetBrainsMono,\n        \"Fira Code Retina\",\n        \"Fira Code\",\n        \"Consolas\",\n        Monaco,\n        \"Hannotate SC\",\n        monospace,\n        -apple-system,\n        system-ui,\n        sans-serif;\n    }\n\n    pre {\n      @apply !m-0 overflow-auto p-4;\n\n      font-size: 0.875em;\n    }\n\n    pre code {\n      @apply flex flex-col;\n    }\n\n    .line {\n      @apply block px-5;\n\n      & > span:last-child {\n        @apply mr-5;\n      }\n\n      /* Expand the row without content */\n      &::after {\n        content: \" \";\n      }\n    }\n\n    .highlighted,\n    .diff {\n      @apply relative break-all;\n\n      &::before {\n        @apply absolute left-0 top-0 h-full w-[2px];\n        content: \"\";\n      }\n    }\n\n    .diff.add {\n      @apply bg-green-100 dark:bg-green-900;\n\n      &::before {\n        @apply bg-green-500;\n      }\n\n      &::after {\n        content: \" +\";\n        @apply absolute left-0 text-green-500;\n      }\n    }\n\n    .diff.remove {\n      @apply bg-red-100 dark:bg-red-900;\n\n      &::before {\n        @apply bg-red-500;\n      }\n\n      &::after {\n        content: \" -\";\n        @apply absolute left-0 text-red-500;\n      }\n    }\n\n    .highlighted {\n      @apply bg-accent/20;\n\n      &::before {\n        @apply bg-accent;\n      }\n    }\n  }\n\n  pre {\n    @apply rounded-none;\n  }\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/index.css",
    "content": ""
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/index.ts",
    "content": "import \"@follow/components/assets/colors-media.css\"\nimport \"@follow/components/assets/tailwind.css\"\nimport \"./index.css\"\n\nimport { createElement } from \"react\"\nimport { createRoot } from \"react-dom/client\"\n\nimport { App } from \"./App\"\nimport { isInRn } from \"./utils\"\n\nconst root = document.querySelector(\"#root\")\nif (root) {\n  createRoot(root).render(createElement(App))\n}\n\n// Set web app background if in browser\nif (!isInRn) {\n  const handler = (e: MediaQueryListEvent) => {\n    // Set background color\n    const isDark = e.matches\n    document.body.style.backgroundColor = isDark ? \"#000\" : \"#fff\"\n  }\n\n  // Observe media query\n  const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\")\n  mediaQuery.addEventListener(\"change\", handler)\n  handler({\n    matches: mediaQuery.matches,\n  } as MediaQueryListEvent)\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/managers/webview-bridge.ts",
    "content": "import type { EntryModel } from \"../../types\"\nimport {\n  codeThemeDarkAtom,\n  codeThemeLightAtom,\n  entryAtom,\n  noMediaAtom,\n  readerRenderInlineStyleAtom,\n} from \"../atoms\"\n\ntype Store = ReturnType<typeof import(\"jotai\").createStore>\n\n/**\n * WebView Bridge Manager\n * Handles all JavaScript bridge functions exposed to the native WebView\n */\nexport class WebViewBridgeManager {\n  private store: Store\n\n  constructor(store: Store) {\n    this.store = store\n  }\n\n  /**\n   * Set the current entry to be rendered\n   */\n  setEntry = (entry: EntryModel) => {\n    this.store.set(entryAtom, entry)\n    bridge.measure()\n  }\n\n  /**\n   * Set code highlighting themes for light and dark modes\n   */\n  setCodeTheme = (v: { light: string; dark: string }) => {\n    this.store.set(codeThemeLightAtom, v.light)\n    this.store.set(codeThemeDarkAtom, v.dark)\n  }\n\n  /**\n   * Set reader render inline style preference\n   */\n  setReaderRenderInlineStyle = (value: boolean) => {\n    this.store.set(readerRenderInlineStyleAtom, value)\n  }\n\n  /**\n   * Toggle media display\n   */\n  setNoMedia = (value: boolean) => {\n    this.store.set(noMediaAtom, value)\n  }\n\n  /**\n   * Set root font size for the WebView\n   */\n  setRootFontSize = (size = 16) => {\n    document.documentElement.style.fontSize = `${size}px`\n  }\n\n  /**\n   * Reset the WebView state\n   */\n  reset = () => {\n    this.store.set(entryAtom, null)\n    bridge.measure()\n  }\n\n  /**\n   * Expose all methods to the global window object\n   * This maintains backward compatibility with existing native code\n   */\n  exposeToWindow() {\n    // Minimal native->JS dispatch bridge to centralize API surface\n    if (!window.__FO_BRIDGE__) {\n      const handlers = {\n        setEntry: this.setEntry,\n        setCodeTheme: this.setCodeTheme,\n        setReaderRenderInlineStyle: this.setReaderRenderInlineStyle,\n        setNoMedia: this.setNoMedia,\n        setRootFontSize: this.setRootFontSize,\n      } as const\n\n      const tryParse = (v: any): any => {\n        if (typeof v !== \"string\") return v\n        const s = v.trim()\n        if (!s) return v\n        if ((s.startsWith(\"{\") && s.endsWith(\"}\")) || (s.startsWith(\"[\") && s.endsWith(\"]\"))) {\n          try {\n            return JSON.parse(s)\n          } catch {\n            return v\n          }\n        }\n        if (s === \"true\") return true\n        if (s === \"false\") return false\n        const n = Number(s)\n        if (!Number.isNaN(n) && s === String(n)) return n\n        return v\n      }\n\n      window.__FO_BRIDGE__ = {\n        dispatch(type, payload) {\n          try {\n            // @ts-expect-error\n            const fn = handlers[type]\n            if (typeof fn === \"function\") {\n              fn(tryParse(payload))\n            } else {\n              console.warn(\"[FO_BRIDGE] No handler for\", type)\n            }\n          } catch (e) {\n            console.error(\"[FO_BRIDGE] dispatch error\", type, e)\n          }\n        },\n        applyState(state) {\n          try {\n            if (!state || typeof state !== \"object\") return\n            for (const key of Object.keys(state)) {\n              const fn = handlers[key as keyof typeof handlers]\n              if (typeof fn === \"function\") {\n                // @ts-expect-error\n                fn(tryParse(state[key]))\n              }\n            }\n          } catch (e) {\n            console.error(\"[FO_BRIDGE] applyState error\", e)\n          }\n        },\n      }\n    }\n  }\n}\n\ndeclare global {\n  interface Window {\n    __FO_BRIDGE__: {\n      dispatch: (type: string, payload: string) => void\n      applyState: (state: Record<string, any>) => void\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/parser.tsx",
    "content": "import { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.jsx\"\nimport { Checkbox } from \"@follow/components/ui/checkbox/index.jsx\"\nimport { parseHtml as parseHtmlGeneral } from \"@follow/utils/html\"\nimport type { Components } from \"hast-util-to-jsx-runtime\"\nimport { createElement } from \"react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { createHeadingRenderer, MarkdownLink, MarkdownP } from \"./components\"\nimport { MarkdownImage } from \"./components/image\"\nimport { Math } from \"./components/math\"\nimport { ShikiHighLighter } from \"./components/shiki\"\n\nconst Style: Components[\"style\"] = ({ node, ...props }) => {\n  if (typeof props.children === \"string\") {\n    return createElement(MemoedDangerousHTMLStyle, null, props.children)\n  }\n  return null\n}\n\nexport const parseHtml = (\n  content: string,\n  options?: Partial<{\n    renderInlineStyle: boolean\n    noMedia?: boolean\n  }>,\n) => {\n  return parseHtmlGeneral(content, {\n    ...options,\n    components: {\n      a: ({ node, ...props }) => {\n        // Ignore link wrapper when child is an image to ensure image preview\n        // works instead of navigating to the link URL when clicked\n        //\n        // Check if the link contains only an image as a child\n        if (\n          node.children &&\n          node.children.length === 1 &&\n          node.children[0].type === \"element\" &&\n          node.children[0].tagName === \"img\"\n        ) {\n          // Return only the image element\n          return props.children\n        }\n        return createElement(MarkdownLink, { ...props } as any)\n      },\n\n      h1: (props) => {\n        return createHeadingRenderer(1)(props)\n      },\n      h2: (props) => {\n        return createHeadingRenderer(2)(props)\n      },\n      h3: (props) => {\n        return createHeadingRenderer(3)(props)\n      },\n      h4: (props) => {\n        return createHeadingRenderer(4)(props)\n      },\n      h5: (props) => {\n        return createHeadingRenderer(5)(props)\n      },\n      h6: (props) => {\n        return createHeadingRenderer(6)(props)\n      },\n      style: Style,\n      img: ({ node, ...props }) => {\n        return createElement(MarkdownImage, props as any)\n      },\n\n      p: ({ node, ...props }) => {\n        if (node?.children && node.children.length !== 1) {\n          for (const item of node.children) {\n            item.type === \"element\" &&\n              item.tagName === \"img\" &&\n              ((item.properties as any).inline = true)\n          }\n        }\n        return createElement(MarkdownP, props, props.children)\n      },\n\n      math: Math,\n      hr: ({ node, ...props }) =>\n        createElement(\"hr\", {\n          ...props,\n          className: tw`scale-x-50`,\n        }),\n      input: ({ node, ...props }) => {\n        if (props.type === \"checkbox\") {\n          return createElement(Checkbox, {\n            ...props,\n            disabled: false,\n            className: tw`pointer-events-none mr-2`,\n          })\n        }\n        return createElement(\"input\", props)\n      },\n      pre: ({ node, ...props }) => {\n        if (!props.children) return null\n\n        let language = \"\"\n\n        let codeString = null as string | null\n        if (props.className?.includes(\"language-\")) {\n          language = props.className.replace(\"language-\", \"\")\n        }\n\n        if (typeof props.children !== \"object\") {\n          codeString = props.children.toString()\n        } else {\n          const propsChildren = props.children\n          const children = Array.isArray(propsChildren)\n            ? propsChildren.find((i) => i.type === \"code\")\n            : propsChildren\n\n          // Don't process not code block\n          if (!children) return createElement(\"pre\", props, props.children)\n\n          if (\n            \"type\" in children &&\n            children.type === \"code\" &&\n            children.props.className?.includes(\"language-\")\n          ) {\n            language = children.props.className.replace(\"language-\", \"\")\n          }\n          const code = (\"props\" in children && children.props.children) || children\n          if (!code) return null\n\n          try {\n            codeString = extractCodeFromHtml(renderToString(code))\n          } catch (error) {\n            console.error(\"Code Block Render Error\", error)\n            return createElement(\"pre\", props, props.children)\n          }\n        }\n\n        if (!codeString) return createElement(\"pre\", props, props.children)\n\n        return createElement(ShikiHighLighter, {\n          code: codeString.trimEnd(),\n          language: language.toLowerCase(),\n        })\n      },\n      figure: ({ node, ...props }) =>\n        createElement(\n          \"figure\",\n          {\n            className: \"mx-auto max-w-full\",\n            style: {\n              marginLeft: \"auto\",\n              marginRight: \"auto\",\n              ...(props as any).style,\n            },\n          },\n          props.children,\n        ),\n      table: ({ node, ...props }) =>\n        createElement(\n          \"div\",\n          {\n            className: \"w-full overflow-x-auto\",\n          },\n\n          createElement(\"table\", {\n            ...props,\n            className: tw`w-full my-0`,\n          }),\n        ),\n      video: ({ node, ...props }) =>\n        createElement(\"video\", {\n          ...props,\n          controls: true,\n        }),\n    },\n  })\n}\nfunction extractCodeFromHtml(htmlString: string) {\n  const tempDiv = document.createElement(\"div\")\n  tempDiv.innerHTML = htmlString\n\n  const hasPre = tempDiv.querySelector(\"pre\")\n  if (!hasPre) {\n    tempDiv.innerHTML = `<pre><code>${htmlString}</code></pre>`\n  }\n\n  // 1. line break via <div />\n  const divElements = tempDiv.querySelectorAll(\"div\")\n\n  let code = \"\"\n\n  if (divElements.length > 0) {\n    divElements.forEach((div) => {\n      code += `${div.textContent}\\n`\n    })\n    return code\n  }\n\n  // 2. line wrapper like <span><span>...</span></span>\n  const spanElements = tempDiv.querySelectorAll(\"span > span\")\n\n  // 2.1 outside <span /> as a line break?\n\n  let spanAsLineBreak = false\n\n  if (tempDiv.children.length > 2) {\n    for (const node of tempDiv.children) {\n      const span = node as HTMLSpanElement\n      // 2.2 If the span has only one child and it's a line break, then span can be as a line break\n      spanAsLineBreak = span.children.length === 1 && span.childNodes.item(0).textContent === \"\\n\"\n      if (spanAsLineBreak) break\n    }\n  }\n\n  if (!spanAsLineBreak) {\n    const usingBr = tempDiv.querySelector(\"br\")\n    if (usingBr) {\n      spanAsLineBreak = true\n    }\n  }\n\n  if (spanElements.length > 0) {\n    for (const node of tempDiv.children) {\n      if (spanAsLineBreak) {\n        code += `${node.textContent}`\n      } else {\n        code += `${node.textContent}\\n`\n      }\n    }\n\n    return code\n  }\n\n  return tempDiv.textContent\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/test.txt",
    "content": "<img src=\"https://cdnfile.sspai.com/05/02/2025/article/9391cd60-9adf-11fd-9563-d80c163d11ca.jpeg\" alt=\"Article Cover Image\" style=\"display: block; margin: 0 auto;\"><br><p><strong>编者按：</strong>本文由作者的 <a href=\"https://mp.weixin.qq.com/s/S1mpmhAkafbgs53H5nJcUg\" target=\"_blank\">2024 年小红书平台商业观察</a>精炼和汇总而来，原文是碎片化的思考集锦。作者目前主要从事小红书平台的运营指导工作，创作了网络教程《<a href=\"https://www.xiaobot.net/p/xiaohongshuku?refer=652d3306-2394-456e-bfa3-4f495de5ffd7\" target=\"_blank\">小红书运营手册</a>》，也曾参与过《少数派播客》录制。你可以在阅读本文的同时搭配<a href=\"https://sspai.com/post/93697\" target=\"_blank\">收听播客节目</a>，获得更完整的资讯。</p><h2>了解小红书</h2><p>小红书的个人账号的发展状况已具备构建中国本土领英的素质，甚至它可以办到很多领英当年在中国没有办成的事儿。对个人来说，小红书作为一张公域的社交名片，值得花心思好好设计。而所有的公司和机构都应该在 2025 好好思考，如何在小红书上更好地和用户「玩在一起」。</p><h3>社区就是城市</h3><p>小红书产品联创邓超，做小红书前是个建筑师。他说，社区就是城市。</p><p>以下是他对于做社区的思考：</p><ul><li>To define is to limit：尊重自然生长，让可能先发生。在守住底线和价值观的同时，可以让一些事情先发生，然后再看怎么去牵引和影响，而不是预先规定。</li><li>People matters：人是最基本且最重要的单位。 People based，所有的互动的行为和心智的建立应该建立在人的维度，虽然你可能优先看到的是内容和场景。</li><li>Trust is foundation：信任共识是一切的基础。有价值的互动都建立在信任的基础上，比如交友，比如交易。</li><li>Less is more：如无必要，勿增实体。克制不断做加法的欲望。做减法很难，这是人性。</li><li>Simple but significant：做抽象且有长远价值的事。虽然最后的解决方案不一定是最抽象的解决方案，但是试图抽象化的过程是必不可少的。</li><li>Priority to connection：双向连接优先，普世连接优先。连通性，流动性，可能性，是互相交织影响的。</li><li>Mobility and stability：同时面对流动性和稳定性。我们面对的是一个复杂系统，是一个真实社会，我们要深刻理解阶层的流动性和稳定性的同等重要性。都是具有普世价值的方法论，常看常新。</li></ul><h3>文科公司，人感社区</h3><p>小红书啊，就是个文科创业公司。</p><p>现在市面上最火的内容平台里，几乎只有小红书没有在一开始就做多账号切换登录。跟在小红书工作的朋友确认了，理由跟我依据我对互联网产品工作流程的了解得出的结论基本一致：最早做这个 app 的时候真没想到多账号登录是一个很大的需求。</p><p>——就是没想到，哈哈。</p><p>但，所谓无心插柳柳成荫——因为多账号登录很不方便，再加上其他各种因素的影响，<strong>促成了小红书是中文互联网「人感」最强的社区</strong>。</p><p>一个产品在不同的用户手上能用成不同的样子，自由生长的空间和鼓励作为用户作为「活人」发言和分享的价值观，造就了小红书这座「城市」。</p><p>话说回来，以产品经理的眼光看，小红书的架构问题体现在方方面面。但是因为它其它方面做得太好了，又有搜索这么一个结构化优势，在一众内容平台中绝对是瑕不掩瑜。</p><p>小红书在某些方面取得了明显的胜利，因它的独特风格，它的审美趣味，它在生活中不可替代的作用，主要这两个：<strong>搜索，活人的真实经验。</strong></p><p>关于小红书我最近还有一个很大的感受：在小红书上发送内容，不管是笔记还是评论，我们某种程度上其实在玩角色扮演。扮演什么呢？扮演城市的居民。因为小红书就是小红书之城，小红书 city。这个 city 有极强的风格和价值观，它其实没有那么包容。你反着来，你就出局了。因此，小红书很难走出「十年体」的 IP，太下里巴人的内容，会很难。</p><p>一个会在煮青菜时研究如何让煮完的青菜保持翠绿的人，是值得尊敬的。</p><p>小红书真的行，是因为它是一个鼓励并且真的有很多人分享「如何让煮完的青菜保持绿色」技巧的社区。下行时代，人们靠这些从细微之处让生活变得美好的搜索结果页活下去。它确实没有那么完美。但它占据独特的生态位。小红书是一个增长平台，让你忽略它的很多缺陷。也因为它没那么完美，所以在这里，很多生意仍有很大机会。</p><p>所以，<strong>学习小红书的运营，本质上是学习一门语言，一门在小红书如何更好地表达自己本来意思的语言。</strong></p><p>平台大，基建全，看上去很美，但对创作者不一定是好事。在抖音，帮抖音赚到钱的人才能赚到钱。在小红书，只要定位准、内容好就能赚到钱——这是基于目前我看到的内容生态下各个平台创作者的发展状况得出的结论。小红书的基建也在越来越好，以后怎么样尚未可知。</p><p>对于普通创作者我唯有一条建议：想做任何事情，都要立刻马上。</p><h2>运营经验闲谈</h2><h3>「涨粉」不是小红书运营的首要目标</h3><p>做小红书，丢掉「裹小脚」型指标，<strong>以成交为第一目的。</strong></p><p>今天有个创业的博主朋友找我咨询看号。他已经在私域拿到了不小的成绩，现在在拓展小红书。小红书做了几个号了，也有几篇爆款笔记，但一是不涨粉，二也引不了流，问我解法。</p><p>我的答案是——</p><ul><li>第一，<strong>粉丝是小红书最不重要的指标</strong>，这一点<a href=\"https://www.xiaobot.net/p/xiaohongshuku?refer=652d3306-2394-456e-bfa3-4f495de5ffd7\" target=\"_blank\">专栏里</a>也多次反复提及。</li><li>第二，内容在一个特定平台行不行，和调性、具体做什么买卖强相关，是非常个性化的部分了。</li></ul><p>这位博主的内容是：艰难创业、白手起家、小有所成、牛逼的青年人，以人格化的形象兜售服务型 to B产品。</p><p>这一类内容呢，在小红书目前的社区氛围下是确实优势不大的。</p><p>我们说生意和产品，一个人经营企业是否能成功，某种程度上就是被筛选的。做 IP 也一样，IP 是被筛选的。</p><p>我现在有很多 to B 的面向企业家的讲学，听众里的品牌方过往在包括抖音、百度搜索，甚至CCTV 上已经都拿到了特别大的结果。各个渠道样样好，在小红书上大获全胜当然也有可能，但我们也只能说是做大概率上正确的事，然后尊重它的随机性。</p><p>说回来这个客户，肯定不是说他有问题，而是说他的人生现阶段的这些属性，让他在现阶段的小红书，确实不是能够特别受欢迎。</p><p>那我们再说回来不涨粉的问题，其实何必非得要在小红书涨粉呢？我的这位客户，他有一个执念，是说我过往所有的成绩都在私域，我在公域没有特别硬的成绩。</p><p>我说你这就搞错了。</p><p><strong>什么公域私域，它都是我们业务的渠道</strong>。我们整天做这行的，为了方便业务区分，才会这么划分，但不要真的给自己套上、裹上思想的小脚。什么公域私域，能抓老鼠就是好猫。</p><p>以上内容我们得出总结：<strong>我们一定要摘掉粉丝量的桎梏，不给自己订立那些特别刚性的小脚型指标——以做成交为准。做大概率上正确的事就好，等风来。</strong></p><p>就像小红书，小红书本身是一个文科创业公司，啥时候风吹到小红书这，小红书自己过去很多年也没数，不知道，突然起来了，就是因为它坚持它在中文互联网的独特性。</p><p>有技术背景的人，真的是可以在日常使用中看得到小红书基础架构的各种问题和漏洞，在技术上，小红书完全称不上是一个有多硬的公司，但它够特别，它就能占据一个生态位。</p><p>所以说无妨，以本色前行，直到你变为正确，能扛周期是硬道理。</p><p>让小红书平台与众不同的另外一点，在于它的推荐算法。<strong>推荐算法对创作者的最大价值，在于绕开权贵。</strong></p><p>这也是小红书的了不起之处：<strong>小红书算法更看笔记价值，不太看作者何许人也。</strong></p><p>于是问题产生：创作者很难在这里成为权贵，因为在价值观上，消费者，内容消费者，不管你是谁，你有用就行了。</p><p>于是需要放下粉丝数的桎梏。传统平台最重要的指标——粉丝数——在小红书最不重要。</p><p>——可以告诉你那执意想让公司账号涨粉的老板。</p><h3>账号的定位和运营</h3><p>今天有位读者分享了他的「账号三件套」，内容非常好，是用心了的，但是有个小问题，很硬的小问题：名字起得不好。</p><p>不仅大小写、中英文都有，居然还有标点符号，用户完全没法搜。在小红书，名字里最好要带上行业词，做搜索优化。像现在这个 ID 里的「教育观」，就不是一个用户会主动搜索的词。</p><p>坦白来说，「后厂女工小王」我觉得也是个烂名字，属于强撑着运营起来了。很多我的用户们，虽然是上班族但也不知道后厂村，「厂」还经常会被打成「场」。六个字也太拗口，我会被人叫，「后厂女王小工」。后厂村这个词也没有啥热度，比不上「西二旗」。其实刚开始起名字的时候我就用百度指数搜了，我也知道这词热度不大行，但我看着西二旗三个字就烦。</p><p>总之，这个名字真的很拉。但我接受它的存在，它伴随我成长了。</p><p>你可以从很多渠道知道一个人，然后记住她，渠道是渠道，名字是名字，名字是一部分，渠道是一部分，当内容足够强，名字烂也没关系了，但名字烂会折损内容的传播。</p><p><strong>在一开始就尽量起个好名，能减少很多未来的麻烦。</strong></p><p>今天上午服务了一个小红书博主客户，借她的案例，跟大家一起温习一下「账号三件套」这一章节。</p><p>图一是原先的简介，图二是优化后的。博主本人生活在纽约，是房产经纪，创业者。 </p><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/424adf9c64409faa135901aa1af6c9e2.png?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><p>经过沟通，我发发现博主自己没有发现的个人优势，如丰富的当地生活经历，亮眼的有实感的成交成绩，更贴近生活接地气的生活理念。</p><p>所以我主张去掉了虽然看着挺厉害但是用户打眼无感的「两亿美金」，把散碎的关键词组织成了更服务于凸显专业、客户信任、懂行的专家。</p><p>做个人 IP 的朋友可以参考——<strong>我们做账号是服务于自己的什么工作，那就写服务于咱们优点的事儿</strong>，其它的就不写了。</p><p>会弹钢琴对找产品经理的工作可能没啥用，还显得在显摆啥似的。但是如果有一个 5 万粉丝的音乐账号这就值得写了。</p><p>此外，名字我也建议她改，直接带上从事行业、地域，利于搜索，这个对她的领域有效，大家可以因地制宜。</p><h3>货架思维</h3><p>每次我从首页点进去某篇笔记，会在返回以后看看为什么这个封面吸引我，看看旁边的笔记又是啥，为啥不选别的选这个。</p><p><strong>大家平时刷小红书的时候也要注意培养这样的思考方式，是为新媒体运营的「货架思维」。</strong></p><p>分享一段小红书新星博主群里大家对营养师账号定位的讨论，给有类似垂直领域账号需求的朋友们参考：</p><ul><li><strong>账号不等于职业，内容不等于职业</strong>：你的内容一定是实用价值加精神价值。比如，旅行博主，就不能只是旅行工具人说明书，而是做喜欢旅行的人，内容里有旅行的元素。</li><li><strong>划小受众范围，输出实用内容</strong>，此为吸引精准粉丝；做免费实用内容分享同时注意情感共鸣，此为提高用户黏性</li><li><strong>看能不能把专业能力迁移到新的消费场景里</strong>：比如产品经理讲如何像做产品一样规划人生，魔术师讲怎么用变魔术追女生。</li></ul><p>小红书运营常见问题之账号等级是否影响内容分发权重，回答见图。</p><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/588ec690127711121ba7f0dffb770cdb.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><figure style=\"width:424px;\"><img src=\"https://cdnfile.sspai.com/2025/02/05/376c2b79d734b7fd482413e08b806853.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><p>如今小红书的账号等级已经越来越不被提及了。在混战的时候，账号等级是一个标尺，它的作用类似于每周给创作者发的周报，主要作用就是发给作者的强心剂。</p><p>现在作者已经不需要这样的强心剂了，早已有了更刺激的东西。</p><p><strong>小红书会根据热度和表现，对单条笔记进行多次干预动作。由此可见，这是一个很重运营的社区，这是跟其它更重算法机制的平台的不同，它的运营是有「风格」的。</strong>结合算法，再加上有审美和强引导的人工推荐，这个平台还会更强大。</p><h3>如何看待流量</h3><p>我给客户做小红书账号，发出来笔记有的流量好，有的流量不好。有同行一顿分析说，这条流量好是因为切中了用户什么点，那条流量不好是因为没切中用户什么点。而其实真实的原因非常的简单，那就是：</p><ul><li>流量好的那一条我找平台推了。</li><li>流量不好的那一条平台当时还没推。</li><li>后来流量不好的那一条，平台推了，流量也超过原来那条了。</li></ul><p>我就想之前看有些营销分析文章，说江小白成功是因为怎么写文案了，怎么做包装了。其实背后的管理，背后的钱，背后的渠道能力，资源关系、战略方向，站在传统营销人的位置上，根本看不见。</p><p>他只能看到显现出来的营销，一些表面上的东西，然后以为那个就是企业成功的原因。</p><p>如果人一直这么思考，永远都是流于表面，永远都是只学到一些形式，就是永远跟不上。 那些平台在水下经营做的事情，没法在网络上看文章知道。水下经营不是说一定是什么灰色操作，而是说那些叫做「你看不见的努力」。</p><p>就像很多人只看到一个女生打扮得挺漂亮，会穿搭，怎么光发美女图片就能火了，就觉得这么容易吗？但其实美女背后，怎么运动、怎么饮食、怎么睡眠、怎么规律地管理生活，别人是看不到的。 美人是一个系统。</p><p>任何好的内容，任何好的品牌，任何好的产品，都自成系统。在思想的系统之下，才有商品，才有商品的交换。最起码对我来说，我产品经理和内容运营工作经历里的平台视角，多年副业的博主视角，自己做产品做品牌的品牌视角，三方面结合，才造就了今天的我，才让今天的我能干今天的事。</p><p>不过当然了，企业能否成功，最重要的还是人和产品。所谓营销的营，首先是经营的营。</p><p>其实真的往大了说，一个人的事业，一个人的命运，也是这样，时势造就。一命二运三风水，不是一句假话。</p><p>今天通过这个思考向大家传达两个点：</p><ul><li>一是给在平台工作，还尚且没有自己的事业而感到焦虑的朋友打气。大家的工作技能放在社会上是很牛逼的。也许只是需要找一个具体的事情来破除焦虑，我也曾迷茫，我也还在学，但初见成效，你一定也行。</li><li>二是虽然时事如此，行业如此，但你仍然能选择自己的河流。在河流中，固然不能改变河水的走向，但我们可以选择在哪个水系里航行，并且紧握自己手中的桨。</li></ul><p>我们且扬帆。</p><h3>小红书是渠道，不是业务</h3><p>朋友跟我说她的下半年工作规划，说要开一个小红书的 A 账号，再开一个小红书的 B 账号，两头推进。我说在这里你犯了一个错误：小红书，它本身，并不是一个业务。小红书只是我们业务的渠道。</p><p>品牌的品牌，个人的品牌，它一定是先长在了这个品、这个人的身上，然后通过各个渠道去发散，去表达，去传播。而小红书、抖音、视频号、朋友圈，你在前公司的口碑，所有所有的一切，这些都是你的渠道。不是非得是哪个平台的哪个号，才是你的渠道。</p><p>我们要面对的用户，是一个个活生生的人。每个阅读量，它都很具象，是拿着手机的人，点开了你的笔记，而不仅仅是冷冰冰的数据。</p><p>我们可以从任何渠道，任何可以触达潜在客户的场合，找到有业务需求的人。</p><p>小红书不是你。</p><p><strong>把小红书作为方法，把小红书作为承接你「外溢的能力」的载体</strong>。在此基础上，我们才有更进一步的认识：小红书是当前最适合开辟的公域平台，小红书是当前最适合非 KOL 的「普通人」获客的渠道。</p><p>所以我们才要运营小红书账号。因果关系要记清。</p><h3>如何正确投流</h3><p>小红书官方月报是一个创作者和品牌方的运营资料，不宜照本宣科生搬硬套。小红书作者生态里，有一面是官方的月报不会写出来的：</p><ul><li>很多中小博主在小红书平台获客、卖出产品，但是在这个过程中间平台挣不到一分钱。</li></ul><p>平台怎么挣钱呢？目前我从工作中得到的经验和已知的内部信息都指向：</p><ul><li>小红书不在小玩家上挣钱，而是帮大玩家卖东西，做品牌。</li></ul><p>大玩家投流一块钱，这一块钱平台拿走，然后平台帮大玩家赚回三块及以上。</p><p>基于以上背景信息，我想告诉大家的是：</p><ul><li>做小红书不一定得花钱投流，这一点利好中小博主和品牌方。小玩家，在小红书的当前战略和内容生态下，持续有机会花小本或不花钱拿大结果。</li><li>小红书是当今中文互联网的内容社区最适合「赛博摆摊」的平台。你可以在这卖珠宝，卖拍摄服务，卖家里的大鹦鹉生的小鹦鹉，卖几乎任何东西。这里正在聚集越来越多「有钱的闲人」和「想赚钱的投机者」。</li><li>花钱投了流，投不准，可能是因为钱没花对，以及钱没花够。但花对了，花够了，花一块挣十块，不是没机会，但前提是需要确认，你的产品适合小红书。</li></ul><h3>让封面吸引人</h3><p>前几天去看了马蒂斯的个展。马蒂斯是野兽派的创始人和主要代表人物，图一是我在他的个展上发现的一个「小红书双列流」——来自 1925 年的《星期画报》。</p><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/87e3e8a45e622d455dd42fdc1db735a5.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/368dcb89ff97818918b9ebce75fba410.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><p>可以看到，在这样一个页面里，哪个「封面图」最打眼？</p><p>毫无疑问，是左下角的裸女，也就是马蒂斯作品。</p><p>为什么画家热爱裸女，我认为，是因为这是一个天然热度更高、更易成为热点的内容——葛优老师说得好：裸体成为艺术，就是最圣洁的。但是一个不穿衣服的女的，天然就更容易引起艺术品消费市场的注意；而且模特不是一般画家请得起的，是一个需要达到门槛才能创作的选题。</p><p>于是，裸女画，自带热度，且更容易引起业内讨论，以及破圈。下图是马蒂斯作品在中国引起的争议，徐悲鸿专门写文章骂他。</p><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/8b1d0d351f9e1c1fb626071bc3999ba6.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><p>马蒂斯是很会营销的一位画家，他在活着的时候有很多跨界合作，设计舞台剧，给书画插图，给教堂做装修，混圈，和毕加索是好朋友——完全是当代 KOL 那一套。</p><p>我们还是回到双列流的话题：小红书里，不能发裸女 ，那么怎么样可以让封面图更吸引人？</p><figure style=\"width:450px;\"><img src=\"https://cdnfile.sspai.com/2025/02/05/52233f8cfe956aeeb4e7e51d3be03092.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><p><a href=\"https://www.xiaobot.net/p/xiaohongshuku?refer=652d3306-2394-456e-bfa3-4f495de5ffd7\" target=\"_blank\">我的专栏里</a>有一节关于封面图的论述。在小红书，图是主体，文是配文。在这里就不展开细讲了，感兴趣的朋可以搜搜看。</p><p>AI 文生图技术在小红书笔记发布器里也能发挥作用，位置在个人主页 tab 和第一行笔记之间，刷新会更新。</p><p>这个位置挺好的，个人主页，点击率高，也符合发布习惯。解决了用户「有想说的话但不知道配什么图片」的场景痛点，同时也能为内容生产能力较弱的创作者提供辅助，丰富平台内容。</p><h3>科技有温度</h3><p>由一张海报聊聊 AI 作图和修图产品的定位及推广思路。</p><p>上周去阿里做分享，妙鸭推广团队同学问了我一个很有意思的问题：像这样的海报，我作为一个导师，是否有用 AI 生成人像的需求，是否值得作为一个使用场景重点去推？</p><p>我的回答是，虽然我是拥有大量钻石的妙鸭深度用户，出于本身对 AI 发展的兴趣和臭美的需要，我经常打开妙鸭玩玩，但最起码目前我不太会把 AI 生成的我自己做到海报上。</p><p>AI 生成的我，是一张太过完美的脸，而且太容易撞款了。试想一样的 prompt，由产品经理写好了，所有照片都有共性：一样的室内棚拍，一样的侧脸柔光，看多了一眼就知道这就是 AI 生成的，我对如出一辙的柔媚微笑已经疲劳了。</p><p>我觉得这里会有一个误区，包括很多同学在运营小红书的时候也有这个问题：AI 的长处是逼真，但它最应该做到的一定不是以假乱真，而是帮助人们，让真实生活更美好。</p><p>什么是真正帮到了现实生活的需求场景？举例我用妙鸭： </p><p>一、面对这样一个比我本人美丽 20% 的我，首先我心情很好；</p><p> 二、我会依据它的生成结果，了解我更适合什么，调整我日常的妆容； </p><p>三、我需要修图，那么 AI 修图功能可以提升我修图的效率。</p><p>……</p><p>以上都是帮助到我真实生活的地方，是它真正的不可替代性和最应该深化的价值导向。</p><p>在小红书推广方面，妙鸭应该找一个真正属于它自己的角色定位，不是可爱温暖才是人格化，能帮用户变美、帮用户变得更好，这是真正的「科技有温度」。</p><h3>给工程师为自己的产品做小红书的三条建议。</h3><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/5b8485b54e1d287e7c693fcbab248c1c.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><figure><img src=\"https://cdnfile.sspai.com/2025/02/05/36e24504fc20338ed1b7d5253124b05c.jpg?imageView2/2/w/1120/q/90/interlace/1/ignore-error/1/format/webp\"></figure><h3>两个运营小工具</h3><p>分享给日常需要买薯条加热笔记的朋友：从 App Store 和 Play Store下载的小红书 app 充值薯币均会收取 30% 手续费，非 Play Store 下载的 app 无手续费。</p><p>还可以搜索「小红书商城」，找到「薯币充值」，进入<a href=\"https://www.notion.so/581f624d21a8439cbb9dfadb6c01e782?pvs=21\" target=\"_blank\">充值页面</a>。这里支持微信和支付宝支付，该充值方式不收取任何手续费。</p><p>创业，省钱就是赚钱。</p><p>还有个完全免费，无需科学上网，不用 midjourney 的小红书优雅引流图片制作网站：<a href=\"https://photofunia.com/cn/\" target=\"_blank\">https://photofunia.com/cn/</a></p><h2>结尾</h2><p>小红书的搜索运营经常令我忍不住赞叹，很有才华的小朋友的感觉。也许某个短语是网友写的，但是官方总是能 catch 到最核心最亮眼最不俗的那句话，把 UGC 的魅力放到最大——《奶奶成了我的考研搭子》 《理解那英，成为这英》 《是让领导失去欲望的头像》，每个都是一句话小说。</p><p>在我看来，小红书最牛逼之处主要就是两个点：</p><ul><li><strong>第一，它无限突出放大了 UGC 的魅力；</strong></li><li><strong>第二，它创造了「笔记」这种新文体。</strong></li></ul><p>跟前小红书员工朋友聊天，他道出一个真相：在小红书上班是无法成为小红书博主的，因为成为小红书博主的必备条件是「不上班」。</p><p>作为一个「不上班」的人，我的主要业务逐渐转向 to B，不过我 to C 的自媒体课程卖得很好，它给了后面一切故事发生的可能性。我的课程业务，也就是《小红书运营手册》的价值观，主要有三点：</p><ul><li>工资之外，先赚一块。否则独立于公司之外永远只存于想象。这句话说给尚在职但不想上班的朋友。</li><li>你要有打造社交名片的意识，因为在移动互联网如此发达的我们生活的时代，你的作品在网络上，就是你的名片。</li><li>这世界上有两种生意，一种是机会导向，一种是从心出发。人一生中没有多少能从心出发做生意的机会，在当代，自媒体就是这样的机会。</li></ul><p>尽快打造自己的第一个产品吧。</p><p>你知道快乐是激素，是内分泌，是神经递质，不是因为拥有什么外部物质。去创造一个属于你的新东西，创造。</p><p>我们终生都在期待这种时刻在我们的生命中不断发生。</p><p style=\"margin-left:0px;\">> 下载 <a href=\"https://sspai.com/page/client\" target=\"_blank\">少数派 2.0 客户端</a>、关注 <a href=\"https://sspai.com/s/J71e\" target=\"_blank\">少数派公众号</a>，解锁全新阅读体验 📰 </p><p style=\"margin-left:0px;\">> 实用、好用的 <a href=\"https://sspai.com/mall\" target=\"_blank\">正版软件</a>，少数派为你呈现 🚀</p>"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/src/utils.ts",
    "content": "declare const __RN__: any\n\n// eslint-disable-next-line unicorn/no-typeof-undefined\nexport const isInRn = typeof __RN__ !== \"undefined\"\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/tailwind.config.ts",
    "content": "import { extendConfig } from \"@follow/configs/tailwindcss/web\"\nimport path from \"pathe\"\n\nconst rootDir = path.resolve(__dirname, \"../../../..\")\n\nexport default extendConfig({\n  darkMode: \"media\",\n  future: { hoverOnlyWhenSupported: true },\n  content: [\"./src/**/*.{ts,tsx}\", path.resolve(rootDir, \"packages/components/src/**/*.{ts,tsx}\")],\n})\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"moduleResolution\": \"Bundler\",\n    \"target\": \"ES2022\",\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true,\n    \"noImplicitReturns\": false,\n    \"noEmit\": true,\n    \"skipLibCheck\": true,\n    \"types\": [\"vite/client\", \"@follow/types/global\", \"@follow/types/react\"],\n    \"paths\": {\n      \"~/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/types/index.ts",
    "content": "export interface MediaModel {\n  url: string\n  type: \"photo\" | \"video\"\n  preview_image_url?: string\n  width?: number\n  height?: number\n  blurhash?: string\n}\n\nexport interface EntryModel {\n  content?: string\n  title?: string\n  media?: MediaModel[]\n}\n"
  },
  {
    "path": "apps/mobile/web-app/html-renderer/vite.config.mts",
    "content": "import react from \"@vitejs/plugin-react\"\nimport path from \"pathe\"\nimport { defineConfig } from \"vite\"\n\nimport { viteRenderBaseConfig } from \"../../../desktop/configs/vite.render.config\"\nimport { astPlugin } from \"../../../desktop/plugins/vite/ast\"\n\nexport default defineConfig({\n  ...viteRenderBaseConfig,\n  base: \"\",\n  build: {\n    outDir: path.resolve(import.meta.dirname, \"../../../../out/rn-web/html-renderer\"),\n  },\n  resolve: {\n    alias: {\n      \"~\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  define: {\n    ELECTRON: \"false\",\n  },\n\n  plugins: [react({}), astPlugin],\n})\n"
  },
  {
    "path": "apps/mobile/web-app/package.json",
    "content": "{\n  \"name\": \"@follow/rn-micro-web-app\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"cd html-renderer && vite build\",\n    \"typecheck\": \"cd html-renderer && tsc --noEmit\"\n  }\n}\n"
  },
  {
    "path": "apps/ssr/.env.example",
    "content": "VITE_WEB_URL=http://localhost:2233\nVITE_API_URL=http://localhost:3000\n\nVITE_EDITOR=cursor\n"
  },
  {
    "path": "apps/ssr/api/index.ts",
    "content": "// @ts-ignore\n// eslint-disable-next-line antfu/no-import-dist\nimport { createApp } from \"../dist/server/index.mjs\"\n\nexport default async function handler(req: any, res: any) {\n  const app = await createApp()\n  await app.ready()\n  app.server.emit(\"request\", req, res)\n}\n"
  },
  {
    "path": "apps/ssr/client/@types/constants.ts",
    "content": "// DONT EDIT THIS FILE MANUALLY\nconst langs = [\"en\", \"ja\", \"zh-CN\", \"zh-TW\"] as const\nexport const currentSupportedLanguages = langs as readonly string[]\nexport type SSRSupportedLanguages = (typeof langs)[number]\n\nexport const dayjsLocaleImportMap = {\n  en: [\"en\", () => import(\"dayjs/locale/en\")],\n  [\"zh-CN\"]: [\"zh-cn\", () => import(\"dayjs/locale/zh-cn\")],\n  [\"ja\"]: [\"ja\", () => import(\"dayjs/locale/ja\")],\n  [\"zh-TW\"]: [\"zh-tw\", () => import(\"dayjs/locale/zh-tw\")],\n}\nexport const ns = [\"common\", \"external\"] as const\nexport const defaultNS = \"external\" as const\n"
  },
  {
    "path": "apps/ssr/client/@types/default-resource.ts",
    "content": "// DONT EDIT THIS FILE MANUALLY\nimport common_en from \"@locales/common/en.json\"\nimport common_ja from \"@locales/common/ja.json\"\nimport common_zhCN from \"@locales/common/zh-CN.json\"\nimport common_zhTW from \"@locales/common/zh-TW.json\"\nimport external_en from \"@locales/external/en.json\"\nimport external_zhCN from \"@locales/external/zh-CN.json\"\n\nimport type { ns, SSRSupportedLanguages } from \"./constants\"\n\n/**\n * This file is the language resource that is loaded in full when the app is initialized.\n * When switching languages, the app will automatically download the required language resources,\n * we will not load all the language resources to minimize the first screen loading time of the app.\n * Generally, we only load english resources synchronously by default.\n * In addition, we attach common resources for other languages, and the size of the common resources must be controlled.\n */\nexport const defaultResources = {\n  en: {\n    common: common_en,\n    external: external_en,\n  },\n  \"zh-CN\": {\n    common: common_zhCN,\n    external: external_zhCN,\n  },\n  ja: {\n    common: common_ja,\n  },\n  \"zh-TW\": { common: common_zhTW },\n} satisfies Record<\n  SSRSupportedLanguages,\n  Partial<Record<(typeof ns)[number], Record<string, string>>>\n>\n"
  },
  {
    "path": "apps/ssr/client/@types/i18next.d.ts",
    "content": "import type { defaultNS, ns } from \"./constants\"\nimport type { defaultResources as resources } from \"./default-resource\"\n\ndeclare module \"i18next\" {\n  interface CustomTypeOptions {\n    ns: typeof ns\n    resources: (typeof resources)[\"en\"]\n    defaultNS: typeof defaultNS\n  }\n}\n"
  },
  {
    "path": "apps/ssr/client/App.tsx",
    "content": "import * as React from \"react\"\nimport { Outlet } from \"react-router\"\n\nimport { RootProviders } from \"./providers/root-providers\"\n\nfunction App() {\n  return (\n    <RootProviders>\n      <Outlet />\n    </RootProviders>\n  )\n}\n\nexport { App as Component }\n"
  },
  {
    "path": "apps/ssr/client/atoms/server-configs.ts",
    "content": "import { createAtomHooks } from \"@follow/utils/jotai\"\nimport type { StatusConfigs } from \"@follow-app/client-sdk\"\nimport { atom } from \"jotai\"\n\nexport const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks(\n  atom<Nullable<StatusConfigs>>(null),\n)\n"
  },
  {
    "path": "apps/ssr/client/atoms/settings/general.ts",
    "content": "import type { GeneralSettings } from \"@follow/shared/settings/interface\"\n\nimport { createSettingAtom } from \"./helper\"\n\nconst createDefaultSettings = (): Partial<GeneralSettings> => ({\n  // App\n  appLaunchOnStartup: false,\n  language: \"en\",\n  // Data control\n  sendAnonymousData: true,\n\n  // view\n  unreadOnly: false,\n  // mark unread\n  scrollMarkUnread: true,\n  hoverMarkUnread: false,\n  renderMarkUnread: false,\n  // UX\n  // autoHideFeedColumn: true,\n  groupByDate: false,\n  // Secure\n  jumpOutLinkWarn: true,\n  voice: \"\",\n})\n\nexport const {\n  useSettingKey: useGeneralSettingKey,\n  useSettingSelector: useGeneralSettingSelector,\n  setSetting: setGeneralSetting,\n  clearSettings: clearGeneralSettings,\n  initializeDefaultSettings: initializeDefaultGeneralSettings,\n  getSettings: getGeneralSettings,\n  useSettingValue: useGeneralSettingValue,\n\n  settingAtom: __generalSettingAtom,\n} = createSettingAtom(\"general\", createDefaultSettings)\n\nexport const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [\n  \"appLaunchOnStartup\",\n  \"sendAnonymousData\",\n  \"language\",\n]\n"
  },
  {
    "path": "apps/ssr/client/atoms/settings/helper.ts",
    "content": "import { useRefValue } from \"@follow/hooks\"\nimport { createAtomHooks } from \"@follow/utils/jotai\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { useAtomValue } from \"jotai\"\nimport { atomWithStorage, selectAtom } from \"jotai/utils\"\nimport { useMemo } from \"react\"\nimport { shallow } from \"zustand/shallow\"\n\nexport const createSettingAtom = <T extends object>(\n  settingKey: string,\n  createDefaultSettings: () => T,\n) => {\n  const atom = atomWithStorage(getStorageNS(settingKey), createDefaultSettings(), undefined, {\n    getOnInit: true,\n  })\n\n  const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom)\n\n  const initializeDefaultSettings = () => {\n    const currentSettings = getSettings()\n    const defaultSettings = createDefaultSettings()\n    if (typeof currentSettings !== \"object\") setSettings(defaultSettings)\n    const newSettings = { ...defaultSettings, ...currentSettings }\n    setSettings(newSettings)\n  }\n\n  const selectAtomCacheMap = {} as Record<keyof ReturnType<typeof getSettings>, any>\n\n  const useSettingKey = <T extends keyof ReturnType<typeof getSettings>>(key: T) => {\n    let selectedAtom = selectAtomCacheMap[key]\n    if (!selectedAtom) {\n      selectedAtom = selectAtom(atom, (s) => s[key])\n      selectAtomCacheMap[key] = selectedAtom\n    }\n\n    return useAtomValue(selectedAtom) as ReturnType<typeof getSettings>[T]\n  }\n\n  const useSettingSelector = <\n    T extends keyof ReturnType<typeof getSettings>,\n    S extends ReturnType<typeof getSettings>,\n    R = S[T],\n  >(\n    selector: (s: S) => R,\n  ): R => {\n    const stableSelector = useRefValue(selector)\n\n    return useAtomValue(\n      // @ts-expect-error\n      useMemo(() => selectAtom(atom, stableSelector.current, shallow), [stableSelector]),\n    )\n  }\n\n  const setSetting = <K extends keyof ReturnType<typeof getSettings>>(\n    key: K,\n    value: ReturnType<typeof getSettings>[K],\n  ) => {\n    const updated = Date.now()\n    setSettings({\n      ...getSettings(),\n      [key]: value,\n\n      updated,\n    })\n  }\n\n  const clearSettings = () => {\n    setSettings(createDefaultSettings())\n  }\n\n  Object.defineProperty(useSettingValue, \"select\", {\n    value: useSettingSelector,\n  })\n\n  return {\n    useSettingKey,\n    useSettingSelector,\n    setSetting,\n    clearSettings,\n    initializeDefaultSettings,\n\n    useSettingValue,\n    getSettings,\n\n    settingAtom: atom,\n  } as {\n    useSettingKey: typeof useSettingKey\n    useSettingSelector: typeof useSettingSelector\n    setSetting: typeof setSetting\n    clearSettings: typeof clearSettings\n    initializeDefaultSettings: typeof initializeDefaultSettings\n    useSettingValue: typeof useSettingValue & {\n      select: <T extends keyof ReturnType<() => T>>(key: T) => Awaited<T[T]>\n    }\n    getSettings: typeof getSettings\n    settingAtom: typeof atom\n  }\n}\n"
  },
  {
    "path": "apps/ssr/client/atoms/user.ts",
    "content": "import { createAtomHooks } from \"@follow/utils/jotai\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\nimport { atom } from \"jotai\"\n\nexport const [, , useWhoami, , whoami, setWhoami] = createAtomHooks(atom<Nullable<AuthUser>>(null))\n\nexport const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] =\n  createAtomHooks(atom<boolean>(false))\n"
  },
  {
    "path": "apps/ssr/client/components/common/404.tsx",
    "content": "import { openInFollowApp } from \"@client/lib/helper\"\nimport { RootProviders } from \"@client/providers/root-providers\"\nimport { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.jsx\"\nimport { useSyncThemeWebApp, useTitle } from \"@follow/hooks\"\nimport { m, useAnimationControls } from \"motion/react\"\nimport { Fragment, useEffect, useState } from \"react\"\nimport * as React from \"react\"\n\nconst NotFoundContent = () => {\n  const [glitchText, setGlitchText] = useState(\"404\")\n  const [isGlitching, setIsGlitching] = useState(false)\n\n  // Animation controls\n  const iconControls = useAnimationControls()\n  const messageControls = useAnimationControls()\n  const titleControls = useAnimationControls()\n  const descriptionControls = useAnimationControls()\n  const buttonsControls = useAnimationControls()\n  const helpControls = useAnimationControls()\n\n  useTitle(\"404 - Page Not Found\")\n  useSyncThemeWebApp()\n\n  useEffect(() => {\n    // Start all animations in parallel with their respective delays\n    iconControls.start({\n      scale: 1,\n      rotate: 0,\n      transition: {\n        duration: 0.8,\n        type: \"spring\",\n        stiffness: 100,\n        delay: 0.2,\n      },\n    })\n\n    messageControls.start({\n      opacity: 1,\n      y: 0,\n      transition: { duration: 0.6, delay: 0.4 },\n    })\n\n    titleControls.start({\n      opacity: 1,\n      x: 0,\n      transition: { duration: 0.5, delay: 0.6 },\n    })\n\n    descriptionControls.start({\n      opacity: 1,\n      x: 0,\n      transition: { duration: 0.5, delay: 0.8 },\n    })\n\n    buttonsControls.start({\n      opacity: 1,\n      scale: 1,\n      transition: { duration: 0.5, delay: 1 },\n    })\n\n    helpControls.start({\n      opacity: 1,\n      transition: { duration: 0.5, delay: 1.2 },\n    })\n  }, [\n    iconControls,\n    messageControls,\n    titleControls,\n    descriptionControls,\n    buttonsControls,\n    helpControls,\n  ])\n\n  useEffect(() => {\n    if (!isGlitching) return\n\n    const glitchTexts = [\"404\", \"40₄\", \"4Ø4\", \"404\", \"4◯4\", \"4○4\", \"4𝟘4\"]\n\n    const glitchInterval = setInterval(() => {\n      const randomText = glitchTexts[Math.floor(Math.random() * glitchTexts.length)]\n      setGlitchText(randomText || \"404\")\n    }, 200)\n    return () => {\n      setGlitchText(\"404\")\n      clearInterval(glitchInterval)\n    }\n  }, [isGlitching])\n  const handleGoHome = () => {\n    window.location.href = \"/\"\n  }\n\n  const handleOpenInApp = () => {\n    openInFollowApp({\n      deeplink: \"\",\n      fallbackUrl: \"/\",\n    })\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <MemoedDangerousHTMLStyle>\n        {`\n          @keyframes float {\n            0%, 100% { transform: translateY(0px); }\n            50% { transform: translateY(-10px); }\n          }\n\n          @keyframes pulse-glow {\n            0%, 100% { box-shadow: 0 0 20px rgba(168, 162, 158, 0.3); }\n            50% { box-shadow: 0 0 40px rgba(168, 162, 158, 0.6); }\n          }\n\n          @keyframes shake {\n            0%, 100% { transform: translateX(0); }\n            25% { transform: translateX(-2px); }\n            75% { transform: translateX(2px); }\n          }\n\n          @keyframes glitch {\n            0% { transform: translate(0); }\n            20% { transform: translate(-2px, 2px); }\n            40% { transform: translate(-2px, -2px); }\n            60% { transform: translate(2px, 2px); }\n            80% { transform: translate(2px, -2px); }\n            100% { transform: translate(0); }\n          }\n\n          .float-animation {\n            animation: float 3s ease-in-out infinite;\n          }\n\n          .pulse-glow-animation {\n            animation: pulse-glow 2s ease-in-out infinite;\n          }\n\n          .shake-animation {\n            animation: shake 0.5s ease-in-out;\n          }\n\n          .glitch-animation {\n            animation: glitch 0.1s linear infinite;\n          }`}\n      </MemoedDangerousHTMLStyle>\n      <div className=\"my-8 flex flex-col items-center justify-center pt-20\">\n        <Fragment>\n          {/* 404 Icon with animations */}\n          <m.div\n            className=\"mb-8 flex items-center justify-center\"\n            initial={{ scale: 0, rotate: -180 }}\n            animate={iconControls}\n          >\n            <m.div\n              className=\"float-animation pulse-glow-animation flex size-32 cursor-pointer items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800\"\n              whileHover={{\n                scale: 1.1,\n                rotate: [0, -5, 5, -5, 0],\n                transition: { duration: 0.5 },\n              }}\n              whileTap={{ scale: 0.95 }}\n              onClick={() => {\n                const element = document.querySelector(\".shake-animation\")\n                if (element) {\n                  element.classList.remove(\"shake-animation\")\n                  setTimeout(() => element.classList.add(\"shake-animation\"), 10)\n                }\n              }}\n              onMouseEnter={() => {\n                setIsGlitching(true)\n              }}\n              onMouseLeave={() => {\n                setIsGlitching(false)\n              }}\n            >\n              <m.span\n                className={`select-none text-4xl font-bold text-zinc-400 dark:text-zinc-600 ${isGlitching ? \"glitch-animation\" : \"\"}`}\n                key={glitchText}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.1 }}\n              >\n                {glitchText}\n              </m.span>\n            </m.div>\n          </m.div>\n          {/* Error Message with stagger animation */}\n          <m.div\n            className=\"mb-8 flex flex-col items-center text-center\"\n            initial={{ opacity: 0, y: 50 }}\n            animate={messageControls}\n          >\n            <m.h1\n              className=\"mb-4 text-3xl font-bold text-zinc-900 dark:text-zinc-100\"\n              initial={{ opacity: 0, x: -30 }}\n              animate={titleControls}\n            >\n              Page Not Found\n            </m.h1>\n            <m.p\n              className=\"max-w-md text-base text-zinc-500 dark:text-zinc-400\"\n              initial={{ opacity: 0, x: 30 }}\n              animate={descriptionControls}\n            >\n              Sorry, the page you are looking for doesn't exist or has been moved. Please check the\n              URL or return to the homepage to continue browsing.\n            </m.p>\n          </m.div>\n          {/* Action Buttons with hover effects */}\n          <m.div\n            className=\"flex flex-col items-center gap-4 sm:flex-row\"\n            initial={{ opacity: 0, scale: 0.8 }}\n            animate={buttonsControls}\n          >\n            <m.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>\n              <Button\n                onClick={handleGoHome}\n                buttonClassName=\"px-6 py-2 transition-all duration-200\"\n              >\n                Go Home\n              </Button>\n            </m.div>\n\n            <m.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>\n              <Button\n                variant=\"outline\"\n                onClick={handleOpenInApp}\n                buttonClassName=\"px-6 py-2 transition-all duration-200\"\n              >\n                Open {APP_NAME}\n              </Button>\n            </m.div>\n          </m.div>\n          {/* Additional Help with fade in */}\n          <m.div className=\"mt-12 text-center\" initial={{ opacity: 0 }} animate={helpControls}>\n            <p className=\"text-sm text-zinc-400 dark:text-zinc-500\">\n              If you believe this is an error, please submit a issue on{\" \"}\n              <m.a\n                className=\"text-accent transition-colors duration-200\"\n                href=\"https://github.com/rssnext/folo/issues\"\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                whileHover={{ scale: 1.05 }}\n                style={{ display: \"inline-block\" }}\n              >\n                GitHub\n              </m.a>\n            </p>\n          </m.div>\n          {/* Floating particles effect */}\n          <div className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n            {Array.from({ length: 6 }).map((_, i) => (\n              <m.div\n                key={i}\n                className=\"absolute size-1 rounded-full bg-zinc-300 opacity-30 dark:bg-zinc-600\"\n                initial={{\n                  x: Math.random() * (typeof window !== \"undefined\" ? window.innerWidth : 1000),\n                  y: Math.random() * (typeof window !== \"undefined\" ? window.innerHeight : 800),\n                }}\n                animate={{\n                  y: [null, -100],\n                  opacity: [0.3, 0],\n                }}\n                transition={{\n                  duration: Math.random() * 3 + 2,\n                  repeat: Infinity,\n                  delay: Math.random() * 2,\n                }}\n              />\n            ))}\n          </div>{\" \"}\n        </Fragment>\n      </div>\n    </div>\n  )\n}\n\nexport const NotFound = () => {\n  return (\n    <RootProviders>\n      <NotFoundContent />\n    </RootProviders>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/common/PoweredByFooter.tsx",
    "content": "import { SocialMediaLinks } from \"@follow/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as React from \"react\"\n\nexport const PoweredByFooter: Component = ({ className }) => (\n  <footer className={cn(\"border-t border-border/40 bg-background/60 backdrop-blur-sm\", className)}>\n    <div className=\"mx-auto w-full max-w-[var(--container-max-width)] px-6 py-10 lg:px-8\">\n      {/* Main row */}\n      <div className=\"flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between\">\n        {/* Left: copyright + legal */}\n        <div className=\"flex flex-col items-center gap-3 sm:items-start\">\n          <p className=\"text-center text-xs text-text-tertiary md:text-left\">\n            © {new Date().getFullYear()} <a href=\"https://app.folo.is\">Folo</a>. All rights\n            reserved.\n          </p>\n          <div className=\"flex items-center justify-center gap-4 md:justify-start\">\n            <a\n              href=\"https://folo.is/privacy-policy\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-xs text-text-secondary transition-colors hover:text-text\"\n            >\n              Privacy Policy\n            </a>\n            <a\n              href=\"https://folo.is/terms-of-service\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-xs text-text-secondary transition-colors hover:text-text\"\n            >\n              Terms of Service\n            </a>\n          </div>\n        </div>\n\n        {/* Right: social links */}\n        <div className=\"flex items-center justify-center gap-2 md:justify-end\">\n          {SocialMediaLinks.map((link) => (\n            <a\n              key={link.url}\n              href={link.url}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className={cn(\n                \"group relative flex items-center justify-center rounded-lg\",\n                \"hover:bg-fill/40 active:bg-fill/60\",\n                \"size-9\",\n              )}\n              aria-label={link.label}\n            >\n              <i\n                className={cn(\n                  link.iconClassName,\n                  \"transition-transform duration-200 group-hover:scale-110\",\n                )}\n              />\n              <div className=\"absolute inset-0 rounded-lg bg-gradient-to-r from-blue/10 to-purple/10 opacity-0 transition-opacity duration-200 group-hover:opacity-100\" />\n            </a>\n          ))}\n        </div>\n      </div>\n    </div>\n  </footer>\n)\n"
  },
  {
    "path": "apps/ssr/client/components/items/grid.tsx",
    "content": "import type { Feed } from \"@client/query/feed\"\nimport { TitleMarquee } from \"@follow/components/ui/marquee/index.jsx\"\nimport type { ParsedEntry } from \"@follow-app/client-sdk\"\nimport dayjs from \"dayjs\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\n\nimport { FeedIcon } from \"../ui/feed-icon\"\nimport { LazyImage } from \"../ui/image\"\n\nexport const GridList: FC<{\n  entries: ParsedEntry[]\n  feed?: Feed\n}> = ({ entries, feed }) => {\n  return (\n    <div className=\"grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 lg:px-3\">\n      {entries.map((entry) => (\n        <div\n          className=\"overflow-hidden rounded-md p-1.5 duration-200 hover:bg-material-medium\"\n          key={entry.id}\n        >\n          <div className=\"relative -mx-1.5 -mt-1.5\">\n            <LazyImage\n              src={entry.media?.[0]!.url}\n              className=\"aspect-video h-auto w-full shrink-0 rounded-md object-cover\"\n            />\n          </div>\n          <GridItemFooter feed={feed} entryId={entry.id} entryPreview={entry} />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nconst GridItemFooter: FC<{\n  feed?: Feed\n  entryId: string\n  entryPreview: ParsedEntry\n}> = ({ feed, entryPreview }) => {\n  return (\n    <div className={\"relative px-2 py-1 text-sm\"}>\n      <div className=\"flex items-center\">\n        <div className={\"relative mb-1 mt-1.5 flex w-full items-center gap-1 truncate font-medium\"}>\n          <TitleMarquee className=\"min-w-0 grow\">{entryPreview.title}</TitleMarquee>\n        </div>\n      </div>\n      <div className=\"flex items-center gap-1 truncate text-[13px]\">\n        <FeedIcon\n          fallback\n          className=\"mr-0.5 flex\"\n          target={feed?.feed}\n          entry={entryPreview}\n          size={18}\n        />\n        <span className={\"min-w-0 truncate\"}>{feed?.feed.title}</span>\n        <span className={\"text-zinc-500\"}>·</span>\n        <span className={\"text-zinc-500\"}>\n          {dayjs\n            .duration(dayjs(entryPreview.publishedAt).diff(dayjs(), \"minute\"), \"minute\")\n            .humanize()}\n        </span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/items/index.tsx",
    "content": "import { GridList } from \"@client/components/items/grid\"\nimport { NormalListItem } from \"@client/components/items/normal\"\nimport { PictureList } from \"@client/components/items/picture\"\nimport type { Feed } from \"@client/query/feed\"\nimport { FeedViewType } from \"@follow/constants\"\nimport type { ParsedEntry } from \"@follow-app/client-sdk\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\nimport * as React from \"react\"\n\nconst viewsRenderType = {\n  Normal: [\n    FeedViewType.Articles,\n    FeedViewType.Audios,\n    FeedViewType.Notifications,\n    FeedViewType.SocialMedia,\n  ],\n  Picture: [FeedViewType.Pictures],\n  Grid: [FeedViewType.Videos],\n}\n\nexport const Item = ({\n  entries,\n  feed,\n  view,\n}: {\n  entries: ParsedEntry[]\n  feed?: Feed\n  view: FeedViewType\n}) => {\n  return useMemo(() => {\n    switch (true) {\n      case viewsRenderType.Normal.includes(view): {\n        return <NormalList entries={entries} feed={feed} />\n      }\n      case viewsRenderType.Picture.includes(view): {\n        return <PictureList entries={entries} feed={feed} />\n      }\n      case viewsRenderType.Grid.includes(view): {\n        return <GridList entries={entries} feed={feed} />\n      }\n    }\n  }, [entries, feed, view])\n}\n\nconst NormalList: FC<{\n  entries: ParsedEntry[]\n\n  feed?: Feed\n}> = ({ entries, feed }) => {\n  return (\n    <>\n      {entries?.map((entry) => (\n        <div className=\"relative cursor-default\" key={entry.id}>\n          <NormalListItem\n            withDetails\n            entryPreview={{\n              entry,\n              feed: feed?.feed,\n\n              feedId: feed?.feed.id,\n            }}\n          />\n        </div>\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/items/normal.tsx",
    "content": "import { RelativeTime } from \"@follow/components/ui/datetime/index.jsx\"\nimport { EllipsisHorizontalTextWithTooltip } from \"@follow/components/ui/typography/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FeedSchema, ParsedEntry } from \"@follow-app/client-sdk\"\nimport { memo } from \"react\"\nimport * as React from \"react\"\n\nimport { FeedIcon } from \"../ui/feed-icon\"\nimport { LazyImage } from \"../ui/image\"\n\nfunction NormalListItemImpl({\n  entryPreview,\n\n  withDetails,\n}: {\n  entryPreview: {\n    entry: ParsedEntry\n    feed: Nullable<FeedSchema>\n    feedId: Nullable<string>\n  }\n  withDetails?: boolean\n}) {\n  const entry = entryPreview\n\n  const feed = entryPreview?.feed\n\n  if (!entry || !feed) return null\n  const displayTime = entry.entry.publishedAt\n\n  return (\n    <div\n      className={\n        \"group relative mx-auto flex max-w-3xl gap-2 py-4 pl-3 pr-2 before:pointer-events-none before:absolute before:-inset-x-2 before:inset-y-0 before:z-[-1] before:scale-0 before:rounded-xl before:opacity-0 before:transition-all before:duration-200 hover:before:scale-100 hover:before:bg-material-ultra-thick hover:before:opacity-100\"\n      }\n    >\n      <FeedIcon target={feed} fallback entry={entry.entry} />\n      <div className={\"-mt-0.5 flex-1 text-base leading-tight\"}>\n        <div className={cn(\"flex gap-1 text-xs font-bold\", \"text-text-secondary\")}>\n          <EllipsisHorizontalTextWithTooltip className=\"truncate\">\n            {feed?.title}\n          </EllipsisHorizontalTextWithTooltip>\n          <span>·</span>\n          <span className=\"shrink-0\">{!!displayTime && <RelativeTime date={displayTime} />}</span>\n        </div>\n        <div className={cn(\"relative my-0.5 line-clamp-1 break-words font-medium text-text\")}>\n          {entry.entry.title}\n        </div>\n        {withDetails && (\n          <div className=\"flex gap-2\">\n            <div className={cn(\"grow text-sm\", \"line-clamp-3 text-text-secondary\")}>\n              {entry.entry.description}\n            </div>\n          </div>\n        )}\n      </div>\n      {entry.entry.media?.[0] && (\n        <div className=\"relative size-24 shrink-0 overflow-hidden rounded\">\n          <LazyImage\n            proxy={{\n              width: 160,\n              height: 160,\n            }}\n            className=\"overflow-hidden rounded-lg\"\n            src={entry.entry.media[0].url}\n            height={entry.entry.media[0].height}\n            width={entry.entry.media[0].width}\n            blurhash={entry.entry.media[0].blurhash}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport const NormalListItem = memo(NormalListItemImpl)\n"
  },
  {
    "path": "apps/ssr/client/components/items/picture.tsx",
    "content": "import { LazyImage } from \"@client/components/ui/image\"\nimport { getPreferredTitle } from \"@client/lib/helper\"\nimport type { Feed } from \"@client/query/feed\"\nimport { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.jsx\"\nimport { TitleMarquee } from \"@follow/components/ui/marquee/index.jsx\"\nimport {\n  MasonryItemsAspectRatioContext,\n  MasonryItemsAspectRatioSetterContext,\n  MasonryItemWidthContext,\n  useMasonryItemRatio,\n  useMasonryItemWidth,\n  useSetStableMasonryItemRatio,\n} from \"@follow/components/ui/masonry/contexts.jsx\"\nimport { Masonry } from \"@follow/components/ui/masonry/index.jsx\"\nimport { nextFrame } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { ParsedEntry } from \"@follow-app/client-sdk\"\nimport dayjs from \"dayjs\"\nimport { throttle } from \"es-toolkit/compat\"\nimport type { RenderComponentProps } from \"masonic\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport * as React from \"react\"\nimport { PhotoProvider, PhotoView } from \"react-photo-view\"\nimport inlineStyle from \"react-photo-view/dist/react-photo-view.css?raw\"\n\nimport { FeedIcon } from \"../ui/feed-icon\"\n\nconst MasonryItemFixedDimensionWrapper = (\n  props: PropsWithChildren<{\n    url: string\n    ratio?: number\n    height?: number\n  }>,\n) => {\n  const { url, children, ratio } = props\n  const itemWidth = useMasonryItemWidth()\n\n  const itemHeight = ratio ? itemWidth * ratio : itemWidth\n  const stableRadio = useState(() => itemWidth / itemHeight || 1)[0]\n  const setItemStableRatio = useSetStableMasonryItemRatio()\n\n  const stableRadioCtx = useMasonryItemRatio(url)\n\n  useEffect(() => {\n    setItemStableRatio(url, stableRadio)\n  }, [setItemStableRatio, stableRadio, url])\n\n  const style = useMemo(\n    () => ({\n      width: itemWidth,\n      height: itemWidth / stableRadioCtx!,\n    }),\n    [itemWidth, stableRadioCtx],\n  )\n\n  if (!style.height) return null\n\n  return (\n    <div className=\"relative flex h-full overflow-hidden\" style={style}>\n      {children}\n    </div>\n  )\n}\n\nconst maskStyle = {\n  maskImage: \"linear-gradient(rgba(255, 255, 255, 0) 0%, rgb(255, 255, 255) 10px)\",\n}\n\nconst breakpoints = {\n  0: 1,\n  // 32rem => 32 * 16= 512\n  512: 2,\n  // 48rem => 48 * 16= 768\n  768: 3,\n  // 72rem => 72 * 16= 1152\n  976: 4,\n  // 80rem => 80 * 16= 1280\n  1280: 5,\n  1536: 6,\n  1792: 7,\n  2048: 8,\n}\nconst getCurrentColumn = (w: number) => {\n  // Initialize column count with the minimum number of columns\n  let columns = 1\n\n  // Iterate through each breakpoint and determine the column count\n  for (const [breakpoint, cols] of Object.entries(breakpoints)) {\n    if (w >= Number.parseInt(breakpoint)) {\n      columns = cols\n    } else {\n      break\n    }\n  }\n\n  return columns\n}\nexport const PictureList: FC<{\n  entries: ParsedEntry[]\n\n  feed?: Feed\n}> = ({ entries, feed }) => {\n  const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})\n  const [currentItemWidth, setCurrentItemWidth] = useState(0)\n  const [currentColumn, setCurrentColumn] = useState(1)\n  const containerRef = useRef<HTMLDivElement>(null)\n  const [isInitLayout, setIsInitLayout] = useState(false)\n\n  const xGutter = currentColumn === 1 ? 0 : 12\n  const yGutter = 12\n  useLayoutEffect(() => {\n    const $warpper = containerRef.current\n    if (!$warpper) return\n    const handler = () => {\n      const column = getCurrentColumn($warpper.clientWidth)\n\n      setCurrentItemWidth(Math.trunc($warpper.clientWidth / column - xGutter))\n\n      setCurrentColumn(column)\n\n      nextFrame(() => {\n        setIsInitLayout(true)\n      })\n    }\n    const recal = throttle(handler, 1000 / 12)\n\n    let previousWidth = $warpper.offsetWidth\n    const resizeObserver = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const newWidth = entry.contentRect.width\n\n        if (newWidth !== previousWidth) {\n          previousWidth = newWidth\n\n          recal()\n        }\n      }\n    })\n    recal()\n    resizeObserver.observe($warpper)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [xGutter])\n\n  const items = useMemo(() => {\n    const flattenedItems = []\n    const imageSrcSet = new Set<string>()\n    for (let i = 0; i < entries?.length || 0; i++) {\n      const entry = entries[i]!\n      if (!entry.media) continue\n      for (let j = 0; j < entry.media?.length || 0; j++) {\n        const media = entry.media[j]!\n        if (imageSrcSet.has(media.url)) continue\n        imageSrcSet.add(media.url)\n\n        flattenedItems.push({\n          url: media.url,\n          height: media.height,\n          width: media.width,\n          id: entry.id,\n          blurhash: media.blurhash,\n\n          entry,\n          feed,\n        })\n      }\n    }\n    return flattenedItems\n  }, [entries, feed])\n\n  return (\n    <PhotoProvider>\n      <div className=\"relative flex min-w-0 px-2 lg:px-6\">\n        <MemoedDangerousHTMLStyle>{inlineStyle}</MemoedDangerousHTMLStyle>\n        <div className=\"flex w-full flex-wrap\" ref={containerRef}>\n          {isInitLayout && (\n            <MasonryItemWidthContext value={currentItemWidth}>\n              {/* eslint-disable-next-line @eslint-react/no-context-provider */}\n              <MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>\n                <MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>\n                  <div className=\"relative w-full\">\n                    <Masonry\n                      items={items}\n                      columnGutter={yGutter}\n                      columnWidth={currentItemWidth}\n                      columnCount={currentColumn}\n                      overscanBy={2}\n                      render={render}\n                      itemKey={itemKey}\n                    />\n                  </div>\n                </MasonryItemsAspectRatioSetterContext>\n              </MasonryItemsAspectRatioContext.Provider>\n            </MasonryItemWidthContext>\n          )}\n        </div>\n      </div>\n    </PhotoProvider>\n  )\n}\n\nconst itemKey = (item: { url: string }) => item.url\nconst render: React.ComponentType<\n  RenderComponentProps<{\n    url: string\n    height: number | undefined\n    width: number | undefined\n    blurhash: string | undefined\n    id: string\n    entry: ParsedEntry\n    feed?: Feed\n  }>\n> = memo(({ data }) => {\n  const [isHovered, setIsHovered] = useState(false)\n\n  return (\n    <MasonryItemFixedDimensionWrapper\n      url={data.url}\n      key={data.id}\n      height={data.height}\n      ratio={data.height && data.width ? data.height / data.width : undefined}\n    >\n      <div\n        className=\"size-full overflow-hidden rounded-md\"\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => setIsHovered(false)}\n      >\n        <PhotoView src={data.url}>\n          <LazyImage\n            src={data.url}\n            height={data.height}\n            width={data.width}\n            blurhash={data.blurhash}\n            className=\"duration-200 hover:scale-105\"\n          />\n        </PhotoView>\n\n        <AnimatePresence>\n          {isHovered && (\n            <m.div\n              initial={{ opacity: 0, y: 10 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 10 }}\n              className=\"absolute inset-x-0 -bottom-px z-[3] overflow-hidden rounded-b-md pb-1\"\n              key=\"footer\"\n            >\n              <div className=\"absolute inset-x-0 bottom-0 h-[56px]\" style={maskStyle}>\n                <div className=\"absolute inset-x-0 bottom-0 h-[76px] bg-gradient-to-t from-black/80 to-transparent\" />\n              </div>\n              <GridItemFooter\n                entry={data.entry}\n                feed={data.feed}\n                titleClassName=\"!text-white\"\n                descriptionClassName=\"!text-white/80\"\n                timeClassName=\"!text-white/60\"\n              />\n            </m.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </MasonryItemFixedDimensionWrapper>\n  )\n})\nconst GridItemFooter = ({\n  entry,\n\n  titleClassName,\n  descriptionClassName,\n  timeClassName,\n  feed,\n}: {\n  entry: ParsedEntry\n  feed?: Feed\n  titleClassName?: string\n  descriptionClassName?: string\n  timeClassName?: string\n}) => {\n  return (\n    <div className={cn(\"relative px-2 py-1 text-sm\")}>\n      <div className=\"flex items-center\">\n        <div className={\"mr-1 size-1.5 shrink-0 self-center rounded-full bg-accent duration-200\"} />\n        <div\n          className={cn(\n            \"relative mb-1 mt-1.5 flex w-full items-center gap-1 truncate font-medium\",\n            titleClassName,\n          )}\n        >\n          <TitleMarquee className=\"min-w-0 grow\">{entry.title}</TitleMarquee>\n        </div>\n      </div>\n      <div className=\"flex items-center gap-1 truncate text-[13px]\">\n        <FeedIcon fallback className=\"mr-0.5 flex\" target={feed?.feed} entry={entry} size={18} />\n        <span className={cn(\"min-w-0 truncate\", descriptionClassName)}>\n          {getPreferredTitle(feed?.feed)}\n        </span>\n        <span className={cn(\"text-zinc-500\", timeClassName)}>·</span>\n        <span className={cn(\"text-zinc-500\", timeClassName)}>\n          {dayjs.duration(dayjs(entry.publishedAt).diff(dayjs(), \"minute\"), \"minute\").humanize()}\n        </span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/layout/header/index.tsx",
    "content": "import { Folo } from \"@follow/components/icons/folo.js\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { MotionValue } from \"motion/react\"\nimport { m, useMotionValueEvent, useScroll } from \"motion/react\"\nimport * as React from \"react\"\nimport { useState } from \"react\"\n\nconst useMotionValueToState = (value: MotionValue<number>) => {\n  const [state, setState] = useState(value.get())\n  useMotionValueEvent(value, \"change\", (v) => setState(v))\n  return state\n}\n\nfunction Container({ className, ...props }: React.ComponentPropsWithoutRef<\"div\">) {\n  return (\n    <div\n      className={cn(\"mx-auto w-full max-w-[var(--container-max-width)]\", className)}\n      {...props}\n    />\n  )\n}\n\nconst HeaderWrapper: Component = (props) => {\n  const { scrollY } = useScroll()\n  const scrollYState = useMotionValueToState(scrollY)\n\n  // Enhanced scroll state management\n  const isHeaderElevated = scrollYState > 20\n  const isCompact = scrollYState > 60\n\n  return (\n    <header className={\"fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-out\"}>\n      <div\n        className={cn(\n          \"mx-4 mt-4 max-w-5xl transition-all duration-300 ease-out lg:mx-auto\",\n          isCompact ? \"mt-2\" : \"mt-4\",\n        )}\n      >\n        <m.div\n          className={cn(\n            \"rounded-full border border-transparent px-6 transition-all duration-300 ease-out\",\n            \"relative flex items-center\",\n            isCompact ? \"py-2 pl-5 pr-2\" : \"py-3\",\n            isHeaderElevated && [\n              \"border-border/50 bg-background/80 shadow-sm backdrop-blur-xl\",\n              \"supports-[backdrop-filter]:bg-background/60\",\n            ],\n          )}\n        >\n          {props.children}\n        </m.div>\n      </div>\n    </header>\n  )\n}\n\nexport const Header = () => {\n  const { scrollY } = useScroll()\n  const scrollYState = useMotionValueToState(scrollY)\n  const isCompact = scrollYState > 60\n\n  return (\n    <HeaderWrapper>\n      <Container className=\"w-full\">\n        <nav className=\"relative flex w-full items-center justify-between\">\n          {/* Enhanced Logo Section */}\n          <m.div\n            whileHover={{ scale: 1.02 }}\n            whileTap={{ scale: 0.98 }}\n            className=\"flex shrink-0 items-center\"\n          >\n            <a\n              className={cn(\n                \"group flex items-center gap-3 rounded-lg px-2 py-1.5 transition-all duration-200\",\n                \"hover:bg-fill/30\",\n              )}\n              href=\"/\"\n            >\n              <Logo\n                className={cn(\n                  \"transition-all duration-300\",\n                  isCompact ? \"h-6 w-auto\" : \"h-8 w-auto\",\n                )}\n              />\n              <Folo\n                className={cn(\"transition-all duration-300\", isCompact ? \"size-7\" : \"size-10\")}\n              />\n            </a>\n          </m.div>\n\n          {/* Right actions */}\n          <div className=\"flex shrink-0 items-center gap-2\">\n            {/* GitHub stars pill */}\n            <m.a\n              href=\"https://github.com/RSSNext/Folo\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              whileHover={{ scale: 1.02 }}\n              whileTap={{ scale: 0.98 }}\n              className={cn(\n                \"inline-flex items-center gap-2 rounded-full border border-fill-tertiary px-6 font-medium\",\n                \"bg-fill-quinary text-sm text-text backdrop-blur-background hover:bg-fill-tertiary\",\n                isCompact ? \"h-8\" : \"h-10\",\n              )}\n            >\n              <i className=\"i-mgc-github-cute-fi text-base\" />\n              GitHub\n            </m.a>\n\n            {/* Sign in pill */}\n            <m.a\n              href=\"/login\"\n              whileHover={{ scale: 1.02 }}\n              whileTap={{ scale: 0.98 }}\n              className={cn(\n                \"inline-flex items-center justify-center rounded-full px-6 font-medium\",\n                \"border border-accent/20 bg-accent/90 text-sm text-white shadow-sm hover:shadow\",\n                isCompact ? \"h-8\" : \"h-10\",\n              )}\n            >\n              Sign in\n            </m.a>\n          </div>\n        </nav>\n      </Container>\n    </HeaderWrapper>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/ui/feed-certification.tsx",
    "content": "import { useWhoami } from \"@client/atoms/user\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@follow/components/ui/tooltip/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FeedSchema } from \"@follow-app/client-sdk\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nexport const FeedCertification = ({\n  feed,\n  className,\n}: {\n  feed: FeedSchema\n  className?: string\n}) => {\n  const me = useWhoami()\n\n  const { t } = useTranslation()\n\n  const { type } = feed\n\n  return (\n    feed.ownerUserId &&\n    (feed.ownerUserId === me?.id ? (\n      <Tooltip delayDuration={300}>\n        <TooltipTrigger asChild>\n          <i className={cn(\"i-mgc-certificate-cute-fi ml-1.5 shrink-0 text-accent\", className)} />\n        </TooltipTrigger>\n\n        <TooltipPortal>\n          <TooltipContent className=\"px-4 py-2\">\n            <div className=\"flex items-center text-base font-semibold\">\n              <i className=\"i-mgc-certificate-cute-fi mr-2 size-4 shrink-0 text-accent\" />\n              {type === \"feed\" ? t(\"feed_item.claimed_feed\") : t(\"feed_item.claimed_list\")}\n            </div>\n            <div>{t(\"feed_item.claimed_by_you\")}</div>\n          </TooltipContent>\n        </TooltipPortal>\n      </Tooltip>\n    ) : (\n      <Tooltip delayDuration={300}>\n        <TooltipTrigger asChild>\n          <i\n            className={cn(\"i-mgc-certificate-cute-fi ml-1.5 shrink-0 text-amber-500\", className)}\n          />\n        </TooltipTrigger>\n\n        <TooltipPortal>\n          <TooltipContent className=\"px-4 py-2\">\n            <div className=\"flex items-center text-base font-semibold\">\n              <i className=\"i-mgc-certificate-cute-fi mr-2 shrink-0 text-amber-500\" />\n              {type === \"feed\" ? t(\"feed_item.claimed_feed\") : t(\"feed_item.claimed_list\")}\n            </div>\n            <div className=\"mt-1 flex items-center gap-1.5\">\n              <span>{t(\"feed_item.claimed_by_owner\")}</span>\n              {feed.owner ? (\n                <Avatar className=\"inline-flex aspect-square size-5 rounded-full\">\n                  <AvatarImage src={feed.owner.image || undefined} />\n                  <AvatarFallback>{feed.owner.name?.slice(0, 2)}</AvatarFallback>\n                </Avatar>\n              ) : (\n                <span>{t(\"feed_item.claimed_by_unknown\")}</span>\n              )}\n            </div>\n          </TooltipContent>\n        </TooltipPortal>\n      </Tooltip>\n    ))\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/ui/feed-icon.tsx",
    "content": "import { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { PlatformIcon } from \"@follow/components/ui/platform-icon/index.jsx\"\nimport { getBackgroundGradient } from \"@follow/utils/color\"\nimport { getImageProxyUrl, replaceImgUrlIfNeed } from \"@follow/utils/img-proxy\"\nimport { cn, getUrlIcon } from \"@follow/utils/utils\"\nimport type { FeedGetResponse } from \"@follow-app/client-sdk\"\nimport type { MediaModel } from \"@folo-services/drizzle\"\nimport { m } from \"motion/react\"\nimport type { ReactNode } from \"react\"\nimport { useMemo } from \"react\"\nimport * as React from \"react\"\n\nconst getFeedIconSrc = ({\n  src,\n  siteUrl,\n  fallback,\n  proxy,\n}: {\n  src?: string\n  siteUrl?: string\n  fallback?: boolean\n  proxy?: { height: number; width: number }\n} = {}) => {\n  if (src) {\n    if (proxy) {\n      return [\n        getImageProxyUrl({\n          url: src,\n          width: proxy.width,\n          height: proxy.height,\n          canUseProxy: true,\n        }),\n        \"\",\n      ]\n    }\n\n    return [src, \"\"]\n  }\n  if (!siteUrl) return [\"\", \"\"]\n  const ret = getUrlIcon(siteUrl, fallback)\n\n  return [ret.src, ret.fallbackUrl]\n}\n\nconst FallbackableImage = function FallbackableImage({\n  ref,\n  fallbackUrl,\n  ...rest\n}: {\n  fallbackUrl: string\n} & React.ImgHTMLAttributes<HTMLImageElement> & {\n    ref?: React.Ref<HTMLImageElement | null>\n  }) {\n  return (\n    <img\n      onError={(e) => {\n        if (fallbackUrl && e.currentTarget.src !== fallbackUrl) {\n          e.currentTarget.src = fallbackUrl\n        } else {\n          rest.onError?.(e)\n          // Empty svg\n          e.currentTarget.src =\n            \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3C/svg%3E\"\n        }\n      }}\n      {...rest}\n      ref={ref}\n    />\n  )\n}\n\ntype FeedIconEntry = {\n  media?: Nullable<MediaModel[]>\n\n  [key: string]: any\n}\nconst fadeInVariant = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n}\n\ntype FeedIconTarget = {\n  title?: Nullable<string>\n  image?: Nullable<string>\n  siteUrl?: Nullable<string>\n  type: \"feed\" | \"list\" | \"inbox\"\n  entry?: FeedIconEntry | null\n  useMedia?: boolean\n  feed?: FeedGetResponse[\"data\"][\"feed\"] | null\n\n  url?: string\n  [key: string]: any\n}\nconst isIconLoadedSet = new Set<string>()\nexport function FeedIcon({\n  target,\n  entry,\n  fallbackUrl,\n  className,\n  size = 20,\n  fallback = true,\n  fallbackElement,\n  siteUrl,\n  useMedia,\n  disableFadeIn,\n  noMargin,\n}: {\n  target?: FeedIconTarget | null\n  entry?: FeedIconEntry | null\n  fallbackUrl?: string\n  className?: string\n  size?: number\n  siteUrl?: string\n  /**\n   * Image loading error fallback to site icon\n   */\n  fallback?: boolean\n  fallbackElement?: ReactNode\n\n  useMedia?: boolean\n  disableFadeIn?: boolean\n  noMargin?: boolean\n}) {\n  const marginClassName = noMargin ? \"\" : \"mr-2\"\n  const image =\n    (useMedia\n      ? entry?.media?.find((i) => i.type === \"photo\")?.url || entry?.authorAvatar\n      : entry?.authorAvatar) || target?.image\n\n  const colors = useMemo(\n    () => getBackgroundGradient(target?.title || target?.url || siteUrl || \"\"),\n    [target?.title, target?.url, siteUrl],\n  )\n  let ImageElement: ReactNode\n  let finalSrc = \"\"\n\n  const sizeStyle: React.CSSProperties = useMemo(\n    () => ({\n      width: size,\n      height: size,\n    }),\n    [size],\n  )\n  const colorfulStyle: React.CSSProperties = useMemo(() => {\n    const [, , , bgAccent, bgAccentLight, bgAccentUltraLight] = colors\n    return {\n      backgroundImage: `linear-gradient(to top, ${bgAccent} 0%, ${bgAccentLight} 99%, ${bgAccentUltraLight} 100%)`,\n\n      ...sizeStyle,\n    }\n  }, [colors, sizeStyle])\n\n  const fallbackIcon = (\n    <span\n      style={colorfulStyle}\n      className={cn(\n        \"flex shrink-0 items-center justify-center rounded-sm\",\n        \"text-white\",\n        marginClassName,\n        className,\n      )}\n    >\n      <span\n        style={{\n          fontSize: size / 2,\n        }}\n      >\n        {!!target?.title && target.title[0]}\n      </span>\n    </span>\n  )\n\n  switch (true) {\n    case !target && !!siteUrl: {\n      const [src] = getFeedIconSrc({\n        siteUrl,\n      })\n      finalSrc = src!\n\n      const isIconLoaded = isIconLoadedSet.has(src!)\n      isIconLoadedSet.add(src!)\n\n      ImageElement = (\n        <PlatformIcon url={siteUrl} style={sizeStyle} className={cn(\"center\", className)}>\n          <m.img style={sizeStyle} {...(disableFadeIn || isIconLoaded ? {} : fadeInVariant)} />\n        </PlatformIcon>\n      )\n      break\n    }\n    case !!image: {\n      finalSrc = getImageProxyUrl({\n        url: image,\n        width: size * 2,\n        height: size * 2,\n      })\n      const isIconLoaded = isIconLoadedSet.has(finalSrc)\n      isIconLoadedSet.add(finalSrc)\n\n      ImageElement = (\n        <PlatformIcon url={image} style={sizeStyle} className={cn(\"center\", className)}>\n          <m.img\n            className={cn(marginClassName, className)}\n            style={sizeStyle}\n            {...(disableFadeIn || isIconLoaded ? {} : fadeInVariant)}\n          />\n        </PlatformIcon>\n      )\n      break\n    }\n    case !!fallbackUrl:\n    case !!target?.siteUrl: {\n      const [src, fallbackSrc] = getFeedIconSrc({\n        siteUrl: target?.siteUrl || fallbackUrl,\n        fallback,\n        proxy: {\n          width: size * 2,\n          height: size * 2,\n        },\n      })\n      finalSrc = src!\n\n      ImageElement = (\n        <PlatformIcon\n          url={target?.siteUrl || fallbackUrl}\n          style={sizeStyle}\n          className={cn(\"center\", className)}\n        >\n          <FallbackableImage\n            className={cn(marginClassName, className)}\n            style={sizeStyle}\n            fallbackUrl={fallbackSrc!}\n          />\n        </PlatformIcon>\n      )\n      break\n    }\n    case target?.type === \"inbox\": {\n      ImageElement = (\n        <i className={cn(\"i-mgc-inbox-cute-fi shrink-0\", marginClassName)} style={sizeStyle} />\n      )\n      break\n    }\n    case !!target?.title && !!target.title[0]: {\n      ImageElement = fallbackIcon\n      break\n    }\n    default: {\n      ImageElement = (\n        <i className={cn(\"i-mgc-link-cute-re shrink-0\", marginClassName)} style={sizeStyle} />\n      )\n      break\n    }\n  }\n\n  if (!ImageElement) {\n    return null\n  }\n\n  if (fallback && !!finalSrc) {\n    return (\n      <Avatar className={cn(\"shrink-0\", marginClassName)} style={sizeStyle}>\n        <AvatarImage\n          className=\"rounded-sm object-cover\"\n          asChild\n          src={replaceImgUrlIfNeed({ url: finalSrc, inBrowser: true })}\n        >\n          {ImageElement}\n        </AvatarImage>\n        <AvatarFallback delayMs={200} asChild>\n          {fallbackElement || fallbackIcon}\n        </AvatarFallback>\n      </Avatar>\n    )\n  }\n\n  // Is Icon\n  if (!finalSrc) return ImageElement\n  // Else\n  return (\n    <Avatar className={cn(\"shrink-0\", marginClassName)} style={sizeStyle}>\n      <AvatarImage asChild src={replaceImgUrlIfNeed({ url: finalSrc, inBrowser: true })}>\n        {ImageElement}\n      </AvatarImage>\n      <AvatarFallback delayMs={200}>\n        <div className={className} style={sizeStyle} data-placeholder={finalSrc} />\n      </AvatarFallback>\n    </Avatar>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/ui/image.tsx",
    "content": "import { getImageProxyUrl } from \"@follow/utils/img-proxy\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as Avatar from \"@radix-ui/react-avatar\"\nimport type { MouseEvent, PropsWithChildren } from \"react\"\nimport { useMemo } from \"react\"\nimport * as React from \"react\"\nimport { Blurhash } from \"react-blurhash\"\n\ntype LazyImageProps = PropsWithChildren<{\n  src?: string\n  blurhash?: string\n\n  className?: string\n\n  height?: number\n  width?: number\n\n  proxy?: {\n    width: number\n    height: number\n  }\n  onClick?: (e: MouseEvent) => void\n}>\nexport const LazyImage = ({\n  ref,\n  src,\n  blurhash,\n  className,\n  height,\n  width,\n  proxy,\n  onClick,\n}: LazyImageProps & { ref?: React.Ref<HTMLImageElement | null> }) => {\n  const nextSrc = useMemo(() => {\n    if (!src) return src\n\n    if (!proxy?.height && !proxy?.width) {\n      return src\n    }\n    return getImageProxyUrl({\n      url: src,\n      width: proxy?.width,\n      height: proxy?.height,\n      canUseProxy: true,\n    })\n  }, [src, proxy?.height, proxy?.width])\n  return (\n    <Avatar.Root className=\"relative\">\n      <Avatar.Image\n        ref={ref}\n        src={nextSrc}\n        height={height}\n        width={width}\n        className={cn(\"size-full object-cover\", className)}\n        onClick={onClick}\n        tabIndex={1}\n      />\n      <Avatar.Fallback asChild>\n        <div\n          className={cn(\n            \"center size-full max-w-full\",\n\n            !blurhash && \"bg-theme-inactive/50\",\n            className,\n          )}\n          style={{\n            aspectRatio: height && width ? height / width : undefined,\n            width,\n          }}\n        >\n          {blurhash && (\n            <Blurhash hash={blurhash} resolutionX={32} resolutionY={32} className=\"!size-full\" />\n          )}\n        </div>\n      </Avatar.Fallback>\n    </Avatar.Root>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/components/ui/user-avatar.tsx",
    "content": "import { useWhoami } from \"@client/atoms/user\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { UserRole } from \"@follow/constants\"\nimport { getBackgroundGradient } from \"@follow/utils/color\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useMemo } from \"react\"\nimport * as React from \"react\"\n\nexport const UserAvatar = ({ className }: { className?: string }) => {\n  let user = useWhoami()\n\n  const colors = useMemo(\n    () => getBackgroundGradient(user?.name || user?.image || \"\"),\n    [user?.name, user?.image],\n  )\n  const colorfulStyle: React.CSSProperties = useMemo(() => {\n    const [, , , bgAccent, bgAccentLight, bgAccentUltraLight] = colors\n    return {\n      // Create a bottom-left to top-right avatar fallback background gradient\n      backgroundImage: `linear-gradient(to top right, ${bgAccent} 20%, ${bgAccentLight} 90%, ${bgAccentUltraLight} 150%)`,\n    }\n  }, [colors])\n\n  if (!user) {\n    if (import.meta.env.DEV) {\n      user = {\n        id: \"1\",\n        name: \"Innei\",\n        image: \"https://avatars-githubusercontent-webp.webp.se/u/41265413?v=4\",\n        handle: \"innei\",\n        role: UserRole.Free,\n        isAnonymous: false,\n        suspended: false,\n        stripeCustomerId: \"\",\n        roleEndAt: new Date().toISOString(),\n        bio: \"\",\n        website: \"\",\n        socialLinks: {} as any,\n        email: \"\",\n        emailVerified: false,\n        createdAt: new Date().toISOString(),\n        updatedAt: new Date().toISOString(),\n        twoFactorEnabled: false,\n        deleted: false,\n        inactive: false,\n        lastLoginMethod: \"github\",\n        appleAppAccountToken: null,\n      }\n    } else {\n      return null\n    }\n  }\n  const { name, image } = user!\n\n  return (\n    <div\n      className={cn(\n        \"flex h-20 items-center justify-center gap-8 px-10 py-4 text-2xl font-medium text-text-secondary\",\n        className,\n      )}\n    >\n      <Avatar className=\"border\">\n        <AvatarImage\n          className=\"aspect-square size-full duration-200 animate-in fade-in-0\"\n          src={image!}\n        />\n        <AvatarFallback style={colorfulStyle} className=\"text-sm\">\n          {name?.slice(0, 2)}\n        </AvatarFallback>\n      </Avatar>\n\n      <div className=\"truncate text-text\">{name}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/configs.ts",
    "content": "export const siteConfig = {\n  repoUrl: \"https://github.com/RSSNext/Follow\",\n  appUrl: \"https://app.folo.is\",\n}\n"
  },
  {
    "path": "apps/ssr/client/global.d.ts",
    "content": "// data hydrate\n\n// e.g. window.__HYDRATE__['feeds.$get,query:id=41223694984583197']\ndeclare global {\n  interface Window {\n    __HYDRATE__: Record<string, any>\n  }\n\n  export const GIT_COMMIT_SHA: string\n  export const APP_VERSION: string\n  export const APP_NAME: string\n}\n\nexport {}\n"
  },
  {
    "path": "apps/ssr/client/hooks/useRecaptchaToken.ts",
    "content": "import { useCallback } from \"react\"\nimport { useGoogleReCaptcha } from \"react-google-recaptcha-v3\"\n\nexport const useRecaptchaToken = () => {\n  const { executeRecaptcha } = useGoogleReCaptcha()\n\n  return useCallback(\n    async (action: string) => {\n      if (!executeRecaptcha) {\n        return null\n      }\n\n      try {\n        return await executeRecaptcha(action)\n      } catch (error) {\n        console.error(\"Failed to execute reCAPTCHA\", error)\n        return null\n      }\n    },\n    [executeRecaptcha],\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/i18n.ts",
    "content": "import { Chain } from \"@follow/utils/chain\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { jotaiStore } from \"@follow/utils/jotai\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport i18next from \"i18next\"\nimport { atom } from \"jotai\"\nimport { initReactI18next } from \"react-i18next\"\n\nimport { defaultNS, ns } from \"./@types/constants\"\nimport { defaultResources } from \"./@types/default-resource\"\nimport { getGeneralSettings } from \"./atoms/settings/general\"\n\nexport const i18nAtom = atom(i18next)\n\nexport const langChain = new Chain()\n\nexport class LocaleCache {\n  static shared = new LocaleCache()\n  private getKey(lang: string) {\n    return getStorageNS(`locale-${lang}`)\n  }\n  get(lang: string) {\n    const key = this.getKey(lang)\n    const cache = localStorage.getItem(key)\n    if (!cache) return null\n    return JSON.parse(cache)\n  }\n  set(lang: string) {\n    const key = this.getKey(lang)\n    const mergedResources = {} as any\n    for (const nsKey of ns) {\n      const nsResources = i18next.getResourceBundle(lang, nsKey)\n      mergedResources[nsKey] = nsResources\n    }\n    localStorage.setItem(key, JSON.stringify(mergedResources))\n  }\n}\n\nexport const fallbackLanguage = \"en\"\nexport const initI18n = async () => {\n  const i18next = jotaiStore.get(i18nAtom)\n\n  const lang = getGeneralSettings().language!\n\n  const mergedResources = {\n    ...defaultResources,\n  }\n\n  let cache = null as any\n  if (!import.meta.env.DEV) {\n    cache = LocaleCache.shared.get(lang)\n    if (cache) {\n      mergedResources[lang as keyof typeof mergedResources] = cache\n    }\n  }\n\n  await i18next.use(initReactI18next).init({\n    ns,\n    lng: cache ? lang : fallbackLanguage,\n    fallbackLng: {\n      default: [fallbackLanguage],\n      \"zh-TW\": [\"zh-CN\", fallbackLanguage],\n    },\n    defaultNS,\n    debug: import.meta.env.DEV,\n\n    resources: mergedResources,\n  })\n}\n\nif (import.meta.hot) {\n  import.meta.hot.on(\n    \"i18n-update\",\n    async ({ file, content }: { file: string; content: string }) => {\n      const resources = JSON.parse(content)\n      const i18next = jotaiStore.get(i18nAtom)\n\n      const nsName = file.match(/locales\\/(.+?)\\//)?.[1]\n\n      if (!nsName) return\n      const lang = file.split(\"/\").pop()?.replace(\".json\", \"\")\n      if (!lang) return\n      i18next.addResourceBundle(lang, nsName, resources, true, true)\n\n      console.info(\"reload\", lang, nsName)\n      await i18next.reloadResources(lang, nsName)\n\n      import.meta.env.DEV && EventBus.dispatch(\"I18N_UPDATE\", \"\")\n    },\n  )\n}\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface CustomEvent {\n    I18N_UPDATE: string\n  }\n}\n"
  },
  {
    "path": "apps/ssr/client/index.tsx",
    "content": "import \"@follow/components/tailwind\"\nimport \"@follow/components/dayjs\"\nimport \"./styles/index.css\"\n\nimport * as React from \"react\"\nimport ReactDOM from \"react-dom/client\"\nimport { RouterProvider } from \"react-router/dom\"\n\nimport { initialize } from \"./initialize\"\nimport { router } from \"./router\"\n\nconst $container = document.querySelector(\"#root\") as HTMLElement\ninitialize().finally(() => {\n  ReactDOM.createRoot($container).render(\n    <React.StrictMode>\n      <RouterProvider router={router} />\n    </React.StrictMode>,\n  )\n})\n"
  },
  {
    "path": "apps/ssr/client/initialize/helper.ts",
    "content": "import { tracker } from \"@follow/tracker\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\n\nexport const setIntegrationIdentify = async (user: AuthUser) => {\n  tracker.identify(user)\n\n  await import(\"@sentry/react\").then(({ setTag }) => {\n    setTag(\"user_id\", user.id)\n    setTag(\"user_name\", user.name)\n  })\n}\n"
  },
  {
    "path": "apps/ssr/client/initialize/index.ts",
    "content": "import { initI18n } from \"@client/i18n\"\nimport { initializeDayjs } from \"@follow/components/dayjs\"\n\nimport { initSentry } from \"./sentry\"\n\nexport const initialize = async () => {\n  initializeDayjs()\n  await Promise.all([initI18n(), initSentry()])\n}\n"
  },
  {
    "path": "apps/ssr/client/initialize/sentry.ts",
    "content": "import { env } from \"@follow/shared/env.ssr\"\nimport type { BrowserOptions } from \"@sentry/react\"\nimport { FetchError } from \"ofetch\"\n\nexport const initSentry = async () => {\n  if (!window.SENTRY_RELEASE) return\n  if (import.meta.env.DEV) return\n  const Sentry = await import(\"@sentry/react\")\n  Sentry.init({\n    dsn: env.VITE_SENTRY_DSN,\n\n    integrations: [\n      Sentry.httpClientIntegration(),\n      Sentry.captureConsoleIntegration({\n        levels: [\"error\"],\n      }),\n    ],\n    ...SentryConfig,\n  })\n\n  Sentry.setTag(\"app_version\", APP_VERSION)\n  Sentry.setTag(\"build\", \"external-pages\")\n}\n\nconst ERROR_PATTERNS = [\n  /Network Error/i,\n  /Fetch Error/i,\n  /XHR Error/i,\n  /adsbygoogle/i,\n  /Failed to fetch/i,\n  \"fetch failed\",\n  \"Unable to open cursor\",\n  \"Document is not focused.\",\n  \"HTTP Client Error\",\n  // Biz errors\n  \"Chain aborted\",\n]\nconst SentryConfig: BrowserOptions = {\n  // Performance Monitoring\n  tracesSampleRate: 1, //  Capture 100% of the transactions\n  // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled\n  tracePropagationTargets: [\"localhost\", env.VITE_API_URL],\n  // Session Replay\n  replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.\n  replaysOnErrorSampleRate: 1,\n\n  beforeSend(event, hint) {\n    const error = hint.originalException\n\n    if (error instanceof Error) {\n      const isIgnoredError = ERROR_PATTERNS.some((pattern) =>\n        pattern instanceof RegExp ? pattern.test(error.message) : error.message.includes(pattern),\n      )\n\n      if (isIgnoredError) {\n        return null\n      }\n    }\n\n    const isPassthroughError = [FetchError].some((errorType) => {\n      if (error instanceof errorType) {\n        return true\n      }\n      return false\n    })\n    const isAbortError = error instanceof Error && error.name === \"AbortError\"\n\n    if (isPassthroughError || isAbortError) {\n      return null\n    }\n    return event\n  },\n}\n"
  },
  {
    "path": "apps/ssr/client/lib/api-fetch.ts",
    "content": "import \"client-only\"\n\nimport { env } from \"@follow/shared/env.ssr\"\nimport { createSSRAPIHeaders } from \"@follow/utils/headers\"\nimport { FollowClient } from \"@follow-app/client-sdk\"\n\nimport PKG from \"../../../desktop/package.json\"\n\nexport const followClient = new FollowClient({\n  credentials: \"include\",\n  timeout: 30000,\n  baseURL: env.VITE_API_URL,\n  fetch: async (input: any, options = {}) =>\n    fetch(input.toString(), {\n      ...options,\n      cache: \"no-store\",\n    }),\n})\n\nfollowClient.addRequestInterceptor(async (ctx) => {\n  const { options } = ctx\n  const header = new Headers(options.headers)\n\n  const headers = createSSRAPIHeaders({ version: PKG.version })\n\n  Object.entries(headers).forEach(([key, value]) => {\n    header.set(key, value)\n  })\n\n  options.headers = Object.fromEntries(header.entries())\n  return ctx\n})\n"
  },
  {
    "path": "apps/ssr/client/lib/auth.ts",
    "content": "import { Auth } from \"@follow/shared/auth\"\nimport { env } from \"@follow/shared/env.ssr\"\nimport { createDesktopAPIHeaders } from \"@follow/utils/headers\"\n\nimport PKG from \"../../../desktop/package.json\"\n\nconst headers = createDesktopAPIHeaders({ version: PKG.version })\n\nconst auth = new Auth({\n  apiURL: env.VITE_API_URL,\n  webURL: env.VITE_WEB_URL,\n  fetchOptions: {\n    headers,\n    cache: \"no-store\",\n  },\n})\n\n// @keep-sorted\nexport const {\n  changeEmail,\n  changePassword,\n  getAccountInfo,\n  getLastUsedLoginMethod,\n  getProviders,\n  getSession,\n  linkSocial,\n  listAccounts,\n  oneTimeToken,\n  resetPassword,\n  sendVerificationEmail,\n  signIn,\n  signOut,\n  signUp,\n  twoFactor,\n  unlinkAccount,\n  updateUser,\n} = auth.authClient\n\nexport const forgetPassword = auth.authClient.requestPasswordReset\n\nexport const { loginHandler } = auth\n"
  },
  {
    "path": "apps/ssr/client/lib/helper.ts",
    "content": "import { DEEPLINK_SCHEME } from \"@follow/shared/constants\"\nimport type { FollowClient } from \"@follow-app/client-sdk\"\n\nimport type { defineMetadata } from \"../../src/meta-handler\"\n\ntype Target = {\n  id: string\n  title?: Nullable<string>\n  [key: string]: any\n}\nexport const getPreferredTitle = (target?: Target | null) => {\n  if (!target?.id) {\n    return target?.title\n  }\n\n  return target.title\n}\n\nexport const getHydrateData = (key: string) => {\n  return window.__HYDRATE__?.[key]\n}\n\n// type ExtractHydrateData<T> = T extends readonly (infer Item)[]\n//   ? Item extends { readonly type: \"hydrate\"; readonly data: infer D }\n//     ? D\n//     : never\n//   : never\n\n// export type GetHydrateData<T> = T extends (...args: any[]) => Promise<infer R>\n//   ? ExtractHydrateData<R>\n//   : T extends Promise<infer R>\n//     ? ExtractHydrateData<R>\n//     : ExtractHydrateData<T>\n\ntype ExtractHydrateData<T> = T extends readonly (infer Item)[]\n  ? Item extends { readonly type: \"hydrate\"; readonly data: infer D }\n    ? D\n    : never\n  : never\n\ntype UnwrapMetadataFn<T> =\n  T extends <P extends Record<string, string>>(args: {\n    params: P\n    apiClient: FollowClient\n    origin: string\n    throwError: (status: number, message: any) => never\n  }) => Promise<infer R> | infer R\n    ? R\n    : never\n\nexport type GetHydrateData<T> = T extends (...args: any[]) => Promise<infer R>\n  ? ExtractHydrateData<R>\n  : T extends (...args: any[]) => infer R\n    ? ExtractHydrateData<R>\n    : T extends Promise<infer R>\n      ? ExtractHydrateData<R>\n      : T extends typeof defineMetadata\n        ? ExtractHydrateData<UnwrapMetadataFn<Parameters<T>[0]>>\n        : ExtractHydrateData<T>\n\nexport const openInFollowApp = ({\n  deeplink,\n  fallback,\n  fallbackUrl,\n}: {\n  deeplink: string\n  fallback?: () => void\n  fallbackUrl?: string\n}): Promise<boolean> => {\n  return new Promise((resolve) => {\n    const timeout = 500\n    let isAppOpened = false\n\n    const handleBlur = () => {\n      isAppOpened = true\n      cleanup()\n      resolve(true)\n    }\n\n    const cleanup = () => {\n      window.removeEventListener(\"blur\", handleBlur)\n    }\n\n    window.addEventListener(\"blur\", handleBlur)\n\n    const deeplinkUrl = `${DEEPLINK_SCHEME}${deeplink}`\n    console.info(\"Open deeplink:\", deeplinkUrl)\n    window.location.href = deeplinkUrl\n\n    setTimeout(() => {\n      cleanup()\n      if (!isAppOpened) {\n        fallback?.()\n        if (fallbackUrl) {\n          window.location.href = fallbackUrl\n        }\n        resolve(false)\n        return\n      }\n    }, timeout)\n  })\n}\n"
  },
  {
    "path": "apps/ssr/client/lib/query-client.ts",
    "content": "import { QueryClient } from \"@tanstack/react-query\"\nimport { FetchError } from \"ofetch\"\n\nconst DO_NOT_RETRY_CODES = new Set([400, 401, 403, 404, 422])\nexport const queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      refetchOnWindowFocus: false,\n      retryDelay: 1000,\n      retry(failureCount, error) {\n        console.error(error)\n        if (\n          error instanceof FetchError &&\n          (error.statusCode === undefined || DO_NOT_RETRY_CODES.has(error.statusCode))\n        ) {\n          return false\n        }\n\n        return !!(3 - failureCount)\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "apps/ssr/client/lib/store.ts",
    "content": "export { jotaiStore } from \"@follow/utils/jotai\"\n"
  },
  {
    "path": "apps/ssr/client/lib/url-builder.ts",
    "content": "import { env } from \"@follow/shared/env.ssr\"\nimport { UrlBuilder as UrlBuilderClass } from \"@follow/utils/url-builder\"\n\nexport const UrlBuilder = new UrlBuilderClass(env.VITE_WEB_URL)\n"
  },
  {
    "path": "apps/ssr/client/modules/login/index.tsx",
    "content": "import { UserAvatar } from \"@client/components/ui/user-avatar\"\nimport { useRecaptchaToken } from \"@client/hooks/useRecaptchaToken\"\nimport {\n  getLastUsedLoginMethod,\n  loginHandler,\n  oneTimeToken,\n  signIn,\n  signOut,\n  twoFactor,\n} from \"@client/lib/auth\"\nimport { openInFollowApp } from \"@client/lib/helper\"\nimport { queryClient } from \"@client/lib/query-client\"\nimport { useSession } from \"@client/query/auth\"\nimport { useAuthProviders } from \"@client/query/users\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button, MotionButtonBase } from \"@follow/components/ui/button/index.js\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { DEEPLINK_SCHEME } from \"@follow/shared/constants\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport * as React from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { Link, useLocation, useNavigate } from \"react-router\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nconst parseCliCallbackUrl = (search: string): URL | null => {\n  const params = new URLSearchParams(search)\n  const rawCliCallback = params.get(\"cli_callback\")\n  if (!rawCliCallback) {\n    return null\n  }\n\n  try {\n    const url = new URL(rawCliCallback)\n    const isAllowedProtocol = url.protocol === \"http:\"\n    const isAllowedHost = url.hostname === \"127.0.0.1\" || url.hostname === \"localhost\"\n\n    if (!isAllowedProtocol || !isAllowedHost) {\n      return null\n    }\n\n    return url\n  } catch {\n    return null\n  }\n}\n\nconst parseTokenFromDeepLinkPath = (path: string): string | null => {\n  try {\n    const url = new URL(path, window.location.origin)\n    return url.searchParams.get(\"token\")\n  } catch {\n    return null\n  }\n}\n\nexport function Login() {\n  const { status, refetch } = useSession()\n\n  const [redirecting, setRedirecting] = useState(false)\n\n  const { data: authProviders, isLoading } = useAuthProviders()\n\n  const location = useLocation()\n  const urlParams = new URLSearchParams(location.search)\n  const provider = urlParams.get(\"provider\")\n  const isCredentialProvider = provider === \"credential\"\n  const cliCallbackUrl = useMemo(() => parseCliCallbackUrl(location.search), [location.search])\n\n  const isAuthenticated = status === \"authenticated\"\n\n  const { t } = useTranslation()\n\n  const startSocialLogin = useCallback(\n    (providerName: string) => {\n      if (cliCallbackUrl) {\n        void signIn.social({\n          provider: providerName as \"google\" | \"github\" | \"apple\",\n          callbackURL: window.location.href,\n        })\n        return\n      }\n\n      loginHandler(providerName, \"app\")\n    },\n    [cliCallbackUrl],\n  )\n\n  useEffect(() => {\n    if (provider && !isCredentialProvider && status === \"unauthenticated\") {\n      startSocialLogin(provider)\n      setRedirecting(true)\n    }\n  }, [isCredentialProvider, provider, startSocialLogin, status])\n\n  const getCallbackUrl = useCallback(async () => {\n    const { data } = await oneTimeToken.generate()\n    if (!data) return null\n    return {\n      url: `auth?token=${data.token}`,\n    }\n  }, [])\n\n  const [openFailed, setOpenFailed] = useState(false)\n  const [callbackUrl, setCallbackUrl] = useState<string>()\n  const callbackUrlWithScheme = callbackUrl ? `${DEEPLINK_SCHEME}${callbackUrl}` : undefined\n\n  const [lastMethod, setLastMethod] = useState<string | null>(null)\n  useEffect(() => {\n    let lastMethodValue = getLastUsedLoginMethod()\n    if (lastMethodValue === \"email\") {\n      lastMethodValue = \"credential\"\n    }\n    if (lastMethodValue) {\n      setLastMethod(lastMethodValue)\n    }\n  }, [lastMethod])\n\n  const handleOpenApp = useCallback(async () => {\n    const callbackUrl = await getCallbackUrl()\n    if (!callbackUrl) return\n    setCallbackUrl(callbackUrl.url)\n    openInFollowApp({\n      deeplink: callbackUrl.url,\n      fallback: () => {\n        setOpenFailed(true)\n      },\n    })\n  }, [getCallbackUrl])\n\n  const handleCliCallback = useCallback(async () => {\n    if (!cliCallbackUrl) {\n      return\n    }\n\n    const callbackUrl = await getCallbackUrl()\n    if (!callbackUrl) {\n      return\n    }\n\n    const token = parseTokenFromDeepLinkPath(callbackUrl.url)\n    if (!token) {\n      return\n    }\n\n    const redirectUrl = new URL(cliCallbackUrl.toString())\n    redirectUrl.searchParams.set(\"token\", token)\n    window.location.replace(redirectUrl.toString())\n  }, [cliCallbackUrl, getCallbackUrl])\n\n  const onceRef = useRef(false)\n  useEffect(() => {\n    if (!isAuthenticated || onceRef.current) {\n      return\n    }\n\n    onceRef.current = true\n    if (cliCallbackUrl) {\n      void handleCliCallback()\n      return\n    }\n\n    void handleOpenApp()\n  }, [cliCallbackUrl, handleCliCallback, handleOpenApp, isAuthenticated])\n\n  const navigate = useNavigate()\n\n  const [isEmail, setIsEmail] = useState(false)\n  const isDark = useIsDark()\n\n  const LoginOrStatusContent = useMemo(() => {\n    switch (true) {\n      case isAuthenticated: {\n        return (\n          <div className=\"mt-4 flex w-full flex-col items-center justify-center px-4\">\n            <div className=\"relative flex items-center justify-center gap-10\">\n              <UserAvatar className=\"gap-4 px-10 py-4 text-2xl\" />\n              <div className=\"absolute right-0\">\n                <Button\n                  variant=\"ghost\"\n                  onClick={async () => {\n                    await signOut()\n                    await refetch()\n                  }}\n                >\n                  <i className=\"i-mingcute-exit-line text-xl\" />\n                </Button>\n              </div>\n            </div>\n            <p className=\"mt-4 text-center\">\n              {t(\"redirect.successMessage\", { app_name: APP_NAME })}\n            </p>\n            <p className=\"mt-2 text-center text-sm text-text-secondary\">\n              {t(\"redirect.instruction\", { app_name: APP_NAME })}\n            </p>\n            <div className=\"center mt-8 flex flex-col gap-4 sm:flex-row\">\n              <Button\n                variant=\"primary\"\n                buttonClassName=\"h-12 !rounded-full px-10 text-lg\"\n                onClick={handleOpenApp}\n              >\n                {t(\"redirect.openApp\", { app_name: APP_NAME })}\n              </Button>\n            </div>\n            {openFailed && callbackUrlWithScheme && (\n              <div className=\"mt-8 w-[31rem] space-y-2 text-center text-sm text-text\">\n                <p className=\"text-base\">\n                  <Trans\n                    t={t}\n                    i18nKey=\"login.no_client\"\n                    components={{\n                      weblink: <a href=\"/\" className=\"text-accent\" />,\n                    }}\n                  />\n                </p>\n                <p>{t(\"login.enter_token\")}</p>\n                <p className=\"flex items-center justify-center gap-4 rounded-lg bg-fill-tertiary p-3\">\n                  <span className=\"blur-sm hover:blur-none\">{callbackUrlWithScheme}</span>\n                  <i\n                    className=\"i-mgc-copy-2-cute-re size-4 cursor-pointer\"\n                    onClick={() => {\n                      navigator.clipboard.writeText(callbackUrlWithScheme)\n                    }}\n                  />\n                </p>\n              </div>\n            )}\n          </div>\n        )\n      }\n      default: {\n        return (\n          <>\n            {isEmail ? (\n              <LoginWithPassword />\n            ) : (\n              <div className=\"mb-3 flex flex-col items-center justify-center gap-4\">\n                {Object.entries(authProviders || []).map(([key, provider]) => (\n                  <MotionButtonBase\n                    key={key}\n                    onClick={() => {\n                      if (key === \"credential\") {\n                        setIsEmail(true)\n                      } else {\n                        startSocialLogin(key)\n                      }\n                    }}\n                    className=\"center relative w-full gap-2 rounded-xl border py-3 pl-5 font-semibold duration-200 hover:bg-material-medium\"\n                  >\n                    <img\n                      className={cn(\n                        \"absolute left-9 h-5\",\n                        !provider.iconDark64 &&\n                          \"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert\",\n                      )}\n                      src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}\n                    />\n                    <span>{t(\"login.continueWith\", { provider: provider.name })}</span>\n                    {lastMethod === key && (\n                      <div className=\"absolute -right-2 -top-2 rounded-xl bg-accent px-2 py-0.5 text-sm text-white\">\n                        {t(\"login.lastUsed\")}\n                      </div>\n                    )}\n                  </MotionButtonBase>\n                ))}\n              </div>\n            )}\n            <Divider />\n            {isEmail ? (\n              <div className=\"cursor-pointer pb-2 text-center\" onClick={() => setIsEmail(false)}>\n                Back\n              </div>\n            ) : (\n              <div\n                className=\"cursor-pointer pb-2 text-center\"\n                onClick={() => {\n                  navigate(\"/register\")\n                }}\n              >\n                <Trans\n                  t={t}\n                  i18nKey=\"login.no_account\"\n                  components={{\n                    strong: <span className=\"text-accent\" />,\n                  }}\n                />\n              </div>\n            )}\n          </>\n        )\n      }\n    }\n  }, [\n    authProviders,\n    handleOpenApp,\n    startSocialLogin,\n    isAuthenticated,\n    refetch,\n    t,\n    isEmail,\n    navigate,\n    openFailed,\n    callbackUrl,\n    isDark,\n    lastMethod,\n  ])\n  const Content = useMemo(() => {\n    switch (true) {\n      case redirecting: {\n        return <div className=\"center\">{t(\"login.redirecting\")}</div>\n      }\n      default: {\n        return <div className=\"flex min-w-80 flex-col gap-3\">{LoginOrStatusContent}</div>\n      }\n    }\n  }, [LoginOrStatusContent, redirecting, t])\n\n  return (\n    <div className=\"flex w-full flex-col items-center justify-center\">\n      <Logo className=\"size-16\" />\n\n      {!isAuthenticated && !isLoading && (\n        <h1 className=\"my-8 text-3xl\">\n          {t(\"login.logInTo\")} <b>{` ${APP_NAME}`}</b>\n        </h1>\n      )}\n      {Content}\n      {isLoading && <LoadingCircle className=\"mt-8\" size=\"large\" />}\n    </div>\n  )\n}\n\nconst formSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(8).max(128),\n  code: z.string().length(6).regex(/^\\d+$/).optional(),\n})\n\nfunction LoginWithPassword() {\n  const { t } = useTranslation()\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      email: \"\",\n      password: \"\",\n    },\n  })\n  const [needTwoFactor, setNeedTwoFactor] = useState(false)\n  const [isButtonLoading, setIsButtonLoading] = useState(false)\n\n  const requestRecaptchaToken = useRecaptchaToken()\n\n  async function onSubmit(values: z.infer<typeof formSchema>) {\n    setIsButtonLoading(true)\n    try {\n      if (needTwoFactor && values.code) {\n        const res = await twoFactor.verifyTotp({ code: values.code })\n        if (res?.error) {\n          toast.error(res.error.message)\n          setIsButtonLoading(false)\n        } else {\n          queryClient.invalidateQueries({ queryKey: [\"auth\", \"session\"] })\n        }\n        return\n      }\n\n      const recaptchaToken = await requestRecaptchaToken(\"ssr_login\")\n      const res = await loginHandler(\"credential\", \"app\", {\n        ...values,\n        headers: recaptchaToken\n          ? {\n              \"x-token\": `r3:${recaptchaToken}`,\n            }\n          : undefined,\n      })\n\n      if (res?.error) {\n        toast.error(res.error.message)\n        setIsButtonLoading(false)\n        return\n      }\n\n      if ((res?.data as any)?.twoFactorRedirect) {\n        setNeedTwoFactor(true)\n        form.setValue(\"code\", \"\")\n        setTimeout(() => form.setFocus(\"code\"), 0)\n        setIsButtonLoading(false)\n        return\n      } else {\n        queryClient.invalidateQueries({ queryKey: [\"auth\", \"session\"] })\n      }\n    } catch (error) {\n      console.error(\"Login error:\", error)\n      toast.error(t(\"login.errors.unknown\"))\n      setIsButtonLoading(false)\n    }\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-1\">\n        <FormField\n          control={form.control}\n          name=\"email\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t(\"login.email\")}</FormLabel>\n              <FormControl>\n                <Input type=\"email\" {...field} disabled={isButtonLoading || needTwoFactor} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"password\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel className=\"flex items-center justify-between\">\n                {t(\"login.password\")}\n                <Link\n                  to=\"/forget-password\"\n                  className=\"block py-1 text-xs text-accent hover:underline\"\n                >\n                  {t(\"login.forget_password.note\")}\n                </Link>\n              </FormLabel>\n              <FormControl>\n                <Input type=\"password\" {...field} disabled={isButtonLoading || needTwoFactor} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        {needTwoFactor && (\n          <FormField\n            control={form.control}\n            name=\"code\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t(\"login.two_factor.code\")}</FormLabel>\n                <FormControl>\n                  <Input type=\"text\" {...field} disabled={isButtonLoading} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        )}\n        <Button\n          type=\"submit\"\n          buttonClassName=\"!mt-3 w-full\"\n          isLoading={isButtonLoading}\n          size=\"lg\"\n          disabled={isButtonLoading}\n        >\n          {needTwoFactor\n            ? t(\"login.two_factor.verify\")\n            : t(\"login.continueWith\", { provider: t(\"words.email\") })}\n        </Button>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(login)/forget-password.tsx",
    "content": "import { useRecaptchaToken } from \"@client/hooks/useRecaptchaToken\"\nimport { forgetPassword } from \"@client/lib/auth\"\nimport { Button } from \"@follow/components/ui/button/index.jsx\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@follow/components/ui/card/index.jsx\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { env } from \"@follow/shared/env.ssr\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport * as React from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nconst createEmailSchema = (t: any) =>\n  z.object({\n    email: z\n      .string()\n      .min(1, t(\"login.forget_password.email_required\"))\n      .email(t(\"login.forget_password.email_invalid\")),\n  })\n\nexport function Component() {\n  const { t } = useTranslation()\n\n  const requestRecaptchaToken = useRecaptchaToken()\n\n  const EmailSchema = createEmailSchema(t)\n\n  const form = useForm<z.infer<typeof EmailSchema>>({\n    resolver: zodResolver(EmailSchema),\n    defaultValues: {\n      email: \"\",\n    },\n    mode: \"onChange\",\n    delayError: 500,\n  })\n\n  const { isValid } = form.formState\n  const updateMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof EmailSchema>) => {\n      const recaptchaToken = await requestRecaptchaToken(\"ssr_forget_password\")\n      const res = await forgetPassword(\n        {\n          email: values.email,\n          redirectTo: `${env.VITE_WEB_URL}/reset-password`,\n        },\n        {\n          headers: recaptchaToken\n            ? {\n                \"x-token\": `r3:${recaptchaToken}`,\n              }\n            : undefined,\n        },\n      )\n      if (res.error) {\n        throw new Error(res.error.message)\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n    onSuccess: () => {\n      toast.success(t(\"login.forget_password.success\"))\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof EmailSchema>) {\n    updateMutation.mutate(values)\n  }\n\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <Card className=\"w-[500px] max-w-full\">\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            <span>{t(\"login.forget_password.label\")}</span>\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          <CardDescription className=\"mb-4\">\n            {t(\"login.forget_password.description\")}\n          </CardDescription>\n          <Form {...form}>\n            <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n              <FormField\n                control={form.control}\n                name=\"email\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t(\"login.email\")}</FormLabel>\n                    <FormControl>\n                      <Input type=\"email\" {...field} />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n              <div className=\"text-right\">\n                <Button\n                  disabled={!isValid || updateMutation.isPending}\n                  type=\"submit\"\n                  isLoading={updateMutation.isPending}\n                >\n                  {t(\"login.submit\")}\n                </Button>\n              </div>\n            </form>\n          </Form>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(login)/layout.tsx",
    "content": "import { setWhoami } from \"@client/atoms/user\"\nimport { setIntegrationIdentify } from \"@client/initialize/helper\"\nimport { useSession } from \"@client/query/auth\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\nimport { useEffect } from \"react\"\nimport * as React from \"react\"\nimport { Outlet } from \"react-router\"\n\nexport function Component() {\n  return (\n    <>\n      <UserProvider />\n      <Outlet />\n    </>\n  )\n}\n\nconst UserProvider = () => {\n  const { session } = useSession()\n\n  useEffect(() => {\n    if (!session?.user) return\n\n    setWhoami(session.user as unknown as AuthUser)\n\n    setIntegrationIdentify(session.user as unknown as AuthUser)\n  }, [session?.user])\n\n  return null\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(login)/login/index.tsx",
    "content": "import { Login } from \"@client/modules/login\"\nimport * as React from \"react\"\n\nexport function Component() {\n  return <Login />\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(login)/login/metadata.ts",
    "content": "import type { GetHydrateData } from \"@client/lib/helper\"\nimport type { AuthProvider } from \"@client/query/users\"\n\nimport { createApiFetch } from \"../../../../src/lib/api-client\"\nimport { defineMetadata } from \"../../../../src/meta-handler\"\n\nconst getTypedProviders = async () => {\n  const apiFetch = createApiFetch()\n  const data = (await apiFetch(\"/better-auth/get-providers\")) as Record<string, AuthProvider>\n\n  return data\n}\n\nconst meta = defineMetadata(async () => {\n  const providers = await getTypedProviders()\n\n  return [\n    {\n      type: \"title\",\n      title: \"Login\",\n    },\n    {\n      type: \"hydrate\",\n      data: providers,\n      path: \"/login\",\n      key: `betterAuth`,\n    },\n  ] as const\n})\n\nexport type LoginHydrateData = GetHydrateData<typeof meta>\nexport default meta\n"
  },
  {
    "path": "apps/ssr/client/pages/(login)/register.tsx",
    "content": "import { useRecaptchaToken } from \"@client/hooks/useRecaptchaToken\"\nimport { loginHandler, signUp } from \"@client/lib/auth\"\nimport { useAuthProviders } from \"@client/query/users\"\nimport { Logo } from \"@follow/components/icons/logo.jsx\"\nimport { Button, MotionButtonBase } from \"@follow/components/ui/button/index.jsx\"\nimport { Divider } from \"@follow/components/ui/divider/index.js\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { useIsDark } from \"@follow/hooks\"\nimport { tracker } from \"@follow/tracker\"\nimport { cn } from \"@follow/utils/utils\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useState } from \"react\"\nimport * as React from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { Trans, useTranslation } from \"react-i18next\"\nimport { useNavigate } from \"react-router\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nexport function Component() {\n  return (\n    <div className=\"flex h-full flex-col items-center justify-center gap-8\">\n      <Logo className=\"size-16\" />\n      <RegisterForm />\n    </div>\n  )\n}\n\nconst formSchema = z\n  .object({\n    email: z.string().email(),\n    password: z.string().min(8).max(128),\n    confirmPassword: z.string(),\n  })\n  .refine((data) => data.password === data.confirmPassword, {\n    message: \"Passwords don't match\",\n    path: [\"confirmPassword\"],\n  })\n\nfunction RegisterForm() {\n  const { t } = useTranslation()\n  const [isSubmitting, setIsSubmitting] = useState(false)\n  const navigate = useNavigate()\n  const requestRecaptchaToken = useRecaptchaToken()\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      email: \"\",\n      password: \"\",\n      confirmPassword: \"\",\n    },\n  })\n  const [isEmail, setIsEmail] = useState(false)\n  const isDark = useIsDark()\n\n  const { data: authProviders } = useAuthProviders()\n\n  async function onSubmit(values: z.infer<typeof formSchema>) {\n    setIsSubmitting(true)\n\n    try {\n      const recaptchaToken = await requestRecaptchaToken(\"ssr_register\")\n      await signUp.email(\n        {\n          email: values.email,\n          password: values.password,\n          name: values.email.split(\"@\")[0]!,\n          callbackURL: \"/\",\n        },\n        {\n          onSuccess() {\n            tracker.register({\n              type: \"email\",\n            })\n            navigate(\"/login\")\n          },\n          onError(context) {\n            toast.error(context.error.message)\n          },\n          headers: recaptchaToken\n            ? {\n                \"x-token\": `r3:${recaptchaToken}`,\n              }\n            : undefined,\n        },\n      )\n    } finally {\n      setIsSubmitting(false)\n    }\n  }\n\n  return (\n    <div className=\"relative min-w-80\">\n      <h1 className=\"mb-8 text-center text-3xl\">\n        {t(\"login.signUpTo\")} <b>{` ${APP_NAME}`}</b>\n      </h1>\n      {isEmail ? (\n        <Form {...form}>\n          <form onSubmit={form.handleSubmit(onSubmit)} className=\"mt-6 space-y-4\">\n            <FormField\n              control={form.control}\n              name=\"email\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t(\"register.email\")}</FormLabel>\n                  <FormControl>\n                    <Input type=\"email\" {...field} disabled={isSubmitting} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <FormField\n              control={form.control}\n              name=\"password\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t(\"register.password\")}</FormLabel>\n                  <FormControl>\n                    <Input type=\"password\" {...field} disabled={isSubmitting} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <FormField\n              control={form.control}\n              name=\"confirmPassword\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t(\"register.confirm_password\")}</FormLabel>\n                  <FormControl>\n                    <Input type=\"password\" {...field} disabled={isSubmitting} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <Button\n              isLoading={isSubmitting}\n              disabled={isSubmitting}\n              type=\"submit\"\n              buttonClassName=\"w-full\"\n              size=\"lg\"\n            >\n              {t(\"register.submit\")}\n            </Button>\n          </form>\n        </Form>\n      ) : (\n        <div className=\"mb-3 flex flex-col items-center justify-center gap-4\">\n          {Object.entries(authProviders || []).map(([key, provider]) => (\n            <MotionButtonBase\n              key={key}\n              onClick={() => {\n                if (key === \"credential\") {\n                  setIsEmail(true)\n                } else {\n                  loginHandler(key, \"app\")\n                }\n              }}\n              className=\"center relative w-full gap-2 rounded-xl border p-2.5 pl-5 font-semibold duration-200 hover:bg-material-medium\"\n            >\n              <img\n                className={cn(\n                  \"absolute left-9 h-5\",\n                  !provider.iconDark64 && \"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert\",\n                )}\n                src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}\n              />\n              <span>{t(\"login.continueWith\", { provider: provider.name })}</span>\n            </MotionButtonBase>\n          ))}\n        </div>\n      )}\n      <Divider className=\"my-7\" />\n      {isEmail ? (\n        <div className=\"cursor-pointer pb-2 text-center\" onClick={() => setIsEmail(false)}>\n          Back\n        </div>\n      ) : (\n        <div\n          className=\"cursor-pointer pb-2 text-center\"\n          onClick={() => {\n            navigate(\"/login\")\n          }}\n        >\n          <Trans\n            t={t}\n            i18nKey=\"login.have_account\"\n            components={{\n              strong: <span className=\"text-accent\" />,\n            }}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(login)/reset-password.tsx",
    "content": "import { resetPassword } from \"@client/lib/auth\"\nimport { Button } from \"@follow/components/ui/button/index.jsx\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@follow/components/ui/card/index.jsx\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@follow/components/ui/form/index.jsx\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useMutation } from \"@tanstack/react-query\"\nimport * as React from \"react\"\nimport { useForm } from \"react-hook-form\"\nimport { useTranslation } from \"react-i18next\"\nimport { useNavigate } from \"react-router\"\nimport { toast } from \"sonner\"\nimport { z } from \"zod\"\n\nconst passwordSchema = z.string().min(8).max(128)\nconst initPasswordFormSchema = z\n  .object({\n    newPassword: passwordSchema,\n    confirmPassword: passwordSchema,\n  })\n  .refine((data) => data.newPassword === data.confirmPassword, {\n    message: \"Passwords don't match\",\n    path: [\"confirmPassword\"],\n  })\n\nexport function Component() {\n  const { t } = useTranslation()\n  const form = useForm<z.infer<typeof initPasswordFormSchema>>({\n    resolver: zodResolver(initPasswordFormSchema),\n    defaultValues: {\n      newPassword: \"\",\n      confirmPassword: \"\",\n    },\n  })\n\n  const { isValid } = form.formState\n\n  const navigate = useNavigate()\n  const updateMutation = useMutation({\n    mutationFn: async (values: z.infer<typeof initPasswordFormSchema>) => {\n      const token = new URLSearchParams(window.location.search).get(\"token\")\n      if (!token) {\n        throw new Error(\"Token not found\")\n      }\n\n      const res = await resetPassword({ newPassword: values.newPassword, token })\n      const error = res.error?.message\n      if (error) {\n        throw new Error(error)\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message)\n    },\n    onSuccess: () => {\n      toast.success(t(\"login.reset_password.success\"))\n      navigate(\"/login\")\n    },\n  })\n\n  function onSubmit(values: z.infer<typeof initPasswordFormSchema>) {\n    updateMutation.mutate(values)\n  }\n\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <Card className=\"w-[500px] max-w-full\">\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            <span>{t(\"login.forget_password.label\")}</span>\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          <CardDescription>{t(\"login.reset_password.description\")}</CardDescription>\n          <Form {...form}>\n            <form onSubmit={form.handleSubmit(onSubmit)} className=\"mt-4 space-y-4\">\n              <FormField\n                control={form.control}\n                name=\"newPassword\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t(\"login.new_password.label\")}</FormLabel>\n                    <FormControl>\n                      <Input type=\"password\" {...field} />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n              <FormField\n                control={form.control}\n                name=\"confirmPassword\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t(\"login.confirm_password.label\")}</FormLabel>\n                    <FormControl>\n                      <Input type=\"password\" {...field} />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <div className=\"text-right\">\n                <Button disabled={!isValid} type=\"submit\" isLoading={updateMutation.isPending}>\n                  {t(\"login.submit\")}\n                </Button>\n              </div>\n            </form>\n          </Form>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/index.tsx",
    "content": "import * as React from \"react\"\n\nexport const Component = () => {\n  return <div>(*‘ v`*) Hello, Folo</div>\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/layout.tsx",
    "content": "import { NotFound } from \"@client/components/common/404\"\nimport * as React from \"react\"\nimport { Outlet } from \"react-router\"\n\nexport const Component = () => {\n  if (document.documentElement.dataset.notFound === \"true\") {\n    return <NotFound />\n  }\n  return <Outlet />\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/share/feeds/[id]/index.tsx",
    "content": "import { Item } from \"@client/components/items\"\nimport { FeedCertification } from \"@client/components/ui/feed-certification\"\nimport { FeedIcon } from \"@client/components/ui/feed-icon\"\nimport { openInFollowApp } from \"@client/lib/helper\"\nimport { useFeed } from \"@client/query/feed\"\nimport { FollowIcon } from \"@follow/components/icons/follow.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.jsx\"\nimport { RelativeTime } from \"@follow/components/ui/datetime/index.jsx\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { useTitle } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { FeedSchema } from \"@follow-app/client-sdk\"\nimport { Fragment } from \"react\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useParams, useSearchParams } from \"react-router\"\n\nconst numberFormatter = new Intl.NumberFormat()\nexport function Component() {\n  const { id } = useParams()\n  const [search] = useSearchParams()\n\n  const { t } = useTranslation()\n\n  const feed = useFeed({\n    id: id!,\n  })\n  const view = Number.parseInt(search.get(\"view\") || feed.data?.analytics?.view?.toString() || \"0\")\n\n  const feedData = feed.data?.feed as FeedSchema\n  const analytics = feed.data?.analytics\n  const isSubscribed = !!feed.data?.subscription\n  const entries = feed.data?.entries.map((entry) => ({\n    ...entry,\n    id: entry.guid,\n    content: entry.description,\n    feedId: feed.data?.feed.id,\n    insertedAt: entry.publishedAt,\n  }))\n\n  useTitle(feed.data?.feed.title)\n\n  if (feed.isLoading || !feed.data?.feed || !feedData) {\n    return <LoadingCircle size=\"large\" className=\"center fixed inset-0\" />\n  }\n\n  return (\n    <Fragment>\n      {/* Hero Section */}\n      <div>\n        <div className=\"mx-auto max-w-4xl px-6 py-8 text-center sm:px-8 sm:py-12\">\n          {/* Feed Icon */}\n          <div className=\"mb-6\">\n            <div className=\"relative mx-auto inline-block\">\n              <FeedIcon\n                fallback\n                target={feedData}\n                className=\"mask-squircle mask border border-border\"\n                noMargin\n                size={80}\n              />\n            </div>\n          </div>\n\n          {/* Feed Info */}\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center justify-center gap-2\">\n              <h1 className=\"text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl\">\n                {feedData.title}\n              </h1>\n              <FeedCertification feed={feedData} />\n            </div>\n\n            <p className=\"break-all text-base text-zinc-500 dark:text-zinc-400\">{feedData.url}</p>\n\n            {feedData.description && (\n              <p className=\"mx-auto max-w-2xl text-balance text-zinc-600 dark:text-zinc-400\">\n                {feedData.description}\n              </p>\n            )}\n\n            {/* Stats */}\n            <div className=\"!mt-8 flex justify-center\">\n              <div className=\"flex items-center divide-x divide-material-ultra-thick\">\n                {!!analytics?.subscriptionCount && (\n                  <div className=\"px-4 text-center\">\n                    <div className=\"text-xl font-semibold text-zinc-900 dark:text-zinc-100\">\n                      {numberFormatter.format(analytics.subscriptionCount)}\n                    </div>\n                    <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                      {t(\"feed.follower\", { count: analytics.subscriptionCount })}\n                    </div>\n                  </div>\n                )}\n\n                {analytics?.updatesPerWeek ? (\n                  <div className=\"px-4 text-center\">\n                    <div className=\"text-xl font-semibold text-zinc-900 dark:text-zinc-100\">\n                      {analytics.updatesPerWeek}\n                    </div>\n                    <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                      {analytics.updatesPerWeek > 1 ? \"entries\" : \"entry\"}/week\n                    </div>\n                  </div>\n                ) : analytics?.latestEntryPublishedAt ? (\n                  <div className=\"px-4 text-center\">\n                    <div className=\"text-lg font-medium text-zinc-900 dark:text-zinc-100\">\n                      <RelativeTime\n                        date={analytics.latestEntryPublishedAt}\n                        displayAbsoluteTimeAfterDay={Infinity}\n                      />\n                    </div>\n                    <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                      {t(\"feed.updated_at\")}\n                    </div>\n                  </div>\n                ) : null}\n              </div>\n            </div>\n\n            {/* Follow Button */}\n            <div className=\"!mt-8\">\n              <Button\n                variant={isSubscribed ? \"outline\" : \"primary\"}\n                size=\"lg\"\n                onClick={() => {\n                  openInFollowApp({\n                    deeplink: `feed?id=${id}&view=${view}`,\n                    fallbackUrl: `/timeline/view-${view}/${id}/pending`,\n                  })\n                }}\n              >\n                <FollowIcon className=\"mr-2 size-4\" />\n                {isSubscribed\n                  ? t(\"feed.actions.followed\")\n                  : t(\"feed.actions.open\", { which: APP_NAME })}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Entries Section */}\n      <div className={cn(\"w-full pb-12 pt-8\", \"flex flex-col gap-2\")}>\n        <Item entries={entries} feed={feed.data} view={view} />\n      </div>\n    </Fragment>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/share/feeds/[id]/metadata.ts",
    "content": "import type { GetHydrateData } from \"@client/lib/helper\"\nimport { APPLE_APP_STORE_ID } from \"@follow/constants\"\n\nimport { callNotFound } from \"../../../../../../src/lib/not-found\"\nimport { defineMetadata } from \"../../../../../../src/meta-handler\"\n\nconst meta = defineMetadata(async ({ params, apiClient, origin }) => {\n  const feedId = params.id\n\n  const feed = await apiClient.api.feeds.get({ id: feedId }).catch(callNotFound)\n\n  const { title, description } = feed.data.feed\n\n  return [\n    {\n      type: \"openGraph\",\n      title: title || \"\",\n      description: description || \"\",\n      image: `${origin}/og/feed/${feedId}`,\n    },\n    {\n      type: \"title\",\n      title: title || \"\",\n    },\n    {\n      type: \"hydrate\",\n      data: feed.data,\n      path: `/feeds/${feedId}`,\n      key: `feeds.$get,query:id=${feedId}`,\n    },\n    {\n      type: \"meta\",\n      property: \"apple-itunes-app\",\n      content: `app-id=${APPLE_APP_STORE_ID}, app-argument=follow://add?id=${feedId}&type=feed`,\n    },\n  ] as const\n})\n\nexport type FeedHydrateData = GetHydrateData<typeof meta>\nexport default meta\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/share/lists/[id]/index.tsx",
    "content": "import { Item } from \"@client/components/items\"\nimport { FeedCertification } from \"@client/components/ui/feed-certification\"\nimport { FeedIcon } from \"@client/components/ui/feed-icon\"\nimport { openInFollowApp } from \"@client/lib/helper\"\nimport { useList } from \"@client/query/list\"\nimport { FollowIcon } from \"@follow/components/icons/follow.jsx\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.jsx\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { useTitle } from \"@follow/hooks\"\nimport { cn, formatNumber } from \"@follow/utils/utils\"\nimport type { FeedSchema } from \"@follow-app/client-sdk\"\nimport { Fragment, memo } from \"react\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { useParams } from \"react-router\"\n\nconst FeedRow = memo<{ feed: FeedSchema }>(({ feed }) => {\n  return (\n    <a\n      className=\"bg-card group relative flex cursor-pointer items-start justify-between rounded-lg border border-border/40 p-4 transition-all duration-200 hover:border-border hover:shadow-sm hover:shadow-black/5 dark:hover:shadow-white/5\"\n      href={`/share/feeds/${feed.id}`}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n    >\n      <div className=\"flex min-w-0 flex-1 items-start space-x-3\">\n        <div className=\"shrink-0\">\n          <FeedIcon fallback target={feed} className=\"mask-squircle mask\" size={40} />\n        </div>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-center space-x-2\">\n            <h3 className=\"truncate font-medium text-zinc-900 transition-colors group-hover:text-accent dark:text-zinc-100\">\n              {feed.title}\n            </h3>\n            <FeedCertification feed={feed} />\n          </div>\n          {feed.description && (\n            <p className=\"mt-1 line-clamp-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400\">\n              {feed.description}\n            </p>\n          )}\n        </div>\n      </div>\n      <div className=\"ml-3 shrink-0\">\n        <i className=\"i-mingcute-arrow-right-line text-zinc-400 transition-transform group-hover:translate-x-1\" />\n      </div>\n    </a>\n  )\n})\n\n// Backend limit\nconst SIZE = 5\nFeedRow.displayName = \"FeedRow\"\n\nexport function Component() {\n  const { id } = useParams()\n\n  const list = useList({\n    id: id!,\n  })\n  const listData = list.data?.list\n  const isSubscribed = !!list.data?.subscription\n\n  const { t } = useTranslation()\n\n  const feedMap =\n    list.data?.list.feeds?.reduce(\n      (acc, feed) => {\n        acc[feed.id] = feed\n        return acc\n      },\n      {} as Record<string, FeedSchema>,\n    ) || {}\n\n  useTitle(list.data?.list.title)\n\n  const handleOpenInFollowApp = () => {\n    openInFollowApp({\n      deeplink: `list?id=${id}&view=${list.data.list.view}`,\n      fallbackUrl: `/timeline/view-${list.data.list.view}/list-${id}/pending`,\n    })\n  }\n\n  if (list.isLoading) {\n    return <LoadingCircle size=\"large\" className=\"center fixed inset-0\" />\n  }\n\n  if (!list.data?.list) {\n    return null\n  }\n\n  return (\n    <Fragment>\n      {/* Hero Section */}\n      <div>\n        <div className=\"mx-auto max-w-4xl px-6 py-8 text-center sm:px-8 sm:py-12\">\n          {/* List Icon */}\n          <div className=\"mb-6\">\n            <div className=\"relative mx-auto inline-block\">\n              <FeedIcon\n                fallback\n                target={list.data.list}\n                className=\"mask-squircle mask border border-border\"\n                size={80}\n                noMargin\n              />\n            </div>\n          </div>\n\n          {/* List Info */}\n          <div className=\"space-y-4\">\n            <h1 className=\"text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl\">\n              {list.data.list.title}\n            </h1>\n\n            {/* Owner */}\n            <div className=\"flex justify-center\">\n              <a\n                href={`/share/users/${list.data.list.owner?.id}`}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"flex items-center space-x-2 text-zinc-500 transition-colors hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300\"\n              >\n                <span className=\"text-sm\">{t(\"feed.madeby\")}</span>\n                <Avatar className=\"size-6 border border-border/60\">\n                  <AvatarImage src={list.data.list.owner?.image || undefined} />\n                  <AvatarFallback className=\"text-xs\">\n                    {list.data.list.owner?.name?.slice(0, 2)}\n                  </AvatarFallback>\n                </Avatar>\n                <span className=\"text-sm font-medium\">{list.data.list.owner?.name}</span>\n              </a>\n            </div>\n\n            {list.data.list.description && (\n              <p className=\"mx-auto max-w-2xl text-balance text-zinc-600 dark:text-zinc-400\">\n                {list.data.list.description}\n              </p>\n            )}\n\n            {/* Stats */}\n            <div className=\"!mt-8 flex justify-center\">\n              <div className=\"flex items-center divide-x divide-material-ultra-thick\">\n                <div className=\"px-4 text-center\">\n                  <div className=\"text-xl font-semibold text-zinc-900 dark:text-zinc-100\">\n                    {list.data.feedCount || 0}\n                  </div>\n                  <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                    {(list.data.list.feedIds?.length || 0) > 1 ? \"Feeds\" : \"Feed\"}\n                  </div>\n                </div>\n\n                {!!list.data?.subscriptionCount && (\n                  <div className=\"px-4 text-center\">\n                    <div className=\"text-xl font-semibold text-zinc-900 dark:text-zinc-100\">\n                      {formatNumber(list.data.subscriptionCount)}\n                    </div>\n                    <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                      {t(\"feed.follower\", { count: list.data.subscriptionCount })}\n                    </div>\n                  </div>\n                )}\n              </div>\n            </div>\n\n            {/* Follow Button */}\n            <div className=\"!mt-8\">\n              <Button\n                variant={isSubscribed ? \"outline\" : \"primary\"}\n                size=\"lg\"\n                onClick={handleOpenInFollowApp}\n              >\n                <FollowIcon className=\"mr-2 size-4\" />\n                {isSubscribed\n                  ? t(\"feed.actions.followed\")\n                  : t(\"feed.actions.open\", { which: APP_NAME })}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Feeds Section */}\n      <div className=\"mx-auto max-w-6xl px-6 pb-8 sm:px-8\">\n        <div className=\"mb-6 border-b border-border/40 pb-3\">\n          <h2 className=\"text-xl font-medium text-zinc-900 dark:text-zinc-100\">\n            Feeds in this List\n          </h2>\n        </div>\n\n        <div className=\"grid gap-3 sm:grid-cols-1 lg:grid-cols-2\">\n          {listData.feedIds?.slice(0, SIZE).map((feedId) => (\n            <FeedRow feed={feedMap[feedId]!} key={feedId} />\n          ))}\n        </div>\n\n        {\"feedCount\" in list.data && list.data.feedCount > SIZE && (\n          <div className=\"mt-6 text-center\">\n            <button\n              type=\"button\"\n              onClick={handleOpenInFollowApp}\n              className=\"text-sm text-zinc-500 transition-colors hover:text-accent dark:text-zinc-400\"\n            >\n              {t(\"feed.follow_to_view_all\", {\n                count: list.data.feedCount || 0,\n              })}\n            </button>\n          </div>\n        )}\n      </div>\n\n      {/* Entries Preview */}\n      {!!list.data.entries?.length && (\n        <div className=\"mx-auto max-w-6xl px-6 pb-16 sm:px-8\">\n          <div className=\"mb-6 border-b border-border/40 pb-3\">\n            <h2 className=\"text-xl font-medium text-zinc-900 dark:text-zinc-100\">Recent Posts</h2>\n          </div>\n\n          <div className={cn(\"w-full\", \"flex flex-col gap-2\")}>\n            <Item entries={list.data.entries} view={list.data.list.view} />\n          </div>\n        </div>\n      )}\n    </Fragment>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/share/lists/[id]/metadata.ts",
    "content": "import { APPLE_APP_STORE_ID } from \"@follow/constants\"\n\nimport { callNotFound } from \"../../../../../../src/lib/not-found\"\nimport { defineMetadata } from \"../../../../../../src/meta-handler\"\n\nexport default defineMetadata(async ({ params, apiClient, origin }) => {\n  const listId = params.id!\n  const list = await apiClient.api.lists.get({ listId }).catch(callNotFound)\n\n  const { title, description } = list.data.list\n  return [\n    {\n      type: \"openGraph\",\n      title: title || \"\",\n      description: description || \"\",\n      image: `${origin}/og/list/${listId}`,\n    },\n    {\n      type: \"title\",\n      title: title || \"\",\n    },\n    {\n      type: \"hydrate\",\n      data: list.data,\n\n      key: `lists.$get,query:listId=${listId}`,\n    },\n    {\n      type: \"meta\",\n      property: \"apple-itunes-app\",\n      content: `app-id=${APPLE_APP_STORE_ID}, app-argument=follow://add?id=${listId}&type=list`,\n    },\n  ]\n})\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/share/users/[id]/index.tsx",
    "content": "import { FeedIcon } from \"@client/components/ui/feed-icon\"\nimport { openInFollowApp } from \"@client/lib/helper\"\nimport { UrlBuilder } from \"@client/lib/url-builder\"\nimport { useListsByUserId } from \"@client/query/list\"\nimport { useUserQuery, useUserSubscriptionsQuery } from \"@client/query/users\"\nimport { FollowIcon } from \"@follow/components/icons/follow.jsx\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@follow/components/ui/avatar/index.jsx\"\nimport { Button } from \"@follow/components/ui/button/index.jsx\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { useTitle } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { SubscriptionWithFeed, UserProfile } from \"@follow-app/client-sdk\"\nimport * as React from \"react\"\nimport { Fragment, memo, useState } from \"react\"\nimport { useParams } from \"react-router\"\n\ninterface FeedCardProps {\n  subscription: SubscriptionWithFeed\n  feedId: string\n  view: number\n}\n\nconst FeedCard = memo<FeedCardProps>(({ subscription, feedId, view }) => {\n  if (!(\"feeds\" in subscription)) {\n    return null\n  }\n  return (\n    <div className=\"group/card relative overflow-hidden rounded-xl border border-border/40 p-5 transition-all duration-200 hover:border-border hover:shadow-sm hover:shadow-black/5 dark:hover:shadow-white/5\">\n      {/* Feed Content */}\n      <a\n        className=\"block\"\n        href={UrlBuilder.shareFeed(feedId)}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n      >\n        <div className=\"flex items-start space-x-4\">\n          <div className=\"shrink-0\">\n            <FeedIcon\n              fallback\n              target={{\n                type: \"feed\",\n                ...subscription.feeds,\n              }}\n              size={44}\n              className=\"rounded-lg\"\n            />\n          </div>\n          <div className=\"min-w-0 flex-1\">\n            <h3 className=\"truncate font-medium text-zinc-900 transition-colors group-hover/card:text-accent dark:text-zinc-100\">\n              {subscription.feeds?.title}\n            </h3>\n            {subscription.feeds?.description && (\n              <p className=\"mt-2 line-clamp-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400\">\n                {subscription.feeds.description}\n              </p>\n            )}\n          </div>\n        </div>\n      </a>\n\n      {/* Follow Button - positioned absolutely to avoid layout shift */}\n      <div className=\"absolute bottom-4 right-4 translate-y-2 opacity-0 transition-all duration-200 ease-out group-hover/card:translate-y-0 group-hover/card:opacity-100\">\n        <Button\n          size=\"sm\"\n          variant=\"primary\"\n          onClick={(e) => {\n            e.preventDefault()\n            e.stopPropagation()\n            openInFollowApp({\n              deeplink: `feed?id=${feedId}&view=${view}`,\n              fallbackUrl: `/timeline/view-${view}/${feedId}/pending`,\n            })\n          }}\n        >\n          <FollowIcon className=\"mr-1.5 size-3\" />\n          Open in {APP_NAME}\n        </Button>\n      </div>\n    </div>\n  )\n})\n\nFeedCard.displayName = \"FeedCard\"\n\nexport const Component = () => {\n  const params = useParams()\n\n  const user = useUserQuery(params.id)\n\n  useTitle(user.data?.name)\n\n  return (\n    <>\n      {user.isLoading ? (\n        <LoadingCircle size=\"large\" className=\"center fixed inset-0\" />\n      ) : (\n        <Fragment>\n          <UserHero user={user.data!} />\n          <Lists userId={user.data?.id} />\n          {/* Subscriptions Section */}\n          <Subscriptions userId={user.data?.id} />\n        </Fragment>\n      )}\n    </>\n  )\n}\n\nconst UserHero = ({ user }: { user: UserProfile }) => {\n  const subscriptions = useUserSubscriptionsQuery(user.id)\n\n  const totalFeeds = Object.values(subscriptions.data || {}).reduce(\n    (total, category) => total + category.length,\n    0,\n  )\n\n  return (\n    <div className=\"mx-auto max-w-4xl px-6 py-8 text-center sm:px-8 sm:py-12\">\n      {/* Avatar */}\n      <div className=\"mb-6\">\n        <Avatar className=\"mx-auto size-20 border border-border\">\n          <AvatarImage className=\"duration-300 animate-in fade-in-0\" src={user.image!} />\n          <AvatarFallback className=\"bg-zinc-100 text-xl font-medium text-zinc-600 dark:bg-neutral-800 dark:text-neutral-400\">\n            {user.name?.slice(0, 2)}\n          </AvatarFallback>\n        </Avatar>\n      </div>\n\n      {/* User Info */}\n      <div className=\"space-y-3\">\n        <h1 className=\"text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl\">\n          {user.name}\n        </h1>\n        {user.handle && (\n          <p className=\"text-base text-zinc-500 dark:text-zinc-400\">@{user.handle}</p>\n        )}\n\n        {/* Stats */}\n        <div className=\"!mt-8 flex justify-center\">\n          <div className=\"flex items-center divide-x divide-material-ultra-thick\">\n            <div className=\"px-4 text-center\">\n              <div className=\"text-xl font-semibold text-zinc-900 dark:text-zinc-100\">\n                {totalFeeds}\n              </div>\n              <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">Subscriptions</div>\n            </div>\n            <div className=\"px-4 text-center\">\n              <div className=\"text-xl font-semibold text-zinc-900 dark:text-zinc-100\">\n                {Object.keys(subscriptions.data || {}).length}\n              </div>\n              <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">Categories</div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nconst Lists = ({ userId }: { userId: string }) => {\n  const lists = useListsByUserId(userId)\n  if (lists.isLoading) {\n    return (\n      <div className=\"mx-auto max-w-6xl px-6 pb-16 sm:px-8\">\n        <div className=\"flex h-64 items-center justify-center\">\n          <LoadingCircle size=\"large\" />\n        </div>\n      </div>\n    )\n  }\n\n  if (!lists.data || lists.data.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"mx-auto w-full max-w-6xl px-6 pb-16 sm:px-8\">\n      <div className=\"mb-6 border-b border-border/40 pb-3\">\n        <h2 className=\"text-xl font-medium text-zinc-900 dark:text-zinc-100\">Created Lists</h2>\n      </div>\n      <div data-testid=\"profile-lists\" className=\"flex flex-col space-y-4\">\n        {lists.data?.map((list) => (\n          <a\n            key={list.id}\n            href={UrlBuilder.shareList(list.id)}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group/card relative flex items-start space-x-4 overflow-hidden border-b border-border/40 pb-4 last:border-0\"\n          >\n            <FeedIcon\n              fallback\n              target={list}\n              className=\"mask-squircle mask border border-border\"\n              size={80}\n              noMargin\n            />\n\n            <div className=\"flex min-w-0 flex-1 flex-col justify-between self-stretch\">\n              <div>\n                <h3 className=\"truncate font-medium text-zinc-900 transition-colors group-hover/card:text-accent dark:text-zinc-100\">\n                  {list.title}\n                </h3>\n                {list.description && (\n                  <p className=\"mt-1 line-clamp-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400\">\n                    {list.description}\n                  </p>\n                )}\n              </div>\n              <div className=\"mt-2 flex items-center space-x-4 text-sm text-zinc-500 dark:text-zinc-400\">\n                {typeof list.subscriptionCount === \"number\" && (\n                  <div className=\"flex items-center space-x-1\">\n                    <i className=\"i-mingcute-group-2-line\" />\n                    <span>\n                      {list.subscriptionCount}\n                      <span className=\"hidden sm:inline\">\n                        {list.subscriptionCount > 1 ? \" Subscriptions\" : \" Subscription\"}\n                      </span>\n                    </span>\n                  </div>\n                )}\n              </div>\n            </div>\n            <div className=\"absolute bottom-4 right-4\">\n              <Button\n                size=\"sm\"\n                variant=\"primary\"\n                buttonClassName=\"opacity-0 transition-opacity group-hover/card:opacity-100\"\n                onClick={(e) => {\n                  e.preventDefault()\n                  e.stopPropagation()\n                  openInFollowApp({\n                    deeplink: `list?id=${list.id}`,\n                    fallbackUrl: `/list/${list.id}`,\n                  })\n                }}\n              >\n                <FollowIcon className=\"mr-1.5 size-3\" />\n                Open in {APP_NAME}\n              </Button>\n            </div>\n          </a>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nconst Subscriptions = ({ userId }: { userId: string }) => {\n  const subscriptions = useUserSubscriptionsQuery(userId)\n\n  const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>({})\n\n  const toggleCategory = (category: string) => {\n    setExpandedCategories((prev) => ({\n      ...prev,\n      [category]: !(prev[category] ?? true),\n    }))\n  }\n  return (\n    <div className=\"mx-auto max-w-6xl px-6 pb-16 sm:px-8\">\n      {subscriptions.isLoading ? (\n        <div className=\"flex h-64 items-center justify-center\">\n          <LoadingCircle size=\"large\" />\n        </div>\n      ) : (\n        <div data-testid=\"profile-subscriptions\" className=\"space-y-8\">\n          {Object.keys(subscriptions.data || {}).map((category) => {\n            const isExpanded = expandedCategories[category] ?? true\n\n            return (\n              <div key={category} className=\"group\">\n                {/* Category Header */}\n                <button\n                  type=\"button\"\n                  className=\"mb-6 flex w-full items-center justify-between border-b border-border/40 pb-3 text-left transition-colors hover:border-border/60\"\n                  onClick={() => toggleCategory(category)}\n                >\n                  <h2 className=\"text-xl font-medium text-zinc-900 dark:text-zinc-100\">\n                    {category}\n                  </h2>\n                  <div className=\"flex items-center space-x-3\">\n                    <span className=\"rounded-full bg-zinc-100 px-3 py-1 text-sm font-medium text-zinc-600 dark:bg-neutral-800 dark:text-neutral-400\">\n                      {subscriptions.data?.[category]?.length || 0}\n                    </span>\n                    <i\n                      className={cn(\n                        \"i-mingcute-down-line text-zinc-400 transition-transform duration-200\",\n                        isExpanded && \"rotate-180\",\n                      )}\n                    />\n                  </div>\n                </button>\n\n                {/* Feeds Grid with collapse animation */}\n                <div\n                  className={cn(\n                    \"overflow-hidden transition-all duration-300 ease-out\",\n                    isExpanded ? \"max-h-[5000px] opacity-100\" : \"max-h-0 opacity-0\",\n                  )}\n                >\n                  <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n                    {subscriptions.data?.[category]!.map(\n                      (subscription) =>\n                        \"feeds\" in subscription && (\n                          <FeedCard\n                            key={subscription.feedId}\n                            subscription={subscription}\n                            feedId={subscription.feedId}\n                            view={subscription.view}\n                          />\n                        ),\n                    )}\n                  </div>\n                </div>\n              </div>\n            )\n          })}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/pages/(main)/share/users/[id]/metadata.ts",
    "content": "import { isBizId } from \"@follow/utils/utils\"\n\nimport { callNotFound } from \"../../../../../../src/lib/not-found\"\nimport type { MetaTag } from \"../../../../../../src/meta-handler\"\nimport { defineMetadata } from \"../../../../../../src/meta-handler\"\n\nexport default defineMetadata(async ({ params, apiClient, origin }): Promise<MetaTag[]> => {\n  const userIdOrHandle = params.id\n\n  let handle = undefined\n  let userId = undefined\n\n  if (!userIdOrHandle) {\n    throw new Error(\"User ID or handle is required\")\n  }\n\n  if (isBizId(userIdOrHandle || \"\")) {\n    userId = userIdOrHandle\n  } else {\n    handle = userIdOrHandle.startsWith(\"@\") ? userIdOrHandle.slice(1) : userIdOrHandle\n  }\n\n  const profileRes = await apiClient.api.profiles\n    .getProfile({ id: userId, handle })\n    .catch(callNotFound)\n\n  const realUserId = profileRes.data.id\n  const [subscriptionsRes, listsRes] = await Promise.allSettled([\n    profileRes.data.id ? apiClient.api.subscriptions.get({ userId: realUserId }) : Promise.reject(),\n    profileRes.data.id ? apiClient.api.lists.list({ userId: realUserId }) : Promise.reject(),\n  ])\n\n  const isSubscriptionsResolved = subscriptionsRes.status === \"fulfilled\"\n  const isListsResolved = listsRes.status === \"fulfilled\"\n  const { name } = profileRes.data\n  const subscriptions = isSubscriptionsResolved ? subscriptionsRes.value.data : []\n\n  return [\n    {\n      type: \"title\",\n      title: name || \"\",\n    },\n    {\n      type: \"description\",\n      description:\n        subscriptions.length > 0\n          ? `${name} followed ${subscriptions.length} public subscription${\n              subscriptions.length > 1 ? \"s\" : \"\"\n            }. Follow them to get their latest updates on ${APP_NAME}`\n          : \"\",\n    },\n    {\n      type: \"openGraph\",\n      title: `${name} on ${APP_NAME}`,\n      image: `${origin}/og/user/${userIdOrHandle}`,\n    },\n    {\n      type: \"hydrate\",\n      data: profileRes.data,\n\n      key: `profiles.$get,query:id=${userIdOrHandle}`,\n    },\n    isSubscriptionsResolved && {\n      type: \"hydrate\",\n      data: subscriptionsRes.value.data,\n\n      key: `subscriptions.$get,query:userId=${realUserId}`,\n    },\n    isListsResolved && {\n      type: \"hydrate\",\n      data: listsRes.value.data,\n\n      key: `lists.list.$get,query:userId=${realUserId}`,\n    },\n  ].filter((v) => !!v) as MetaTag[]\n})\n"
  },
  {
    "path": "apps/ssr/client/pages/layout.tsx",
    "content": "import { PoweredByFooter } from \"@client/components/common/PoweredByFooter\"\nimport { Header } from \"@client/components/layout/header\"\nimport { MemoedDangerousHTMLStyle } from \"@follow/components/common/MemoedDangerousHTMLStyle.jsx\"\nimport * as React from \"react\"\nimport { Outlet } from \"react-router\"\n\nexport const Component = () => {\n  return (\n    <div className=\"flex h-full flex-col\">\n      <MemoedDangerousHTMLStyle>\n        {`:root {\n          --container-max-width: 1024px;\n          }`}\n      </MemoedDangerousHTMLStyle>\n      <Header />\n      <main className=\"center relative mx-auto mb-12 mt-[calc(100px+3rem)] flex w-full max-w-[var(--container-max-width)] flex-1 flex-col bg-background p-4 lg:p-0\">\n        <Outlet />\n      </main>\n      <PoweredByFooter />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/providers/root-providers.tsx",
    "content": "import { MotionProvider } from \"@follow/components/common/MotionProvider.jsx\"\nimport { EventProvider } from \"@follow/components/providers/event-provider.jsx\"\nimport { StableRouterProvider } from \"@follow/components/providers/stable-router-provider.jsx\"\nimport { Toaster } from \"@follow/components/ui/toast/index.jsx\"\nimport { useSyncThemeWebApp } from \"@follow/hooks\"\nimport { env } from \"@follow/shared/env.ssr\"\nimport { QueryClientProvider } from \"@tanstack/react-query\"\nimport { Provider } from \"jotai\"\nimport { ModalStackContainer } from \"rc-modal-sheet/m\"\nimport type { FC, PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { GoogleReCaptchaProvider } from \"react-google-recaptcha-v3\"\n\nimport { queryClient } from \"../lib/query-client\"\nimport { jotaiStore } from \"../lib/store\"\nimport { ServerConfigsProvider } from \"./server-configs-provider\"\nimport { UserProvider } from \"./user-provider\"\n\nconst ThemeProvider = () => {\n  useSyncThemeWebApp()\n  return null\n}\n\nexport const RootProviders: FC<PropsWithChildren> = ({ children }) => (\n  <MotionProvider>\n    <Provider store={jotaiStore}>\n      <RecaptchaProvider>\n        <QueryClientProvider client={queryClient}>\n          <ServerConfigsProvider />\n          <ThemeProvider />\n          <EventProvider />\n          <StableRouterProvider />\n          <ModalStackContainer>\n            <UserProvider />\n            <Toaster />\n            {children}\n          </ModalStackContainer>\n        </QueryClientProvider>\n      </RecaptchaProvider>\n    </Provider>\n  </MotionProvider>\n)\n\nconst RecaptchaProvider: FC<PropsWithChildren> = ({ children }) => {\n  const siteKey = env.VITE_RECAPTCHA_V3_SITE_KEY\n\n  if (!siteKey) {\n    return children\n  }\n\n  return (\n    <GoogleReCaptchaProvider\n      reCaptchaKey={siteKey}\n      scriptProps={{ async: true, defer: true, appendTo: \"body\" }}\n    >\n      {children}\n    </GoogleReCaptchaProvider>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/client/providers/server-configs-provider.tsx",
    "content": "import { setServerConfigs } from \"@client/atoms/server-configs\"\nimport { followClient } from \"@client/lib/api-fetch\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useEffect } from \"react\"\n\nconst useServerConfigsQuery = () => {\n  const { data } = useQuery({\n    queryKey: [\"server-configs\"],\n    queryFn: async () => await followClient.api.status.getConfigs().then((res) => res.data),\n  })\n  return data\n}\n\nexport const ServerConfigsProvider = () => {\n  const serverConfigs = useServerConfigsQuery()\n\n  useEffect(() => {\n    if (!serverConfigs) return\n    setServerConfigs(serverConfigs)\n  }, [serverConfigs])\n\n  return null\n}\n"
  },
  {
    "path": "apps/ssr/client/providers/user-provider.tsx",
    "content": "import { setWhoami } from \"@client/atoms/user\"\nimport { setIntegrationIdentify } from \"@client/initialize/helper\"\nimport { useSession } from \"@client/query/auth\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\nimport { useEffect } from \"react\"\n\nexport const UserProvider = () => {\n  const { session } = useSession()\n\n  useEffect(() => {\n    if (!session?.user) return\n    // @ts-expect-error FIXME\n    setWhoami(session.user)\n\n    setIntegrationIdentify(session.user as unknown as AuthUser)\n  }, [session?.user])\n\n  return null\n}\n"
  },
  {
    "path": "apps/ssr/client/query/auth.ts",
    "content": "import { getSession } from \"@client/lib/auth\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport type { FetchError } from \"ofetch\"\n\nexport const useSession = (options?: { enabled?: boolean }) => {\n  const { data, isLoading, ...rest } = useQuery({\n    queryKey: [\"auth\", \"session\"],\n    queryFn: () => getSession(),\n    retry(failureCount, error) {\n      const fetchError = error as FetchError\n\n      if (fetchError.statusCode === undefined) {\n        return false\n      }\n\n      return !!(3 - failureCount)\n    },\n    enabled: options?.enabled ?? true,\n    meta: {\n      persist: true,\n    },\n  })\n  const { error } = rest\n  const fetchError = error as FetchError\n\n  return {\n    session: data?.data,\n    ...rest,\n    status: isLoading\n      ? \"loading\"\n      : data?.data\n        ? \"authenticated\"\n        : fetchError\n          ? \"error\"\n          : \"unauthenticated\",\n  }\n}\n"
  },
  {
    "path": "apps/ssr/client/query/entries.ts",
    "content": "import { followClient } from \"@client/lib/api-fetch\"\nimport { getHydrateData } from \"@client/lib/helper\"\nimport { useQuery } from \"@tanstack/react-query\"\n\nimport type { Feed } from \"./feed\"\n\nconst fetchEntriesPreview = async ({ id }: { id?: string }) => {\n  const res = await followClient.api.entries.preview({\n    id: id!,\n  })\n\n  return res.data\n}\nexport const useEntriesPreview = ({ id }: { id?: string }) => {\n  return useQuery({\n    queryKey: [\"entries-preview\", id],\n    queryFn: () => fetchEntriesPreview({ id }),\n    enabled: !!id,\n    initialData: getHydrateData(`feeds.$get,query:id=${id}`)?.[\"entries\"],\n  })\n}\n\nexport type EntriesPreview = (Awaited<ReturnType<typeof fetchEntriesPreview>> & {\n  feeds?: Feed[\"feed\"]\n})[]\n"
  },
  {
    "path": "apps/ssr/client/query/feed.ts",
    "content": "import { followClient } from \"@client/lib/api-fetch\"\nimport { getHydrateData } from \"@client/lib/helper\"\nimport type { FeedHydrateData } from \"@client/pages/(main)/share/feeds/[id]/metadata\"\nimport { useQuery } from \"@tanstack/react-query\"\n\nasync function fetchFeedById(id: string) {\n  const res = await followClient.api.feeds.get({\n    id,\n    entriesLimit: 8,\n  })\n  return res.data\n}\n\nexport const useFeed = ({ id }: { id: string }) => {\n  return useQuery({\n    queryKey: [\"feed\", id],\n    queryFn: () => fetchFeedById(id),\n    initialData: getHydrateData(`feeds.$get,query:id=${id}`) as FeedHydrateData,\n  })\n}\n\nexport type Feed = Awaited<ReturnType<typeof fetchFeedById>>\n"
  },
  {
    "path": "apps/ssr/client/query/list.ts",
    "content": "import { followClient } from \"@client/lib/api-fetch\"\nimport { getHydrateData } from \"@client/lib/helper\"\nimport { useQuery } from \"@tanstack/react-query\"\n\nconst fetchListById = async (id: string) => {\n  const res = await followClient.api.lists.get({ listId: id })\n  return res.data\n}\n\nexport const useList = ({ id }: { id?: string }) =>\n  useQuery({\n    queryKey: [\"lists\", id],\n    queryFn: () => fetchListById(id!),\n    enabled: !!id,\n    initialData: getHydrateData(`lists.$get,query:listId=${id}`) as any as Awaited<\n      ReturnType<typeof fetchListById>\n    >,\n  })\n\nconst fetchListsByUserId = async (userId: string) => {\n  const res = await followClient.api.lists.list({ userId })\n  return res.data\n}\n\nexport const useListsByUserId = (userId: string) =>\n  useQuery({\n    queryKey: [\"lists\", userId],\n    queryFn: () => fetchListsByUserId(userId),\n    enabled: !!userId,\n    initialData: getHydrateData(`lists.list.$get,query:userId=${userId}`) as any as Awaited<\n      ReturnType<typeof fetchListsByUserId>\n    >,\n  })\n"
  },
  {
    "path": "apps/ssr/client/query/users.ts",
    "content": "import { followClient } from \"@client/lib/api-fetch\"\nimport { getProviders } from \"@client/lib/auth\"\nimport { getHydrateData } from \"@client/lib/helper\"\nimport type { LoginHydrateData } from \"@client/pages/(login)/login/metadata\"\nimport { isBizId, sortByAlphabet } from \"@follow/utils/utils\"\nimport type {\n  InboxSubscriptionResponse,\n  ListSubscriptionResponse,\n  SubscriptionWithFeed,\n} from \"@follow-app/client-sdk\"\nimport { useQuery } from \"@tanstack/react-query\"\n\ntype GetUserSubscriptionsResponse = (\n  | SubscriptionWithFeed\n  | ListSubscriptionResponse\n  | InboxSubscriptionResponse\n)[]\n\nconst UN_CATEGORIZED = \"Uncategorized\"\nconst groupSubscriptions = (\n  subscriptions: GetUserSubscriptionsResponse,\n): Record<string, GetUserSubscriptionsResponse> => {\n  const groupFolder = {} as Record<string, GetUserSubscriptionsResponse>\n  for (const subscription of subscriptions.filter((s) => !s.isPrivate) || []) {\n    if (!subscription.category && \"feeds\" in subscription) {\n      subscription.category = UN_CATEGORIZED\n    }\n    if (subscription.category) {\n      if (!groupFolder[subscription.category]) {\n        groupFolder[subscription.category] = []\n      }\n      groupFolder[subscription.category]!.push(subscription)\n    }\n  }\n\n  // Move the un-categorized to the last\n  if (Object.keys(groupFolder)[0] === UN_CATEGORIZED) {\n    const temp = groupFolder[UN_CATEGORIZED]\n    delete groupFolder[UN_CATEGORIZED]\n\n    Reflect.defineProperty(groupFolder, UN_CATEGORIZED, {\n      value:\n        temp?.sort((a, b) => (a.title && b.title ? sortByAlphabet(a.title, b.title) : 0)) || [],\n      writable: true,\n      configurable: true,\n      enumerable: true,\n    })\n  }\n  return groupFolder\n}\n\nconst fetchUserSubscriptions = async (userId: string | undefined) => {\n  const res = await followClient.api.subscriptions.get({\n    userId,\n  })\n  return res.data\n}\n\nexport const useUserSubscriptionsQuery = (userId: string | undefined) => {\n  return useQuery({\n    queryKey: [\"subscriptions\", \"group\", userId],\n    queryFn: async () => {\n      return fetchUserSubscriptions(userId)\n    },\n    select: groupSubscriptions,\n    enabled: !!userId,\n    initialData: getHydrateData(`subscriptions.$get,query:userId=${userId}`) as any as Awaited<\n      ReturnType<typeof fetchUserSubscriptions>\n    >,\n  })\n}\n\nexport const fetchUser = async (handleOrId: string | undefined) => {\n  const handle = isBizId(handleOrId || \"\")\n    ? handleOrId\n    : `${handleOrId}`.startsWith(\"@\")\n      ? `${handleOrId}`.slice(1)\n      : handleOrId\n\n  const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle })\n  return res.data\n}\n\nexport type User = Awaited<ReturnType<typeof fetchUser>>\nexport const useUserQuery = (handleOrId: string | undefined) => {\n  return useQuery({\n    queryKey: [\"profiles\", handleOrId],\n    queryFn: () => fetchUser(handleOrId),\n    enabled: !!handleOrId,\n    initialData: getHydrateData(`profiles.$get,query:id=${handleOrId}`) as any as User,\n  })\n}\nexport interface AuthProvider {\n  name: string\n  id: string\n  color: string\n  icon: string\n  icon64: string\n  iconDark64?: string\n}\nconst getTypedProviders = async () => {\n  const providers = await getProviders()\n  return providers.data as Record<string, AuthProvider>\n}\nexport const useAuthProviders = () => {\n  return useQuery({\n    queryKey: [\"providers\"],\n    queryFn: async () => getTypedProviders(),\n    initialData: getHydrateData(`betterAuth`) as LoginHydrateData,\n  })\n}\n\nexport { getTypedProviders as getAuthProviders }\n"
  },
  {
    "path": "apps/ssr/client/router.tsx",
    "content": "import { wrapCreateBrowserRouterV7 } from \"@sentry/react\"\nimport * as React from \"react\"\nimport { createBrowserRouter, createHashRouter } from \"react-router\"\n\nimport { NotFound } from \"./components/common/404\"\n// @ts-ignore\nimport tree from \"./generated-routes\"\n\ndeclare global {\n  interface Window {\n    SENTRY_RELEASE: string\n    __DEBUG_PROXY__: boolean\n  }\n}\nlet routerCreator = window[\"__DEBUG_PROXY__\"] ? createHashRouter : createBrowserRouter\nif (window.SENTRY_RELEASE) {\n  routerCreator = wrapCreateBrowserRouterV7(routerCreator)\n}\n\nexport const router = routerCreator([\n  {\n    path: \"/\",\n    lazy: () => import(\"./App\"),\n    children: tree,\n    // errorElement: <ErrorElement />,\n  },\n  {\n    path: \"*\",\n    element: <NotFound />,\n  },\n])\n"
  },
  {
    "path": "apps/ssr/client/styles/index.css",
    "content": "@import \"@fontsource/sn-pro/200-italic.css\";\n@import \"@fontsource/sn-pro/200.css\";\n@import \"@fontsource/sn-pro/300-italic.css\";\n@import \"@fontsource/sn-pro/300.css\";\n@import \"@fontsource/sn-pro/400-italic.css\";\n@import \"@fontsource/sn-pro/400.css\";\n@import \"@fontsource/sn-pro/500-italic.css\";\n@import \"@fontsource/sn-pro/500.css\";\n@import \"@fontsource/sn-pro/600-italic.css\";\n@import \"@fontsource/sn-pro/600.css\";\n@import \"@fontsource/sn-pro/700-italic.css\";\n@import \"@fontsource/sn-pro/700.css\";\n@import \"@fontsource/sn-pro/800-italic.css\";\n@import \"@fontsource/sn-pro/800.css\";\n@import \"@fontsource/sn-pro/900-italic.css\";\n@import \"@fontsource/sn-pro/900.css\";\n\nbutton {\n  --cursor-button: pointer;\n}\n\n::selection {\n  @apply bg-accent text-white;\n}\n\n:root {\n  --bg-opacity: rgba(255, 255, 255, 0.72);\n  --test: red;\n}\n\n[data-theme=\"dark\"] {\n  --bg-opacity: rgba(29, 29, 31, 0.72);\n}\n\n.grecaptcha-badge {\n  visibility: hidden !important;\n}\n"
  },
  {
    "path": "apps/ssr/global.ts",
    "content": "Object.assign(globalThis, {\n  APP_NAME: \"Folo\",\n  ELECTRON: false,\n})\n\ntry {\n  void __DEV__\n} catch {\n  Object.assign(globalThis, {\n    __DEV__: process.env.NODE_ENV === \"development\",\n  })\n}\n"
  },
  {
    "path": "apps/ssr/helper/meta-map.ts",
    "content": "import * as fs from \"node:fs/promises\"\nimport { fileURLToPath } from \"node:url\"\n\nimport chokidar from \"chokidar\"\nimport fg from \"fast-glob\"\nimport * as path from \"pathe\"\nimport { dirname } from \"pathe\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\nasync function generateMetaMap() {\n  const files = await fg.glob(\"./client/pages/**/*/metadata.ts\", {\n    cwd: path.resolve(__dirname, \"..\"),\n  })\n\n  const imports: string[] = []\n  const routes: Record<string, string> = {}\n\n  files.forEach((file, index) => {\n    const routePath = file\n      .replace(\"client/pages/\", \"\")\n      .replace(\"/metadata.ts\", \"\")\n      .replaceAll(/\\[([^\\]]+)\\]/g, \":$1\")\n      .replaceAll(/\\([^)]+\\)\\//g, \"\")\n      .replace(/^\\./, \"\")\n\n      .replaceAll(/\\([^)]+\\)\\//g, \"\")\n      .replace(/^\\./, \"\")\n\n    const importName = `i${index}`\n    imports.push(`import ${importName} from \"../${file.replace(\".ts\", \"\")}\"`)\n    routes[routePath] = importName\n  })\n\n  const content =\n    \"// This file is generated by `pnpm run meta`\\n\" +\n    `${imports.join(\"\\n\")}\\n\nexport default {\n${Object.entries(routes)\n  .map(([route, imp]) => `  \"${route}\": ${imp},`)\n  .join(\"\\n\")}\n}\n`\n\n  const originalContent = await fs.readFile(\n    path.resolve(__dirname, \"../src/meta-handler.map.ts\"),\n    \"utf-8\",\n  )\n  if (originalContent === content) return\n  await fs.writeFile(path.resolve(__dirname, \"../src/meta-handler.map.ts\"), content, \"utf-8\")\n  console.info(\"Meta map generated successfully!\")\n}\n\nasync function watch() {\n  const watchPath = path.resolve(__dirname, \"..\", \"./client/pages\")\n  console.info(\"Watching metadata files...\")\n\n  await generateMetaMap()\n  const watcher = chokidar.watch(watchPath, {\n    ignoreInitial: false,\n  })\n\n  watcher.on(\"add\", () => {\n    console.info(\"Metadata file added/changed, regenerating map...\")\n    generateMetaMap()\n  })\n\n  watcher.on(\"unlink\", () => {\n    console.info(\"Metadata file removed, regenerating map...\")\n    generateMetaMap()\n  })\n\n  watcher.on(\"change\", () => {\n    console.info(\"Metadata file changed, regenerating map...\")\n    generateMetaMap()\n  })\n\n  process.on(\"SIGINT\", () => {\n    watcher.close()\n    process.exit(0)\n  })\n}\n\nif (process.argv.includes(\"--watch\")) {\n  watch().catch(console.error)\n} else {\n  generateMetaMap().catch(console.error)\n}\n"
  },
  {
    "path": "apps/ssr/index.html",
    "content": "<!doctype html>\n<html lang=\"en\" class=\"h-full\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"referrer\" content=\"no-referrer\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0\"\n    />\n\n    <meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#ffffff\" />\n    <meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#000000\" />\n\n    <link rel=\"icon\" href=\"/favicon.ico\" sizes=\"48x48\" type=\"image/x-icon\" />\n    <link rel=\"icon\" href=\"/icon.svg\" sizes=\"any\" type=\"image/svg+xml\" />\n    <!-- FireFox -->\n    <link rel=\"shortcut icon\" href=\"/favicon.ico\" type=\"image/x-icon\" />\n    <title>Folo</title>\n\n    <meta name=\"apple-itunes-app\" content=\"app-id=6739802604\" />\n    <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n    <!-- Google tag (gtag.js) -->\n    <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-DZMBZBW3EC\"></script>\n    <script>\n      window.dataLayer = window.dataLayer || []\n      function gtag() {\n        dataLayer.push(arguments)\n      }\n      gtag(\"js\", new Date())\n\n      gtag(\"config\", \"G-DZMBZBW3EC\")\n    </script>\n    <script>\n      function setTheme() {\n        let e = \"follow:color-mode\",\n          t = document.documentElement,\n          a = localStorage.getItem(e)\n        function h() {\n          return window.matchMedia\n            ? window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n              ? \"dark\"\n              : window.matchMedia(\"(prefers-color-scheme: light)\").matches\n                ? \"light\"\n                : void 0\n            : void 0\n        }\n        if (!a) {\n          t.dataset.theme = h() || \"light\"\n          return\n        }\n        switch ((a = JSON.parse(a))) {\n          case \"dark\":\n            t.dataset.theme = \"dark\"\n            break\n          case \"light\":\n            t.dataset.theme = \"light\"\n            break\n          case \"system\":\n            t.dataset.theme = h() || \"light\"\n        }\n      }\n      setTheme()\n    </script>\n  </head>\n  <body class=\"flex h-full flex-col\">\n    <div id=\"root\" class=\"flex h-full flex-col\"></div>\n  </body>\n  <script type=\"module\" src=\"/client/index.tsx\"></script>\n</html>\n"
  },
  {
    "path": "apps/ssr/index.ts",
    "content": "import \"./global\"\nimport \"./src/lib/load-env\"\n\nimport os from \"node:os\"\n\nimport middie from \"@fastify/middie\"\nimport { fastifyRequestContext } from \"@fastify/request-context\"\nimport type { FastifyRequest } from \"fastify\"\nimport Fastify from \"fastify\"\nimport { nanoid } from \"nanoid\"\nimport { FetchError } from \"ofetch\"\n\nimport { MetaError } from \"./src/meta-handler\"\nimport { globalRoute } from \"./src/router/global\"\nimport { ogRoute } from \"./src/router/og\"\n\nconst isVercel = process.env.VERCEL === \"1\"\n\ndeclare module \"@fastify/request-context\" {\n  interface RequestContextData {\n    req: FastifyRequest\n  }\n}\n\nexport const createApp = async () => {\n  const app = Fastify({})\n\n  app.register(fastifyRequestContext)\n  await app.register(middie, {\n    hook: \"onRequest\",\n  })\n\n  app.setErrorHandler(function (err, req, reply) {\n    this.log.error(err)\n\n    const traceId = nanoid(8)\n\n    if (err instanceof FetchError) {\n      reply\n        .status((err as FetchError).response?.status || 500)\n        .send({ ok: false, traceId, message: err.message })\n    } else if (err instanceof MetaError) {\n      reply.status(err.status).send({ ok: false, traceId, message: err.metaMessage })\n    } else {\n      const message = (err as any).message || \"Internal Server Error\"\n      const status = Number.parseInt((err as any).code as string) || 500\n      reply.status(status).send({ ok: false, message, traceId })\n    }\n  })\n\n  app.addHook(\"onRequest\", (req, reply, done) => {\n    req.requestContext.set(\"req\", req)\n\n    const { host } = req.headers\n\n    const forwardedHost = req.headers[\"x-forwarded-host\"]\n    const finalHost = forwardedHost || host\n\n    reply.header(\"x-handled-host\", finalHost)\n    done()\n  })\n\n  if (__DEV__) {\n    const devVite = await import(\"./src/lib/dev-vite\")\n    await devVite.registerDevViteServer(app)\n  }\n\n  ogRoute(app)\n  globalRoute(app)\n\n  return app\n}\n\nif (!isVercel) {\n  createApp().then(async (app) => {\n    await app.listen({ port: 2234, host: \"0.0.0.0\" })\n    console.info(\"Server is running on http://localhost:2234\")\n    const ip = getIPAddress()\n\n    if (ip) console.info(`Server is running on http://${ip}:2234`)\n  })\n}\n\nfunction getIPAddress() {\n  const interfaces = os.networkInterfaces()\n  for (const devName in interfaces) {\n    const iface = interfaces[devName]\n\n    for (const alias of iface || []) {\n      if (alias.family === \"IPv4\" && alias.address !== \"127.0.0.1\" && !alias.internal)\n        return alias.address\n    }\n  }\n  return \"0.0.0.0\"\n}\n"
  },
  {
    "path": "apps/ssr/note.md",
    "content": "Rewrite:\n\nTest url:\n\nhttp://localhost:2234/share/feeds/41223694984583197\nhttp://localhost:2234/share/feeds/41375451836487680?view=2\nhttp://localhost:2234/share/feeds/41147805276726317?view=2\nhttp://localhost:2234/share/lists/61046160274909184\n\nhttp://localhost:2234/og/list/61046160274909184\nhttp://localhost:2234/og/user/Innei\nhttp://localhost:2234/og/feed/41223694984583170\n"
  },
  {
    "path": "apps/ssr/package.json",
    "content": "{\n  \"name\": \"@follow/ssr\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsdown && tsx scripts/cleanup-vercel-build.ts\",\n    \"build:worker\": \"cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsx scripts/generate-font-data.ts && tsdown --config tsdown.worker.config.ts && tsx scripts/patch-worker-build.ts && cp -r dist/dist-external ../desktop/out/web/dist-external\",\n    \"deploy:dev\": \"wrangler deploy --env dev\",\n    \"deploy:prod\": \"wrangler deploy\",\n    \"dev\": \"cross-env NODE_ENV=development tsx watch --include \\\"src/**/*.ts\\\" --exclude \\\"./*.ts\\\" --exclude \\\"./*.mjs\\\" index.ts\",\n    \"meta\": \"tsx helper/meta-map.ts --watch\",\n    \"start\": \"tsx index.ts\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@fastify/middie\": \"9.1.0\",\n    \"@fastify/request-context\": \"6.2.1\",\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/tracker\": \"workspace:*\",\n    \"@fontsource/sn-pro\": \"5.2.6\",\n    \"@radix-ui/react-avatar\": \"1.1.11\",\n    \"@resvg/resvg-js\": \"2.6.2\",\n    \"@sentry/react\": \"10.38.0\",\n    \"@tanstack/react-query\": \"5.90.21\",\n    \"blurhash\": \"2.0.5\",\n    \"dayjs\": \"1.11.19\",\n    \"fastify\": \"5.7.4\",\n    \"i18next\": \"25.8.6\",\n    \"jotai\": \"2.17.1\",\n    \"kose-font\": \"1.0.0\",\n    \"linkedom\": \"0.18.12\",\n    \"motion\": \"12.34.0\",\n    \"ofetch\": \"1.5.1\",\n    \"rc-modal-sheet\": \"1.0.2\",\n    \"react\": \"19.0.0\",\n    \"react-blurhash\": \"0.3.0\",\n    \"react-dom\": \"19.0.0\",\n    \"react-google-recaptcha-v3\": \"1.11.0\",\n    \"react-hook-form\": \"7.71.1\",\n    \"react-hotkeys-hook\": \"5.2.4\",\n    \"react-i18next\": \"16.5.4\",\n    \"react-photo-view\": \"1.2.7\",\n    \"react-router\": \"7.13.0\",\n    \"satori\": \"0.19.2\",\n    \"sonner\": \"2.0.7\",\n    \"use-context-selector\": \"2.0.0\",\n    \"xss\": \"1.0.15\",\n    \"zod\": \"3.25.76\"\n  },\n  \"devDependencies\": {\n    \"@follow/components\": \"workspace:*\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/hooks\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"@resvg/resvg-wasm\": \"2.6.2\",\n    \"@types/html-minifier-terser\": \"7.0.2\",\n    \"chokidar\": \"4.0.3\",\n    \"code-inspector-plugin\": \"1.4.2\",\n    \"daisyui\": \"4.12.24\",\n    \"dotenv-flow\": \"4.1.0\",\n    \"es-toolkit\": \"1.44.0\",\n    \"fast-glob\": \"3.3.3\",\n    \"foxact\": \"0.2.52\",\n    \"hono\": \"4.12.1\",\n    \"html-minifier-terser\": \"7.2.0\",\n    \"lightningcss\": \"1.31.1\",\n    \"masonic\": \"4.1.0\",\n    \"nanoid\": \"5.1.6\",\n    \"path-to-regexp\": \"8.3.0\",\n    \"tailwindcss\": \"3.4.17\",\n    \"tailwindcss-uikit-colors\": \"catalog:\",\n    \"tsdown\": \"0.20.3\",\n    \"tsx\": \"4.21.0\",\n    \"typescript\": \"catalog:\",\n    \"vite\": \"7.3.1\",\n    \"vite-plugin-route-builder\": \"0.4.1\",\n    \"wrangler\": \"4.67.0\"\n  }\n}\n"
  },
  {
    "path": "apps/ssr/postcss.config.cjs",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    \"tailwindcss/nesting\": {},\n\n    ...(process.env.NODE_ENV === \"production\" ? { cssnano: {} } : {}),\n  },\n}\n"
  },
  {
    "path": "apps/ssr/public/manifest.json",
    "content": "{\n  \"theme_color\": \"#ff5c00\",\n  \"name\": \"Folo\",\n  \"icons\": [\n    {\n      \"src\": \"/icon-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/icon-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/ssr/scripts/check-fonts.ts",
    "content": "import fs from \"node:fs\"\nimport { createRequire } from \"node:module\"\n\nimport path, { resolve } from \"pathe\"\n\nconst require = createRequire(import.meta.url)\n\nconst snPath = require.resolve(\"@fontsource/sn-pro\")\nconst filesDir = resolve(snPath, \"../files\")\nconst files = fs.readdirSync(filesDir).filter((f) => !f.endsWith(\".woff2\") && !f.includes(\"italic\"))\nfiles.forEach((f) => {\n  const stat = fs.statSync(path.join(filesDir, f))\n  console.info(f, `${(stat.size / 1024).toFixed(1)}KB`)\n})\nconst kosePath = require.resolve(\"kose-font\")\nconst koseSize = fs.statSync(kosePath).size\nconsole.info(`kose-font: ${(koseSize / 1024).toFixed(1)}KB`)\nlet total = files.reduce((sum, f) => sum + fs.statSync(path.join(filesDir, f)).size, 0)\ntotal += koseSize\nconsole.info(`Total: ${(total / 1024 / 1024).toFixed(1)}MB`)\n"
  },
  {
    "path": "apps/ssr/scripts/cleanup-vercel-build.ts",
    "content": "import { rmSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { dirname, resolve } from \"pathe\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nrmSync(resolve(__dirname, \"../.generated\"), { recursive: true, force: true })\n// restore env file\n"
  },
  {
    "path": "apps/ssr/scripts/generate-font-data.ts",
    "content": "import fs from \"node:fs\"\nimport { createRequire } from \"node:module\"\nimport { fileURLToPath } from \"node:url\"\n\nimport path, { dirname, resolve } from \"pathe\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst require = createRequire(import.meta.url)\n\nconst weights = [\n  { name: \"Thin\", weight: 100 },\n  { name: \"ExtraLight\", weight: 200 },\n  { name: \"Light\", weight: 300 },\n  { name: \"Regular\", weight: 400 },\n  { name: \"Italic\", weight: 400 },\n  { name: \"Medium\", weight: 500 },\n  { name: \"SemiBold\", weight: 600 },\n  { name: \"Bold\", weight: 700 },\n  { name: \"ExtraBold\", weight: 800 },\n  { name: \"Black\", weight: 900 },\n] as const\n\nconst snFontDepsPath = require.resolve(\"@fontsource/sn-pro\")\nconst snFontsDirPath = resolve(snFontDepsPath, \"../files\")\nconst snFontsDir = fs\n  .readdirSync(snFontsDirPath)\n  .filter((name) => !name.endsWith(\".woff2\") && !name.includes(\"italic\"))\n\nconst fontsData: Record<string, string> = {}\n\nfor (const file of snFontsDir) {\n  const weight = weights.find((w) => file.includes(w.weight.toString()))\n  if (!weight) continue\n  const data = fs.readFileSync(path.join(snFontsDirPath, file))\n  fontsData[`sn-pro-${weight.weight}`] = data.toString(\"base64\")\n}\n\n// kose-font is too large (~24MB) to bundle, loaded from R2 at runtime instead\n\nconst outDir = path.join(__dirname, \"../.generated\")\nfs.mkdirSync(outDir, { recursive: true })\nfs.writeFileSync(\n  path.join(outDir, \"fonts-data.ts\"),\n  `export default ${JSON.stringify(fontsData)} as Record<string, string>`,\n)\nconsole.info(\"Generated fonts-data.ts with\", Object.keys(fontsData).length, \"fonts\")\n"
  },
  {
    "path": "apps/ssr/scripts/patch-worker-build.ts",
    "content": "import fs from \"node:fs\"\n\nimport { dirname, join } from \"pathe\"\n\nconst distDir = join(dirname(import.meta.url.replace(\"file://\", \"\")), \"../dist/worker\")\n\nconst files = fs.readdirSync(distDir).filter((f) => f.endsWith(\".mjs\"))\n\nfor (const file of files) {\n  const filePath = join(distDir, file)\n  const code = fs.readFileSync(filePath, \"utf-8\")\n\n  // Fix createRequire(import.meta.url) - import.meta.url is undefined in Cloudflare Workers\n  // Provide a fallback URL so createRequire can initialize properly\n  const patched = code.replaceAll(\n    \"createRequire(import.meta.url)\",\n    'createRequire(import.meta.url || \"file:///worker.mjs\")',\n  )\n\n  if (patched !== code) {\n    fs.writeFileSync(filePath, patched)\n    console.info(`Patched createRequire in ${file}`)\n  }\n}\n"
  },
  {
    "path": "apps/ssr/scripts/prepare-vercel-build.ts",
    "content": "import { mkdirSync } from \"node:fs\"\nimport fs from \"node:fs/promises\"\nimport { fileURLToPath } from \"node:url\"\n\nimport path, { dirname } from \"pathe\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\nmkdirSync(path.join(__dirname, \"../.generated\"), { recursive: true })\n\nasync function generateIndexHtmlData() {\n  const indexHtml = await fs.readFile(path.join(__dirname, \"../dist/index.html\"), \"utf-8\")\n  await fs.writeFile(\n    path.join(__dirname, \"../.generated/index.template.ts\"),\n    `export default ${JSON.stringify(indexHtml)}`,\n  )\n}\n\nasync function main() {\n  await generateIndexHtmlData()\n}\n\nmain()\n"
  },
  {
    "path": "apps/ssr/scripts/skip-ssr-app-vercel-build.sh",
    "content": "#!/bin/bash\n\nLAST_DEPLOY_COMMIT=$(git rev-parse HEAD^)\n\nCHANGED_FILES=$(git diff --name-only $LAST_DEPLOY_COMMIT HEAD)\n\necho \"Changed files: $CHANGED_FILES\"\n\n# Define an array of paths and files to check\nCHECK_PATHS_AND_FILES=(\"apps/ssr/\" \"packages/\" \"apps/ssr/package.json\" \"pnpm-lock.yaml\" \"locales/common\" \"locales/errors\" \"locales/external\")\n\nONLY_SERVER_OR_PACKAGES_CHANGES=false\nfor file in $CHANGED_FILES; do\n  MATCH_FOUND=false\n  for path in \"${CHECK_PATHS_AND_FILES[@]}\"; do\n\n    if [[ \"$file\" =~ ^\"$path\" ]]; then\n\n      echo \"Match file: $file\"\n      MATCH_FOUND=true\n      break\n    fi\n  done\n  if [ \"$MATCH_FOUND\" = true ]; then\n    ONLY_SERVER_OR_PACKAGES_CHANGES=true\n    break\n  fi\ndone\n\nif [ \"$ONLY_SERVER_OR_PACKAGES_CHANGES\" = true ]; then\n  echo \"continue\"\n  exit 1\nelse\n  echo \"skip\"\n  exit\nfi\n"
  },
  {
    "path": "apps/ssr/scripts/upload-fonts-to-r2.ts",
    "content": "import { execSync } from \"node:child_process\"\nimport fs from \"node:fs\"\nimport { createRequire } from \"node:module\"\n\nimport path, { resolve } from \"pathe\"\n\nconst require = createRequire(import.meta.url)\n\nconst BUCKET = \"follow\"\nconst PREFIX = \"ssr-fonts\"\nconst ACCOUNT_ID = \"1f1d1678a2413a54c944b3081bab5c84\"\n\nconst snFontDepsPath = require.resolve(\"@fontsource/sn-pro\")\nconst snFontsDirPath = resolve(snFontDepsPath, \"../files\")\nconst snFontsDir = fs\n  .readdirSync(snFontsDirPath)\n  .filter((name) => !name.endsWith(\".woff2\") && !name.includes(\"italic\"))\n\nfor (const file of snFontsDir) {\n  const filePath = path.join(snFontsDirPath, file)\n  const key = `${PREFIX}/${file}`\n  console.info(`Uploading ${file}...`)\n  execSync(\n    `CLOUDFLARE_ACCOUNT_ID=${ACCOUNT_ID} npx wrangler r2 object put ${BUCKET}/${key} --file \"${filePath}\" --remote`,\n    { stdio: \"inherit\" },\n  )\n}\n\nconst koseFontPath = require.resolve(\"kose-font\")\nconsole.info(\"Uploading kose-font.ttf...\")\nexecSync(\n  `CLOUDFLARE_ACCOUNT_ID=${ACCOUNT_ID} npx wrangler r2 object put ${BUCKET}/${PREFIX}/kose-font.ttf --file \"${koseFontPath}\" --remote`,\n  { stdio: \"inherit\" },\n)\n\nconsole.info(\"All fonts uploaded successfully!\")\n"
  },
  {
    "path": "apps/ssr/src/global.d.ts",
    "content": "declare const __DEV__: boolean\n"
  },
  {
    "path": "apps/ssr/src/lib/api-client.ts",
    "content": "import \"./load-env\"\n\nimport { requestContext } from \"@fastify/request-context\"\nimport { env } from \"@follow/shared/env.ssr\"\nimport { createSSRAPIHeaders } from \"@follow/utils/headers\"\nimport { FollowClient } from \"@follow-app/client-sdk\"\nimport { ofetch } from \"ofetch\"\n\nimport PKG from \"../../../desktop/package.json\"\n\nconst getBaseURL = () => {\n  const req = requestContext.get(\"req\")!\n  const { host } = req.headers\n  let baseURL = env.VITE_API_URL\n\n  if (env.VITE_API_URL.startsWith(\"/\")) {\n    baseURL = `http://${host}${env.VITE_API_URL}`\n  }\n  return baseURL\n}\nexport const createApiFetch = () => {\n  const baseURL = getBaseURL()\n\n  return ofetch.create({\n    credentials: \"include\",\n    retry: false,\n    cache: \"no-store\",\n    onRequest(context) {\n      if (__DEV__) console.info(`request: ${context.request}`)\n\n      const header = new Headers(context.options.headers)\n\n      const headers = createSSRAPIHeaders({ version: PKG.version })\n\n      Object.entries(headers).forEach(([key, value]) => {\n        header.set(key, value)\n      })\n\n      context.options.headers = header\n    },\n    onRequestError(context) {\n      if (context.error.name === \"AbortError\") {\n        return\n      }\n    },\n    baseURL,\n  })\n}\n\nexport const createFollowClient = () => {\n  const authSessionToken = getTokenFromCookie(requestContext.get(\"req\")?.headers.cookie || \"\")\n\n  const baseURL = getBaseURL()\n\n  const client = new FollowClient({\n    credentials: \"include\",\n    timeout: 30000,\n    baseURL,\n    fetch: async (input: any, options = {}) => fetch(input.toString(), options),\n  })\n\n  client.addRequestInterceptor(async (ctx) => {\n    const { options } = ctx\n    const header = new Headers(options.headers)\n\n    const headers = createSSRAPIHeaders({ version: PKG.version })\n\n    Object.entries(headers).forEach(([key, value]) => {\n      header.set(key, value)\n    })\n\n    if (authSessionToken) {\n      header.set(\"Cookie\", `__Secure-better-auth.session_token=${authSessionToken}`)\n    }\n\n    options.headers = Object.fromEntries(header.entries())\n    return ctx\n  })\n\n  return client\n}\n\nexport const getTokenFromCookie = (cookie: string) => {\n  const parsedCookieMap = cookie\n    .split(\";\")\n    .map((item) => item.trim())\n    .reduce(\n      (acc, item) => {\n        const [key, value] = item.split(\"=\")\n        acc[key!] = value!\n        return acc\n      },\n      {} as Record<string, string>,\n    )\n  return parsedCookieMap[\"__Secure-better-auth.session_token\"]\n}\n"
  },
  {
    "path": "apps/ssr/src/lib/dev-vite.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport type { FastifyInstance } from \"fastify\"\nimport { dirname, resolve } from \"pathe\"\nimport type { ViteDevServer } from \"vite\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst root = resolve(__dirname, \"../..\")\n\nlet globalVite: ViteDevServer\nexport const registerDevViteServer = async (app: FastifyInstance) => {\n  const createViteServer = await import(\"vite\").then((m) => m.createServer)\n\n  const vite = await createViteServer({\n    server: { middlewareMode: true },\n    appType: \"custom\",\n\n    configFile: resolve(root, \"vite.config.mts\"),\n    envDir: root,\n  })\n  globalVite = vite\n\n  // @ts-ignore\n  app.use(vite.middlewares)\n  return vite\n}\n\nexport const getViteServer = () => {\n  return globalVite\n}\n"
  },
  {
    "path": "apps/ssr/src/lib/load-env.ts",
    "content": "import { config } from \"dotenv-flow\"\n\nconfig({\n  files: [\".env.development.local\", \".env\"],\n})\n"
  },
  {
    "path": "apps/ssr/src/lib/load-env.worker.ts",
    "content": "// No-op for Cloudflare Workers - environment variables are provided via wrangler config\nexport {}\n"
  },
  {
    "path": "apps/ssr/src/lib/not-found.ts",
    "content": "import { FetchError } from \"ofetch\"\n\nexport class NotFoundError extends Error {\n  constructor(reason: string) {\n    super(`Page not found: ${reason}`)\n  }\n}\nexport const callNotFound = (e: any) => {\n  if (e instanceof FetchError && e.status === 404) {\n    throw new NotFoundError(e.message)\n  }\n  throw e\n}\n"
  },
  {
    "path": "apps/ssr/src/lib/og/fonts.ts",
    "content": "import fs from \"node:fs\"\nimport { createRequire } from \"node:module\"\n\nimport path, { resolve } from \"pathe\"\n\nconst require = createRequire(import.meta.url)\n\nconst weights = [\n  {\n    name: \"Thin\",\n    weight: 100,\n  },\n  {\n    name: \"ExtraLight\",\n    weight: 200,\n  },\n  {\n    name: \"Light\",\n    weight: 300,\n  },\n  {\n    name: \"Regular\",\n    weight: 400,\n  },\n  {\n    name: \"Italic\",\n    weight: 400,\n  },\n  {\n    name: \"Medium\",\n    weight: 500,\n  },\n  {\n    name: \"SemiBold\",\n    weight: 600,\n  },\n  {\n    name: \"Bold\",\n    weight: 700,\n  },\n  {\n    name: \"ExtraBold\",\n    weight: 800,\n  },\n  {\n    name: \"Black\",\n    weight: 900,\n  },\n] as const\nlet fontsData = [] as any[]\n\nconst snFontDepsPath = require.resolve(\"@fontsource/sn-pro\")\nconst snFontsDirPath = resolve(snFontDepsPath, \"../files\")\nconst snFontsDir = fs\n  .readdirSync(snFontsDirPath)\n  .filter((name) => !name.endsWith(\".woff2\") && !name.includes(\"italic\"))\n\nfontsData = snFontsDir.map((file) => {\n  const weight = weights.find((weight) => file.includes(weight.weight.toString()))\n  if (!weight) {\n    return null\n  }\n  return {\n    name: `SN Pro`,\n    data: fs.readFileSync(path.join(snFontsDirPath, file)),\n    weight: weight.weight,\n    style: file.includes(\"Italic\") ? \"italic\" : (\"normal\" as \"italic\" | \"normal\"),\n  }\n})\n\nconst koseFontPath = require.resolve(\"kose-font\")\n\nconst koseFontData = fs.readFileSync(koseFontPath)\n\nfontsData.push({\n  name: \"Kose\",\n  data: koseFontData,\n  weight: 400,\n  style: \"normal\" as \"italic\" | \"normal\",\n})\n\n// if (isDev) {\n//   const fontDepsPath = require.resolve(\"@fontsource/sn-pro\")\n//   const fontsDirPath = resolve(fontDepsPath, \"../files\")\n//   const fontsDir = fs\n//     .readdirSync(fontsDirPath)\n//     .filter((name) => !name.endsWith(\".woff2\") && !name.includes(\"italic\"))\n\n//   fontsData = fontsDir.map((file) => ({\n//     name: file.split(\"-\")[0],\n//     data: fs.readFileSync(path.join(fontsDirPath, file)),\n//     weight: weights.find((weight) => weight.name === file.split(\"-\")[1])?.weight,\n//     style: file.includes(\"Italic\") ? \"italic\" : (\"normal\" as \"italic\" | \"normal\"),\n//   }))\n// } else {\n//   const { default: fontsBase64Data } = require(\"../../.generated/fonts-data\")\n\n//   for (const fontName in fontsBase64Data) {\n//     fontsData.push({\n//       name: fontName.split(\"-\")[0],\n//       weight: weights.find((weight) => weight.name === fontName.split(\"-\")[1])?.weight,\n//       style: fontName.includes(\"Italic\") ? \"italic\" : (\"normal\" as \"italic\" | \"normal\"),\n//       data: Buffer.from(fontsBase64Data[fontName], \"base64\"),\n//     })\n//   }\n// }\n\nexport default fontsData\n"
  },
  {
    "path": "apps/ssr/src/lib/og/fonts.worker.ts",
    "content": "import fontsBase64Data from \"../../../.generated/fonts-data\"\n\nlet cachedFonts: any[] | null = null\nlet koseFont: any | null = null\n\n// Global reference to R2 bucket, set by worker-entry.ts\nlet _fontsBucket: R2Bucket | null = null\nexport function setFontsBucket(bucket: R2Bucket) {\n  _fontsBucket = bucket\n}\n\nfunction decodeSNProFonts(): any[] {\n  const fontsData: any[] = []\n  for (const [key, base64] of Object.entries(fontsBase64Data)) {\n    if (key === \"kose-400\") continue\n    const weightStr = key.split(\"-\").pop()!\n    const weight = Number.parseInt(weightStr)\n    const buf = Uint8Array.from(atob(base64), (c) => c.codePointAt(0)!)\n    fontsData.push({\n      name: \"SN Pro\",\n      data: buf.buffer,\n      weight,\n      style: \"normal\" as const,\n    })\n  }\n  return fontsData\n}\n\nasync function loadKoseFont(): Promise<any | null> {\n  if (koseFont) return koseFont\n  if (!_fontsBucket) {\n    console.warn(\"R2 fonts bucket not configured, skipping kose-font\")\n    return null\n  }\n  try {\n    const obj = await _fontsBucket.get(\"ssr-fonts/kose-font.ttf\")\n    if (!obj) {\n      console.warn(\"Kose font not found in R2\")\n      return null\n    }\n    const data = await obj.arrayBuffer()\n    koseFont = {\n      name: \"Kose\",\n      data,\n      weight: 400,\n      style: \"normal\" as const,\n    }\n    return koseFont\n  } catch (e) {\n    console.error(\"Failed to load kose font from R2:\", e)\n    return null\n  }\n}\n\nexport async function getFonts(): Promise<any[]> {\n  if (cachedFonts) return cachedFonts\n\n  const snProFonts = decodeSNProFonts()\n  const kose = await loadKoseFont()\n\n  cachedFonts = kose ? [...snProFonts, kose] : snProFonts\n  return cachedFonts\n}\n\n// Default export for compatibility with original fonts module API\nexport default [] as any[]\n"
  },
  {
    "path": "apps/ssr/src/lib/og/render-to-image.ts",
    "content": "import { Resvg } from \"@resvg/resvg-js\"\nimport type { ReactElement } from \"react\"\nimport type { SatoriOptions } from \"satori\"\nimport satori from \"satori\"\n\nimport fonts from \"./fonts\"\n\nexport async function renderToImage(\n  node: ReactElement,\n  options: {\n    width?: number\n    height: number\n    debug?: boolean\n    fonts?: SatoriOptions[\"fonts\"]\n  },\n) {\n  const svg = await satori(node, {\n    ...options,\n    fonts: options.fonts || fonts,\n  })\n\n  const w = new Resvg(svg)\n  const image = w.render().asPng()\n\n  return {\n    image,\n    contentType: \"image/png\",\n  }\n}\n"
  },
  {
    "path": "apps/ssr/src/lib/og/render-to-image.worker.ts",
    "content": "import type { ReactElement } from \"react\"\nimport type { SatoriOptions } from \"satori\"\nimport satori from \"satori\"\n\nimport { getFonts } from \"./fonts.worker\"\nimport { ensureInitialized, Resvg } from \"./resvg-wasm-shim\"\n\nexport async function renderToImage(\n  node: ReactElement,\n  options: {\n    width?: number\n    height: number\n    debug?: boolean\n    fonts?: SatoriOptions[\"fonts\"]\n  },\n) {\n  await ensureInitialized()\n\n  const fonts = options.fonts || (await getFonts())\n\n  const svg = await satori(node, {\n    ...options,\n    fonts,\n  })\n\n  const w = new Resvg(svg)\n  const image = w.render().asPng()\n\n  return {\n    image,\n    contentType: \"image/png\",\n  }\n}\n"
  },
  {
    "path": "apps/ssr/src/lib/og/resvg-wasm-shim.ts",
    "content": "import { initWasm } from \"@resvg/resvg-wasm\"\n\nlet initialized = false\n\nlet _wasmModule: WebAssembly.Module | null = null\n\nexport function setWasmModule(mod: WebAssembly.Module) {\n  _wasmModule = mod\n}\n\nasync function ensureInitialized() {\n  if (!initialized) {\n    if (_wasmModule) {\n      await initWasm(_wasmModule)\n    }\n    initialized = true\n  }\n}\n\nexport { ensureInitialized }\n\nexport { Resvg } from \"@resvg/resvg-wasm\"\n"
  },
  {
    "path": "apps/ssr/src/lib/seo.ts",
    "content": "import xss from \"xss\"\n\nexport function buildSeoMetaTags(\n  document: Document,\n  configs: {\n    openGraph: {\n      title: string\n      description?: string\n      image?: string | null\n    }\n  },\n) {\n  const openGraph = {\n    title: xss(configs.openGraph.title),\n    description: xss(configs.openGraph.description ?? \"\"),\n    image: xss(configs.openGraph.image ?? \"\"),\n  }\n\n  const createMeta = (property: string, content: string) => {\n    const $meta = document.createElement(\"meta\")\n    $meta.setAttribute(\"property\", property)\n    $meta.setAttribute(\"content\", content)\n    return $meta\n  }\n\n  return [\n    createMeta(\"og:title\", openGraph.title),\n    createMeta(\"og:description\", openGraph.description),\n    createMeta(\"og:image\", openGraph.image),\n    createMeta(\"twitter:card\", \"summary_large_image\"),\n    createMeta(\"twitter:title\", openGraph.title),\n    createMeta(\"twitter:description\", openGraph.description),\n    createMeta(\"twitter:image\", openGraph.image),\n  ]\n}\n"
  },
  {
    "path": "apps/ssr/src/lib/worker-request-context.ts",
    "content": "import { AsyncLocalStorage } from \"node:async_hooks\"\n\n// Shim for @fastify/request-context that works in Workers\n// Uses AsyncLocalStorage to provide per-request context\n\nconst storage = new AsyncLocalStorage<Map<string, any>>()\n\nexport const requestContext = {\n  get(key: string) {\n    const store = storage.getStore()\n    return store?.get(key)\n  },\n  set(key: string, value: any) {\n    const store = storage.getStore()\n    store?.set(key, value)\n  },\n}\n\nexport function runWithRequestContext<T>(fn: () => T | Promise<T>): T | Promise<T> {\n  const store = new Map<string, any>()\n  return storage.run(store, fn)\n}\n\n// Provide a req-like object with requestContext for compatibility\nexport function createRequestProxy(\n  url: string,\n  headers: Record<string, string>,\n  params: Record<string, string> = {},\n) {\n  return {\n    originalUrl: url,\n    headers,\n    params,\n    requestContext: {\n      get(key: string) {\n        return requestContext.get(key)\n      },\n      set(key: string, value: any) {\n        requestContext.set(key, value)\n      },\n    },\n  }\n}\n"
  },
  {
    "path": "apps/ssr/src/meta-handler.map.ts",
    "content": "// This file is generated by `pnpm run meta`\nimport i0 from \"../client/pages/(login)/login/metadata\"\nimport i3 from \"../client/pages/(main)/share/feeds/[id]/metadata\"\nimport i2 from \"../client/pages/(main)/share/lists/[id]/metadata\"\nimport i1 from \"../client/pages/(main)/share/users/[id]/metadata\"\n\nexport default {\n  \"/login\": i0,\n  \"/share/users/:id\": i1,\n  \"/share/lists/:id\": i2,\n  \"/share/feeds/:id\": i3,\n}\n"
  },
  {
    "path": "apps/ssr/src/meta-handler.ts",
    "content": "import { env } from \"@follow/shared/env.ssr\"\nimport type { FollowClient } from \"@follow-app/client-sdk\"\nimport type { FastifyReply, FastifyRequest } from \"fastify\"\nimport { match } from \"path-to-regexp\"\n\nimport { createFollowClient } from \"./lib/api-client\"\nimport importer from \"./meta-handler.map\"\n\ninterface MetaTagdata {\n  type: \"meta\"\n  property: string\n  content: string\n}\n\ninterface MetaOpenGraph {\n  type: \"openGraph\"\n  title: string\n  description?: string\n  image?: string | null\n}\n\ninterface MetaTitle {\n  type: \"title\"\n  title: string\n}\n\ninterface MetaDescription {\n  type: \"description\"\n  description: string\n}\n\ninterface MetaHydrateData {\n  type: \"hydrate\"\n  data: any\n\n  key: string\n}\nexport type MetaTag = MetaTagdata | MetaOpenGraph | MetaTitle | MetaHydrateData | MetaDescription\n\nexport async function injectMetaHandler(\n  req: FastifyRequest,\n  res: FastifyReply,\n): Promise<MetaTag[]> {\n  const apiClient = createFollowClient()\n  const webOrigin = env.VITE_WEB_URL\n  const url = req.originalUrl\n\n  for (const [pattern, handler] of Object.entries(importer)) {\n    const matchFn = match(pattern, { decode: decodeURIComponent })\n    const parsedUrl = new URL(url, webOrigin)\n    const result = matchFn(parsedUrl.pathname)\n\n    if (result) {\n      return (await handler({\n        params: result.params as Record<string, string>,\n        searchParams: parsedUrl.searchParams,\n        url: parsedUrl,\n        req,\n        apiClient,\n        origin: webOrigin,\n        setStatus(status) {\n          res.status(status)\n        },\n        setStatusText(statusText) {\n          res.raw.statusMessage = statusText\n        },\n        throwError(status, message) {\n          throw new MetaError(status, message)\n        },\n      })) as MetaTag[]\n    }\n  }\n\n  return []\n}\n\nexport function defineMetadata<Params extends Record<string, string>, T extends readonly MetaTag[]>(\n  fn: (args: {\n    req: FastifyRequest\n    url: URL\n    params: Params\n    apiClient: FollowClient\n    searchParams: URLSearchParams\n    origin: string\n    setStatus: (status: number) => void\n    setStatusText: (statusText: string) => void\n    throwError: (status: number, message: any) => never\n  }) => Promise<T> | T,\n) {\n  return fn\n}\nexport class MetaError extends Error {\n  status: number\n  metaMessage: object\n  constructor(status: number, message: object) {\n    super(\"Meta Error\")\n    this.status = status\n    this.metaMessage = message\n  }\n}\n"
  },
  {
    "path": "apps/ssr/src/router/global.ts",
    "content": "import { readFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport { env } from \"@follow/shared/env.ssr\"\nimport type { FastifyInstance, FastifyReply, FastifyRequest } from \"fastify\"\nimport { minify } from \"html-minifier-terser\"\nimport { parseHTML } from \"linkedom\"\nimport { FetchError } from \"ofetch\"\nimport path, { dirname, resolve } from \"pathe\"\nimport xss from \"xss\"\n\nimport { NotFoundError } from \"../lib/not-found\"\nimport { buildSeoMetaTags } from \"../lib/seo\"\nimport { injectMetaHandler, MetaError } from \"../meta-handler\"\n\nconst devHandler = (app: FastifyInstance) => {\n  app.get(\"*\", async (req, reply) => {\n    const url = req.originalUrl\n    const __dirname = dirname(fileURLToPath(import.meta.url))\n\n    const root = resolve(__dirname, \"../..\")\n\n    const vite = await import(\"../lib/dev-vite\").then((m) => m.getViteServer())\n    try {\n      let template = readFileSync(path.resolve(root, vite.config.root, \"index.html\"), \"utf-8\")\n      template = await vite.transformIndexHtml(url, template)\n      const { document } = parseHTML(template)\n      await safeInjectMetaToTemplate(document, req, reply)\n\n      reply.type(\"text/html\")\n      reply.send(document.toString())\n    } catch (e) {\n      vite.ssrFixStacktrace(e as Error)\n      reply.code(500).send(e)\n    }\n  })\n}\nconst prodHandler = (app: FastifyInstance) => {\n  app.get(\"*\", async (req, reply) => {\n    // @ts-ignore\n    const template = await import(\"../../.generated/index.template\").then((m) => m.default)\n    const { document } = parseHTML(template)\n    await safeInjectMetaToTemplate(document, req, reply)\n\n    const scriptContent = `function injectEnv(env2) {\n    for (const key in env2) {\n      if (env2[key] === void 0) continue;\n      globalThis[\"__followEnv\"] ??= {};\n      globalThis[\"__followEnv\"][key] = env2[key];\n    }\n  }\ninjectEnv({\"VITE_API_URL\":\"${env.VITE_API_URL}\",\"VITE_WEB_URL\":\"${env.VITE_WEB_URL}\"})`\n    const $script = document.createElement(\"script\")\n    $script.innerHTML = scriptContent\n    document.head.prepend($script)\n\n    reply.type(\"text/html\")\n    reply.send(\n      await minify(document.toString(), {\n        removeComments: true,\n        html5: true,\n        minifyJS: true,\n        minifyCSS: true,\n        removeTagWhitespace: true,\n        collapseWhitespace: true,\n        collapseBooleanAttributes: true,\n        collapseInlineTagWhitespace: true,\n      }),\n    )\n  })\n}\nexport const globalRoute = __DEV__ ? devHandler : prodHandler\n\nasync function safeInjectMetaToTemplate(\n  document: Document,\n  req: FastifyRequest,\n  res: FastifyReply,\n) {\n  try {\n    return await injectMetaToTemplate(document, req, res)\n  } catch (e) {\n    console.error(\"inject meta error\", e)\n\n    if (e instanceof NotFoundError) {\n      res.code(404)\n      document.documentElement.dataset.notFound = \"true\"\n      return document\n    }\n\n    if (e instanceof FetchError && e.response?.status) {\n      res.code(e.response.status)\n    }\n\n    if (e instanceof MetaError) {\n      throw e\n    }\n    return document\n  }\n}\n\nasync function injectMetaToTemplate(document: Document, req: FastifyRequest, res: FastifyReply) {\n  const injectMetadata = await injectMetaHandler(req, res)\n\n  if (!injectMetadata) {\n    return document\n  }\n\n  for (const meta of injectMetadata) {\n    switch (meta.type) {\n      case \"openGraph\": {\n        const $metaArray = buildSeoMetaTags(document, { openGraph: meta })\n        for (const $meta of $metaArray) {\n          document.head.append($meta)\n        }\n        break\n      }\n      case \"meta\": {\n        // Find Old Meta\n        const $oldMeta = document.querySelector(`meta[name=\"${meta.property}\"]`)\n        if ($oldMeta) {\n          $oldMeta.setAttribute(\"content\", xss(meta.content))\n        } else {\n          const $meta = document.createElement(\"meta\")\n          $meta.setAttribute(\"name\", meta.property)\n          $meta.setAttribute(\"content\", xss(meta.content))\n          document.head.append($meta)\n        }\n        break\n      }\n      case \"title\": {\n        if (meta.title) {\n          const $title = document.querySelector(\"title\")\n          if ($title) {\n            $title.textContent = `${xss(meta.title)} | Folo`\n          } else {\n            const $head = document.querySelector(\"head\")\n            if ($head) {\n              const $title = document.createElement(\"title\")\n              $title.textContent = `${xss(meta.title)} | Folo`\n              $head.append($title)\n            }\n          }\n        }\n        break\n      }\n      case \"description\": {\n        const $meta = document.createElement(\"meta\")\n        $meta.setAttribute(\"name\", \"description\")\n        $meta.setAttribute(\"content\", xss(meta.description))\n        document.head.append($meta)\n        break\n      }\n      case \"hydrate\": {\n        // Insert hydrate script\n        const script = document.createElement(\"script\")\n        script.innerHTML = `\n          window.__HYDRATE__ = window.__HYDRATE__ || {}\n          window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})\n        `\n        document.head.append(script)\n        break\n      }\n    }\n  }\n\n  return document\n}\n"
  },
  {
    "path": "apps/ssr/src/router/og/__base.tsx",
    "content": "import { getBackgroundGradient } from \"@follow/utils/color\"\nimport * as React from \"react\"\n\nexport const OGCanvas = ({ children, seed }: { children: React.ReactNode; seed: string }) => {\n  const [bgAccent, bgAccentLight] = getBackgroundGradient(seed)\n\n  return (\n    <div\n      style={{\n        background: \"#0a0f1a\",\n        width: \"100%\",\n        height: \"100%\",\n        display: \"flex\",\n        position: \"relative\",\n        fontFamily: \"SN Pro\",\n        color: \"#e6edf3\",\n      }}\n    >\n      {/* Background Grid Pattern */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          width: \"100%\",\n          height: \"100%\",\n          background: `\n            linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),\n            linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)\n          `,\n          backgroundSize: \"40px 40px\",\n        }}\n      />\n      {/* Background Glows */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          width: \"100%\",\n          height: \"100%\",\n          background: `\n            radial-gradient(circle at 15% 20%, ${bgAccent}20 0%, transparent 40%),\n            radial-gradient(circle at 85% 80%, ${bgAccentLight}15 0%, transparent 40%)\n          `,\n        }}\n      />\n\n      {/* Main layout container */}\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          width: \"100%\",\n          height: \"100%\",\n          padding: \"40px 60px\",\n          gap: 40,\n        }}\n      >\n        {/* Header */}\n        <div\n          style={{\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            alignItems: \"center\",\n            width: \"100%\",\n          }}\n        >\n          {/* Follow Logo */}\n          <div style={{ display: \"flex\", alignItems: \"center\", gap: 16 }}>\n            <FollowIcon />\n            <LogoText />\n          </div>\n\n          {/* AI RSS */}\n          <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n            <span style={{ fontSize: 20, color: \"#afb8c1\" }}>Follow everything in one place</span>\n            <RSSIcon color={\"#F26522\"} />\n          </div>\n        </div>\n\n        {/* Content */}\n        <div\n          style={{\n            flex: 1,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            width: \"100%\",\n          }}\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction RSSIcon({ color }: { color: string }) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 24 24\">\n      <path\n        fill={color}\n        d=\"M7.23.01c-2.685.14-4.2.637-5.364 1.76C.835 2.761.339 3.981.089 6.13.01 6.82 0 7.46 0 12.001s.01 5.183.089 5.87c.25 2.149.746 3.369 1.777 4.362 1.02.983 2.167 1.435 4.263 1.678C6.817 23.99 7.458 24 12 24s5.183-.01 5.87-.089c2.097-.243 3.244-.695 4.264-1.678 1.031-.993 1.527-2.213 1.777-4.362.079-.687.089-1.328.089-5.87s-.01-5.182-.089-5.87c-.25-2.149-.746-3.369-1.777-4.362C21.124.795 19.975.34 17.923.096 17.3.023 16.535.01 12.38.002A437 437 0 0 0 7.23.01m1.395 5.7c3.14.345 5.935 1.97 7.721 4.49 1.345 1.896 2.064 4.273 1.985 6.554-.019.545-.042.69-.14.893a1.35 1.35 0 0 1-.783.672c-.32.095-.416.094-.735-.009a1.24 1.24 0 0 1-.767-.687c-.1-.234-.114-.345-.107-.878.03-2.43-.778-4.474-2.426-6.116-1.657-1.652-3.7-2.462-6.118-2.427-.534.008-.642-.005-.875-.106-.474-.206-.739-.615-.739-1.143 0-.62.383-1.096 1.005-1.247.23-.056 1.461-.053 1.979.004m-.522 4.494a7.32 7.319 0 0 1 2.855 1.18 8.71 8.709 0 0 1 1.659 1.658c.775 1.091 1.248 2.51 1.27 3.81.007.381-.012.55-.08.705-.194.447-.7.785-1.174.785-.473 0-.985-.341-1.168-.78-.046-.11-.103-.452-.127-.761-.094-1.195-.47-2.025-1.292-2.847-.822-.821-1.652-1.198-2.847-1.292-.627-.05-.877-.132-1.13-.372a1.25 1.25 0 0 1-.293-1.43c.187-.401.496-.64.971-.754.151-.035.852.015 1.356.098m.219 4.505c.397.182.789.57.971.965.125.267.144.372.144.766s-.02.499-.142.761a2.15 2.15 0 0 1-.97.973c-.266.125-.371.144-.766.144-.393 0-.498-.02-.76-.142a2.15 2.15 0 0 1-.974-.97c-.124-.266-.143-.371-.143-.766 0-.405.018-.493.157-.786.346-.727 1.009-1.133 1.798-1.1.283.012.459.052.685.155\"\n      />\n    </svg>\n  )\n}\n\nfunction FollowIcon() {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 24 24\">\n      <path\n        fill=\"#ff5c00\"\n        d=\"M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z\"\n      />\n      <path\n        fill=\"#fff\"\n        d=\"M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z\"\n      />\n    </svg>\n  )\n}\n\nfunction LogoText() {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 24 24\">\n      <path\n        fill=\"currentColor\"\n        d=\"M.899 16.997c-.567 0-.899-.358-.899-.994v-7.77c0-.637.36-.996 1.01-.996h4.34c.595 0 .927.29.927.788 0 .497-.332.774-.926.774H1.797v2.336H5.06c.595 0 .927.263.927.76 0 .512-.332.775-.927.775H1.797v3.332c0 .636-.318.996-.898.996m9.035.125c-2.101 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.13 0 3.567 1.534 3.567 3.705 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185m5.266 1.367c-.553 0-.857-.359-.857-.967V7.845c0-.608.304-.968.857-.968s.857.36.857.968v8.185c0 .608-.29.967-.857.967m5.234.125c-2.102 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.129 0 3.566 1.534 3.566 3.704 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185\"\n      />\n    </svg>\n  )\n}\n\nexport async function getImageBase64(image: string | null | undefined) {\n  if (!image) {\n    return null\n  }\n\n  const url = new URL(image)\n  return await fetch(image, {\n    headers: {\n      \"User-Agent\":\n        \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36\",\n      Referer: url.origin,\n    },\n  }).then(async (res) => {\n    const isImage = res.headers.get(\"content-type\")?.startsWith(\"image/\")\n    if (isImage) {\n      const arrayBuffer = await res.arrayBuffer()\n\n      return `data:${res.headers.get(\"content-type\")};base64,${Buffer.from(arrayBuffer).toString(\"base64\")}`\n    }\n    return null\n  })\n}\n\nexport const OGAvatar: React.FC<{ base64?: Nullable<string>; title: string }> = ({\n  base64,\n  title,\n}) => {\n  const [, , , bgAccent, bgAccentLight] = getBackgroundGradient(title)\n  return (\n    <>\n      {base64 ? (\n        <div\n          style={{\n            position: \"relative\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <img\n            src={base64}\n            style={{\n              width: 160,\n              height: 160,\n              borderRadius: \"50%\",\n              border: `3px solid ${bgAccentLight}50`,\n              boxShadow: `0 0 25px ${bgAccent}40`,\n            }}\n          />\n        </div>\n      ) : (\n        <div\n          style={{\n            position: \"relative\",\n            width: 160,\n            height: 160,\n            borderRadius: \"50%\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            background: `linear-gradient(135deg, ${bgAccent} 0%, ${bgAccentLight} 100%)`,\n            border: `3px solid ${bgAccentLight}50`,\n            boxShadow: `0 0 25px ${bgAccent}40`,\n          }}\n        >\n          <span\n            style={{\n              fontSize: 64,\n              fontWeight: 700,\n              color: \"white\",\n              letterSpacing: \"-0.02em\",\n            }}\n          >\n            {title?.[0]}\n          </span>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "apps/ssr/src/router/og/feed.tsx",
    "content": "import { getFeedIconSrc } from \"@follow/components/utils/icon.js\"\nimport { formatNumber } from \"@follow/utils\"\nimport type { FollowClient } from \"@follow-app/client-sdk\"\nimport * as React from \"react\"\n\nimport { renderToImage } from \"../../lib/og/render-to-image\"\nimport { getImageBase64, OGAvatar, OGCanvas } from \"./__base\"\n\nexport const renderFeedOG = async (apiClient: FollowClient, feedId: string) => {\n  const feed = await apiClient.api.feeds.get({ id: feedId }).catch(() => null)\n\n  if (!feed?.data.feed) {\n    throw 404\n  }\n\n  const { title, description, image } = feed.data.feed\n\n  const [src] = getFeedIconSrc({\n    siteUrl: feed.data.feed.siteUrl!,\n    proxy: {\n      width: 256,\n      height: 256,\n    },\n    fallback: true,\n    src: image!,\n  })\n\n  const imageBase64 = await getImageBase64(image || src)\n\n  try {\n    const imageRes = await renderToImage(\n      <OGCanvas seed={title!}>\n        <div\n          style={{\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            textAlign: \"center\",\n            gap: 15,\n            padding: \"0 60px\",\n          }}\n        >\n          <OGAvatar base64={imageBase64} title={title!} />\n          <div\n            style={{\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              gap: 10,\n              overflow: \"hidden\",\n            }}\n          >\n            <h3\n              style={{\n                color: \"#e6edf3\",\n                fontSize: \"3.2rem\",\n                fontWeight: 600,\n                margin: 0,\n              }}\n            >\n              {title}\n            </h3>\n            {description && (\n              <p\n                style={{\n                  fontSize: \"1.8rem\",\n                  color: \"#8b949e\",\n                  margin: 0,\n                  maxHeight: \"5.4rem\",\n                  lineHeight: 1.5,\n                  overflow: \"hidden\",\n                }}\n              >\n                {description}\n              </p>\n            )}\n          </div>\n          <p\n            style={{\n              fontSize: \"1.5rem\",\n              color: \"#afb8c1\",\n              fontWeight: 500,\n              margin: 0,\n              paddingTop: 10,\n            }}\n          >\n            {formatNumber(feed.data.subscriptionCount || 0)} followers with{\" \"}\n            {formatNumber(feed.data.readCount || 0)} recent reads on Folo\n          </p>\n        </div>\n      </OGCanvas>,\n      {\n        width: 1200,\n        height: 600,\n      },\n    )\n\n    return imageRes\n  } catch (err) {\n    console.error(err)\n    return null\n  }\n}\n"
  },
  {
    "path": "apps/ssr/src/router/og/index.ts",
    "content": "import { Readable } from \"node:stream\"\n\nimport type { FastifyInstance, FastifyReply } from \"fastify\"\n\nimport { createFollowClient } from \"../../lib/api-client\"\nimport { renderFeedOG } from \"./feed\"\nimport { renderListOG } from \"./list\"\nimport { renderUserOG } from \"./user\"\n\nexport const ogRoute = (app: FastifyInstance) => {\n  app.get(\"/og/:type/:id\", async (req, reply) => {\n    const { type, id } = req.params as Record<string, string>\n\n    const apiClient = createFollowClient()\n    let imageRes: {\n      image: Buffer\n      contentType: string\n    } | null = null\n    const errorFallback = createErrorFallback(reply)\n\n    switch (type) {\n      case \"feed\": {\n        imageRes = await renderFeedOG(apiClient, id!).catch(errorFallback)\n\n        break\n      }\n      case \"user\": {\n        imageRes = await renderUserOG(apiClient, id!).catch(errorFallback)\n        break\n      }\n      case \"list\": {\n        imageRes = await renderListOG(apiClient, id!).catch(errorFallback)\n        break\n      }\n      default: {\n        return reply.code(404).send(\"Not found\")\n      }\n    }\n\n    if (!imageRes) {\n      if (!reply.sent) {\n        return reply.code(404).send(\"Not found\")\n      }\n      return\n    }\n\n    const stream = new Readable({\n      read() {\n        this.push(imageRes.image)\n        this.push(null)\n      },\n    })\n\n    reply.type(imageRes.contentType).headers({\n      \"Cache-Control\": \"max-age=3600, s-maxage=3600, stale-while-revalidate=600\",\n      \"Cloudflare-CDN-Cache-Control\": \"max-age=3600, s-maxage=3600, stale-while-revalidate=600\",\n      \"CDN-Cache-Control\": \"max-age=3600, s-maxage=3600, stale-while-revalidate=600\",\n    })\n    return reply.send(stream)\n  })\n}\n\nconst createErrorFallback = (reply: FastifyReply) => (code: number | Error) => {\n  if (typeof code !== \"number\" && code instanceof Error) {\n    reply.code(500).send(code.message)\n    return null\n  }\n  let message = \"Internal server error\"\n  if (code === 404) {\n    message = \"Not found\"\n  }\n  reply.code(code).send(message)\n  return null\n}\n"
  },
  {
    "path": "apps/ssr/src/router/og/list.tsx",
    "content": "import { getFeedIconSrc } from \"@follow/components/utils/icon.js\"\nimport type { FollowClient } from \"@follow-app/client-sdk\"\nimport * as React from \"react\"\n\nimport { renderToImage } from \"../../lib/og/render-to-image\"\nimport { getImageBase64, OGAvatar, OGCanvas } from \"./__base\"\n\nexport const renderListOG = async (apiClient: FollowClient, listId: string) => {\n  const feed = await apiClient.api.lists.get({ listId }).catch(() => null)\n\n  if (!feed?.data.list) {\n    throw 404\n  }\n\n  const { title, description, image } = feed.data.list\n\n  const [src] = getFeedIconSrc({\n    proxy: {\n      width: 256,\n      height: 256,\n    },\n    fallback: true,\n    src: image!,\n  })\n\n  const numberFormatter = new Intl.NumberFormat(\"en-US\")\n  let imageBase64: string | null = null\n\n  if (src) {\n    imageBase64 = await getImageBase64(src)\n  }\n\n  try {\n    const imageRes = await renderToImage(\n      <OGCanvas seed={title!}>\n        <div\n          style={{\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            textAlign: \"center\",\n            gap: 15,\n            padding: \"0 60px\",\n          }}\n        >\n          <OGAvatar base64={imageBase64} title={title!} />\n          <div\n            style={{\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              gap: 10,\n              overflow: \"hidden\",\n            }}\n          >\n            <h3\n              style={{\n                color: \"#e6edf3\",\n                fontSize: \"3.2rem\",\n                fontWeight: 600,\n                margin: 0,\n              }}\n            >\n              {title}\n            </h3>\n            {description && (\n              <p\n                style={{\n                  fontSize: \"1.8rem\",\n                  color: \"#8b949e\",\n                  margin: 0,\n                  maxHeight: \"5.4rem\",\n                  lineHeight: 1.5,\n                  overflow: \"hidden\",\n                }}\n              >\n                {description}\n              </p>\n            )}\n          </div>\n          <p\n            style={{\n              fontSize: \"1.5rem\",\n              color: \"#afb8c1\",\n              fontWeight: 500,\n              margin: 0,\n              paddingTop: 10,\n            }}\n          >\n            {numberFormatter.format(feed.data.subscriptionCount)} followers with{\" \"}\n            {numberFormatter.format(feed.data.readCount)} recent reads on Folo\n          </p>\n        </div>\n      </OGCanvas>,\n      {\n        width: 1200,\n        height: 600,\n      },\n    )\n\n    return imageRes\n  } catch (err) {\n    console.error(err)\n    return null\n  }\n}\n"
  },
  {
    "path": "apps/ssr/src/router/og/user.tsx",
    "content": "import { isBizId } from \"@follow/utils/utils\"\nimport type { FollowClient } from \"@follow-app/client-sdk\"\nimport * as React from \"react\"\n\nimport { renderToImage } from \"../../lib/og/render-to-image\"\nimport { getImageBase64, OGAvatar, OGCanvas } from \"./__base\"\n\nexport const renderUserOG = async (apiClient: FollowClient, id: string) => {\n  const handle = isBizId(id || \"\") ? id : `${id}`.startsWith(\"@\") ? `${id}`.slice(1) : id\n\n  const user = await apiClient.api.profiles.getProfile({\n    id,\n    handle,\n  })\n\n  if (!user) {\n    throw 404\n  }\n\n  const imageBase64 = await getImageBase64(user.data.image)\n\n  return await renderToImage(\n    <OGCanvas seed={user.data.id}>\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          width: \"100%\",\n          height: \"100%\",\n          gap: 25,\n        }}\n      >\n        <OGAvatar base64={imageBase64} title={user.data.name!} />\n\n        <div\n          style={{\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n          }}\n        >\n          <h3\n            style={{\n              color: \"#ffffff\",\n              fontSize: \"4.5rem\",\n              fontWeight: 700,\n              margin: 0,\n              letterSpacing: \"-0.02em\",\n            }}\n          >\n            {user.data.name}\n          </h3>\n          {user.data.handle && (\n            <p\n              style={{\n                fontSize: \"2.5rem\",\n                color: \"#8b949e\",\n                margin: 0,\n                marginTop: 12,\n              }}\n            >\n              @{user.data.handle}\n            </p>\n          )}\n        </div>\n      </div>\n    </OGCanvas>,\n    {\n      width: 1200,\n      height: 600,\n    },\n  )\n}\n"
  },
  {
    "path": "apps/ssr/tailwind.config.ts",
    "content": "import { extendConfig } from \"@follow/configs/tailwindcss/web\"\nimport daisyui from \"daisyui\"\nimport type { Config } from \"tailwindcss\"\nimport { withUIKit } from \"tailwindcss-uikit-colors/macos\"\n\n/** @type {import('tailwindcss').Config} */\nexport default withUIKit(\n  extendConfig({\n    content: [\n      \"./client/**/*.{ts,tsx}\",\n      \"./index.html\",\n      \"./node_modules/@follow/components/**/*.{ts,tsx}\",\n      \"./node_modules/rc-modal-sheet/**/*.{js,ts,tsx}\",\n      \"../../node_modules/rc-modal-sheet/**/*.{js,ts,tsx}\",\n      \"../../packages/**/*.{ts,tsx}\",\n    ],\n    plugins: [daisyui as unknown as NonNullable<Config[\"plugins\"]>[number]],\n    daisyui: {\n      logs: false,\n      darkTheme: \"dark\",\n      themes: [\n        {\n          light: {\n            \"color-scheme\": \"light\",\n\n            primary: \"#007AFF\", //#0A84FF\n            accent: \"#FF5C00\",\n            \"accent-content\": \"#fff\",\n            neutral: \"#212427\",\n\n            \"base-100\": \"#fff\",\n            \"base-content\": \"#0C0A09\",\n\n            info: \"#32ADE6\", // 50 173 230\n            success: \"#34C759\",\n            warning: \"#ff9f0a\",\n            error: \"#ff453a\", // 255 59 48\n\n            \"--rounded-btn\": \"1.9rem\",\n            \"--tab-border\": \"2px\",\n            \"--tab-radius\": \".5rem\",\n          },\n          dark: {\n            \"color-scheme\": \"dark\",\n\n            primary: \"#0A84FF\",\n            accent: \"#FF5C00\",\n            \"accent-content\": \"#fff\",\n            neutral: \"#2a2a2a\",\n            \"base-100\": \"#121212\",\n            \"base-content\": \"#FAFAF9\",\n\n            info: \"#32ADE6\", // 50 173 230\n            success: \"#34C759\",\n            warning: \"#ff9f0a\",\n            error: \"#ff453a\", // 255 59 48\n\n            \"--rounded-btn\": \"1.9rem\",\n            \"--tab-border\": \"2px\",\n            \"--tab-radius\": \".5rem\",\n          },\n        },\n      ],\n    },\n  }),\n)\n"
  },
  {
    "path": "apps/ssr/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"target\": \"ES2022\",\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true,\n    \"noEmit\": true,\n    \"moduleResolution\": \"Bundler\",\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react\",\n    \"incremental\": true,\n    \"types\": [\"vite/client\", \"@follow/types/react\", \"@follow/types/global\"],\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@src/*\": [\"./src/*\"],\n      \"@client/*\": [\"./client/*\"],\n      \"@locales/*\": [\"../../locales/*\"]\n    }\n  },\n  \"include\": [\n    \"./src/**/*.ts\",\n    \"./src/**/*.tsx\",\n    \"./index.ts\",\n    \"./api/index.ts\",\n    \"./scripts/**/*.ts\",\n    \"tsdown.config.ts\",\n    \"./client/**/*.ts\",\n    \"./client/**/*.tsx\",\n    \"vite.config.mts\",\n    \"./tailwind.config.ts\",\n    \"./helper/**/*.ts\"\n  ],\n  \"exclude\": [\"node_modules\", \"./src/**/*.worker.ts\"]\n}\n"
  },
  {
    "path": "apps/ssr/tsdown.config.ts",
    "content": "import { createReadStream, createWriteStream, readdirSync, renameSync } from \"node:fs\"\nimport { createRequire } from \"node:module\"\n\nimport { resolve } from \"pathe\"\nimport { defineConfig } from \"tsdown\"\n\nexport default defineConfig({\n  entry: [\"./index.ts\"],\n  outDir: \"dist/server\",\n\n  clean: true,\n  format: [\"esm\"],\n  external: [\"lightningcss\", \"vite\"],\n  inlineOnly: false,\n  treeshake: true,\n\n  define: {\n    __DEV__: JSON.stringify(process.env.NODE_ENV === \"development\"),\n  },\n\n  hooks(hooks) {\n    hooks.hook(\"build:done\", async () => {\n      if (process.env.VERCEL !== \"1\") return\n\n      const outputFile = \"dist/server/index.mjs\"\n      const tempFile = `${outputFile}.tmp`\n\n      try {\n        const insertCode = `try {\nconst noop = () => {}\nawait import(\"@fontsource/sn-pro\").then(noop)\nawait import('kose-font').then(noop)\nawait import('kose-font/fonts/KosefontP-JP.ttf').then(noop)\nawait import('kose-font/fonts/Kosefont-JP.ttf').then(noop)\n${(() => {\n  const require = createRequire(import.meta.url)\n  const fontDepsPath = require.resolve(\"@fontsource/sn-pro\")\n  const fontsDirPath = resolve(fontDepsPath, \"../files\")\n  return readdirSync(fontsDirPath)\n    .map((file) => `require.resolve(\"@fontsource/sn-pro/files/${file}\")`)\n    .join(\"\\n\")\n})()}\n} catch {}\n      `\n\n        const writeStream = createWriteStream(tempFile)\n\n        if (insertCode) {\n          writeStream.write(insertCode)\n        }\n\n        const readStream = createReadStream(outputFile)\n\n        await new Promise<void>((resolve, reject) => {\n          readStream.pipe(writeStream)\n          writeStream.on(\"finish\", () => resolve())\n          writeStream.on(\"error\", reject)\n          readStream.on(\"error\", reject)\n        })\n\n        renameSync(tempFile, outputFile)\n\n        console.info(\"Successfully inserted font dependencies into\", outputFile)\n      } catch (error) {\n        console.error(\"Failed to modify output file:\", error)\n      }\n    })\n  },\n})\n"
  },
  {
    "path": "apps/ssr/tsdown.worker.config.ts",
    "content": "import { dirname, resolve } from \"pathe\"\nimport { defineConfig } from \"tsdown\"\n\nconst __dirname = dirname(import.meta.url.replace(\"file://\", \"\"))\n\nexport default defineConfig({\n  entry: [\"./worker-entry.ts\"],\n  outDir: \"dist/worker\",\n\n  clean: true,\n  format: [\"esm\"],\n  external: [\"node:*\", /\\.wasm$/],\n  noExternal: [\"**\"],\n  inlineOnly: false,\n  treeshake: true,\n  splitting: false,\n\n  alias: {\n    \"./src/lib/og/render-to-image\": \"./src/lib/og/render-to-image.worker\",\n    \"./src/lib/og/fonts\": \"./src/lib/og/fonts.worker\",\n    \"../../lib/og/render-to-image\": \"../../lib/og/render-to-image.worker\",\n    \"./src/lib/load-env\": \"./src/lib/load-env.worker\",\n    \"@fastify/request-context\": resolve(__dirname, \"src/lib/worker-request-context.ts\"),\n  },\n\n  define: {\n    __DEV__: JSON.stringify(false),\n  },\n})\n"
  },
  {
    "path": "apps/ssr/vercel.json",
    "content": "{\n  \"rewrites\": [\n    {\n      \"source\": \"/((?!external-dist|dist-external).*)\",\n      \"destination\": \"/api\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/ssr/vite.config.mts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nimport react from \"@vitejs/plugin-react\"\nimport { codeInspectorPlugin } from \"code-inspector-plugin\"\nimport { dirname, resolve } from \"pathe\"\nimport { defineConfig } from \"vite\"\nimport { routeBuilderPlugin } from \"vite-plugin-route-builder\"\n\nimport { viteRenderBaseConfig } from \"../desktop/configs/vite.render.config\"\nimport { astPlugin } from \"../desktop/plugins/vite/ast\"\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\nexport default defineConfig({\n  resolve: {\n    alias: {\n      \"@pkg\": resolve(__dirname, \"../../package.json\"),\n      \"@client\": resolve(__dirname, \"./client\"),\n      \"@locales\": resolve(__dirname, \"../../locales\"),\n    },\n  },\n  define: {\n    ...viteRenderBaseConfig.define,\n    ELECTRON: \"false\",\n  },\n  build: {\n    rollupOptions: {\n      output: {\n        assetFileNames: \"dist-external/[name].[hash].[ext]\",\n        chunkFileNames: \"dist-external/[name].[hash].js\",\n        entryFileNames: \"dist-external/[name].[hash].js\",\n      },\n    },\n  },\n  plugins: [\n    routeBuilderPlugin({\n      pagePattern: \"client/pages/**/*.tsx\",\n      outputPath: \"client/generated-routes.ts\",\n      enableInDev: true,\n      segmentGroupOrder: [\"(main)\", \"(login)\"],\n    }),\n    react(),\n    astPlugin,\n    codeInspectorPlugin({\n      bundler: \"vite\",\n      editor: \"cursor\",\n      hotKeys: [\"altKey\"],\n    }),\n  ],\n\n  server: {\n    proxy: {\n      \"/api\": {\n        target: \"https://api.follow.is\",\n        changeOrigin: true,\n        rewrite(path) {\n          return path.replace(\"/api\", \"\")\n        },\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "apps/ssr/worker-app.ts",
    "content": "import { fastifyRequestContext } from \"@fastify/request-context\"\nimport type { FastifyRequest } from \"fastify\"\nimport Fastify from \"fastify\"\nimport { nanoid } from \"nanoid\"\nimport { FetchError } from \"ofetch\"\n\nimport { MetaError } from \"./src/meta-handler\"\nimport { globalRoute } from \"./src/router/global\"\nimport { ogRoute } from \"./src/router/og\"\n\ndeclare module \"@fastify/request-context\" {\n  interface RequestContextData {\n    req: FastifyRequest\n  }\n}\n\nexport const createApp = () => {\n  const app = Fastify({})\n\n  // Test: minimal route to verify Fastify works in Workers\n  app.get(\"/healthz\", async () => ({ ok: true }))\n\n  app.register(fastifyRequestContext)\n\n  app.after(() => {\n    app.setErrorHandler(function (err, req, reply) {\n      this.log.error(err)\n\n      const traceId = nanoid(8)\n\n      if (err instanceof FetchError) {\n        reply\n          .status((err as FetchError).response?.status || 500)\n          .send({ ok: false, traceId, message: err.message })\n      } else if (err instanceof MetaError) {\n        reply.status(err.status).send({ ok: false, traceId, message: err.metaMessage })\n      } else {\n        const message = (err as any).message || \"Internal Server Error\"\n        const status = Number.parseInt((err as any).code as string) || 500\n        reply.status(status).send({ ok: false, message, traceId })\n      }\n    })\n\n    app.addHook(\"onRequest\", (req, reply, done) => {\n      req.requestContext.set(\"req\", req)\n\n      const { host } = req.headers\n\n      const forwardedHost = req.headers[\"x-forwarded-host\"]\n      const finalHost = forwardedHost || host\n\n      reply.header(\"x-handled-host\", finalHost)\n      done()\n    })\n\n    ogRoute(app)\n    globalRoute(app)\n  })\n\n  return app\n}\n"
  },
  {
    "path": "apps/ssr/worker-entry.ts",
    "content": "import \"./global\"\n\n// Global route dependencies\nimport { env } from \"@follow/shared/env.ssr\"\nimport { Hono } from \"hono\"\nimport { minify } from \"html-minifier-terser\"\nimport { parseHTML } from \"linkedom\"\nimport { FetchError } from \"ofetch\"\nimport xss from \"xss\"\n\n// @ts-expect-error - WASM import handled by Wrangler\nimport resvgWasm from \"./resvg.wasm\"\n// OG image rendering\nimport { createFollowClient } from \"./src/lib/api-client\"\nimport { NotFoundError } from \"./src/lib/not-found\"\nimport { setFontsBucket } from \"./src/lib/og/fonts.worker\"\nimport { setWasmModule } from \"./src/lib/og/resvg-wasm-shim\"\nimport { buildSeoMetaTags } from \"./src/lib/seo\"\nimport {\n  createRequestProxy,\n  requestContext,\n  runWithRequestContext,\n} from \"./src/lib/worker-request-context\"\nimport { injectMetaHandler, MetaError } from \"./src/meta-handler\"\nimport { renderFeedOG } from \"./src/router/og/feed\"\nimport { renderListOG } from \"./src/router/og/list\"\nimport { renderUserOG } from \"./src/router/og/user\"\n\nObject.assign(globalThis, {\n  __DEV__: false,\n})\n\n// Initialize WASM module\nsetWasmModule(resvgWasm)\n\ninterface Env {\n  FONTS_BUCKET: R2Bucket\n  ASSETS: Fetcher\n  VITE_API_URL: string\n  VITE_WEB_URL: string\n  VITE_SENTRY_DSN: string\n}\n\nconst app = new Hono<{ Bindings: Env }>()\n\nlet envInitialized = false\n\n// Redirects (migrated from vercel.json)\napp.get(\"/feed/:id\", (c) => {\n  return c.redirect(`/share/feeds/${c.req.param(\"id\")}`, 301)\n})\napp.get(\"/list/:id\", (c) => {\n  return c.redirect(`/share/lists/${c.req.param(\"id\")}`, 301)\n})\napp.get(\"/profile/:path{.*}\", (c) => {\n  return c.redirect(`/share/users/${c.req.param(\"path\")}`, 301)\n})\n\n// Middleware: set up env vars and request context\napp.use(\"*\", async (c, next) => {\n  if (!envInitialized) {\n    const bindings = c.env\n    for (const [key, value] of Object.entries(bindings)) {\n      if (typeof value === \"string\") {\n        process.env[key] = value\n      }\n    }\n    if (bindings.FONTS_BUCKET) {\n      setFontsBucket(bindings.FONTS_BUCKET)\n    }\n    envInitialized = true\n  }\n\n  return runWithRequestContext(async () => {\n    const host = c.req.header(\"host\") || \"\"\n    const forwardedHost = c.req.header(\"x-forwarded-host\")\n    const finalHost = forwardedHost || host\n\n    // Create a req-like proxy for compatibility with existing modules\n    const headers: Record<string, string> = {}\n    c.req.raw.headers.forEach((value, key) => {\n      headers[key] = value\n    })\n\n    const reqProxy = createRequestProxy(\n      c.req.path + (c.req.raw.url.includes(\"?\") ? `?${c.req.raw.url.split(\"?\")[1]}` : \"\"),\n      headers,\n    )\n\n    // Set request context values\n    requestContext.set(\"req\", reqProxy)\n\n    await next()\n\n    c.header(\"x-handled-host\", finalHost)\n  })\n})\n\n// OG image route\napp.get(\"/og/:type/:id\", async (c) => {\n  const type = c.req.param(\"type\")\n  const id = c.req.param(\"id\")\n\n  const apiClient = createFollowClient()\n  let imageRes: { image: Buffer; contentType: string } | null = null\n\n  try {\n    switch (type) {\n      case \"feed\": {\n        imageRes = await renderFeedOG(apiClient, id)\n        break\n      }\n      case \"user\": {\n        imageRes = await renderUserOG(apiClient, id)\n        break\n      }\n      case \"list\": {\n        imageRes = await renderListOG(apiClient, id)\n        break\n      }\n      default: {\n        return c.text(\"Not found\", 404)\n      }\n    }\n  } catch (e: any) {\n    if (typeof e === \"number\") {\n      return c.text(e === 404 ? \"Not found\" : \"Internal server error\", e)\n    }\n    console.error(\"OG render error:\", e)\n    return c.text(e?.message || \"Internal server error\", 500)\n  }\n\n  if (!imageRes) {\n    return c.text(\"Not found\", 404)\n  }\n\n  return new Response(imageRes.image, {\n    headers: {\n      \"Content-Type\": imageRes.contentType,\n      \"Cache-Control\": \"max-age=3600, s-maxage=3600, stale-while-revalidate=600\",\n      \"Cloudflare-CDN-Cache-Control\": \"max-age=3600, s-maxage=3600, stale-while-revalidate=600\",\n      \"CDN-Cache-Control\": \"max-age=3600, s-maxage=3600, stale-while-revalidate=600\",\n    },\n  })\n})\n\n// SSR routes - use SSR template with meta injection\n// These routes correspond to the vercel.json rewrites to follow-external-ssr\nconst ssrHandler = async (c: any) => {\n  // @ts-ignore - dynamic import of generated template\n  const template = await import(\"./.generated/index.template\").then((m) => m.default)\n  const { document } = parseHTML(template)\n\n  // Inject meta tags\n  try {\n    await injectMetaToTemplate(document, c)\n  } catch (e) {\n    console.error(\"inject meta error\", e)\n\n    if (e instanceof NotFoundError) {\n      c.status(404)\n      document.documentElement.dataset.notFound = \"true\"\n    } else if (e instanceof FetchError && e.response?.status) {\n      c.status(e.response.status as any)\n    } else if (e instanceof MetaError) {\n      return c.json({ ok: false, message: e.metaMessage }, e.status as any)\n    }\n  }\n\n  injectEnvToDocument(document)\n\n  const html = await minify(document.toString(), {\n    removeComments: true,\n    html5: true,\n    minifyJS: true,\n    minifyCSS: true,\n    removeTagWhitespace: true,\n    collapseWhitespace: true,\n    collapseBooleanAttributes: true,\n    collapseInlineTagWhitespace: true,\n  })\n\n  return c.html(html)\n}\n\napp.get(\"/share/*\", ssrHandler)\napp.get(\"/login\", ssrHandler)\napp.get(\"/register\", ssrHandler)\napp.get(\"/forget-password\", ssrHandler)\napp.get(\"/reset-password\", ssrHandler)\n\n// SPA catch-all - fetch index.html from Assets and inject env vars\napp.get(\"*\", async (c) => {\n  const assetResponse = await c.env.ASSETS.fetch(new Request(\"http://fakehost/index.html\"))\n  const spaHtml = await assetResponse.text()\n  const { document } = parseHTML(spaHtml)\n\n  injectEnvToDocument(document)\n\n  const html = document.toString()\n  return c.html(html)\n})\n\n// Error handling\napp.onError((err, c) => {\n  console.error(err)\n\n  if (err instanceof FetchError) {\n    return c.json({ ok: false, message: err.message }, (err.response?.status as any) || 500)\n  }\n  if (err instanceof MetaError) {\n    return c.json({ ok: false, message: err.metaMessage }, err.status as any)\n  }\n\n  return c.json({ ok: false, message: err.message || \"Internal Server Error\" }, 500)\n})\n\nexport default app\n\n// Helper: inject env vars into HTML document\nfunction injectEnvToDocument(document: any) {\n  const scriptContent = `function injectEnv(env2) {\n    for (const key in env2) {\n      if (env2[key] === void 0) continue;\n      globalThis[\"__followEnv\"] ??= {};\n      globalThis[\"__followEnv\"][key] = env2[key];\n    }\n  }\ninjectEnv({\"VITE_API_URL\":\"${env.VITE_API_URL}\",\"VITE_WEB_URL\":\"${env.VITE_WEB_URL}\"})`\n  const $script = document.createElement(\"script\")\n  $script.innerHTML = scriptContent\n  document.head.prepend($script)\n}\n\n// Helper: inject meta tags into HTML document\nasync function injectMetaToTemplate(document: Document, c: any) {\n  // Create a req/res proxy compatible with injectMetaHandler\n  const reqProxy = requestContext.get(\"req\")\n  const resProxy = {\n    status(code: number) {\n      c.status(code)\n    },\n    raw: { statusMessage: \"\" },\n  }\n\n  const injectMetadata = await injectMetaHandler(reqProxy as any, resProxy as any)\n\n  if (!injectMetadata) return document\n\n  for (const meta of injectMetadata) {\n    switch (meta.type) {\n      case \"openGraph\": {\n        const $metaArray = buildSeoMetaTags(document, { openGraph: meta })\n        for (const $meta of $metaArray) {\n          document.head.append($meta)\n        }\n        break\n      }\n      case \"meta\": {\n        const $oldMeta = document.querySelector(`meta[name=\"${meta.property}\"]`)\n        if ($oldMeta) {\n          $oldMeta.setAttribute(\"content\", xss(meta.content))\n        } else {\n          const $meta = document.createElement(\"meta\")\n          $meta.setAttribute(\"name\", meta.property)\n          $meta.setAttribute(\"content\", xss(meta.content))\n          document.head.append($meta)\n        }\n        break\n      }\n      case \"title\": {\n        if (meta.title) {\n          const $title = document.querySelector(\"title\")\n          if ($title) {\n            $title.textContent = `${xss(meta.title)} | Folo`\n          } else {\n            const $head = document.querySelector(\"head\")\n            if ($head) {\n              const $title = document.createElement(\"title\")\n              $title.textContent = `${xss(meta.title)} | Folo`\n              $head.append($title)\n            }\n          }\n        }\n        break\n      }\n      case \"description\": {\n        const $meta = document.createElement(\"meta\")\n        $meta.setAttribute(\"name\", \"description\")\n        $meta.setAttribute(\"content\", xss(meta.description))\n        document.head.append($meta)\n        break\n      }\n      case \"hydrate\": {\n        const script = document.createElement(\"script\")\n        script.innerHTML = `\n          window.__HYDRATE__ = window.__HYDRATE__ || {}\n          window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})\n        `\n        document.head.append(script)\n        break\n      }\n    }\n  }\n\n  return document\n}\n"
  },
  {
    "path": "apps/ssr/wrangler.jsonc",
    "content": "{\n  \"$schema\": \"./node_modules/wrangler/config-schema.json\",\n  \"name\": \"folo-ssr\",\n  \"main\": \"dist/worker/worker-entry.mjs\",\n  \"compatibility_date\": \"2026-02-01\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"account_id\": \"1f1d1678a2413a54c944b3081bab5c84\",\n  \"placement\": {\n    \"mode\": \"smart\",\n  },\n  \"limits\": {\n    \"cpu_ms\": 30000,\n  },\n  \"rules\": [\n    {\n      \"type\": \"CompiledWasm\",\n      \"globs\": [\"**/*.wasm\"],\n      \"fallthrough\": true,\n    },\n  ],\n  \"assets\": {\n    \"directory\": \"../desktop/out/web\",\n    \"not_found_handling\": \"none\",\n    \"html_handling\": \"none\",\n    \"binding\": \"ASSETS\",\n  },\n  \"workers_dev\": true,\n  \"routes\": [\n    {\n      \"pattern\": \"app.folo.is/share/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/og/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/login*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/register*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/forget-password*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/reset-password*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/feed/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/list/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n    {\n      \"pattern\": \"app.folo.is/profile/*\",\n      \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n    },\n  ],\n  \"r2_buckets\": [\n    {\n      \"binding\": \"FONTS_BUCKET\",\n      \"bucket_name\": \"follow\",\n    },\n  ],\n  \"vars\": {\n    \"VITE_API_URL\": \"https://api.folo.is\",\n    \"VITE_WEB_URL\": \"https://app.folo.is\",\n    \"VITE_SENTRY_DSN\": \"https://e5bccf7428aa4e881ed5cb713fdff181@o4507542488023040.ingest.us.sentry.io/4507570439979008\",\n  },\n  \"env\": {\n    \"dev\": {\n      \"name\": \"folo-ssr-dev\",\n      \"workers_dev\": true,\n      \"routes\": [\n        {\n          \"pattern\": \"dev.folo.is/share/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/og/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/login*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/register*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/forget-password*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/reset-password*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/feed/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/list/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n        {\n          \"pattern\": \"dev.folo.is/profile/*\",\n          \"zone_id\": \"115ea8e6a7865dbfc1cf4530d5f87f63\",\n        },\n      ],\n      \"r2_buckets\": [\n        {\n          \"binding\": \"FONTS_BUCKET\",\n          \"bucket_name\": \"follow\",\n        },\n      ],\n      \"vars\": {\n        \"VITE_API_URL\": \"https://api.dev.folo.is\",\n        \"VITE_WEB_URL\": \"https://dev.folo.is\",\n        \"VITE_SENTRY_DSN\": \"https://e5bccf7428aa4e881ed5cb713fdff181@o4507542488023040.ingest.us.sentry.io/4507570439979008\",\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "changelogithub.config.ts",
    "content": "export default {\n  tagFilter: (tag: string) =>\n    (tag.startsWith(\"mobile/v\") || tag.startsWith(\"desktop/v\")) && !tag.includes(\"nightly\"),\n  dry: !process.env.CI,\n}\n"
  },
  {
    "path": "conductor.json",
    "content": "{\n  \"scripts\": {\n    \"setup\": \"pnpm i; cp $CONDUCTOR_ROOT_PATH/apps/desktop/.env.local apps/desktop/.env.local\",\n    \"run\": \"pnpm dev:web\"\n  }\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "// @ts-check\nimport { fixupPluginRules } from \"@eslint/compat\"\nimport { defineConfig } from \"eslint-config-hyoban\"\nimport reactNative from \"eslint-plugin-react-native\"\nimport path from \"pathe\"\n\nimport checkI18nJson from \"./plugins/eslint/eslint-check-i18n-json.js\"\nimport noDebug from \"./plugins/eslint/eslint-no-debug.js\"\nimport packageJsonExtend from \"./plugins/eslint/eslint-package-json.js\"\nimport recursiveSort from \"./plugins/eslint/eslint-recursive-sort.js\"\n\nexport default defineConfig(\n  {\n    formatting: false,\n    lessOpinionated: true,\n    ignores: [\n      \"resources/**\",\n      \"apps/mobile/android/**\",\n      \"apps/mobile/ios/**\",\n      \"apps/mobile/.expo\",\n      \"apps/mobile/native/build/**\",\n      \"**/generated-routes.ts\",\n    ],\n    preferESM: false,\n    tailwindCSS: {\n      order: false,\n    },\n  },\n  {\n    settings: {\n      tailwindcss: {\n        whitelist: [\"center\"],\n      },\n    },\n    plugins: {\n      \"no-debug\": noDebug,\n    },\n    rules: {\n      \"no-debug/no-debug-stack\": \"error\",\n      \"tailwindcss/classnames-order\": \"off\",\n      \"tailwindcss/enforces-negative-arbitrary-values\": \"off\",\n      \"tailwindcss/enforces-shorthand\": \"off\",\n      \"tailwindcss/migration-from-tailwind-2\": \"off\",\n      \"tailwindcss/no-arbitrary-value\": \"off\",\n      \"tailwindcss/no-contradicting-classname\": \"off\",\n      \"tailwindcss/no-custom-classname\": \"off\",\n      \"tailwindcss/no-unnecessary-arbitrary-value\": \"off\",\n      \"@eslint-react/no-clone-element\": 0,\n      \"@eslint-react/hooks-extra/no-direct-set-state-in-use-effect\": 0,\n      \"@eslint-react/dom/no-flush-sync\": 1,\n      \"@eslint-react/hooks-extra/no-unnecessary-use-callback\": \"warn\",\n      \"unicorn/no-array-callback-reference\": 0,\n      \"no-restricted-syntax\": 0,\n      \"no-restricted-globals\": [\n        \"error\",\n        {\n          name: \"location\",\n          message:\n            \"Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \\n\\n\" +\n            \"You can use `useLocaltion` or `getReadonlyRoute` to get the route info.\",\n        },\n      ],\n\n      // disable react compiler rules for now\n      \"react-hooks/no-unused-directives\": \"off\",\n      \"react-hooks/static-components\": \"off\",\n      \"react-hooks/use-memo\": \"off\",\n      \"react-hooks/component-hook-factories\": \"off\",\n      \"react-hooks/preserve-manual-memoization\": \"off\",\n      \"react-hooks/immutability\": \"off\",\n      \"react-hooks/globals\": \"off\",\n      \"react-hooks/refs\": \"off\",\n      \"react-hooks/set-state-in-effect\": \"off\",\n      \"react-hooks/error-boundaries\": \"off\",\n      \"react-hooks/purity\": \"off\",\n      \"react-hooks/set-state-in-render\": \"off\",\n      \"react-hooks/unsupported-syntax\": \"off\",\n      \"react-hooks/config\": \"off\",\n      \"react-hooks/gating\": \"off\",\n\n      \"unicorn/require-module-specifiers\": \"off\",\n    },\n  },\n  // use correct tailwind config for eslint\n  {\n    settings: {\n      tailwindcss: {\n        config: path.join(import.meta.dirname, \"apps/desktop/tailwind.config.ts\"),\n      },\n    },\n  },\n  {\n    files: [\"apps/ssr/**/*\"],\n    settings: {\n      tailwindcss: {\n        config: path.join(import.meta.dirname, \"apps/ssr/tailwind.config.ts\"),\n      },\n    },\n  },\n  {\n    files: [\"apps/mobile/**/*\"],\n    settings: {\n      tailwindcss: {\n        config: path.join(import.meta.dirname, \"apps/mobile/tailwind.config.ts\"),\n      },\n    },\n  },\n  {\n    files: [\"**/*.tsx\"],\n    rules: {\n      \"@stylistic/jsx-self-closing-comp\": \"error\",\n    },\n  },\n  // @ts-expect-error\n  {\n    files: [\"locales/**/*.json\"],\n    plugins: {\n      \"recursive-sort\": recursiveSort,\n      \"check-i18n-json\": checkI18nJson,\n    },\n    rules: {\n      \"recursive-sort/recursive-sort\": \"error\",\n      \"check-i18n-json/valid-i18n-keys\": \"error\",\n      \"check-i18n-json/no-extra-keys\": \"error\",\n    },\n  },\n  {\n    files: [\"package.json\", \"apps/**/package.json\", \"packages/**/package.json\"],\n    plugins: {\n      \"package-json-extend\": packageJsonExtend,\n    },\n    rules: {\n      \"package-json-extend/ensure-package-version\": \"error\",\n      \"package-json-extend/no-duplicate-package\": \"error\",\n      \"package-json/require-type\": 0,\n    },\n  },\n  {\n    files: [\"**/*.{js,ts,tsx}\"],\n    rules: {\n      \"no-restricted-imports\": [\n        \"error\",\n        {\n          paths: [\n            {\n              name: \"node:path\",\n              message:\n                \"For better cross-platform compatibility, please use 'pathe' instead of 'node:path'\",\n            },\n          ],\n        },\n      ],\n    },\n  },\n  {\n    plugins: {\n      // @ts-expect-error\n      \"react-native\": fixupPluginRules(reactNative),\n    },\n    files: [\"apps/mobile/**/*\"],\n    rules: {\n      \"react-native/no-inline-styles\": \"warn\",\n    },\n  },\n)\n"
  },
  {
    "path": "locales/ai/en.json",
    "content": "{\n  \"ai_summary\": \"AI Summary\",\n  \"analytics.chart_placeholder\": \"Chart visualization will be implemented with recharts\",\n  \"analytics.efficiency_analysis\": \"Efficiency Analysis\",\n  \"analytics.efficiency_placeholder\": \"Model efficiency comparison and optimization suggestions\",\n  \"analytics.history_operation\": \"{{operation}} operation\",\n  \"analytics.history_usage\": \"AI Credits usage\",\n  \"analytics.no_data\": \"No usage data available for the selected period\",\n  \"analytics.no_history\": \"No usage history available\",\n  \"analytics.operation_types.chat\": \"Chat\",\n  \"analytics.operation_types.onboarding\": \"Onboarding\",\n  \"analytics.operation_types.task\": \"Task\",\n  \"analytics.operation_types.title_generation\": \"Title Generation\",\n  \"analytics.patterns_placeholder\": \"Pattern analysis will show peak usage times and habits\",\n  \"analytics.tabs.efficiency\": \"Efficiency\",\n  \"analytics.tabs.history\": \"History\",\n  \"analytics.tabs.overview\": \"Overview\",\n  \"analytics.tabs.patterns\": \"Patterns\",\n  \"analytics.trend.operations\": \"Operations\",\n  \"analytics.trend.title\": \"Usage Trends (Last 30 Days)\",\n  \"analytics.trend.tokens\": \"Credits Used\",\n  \"analytics.usage_history\": \"Usage History\",\n  \"analytics.usage_patterns\": \"Usage Patterns\",\n  \"analytics.usage_trends\": \"Usage Trends (Last 30 Days)\",\n  \"byok.description\": \"Use your own API keys for AI providers to have more control over costs and usage.\",\n  \"byok.enabled\": \"Enable BYOK\",\n  \"byok.providers.add\": \"Add Provider\",\n  \"byok.providers.add_title\": \"Add BYOK Provider\",\n  \"byok.providers.added\": \"Provider added successfully\",\n  \"byok.providers.configured\": \"Configured\",\n  \"byok.providers.delete\": \"Delete provider\",\n  \"byok.providers.delete_message\": \"Are you sure you want to delete this provider? This action cannot be undone.\",\n  \"byok.providers.delete_title\": \"Delete Provider\",\n  \"byok.providers.deleted\": \"Provider deleted successfully\",\n  \"byok.providers.edit\": \"Edit provider\",\n  \"byok.providers.edit_title\": \"Edit BYOK Provider\",\n  \"byok.providers.empty.description\": \"Add your own API keys for AI providers to use your own infrastructure.\",\n  \"byok.providers.empty.title\": \"No Providers Configured\",\n  \"byok.providers.form.api_key\": \"API Key\",\n  \"byok.providers.form.api_key_help\": \"Your API key will be stored securely and encrypted.\",\n  \"byok.providers.form.api_key_placeholder\": \"Enter your API key\",\n  \"byok.providers.form.base_url\": \"Base URL\",\n  \"byok.providers.form.base_url_help\": \"Optional. Leave empty to use the default API endpoint.\",\n  \"byok.providers.form.base_url_placeholder\": \"e.g. https://api.openai.com/v1\",\n  \"byok.providers.form.provider\": \"Provider\",\n  \"byok.providers.headers_count\": \"header(s)\",\n  \"byok.providers.no_api_key\": \"No API key set\",\n  \"byok.providers.title\": \"Providers\",\n  \"byok.providers.updated\": \"Provider updated successfully\",\n  \"byok.title\": \"Bring Your Own Key (BYOK)\",\n  \"chat.history.auto_title\": \"{{- datetime}} chat\",\n  \"clear_chat\": \"Clear Chat\",\n  \"clear_chat_message\": \"Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.\",\n  \"common.generate_title\": \"Generate Title\",\n  \"common.generating_title\": \"Generating title...\",\n  \"common.new_chat\": \"New Chat\",\n  \"customize_shortcuts\": \"Customize shortcuts\",\n  \"delete_chat\": \"Delete Chat\",\n  \"delete_chat_error\": \"Failed to delete chat\",\n  \"delete_chat_message\": \"Are you sure you want to delete \\\"{{title}}\\\"? This action cannot be undone.\",\n  \"delete_chat_success\": \"Chat deleted successfully\",\n  \"features.title\": \"Features\",\n  \"integration.mcp.description\": \"Connect to MCP-compatible services that extend AI capabilities through secure OAuth integration.\",\n  \"integration.mcp.enabled\": \"Enable MCP Services\",\n  \"integration.mcp.security.description\": \"OAuth authentication tokens are securely stored using AES-GCM-256 encryption to protect your credentials and ensure data privacy.\",\n  \"integration.mcp.security.title\": \"Security Disclosure\",\n  \"integration.mcp.service.active\": \"Active\",\n  \"integration.mcp.service.added\": \"MCP service added successfully\",\n  \"integration.mcp.service.auth_message\": \"This service requires OAuth authorization. Click 'Open Authorization' to proceed with authentication in a new window.\",\n  \"integration.mcp.service.auth_required\": \"Authorization Required\",\n  \"integration.mcp.service.auth_window_opened\": \"Authorization window opened. Please complete authentication.\",\n  \"integration.mcp.service.baseUrl\": \"Service Base URL\",\n  \"integration.mcp.service.baseUrl_placeholder\": \"e.g. https://api.example.com\",\n  \"integration.mcp.service.connect\": \"Connect\",\n  \"integration.mcp.service.connected\": \"Connected\",\n  \"integration.mcp.service.connected_success\": \"Successfully connected to MCP service\",\n  \"integration.mcp.service.connecting\": \"Connecting...\",\n  \"integration.mcp.service.connection_failed\": \"Failed to connect to MCP service\",\n  \"integration.mcp.service.degraded\": \"Degraded\",\n  \"integration.mcp.service.delete_message\": \"Are you sure you want to delete this MCP service? This action cannot be undone.\",\n  \"integration.mcp.service.delete_title\": \"Delete MCP Service\",\n  \"integration.mcp.service.deleted\": \"MCP service deleted successfully\",\n  \"integration.mcp.service.disabled\": \"Disabled\",\n  \"integration.mcp.service.disconnect\": \"Disconnect\",\n  \"integration.mcp.service.disconnected\": \"Disconnected\",\n  \"integration.mcp.service.discover\": \"Discover Service\",\n  \"integration.mcp.service.discovery_failed\": \"Failed to discover service endpoints\",\n  \"integration.mcp.service.enabled\": \"Enabled\",\n  \"integration.mcp.service.endpoints\": \"OAuth Endpoints\",\n  \"integration.mcp.service.error\": \"Connection Error\",\n  \"integration.mcp.service.healthy\": \"Healthy\",\n  \"integration.mcp.service.inactive\": \"Inactive\",\n  \"integration.mcp.service.name\": \"Service Name\",\n  \"integration.mcp.service.name_placeholder\": \"e.g. GitHub Tools, Slack Integration\",\n  \"integration.mcp.service.open_auth\": \"Open Authorization\",\n  \"integration.mcp.service.open_manually\": \"Open Authorization\",\n  \"integration.mcp.service.popup_blocked\": \"Authorization popup was blocked by your browser\",\n  \"integration.mcp.service.reconnect\": \"Reconnect\",\n  \"integration.mcp.service.scopes\": \"Required Scopes\",\n  \"integration.mcp.service.unhealthy\": \"Unhealthy\",\n  \"integration.mcp.service.updated\": \"MCP service updated successfully\",\n  \"integration.mcp.service.validation.baseUrl_required\": \"Base URL is required\",\n  \"integration.mcp.service.validation.invalid_url\": \"Please enter a valid URL\",\n  \"integration.mcp.service.validation.name_required\": \"Service name is required\",\n  \"integration.mcp.services.add\": \"Add Service\",\n  \"integration.mcp.services.empty.description\": \"Connect MCP services to extend AI capabilities with external tools, APIs, and data sources.\",\n  \"integration.mcp.services.empty.title\": \"No MCP Services Connected\",\n  \"integration.mcp.services.title\": \"MCP Services\",\n  \"integration.security.description\": \"OAuth authentication tokens and AI BYOK API keys are securely stored using AES-GCM-256 encryption to protect your credentials and ensure data privacy.\",\n  \"integration.security.title\": \"Security Disclosure\",\n  \"integration.title\": \"Integration\",\n  \"memories.actions.load_more\": \"Load more\",\n  \"memories.actions.loading\": \"Loading...\",\n  \"memories.importance\": \"Importance\",\n  \"memories.list.description\": \"The AI learns about you through conversations. These memories help it understand you better over time.\",\n  \"memories.list.empty.description\": \"As you chat with the AI, it will gradually learn about your preferences and habits. Memories will appear here.\",\n  \"memories.list.empty.title\": \"No impressions yet\",\n  \"memories.section.title\": \"AI's Memory of You\",\n  \"memories.toast.deleted\": \"Memory deleted\",\n  \"memories.toast.failed\": \"Failed to save memory\",\n  \"memories.toast.processing\": \"Processing...\",\n  \"memories.toast.saved\": \"Memory saved\",\n  \"memories.toast.saving\": \"Saving memory...\",\n  \"memories.toast.updated\": \"Memory updated\",\n  \"memories.toast.updating\": \"Updating memory...\",\n  \"mentions.date.relative.last_15_days.label\": \"Last 15 days\",\n  \"mentions.date.relative.last_15_days.search\": \"last 15 days|past 15 days|last half month|past half month\",\n  \"mentions.date.relative.last_30_days.label\": \"Last 30 days\",\n  \"mentions.date.relative.last_30_days.search\": \"last 30 days|past 30 days\",\n  \"mentions.date.relative.last_3_days.label\": \"Last 3 days\",\n  \"mentions.date.relative.last_3_days.search\": \"last 3 days|past 3 days\",\n  \"mentions.date.relative.last_7_days.label\": \"Last 7 days\",\n  \"mentions.date.relative.last_7_days.search\": \"last 7 days|past 7 days\",\n  \"mentions.date.relative.last_month.label\": \"Last month\",\n  \"mentions.date.relative.last_month.search\": \"last month|previous month\",\n  \"mentions.date.relative.last_week.label\": \"Last week\",\n  \"mentions.date.relative.last_week.search\": \"last week|previous week\",\n  \"mentions.date.relative.this_month.label\": \"This month\",\n  \"mentions.date.relative.this_month.search\": \"this month|current month\",\n  \"mentions.date.relative.this_week.label\": \"This week\",\n  \"mentions.date.relative.this_week.search\": \"this week|current week\",\n  \"mentions.date.relative.today.label\": \"Today\",\n  \"mentions.date.relative.today.search\": \"today\",\n  \"mentions.date.relative.yesterday.label\": \"Yesterday\",\n  \"mentions.date.relative.yesterday.search\": \"yesterday\",\n  \"mentions.date.weekday.auto.label\": \"{{weekday}}\",\n  \"mentions.date.weekday.day.friday.label\": \"Friday\",\n  \"mentions.date.weekday.day.friday.search\": \"friday|fri\",\n  \"mentions.date.weekday.day.monday.label\": \"Monday\",\n  \"mentions.date.weekday.day.monday.search\": \"monday|mon\",\n  \"mentions.date.weekday.day.saturday.label\": \"Saturday\",\n  \"mentions.date.weekday.day.saturday.search\": \"saturday|sat\",\n  \"mentions.date.weekday.day.sunday.label\": \"Sunday\",\n  \"mentions.date.weekday.day.sunday.search\": \"sunday|sun\",\n  \"mentions.date.weekday.day.thursday.label\": \"Thursday\",\n  \"mentions.date.weekday.day.thursday.search\": \"thursday|thu\",\n  \"mentions.date.weekday.day.tuesday.label\": \"Tuesday\",\n  \"mentions.date.weekday.day.tuesday.search\": \"tuesday|tue\",\n  \"mentions.date.weekday.day.wednesday.label\": \"Wednesday\",\n  \"mentions.date.weekday.day.wednesday.search\": \"wednesday|wed\",\n  \"mentions.date.weekday.last.label\": \"Last {{weekday}}\",\n  \"mentions.date.weekday.prefix.auto.search\": \"\",\n  \"mentions.date.weekday.prefix.last.search\": \"last|last week\",\n  \"mentions.date.weekday.prefix.this.search\": \"this|this week\",\n  \"mentions.date.weekday.this.label\": \"This {{weekday}}\",\n  \"mentions.section.category\": \"Categories\",\n  \"mentions.section.date\": \"Dates\",\n  \"mentions.section.entry\": \"Entries\",\n  \"mentions.section.feed\": \"Feeds\",\n  \"mentions.section.view\": \"Views\",\n  \"new_shortcuts\": \"New shortcut\",\n  \"personalize.description\": \"Tell me about yourself to get personalized AI responses.\",\n  \"personalize.prompt.help\": \"This helps AI provide personalized responses based on your preferences.\",\n  \"personalize.prompt.placeholder\": \"Tell me about yourself and how you prefer to read content...\",\n  \"personalize.saved\": \"Personalization saved successfully\",\n  \"personalize.title\": \"Personalization\",\n  \"quick_actions.discuss\": \"Discuss insights\",\n  \"quick_actions.questions\": \"Ask questions\",\n  \"quick_actions.simplify\": \"Simplify this\",\n  \"quick_actions.takeaways\": \"Key takeaways\",\n  \"rate_limit.credits_left\": \"{{count}} credits left\",\n  \"rate_limit.depleted\": \"AI credits depleted\",\n  \"rate_limit.minute\": \"minute\",\n  \"rate_limit.minutes\": \"minutes\",\n  \"rate_limit.resets_at\": \"resets at {{time}}\",\n  \"rate_limit.resets_in\": \"resets in {{value}}\",\n  \"rate_limit.resets_next_month\": \"resets at next month\",\n  \"rate_limit.resets_tomorrow\": \"resets at tomorrow\",\n  \"rate_limit.upgrade_plan_button\": \"Free Trial\",\n  \"rate_limit.upgrade_to_get_more\": \"Start free trial to get more AI credits.\",\n  \"schedule.configuration_label\": \"Configuration\",\n  \"schedule.date_time_label\": \"Date & Time\",\n  \"schedule.date_time_placeholder\": \"Select date & time\",\n  \"schedule.day_label\": \"Day\",\n  \"schedule.day_placeholder\": \"Select day\",\n  \"schedule.days.friday\": \"Friday\",\n  \"schedule.days.monday\": \"Monday\",\n  \"schedule.days.saturday\": \"Saturday\",\n  \"schedule.days.sunday\": \"Sunday\",\n  \"schedule.days.thursday\": \"Thursday\",\n  \"schedule.days.tuesday\": \"Tuesday\",\n  \"schedule.days.wednesday\": \"Wednesday\",\n  \"schedule.frequency.daily\": \"Daily\",\n  \"schedule.frequency.monthly\": \"Monthly\",\n  \"schedule.frequency.once\": \"Once\",\n  \"schedule.frequency.weekly\": \"Weekly\",\n  \"schedule.next_execution\": \"Next: {{time}} ({{relative}})\",\n  \"schedule.no_upcoming\": \"No upcoming executions\",\n  \"schedule.presets.daily_6pm\": \"Daily 6PM\",\n  \"schedule.presets.first_9am\": \"1st 9AM\",\n  \"schedule.presets.monday_9am\": \"Mon 9AM\",\n  \"schedule.presets.tomorrow_9am\": \"Tomorrow 9AM\",\n  \"schedule.presets_title\": \"Quick Presets\",\n  \"schedule.time_label\": \"Time\",\n  \"schedule.time_placeholder\": \"Select time\",\n  \"schedule.title\": \"Schedule\",\n  \"session.interrupted.message\": \"We lost connection before the assistant replied. Retry your last message.\",\n  \"session.interrupted.retry\": \"Retry\",\n  \"settings.autoScrollWhenStreaming.description\": \"Automatically scroll to the bottom of the chat when streaming.\",\n  \"settings.autoScrollWhenStreaming.label\": \"Auto scroll when streaming\",\n  \"settings.description\": \"Configure your AI experience and create custom shortcuts.\",\n  \"settings.panel_style.description\": \"The style of the AI chat panel.\",\n  \"settings.panel_style.fixed\": \"Fixed\",\n  \"settings.panel_style.floating\": \"Floating\",\n  \"settings.panel_style.label\": \"Panel Style\",\n  \"settings.showSplineButton.description\": \"Show or hide the AI assistant indicator in the bottom-right corner.\",\n  \"settings.showSplineButton.label\": \"AI Assistant Indicator\",\n  \"settings.title\": \"AI Settings\",\n  \"shortcuts.actions.delete\": \"Delete shortcut\",\n  \"shortcuts.actions.disable\": \"Disable shortcut\",\n  \"shortcuts.actions.edit\": \"Edit shortcut\",\n  \"shortcuts.add\": \"Add Shortcut\",\n  \"shortcuts.added\": \"Shortcut added successfully\",\n  \"shortcuts.context_menu.empty.entry\": \"No entry shortcuts configured yet\",\n  \"shortcuts.context_menu.empty.list\": \"No shortcuts available for the list view yet\",\n  \"shortcuts.create_first\": \"Create your first shortcut\",\n  \"shortcuts.custom_prompt.help\": \"Leave blank to use the default prompt provided by the server.\",\n  \"shortcuts.custom_prompt.title\": \"Custom prompt\",\n  \"shortcuts.custom_prompt_placeholder\": \"Override the default prompt...\",\n  \"shortcuts.default_prompt.label\": \"Default prompt\",\n  \"shortcuts.deleted\": \"Shortcut deleted successfully\",\n  \"shortcuts.empty.description\": \"Create custom AI shortcuts to quickly perform common tasks and get instant AI assistance.\",\n  \"shortcuts.empty.title\": \"No Shortcuts Yet\",\n  \"shortcuts.enabled\": \"Enabled\",\n  \"shortcuts.icon\": \"Icon\",\n  \"shortcuts.manage\": \"Manage shortcuts\",\n  \"shortcuts.name\": \"Name\",\n  \"shortcuts.name_placeholder\": \"e.g. Summarize Article\",\n  \"shortcuts.prompt\": \"Prompt\",\n  \"shortcuts.prompt_placeholder\": \"Write a clear instruction for the AI...\",\n  \"shortcuts.server_delete_disabled\": \"Server-provided shortcuts cannot be deleted\",\n  \"shortcuts.targets.entry\": \"Entry Details\",\n  \"shortcuts.targets.help\": \"Choose scenarios to control where this shortcut appears.\",\n  \"shortcuts.targets.label\": \"Display location\",\n  \"shortcuts.targets.list\": \"Timeline\",\n  \"shortcuts.title\": \"AI Shortcuts\",\n  \"shortcuts.updated\": \"Shortcut updated successfully\",\n  \"shortcuts.validation.name_required\": \"Name is required\",\n  \"shortcuts.validation.prompt_required\": \"A prompt is required\",\n  \"shortcuts.validation.required\": \"Name and prompt are required\",\n  \"shortcuts.validation.targets_required\": \"Select at least one display location\",\n  \"summary_not_available\": \"Summary not available\",\n  \"tasks.actions.delete_task\": \"Delete task\",\n  \"tasks.actions.edit_task\": \"Edit task\",\n  \"tasks.actions.new_task\": \"New Task\",\n  \"tasks.actions.schedule\": \"Schedule Task\",\n  \"tasks.actions.scheduling\": \"Scheduling...\",\n  \"tasks.actions.test_run\": \"Test run\",\n  \"tasks.actions.update\": \"Update Task\",\n  \"tasks.actions.updating\": \"Updating...\",\n  \"tasks.actions.view_reports\": \"View reports\",\n  \"tasks.empty.desc\": \"Create your first AI task to automate your workflows.\",\n  \"tasks.empty.title\": \"No scheduled tasks\",\n  \"tasks.fields.created\": \"Created:\",\n  \"tasks.fields.prompt\": \"Prompt:\",\n  \"tasks.fields.schedule\": \"Schedule:\",\n  \"tasks.manage.desc\": \"Create and manage automated AI tasks that run on your schedule.\",\n  \"tasks.manage.limit_reached\": \"(Limit reached: maximum number of tasks reached)\",\n  \"tasks.manage.title\": \"Schedule AI Tasks\",\n  \"tasks.modal.delete_confirm\": \"Are you sure you want to delete the task \\\"{{name}}\\\"?\",\n  \"tasks.modal.delete_title\": \"Delete Task\",\n  \"tasks.modal.edit_title\": \"Edit AI Task\",\n  \"tasks.modal.new_title\": \"New AI Task\",\n  \"tasks.name\": \"Task Name\",\n  \"tasks.name_placeholder\": \"Enter a descriptive name for your task...\",\n  \"tasks.notify.coming_soon\": \"Coming soon\",\n  \"tasks.notify.email\": \"Email\",\n  \"tasks.notify.email_helper\": \"Send the task result to your account email when it completes.\",\n  \"tasks.prompt\": \"Prompt\",\n  \"tasks.prompt_helper\": \"Provide clear, specific instructions for the AI to execute\",\n  \"tasks.prompt_placeholder\": \"Describe what you want the AI to do when this task runs...\",\n  \"tasks.schedule.daily\": \"Daily at {{time}}\",\n  \"tasks.schedule.monthly\": \"Monthly on day {{day}} at {{time}}\",\n  \"tasks.schedule.once\": \"Once on {{date}} at {{time}}\",\n  \"tasks.schedule.unknown\": \"Unknown schedule\",\n  \"tasks.schedule.weekly\": \"Weekly on {{day}} at {{time}}\",\n  \"tasks.section.info\": \"Task Information\",\n  \"tasks.section.instructions\": \"AI Instructions\",\n  \"tasks.section.notifications\": \"Notifications\",\n  \"tasks.section.schedule\": \"Schedule Configuration\",\n  \"tasks.section.title\": \"AI Tasks\",\n  \"tasks.status.completed\": \"Completed\",\n  \"tasks.status.paused\": \"Paused\",\n  \"tasks.status.scheduled\": \"Scheduled\",\n  \"tasks.status.unknown\": \"Unknown\",\n  \"tasks.toast.create_error\": \"Failed to schedule AI task. Please try again.\",\n  \"tasks.toast.created\": \"AI task scheduled successfully\",\n  \"tasks.toast.delete_failed\": \"Failed to delete task. Please try again.\",\n  \"tasks.toast.delete_success\": \"Task deleted successfully\",\n  \"tasks.toast.load_failed\": \"Failed to load chat messages\",\n  \"tasks.toast.no_report\": \"No report session found for this task yet.\",\n  \"tasks.toast.switch_to_chat\": \"Switch to the chat panel to view reports.\",\n  \"tasks.toast.test_failed\": \"Failed to run test. Please try again.\",\n  \"tasks.toast.test_start\": \"Running...\",\n  \"tasks.toast.test_success\": \"The test ran successfully. You can switch to the chat panel to view the report.\",\n  \"tasks.toast.update_error\": \"Failed to update AI task. Please try again.\",\n  \"tasks.toast.update_failed\": \"Failed to update task. Please try again.\",\n  \"tasks.toast.updated\": \"AI task updated successfully\",\n  \"tasks.validation.date_future\": \"Scheduled date must be in the future\",\n  \"tasks.validation.prompt_max\": \"Prompt must be less than 2000 characters\",\n  \"tasks.validation.prompt_required\": \"Prompt is required\",\n  \"tasks.validation.title_max\": \"Title must be less than 50 characters\",\n  \"tasks.validation.title_required\": \"Title is required\",\n  \"tasks.view_in_settings\": \"View scheduled tasks\",\n  \"timeline.summary.title_template\": \"{{- datetime}} timeline summary\",\n  \"timeline_prompt.prompt.help\": \"Used when AI sorts your timeline. Mention the topics or sources you prefer along with anything you dislike.\",\n  \"timeline_prompt.prompt.placeholder\": \"Describe what you want the AI timeline to prioritize or avoid...\",\n  \"timeline_prompt.saved\": \"Timeline sorting preferences saved successfully\",\n  \"timeline_prompt.title\": \"Timeline Sorting Prompt\",\n  \"timeline_summary.empty\": \"Timeline summary will appear here shortly.\",\n  \"timeline_summary.error\": \"Unable to generate timeline summary right now.\",\n  \"timeline_summary.generating\": \"Summarizing your timeline...\",\n  \"timeline_summary.heading\": \"What's New in Timeline\",\n  \"timeline_summary.options.include\": \"Chat with timeline summary\",\n  \"token_usage.description\": \"Monitor your AI credits consumption and limits.\",\n  \"token_usage.resets_at\": \"Resets at\",\n  \"token_usage.title\": \"AI Credits Usage\",\n  \"token_usage.tokens_remaining\": \"credits remaining\",\n  \"token_usage.tokens_used\": \"{{used}} / {{total}} credits used\",\n  \"token_usage_pill.billed\": \"Billed\",\n  \"token_usage_pill.byok\": \"BYOK\",\n  \"token_usage_pill.credits\": \"Credits\",\n  \"token_usage_pill.credits_usage\": \"AI Credits Usage\",\n  \"token_usage_pill.duration\": \"Duration\",\n  \"token_usage_pill.model_info\": \"Model Info\",\n  \"token_usage_pill.multiplier\": \"Multiplier\",\n  \"token_usage_pill.provider_info\": \"Provider Info\",\n  \"token_usage_pill.system\": \"System\",\n  \"token_usage_pill.total\": \"Total\",\n  \"token_usage_pill.unknown\": \"Unknown\",\n  \"usage_analysis.active_session\": \"Active Session\",\n  \"usage_analysis.current_usage\": \"Current Usage\",\n  \"usage_analysis.detailed_description\": \"Comprehensive overview of your AI usage patterns and insights\",\n  \"usage_analysis.detailed_title\": \"AI Usage Analytics\",\n  \"usage_analysis.no_data\": \"No usage data available\",\n  \"usage_analysis.resets_in\": \"Resets in\",\n  \"usage_analysis.session_duration\": \"Duration: {{duration}}\",\n  \"usage_analysis.title\": \"AI Credits Usage\",\n  \"usage_analysis.tokens_remaining\": \"credits remaining\",\n  \"usage_analysis.tokens_used\": \"Credits Used\",\n  \"usage_analysis.total_credits\": \"Total Credits\",\n  \"usage_analysis.total_limit\": \"Total Limit\",\n  \"usage_analysis.view_details\": \"View Details\",\n  \"usage_analysis.warning.general\": \"Consider reducing usage to avoid hitting limits\",\n  \"usage_analysis.warning.projected\": \"Projected limit in {{eta}}\",\n  \"usage_analysis.warning.rate\": \"Rate: {{rate}} tok/min\",\n  \"usage_analysis.warning.title\": \"High AI usage detected\",\n  \"usage_analysis.window_remaining\": \"Window credits left\",\n  \"welcome_description\": \"I'm here to help you with your reading needs. How can I assist you today?\",\n  \"welcome_description_contextual\": \"Let's discuss this entry together\"\n}\n"
  },
  {
    "path": "locales/ai/fr-FR.json",
    "content": "{\n  \"ai_summary\": \"Résumé IA\",\n  \"analytics.chart_placeholder\": \"La visualisation graphique sera implémentée avec recharts\",\n  \"analytics.efficiency_analysis\": \"Analyse d'efficacité\",\n  \"analytics.efficiency_placeholder\": \"Comparaison de l'efficacité du modèle et suggestions d'optimisation\",\n  \"analytics.history_operation\": \"Opération {{operation}}\",\n  \"analytics.history_usage\": \"Utilisation des crédits IA\",\n  \"analytics.no_data\": \"Aucune donnée d'utilisation disponible pour la période sélectionnée\",\n  \"analytics.no_history\": \"Aucun historique d'utilisation disponible\",\n  \"analytics.operation_types.chat\": \"Discussion\",\n  \"analytics.operation_types.onboarding\": \"Intégration\",\n  \"analytics.operation_types.task\": \"Tâche\",\n  \"analytics.operation_types.title_generation\": \"Génération de titre\",\n  \"analytics.patterns_placeholder\": \"L'analyse des modèles montrera les heures de pointe et les habitudes\",\n  \"analytics.tabs.efficiency\": \"Efficacité\",\n  \"analytics.tabs.history\": \"Historique\",\n  \"analytics.tabs.overview\": \"Aperçu\",\n  \"analytics.tabs.patterns\": \"Modèles\",\n  \"analytics.trend.operations\": \"Opérations\",\n  \"analytics.trend.title\": \"Tendances d'utilisation (30 derniers jours)\",\n  \"analytics.trend.tokens\": \"Crédits utilisés\",\n  \"analytics.usage_history\": \"Historique d'utilisation\",\n  \"analytics.usage_patterns\": \"Modèles d'utilisation\",\n  \"analytics.usage_trends\": \"Tendances d'utilisation (30 derniers jours)\",\n  \"byok.description\": \"Utilisez vos propres clés API pour les fournisseurs d'IA afin d'avoir plus de contrôle sur les coûts et l'utilisation.\",\n  \"byok.enabled\": \"Activer BYOK\",\n  \"byok.providers.add\": \"Ajouter un fournisseur\",\n  \"byok.providers.add_title\": \"Ajouter un fournisseur BYOK\",\n  \"byok.providers.added\": \"Fournisseur ajouté avec succès\",\n  \"byok.providers.configured\": \"Configuré\",\n  \"byok.providers.delete\": \"Supprimer le fournisseur\",\n  \"byok.providers.delete_message\": \"Êtes-vous sûr de vouloir supprimer ce fournisseur ? Cette action est irréversible.\",\n  \"byok.providers.delete_title\": \"Supprimer le fournisseur\",\n  \"byok.providers.deleted\": \"Fournisseur supprimé avec succès\",\n  \"byok.providers.edit\": \"Modifier le fournisseur\",\n  \"byok.providers.edit_title\": \"Modifier le fournisseur BYOK\",\n  \"byok.providers.empty.description\": \"Ajoutez vos propres clés API pour les fournisseurs d'IA afin d'utiliser votre propre infrastructure.\",\n  \"byok.providers.empty.title\": \"Aucun fournisseur configuré\",\n  \"byok.providers.form.api_key\": \"Clé API\",\n  \"byok.providers.form.api_key_help\": \"Votre clé API sera stockée en toute sécurité et chiffrée.\",\n  \"byok.providers.form.api_key_placeholder\": \"Entrez votre clé API\",\n  \"byok.providers.form.base_url\": \"URL de base\",\n  \"byok.providers.form.base_url_help\": \"Optionnel. Laissez vide pour utiliser le point de terminaison API par défaut.\",\n  \"byok.providers.form.base_url_placeholder\": \"ex. https://api.openai.com/v1\",\n  \"byok.providers.form.provider\": \"Fournisseur\",\n  \"byok.providers.headers_count\": \"en-tête(s)\",\n  \"byok.providers.no_api_key\": \"Aucune clé API définie\",\n  \"byok.providers.title\": \"Fournisseurs\",\n  \"byok.providers.updated\": \"Fournisseur mis à jour avec succès\",\n  \"byok.title\": \"Bring Your Own Key (BYOK)\",\n  \"chat.history.auto_title\": \"Discussion du {{- datetime}}\",\n  \"clear_chat\": \"Effacer la discussion\",\n  \"clear_chat_message\": \"Êtes-vous sûr de vouloir effacer tout l'historique ? Cette action supprimera définitivement tout le contenu, y compris tous les journaux de discussion et les données, et est irréversible.\",\n  \"common.generate_title\": \"Générer un titre\",\n  \"common.generating_title\": \"Génération du titre...\",\n  \"common.new_chat\": \"Nouvelle discussion\",\n  \"customize_shortcuts\": \"Personnaliser les raccourcis\",\n  \"delete_chat\": \"Supprimer la discussion\",\n  \"delete_chat_error\": \"Échec de la suppression de la discussion\",\n  \"delete_chat_message\": \"Êtes-vous sûr de vouloir supprimer \\\"{{title}}\\\" ? Cette action est irréversible.\",\n  \"delete_chat_success\": \"Discussion supprimée avec succès\",\n  \"features.title\": \"Fonctionnalités\",\n  \"integration.mcp.description\": \"Connectez-vous aux services compatibles MCP qui étendent les capacités de l'IA via une intégration OAuth sécurisée.\",\n  \"integration.mcp.enabled\": \"Activer les services MCP\",\n  \"integration.mcp.security.description\": \"Les jetons d'authentification OAuth sont stockés en toute sécurité à l'aide du chiffrement AES-GCM-256 pour protéger vos informations d'identification et garantir la confidentialité des données.\",\n  \"integration.mcp.security.title\": \"Divulgation de sécurité\",\n  \"integration.mcp.service.active\": \"Actif\",\n  \"integration.mcp.service.added\": \"Service MCP ajouté avec succès\",\n  \"integration.mcp.service.auth_message\": \"Ce service nécessite une autorisation OAuth. Cliquez sur 'Ouvrir l'autorisation' pour procéder à l'authentification dans une nouvelle fenêtre.\",\n  \"integration.mcp.service.auth_required\": \"Autorisation requise\",\n  \"integration.mcp.service.auth_window_opened\": \"Fenêtre d'autorisation ouverte. Veuillez terminer l'authentification.\",\n  \"integration.mcp.service.baseUrl\": \"URL de base du service\",\n  \"integration.mcp.service.baseUrl_placeholder\": \"ex. https://api.emple.com\",\n  \"integration.mcp.service.connect\": \"Connecter\",\n  \"integration.mcp.service.connected\": \"Connecté\",\n  \"integration.mcp.service.connected_success\": \"Connecté avec succès au service MCP\",\n  \"integration.mcp.service.connecting\": \"Connexion en cours...\",\n  \"integration.mcp.service.connection_failed\": \"Échec de la connexion au service MCP\",\n  \"integration.mcp.service.degraded\": \"Dégradé\",\n  \"integration.mcp.service.delete_message\": \"Êtes-vous sûr de vouloir supprimer ce service MCP ? Cette action est irréversible.\",\n  \"integration.mcp.service.delete_title\": \"Supprimer le service MCP\",\n  \"integration.mcp.service.deleted\": \"Service MCP supprimé avec succès\",\n  \"integration.mcp.service.disabled\": \"Désactivé\",\n  \"integration.mcp.service.disconnect\": \"Déconnecter\",\n  \"integration.mcp.service.disconnected\": \"Déconnecté\",\n  \"integration.mcp.service.discover\": \"Découvrir le service\",\n  \"integration.mcp.service.discovery_failed\": \"Échec de la découverte des points de terminaison du service\",\n  \"integration.mcp.service.enabled\": \"Activé\",\n  \"integration.mcp.service.endpoints\": \"Points de terminaison OAuth\",\n  \"integration.mcp.service.error\": \"Erreur de connexion\",\n  \"integration.mcp.service.healthy\": \"Sain\",\n  \"integration.mcp.service.inactive\": \"Inactif\",\n  \"integration.mcp.service.name\": \"Nom du service\",\n  \"integration.mcp.service.name_placeholder\": \"ex. Outils GitHub, Intégration Slack\",\n  \"integration.mcp.service.open_auth\": \"Ouvrir l'autorisation\",\n  \"integration.mcp.service.open_manually\": \"Ouvrir l'autorisation\",\n  \"integration.mcp.service.popup_blocked\": \"La fenêtre contextuelle d'autorisation a été bloquée par votre navigateur\",\n  \"integration.mcp.service.reconnect\": \"Reconnecter\",\n  \"integration.mcp.service.scopes\": \"Champs requis\",\n  \"integration.mcp.service.unhealthy\": \"Malsain\",\n  \"integration.mcp.service.updated\": \"Service MCP mis à jour avec succès\",\n  \"integration.mcp.service.validation.baseUrl_required\": \"L'URL de base est requise\",\n  \"integration.mcp.service.validation.invalid_url\": \"Veuillez entrer une URL valide\",\n  \"integration.mcp.service.validation.name_required\": \"Le nom du service est requis\",\n  \"integration.mcp.services.add\": \"Ajouter un service\",\n  \"integration.mcp.services.empty.description\": \"Connectez des services MCP pour étendre les capacités de l'IA avec des outils externes, des API et des sources de données.\",\n  \"integration.mcp.services.empty.title\": \"Aucun service MCP connecté\",\n  \"integration.mcp.services.title\": \"Services MCP\",\n  \"integration.security.description\": \"Les jetons d'authentification OAuth et les clés API AI BYOK sont stockés en toute sécurité à l'aide du chiffrement AES-GCM-256 pour protéger vos informations d'identification et garantir la confidentialité des données.\",\n  \"integration.security.title\": \"Divulgation de sécurité\",\n  \"integration.title\": \"Intégration\",\n  \"memories.actions.load_more\": \"Charger plus\",\n  \"memories.actions.loading\": \"Chargement...\",\n  \"memories.importance\": \"Importance\",\n  \"memories.list.description\": \"L'IA apprend à vous connaître au travers des conversations. Ces souvenirs l'aident à mieux vous comprendre au fil du temps.\",\n  \"memories.list.empty.description\": \"Au fur et à mesure que vous discutez avec l'IA, elle apprendra progressivement vos préférences et vos habitudes. Les souvenirs apparaîtront ici.\",\n  \"memories.list.empty.title\": \"Aucun souvenir pour le moment\",\n  \"memories.section.title\": \"Mémoire de l'IA sur vous\",\n  \"memories.toast.deleted\": \"Souvenir supprimé\",\n  \"memories.toast.failed\": \"Échec de l'enregistrement du souvenir\",\n  \"memories.toast.processing\": \"Traitement...\",\n  \"memories.toast.saved\": \"Souvenir enregistré\",\n  \"memories.toast.saving\": \"Enregistrement du souvenir...\",\n  \"memories.toast.updated\": \"Souvenir mis à jour\",\n  \"memories.toast.updating\": \"Mise à jour du souvenir...\",\n  \"mentions.date.relative.last_15_days.label\": \"15 derniers jours\",\n  \"mentions.date.relative.last_15_days.search\": \"15 derniers jours|15 jours passés|dernier demi mois|demi mois passé\",\n  \"mentions.date.relative.last_30_days.label\": \"30 derniers jours\",\n  \"mentions.date.relative.last_30_days.search\": \"30 derniers jours|30 jours passés\",\n  \"mentions.date.relative.last_3_days.label\": \"3 derniers jours\",\n  \"mentions.date.relative.last_3_days.search\": \"3 derniers jours|3 jours passés\",\n  \"mentions.date.relative.last_7_days.label\": \"7 derniers jours\",\n  \"mentions.date.relative.last_7_days.search\": \"7 derniers jours|7 jours passés\",\n  \"mentions.date.relative.last_month.label\": \"Le mois dernier\",\n  \"mentions.date.relative.last_month.search\": \"mois dernier|mois précédent\",\n  \"mentions.date.relative.last_week.label\": \"La semaine dernière\",\n  \"mentions.date.relative.last_week.search\": \"semaine dernière|semaine précédente\",\n  \"mentions.date.relative.this_month.label\": \"Ce mois-ci\",\n  \"mentions.date.relative.this_month.search\": \"ce mois|mois actuel\",\n  \"mentions.date.relative.this_week.label\": \"Cette semaine\",\n  \"mentions.date.relative.this_week.search\": \"cette semaine|semaine actuelle\",\n  \"mentions.date.relative.today.label\": \"Aujourd'hui\",\n  \"mentions.date.relative.today.search\": \"aujourd'hui\",\n  \"mentions.date.relative.yesterday.label\": \"Hier\",\n  \"mentions.date.relative.yesterday.search\": \"hier\",\n  \"mentions.date.weekday.auto.label\": \"{{weekday}}\",\n  \"mentions.date.weekday.day.friday.label\": \"Vendredi\",\n  \"mentions.date.weekday.day.friday.search\": \"vendredi|ven\",\n  \"mentions.date.weekday.day.monday.label\": \"Lundi\",\n  \"mentions.date.weekday.day.monday.search\": \"lundi|lun\",\n  \"mentions.date.weekday.day.saturday.label\": \"Samedi\",\n  \"mentions.date.weekday.day.saturday.search\": \"samedi|sam\",\n  \"mentions.date.weekday.day.sunday.label\": \"Dimanche\",\n  \"mentions.date.weekday.day.sunday.search\": \"dimanche|dim\",\n  \"mentions.date.weekday.day.thursday.label\": \"Jeudi\",\n  \"mentions.date.weekday.day.thursday.search\": \"jeudi|jeu\",\n  \"mentions.date.weekday.day.tuesday.label\": \"Mardi\",\n  \"mentions.date.weekday.day.tuesday.search\": \"mardi|mar\",\n  \"mentions.date.weekday.day.wednesday.label\": \"Mercredi\",\n  \"mentions.date.weekday.day.wednesday.search\": \"mercredi|mer\",\n  \"mentions.date.weekday.last.label\": \"{{weekday}} dernier\",\n  \"mentions.date.weekday.prefix.auto.search\": \"\",\n  \"mentions.date.weekday.prefix.last.search\": \"dernier|semaine dernière\",\n  \"mentions.date.weekday.prefix.this.search\": \"ce|cette semaine\",\n  \"mentions.date.weekday.this.label\": \"Ce {{weekday}}\",\n  \"mentions.section.category\": \"Catégories\",\n  \"mentions.section.date\": \"Dates\",\n  \"mentions.section.entry\": \"Entrées\",\n  \"mentions.section.feed\": \"Flux\",\n  \"mentions.section.view\": \"Vues\",\n  \"new_shortcuts\": \"Nouveau raccourci\",\n  \"personalize.description\": \"Parlez-moi de vous pour obtenir des réponses IA personnalisées.\",\n  \"personalize.prompt.help\": \"Cela aide l'IA à fournir des réponses personnalisées en fonction de vos préférences.\",\n  \"personalize.prompt.placeholder\": \"Parlez-moi de vous et de la façon dont vous préférez lire le contenu...\",\n  \"personalize.saved\": \"Personnalisation enregistrée avec succès\",\n  \"personalize.title\": \"Personnalisation\",\n  \"quick_actions.discuss\": \"Discuter des idées\",\n  \"quick_actions.questions\": \"Poser des questions\",\n  \"quick_actions.simplify\": \"Simplifier ceci\",\n  \"quick_actions.takeaways\": \"Points clés à retenir\",\n  \"rate_limit.credits_left\": \"{{count}} crédits restants\",\n  \"rate_limit.depleted\": \"Crédits IA épuisés\",\n  \"rate_limit.minute\": \"minute\",\n  \"rate_limit.minutes\": \"minutes\",\n  \"rate_limit.resets_at\": \"réinitialisé à {{time}}\",\n  \"rate_limit.resets_in\": \"réinitialisé dans {{value}}\",\n  \"rate_limit.resets_next_month\": \"réinitialisé le mois prochain\",\n  \"rate_limit.resets_tomorrow\": \"réinitialisé demain\",\n  \"rate_limit.upgrade_plan_button\": \"Essai gratuit\",\n  \"rate_limit.upgrade_to_get_more\": \"Commencez l'essai gratuit pour obtenir plus de crédits IA.\",\n  \"schedule.configuration_label\": \"Configuration\",\n  \"schedule.date_time_label\": \"Date et heure\",\n  \"schedule.date_time_placeholder\": \"Sélectionner la date et l'heure\",\n  \"schedule.day_label\": \"Jour\",\n  \"schedule.day_placeholder\": \"Sélectionner le jour\",\n  \"schedule.days.friday\": \"Vendredi\",\n  \"schedule.days.monday\": \"Lundi\",\n  \"schedule.days.saturday\": \"Samedi\",\n  \"schedule.days.sunday\": \"Dimanche\",\n  \"schedule.days.thursday\": \"Jeudi\",\n  \"schedule.days.tuesday\": \"Mardi\",\n  \"schedule.days.wednesday\": \"Mercredi\",\n  \"schedule.frequency.daily\": \"Quotidien\",\n  \"schedule.frequency.monthly\": \"Mensuel\",\n  \"schedule.frequency.once\": \"Une fois\",\n  \"schedule.frequency.weekly\": \"Hebdomadaire\",\n  \"schedule.next_execution\": \"Prochain : {{time}} ({{relative}})\",\n  \"schedule.no_upcoming\": \"Aucune exécution à venir\",\n  \"schedule.presets.daily_6pm\": \"Quotidien 18h\",\n  \"schedule.presets.first_9am\": \"1er 9h\",\n  \"schedule.presets.monday_9am\": \"Lun 9h\",\n  \"schedule.presets.tomorrow_9am\": \"Demain 9h\",\n  \"schedule.presets_title\": \"Préréglages rapides\",\n  \"schedule.time_label\": \"Heure\",\n  \"schedule.time_placeholder\": \"Sélectionner l'heure\",\n  \"schedule.title\": \"Calendrier\",\n  \"session.interrupted.message\": \"Nous avons perdu la connexion avant que l'assistant ne réponde. Réessayez votre dernier message.\",\n  \"session.interrupted.retry\": \"Réessayer\",\n  \"settings.autoScrollWhenStreaming.description\": \"Faire défiler automatiquement vers le bas de la discussion lors du streaming.\",\n  \"settings.autoScrollWhenStreaming.label\": \"Défilement auto. lors du streaming\",\n  \"settings.description\": \"Configurez votre expérience IA et créez des raccourcis personnalisés.\",\n  \"settings.panel_style.description\": \"Le style du panneau de discussion IA.\",\n  \"settings.panel_style.fixed\": \"Fixe\",\n  \"settings.panel_style.floating\": \"Flottant\",\n  \"settings.panel_style.label\": \"Style de panneau\",\n  \"settings.showSplineButton.description\": \"Afficher ou masquer l'indicateur d'assistant IA dans le coin inférieur droit.\",\n  \"settings.showSplineButton.label\": \"Indicateur d'assistant IA\",\n  \"settings.title\": \"Paramètres IA\",\n  \"shortcuts.actions.delete\": \"Supprimer le raccourci\",\n  \"shortcuts.actions.disable\": \"Désactiver le raccourci\",\n  \"shortcuts.actions.edit\": \"Modifier le raccourci\",\n  \"shortcuts.add\": \"Ajouter un raccourci\",\n  \"shortcuts.added\": \"Raccourci ajouté avec succès\",\n  \"shortcuts.context_menu.empty.entry\": \"Aucun raccourci d'entrée configuré pour le moment\",\n  \"shortcuts.context_menu.empty.list\": \"Aucun raccourci disponible pour la vue liste pour le moment\",\n  \"shortcuts.create_first\": \"Créez votre premier raccourci\",\n  \"shortcuts.custom_prompt.help\": \"Laissez vide pour utiliser l'invite par défaut fournie par le serveur.\",\n  \"shortcuts.custom_prompt.title\": \"Invite personnalisée\",\n  \"shortcuts.custom_prompt_placeholder\": \"Remplacer l'invite par défaut...\",\n  \"shortcuts.default_prompt.label\": \"Invite par défaut\",\n  \"shortcuts.deleted\": \"Raccourci supprimé avec succès\",\n  \"shortcuts.empty.description\": \"Créez des raccourcis IA personnalisés pour effectuer rapidement des tâches courantes et obtenir une assistance IA instantanée.\",\n  \"shortcuts.empty.title\": \"Aucun raccourci pour le moment\",\n  \"shortcuts.enabled\": \"Activé\",\n  \"shortcuts.icon\": \"Icône\",\n  \"shortcuts.manage\": \"Gérer les raccourcis\",\n  \"shortcuts.name\": \"Nom\",\n  \"shortcuts.name_placeholder\": \"ex. Résumer l'article\",\n  \"shortcuts.prompt\": \"Invite\",\n  \"shortcuts.prompt_placeholder\": \"Écrivez une instruction claire pour l'IA...\",\n  \"shortcuts.server_delete_disabled\": \"Les raccourcis fournis par le serveur ne peuvent pas être supprimés\",\n  \"shortcuts.targets.entry\": \"Détails de l'entrée\",\n  \"shortcuts.targets.help\": \"Choisissez des scénarios pour contrôler où ce raccourci apparaît.\",\n  \"shortcuts.targets.label\": \"Lieu d'affichage\",\n  \"shortcuts.targets.list\": \"Chronologie\",\n  \"shortcuts.title\": \"Raccourcis IA\",\n  \"shortcuts.updated\": \"Raccourci mis à jour avec succès\",\n  \"shortcuts.validation.name_required\": \"Le nom est requis\",\n  \"shortcuts.validation.prompt_required\": \"Une invite est requise\",\n  \"shortcuts.validation.required\": \"Le nom et l'invite sont requis\",\n  \"shortcuts.validation.targets_required\": \"Sélectionnez au moins un lieu d'affichage\",\n  \"summary_not_available\": \"Résumé non disponible\",\n  \"tasks.actions.delete_task\": \"Supprimer la tâche\",\n  \"tasks.actions.edit_task\": \"Modifier la tâche\",\n  \"tasks.actions.new_task\": \"Nouvelle tâche\",\n  \"tasks.actions.schedule\": \"Planifier la tâche\",\n  \"tasks.actions.scheduling\": \"Planification...\",\n  \"tasks.actions.test_run\": \"Test\",\n  \"tasks.actions.update\": \"Mettre à jour la tâche\",\n  \"tasks.actions.updating\": \"Mise à jour...\",\n  \"tasks.actions.view_reports\": \"Voir les rapports\",\n  \"tasks.empty.desc\": \"Créez votre première tâche IA pour automatiser vos flux de travail.\",\n  \"tasks.empty.title\": \"Aucune tâche planifiée\",\n  \"tasks.fields.created\": \"Créé :\",\n  \"tasks.fields.prompt\": \"Invite :\",\n  \"tasks.fields.schedule\": \"Calendrier :\",\n  \"tasks.manage.desc\": \"Créez et gérez des tâches IA automatisées qui s'exécutent selon votre calendrier.\",\n  \"tasks.manage.limit_reached\": \"(Limite atteinte : nombre maximum de tâches atteint)\",\n  \"tasks.manage.title\": \"Planifier des tâches IA\",\n  \"tasks.modal.delete_confirm\": \"Êtes-vous sûr de vouloir supprimer la tâche \\\"{{name}}\\\" ?\",\n  \"tasks.modal.delete_title\": \"Supprimer la tâche\",\n  \"tasks.modal.edit_title\": \"Modifier la tâche IA\",\n  \"tasks.modal.new_title\": \"Nouvelle tâche IA\",\n  \"tasks.name\": \"Nom de la tâche\",\n  \"tasks.name_placeholder\": \"Entrez un nom descriptif pour votre tâche...\",\n  \"tasks.notify.coming_soon\": \"Bientôt disponible\",\n  \"tasks.notify.email\": \"Email\",\n  \"tasks.notify.email_helper\": \"Envoyer le résultat de la tâche à l'email de votre compte lorsqu'elle se termine.\",\n  \"tasks.prompt\": \"Invite\",\n  \"tasks.prompt_helper\": \"Fournissez des instructions claires et précises pour l'exécution de l'IA\",\n  \"tasks.prompt_placeholder\": \"Décrivez ce que vous voulez que l'IA fasse lorsque cette tâche s'exécute...\",\n  \"tasks.schedule.daily\": \"Quotidien à {{time}}\",\n  \"tasks.schedule.monthly\": \"Mensuel le {{day}} à {{time}}\",\n  \"tasks.schedule.once\": \"Une fois le {{date}} à {{time}}\",\n  \"tasks.schedule.unknown\": \"Calendrier inconnu\",\n  \"tasks.schedule.weekly\": \"Hebdomadaire le {{day}} à {{time}}\",\n  \"tasks.section.info\": \"Informations sur la tâche\",\n  \"tasks.section.instructions\": \"Instructions IA\",\n  \"tasks.section.notifications\": \"Notifications\",\n  \"tasks.section.schedule\": \"Configuration du calendrier\",\n  \"tasks.section.title\": \"Tâches IA\",\n  \"tasks.status.completed\": \"Terminé\",\n  \"tasks.status.paused\": \"En pause\",\n  \"tasks.status.scheduled\": \"Planifié\",\n  \"tasks.status.unknown\": \"Inconnu\",\n  \"tasks.toast.create_error\": \"Échec de la planification de la tâche IA. Veuillez réessayer.\",\n  \"tasks.toast.created\": \"Tâche IA planifiée avec succès\",\n  \"tasks.toast.delete_failed\": \"Échec de la suppression de la tâche. Veuillez réessayer.\",\n  \"tasks.toast.delete_success\": \"Tâche supprimée avec succès\",\n  \"tasks.toast.load_failed\": \"Échec du chargement des messages de discussion\",\n  \"tasks.toast.no_report\": \"Aucune session de rapport trouvée pour cette tâche pour le moment.\",\n  \"tasks.toast.switch_to_chat\": \"Passez au panneau de discussion pour voir les rapports.\",\n  \"tasks.toast.test_failed\": \"Échec de l'exécution du test. Veuillez réessayer.\",\n  \"tasks.toast.test_start\": \"Exécution...\",\n  \"tasks.toast.test_success\": \"Le test a réussi. Vous pouvez passer au panneau de discussion pour voir le rapport.\",\n  \"tasks.toast.update_error\": \"Échec de la mise à jour de la tâche IA. Veuillez réessayer.\",\n  \"tasks.toast.update_failed\": \"Échec de la mise à jour de la tâche. Veuillez réessayer.\",\n  \"tasks.toast.updated\": \"Tâche IA mise à jour avec succès\",\n  \"tasks.validation.date_future\": \"La date planifiée doit être dans le futur\",\n  \"tasks.validation.prompt_max\": \"L'invite doit comporter moins de 2000 caractères\",\n  \"tasks.validation.prompt_required\": \"L'invite est requise\",\n  \"tasks.validation.title_max\": \"Le titre doit comporter moins de 50 caractères\",\n  \"tasks.validation.title_required\": \"Le titre est requis\",\n  \"tasks.view_in_settings\": \"Voir les tâches planifiées\",\n  \"timeline.summary.title_template\": \"Résumé de la chronologie du {{- datetime}}\",\n  \"timeline_prompt.prompt.help\": \"Utilisé lorsque l'IA trie votre chronologie. Mentionnez les sujets ou les sources que vous préférez ainsi que tout ce que vous n'aimez pas.\",\n  \"timeline_prompt.prompt.placeholder\": \"Décrivez ce que vous voulez que la chronologie IA privilégie ou évite...\",\n  \"timeline_prompt.saved\": \"Préférences de tri de la chronologie enregistrées avec succès\",\n  \"timeline_prompt.title\": \"Invite de tri de la chronologie\",\n  \"timeline_summary.empty\": \"Le résumé de la chronologie apparaîtra ici sous peu.\",\n  \"timeline_summary.error\": \"Impossible de générer le résumé de la chronologie pour le moment.\",\n  \"timeline_summary.generating\": \"Résumé de votre chronologie...\",\n  \"timeline_summary.heading\": \"Quoi de neuf dans la chronologie\",\n  \"timeline_summary.options.include\": \"Discuter avec le résumé de la chronologie\",\n  \"token_usage.description\": \"Surveillez votre consommation de crédits IA et vos limites.\",\n  \"token_usage.resets_at\": \"Réinitialisé à\",\n  \"token_usage.title\": \"Utilisation des crédits IA\",\n  \"token_usage.tokens_remaining\": \"crédits restants\",\n  \"token_usage.tokens_used\": \"{{used}} / {{total}} crédits utilisés\",\n  \"token_usage_pill.billed\": \"Facturé\",\n  \"token_usage_pill.byok\": \"BYOK\",\n  \"token_usage_pill.credits\": \"Crédits\",\n  \"token_usage_pill.credits_usage\": \"Utilisation des crédits IA\",\n  \"token_usage_pill.duration\": \"Durée\",\n  \"token_usage_pill.model_info\": \"Infos modèle\",\n  \"token_usage_pill.multiplier\": \"Multiplicateur\",\n  \"token_usage_pill.provider_info\": \"Infos fournisseur\",\n  \"token_usage_pill.system\": \"Système\",\n  \"token_usage_pill.total\": \"Total\",\n  \"token_usage_pill.unknown\": \"Inconnu\",\n  \"usage_analysis.active_session\": \"Session active\",\n  \"usage_analysis.current_usage\": \"Utilisation actuelle\",\n  \"usage_analysis.detailed_description\": \"Aperçu complet de vos habitudes d'utilisation de l'IA et informations\",\n  \"usage_analysis.detailed_title\": \"Analyse d'utilisation de l'IA\",\n  \"usage_analysis.no_data\": \"Aucune donnée d'utilisation disponible\",\n  \"usage_analysis.resets_in\": \"Réinitialisé dans\",\n  \"usage_analysis.session_duration\": \"Durée : {{duration}}\",\n  \"usage_analysis.title\": \"Utilisation des crédits IA\",\n  \"usage_analysis.tokens_remaining\": \"crédits restants\",\n  \"usage_analysis.tokens_used\": \"Crédits utilisés\",\n  \"usage_analysis.total_credits\": \"Crédits totaux\",\n  \"usage_analysis.total_limit\": \"Limite totale\",\n  \"usage_analysis.view_details\": \"Voir les détails\",\n  \"usage_analysis.warning.general\": \"Envisagez de réduire l'utilisation pour éviter d'atteindre les limites\",\n  \"usage_analysis.warning.projected\": \"Limite projetée dans {{eta}}\",\n  \"usage_analysis.warning.rate\": \"Taux : {{rate}} tok/min\",\n  \"usage_analysis.warning.title\": \"Utilisation élevée de l'IA détectée\",\n  \"usage_analysis.window_remaining\": \"Crédits de fenêtre restants\",\n  \"welcome_description\": \"Je suis là pour vous aider avec vos besoins de lecture. Comment puis-je vous aider aujourd'hui ?\",\n  \"welcome_description_contextual\": \"Discutons de cette entrée ensemble\"\n}\n"
  },
  {
    "path": "locales/ai/ja.json",
    "content": "{\n  \"ai_summary\": \"AI要約\",\n  \"analytics.chart_placeholder\": \"チャート可視化はrechartsで実装されます\",\n  \"analytics.efficiency_analysis\": \"効率分析\",\n  \"analytics.efficiency_placeholder\": \"モデル効率比較と最適化提案\",\n  \"analytics.history_operation\": \"{{operation}} 操作\",\n  \"analytics.history_usage\": \"トークン使用\",\n  \"analytics.no_data\": \"選択した期間に利用可能な使用データがありません\",\n  \"analytics.no_history\": \"利用可能な使用履歴がありません\",\n  \"analytics.operation_types.chat\": \"チャット\",\n  \"analytics.operation_types.onboarding\": \"オンボーディング\",\n  \"analytics.operation_types.task\": \"タスク\",\n  \"analytics.operation_types.title_generation\": \"タイトル生成\",\n  \"analytics.patterns_placeholder\": \"パターン分析はピーク使用時間と習慣を表示します\",\n  \"analytics.tabs.efficiency\": \"効率\",\n  \"analytics.tabs.history\": \"履歴\",\n  \"analytics.tabs.overview\": \"概要\",\n  \"analytics.tabs.patterns\": \"パターン\",\n  \"analytics.trend.operations\": \"操作回数\",\n  \"analytics.trend.title\": \"過去30日の使用傾向\",\n  \"analytics.trend.tokens\": \"使用トークン\",\n  \"analytics.usage_history\": \"使用履歴\",\n  \"analytics.usage_patterns\": \"使用パターン\",\n  \"analytics.usage_trends\": \"使用傾向（過去30日）\",\n  \"byok.description\": \"AIプロバイダーに独自のAPIキーを使用して、コストと使用量をより細かく制御できます。\",\n  \"byok.enabled\": \"BYOKを有効にする\",\n  \"byok.providers.add\": \"プロバイダーを追加\",\n  \"byok.providers.add_title\": \"BYOKプロバイダーを追加\",\n  \"byok.providers.added\": \"プロバイダーが正常に追加されました\",\n  \"byok.providers.configured\": \"設定済み\",\n  \"byok.providers.delete\": \"プロバイダーを削除\",\n  \"byok.providers.delete_message\": \"このプロバイダーを削除してもよろしいですか？この操作は元に戻すことはできません。\",\n  \"byok.providers.delete_title\": \"プロバイダーを削除\",\n  \"byok.providers.deleted\": \"プロバイダーが正常に削除されました\",\n  \"byok.providers.edit\": \"プロバイダーを編集\",\n  \"byok.providers.edit_title\": \"BYOKプロバイダーを編集\",\n  \"byok.providers.empty.description\": \"独自のインフラストラクチャを使用するために、AIプロバイダーに独自のAPIキーを追加します。\",\n  \"byok.providers.empty.title\": \"プロバイダーが設定されていません\",\n  \"byok.providers.form.api_key\": \"APIキー\",\n  \"byok.providers.form.api_key_help\": \"APIキーは安全に保存され、暗号化されます。\",\n  \"byok.providers.form.api_key_placeholder\": \"APIキーを入力\",\n  \"byok.providers.form.base_url\": \"ベースURL\",\n  \"byok.providers.form.base_url_help\": \"オプション。空のままにすると、デフォルトのAPIエンドポイントが使用されます。\",\n  \"byok.providers.form.base_url_placeholder\": \"例：https://api.openai.com/v1\",\n  \"byok.providers.form.provider\": \"プロバイダー\",\n  \"byok.providers.headers_count\": \"個のカスタムヘッダー\",\n  \"byok.providers.no_api_key\": \"APIキーが設定されていません\",\n  \"byok.providers.title\": \"プロバイダー\",\n  \"byok.providers.updated\": \"プロバイダーが正常に更新されました\",\n  \"byok.title\": \"Bring Your Own Key (BYOK)\",\n  \"chat.history.auto_title\": \"{{- datetime}} のチャット\",\n  \"clear_chat\": \"Chat をクリア\",\n  \"clear_chat_message\": \"本当にすべての履歴をクリアしてもよろしいですか？この操作はすべてのコンテンツ、Chat ログ、データを永久に削除し、元に戻すことはできません。\",\n  \"common.generate_title\": \"タイトルを生成\",\n  \"common.generating_title\": \"タイトルを生成中...\",\n  \"common.new_chat\": \"新規チャット\",\n  \"customize_shortcuts\": \"ショートカットをカスタマイズ\",\n  \"delete_chat\": \"Chat を削除\",\n  \"delete_chat_error\": \"Chat の削除に失敗しました\",\n  \"delete_chat_message\": \"本当に「{{title}}」を削除してもよろしいですか？この操作は元に戻すことはできません。\",\n  \"delete_chat_success\": \"Chat が正常に削除されました。\",\n  \"features.title\": \"機能\",\n  \"integration.mcp.description\": \"安全なOAuth統合により、AI機能を拡張するMCP対応サービスに接続。\",\n  \"integration.mcp.enabled\": \"MCPサービスを有効にする\",\n  \"integration.mcp.security.description\": \"OAuth認証トークンは、AES-GCM-256暗号化を使用して安全に保存され、資格情報を保護し、データプライバシーを確保します。\",\n  \"integration.mcp.security.title\": \"セキュリティ開示\",\n  \"integration.mcp.service.active\": \"アクティブ\",\n  \"integration.mcp.service.added\": \"MCPサービスが正常に追加されました\",\n  \"integration.mcp.service.auth_message\": \"このサービスはOAuth認証が必要です。新しいウィンドウで認証を進めるには「認証を開く」をクリックしてください。\",\n  \"integration.mcp.service.auth_required\": \"認証が必要\",\n  \"integration.mcp.service.auth_window_opened\": \"認証ウィンドウが開きました。認証を完了してください。\",\n  \"integration.mcp.service.baseUrl\": \"サービスベースURL\",\n  \"integration.mcp.service.baseUrl_placeholder\": \"例：https://api.example.com\",\n  \"integration.mcp.service.connect\": \"接続\",\n  \"integration.mcp.service.connected\": \"接続済み\",\n  \"integration.mcp.service.connected_success\": \"MCPサービスに正常に接続されました\",\n  \"integration.mcp.service.connecting\": \"接続中...\",\n  \"integration.mcp.service.connection_failed\": \"MCPサービスへの接続に失敗しました\",\n  \"integration.mcp.service.degraded\": \"劣化\",\n  \"integration.mcp.service.delete_message\": \"このMCPサービスを削除してもよろしいですか？この操作は元に戻すことはできません。\",\n  \"integration.mcp.service.delete_title\": \"MCPサービスの削除\",\n  \"integration.mcp.service.deleted\": \"MCPサービスが正常に削除されました\",\n  \"integration.mcp.service.disabled\": \"無効\",\n  \"integration.mcp.service.disconnect\": \"切断\",\n  \"integration.mcp.service.disconnected\": \"切断済み\",\n  \"integration.mcp.service.discover\": \"サービスを発見\",\n  \"integration.mcp.service.discovery_failed\": \"サービスエンドポイントの発見に失敗しました\",\n  \"integration.mcp.service.enabled\": \"有効\",\n  \"integration.mcp.service.endpoints\": \"OAuthエンドポイント\",\n  \"integration.mcp.service.error\": \"接続エラー\",\n  \"integration.mcp.service.healthy\": \"正常\",\n  \"integration.mcp.service.inactive\": \"非アクティブ\",\n  \"integration.mcp.service.name\": \"サービス名\",\n  \"integration.mcp.service.name_placeholder\": \"例：GitHub Tools、Slack Integration\",\n  \"integration.mcp.service.open_auth\": \"認証を開く\",\n  \"integration.mcp.service.open_manually\": \"認証を開く\",\n  \"integration.mcp.service.popup_blocked\": \"認証ポップアップがブラウザによってブロックされました\",\n  \"integration.mcp.service.reconnect\": \"再接続\",\n  \"integration.mcp.service.scopes\": \"必要なスコープ\",\n  \"integration.mcp.service.unhealthy\": \"異常\",\n  \"integration.mcp.service.updated\": \"MCPサービスが正常に更新されました\",\n  \"integration.mcp.service.validation.baseUrl_required\": \"ベースURLは必須です\",\n  \"integration.mcp.service.validation.invalid_url\": \"有効なURLを入力してください\",\n  \"integration.mcp.service.validation.name_required\": \"サービス名は必須です\",\n  \"integration.mcp.services.add\": \"サービスを追加\",\n  \"integration.mcp.services.empty.description\": \"MCPサービスに接続して、外部ツール、API、データソースでAI機能を拡張します。\",\n  \"integration.mcp.services.empty.title\": \"MCPサービスが接続されていません\",\n  \"integration.mcp.services.title\": \"MCPサービス\",\n  \"integration.security.description\": \"OAuth認証トークンとAI BYOK APIキーは、AES-GCM-256暗号化を使用して安全に保存され、資格情報を保護し、データのプライバシーを確保します。\",\n  \"integration.security.title\": \"セキュリティ開示\",\n  \"integration.title\": \"統合\",\n  \"memories.actions.load_more\": \"さらに読み込む\",\n  \"memories.actions.loading\": \"読み込み中...\",\n  \"memories.importance\": \"重要度\",\n  \"memories.list.description\": \"AIは会話を通じてあなたのことを学んでいきます。これらの記憶により、時間とともにあなたをより深く理解できるようになります。\",\n  \"memories.list.empty.description\": \"AIとチャットするにつれて、あなたの好みや習慣を徐々に学習していきます。記憶がここに表示されます。\",\n  \"memories.list.empty.title\": \"まだ印象がありません\",\n  \"memories.section.title\": \"AIのあなたへの理解\",\n  \"memories.toast.deleted\": \"メモリを削除しました\",\n  \"memories.toast.failed\": \"メモリの保存に失敗しました\",\n  \"memories.toast.processing\": \"処理中...\",\n  \"memories.toast.saved\": \"メモリを保存しました\",\n  \"memories.toast.saving\": \"メモリを保存中...\",\n  \"memories.toast.updated\": \"メモリを更新しました\",\n  \"memories.toast.updating\": \"メモリを更新中...\",\n  \"mentions.date.relative.last_15_days.label\": \"過去15日間\",\n  \"mentions.date.relative.last_15_days.search\": \"過去15日間|直近15日間|半月\",\n  \"mentions.date.relative.last_30_days.label\": \"過去30日間\",\n  \"mentions.date.relative.last_30_days.search\": \"過去30日間|直近30日間\",\n  \"mentions.date.relative.last_3_days.label\": \"過去3日間\",\n  \"mentions.date.relative.last_3_days.search\": \"過去3日間|直近3日間\",\n  \"mentions.date.relative.last_7_days.label\": \"過去7日間\",\n  \"mentions.date.relative.last_7_days.search\": \"過去7日間|直近7日間\",\n  \"mentions.date.relative.last_month.label\": \"先月\",\n  \"mentions.date.relative.last_month.search\": \"先月|せんげつ\",\n  \"mentions.date.relative.last_week.label\": \"先週\",\n  \"mentions.date.relative.last_week.search\": \"先週|せんしゅう\",\n  \"mentions.date.relative.this_month.label\": \"今月\",\n  \"mentions.date.relative.this_month.search\": \"今月|こんげつ\",\n  \"mentions.date.relative.this_week.label\": \"今週\",\n  \"mentions.date.relative.this_week.search\": \"今週|こんしゅう\",\n  \"mentions.date.relative.today.label\": \"今日\",\n  \"mentions.date.relative.today.search\": \"今日|きょう\",\n  \"mentions.date.relative.yesterday.label\": \"昨日\",\n  \"mentions.date.relative.yesterday.search\": \"昨日|きのう\",\n  \"mentions.date.weekday.auto.label\": \"{{weekday}}\",\n  \"mentions.date.weekday.day.friday.label\": \"金曜日\",\n  \"mentions.date.weekday.day.friday.search\": \"金曜日|金曜|きんようび\",\n  \"mentions.date.weekday.day.monday.label\": \"月曜日\",\n  \"mentions.date.weekday.day.monday.search\": \"月曜日|月曜|げつようび\",\n  \"mentions.date.weekday.day.saturday.label\": \"土曜日\",\n  \"mentions.date.weekday.day.saturday.search\": \"土曜日|土曜|どようび\",\n  \"mentions.date.weekday.day.sunday.label\": \"日曜日\",\n  \"mentions.date.weekday.day.sunday.search\": \"日曜日|日曜|にちようび\",\n  \"mentions.date.weekday.day.thursday.label\": \"木曜日\",\n  \"mentions.date.weekday.day.thursday.search\": \"木曜日|木曜|もくようび\",\n  \"mentions.date.weekday.day.tuesday.label\": \"火曜日\",\n  \"mentions.date.weekday.day.tuesday.search\": \"火曜日|火曜|かようび\",\n  \"mentions.date.weekday.day.wednesday.label\": \"水曜日\",\n  \"mentions.date.weekday.day.wednesday.search\": \"水曜日|水曜|すいようび\",\n  \"mentions.date.weekday.last.label\": \"先週{{weekday}}\",\n  \"mentions.date.weekday.prefix.auto.search\": \"\",\n  \"mentions.date.weekday.prefix.last.search\": \"先週|せんしゅう\",\n  \"mentions.date.weekday.prefix.this.search\": \"今週|こんしゅう\",\n  \"mentions.date.weekday.this.label\": \"今週{{weekday}}\",\n  \"mentions.section.category\": \"カテゴリー\",\n  \"mentions.section.date\": \"日付\",\n  \"mentions.section.entry\": \"エントリー\",\n  \"mentions.section.feed\": \"フィード\",\n  \"mentions.section.view\": \"ビュー\",\n  \"new_shortcuts\": \"ショートカットを追加\",\n  \"personalize.description\": \"あなた自身について教えてください。そうすれば、パーソナライズされたAIの応答が得られます。\",\n  \"personalize.prompt.help\": \"これにより、AIはあなたの好みに基づいてパーソナライズされた応答を提供できます。\",\n  \"personalize.prompt.placeholder\": \"あなた自身について、どのようにコンテンツを読みたいかを教えてください...\",\n  \"personalize.saved\": \"パーソナライズが正常に保存されました\",\n  \"personalize.title\": \"パーソナライズ\",\n  \"quick_actions.discuss\": \"洞察を議論する\",\n  \"quick_actions.questions\": \"質問をする\",\n  \"quick_actions.simplify\": \"簡単にする\",\n  \"quick_actions.takeaways\": \"重要なポイント\",\n  \"rate_limit.credits_left\": \"残り {{count}} クレジット\",\n  \"rate_limit.depleted\": \"AIクレジットが不足しています\",\n  \"rate_limit.minute\": \"分\",\n  \"rate_limit.minutes\": \"分\",\n  \"rate_limit.resets_at\": \"{{time}} にリセット\",\n  \"rate_limit.resets_in\": \"{{value}} 後にリセット\",\n  \"rate_limit.resets_next_month\": \"来月にリセット\",\n  \"rate_limit.resets_tomorrow\": \"明日にリセット\",\n  \"rate_limit.upgrade_plan_button\": \"無料トライアル\",\n  \"rate_limit.upgrade_to_get_more\": \"無料トライアルを開始して、より多くの AI クレジットを利用しましょう。\",\n  \"schedule.configuration_label\": \"設定\",\n  \"schedule.date_time_label\": \"日付と時刻\",\n  \"schedule.date_time_placeholder\": \"日付と時刻を選択\",\n  \"schedule.day_label\": \"曜日\",\n  \"schedule.day_placeholder\": \"曜日を選択\",\n  \"schedule.days.friday\": \"金曜日\",\n  \"schedule.days.monday\": \"月曜日\",\n  \"schedule.days.saturday\": \"土曜日\",\n  \"schedule.days.sunday\": \"日曜日\",\n  \"schedule.days.thursday\": \"木曜日\",\n  \"schedule.days.tuesday\": \"火曜日\",\n  \"schedule.days.wednesday\": \"水曜日\",\n  \"schedule.frequency.daily\": \"毎日\",\n  \"schedule.frequency.monthly\": \"毎月\",\n  \"schedule.frequency.once\": \"一度きり\",\n  \"schedule.frequency.weekly\": \"毎週\",\n  \"schedule.next_execution\": \"次: {{time}} ({{relative}})\",\n  \"schedule.no_upcoming\": \"今後の実行はありません\",\n  \"schedule.presets.daily_6pm\": \"毎日 18:00\",\n  \"schedule.presets.first_9am\": \"1日 9:00\",\n  \"schedule.presets.monday_9am\": \"月曜日 9:00\",\n  \"schedule.presets.tomorrow_9am\": \"明日 9:00\",\n  \"schedule.presets_title\": \"クイックプリセット\",\n  \"schedule.time_label\": \"時刻\",\n  \"schedule.time_placeholder\": \"時刻を選択\",\n  \"schedule.title\": \"スケジュール\",\n  \"session.interrupted.message\": \"前回のリクエストが最後まで完了しませんでした。もう一度送信してください。\",\n  \"session.interrupted.retry\": \"再試行\",\n  \"settings.autoScrollWhenStreaming.description\": \"ストリーミング中にチャットの一番下まで自動スクロールします。\",\n  \"settings.autoScrollWhenStreaming.label\": \"ストリーミング時に自動スクロール\",\n  \"settings.description\": \"AI の体験を設定し、カスタムショートカットを作成します。\",\n  \"settings.panel_style.description\": \"AI チャットパネルのスタイル\",\n  \"settings.panel_style.fixed\": \"固定\",\n  \"settings.panel_style.floating\": \"浮動\",\n  \"settings.panel_style.label\": \"パネルスタイル\",\n  \"settings.showSplineButton.description\": \"右下隅にあるAIアシスタントインジケーターを表示または非表示にします。\",\n  \"settings.showSplineButton.label\": \"AIアシスタントインジケーター\",\n  \"settings.title\": \"AI 設定\",\n  \"shortcuts.actions.delete\": \"ショートカットを削除\",\n  \"shortcuts.actions.disable\": \"ショートカットを無効化\",\n  \"shortcuts.actions.edit\": \"ショートカットを編集\",\n  \"shortcuts.add\": \"ショートカットを追加\",\n  \"shortcuts.added\": \"ショートカットが正常に追加されました\",\n  \"shortcuts.context_menu.empty.entry\": \"エントリ詳細で使えるショートカットはまだありません\",\n  \"shortcuts.context_menu.empty.list\": \"リスト表示で使えるショートカットはまだありません\",\n  \"shortcuts.create_first\": \"最初のショートカットを作成\",\n  \"shortcuts.custom_prompt.help\": \"空のままにすると、サーバー既定のプロンプトが使用されます。\",\n  \"shortcuts.custom_prompt.title\": \"カスタムプロンプト\",\n  \"shortcuts.custom_prompt_placeholder\": \"既定の指示を上書きします...\",\n  \"shortcuts.default_prompt.label\": \"既定のプロンプト\",\n  \"shortcuts.deleted\": \"ショートカットが正常に削除されました\",\n  \"shortcuts.empty.description\": \"一般的なタスクを迅速に実行し、即座にAIアシスタンスを得るためのカスタムAIショートカットを作成します。\",\n  \"shortcuts.empty.title\": \"ショートカットはまだありません\",\n  \"shortcuts.enabled\": \"有効\",\n  \"shortcuts.icon\": \"アイコン\",\n  \"shortcuts.manage\": \"ショートカットを管理\",\n  \"shortcuts.name\": \"名前\",\n  \"shortcuts.name_placeholder\": \"例：記事を要約\",\n  \"shortcuts.prompt\": \"プロンプト\",\n  \"shortcuts.prompt_placeholder\": \"AI に対する明確な指示を書いてください...\",\n  \"shortcuts.server_delete_disabled\": \"サーバー提供のショートカットは削除できません\",\n  \"shortcuts.targets.entry\": \"エントリ詳細\",\n  \"shortcuts.targets.help\": \"ショートカットを表示するシナリオを選択してください。\",\n  \"shortcuts.targets.label\": \"表示場所\",\n  \"shortcuts.targets.list\": \"タイムライン\",\n  \"shortcuts.title\": \"AI ショートカット\",\n  \"shortcuts.updated\": \"ショートカットが正常に更新されました\",\n  \"shortcuts.validation.name_required\": \"名前は必須です\",\n  \"shortcuts.validation.prompt_required\": \"プロンプトは必須です\",\n  \"shortcuts.validation.required\": \"名前とプロンプトは必須です\",\n  \"shortcuts.validation.targets_required\": \"少なくとも1つの表示場所を選択してください\",\n  \"summary_not_available\": \"要約は利用できません\",\n  \"tasks.actions.delete_task\": \"タスクを削除\",\n  \"tasks.actions.edit_task\": \"タスクを編集\",\n  \"tasks.actions.new_task\": \"新規タスク\",\n  \"tasks.actions.schedule\": \"タスクをスケジュール\",\n  \"tasks.actions.scheduling\": \"スケジュール中...\",\n  \"tasks.actions.test_run\": \"テスト実行\",\n  \"tasks.actions.update\": \"タスクを更新\",\n  \"tasks.actions.updating\": \"更新中...\",\n  \"tasks.actions.view_reports\": \"レポートを見る\",\n  \"tasks.empty.desc\": \"最初の AI タスクを作成してワークフローを自動化しましょう。\",\n  \"tasks.empty.title\": \"予定されたタスクはありません\",\n  \"tasks.fields.created\": \"作成日時:\",\n  \"tasks.fields.prompt\": \"プロンプト:\",\n  \"tasks.fields.schedule\": \"スケジュール:\",\n  \"tasks.manage.desc\": \"スケジュールに従って自動実行される AI タスクを作成・管理します。\",\n  \"tasks.manage.limit_reached\": \"(最大タスク数に達しました)\",\n  \"tasks.manage.title\": \"AI タスクをスケジュール\",\n  \"tasks.modal.delete_confirm\": \"タスク \\\"{{name}}\\\" を削除してもよろしいですか？\",\n  \"tasks.modal.delete_title\": \"タスクを削除\",\n  \"tasks.modal.edit_title\": \"AI タスクを編集\",\n  \"tasks.modal.new_title\": \"新規 AI タスク\",\n  \"tasks.name\": \"タスク名\",\n  \"tasks.name_placeholder\": \"タスクのわかりやすい名前を入力...\",\n  \"tasks.notify.coming_soon\": \"近日公開\",\n  \"tasks.notify.email\": \"メール\",\n  \"tasks.notify.email_helper\": \"タスク完了時に結果をアカウントのメールへ送信します。\",\n  \"tasks.prompt\": \"プロンプト\",\n  \"tasks.prompt_helper\": \"AIが実行するための明確で具体的な指示を提供してください\",\n  \"tasks.prompt_placeholder\": \"このタスクが実行されるときにAIに何をさせたいかを記述...\",\n  \"tasks.schedule.daily\": \"毎日 {{time}}\",\n  \"tasks.schedule.monthly\": \"毎月 {{day}} 日 {{time}}\",\n  \"tasks.schedule.once\": \"{{date}} {{time}} 単回\",\n  \"tasks.schedule.unknown\": \"不明なスケジュール\",\n  \"tasks.schedule.weekly\": \"毎週{{day}} {{time}}\",\n  \"tasks.section.info\": \"タスク情報\",\n  \"tasks.section.instructions\": \"AI 指示\",\n  \"tasks.section.notifications\": \"通知\",\n  \"tasks.section.schedule\": \"スケジュール設定\",\n  \"tasks.section.title\": \"AI タスク\",\n  \"tasks.status.completed\": \"完了\",\n  \"tasks.status.paused\": \"一時停止\",\n  \"tasks.status.scheduled\": \"予定済み\",\n  \"tasks.status.unknown\": \"不明\",\n  \"tasks.toast.create_error\": \"AIタスクのスケジュールに失敗しました。もう一度お試しください。\",\n  \"tasks.toast.created\": \"AIタスクが正常にスケジュールされました\",\n  \"tasks.toast.delete_failed\": \"タスクの削除に失敗しました。もう一度お試しください。\",\n  \"tasks.toast.delete_success\": \"タスクが削除されました\",\n  \"tasks.toast.load_failed\": \"チャットメッセージの読み込みに失敗しました\",\n  \"tasks.toast.no_report\": \"このタスクのレポートセッションはまだありません。\",\n  \"tasks.toast.switch_to_chat\": \"レポートを見るにはチャットパネルに切り替えてください。\",\n  \"tasks.toast.test_failed\": \"テスト実行に失敗しました。もう一度お試しください。\",\n  \"tasks.toast.test_start\": \"実行中...\",\n  \"tasks.toast.test_success\": \"テストは正常に実行されました。チャットパネルに切り替えてレポートを確認できます。\",\n  \"tasks.toast.update_error\": \"AIタスクの更新に失敗しました。もう一度お試しください。\",\n  \"tasks.toast.update_failed\": \"タスクの更新に失敗しました。もう一度お試しください。\",\n  \"tasks.toast.updated\": \"AIタスクが正常に更新されました\",\n  \"tasks.validation.date_future\": \"スケジュール日時は未来である必要があります\",\n  \"tasks.validation.prompt_max\": \"プロンプトは2000文字未満である必要があります\",\n  \"tasks.validation.prompt_required\": \"プロンプトは必須です\",\n  \"tasks.validation.title_max\": \"タイトルは50文字未満である必要があります\",\n  \"tasks.validation.title_required\": \"タイトルは必須です\",\n  \"tasks.view_in_settings\": \"スケジュールされたタスクを表示\",\n  \"timeline.summary.title_template\": \"{{- datetime}} のタイムライン要約\",\n  \"timeline_prompt.prompt.help\": \"AI がタイムラインを並び替える際に使われます。好みのテーマやソース、優先度を下げたい内容を書いてください。\",\n  \"timeline_prompt.prompt.placeholder\": \"AI タイムラインで優先表示したい内容や避けたい内容を記述してください...\",\n  \"timeline_prompt.saved\": \"タイムライン並び替えの設定を保存しました\",\n  \"timeline_prompt.title\": \"タイムライン並び替えプロンプト\",\n  \"timeline_summary.empty\": \"まもなくタイムラインの概要が表示されます。\",\n  \"timeline_summary.error\": \"現在タイムラインの概要を生成できません。\",\n  \"timeline_summary.generating\": \"タイムラインを要約しています...\",\n  \"timeline_summary.heading\": \"タイムラインに新しい内容はありますか\",\n  \"timeline_summary.options.include\": \"タイムライン要約でチャット\",\n  \"token_usage.description\": \"AIトークンの消費と制限を監視します。\",\n  \"token_usage.resets_at\": \"リセット日時\",\n  \"token_usage.title\": \"トークン使用状況\",\n  \"token_usage.tokens_remaining\": \"残りトークン\",\n  \"token_usage.tokens_used\": \"{{used}} / {{total}} トークン使用済み\",\n  \"token_usage_pill.billed\": \"請求済み\",\n  \"token_usage_pill.byok\": \"BYOK\",\n  \"token_usage_pill.credits\": \"クレジット\",\n  \"token_usage_pill.credits_usage\": \"AI クレジット使用\",\n  \"token_usage_pill.duration\": \"持続時間\",\n  \"token_usage_pill.model_info\": \"モデル情報\",\n  \"token_usage_pill.multiplier\": \"倍数\",\n  \"token_usage_pill.provider_info\": \"プロバイダー情報\",\n  \"token_usage_pill.system\": \"システム\",\n  \"token_usage_pill.total\": \"合計\",\n  \"token_usage_pill.unknown\": \"不明\",\n  \"usage_analysis.active_session\": \"アクティブセッション\",\n  \"usage_analysis.current_usage\": \"現在の使用状況\",\n  \"usage_analysis.detailed_description\": \"AI使用パターンとインサイトの包括的概要\",\n  \"usage_analysis.detailed_title\": \"AI使用分析\",\n  \"usage_analysis.no_data\": \"利用可能な使用データがありません\",\n  \"usage_analysis.resets_in\": \"リセットまで\",\n  \"usage_analysis.session_duration\": \"持続時間: {{duration}}\",\n  \"usage_analysis.title\": \"トークン使用状況\",\n  \"usage_analysis.tokens_remaining\": \"残りトークン\",\n  \"usage_analysis.tokens_used\": \"使用済みトークン\",\n  \"usage_analysis.total_credits\": \"総クレジット\",\n  \"usage_analysis.total_limit\": \"総上限\",\n  \"usage_analysis.view_details\": \"詳細を表示\",\n  \"usage_analysis.warning.general\": \"上限に達しないよう使用量の削減を検討してください\",\n  \"usage_analysis.warning.projected\": \"{{eta}}で上限に達する可能性があります\",\n  \"usage_analysis.warning.rate\": \"レート：{{rate}} トークン/分\",\n  \"usage_analysis.warning.title\": \"AI の高い使用量が検出されました\",\n  \"usage_analysis.window_remaining\": \"ウィンドウ内の残りクレジット\",\n  \"welcome_description\": \"私はあなたの読書と執筆のニーズをサポートするためにここにいます。今日はどのようにお手伝いできますか？\",\n  \"welcome_description_contextual\": \"このエントリについて一緒に話し合いましょう\"\n}\n"
  },
  {
    "path": "locales/ai/zh-CN.json",
    "content": "{\n  \"ai_summary\": \"AI 摘要\",\n  \"analytics.chart_placeholder\": \"图表可视化将通过 recharts 实现\",\n  \"analytics.efficiency_analysis\": \"效率分析\",\n  \"analytics.efficiency_placeholder\": \"模型效率比较和优化建议\",\n  \"analytics.history_operation\": \"{{operation}} 操作\",\n  \"analytics.history_usage\": \"令牌使用\",\n  \"analytics.no_data\": \"所选期间没有可用的使用数据\",\n  \"analytics.no_history\": \"没有可用的使用历史\",\n  \"analytics.operation_types.chat\": \"聊天\",\n  \"analytics.operation_types.onboarding\": \"入门\",\n  \"analytics.operation_types.task\": \"任务\",\n  \"analytics.operation_types.title_generation\": \"标题生成\",\n  \"analytics.patterns_placeholder\": \"模式分析将显示高峰使用时间和习惯\",\n  \"analytics.tabs.efficiency\": \"效率\",\n  \"analytics.tabs.history\": \"历史\",\n  \"analytics.tabs.overview\": \"概览\",\n  \"analytics.tabs.patterns\": \"模式\",\n  \"analytics.trend.operations\": \"操作次数\",\n  \"analytics.trend.title\": \"最近 30 天使用趋势\",\n  \"analytics.trend.tokens\": \"使用令牌\",\n  \"analytics.usage_history\": \"使用历史\",\n  \"analytics.usage_patterns\": \"使用模式\",\n  \"analytics.usage_trends\": \"使用趋势（最近 30 天）\",\n  \"byok.description\": \"使用您自己的 AI 提供商 API 密钥，以便更好地控制成本和用量。\",\n  \"byok.enabled\": \"启用 BYOK\",\n  \"byok.providers.add\": \"添加提供商\",\n  \"byok.providers.add_title\": \"添加 BYOK 提供商\",\n  \"byok.providers.added\": \"提供商添加成功\",\n  \"byok.providers.configured\": \"已配置\",\n  \"byok.providers.delete\": \"删除提供商\",\n  \"byok.providers.delete_message\": \"确定要删除此提供商吗？此操作无法撤销。\",\n  \"byok.providers.delete_title\": \"删除提供商\",\n  \"byok.providers.deleted\": \"提供商删除成功\",\n  \"byok.providers.edit\": \"编辑提供商\",\n  \"byok.providers.edit_title\": \"编辑 BYOK 提供商\",\n  \"byok.providers.empty.description\": \"添加您自己的 AI 提供商 API 密钥以使用您自己的基础设施。\",\n  \"byok.providers.empty.title\": \"未配置提供商\",\n  \"byok.providers.form.api_key\": \"API 密钥\",\n  \"byok.providers.form.api_key_help\": \"您的 API 密钥将被安全存储和加密。\",\n  \"byok.providers.form.api_key_placeholder\": \"输入您的 API 密钥\",\n  \"byok.providers.form.base_url\": \"基础 URL\",\n  \"byok.providers.form.base_url_help\": \"可选。留空以使用默认 API 端点。\",\n  \"byok.providers.form.base_url_placeholder\": \"例如：https://api.openai.com/v1\",\n  \"byok.providers.form.provider\": \"提供商\",\n  \"byok.providers.headers_count\": \"个自定义请求头\",\n  \"byok.providers.no_api_key\": \"未设置 API 密钥\",\n  \"byok.providers.title\": \"提供商\",\n  \"byok.providers.updated\": \"提供商更新成功\",\n  \"byok.title\": \"自带密钥 (BYOK)\",\n  \"chat.history.auto_title\": \"{{- datetime}} 聊天\",\n  \"clear_chat\": \"清空对话\",\n  \"clear_chat_message\": \"确定要清空所有历史记录吗？此操作将永久删除所有内容，包括所有聊天记录和数据，且无法撤销。\",\n  \"common.generate_title\": \"生成标题\",\n  \"common.generating_title\": \"正在生成标题...\",\n  \"common.new_chat\": \"新对话\",\n  \"customize_shortcuts\": \"自定义快捷方式\",\n  \"delete_chat\": \"删除对话\",\n  \"delete_chat_error\": \"删除对话失败\",\n  \"delete_chat_message\": \"确定要删除\\\"{{title}}\\\"吗？此操作无法撤销。\",\n  \"delete_chat_success\": \"对话删除成功\",\n  \"features.title\": \"功能\",\n  \"integration.mcp.description\": \"通过安全的 OAuth 集成连接到兼容 MCP 的服务，扩展 AI 功能。\",\n  \"integration.mcp.enabled\": \"启用 MCP 服务\",\n  \"integration.mcp.security.description\": \"OAuth 认证令牌使用 AES-GCM-256 加密技术安全存储，以保护您的凭据并确保数据隐私。\",\n  \"integration.mcp.security.title\": \"安全声明\",\n  \"integration.mcp.service.active\": \"活跃\",\n  \"integration.mcp.service.added\": \"MCP 服务添加成功\",\n  \"integration.mcp.service.auth_message\": \"此服务需要 OAuth 授权。点击\\\"打开授权\\\"以在新窗口中进行身份验证。\",\n  \"integration.mcp.service.auth_required\": \"需要授权\",\n  \"integration.mcp.service.auth_window_opened\": \"授权窗口已打开。请完成身份验证。\",\n  \"integration.mcp.service.baseUrl\": \"服务基础 URL\",\n  \"integration.mcp.service.baseUrl_placeholder\": \"例如：https://api.example.com\",\n  \"integration.mcp.service.connect\": \"连接\",\n  \"integration.mcp.service.connected\": \"已连接\",\n  \"integration.mcp.service.connected_success\": \"成功连接到 MCP 服务\",\n  \"integration.mcp.service.connecting\": \"连接中...\",\n  \"integration.mcp.service.connection_failed\": \"连接 MCP 服务失败\",\n  \"integration.mcp.service.degraded\": \"降级\",\n  \"integration.mcp.service.delete_message\": \"确定要删除此 MCP 服务吗？此操作无法撤销。\",\n  \"integration.mcp.service.delete_title\": \"删除 MCP 服务\",\n  \"integration.mcp.service.deleted\": \"MCP 服务删除成功\",\n  \"integration.mcp.service.disabled\": \"已禁用\",\n  \"integration.mcp.service.disconnect\": \"断开连接\",\n  \"integration.mcp.service.disconnected\": \"已断开连接\",\n  \"integration.mcp.service.discover\": \"发现服务\",\n  \"integration.mcp.service.discovery_failed\": \"发现服务端点失败\",\n  \"integration.mcp.service.enabled\": \"已启用\",\n  \"integration.mcp.service.endpoints\": \"OAuth 端点\",\n  \"integration.mcp.service.error\": \"连接错误\",\n  \"integration.mcp.service.healthy\": \"健康\",\n  \"integration.mcp.service.inactive\": \"非活跃\",\n  \"integration.mcp.service.name\": \"服务名称\",\n  \"integration.mcp.service.name_placeholder\": \"例如：GitHub 工具、Slack 集成\",\n  \"integration.mcp.service.open_auth\": \"打开授权\",\n  \"integration.mcp.service.open_manually\": \"打开授权\",\n  \"integration.mcp.service.popup_blocked\": \"授权弹窗被浏览器阻止\",\n  \"integration.mcp.service.reconnect\": \"重新连接\",\n  \"integration.mcp.service.scopes\": \"所需权限范围\",\n  \"integration.mcp.service.unhealthy\": \"不健康\",\n  \"integration.mcp.service.updated\": \"MCP 服务更新成功\",\n  \"integration.mcp.service.validation.baseUrl_required\": \"基础 URL 为必填项\",\n  \"integration.mcp.service.validation.invalid_url\": \"请输入有效的 URL\",\n  \"integration.mcp.service.validation.name_required\": \"服务名称为必填项\",\n  \"integration.mcp.services.add\": \"添加服务\",\n  \"integration.mcp.services.empty.description\": \"连接 MCP 服务以通过外部工具、API 和数据源扩展 AI 功能。\",\n  \"integration.mcp.services.empty.title\": \"未连接 MCP 服务\",\n  \"integration.mcp.services.title\": \"MCP 服务\",\n  \"integration.security.description\": \"OAuth 认证令牌和 AI BYOK API 密钥使用 AES-GCM-256 加密技术安全存储，以保护您的凭据并确保数据隐私。\",\n  \"integration.security.title\": \"安全声明\",\n  \"integration.title\": \"集成\",\n  \"memories.actions.load_more\": \"加载更多\",\n  \"memories.actions.loading\": \"加载中...\",\n  \"memories.importance\": \"重要性\",\n  \"memories.list.description\": \"AI 会在与你的交流中逐渐了解你。这些记忆帮助它更好地理解你的喜好和习惯。\",\n  \"memories.list.empty.description\": \"随着你与 AI 的对话，它会逐渐学习你的偏好和习惯。记忆会出现在这里。\",\n  \"memories.list.empty.title\": \"还没有印象\",\n  \"memories.section.title\": \"AI 对你的印象\",\n  \"memories.toast.deleted\": \"记忆已删除\",\n  \"memories.toast.failed\": \"保存记忆失败\",\n  \"memories.toast.processing\": \"处理中...\",\n  \"memories.toast.saved\": \"记忆已保存\",\n  \"memories.toast.saving\": \"正在保存记忆...\",\n  \"memories.toast.updated\": \"记忆已更新\",\n  \"memories.toast.updating\": \"正在更新记忆...\",\n  \"mentions.date.relative.last_15_days.label\": \"最近 15 天\",\n  \"mentions.date.relative.last_15_days.search\": \"最近 15 天 | 近半个月\",\n  \"mentions.date.relative.last_30_days.label\": \"最近 30 天\",\n  \"mentions.date.relative.last_30_days.search\": \"最近 30 天 | 近一个月\",\n  \"mentions.date.relative.last_3_days.label\": \"最近 3 天\",\n  \"mentions.date.relative.last_3_days.search\": \"最近 3 天 | 近三天\",\n  \"mentions.date.relative.last_7_days.label\": \"最近 7 天\",\n  \"mentions.date.relative.last_7_days.search\": \"最近 7 天 | 近一周\",\n  \"mentions.date.relative.last_month.label\": \"上个月\",\n  \"mentions.date.relative.last_month.search\": \"上个月 | 上月 | 上一个月\",\n  \"mentions.date.relative.last_week.label\": \"上周\",\n  \"mentions.date.relative.last_week.search\": \"上周 | 上一周 | 上星期 | 上个星期 | 上礼拜 | 上个礼拜\",\n  \"mentions.date.relative.this_month.label\": \"这个月\",\n  \"mentions.date.relative.this_month.search\": \"这个月 | 本月 | 这月\",\n  \"mentions.date.relative.this_week.label\": \"这周\",\n  \"mentions.date.relative.this_week.search\": \"这周 | 本周 | 这星期 | 本星期 | 这礼拜 | 本礼拜\",\n  \"mentions.date.relative.today.label\": \"今天\",\n  \"mentions.date.relative.today.search\": \"今天 | 今日\",\n  \"mentions.date.relative.yesterday.label\": \"昨天\",\n  \"mentions.date.relative.yesterday.search\": \"昨天 | 昨日\",\n  \"mentions.date.weekday.auto.label\": \"{{weekday}}\",\n  \"mentions.date.weekday.day.friday.label\": \"周五\",\n  \"mentions.date.weekday.day.friday.search\": \"周五 | 星期五 | 礼拜五\",\n  \"mentions.date.weekday.day.monday.label\": \"周一\",\n  \"mentions.date.weekday.day.monday.search\": \"周一 | 星期一 | 礼拜一\",\n  \"mentions.date.weekday.day.saturday.label\": \"周六\",\n  \"mentions.date.weekday.day.saturday.search\": \"周六 | 星期六 | 礼拜六\",\n  \"mentions.date.weekday.day.sunday.label\": \"周日\",\n  \"mentions.date.weekday.day.sunday.search\": \"周日 | 星期日 | 礼拜日 | 周天 | 星期天 | 礼拜天\",\n  \"mentions.date.weekday.day.thursday.label\": \"周四\",\n  \"mentions.date.weekday.day.thursday.search\": \"周四 | 星期四 | 礼拜四\",\n  \"mentions.date.weekday.day.tuesday.label\": \"周二\",\n  \"mentions.date.weekday.day.tuesday.search\": \"周二 | 星期二 | 礼拜二\",\n  \"mentions.date.weekday.day.wednesday.label\": \"周三\",\n  \"mentions.date.weekday.day.wednesday.search\": \"周三 | 星期三 | 礼拜三\",\n  \"mentions.date.weekday.last.label\": \"上周{{weekday}}\",\n  \"mentions.date.weekday.prefix.auto.search\": \"\",\n  \"mentions.date.weekday.prefix.last.search\": \"上周 | 上一周 | 上星期 | 上个星期 | 上礼拜 | 上个礼拜\",\n  \"mentions.date.weekday.prefix.this.search\": \"这周 | 本周 | 这星期 | 本星期 | 这礼拜 | 本礼拜\",\n  \"mentions.date.weekday.this.label\": \"本周{{weekday}}\",\n  \"mentions.section.category\": \"分类\",\n  \"mentions.section.date\": \"日期\",\n  \"mentions.section.entry\": \"条目\",\n  \"mentions.section.feed\": \"订阅源\",\n  \"mentions.section.view\": \"视图\",\n  \"new_shortcuts\": \"新增快捷方式\",\n  \"personalize.description\": \"介绍一下您自己，以获得个性化的 AI 响应。\",\n  \"personalize.prompt.help\": \"这有助于 AI 根据您的偏好提供个性化响应。\",\n  \"personalize.prompt.placeholder\": \"介绍一下您自己以及您希望如何阅读内容...\",\n  \"personalize.saved\": \"个性化设置保存成功\",\n  \"personalize.title\": \"个性化\",\n  \"quick_actions.discuss\": \"讨论见解\",\n  \"quick_actions.questions\": \"提出问题\",\n  \"quick_actions.simplify\": \"简化内容\",\n  \"quick_actions.takeaways\": \"关键要点\",\n  \"rate_limit.credits_left\": \"剩余 {{count}} 额度\",\n  \"rate_limit.depleted\": \"AI 额度已用尽\",\n  \"rate_limit.minute\": \"分钟\",\n  \"rate_limit.minutes\": \"分钟\",\n  \"rate_limit.resets_at\": \"将于 {{time}} 重置\",\n  \"rate_limit.resets_in\": \"将在 {{value}} 后重置\",\n  \"rate_limit.resets_next_month\": \"将于下个月重置\",\n  \"rate_limit.resets_tomorrow\": \"将于明天重置\",\n  \"rate_limit.upgrade_plan_button\": \"免费试用\",\n  \"rate_limit.upgrade_to_get_more\": \"开启免费试用以获得更多 AI 额度。\",\n  \"schedule.configuration_label\": \"配置\",\n  \"schedule.date_time_label\": \"日期和时间\",\n  \"schedule.date_time_placeholder\": \"选择日期和时间\",\n  \"schedule.day_label\": \"日期\",\n  \"schedule.day_placeholder\": \"选择日期\",\n  \"schedule.days.friday\": \"星期五\",\n  \"schedule.days.monday\": \"星期一\",\n  \"schedule.days.saturday\": \"星期六\",\n  \"schedule.days.sunday\": \"星期日\",\n  \"schedule.days.thursday\": \"星期四\",\n  \"schedule.days.tuesday\": \"星期二\",\n  \"schedule.days.wednesday\": \"星期三\",\n  \"schedule.frequency.daily\": \"每日\",\n  \"schedule.frequency.monthly\": \"每月\",\n  \"schedule.frequency.once\": \"单次\",\n  \"schedule.frequency.weekly\": \"每周\",\n  \"schedule.next_execution\": \"下次执行：{{time}} ({{relative}})\",\n  \"schedule.no_upcoming\": \"没有即将执行的任务\",\n  \"schedule.presets.daily_6pm\": \"每天下午 6 点\",\n  \"schedule.presets.first_9am\": \"1 号上午 9 点\",\n  \"schedule.presets.monday_9am\": \"周一上午 9 点\",\n  \"schedule.presets.tomorrow_9am\": \"明天上午 9 点\",\n  \"schedule.presets_title\": \"快速预设\",\n  \"schedule.time_label\": \"时间\",\n  \"schedule.time_placeholder\": \"选择时间\",\n  \"schedule.title\": \"计划\",\n  \"session.interrupted.message\": \"上次请求似乎未完成，请重试以继续。\",\n  \"session.interrupted.retry\": \"重新发送\",\n  \"settings.autoScrollWhenStreaming.description\": \"在流式传输时自动滚动到聊天底部\",\n  \"settings.autoScrollWhenStreaming.label\": \"流式传输时自动滚动\",\n  \"settings.description\": \"配置您的 AI 体验并创建自定义快捷方式\",\n  \"settings.panel_style.description\": \"AI 聊天面板的样式\",\n  \"settings.panel_style.fixed\": \"固定\",\n  \"settings.panel_style.floating\": \"浮动\",\n  \"settings.panel_style.label\": \"面板样式\",\n  \"settings.showSplineButton.description\": \"显示或隐藏右下角的 AI 助手指示器。\",\n  \"settings.showSplineButton.label\": \"AI 助手指示器\",\n  \"settings.title\": \"AI 设置\",\n  \"shortcuts.actions.delete\": \"删除快捷方式\",\n  \"shortcuts.actions.disable\": \"禁用快捷方式\",\n  \"shortcuts.actions.edit\": \"编辑快捷方式\",\n  \"shortcuts.add\": \"添加快捷方式\",\n  \"shortcuts.added\": \"快捷方式添加成功\",\n  \"shortcuts.context_menu.empty.entry\": \"暂无可用于条目详情的快捷方式\",\n  \"shortcuts.context_menu.empty.list\": \"列表视图暂无可用的快捷方式\",\n  \"shortcuts.create_first\": \"创建您的第一个快捷方式\",\n  \"shortcuts.custom_prompt.help\": \"留空则使用服务端提供的默认提示。\",\n  \"shortcuts.custom_prompt.title\": \"自定义提示\",\n  \"shortcuts.custom_prompt_placeholder\": \"覆盖默认提示内容...\",\n  \"shortcuts.default_prompt.label\": \"默认提示\",\n  \"shortcuts.deleted\": \"快捷方式删除成功\",\n  \"shortcuts.empty.description\": \"创建自定义 AI 快捷方式，快速执行常见任务并获得即时 AI 协助。\",\n  \"shortcuts.empty.title\": \"暂无快捷方式\",\n  \"shortcuts.enabled\": \"已启用\",\n  \"shortcuts.icon\": \"图标\",\n  \"shortcuts.manage\": \"管理快捷方式\",\n  \"shortcuts.name\": \"名称\",\n  \"shortcuts.name_placeholder\": \"例如：总结文章\",\n  \"shortcuts.prompt\": \"提示\",\n  \"shortcuts.prompt_placeholder\": \"为 AI 编写清晰的指令...\",\n  \"shortcuts.server_delete_disabled\": \"该快捷方式由服务端提供，无法删除\",\n  \"shortcuts.targets.entry\": \"条目详情\",\n  \"shortcuts.targets.help\": \"选择适用场景以控制此快捷方式的展示位置。\",\n  \"shortcuts.targets.label\": \"展示位置\",\n  \"shortcuts.targets.list\": \"时间线\",\n  \"shortcuts.title\": \"AI 快捷方式\",\n  \"shortcuts.updated\": \"快捷方式更新成功\",\n  \"shortcuts.validation.name_required\": \"名称为必填项\",\n  \"shortcuts.validation.prompt_required\": \"提示为必填项\",\n  \"shortcuts.validation.required\": \"名称和提示为必填项\",\n  \"shortcuts.validation.targets_required\": \"至少选择一个展示位置\",\n  \"summary_not_available\": \"摘要不可用\",\n  \"tasks.actions.delete_task\": \"删除任务\",\n  \"tasks.actions.edit_task\": \"编辑任务\",\n  \"tasks.actions.new_task\": \"新建任务\",\n  \"tasks.actions.schedule\": \"安排任务\",\n  \"tasks.actions.scheduling\": \"正在安排...\",\n  \"tasks.actions.test_run\": \"测试运行\",\n  \"tasks.actions.update\": \"更新任务\",\n  \"tasks.actions.updating\": \"正在更新...\",\n  \"tasks.actions.view_reports\": \"查看报告\",\n  \"tasks.empty.desc\": \"创建您的第一个 AI 任务以自动化您的工作流程。\",\n  \"tasks.empty.title\": \"暂无计划任务\",\n  \"tasks.fields.created\": \"创建时间：\",\n  \"tasks.fields.prompt\": \"指令：\",\n  \"tasks.fields.schedule\": \"计划：\",\n  \"tasks.manage.desc\": \"创建并管理按计划自动运行的 AI 任务。\",\n  \"tasks.manage.limit_reached\": \"(已达到最大任务数量限制)\",\n  \"tasks.manage.title\": \"计划 AI 任务\",\n  \"tasks.modal.delete_confirm\": \"确定要删除任务 \\\"{{name}}\\\" 吗？\",\n  \"tasks.modal.delete_title\": \"删除任务\",\n  \"tasks.modal.edit_title\": \"编辑 AI 任务\",\n  \"tasks.modal.new_title\": \"新建 AI 任务\",\n  \"tasks.name\": \"任务名称\",\n  \"tasks.name_placeholder\": \"输入一个描述性的任务名称...\",\n  \"tasks.notify.coming_soon\": \"即将推出\",\n  \"tasks.notify.email\": \"邮件\",\n  \"tasks.notify.email_helper\": \"任务完成后将结果发送到您的账户邮箱。\",\n  \"tasks.prompt\": \"指令\",\n  \"tasks.prompt_helper\": \"请提供清晰、具体的指令供 AI 执行\",\n  \"tasks.prompt_placeholder\": \"描述当此任务运行时您希望 AI 做什么...\",\n  \"tasks.schedule.daily\": \"每天 {{time}}\",\n  \"tasks.schedule.monthly\": \"每月第 {{day}} 天 {{time}}\",\n  \"tasks.schedule.once\": \"{{date}} {{time}} 单次\",\n  \"tasks.schedule.unknown\": \"未知计划\",\n  \"tasks.schedule.weekly\": \"每周{{day}} {{time}}\",\n  \"tasks.section.info\": \"任务信息\",\n  \"tasks.section.instructions\": \"AI 指令\",\n  \"tasks.section.notifications\": \"通知\",\n  \"tasks.section.schedule\": \"计划配置\",\n  \"tasks.section.title\": \"AI 任务\",\n  \"tasks.status.completed\": \"已完成\",\n  \"tasks.status.paused\": \"已暂停\",\n  \"tasks.status.scheduled\": \"已计划\",\n  \"tasks.status.unknown\": \"未知\",\n  \"tasks.toast.create_error\": \"安排 AI 任务失败，请重试。\",\n  \"tasks.toast.created\": \"AI 任务安排成功\",\n  \"tasks.toast.delete_failed\": \"删除任务失败，请重试。\",\n  \"tasks.toast.delete_success\": \"任务删除成功\",\n  \"tasks.toast.load_failed\": \"加载聊天消息失败\",\n  \"tasks.toast.no_report\": \"此任务尚无报告会话。\",\n  \"tasks.toast.switch_to_chat\": \"切换到聊天面板查看报告。\",\n  \"tasks.toast.test_failed\": \"测试运行失败，请重试。\",\n  \"tasks.toast.test_start\": \"正在运行…\",\n  \"tasks.toast.test_success\": \"测试成功运行。您可以切换到聊天面板查看报告。\",\n  \"tasks.toast.update_error\": \"更新 AI 任务失败，请重试。\",\n  \"tasks.toast.update_failed\": \"更新任务失败，请重试。\",\n  \"tasks.toast.updated\": \"AI 任务更新成功\",\n  \"tasks.validation.date_future\": \"计划时间必须是未来的时间\",\n  \"tasks.validation.prompt_max\": \"指令必须少于 2000 个字符\",\n  \"tasks.validation.prompt_required\": \"指令为必填项\",\n  \"tasks.validation.title_max\": \"标题必须少于 50 个字符\",\n  \"tasks.validation.title_required\": \"标题为必填项\",\n  \"tasks.view_in_settings\": \"在设置中查看已计划任务\",\n  \"timeline.summary.title_template\": \"{{- datetime}} 时间线总结\",\n  \"timeline_prompt.prompt.help\": \"这些信息会被用于 AI 时间线排序，写出你喜欢的主题、来源及希望降权的内容。\",\n  \"timeline_prompt.prompt.placeholder\": \"描述下你希望 AI 时间线优先展示或避开的内容……\",\n  \"timeline_prompt.saved\": \"时间线排序偏好已保存\",\n  \"timeline_prompt.title\": \"AI 时间线排序提示\",\n  \"timeline_summary.empty\": \"时间线总结即将呈现。\",\n  \"timeline_summary.error\": \"暂时无法生成时间线总结。\",\n  \"timeline_summary.generating\": \"正在生成时间线总结...\",\n  \"timeline_summary.heading\": \"时间线有什么新内容\",\n  \"timeline_summary.options.include\": \"与时间线总结聊天\",\n  \"token_usage.description\": \"监控您的 AI 令牌消耗和限制\",\n  \"token_usage.resets_at\": \"重置时间\",\n  \"token_usage.title\": \"令牌使用情况\",\n  \"token_usage.tokens_remaining\": \"剩余令牌\",\n  \"token_usage.tokens_used\": \"已使用{{used}} / {{total}}令牌\",\n  \"token_usage_pill.billed\": \"计费\",\n  \"token_usage_pill.byok\": \"BYOK\",\n  \"token_usage_pill.credits\": \"额度\",\n  \"token_usage_pill.credits_usage\": \"AI 额度使用\",\n  \"token_usage_pill.duration\": \"持续时间\",\n  \"token_usage_pill.model_info\": \"模型信息\",\n  \"token_usage_pill.multiplier\": \"倍数\",\n  \"token_usage_pill.provider_info\": \"提供商信息\",\n  \"token_usage_pill.system\": \"系统\",\n  \"token_usage_pill.total\": \"总计\",\n  \"token_usage_pill.unknown\": \"未知\",\n  \"usage_analysis.active_session\": \"活跃会话\",\n  \"usage_analysis.current_usage\": \"当前使用情况\",\n  \"usage_analysis.detailed_description\": \"全面了解您的 AI 使用模式和洞察\",\n  \"usage_analysis.detailed_title\": \"AI 使用分析\",\n  \"usage_analysis.no_data\": \"没有可用的使用数据\",\n  \"usage_analysis.resets_in\": \"重置时间\",\n  \"usage_analysis.session_duration\": \"持续时间：{{duration}}\",\n  \"usage_analysis.title\": \"令牌使用情况\",\n  \"usage_analysis.tokens_remaining\": \"剩余令牌\",\n  \"usage_analysis.tokens_used\": \"已使用令牌\",\n  \"usage_analysis.total_credits\": \"总额度\",\n  \"usage_analysis.total_limit\": \"总限制\",\n  \"usage_analysis.view_details\": \"查看详情\",\n  \"usage_analysis.warning.general\": \"请考虑降低使用以避免达到限制\",\n  \"usage_analysis.warning.projected\": \"预计在 {{eta}} 达到限制\",\n  \"usage_analysis.warning.rate\": \"速率：{{rate}} 令牌/分钟\",\n  \"usage_analysis.warning.title\": \"检测到较高的 AI 使用量\",\n  \"usage_analysis.window_remaining\": \"窗口内剩余额度\",\n  \"welcome_description\": \"我在这里帮助您处理阅读需求。今天我能为您做些什么？\",\n  \"welcome_description_contextual\": \"让我们一起讨论这个条目\"\n}\n"
  },
  {
    "path": "locales/ai/zh-TW.json",
    "content": "{\n  \"ai_summary\": \"AI 摘要\",\n  \"analytics.chart_placeholder\": \"圖表可視化將透過 Recharts 實現\",\n  \"analytics.efficiency_analysis\": \"效率分析\",\n  \"analytics.efficiency_placeholder\": \"模型效率比較和優化建議\",\n  \"analytics.history_operation\": \"{{operation}} 操作\",\n  \"analytics.history_usage\": \"令牌使用\",\n  \"analytics.no_data\": \"所選期間沒有可用的使用資料\",\n  \"analytics.no_history\": \"沒有可用的使用歷史\",\n  \"analytics.operation_types.chat\": \"聊天\",\n  \"analytics.operation_types.onboarding\": \"入門\",\n  \"analytics.operation_types.task\": \"任務\",\n  \"analytics.operation_types.title_generation\": \"標題生成\",\n  \"analytics.patterns_placeholder\": \"模式分析將顯示高峰使用時間和習慣\",\n  \"analytics.tabs.efficiency\": \"效率\",\n  \"analytics.tabs.history\": \"歷史\",\n  \"analytics.tabs.overview\": \"概覽\",\n  \"analytics.tabs.patterns\": \"模式\",\n  \"analytics.trend.operations\": \"操作次數\",\n  \"analytics.trend.title\": \"最近30天使用趨勢\",\n  \"analytics.trend.tokens\": \"使用令牌\",\n  \"analytics.usage_history\": \"使用歷史\",\n  \"analytics.usage_patterns\": \"使用模式\",\n  \"analytics.usage_trends\": \"使用趨勢（最近30天）\",\n  \"byok.description\": \"使用您自己的 AI 提供商 API 金鑰，以便更好地控制成本和用量。\",\n  \"byok.enabled\": \"啟用 BYOK\",\n  \"byok.providers.add\": \"新增提供商\",\n  \"byok.providers.add_title\": \"新增 BYOK 提供商\",\n  \"byok.providers.added\": \"提供商新增成功\",\n  \"byok.providers.configured\": \"已設定\",\n  \"byok.providers.delete\": \"刪除提供商\",\n  \"byok.providers.delete_message\": \"確定要刪除此提供商嗎？此操作無法撤銷。\",\n  \"byok.providers.delete_title\": \"刪除提供商\",\n  \"byok.providers.deleted\": \"提供商刪除成功\",\n  \"byok.providers.edit\": \"編輯提供商\",\n  \"byok.providers.edit_title\": \"編輯 BYOK 提供商\",\n  \"byok.providers.empty.description\": \"新增您自己的 AI 提供商 API 金鑰以使用您自己的基礎設施。\",\n  \"byok.providers.empty.title\": \"未設定提供商\",\n  \"byok.providers.form.api_key\": \"API 金鑰\",\n  \"byok.providers.form.api_key_help\": \"您的 API 金鑰將被安全儲存和加密。\",\n  \"byok.providers.form.api_key_placeholder\": \"輸入您的 API 金鑰\",\n  \"byok.providers.form.base_url\": \"基礎 URL\",\n  \"byok.providers.form.base_url_help\": \"選填。留空以使用預設 API 端點。\",\n  \"byok.providers.form.base_url_placeholder\": \"例如：https://api.openai.com/v1\",\n  \"byok.providers.form.provider\": \"提供商\",\n  \"byok.providers.headers_count\": \"個自訂請求標頭\",\n  \"byok.providers.no_api_key\": \"未設定 API 金鑰\",\n  \"byok.providers.title\": \"提供商\",\n  \"byok.providers.updated\": \"提供商更新成功\",\n  \"byok.title\": \"自帶金鑰 (BYOK)\",\n  \"chat.history.auto_title\": \"{{- datetime}} 聊天\",\n  \"clear_chat\": \"清空對話\",\n  \"clear_chat_message\": \"確定要清空所有歷史記錄嗎？此操作將永久刪除所有內容，包括所有聊天記錄和數據，且無法撤銷。\",\n  \"common.generate_title\": \"生成標題\",\n  \"common.generating_title\": \"正在生成標題...\",\n  \"common.new_chat\": \"新對話\",\n  \"customize_shortcuts\": \"自訂快速鍵\",\n  \"delete_chat\": \"刪除對話\",\n  \"delete_chat_error\": \"刪除對話失敗\",\n  \"delete_chat_message\": \"確定要刪除\\\"{{title}}\\\"嗎？此操作無法撤銷。\",\n  \"delete_chat_success\": \"對話刪除成功\",\n  \"features.title\": \"功能\",\n  \"integration.mcp.description\": \"透過安全的 OAuth 整合連接到相容 MCP 的服務，擴充 AI 功能。\",\n  \"integration.mcp.enabled\": \"啟用 MCP 服務\",\n  \"integration.mcp.security.description\": \"OAuth 驗證令牌使用 AES-GCM-256 加密技術安全儲存，以保護您的憑據並確保資料隱私。\",\n  \"integration.mcp.security.title\": \"安全聲明\",\n  \"integration.mcp.service.active\": \"活躍\",\n  \"integration.mcp.service.added\": \"MCP 服務新增成功\",\n  \"integration.mcp.service.auth_message\": \"此服務需要 OAuth 授權。點擊\\\"開啟授權\\\"以在新視窗中進行身份驗證。\",\n  \"integration.mcp.service.auth_required\": \"需要授權\",\n  \"integration.mcp.service.auth_window_opened\": \"授權視窗已打開。請完成身份驗證。\",\n  \"integration.mcp.service.baseUrl\": \"服務基礎 URL\",\n  \"integration.mcp.service.baseUrl_placeholder\": \"例如：https://api.example.com\",\n  \"integration.mcp.service.connect\": \"連接\",\n  \"integration.mcp.service.connected\": \"已連接\",\n  \"integration.mcp.service.connected_success\": \"成功連接到 MCP 服務\",\n  \"integration.mcp.service.connecting\": \"連線中...\",\n  \"integration.mcp.service.connection_failed\": \"連接 MCP 服務失敗\",\n  \"integration.mcp.service.degraded\": \"降級\",\n  \"integration.mcp.service.delete_message\": \"您確定要刪除此 MCP 服務嗎？此操作無法撤銷。\",\n  \"integration.mcp.service.delete_title\": \"刪除 MCP 服務\",\n  \"integration.mcp.service.deleted\": \"MCP 服務刪除成功\",\n  \"integration.mcp.service.disabled\": \"已停用\",\n  \"integration.mcp.service.disconnect\": \"斷開連接\",\n  \"integration.mcp.service.disconnected\": \"已斷開連接\",\n  \"integration.mcp.service.discover\": \"探索服務\",\n  \"integration.mcp.service.discovery_failed\": \"探索服務端點失敗\",\n  \"integration.mcp.service.enabled\": \"已啟用\",\n  \"integration.mcp.service.endpoints\": \"OAuth 端點\",\n  \"integration.mcp.service.error\": \"連接錯誤\",\n  \"integration.mcp.service.healthy\": \"健康\",\n  \"integration.mcp.service.inactive\": \"非活躍\",\n  \"integration.mcp.service.name\": \"服務名稱\",\n  \"integration.mcp.service.name_placeholder\": \"例如：GitHub 工具、Slack 集成\",\n  \"integration.mcp.service.open_auth\": \"開啟授權\",\n  \"integration.mcp.service.open_manually\": \"啟動授權\",\n  \"integration.mcp.service.popup_blocked\": \"授權彈出視窗被瀏覽器阻擋\",\n  \"integration.mcp.service.reconnect\": \"重新連接\",\n  \"integration.mcp.service.scopes\": \"所需權限範圍\",\n  \"integration.mcp.service.unhealthy\": \"不健康\",\n  \"integration.mcp.service.updated\": \"MCP 服務更新成功\",\n  \"integration.mcp.service.validation.baseUrl_required\": \"基礎 URL 為必填項目\",\n  \"integration.mcp.service.validation.invalid_url\": \"請輸入有效的 URL\",\n  \"integration.mcp.service.validation.name_required\": \"服務名稱為必填項目\",\n  \"integration.mcp.services.add\": \"新增服務\",\n  \"integration.mcp.services.empty.description\": \"連接 MCP 服務以透過外部工具、API 和資料源來擴展 AI 功能。\",\n  \"integration.mcp.services.empty.title\": \"未連接 MCP 服務\",\n  \"integration.mcp.services.title\": \"MCP 服務\",\n  \"integration.security.description\": \"OAuth 驗證令牌和 AI BYOK API 金鑰使用 AES-GCM-256 加密技術安全儲存，以保護您的憑據並確保資料隱私。\",\n  \"integration.security.title\": \"安全聲明\",\n  \"integration.title\": \"整合\",\n  \"memories.actions.load_more\": \"載入更多\",\n  \"memories.actions.loading\": \"載入中...\",\n  \"memories.importance\": \"重要性\",\n  \"memories.list.description\": \"AI 會在與你的交流中逐漸了解你。這些記憶能幫助它更好地理解你的偏好與習慣。\",\n  \"memories.list.empty.description\": \"隨著你與 AI 的對話，它會逐漸學習你的偏好和習慣。記憶會顯示在這裡。\",\n  \"memories.list.empty.title\": \"還沒有印象\",\n  \"memories.section.title\": \"AI 對你的印象\",\n  \"memories.toast.deleted\": \"記憶已刪除\",\n  \"memories.toast.failed\": \"儲存記憶失敗\",\n  \"memories.toast.processing\": \"處理中...\",\n  \"memories.toast.saved\": \"記憶已儲存\",\n  \"memories.toast.saving\": \"正在儲存記憶...\",\n  \"memories.toast.updated\": \"記憶已更新\",\n  \"memories.toast.updating\": \"正在更新記憶...\",\n  \"mentions.date.relative.last_15_days.label\": \"最近 15 天\",\n  \"mentions.date.relative.last_15_days.search\": \"最近 15 天|近半個月\",\n  \"mentions.date.relative.last_30_days.label\": \"最近 30 天\",\n  \"mentions.date.relative.last_30_days.search\": \"最近 30 天|近一個月\",\n  \"mentions.date.relative.last_3_days.label\": \"最近 3 天\",\n  \"mentions.date.relative.last_3_days.search\": \"最近 3 天|近三天\",\n  \"mentions.date.relative.last_7_days.label\": \"最近 7 天\",\n  \"mentions.date.relative.last_7_days.search\": \"最近 7 天|近一週\",\n  \"mentions.date.relative.last_month.label\": \"上個月\",\n  \"mentions.date.relative.last_month.search\": \"上個月|上月|上一個月\",\n  \"mentions.date.relative.last_week.label\": \"上週\",\n  \"mentions.date.relative.last_week.search\": \"上週|上一週|上星期|上個星期|上禮拜|上個禮拜\",\n  \"mentions.date.relative.this_month.label\": \"這個月\",\n  \"mentions.date.relative.this_month.search\": \"這個月|本月|這月\",\n  \"mentions.date.relative.this_week.label\": \"這周\",\n  \"mentions.date.relative.this_week.search\": \"這周|本週|這星期|本星期|這禮拜|本禮拜\",\n  \"mentions.date.relative.today.label\": \"今天\",\n  \"mentions.date.relative.today.search\": \"今天|今日\",\n  \"mentions.date.relative.yesterday.label\": \"昨天\",\n  \"mentions.date.relative.yesterday.search\": \"昨天|昨日\",\n  \"mentions.date.weekday.auto.label\": \"{{weekday}}\",\n  \"mentions.date.weekday.day.friday.label\": \"週五\",\n  \"mentions.date.weekday.day.friday.search\": \"週五|星期五|禮拜五\",\n  \"mentions.date.weekday.day.monday.label\": \"週一\",\n  \"mentions.date.weekday.day.monday.search\": \"週一|星期一|禮拜一\",\n  \"mentions.date.weekday.day.saturday.label\": \"週六\",\n  \"mentions.date.weekday.day.saturday.search\": \"週六|星期六|禮拜六\",\n  \"mentions.date.weekday.day.sunday.label\": \"週日\",\n  \"mentions.date.weekday.day.sunday.search\": \"週日|星期日|禮拜日|周天|星期天|禮拜天\",\n  \"mentions.date.weekday.day.thursday.label\": \"週四\",\n  \"mentions.date.weekday.day.thursday.search\": \"週四|星期四|禮拜四\",\n  \"mentions.date.weekday.day.tuesday.label\": \"週二\",\n  \"mentions.date.weekday.day.tuesday.search\": \"週二|星期二|禮拜二\",\n  \"mentions.date.weekday.day.wednesday.label\": \"週三\",\n  \"mentions.date.weekday.day.wednesday.search\": \"週三|星期三|禮拜三\",\n  \"mentions.date.weekday.last.label\": \"上週{{weekday}}\",\n  \"mentions.date.weekday.prefix.auto.search\": \"\",\n  \"mentions.date.weekday.prefix.last.search\": \"上週|上一週|上星期|上個星期|上禮拜|上個禮拜\",\n  \"mentions.date.weekday.prefix.this.search\": \"這周|本週|這星期|本星期|這禮拜|本禮拜\",\n  \"mentions.date.weekday.this.label\": \"本週{{weekday}}\",\n  \"mentions.section.category\": \"分類\",\n  \"mentions.section.date\": \"日期\",\n  \"mentions.section.entry\": \"條目\",\n  \"mentions.section.feed\": \"RSS 摘要\",\n  \"mentions.section.view\": \"視圖\",\n  \"new_shortcuts\": \"新增快速鍵\",\n  \"personalize.description\": \"介紹一下您自己，以獲得個性化的 AI 回應。\",\n  \"personalize.prompt.help\": \"這有助於 AI 根據您的偏好提供個性化的回應。\",\n  \"personalize.prompt.placeholder\": \"介紹一下您自己以及您希望如何閱讀內容...\",\n  \"personalize.saved\": \"個人化設定儲存成功\",\n  \"personalize.title\": \"個性化\",\n  \"quick_actions.discuss\": \"討論見解\",\n  \"quick_actions.questions\": \"提出問題\",\n  \"quick_actions.simplify\": \"簡化內容\",\n  \"quick_actions.takeaways\": \"關鍵重點\",\n  \"rate_limit.credits_left\": \"剩餘 {{count}} 額度\",\n  \"rate_limit.depleted\": \"AI 額度已用盡\",\n  \"rate_limit.minute\": \"分鐘\",\n  \"rate_limit.minutes\": \"分鐘\",\n  \"rate_limit.resets_at\": \"將於 {{time}} 重設\",\n  \"rate_limit.resets_in\": \"將在 {{value}} 後重設\",\n  \"rate_limit.resets_next_month\": \"將於下個月重設\",\n  \"rate_limit.resets_tomorrow\": \"將於明天重設\",\n  \"rate_limit.upgrade_plan_button\": \"免費試用\",\n  \"rate_limit.upgrade_to_get_more\": \"開啟免費試用以獲得更多 AI 額度。\",\n  \"schedule.configuration_label\": \"設定\",\n  \"schedule.date_time_label\": \"日期和時間\",\n  \"schedule.date_time_placeholder\": \"選擇日期和時間\",\n  \"schedule.day_label\": \"日期\",\n  \"schedule.day_placeholder\": \"選擇日期\",\n  \"schedule.days.friday\": \"星期五\",\n  \"schedule.days.monday\": \"星期一\",\n  \"schedule.days.saturday\": \"星期六\",\n  \"schedule.days.sunday\": \"星期日\",\n  \"schedule.days.thursday\": \"星期四\",\n  \"schedule.days.tuesday\": \"星期二\",\n  \"schedule.days.wednesday\": \"星期三\",\n  \"schedule.frequency.daily\": \"每日\",\n  \"schedule.frequency.monthly\": \"每月\",\n  \"schedule.frequency.once\": \"單次\",\n  \"schedule.frequency.weekly\": \"每週\",\n  \"schedule.next_execution\": \"下次執行：{{time}} ({{relative}})\",\n  \"schedule.no_upcoming\": \"沒有即將執行的任務\",\n  \"schedule.presets.daily_6pm\": \"每天下午 6 點\",\n  \"schedule.presets.first_9am\": \"1 號上午 9 點\",\n  \"schedule.presets.monday_9am\": \"週一上午 9 點\",\n  \"schedule.presets.tomorrow_9am\": \"明天上午 9 點\",\n  \"schedule.presets_title\": \"快速預設\",\n  \"schedule.time_label\": \"時間\",\n  \"schedule.time_placeholder\": \"選擇時間\",\n  \"schedule.title\": \"排程\",\n  \"session.interrupted.message\": \"上次請求似乎未完成，請重試以繼續。\",\n  \"session.interrupted.retry\": \"重新傳送\",\n  \"settings.autoScrollWhenStreaming.description\": \"在串流時自動捲動到聊天底部。\",\n  \"settings.autoScrollWhenStreaming.label\": \"串流時自動捲動\",\n  \"settings.description\": \"設定您的 AI 體驗並建立自訂快速鍵\",\n  \"settings.panel_style.description\": \"AI 聊天面板的樣式\",\n  \"settings.panel_style.fixed\": \"固定\",\n  \"settings.panel_style.floating\": \"浮動\",\n  \"settings.panel_style.label\": \"面板樣式\",\n  \"settings.showSplineButton.description\": \"顯示或隱藏右下角的 AI 助手指示器。\",\n  \"settings.showSplineButton.label\": \"AI 助手指示器\",\n  \"settings.title\": \"AI 設定\",\n  \"shortcuts.actions.delete\": \"刪除快速鍵\",\n  \"shortcuts.actions.disable\": \"停用快速鍵\",\n  \"shortcuts.actions.edit\": \"編輯快速鍵\",\n  \"shortcuts.add\": \"新增快速鍵\",\n  \"shortcuts.added\": \"快捷方式新增成功\",\n  \"shortcuts.context_menu.empty.entry\": \"尚無可用於條目詳情的快速鍵\",\n  \"shortcuts.context_menu.empty.list\": \"列表視圖尚無可用的快速鍵\",\n  \"shortcuts.create_first\": \"建立您的第一個快速鍵\",\n  \"shortcuts.custom_prompt.help\": \"留空則使用伺服器端提供的預設提示。\",\n  \"shortcuts.custom_prompt.title\": \"自訂提示\",\n  \"shortcuts.custom_prompt_placeholder\": \"覆蓋預設提示內容...\",\n  \"shortcuts.default_prompt.label\": \"預設提示\",\n  \"shortcuts.deleted\": \"快速鍵刪除成功\",\n  \"shortcuts.empty.description\": \"建立自訂 AI 快速鍵，快速執行常見任務並獲得即時 AI 協助。\",\n  \"shortcuts.empty.title\": \"尚無快速鍵\",\n  \"shortcuts.enabled\": \"已啟用\",\n  \"shortcuts.icon\": \"圖示\",\n  \"shortcuts.manage\": \"管理快速鍵\",\n  \"shortcuts.name\": \"名稱\",\n  \"shortcuts.name_placeholder\": \"例如：摘要文章\",\n  \"shortcuts.prompt\": \"提示\",\n  \"shortcuts.prompt_placeholder\": \"為 AI 撰寫清晰的指令...\",\n  \"shortcuts.server_delete_disabled\": \"該快速鍵由伺服器端提供，無法刪除\",\n  \"shortcuts.targets.entry\": \"條目詳情\",\n  \"shortcuts.targets.help\": \"選擇適用場景以控制此快捷方式的展示位置。\",\n  \"shortcuts.targets.label\": \"展示位置\",\n  \"shortcuts.targets.list\": \"時間軸\",\n  \"shortcuts.title\": \"AI 快捷方式\",\n  \"shortcuts.updated\": \"快捷方式更新成功\",\n  \"shortcuts.validation.name_required\": \"名稱為必填項目\",\n  \"shortcuts.validation.prompt_required\": \"提示為必填項目\",\n  \"shortcuts.validation.required\": \"名稱和提示為必填項目\",\n  \"shortcuts.validation.targets_required\": \"至少選擇一個展示位置\",\n  \"summary_not_available\": \"摘要不可用\",\n  \"tasks.actions.delete_task\": \"刪除任務\",\n  \"tasks.actions.edit_task\": \"編輯任務\",\n  \"tasks.actions.new_task\": \"新增任務\",\n  \"tasks.actions.schedule\": \"安排任務\",\n  \"tasks.actions.scheduling\": \"正在安排...\",\n  \"tasks.actions.test_run\": \"測試執行\",\n  \"tasks.actions.update\": \"更新任務\",\n  \"tasks.actions.updating\": \"正在更新...\",\n  \"tasks.actions.view_reports\": \"查看報告\",\n  \"tasks.empty.desc\": \"創建您的第一個 AI 任務以自動化您的工作流程。\",\n  \"tasks.empty.title\": \"暫無計劃任務\",\n  \"tasks.fields.created\": \"建立時間：\",\n  \"tasks.fields.prompt\": \"指令：\",\n  \"tasks.fields.schedule\": \"排程：\",\n  \"tasks.manage.desc\": \"建立並管理按排程自動執行的 AI 任務。\",\n  \"tasks.manage.limit_reached\": \"(已達到最大任務數量限制)\",\n  \"tasks.manage.title\": \"排程 AI 任務\",\n  \"tasks.modal.delete_confirm\": \"確定要刪除任務 \\\"{{name}}\\\" 嗎？\",\n  \"tasks.modal.delete_title\": \"刪除任務\",\n  \"tasks.modal.edit_title\": \"編輯 AI 任務\",\n  \"tasks.modal.new_title\": \"新增 AI 任務\",\n  \"tasks.name\": \"任務名稱\",\n  \"tasks.name_placeholder\": \"輸入一個描述性的任務名稱...\",\n  \"tasks.notify.coming_soon\": \"即將推出\",\n  \"tasks.notify.email\": \"郵件\",\n  \"tasks.notify.email_helper\": \"任務完成後將結果發送到您的帳戶信箱。\",\n  \"tasks.prompt\": \"指令\",\n  \"tasks.prompt_helper\": \"請提供清晰、具體的指令供 AI 執行\",\n  \"tasks.prompt_placeholder\": \"描述當此任務執行時您希望 AI 做什麼...\",\n  \"tasks.schedule.daily\": \"每天 {{time}}\",\n  \"tasks.schedule.monthly\": \"每月第 {{day}} 天 {{time}}\",\n  \"tasks.schedule.once\": \"{{date}} {{time}} 單次\",\n  \"tasks.schedule.unknown\": \"未知排程\",\n  \"tasks.schedule.weekly\": \"每週{{day}} {{time}}\",\n  \"tasks.section.info\": \"任務資訊\",\n  \"tasks.section.instructions\": \"AI 指令\",\n  \"tasks.section.notifications\": \"通知\",\n  \"tasks.section.schedule\": \"排程設定\",\n  \"tasks.section.title\": \"AI 任務\",\n  \"tasks.status.completed\": \"已完成\",\n  \"tasks.status.paused\": \"已暫停\",\n  \"tasks.status.scheduled\": \"已排程\",\n  \"tasks.status.unknown\": \"未知\",\n  \"tasks.toast.create_error\": \"安排 AI 任務失敗，請重試。\",\n  \"tasks.toast.created\": \"AI 任務安排成功\",\n  \"tasks.toast.delete_failed\": \"刪除任務失敗，請重試。\",\n  \"tasks.toast.delete_success\": \"任務刪除成功\",\n  \"tasks.toast.load_failed\": \"載入聊天訊息失敗\",\n  \"tasks.toast.no_report\": \"此任務尚無報告。\",\n  \"tasks.toast.switch_to_chat\": \"切換到聊天面板檢視報告。\",\n  \"tasks.toast.test_failed\": \"測試執行失敗，請重試。\",\n  \"tasks.toast.test_start\": \"開始執行...\",\n  \"tasks.toast.test_success\": \"報測試已成功執行。您可以切換到聊天面板查看告。\",\n  \"tasks.toast.update_error\": \"更新 AI 任務失敗，請重試。\",\n  \"tasks.toast.update_failed\": \"更新任務失敗，請重試。\",\n  \"tasks.toast.updated\": \"AI 任務更新成功\",\n  \"tasks.validation.date_future\": \"排程時間必須是未來的時間\",\n  \"tasks.validation.prompt_max\": \"指令必須少於 2000 個字元\",\n  \"tasks.validation.prompt_required\": \"指令為必填項目\",\n  \"tasks.validation.title_max\": \"標題必須少於 50 個字元\",\n  \"tasks.validation.title_required\": \"標題為必填項目\",\n  \"tasks.view_in_settings\": \"在設定中檢視已排程任務\",\n  \"timeline.summary.title_template\": \"{{- datetime}} 時間軸摘要\",\n  \"timeline_prompt.prompt.help\": \"這些資訊會用於 AI 時間軸排序，請寫下你偏好的主題、來源，以及想降低權重的內容。\",\n  \"timeline_prompt.prompt.placeholder\": \"描述你希望 AI 時間軸優先顯示或避免的內容……\",\n  \"timeline_prompt.saved\": \"時間軸排序偏好已儲存\",\n  \"timeline_prompt.title\": \"AI 時間軸排序提示\",\n  \"timeline_summary.empty\": \"時間軸摘要即將呈現。\",\n  \"timeline_summary.error\": \"暫時無法產生時間軸摘要。\",\n  \"timeline_summary.generating\": \"正在產生時間軸摘要...\",\n  \"timeline_summary.heading\": \"時間軸有什麼新內容\",\n  \"timeline_summary.options.include\": \"與時間軸摘要聊天\",\n  \"token_usage.description\": \"監控您的 AI 令牌消耗和限制\",\n  \"token_usage.resets_at\": \"重設時間\",\n  \"token_usage.title\": \"令牌使用狀況\",\n  \"token_usage.tokens_remaining\": \"剩餘令牌\",\n  \"token_usage.tokens_used\": \"已使用 {{used}} / {{total}} 令牌\",\n  \"token_usage_pill.billed\": \"計費\",\n  \"token_usage_pill.byok\": \"BYOK\",\n  \"token_usage_pill.credits\": \"額度\",\n  \"token_usage_pill.credits_usage\": \"AI 額度使用\",\n  \"token_usage_pill.duration\": \"持續時間\",\n  \"token_usage_pill.model_info\": \"模型資訊\",\n  \"token_usage_pill.multiplier\": \"倍數\",\n  \"token_usage_pill.provider_info\": \"提供商資訊\",\n  \"token_usage_pill.system\": \"系統\",\n  \"token_usage_pill.total\": \"總計\",\n  \"token_usage_pill.unknown\": \"未知\",\n  \"usage_analysis.active_session\": \"活躍工作階段\",\n  \"usage_analysis.current_usage\": \"目前使用狀況\",\n  \"usage_analysis.detailed_description\": \"全面瞭解您的 AI 使用模式和洞察\",\n  \"usage_analysis.detailed_title\": \"AI 使用分析\",\n  \"usage_analysis.no_data\": \"沒有可用的使用資料\",\n  \"usage_analysis.resets_in\": \"重設時間\",\n  \"usage_analysis.session_duration\": \"持續時間：{{duration}}\",\n  \"usage_analysis.title\": \"令牌使用狀況\",\n  \"usage_analysis.tokens_remaining\": \"剩餘令牌\",\n  \"usage_analysis.tokens_used\": \"已使用令牌\",\n  \"usage_analysis.total_credits\": \"總額度\",\n  \"usage_analysis.total_limit\": \"總限制\",\n  \"usage_analysis.view_details\": \"檢視詳情\",\n  \"usage_analysis.warning.general\": \"請考慮降低使用以避免達到限制\",\n  \"usage_analysis.warning.projected\": \"預計在 {{eta}} 達到限制\",\n  \"usage_analysis.warning.rate\": \"速率：{{rate}} 令牌/分鐘\",\n  \"usage_analysis.warning.title\": \"檢測到較高的 AI 使用量\",\n  \"usage_analysis.window_remaining\": \"視窗內剩餘額度\",\n  \"welcome_description\": \"我在這裡幫助您處理閱讀需求。今天我能為您做些什麼？\",\n  \"welcome_description_contextual\": \"讓我們一起討論這個條目\"\n}\n"
  },
  {
    "path": "locales/app/en.json",
    "content": "{\n  \"achievement.all_done\": \"All done!\",\n  \"achievement.alpha_tester\": \"Alpha Tester\",\n  \"achievement.alpha_tester_description\": \"You are an alpha tester of Folo\",\n  \"achievement.description\": \"Be a hardcore player and mint NFTs.\",\n  \"achievement.first_claim_feed\": \"Feed Owner\",\n  \"achievement.first_claim_feed_description\": \"You own your feed on Folo\",\n  \"achievement.first_create_list\": \"List Creator\",\n  \"achievement.first_create_list_description\": \"You created your first list on Folo\",\n  \"achievement.follow_special_feed\": \"Special Feed Follower\",\n  \"achievement.follow_special_feed_description\": \"You followed the special feed on Folo\",\n  \"achievement.list_subscribe_100\": \"100 List Subscriber\",\n  \"achievement.list_subscribe_100_description\": \"100 subscribers subscribed to the list you created\",\n  \"achievement.list_subscribe_50\": \"50 List Subscriber\",\n  \"achievement.list_subscribe_500\": \"500 List Subscriber\",\n  \"achievement.list_subscribe_500_description\": \"500 subscribers subscribed to the list you created\",\n  \"achievement.list_subscribe_50_description\": \"50 subscribers subscribed to the list you created\",\n  \"achievement.nft_coming_soon\": \"You cannot mint NFTs at this time. Once we are ready, they will be automatically credited to your account.\",\n  \"achievement.product_hunt_vote\": \"Product Hunt Upvoter\",\n  \"achievement.product_hunt_vote_description\": \"You supported Folo on Product Hunt\",\n  \"activation.activate\": \"Activate\",\n  \"activation.description\": \"During the public testing phase, you need an invitation code to use this feature.\",\n  \"activation.plan.description\": \"You are currently using the free plan. Start free trial to unlock more features.\",\n  \"activation.plan.title\": \"Upgrade Plan (Free Trial)\",\n  \"activation.plan.upgrade\": \"Free Trial\",\n  \"activation.title\": \"Invitation Code\",\n  \"ai.summary_not_available\": \"Summary not available\",\n  \"ai.summary_upgrade_required_description\": \"Unlock unlimited AI summaries, translations, and more intelligent features with Pro plan.\",\n  \"ai.summary_upgrade_required_title\": \"Start free trial to continue using AI summary\",\n  \"ai.summary_upgrade_view_plans\": \"View Plans\",\n  \"app.copy_logo_svg\": \"Copy Logo SVG\",\n  \"app.copy_logo_text_svg\": \"Copy Logo Text SVG\",\n  \"app.toggle_sidebar\": \"Toggle Sidebar\",\n  \"discover.any_url_or_keyword\": \"Any URL or Keyword\",\n  \"discover.default_option\": \" (default)\",\n  \"discover.feed_description\": \"The description of this feed is as follows, and you can fill out the parameter form with the relevant information.\",\n  \"discover.feed_maintainers\": \"This feed is provided by RSSHub, with credit to <maintainers />\",\n  \"discover.import.click_to_upload\": \"Click to upload OPML file\",\n  \"discover.import.conflictItems\": \"Conflict Items\",\n  \"discover.import.import_completed_with_issues\": \"Import completed with some issues\",\n  \"discover.import.import_successful\": \"Import completed successfully\",\n  \"discover.import.noItems\": \"No items\",\n  \"discover.import.no_feeds_found\": \"No feeds found matching your search.\",\n  \"discover.import.opml\": \"OPML file\",\n  \"discover.import.opml_step1\": \"Export OPML from your RSS reader\",\n  \"discover.import.opml_step1_feedly\": \"Export OPML from Feedly\",\n  \"discover.import.opml_step1_feedly_step1\": \"Open <Link />.\",\n  \"discover.import.opml_step1_feedly_step2\": \"Click the \\\"Download Your Feedly OPML\\\" button.\",\n  \"discover.import.opml_step1_inoreader\": \"Export OPML from Inoreader\",\n  \"discover.import.opml_step1_inoreader_step1\": \"Open <Link />.\",\n  \"discover.import.opml_step1_inoreader_step2\": \"Switch to the \\\"SYSTEM FOLDERS\\\" tab.\",\n  \"discover.import.opml_step1_inoreader_step3\": \"Click the \\\"OPML\\\" button on the right side of \\\"Newsfeed\\\".\",\n  \"discover.import.opml_step1_other\": \"Export OPML from other readers\",\n  \"discover.import.opml_step1_other_step1\": \"OPML is a widely supported open format and is essentially the standard for sharing feed subscription lists. Nearly all RSS readers allow for OPML import and export. If you're unsure how to do this, please refer to your RSS reader's help manual or join our community for assistance.\",\n  \"discover.import.opml_step2\": \"Import OPML to Folo\",\n  \"discover.import.parse_opml\": \"Parse OPML\",\n  \"discover.import.parsedErrorItems\": \"Parsed Error Items\",\n  \"discover.import.preview_opml_content\": \"Preview OPML Content\",\n  \"discover.import.quota_exceeded\": \"Quota exceeded\",\n  \"discover.import.quota_exceeded_warning\": \"You have selected more feeds than your remaining quota allows. Please deselect some feeds to continue.\",\n  \"discover.import.quota_limit_reached\": \"Quota limit reached\",\n  \"discover.import.quota_status\": \"Import quota:\",\n  \"discover.import.quota_warning\": \"You have {{remaining}} feeds remaining in your quota.\",\n  \"discover.import.remaining_quota\": \"You can import {{remaining}} more feeds\",\n  \"discover.import.result\": \"<SuccessfulNum /> feeds were successfully imported.<br /><ConflictNum /> were already subscribed to.<br /><ErrorNum /> failed to import.\",\n  \"discover.import.search_feeds_placeholder\": \"Search feeds...\",\n  \"discover.import.select_all_feeds\": \"Select all feeds\",\n  \"discover.import.select_all_filtered\": \"Select all filtered\",\n  \"discover.import.select_feeds_description\": \"Review and select which feeds you want to import. All feeds are selected by default.\",\n  \"discover.import.select_feeds_to_import\": \"Select feeds to import\",\n  \"discover.import.successfulItems\": \"Successful Items\",\n  \"discover.inbox.actions\": \"Actions\",\n  \"discover.inbox.description\": \"You can receive information via email and webhooks through the inbox.\",\n  \"discover.inbox.email\": \"Email\",\n  \"discover.inbox.handle\": \"Handle\",\n  \"discover.inbox.no_inbox\": \"You currently have no inbox, click the button below to create your first inbox.\",\n  \"discover.inbox.secret\": \"Secret\",\n  \"discover.inbox.title\": \"Title\",\n  \"discover.inbox.webhooks_docs\": \"Webhooks docs\",\n  \"discover.inbox_create\": \"New Inbox\",\n  \"discover.inbox_create_description\": \"You don't have an inbox yet. Create an inbox to receive information via inbox.\",\n  \"discover.inbox_create_error\": \"Failed to create inbox.\",\n  \"discover.inbox_create_success\": \"Inbox created successfully.\",\n  \"discover.inbox_destroy\": \"Destroy\",\n  \"discover.inbox_destroy_confirm\": \"Confirm destroy the inbox?\",\n  \"discover.inbox_destroy_error\": \"Failed to destroy inbox.\",\n  \"discover.inbox_destroy_success\": \"Inbox destroyed successfully.\",\n  \"discover.inbox_destroy_warning\": \"Warning: once destroyed, the email will no longer be available, and all content will be permanently deleted and unrecoverable.\",\n  \"discover.inbox_handle\": \"Handle\",\n  \"discover.inbox_title\": \"Title\",\n  \"discover.inbox_update\": \"Update\",\n  \"discover.inbox_update_error\": \"Failed to change inbox.\",\n  \"discover.inbox_update_success\": \"Inbox updated successfully.\",\n  \"discover.popular\": \"Popular\",\n  \"discover.preview\": \"Preview\",\n  \"discover.rss_hub_route\": \"RSSHub Route\",\n  \"discover.rss_url\": \"RSS URL\",\n  \"discover.search.results_one\": \"Found {{count}} feed\",\n  \"discover.search.results_other\": \"Found {{count}} feeds\",\n  \"discover.search.results_zero\": \"No feeds found\",\n  \"discover.select_placeholder\": \"Select\",\n  \"discover.target.feeds\": \"Feeds\",\n  \"discover.target.label\": \"Search for\",\n  \"discover.target.lists\": \"Lists\",\n  \"discover.tips.auto_detect\": \"Auto-detect input type\",\n  \"discover.tips.search_keyword\": \"Search by keyword, URL, RSS feed, or RSSHub route\",\n  \"discover.tools.description\": \"More Tools\",\n  \"discover.tools.import\": \"Import OPML\",\n  \"discover.tools.inbox\": \"Inbox\",\n  \"discover.tools.transform\": \"HTML to RSS\",\n  \"discover.tools.user\": \"Follow User\",\n  \"entry.click_to_return\": \"Scroll up or click here to return\",\n  \"entry.exit_detail\": \"Return to timeline\",\n  \"entry.scroll_up_to_exit\": \"Scroll up to return to timeline\",\n  \"entry_actions.copied_notify\": \"{{which}} copied to clipboard.\",\n  \"entry_actions.copy_link\": \"Copy Link\",\n  \"entry_actions.copy_title\": \"Copy Title\",\n  \"entry_actions.delete\": \"Delete\",\n  \"entry_actions.deleted\": \"Deleted.\",\n  \"entry_actions.export_as_pdf\": \"Export as PDF\",\n  \"entry_actions.failed_to_delete\": \"Failed to delete.\",\n  \"entry_actions.failed_to_login_to_qbittorrent\": \"Failed to login to qBittorrent\",\n  \"entry_actions.failed_to_save_to_cubox\": \"Failed to save to Cubox.\",\n  \"entry_actions.failed_to_save_to_eagle\": \"Failed to save to Eagle.\",\n  \"entry_actions.failed_to_save_to_instapaper\": \"Failed to save to Instapaper.\",\n  \"entry_actions.failed_to_save_to_obsidian\": \"Failed to save to Obsidian\",\n  \"entry_actions.failed_to_save_to_outline\": \"Failed to save to Outline.\",\n  \"entry_actions.failed_to_save_to_qbittorrent\": \"Failed to download with qBittorrent\",\n  \"entry_actions.failed_to_save_to_readeck\": \"Failed to save to Readeck.\",\n  \"entry_actions.failed_to_save_to_readwise\": \"Failed to save to Readwise.\",\n  \"entry_actions.failed_to_save_to_zotero\": \"Failed to save to Zotero.\",\n  \"entry_actions.image_gallery\": \"Image Gallery\",\n  \"entry_actions.image_gallery_description\": \"When there are multiple large images in the article, you can browse all images in the article in Gallery Modal\",\n  \"entry_actions.mark_above_as_read\": \"Mark Above as Read\",\n  \"entry_actions.mark_as_read\": \"Mark as Read / UnRead\",\n  \"entry_actions.mark_as_unread\": \"Mark as Unread\",\n  \"entry_actions.mark_below_as_read\": \"Mark Below as Read\",\n  \"entry_actions.no_bittorrent_urls_found\": \"No BitTorrent URLs found in this entry.\",\n  \"entry_actions.open_in_browser\": \"Open in {{which}}\",\n  \"entry_actions.recent_reader\": \"Recent Reader:\",\n  \"entry_actions.save_media_to_eagle\": \"Save Media to Eagle\",\n  \"entry_actions.save_to_cubox\": \"Save to Cubox\",\n  \"entry_actions.save_to_instapaper\": \"Save to Instapaper\",\n  \"entry_actions.save_to_obsidian\": \"Save to Obsidian\",\n  \"entry_actions.save_to_outline\": \"Save to Outline\",\n  \"entry_actions.save_to_qbittorrent\": \"Download with qBittorrent\",\n  \"entry_actions.save_to_readeck\": \"Save to Readeck\",\n  \"entry_actions.save_to_readwise\": \"Save to Readwise\",\n  \"entry_actions.save_to_zotero\": \"Save to Zotero\",\n  \"entry_actions.saved_to_cubox\": \"Saved to Cubox\",\n  \"entry_actions.saved_to_eagle\": \"Saved to Eagle.\",\n  \"entry_actions.saved_to_instapaper\": \"Saved to Instapaper.\",\n  \"entry_actions.saved_to_obsidian\": \"Saved to Obsidian\",\n  \"entry_actions.saved_to_outline\": \"Saved to Outline.\",\n  \"entry_actions.saved_to_qbittorrent\": \"Torrents added to qBittorrent.\",\n  \"entry_actions.saved_to_readeck\": \"Saved to Readeck.\",\n  \"entry_actions.saved_to_readwise\": \"Saved to Readwise.\",\n  \"entry_actions.saved_to_zotero\": \"Saved to Zotero\",\n  \"entry_actions.share\": \"Share\",\n  \"entry_actions.star\": \"Star / UnStar\",\n  \"entry_actions.starred\": \"Starred.\",\n  \"entry_actions.toggle_ai_summary\": \"AI Summary\",\n  \"entry_actions.toggle_ai_translation\": \"AI Translation\",\n  \"entry_actions.unstar\": \"Unstar\",\n  \"entry_actions.unstarred\": \"Unstarred.\",\n  \"entry_actions.view_source_content\": \"View Source Content\",\n  \"entry_actions.view_source_content_description\": \"Try to read this article's content in the style of the source website\",\n  \"entry_actions.warn_info_ai_chat_pinned_tip\": \"This feature is currently in gray testing, and only a small number of users have access permissions.\",\n  \"entry_actions.warn_info_for_desktop\": \"This feature is only available in the desktop app\",\n  \"entry_column.filtered_content_tip\": \"You have filtered content hidden.\",\n  \"entry_column.filtered_content_tip_2\": \"In addition to the entries shown above, there is also filtered content.\",\n  \"entry_column.refreshing\": \"Refreshing new entries...\",\n  \"entry_content.ai_summary\": \"AI summary\",\n  \"entry_content.fetching_content\": \"Fetching original content and processing...\",\n  \"entry_content.fetching_content_failed\": \"Readability: Failed to fetch original content, try again later or back to the RSS content.\",\n  \"entry_content.header.play_tts\": \"Play TTS\",\n  \"entry_content.header.play_tts_description\": \"Select a TTS voice in settings and start TTS to convert to audio content\",\n  \"entry_content.header.readability\": \"Readability\",\n  \"entry_content.header.readability_description\": \"Using readability's ability to obtain the original website content and parse it may allow for more readable content. Recommended to use it when the RSS content is not available or incomplete.\",\n  \"entry_content.no_content\": \"No media available\",\n  \"entry_content.readability_notice\": \"This content is provided by Readability. If you find typographical anomalies, please go to the source site to view the original content.\",\n  \"entry_content.render_error\": \"Render error:\",\n  \"entry_content.report_issue\": \"Report issue\",\n  \"entry_content.selection_toolbar.ask_ai\": \"Ask AI\",\n  \"entry_content.selection_toolbar.copied\": \"Copied\",\n  \"entry_content.selection_toolbar.copy\": \"Copy\",\n  \"entry_content.selection_toolbar.copy_image\": \"Copy Image\",\n  \"entry_content.selection_toolbar.copying\": \"Copying...\",\n  \"entry_content.selection_toolbar.generating\": \"Generating...\",\n  \"entry_content.selection_toolbar.poster_copied\": \"Poster copied to clipboard\",\n  \"entry_content.selection_toolbar.poster_copy_failed\": \"Failed to copy poster\",\n  \"entry_content.selection_toolbar.share\": \"Share\",\n  \"entry_content.selection_toolbar.share_poster\": \"Share Poster\",\n  \"entry_content.web_app_notice\": \"Maybe web app doesn't support this content type. But you can download the desktop app.\",\n  \"entry_list.zero_unread\": \"Zero Unread\",\n  \"entry_list_header.ai_timeline\": \"AI timeline\",\n  \"entry_list_header.ai_timeline_loading\": \"Reordering timeline with AI...\",\n  \"entry_list_header.ai_timeline_prompt_required\": \"Set your AI timeline sorting prompt first\",\n  \"entry_list_header.grid\": \"Grid\",\n  \"entry_list_header.image_only\": \"Image Only\",\n  \"entry_list_header.items\": \"items\",\n  \"entry_list_header.masonry\": \"Masonry\",\n  \"entry_list_header.masonry_column\": \"Masonry Column\",\n  \"entry_list_header.preview_mode\": \"Preview Mode\",\n  \"entry_list_header.refetch\": \"Refetch\",\n  \"entry_list_header.refresh\": \"Refresh\",\n  \"entry_list_header.show_all\": \"Show all\",\n  \"entry_list_header.show_unread_only\": \"Show unread Only\",\n  \"entry_list_header.timeline_summary\": \"Summarize timeline\",\n  \"entry_list_header.unread\": \"unread\",\n  \"feed.actions.follow\": \"Follow\",\n  \"feed.actions.followed\": \"Followed\",\n  \"feed.followsAndFeeds\": \"{{subscriptionCount}} {{subscriptionNoun}} and {{feedsCount}} {{feedsNoun}} on {{appName}}\",\n  \"feed.read_one\": \"read\",\n  \"feed.read_other\": \"reads\",\n  \"feed_category.onboarding_feed\": \"This category contains onboarding feeds\",\n  \"feed_claim_modal.choose_verification_method\": \"There are three ways to choose from, you can choose one of them to verify.\",\n  \"feed_claim_modal.claim_button\": \"Claim\",\n  \"feed_claim_modal.content_instructions\": \"Copy the content below and post it to your latest RSS feed.\",\n  \"feed_claim_modal.description_current\": \"Current description:\",\n  \"feed_claim_modal.description_instructions\": \"Copy the following content and paste it into the <code /> field of your RSS feed.\",\n  \"feed_claim_modal.failed_to_load\": \"Failed to load claim message\",\n  \"feed_claim_modal.rss_format_choice\": \"RSS generators generally have two formats to choose from. Please copy the XML and JSON formats below as needed.\",\n  \"feed_claim_modal.rss_instructions\": \"Copy the code below and paste it into your RSS generator.\",\n  \"feed_claim_modal.rss_json_format\": \"JSON Format\",\n  \"feed_claim_modal.rss_xml_format\": \"XML Format\",\n  \"feed_claim_modal.rsshub_notice\": \"This feed is provided by RSSHub with a 1 hour cache time. Please allow up to 1 hour for changes to appear after publishing content.\",\n  \"feed_claim_modal.tab_content\": \"Content\",\n  \"feed_claim_modal.tab_description\": \"Description\",\n  \"feed_claim_modal.tab_rss\": \"RSS Tag\",\n  \"feed_claim_modal.title\": \"Feed Claim\",\n  \"feed_claim_modal.verify_ownership\": \"To claim this feed as your own, you need to verify ownership.\",\n  \"feed_form.add_feed\": \"Add Feed\",\n  \"feed_form.add_follow\": \"Add follow\",\n  \"feed_form.category\": \"Category\",\n  \"feed_form.category_description\": \"By default, your follows will be grouped by website.\",\n  \"feed_form.error_fetching_feed\": \"Error in fetching feed.\",\n  \"feed_form.fee\": \"Follow fee\",\n  \"feed_form.fee_description\": \"To follow this list, you must pay a fee to the list creator.\",\n  \"feed_form.feed_not_found\": \"Feed not found.\",\n  \"feed_form.feedback\": \"Feedback\",\n  \"feed_form.fill_default\": \"Fill\",\n  \"feed_form.follow\": \"Follow\",\n  \"feed_form.follow_with_fee\": \"Follow with {{fee}} Power\",\n  \"feed_form.followed\": \"🎉 Followed.\",\n  \"feed_form.hide_from_timeline\": \"Hide from Timeline\",\n  \"feed_form.hide_from_timeline_description\": \"Whether this subscription's entries are visible on your main view timeline.\",\n  \"feed_form.private_follow\": \"Private Follow\",\n  \"feed_form.private_follow_description\": \"Whether this follow is publicly visible on your profile page.\",\n  \"feed_form.retry\": \"Retry\",\n  \"feed_form.title\": \"Title\",\n  \"feed_form.title_description\": \"Custom title for this Feed. Leave empty to use the default.\",\n  \"feed_form.unfollow\": \"Unfollow\",\n  \"feed_form.update\": \"Update\",\n  \"feed_form.update_follow\": \"Update follow\",\n  \"feed_form.updated\": \"🎉 Updated.\",\n  \"feed_form.view\": \"View\",\n  \"feed_item.claimed_by_owner\": \"This feed is claimed by\",\n  \"feed_item.claimed_by_unknown\": \"its owner.\",\n  \"feed_item.claimed_by_you\": \"Claimed by you\",\n  \"feed_item.claimed_feed\": \"Claimed Feed\",\n  \"feed_item.claimed_list\": \"Claimed List\",\n  \"feed_item.error_since\": \"Error since\",\n  \"feed_item.not_publicly_visible\": \"Not publicly visible on your profile page\",\n  \"feed_item.onboarding_feed\": \"This is an onboarding feed\",\n  \"login.agree_to\": \"By continuing, you agree to our\",\n  \"login.back\": \"Back\",\n  \"login.confirm_password.label\": \"Confirm Password\",\n  \"login.continueWith\": \"Continue with {{provider}}\",\n  \"login.email\": \"Email\",\n  \"login.enter_token\": \"Enter authorization token to continue\",\n  \"login.forget_password.note\": \"Forgot your password?\",\n  \"login.have_account\": \"Already have an account? <strong>Sign in</strong>\",\n  \"login.lastUsed\": \"Last used\",\n  \"login.magic_link_sent\": \"Magic link sent! Check your email.\",\n  \"login.no_account\": \"Don't have an account? <strong>Sign up</strong>\",\n  \"login.or\": \"OR\",\n  \"login.password\": \"Password\",\n  \"login.password_optional\": \"Optional\",\n  \"login.privacy\": \"Privacy Policy\",\n  \"login.send_magic_link\": \"Send Magic Link\",\n  \"login.signUp\": \"Sign up with email\",\n  \"login.submit\": \"Submit\",\n  \"login.terms\": \"Terms of Service\",\n  \"login.title\": \"Welcome to Folo\",\n  \"login.with_email.title\": \"Login with Email\",\n  \"mark_all_read_button.auto_confirm_info\": \"Will be confirmed automatically after {{countdown}}s.\",\n  \"mark_all_read_button.confirm\": \"Confirm\",\n  \"mark_all_read_button.confirm_mark_all\": \"Mark <which /> as read?\",\n  \"mark_all_read_button.confirm_mark_all_info\": \"Confirm mark all as read?\",\n  \"mark_all_read_button.done\": \"Marked\",\n  \"mark_all_read_button.mark_all_as_read\": \"Mark all as read\",\n  \"mark_all_read_button.mark_as_read\": \"Mark <which /> as read\",\n  \"mark_all_read_button.undo\": \"Undo\",\n  \"new_user_dialog.actions.close\": \"Close dialog\",\n  \"new_user_dialog.actions.finish\": \"Explore!\",\n  \"new_user_dialog.ai.description\": \"Answer a few prompts and Folo's AI curates feeds, lists, and reading plans for you.\",\n  \"new_user_dialog.ai.highlight_1\": \"Describe what you need in plain language.\",\n  \"new_user_dialog.ai.highlight_2\": \"Receive recommended sources with context for why they matter.\",\n  \"new_user_dialog.ai.highlight_3\": \"Keep chatting to refine summaries, tone, and frequency.\",\n  \"new_user_dialog.ai.primary\": \"Launch AI onboarding\",\n  \"new_user_dialog.ai.title\": \"Let the AI Copilot build your starter stack\",\n  \"new_user_dialog.import.description\": \"Import OPML exports from any reader and preview every feed before subscribing.\",\n  \"new_user_dialog.import.highlight_1\": \"Upload OPML files from Feedly, Inoreader, or any RSS reader.\",\n  \"new_user_dialog.import.highlight_2\": \"Preview and choose which feeds to keep.\",\n  \"new_user_dialog.import.highlight_3\": \"Organize imported feeds into folders and lists immediately.\",\n  \"new_user_dialog.import.primary\": \"Import OPML\",\n  \"new_user_dialog.import.title\": \"Bring everything you already follow\",\n  \"new_user_dialog.overview.description\": \"Folo is an AI RSS reader that brings feeds, newsletters, podcasts, and social updates into one focused home.\",\n  \"new_user_dialog.overview.highlight_1\": \"Pin feeds, lists, and topics to switch contexts instantly.\",\n  \"new_user_dialog.overview.highlight_2\": \"Use the two-column layout to skim updates while keeping the current entry open.\",\n  \"new_user_dialog.overview.highlight_3\": \"Keyboard shortcuts and filters keep you in flow.\",\n  \"new_user_dialog.overview.primary\": \"Browse Discover\",\n  \"new_user_dialog.overview.title\": \"See everything you care about in one calm home view\",\n  \"new_user_dialog.replay_video\": \"Replay video\",\n  \"new_user_dialog.step_label.ai\": \"Step 2 · AI Copilot\",\n  \"new_user_dialog.step_label.import\": \"Step 3 · Import\",\n  \"new_user_dialog.step_label.overview\": \"Step 1 · Overview\",\n  \"new_user_dialog.title\": \"Welcome to Folo\",\n  \"new_user_guide.actions.back\": \"Back\",\n  \"new_user_guide.actions.finish\": \"Finish\",\n  \"new_user_guide.actions.import_opml\": \"Import OPML\",\n  \"new_user_guide.actions.next\": \"Next\",\n  \"new_user_guide.ai_chat.intro\": \"Welcome to Folo, the AI reader that reads the Internet for you.\\n\\nTell me something about you.\",\n  \"new_user_guide.ai_chat.reroll\": \"Reroll\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\": \"I'm learning about AI regulation in the European Union\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\": \"I write a climate tech newsletter and need daily sources\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\": \"I follow cybersecurity and open-source vulnerability news\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\": \"I study drug delivery and want to learn about new FDA approvals\",\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\": \"I'm a fashion designer\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\": \"I'm an investor and need to keep up with stock market and company news\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\": \"I'm planning a trip to Japan and want local tips\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\": \"I research nano engineering\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\": \"I'm a NASA fan\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\": \"I'm building a personal finance tracker and need best practices\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\": \"I'm exploring plant-based cooking trends\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\": \"I need summaries for long podcasts about economics\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\": \"I coach a high school robotics team and hunt for project ideas\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\": \"I'm a marketing manager launching a SaaS product\",\n  \"new_user_guide.ai_chat.you_can_say\": \"You can say...\",\n  \"new_user_guide.confirm_skip.message\": \"Are you sure you want to skip the onboarding setup?\",\n  \"new_user_guide.confirm_skip.title\": \"Skip onboarding?\",\n  \"new_user_guide.intro.description\": \"This guide will help you get started with the app.\",\n  \"new_user_guide.intro.title\": \"Vibe Reading with AI\",\n  \"new_user_guide.selection.empty_description\": \"Describe what you're looking for in the AI chat and we'll recommend feeds for you.\",\n  \"new_user_guide.selection.empty_title\": \"No feeds selected yet\",\n  \"notify.store.default\": \"the store\",\n  \"notify.store.mas\": \"App Store\",\n  \"notify.store.mss\": \"Microsoft Store\",\n  \"notify.unfollow_feed\": \"<FeedItem /> have been unfollowed.\",\n  \"notify.unfollow_feed_many\": \"All selected feeds have been unfollowed.\",\n  \"notify.update_info\": \"{{app_name}} is ready to update!\",\n  \"notify.update_info_1\": \"Click to restart\",\n  \"notify.update_info_2\": \"Click to reload page\",\n  \"notify.update_info_3\": \"Touch to reload page\",\n  \"notify.update_info_store\": \"Click to open {{store}}\",\n  \"player.back_10s\": \"Back 10s\",\n  \"player.close\": \"Close\",\n  \"player.download\": \"Download\",\n  \"player.exit_full_screen\": \"Exit Full Screen\",\n  \"player.forward_10s\": \"Forward 10s\",\n  \"player.full_screen\": \"Full Screen\",\n  \"player.mute\": \"Mute\",\n  \"player.open_entry\": \"Open Entry\",\n  \"player.pause\": \"Pause\",\n  \"player.play\": \"Play\",\n  \"player.playback_rate\": \"Playback Rate\",\n  \"player.unmute\": \"Unmute\",\n  \"player.volume\": \"Volume\",\n  \"quick_add.placeholder\": \"Quick follow a feed, typing feed url here...\",\n  \"quick_add.title\": \"Quick Follow\",\n  \"register.confirm_password\": \"Confirm Password\",\n  \"register.email\": \"Email\",\n  \"register.login\": \"Login\",\n  \"register.magic_link_sent\": \"Magic link sent! Check your email.\",\n  \"register.password\": \"Password\",\n  \"register.password_optional\": \"Optional\",\n  \"register.referral.days\": \"Sign up with this referral code to get {{days}} days of Pro Preview\",\n  \"register.referral.description\": \"Sign up with referral code to get extra days of Pro Preview.\",\n  \"register.referral.invalid\": \"Invalid referral code\",\n  \"register.referral.label\": \"Referral Code\",\n  \"register.send_magic_link\": \"Send Magic Link\",\n  \"register.submit\": \"Create account\",\n  \"resize.tooltip.double_click_to_collapse\": \"<b>Double click</b> to reset to default size\",\n  \"resize.tooltip.drag_to_resize\": \"<b>Drag</b> to resize\",\n  \"search.empty.no_results\": \"No results found.\",\n  \"search.group.entries\": \"Entries\",\n  \"search.group.feeds\": \"Feeds\",\n  \"search.options.all\": \"All\",\n  \"search.options.entry\": \"Entry\",\n  \"search.options.feed\": \"Feed\",\n  \"search.options.search_type\": \"Search Type\",\n  \"search.placeholder\": \"Search...\",\n  \"search.result_count_local_mode\": \"(Local mode)\",\n  \"search.tooltip.local_search\": \"This search covers locally available data. Try a Refetch to include the latest data.\",\n  \"share.actions\": \"Actions\",\n  \"share.copy_failed\": \"Failed to copy link\",\n  \"share.copy_link\": \"Copy Link\",\n  \"share.default_description\": \"Check out this entry\",\n  \"share.default_title\": \"Entry Share\",\n  \"share.discover_more\": \"Discover more on Folo\",\n  \"share.link_copied\": \"Link copied to clipboard\",\n  \"share.social_media\": \"Social Media\",\n  \"share.system_share\": \"System Share\",\n  \"share.title\": \"Share Entry\",\n  \"shortcuts.guide.title\": \"Shortcuts Guideline\",\n  \"sidebar.add_more_feeds\": \"Add more feeds\",\n  \"sidebar.already_on_discover_page\": \"You're already on the Discover page! Add the content you want to follow to the panel on the right.\",\n  \"sidebar.category_remove_dialog.cancel\": \"Cancel\",\n  \"sidebar.category_remove_dialog.continue\": \"Ungroup\",\n  \"sidebar.category_remove_dialog.description\": \"Feeds in this category will be ungrouped and moved back to their default grouping.\",\n  \"sidebar.category_remove_dialog.error\": \"Failed to ungroup category\",\n  \"sidebar.category_remove_dialog.success\": \"Category ungrouped successfully\",\n  \"sidebar.category_remove_dialog.title\": \"Ungroup Category\",\n  \"sidebar.category_unsubscribe_dialog.cancel\": \"Cancel\",\n  \"sidebar.category_unsubscribe_dialog.confirm\": \"Unsubscribe {{count}} feeds\",\n  \"sidebar.category_unsubscribe_dialog.confirm_one\": \"Unsubscribe 1 feed\",\n  \"sidebar.category_unsubscribe_dialog.confirm_other\": \"Unsubscribe {{count}} feeds\",\n  \"sidebar.category_unsubscribe_dialog.confirm_zero\": \"Unsubscribe 0 feeds\",\n  \"sidebar.category_unsubscribe_dialog.description\": \"You are about to unsubscribe from {{count}} feeds in {{category}}. This action cannot be undone.\",\n  \"sidebar.category_unsubscribe_dialog.description_one\": \"You are about to unsubscribe from the only feed in {{category}}. This action cannot be undone.\",\n  \"sidebar.category_unsubscribe_dialog.description_other\": \"You are about to unsubscribe from {{count}} feeds in {{category}}. This action cannot be undone.\",\n  \"sidebar.category_unsubscribe_dialog.description_zero\": \"There are no feeds in {{category}} to unsubscribe.\",\n  \"sidebar.category_unsubscribe_dialog.error\": \"Failed to unsubscribe from category\",\n  \"sidebar.category_unsubscribe_dialog.success\": \"Unsubscribed from {{count}} feeds in {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.success_one\": \"Unsubscribed from 1 feed in {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.success_other\": \"Unsubscribed from {{count}} feeds in {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.success_zero\": \"No feeds were unsubscribed in {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.title\": \"Unsubscribe {{folderName}}\",\n  \"sidebar.feed_actions.claim\": \"Claim\",\n  \"sidebar.feed_actions.claim_feed\": \"Claim Feed\",\n  \"sidebar.feed_actions.copy_email_address\": \"Copy Email Address\",\n  \"sidebar.feed_actions.copy_feed_badge\": \"Copy Feed Badge\",\n  \"sidebar.feed_actions.copy_feed_id\": \"Copy Feed ID\",\n  \"sidebar.feed_actions.copy_feed_url\": \"Copy Feed URL\",\n  \"sidebar.feed_actions.copy_list_id\": \"Copy List ID\",\n  \"sidebar.feed_actions.copy_list_url\": \"Copy List URL\",\n  \"sidebar.feed_actions.create_list\": \"Create New List\",\n  \"sidebar.feed_actions.edit\": \"Edit\",\n  \"sidebar.feed_actions.edit_feed\": \"Edit Feed\",\n  \"sidebar.feed_actions.edit_inbox\": \"Edit Inbox\",\n  \"sidebar.feed_actions.edit_list\": \"Edit List\",\n  \"sidebar.feed_actions.feed_owned_by_you\": \"This feed is owned by you\",\n  \"sidebar.feed_actions.list_owned_by_you\": \"This List is owned by you\",\n  \"sidebar.feed_actions.mark_all_as_read\": \"Mark as Read\",\n  \"sidebar.feed_actions.navigate_to_feed\": \"Navigate to Feed\",\n  \"sidebar.feed_actions.navigate_to_list\": \"Navigate to List\",\n  \"sidebar.feed_actions.new_inbox\": \"New Inbox\",\n  \"sidebar.feed_actions.open_feed_in_browser\": \"Open Feed in {{which}}\",\n  \"sidebar.feed_actions.open_list_in_browser\": \"Open List in {{which}}\",\n  \"sidebar.feed_actions.open_site_in_browser\": \"Open Site in {{which}}\",\n  \"sidebar.feed_actions.reset_feed\": \"Reset Feed\",\n  \"sidebar.feed_actions.reset_feed_error\": \"Failed to reset feed.\",\n  \"sidebar.feed_actions.reset_feed_success\": \"Feed reset successfully.\",\n  \"sidebar.feed_actions.resetting_feed\": \"Resetting feed...\",\n  \"sidebar.feed_actions.unfollow\": \"Unfollow\",\n  \"sidebar.feed_actions.unfollow_feed\": \"Unfollow Feed\",\n  \"sidebar.feed_actions.unfollow_feed_many\": \"Unfollow All Selected Feeds\",\n  \"sidebar.feed_actions.unfollow_feed_many_confirm\": \"Confirm unfollow all selected feeds?\",\n  \"sidebar.feed_actions.unfollow_feed_many_warning\": \"Warning: This operation will unfollow all selected feeds, and cannot be undone.\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_category\": \"Move to Category\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_list\": \"Add Feeds to List\",\n  \"sidebar.feed_column.context_menu.change_to_other_view\": \"Switch to Another View\",\n  \"sidebar.feed_column.context_menu.create_category\": \"New Category\",\n  \"sidebar.feed_column.context_menu.mark_as_read\": \"Mark as Read\",\n  \"sidebar.feed_column.context_menu.new_category_modal.category_name\": \"Category Name\",\n  \"sidebar.feed_column.context_menu.new_category_modal.create\": \"Create\",\n  \"sidebar.feed_column.context_menu.rename_category\": \"Rename Category\",\n  \"sidebar.feed_column.context_menu.rename_category_error\": \"Failed to rename category\",\n  \"sidebar.feed_column.context_menu.rename_category_success\": \"Category renamed successfully\",\n  \"sidebar.feed_column.context_menu.title\": \"Move to New Category\",\n  \"sidebar.feed_column.context_menu.ungroup_category\": \"Ungroup Category\",\n  \"sidebar.feed_column.context_menu.ungroup_category_confirmation\": \"Ungroup category {{folderName}}?\",\n  \"sidebar.feed_column.context_menu.unsubscribe_category\": \"Unsubscribe All in Category\",\n  \"sidebar.select_sort_method\": \"Select a sort method\",\n  \"sidebar.timeline_tabs.customize\": \"Customize View Tabs...\",\n  \"sidebar.timeline_tabs.drag_tab\": \"Drag timeline tab\",\n  \"sidebar.timeline_tabs.empty_hidden\": \"Drag tabs here to hide them from the sidebar.\",\n  \"sidebar.timeline_tabs.empty_visible\": \"Drag tabs here to show them in the sidebar.\",\n  \"sidebar.timeline_tabs.hidden\": \"Hidden\",\n  \"sidebar.timeline_tabs.hide_tab\": \"Hide this view\",\n  \"sidebar.timeline_tabs.instructions\": \"Drag tabs between sections to reorder, show, or hide them.\",\n  \"sidebar.timeline_tabs.reset\": \"Reset to default\",\n  \"sidebar.timeline_tabs.visible\": \"Visible\",\n  \"signin.continue_with\": \"Continue with {{provider}}\",\n  \"signin.sign_in_to\": \"Sign in to\",\n  \"signin.sign_up_to\": \"Sign up to\",\n  \"subscription_limit_warning\": \"Subscription limit reached: <br /><b>{{feedCount}}/{{feedLimit}} feeds</b>, <br /><b>{{rsshubCount}}/{{rsshubLimit}} RSSHub</b>. <br /> Please start free trial to unlock more quota or clean up unused feeds.\",\n  \"sync_indicator.disabled\": \"Due to security reasons, sync is disabled.\",\n  \"sync_indicator.offline\": \"Offline\",\n  \"sync_indicator.synced\": \"Synced with server\",\n  \"trending.entry\": \"Trending Entries\",\n  \"trending.entry_no_results\": \"No trending entries found\",\n  \"trending.feed\": \"Trending Feeds\",\n  \"trending.list\": \"Trending Lists\",\n  \"trending.user\": \"Trending Users\",\n  \"tutorial.scroll_to_exit.description\": \"Scroll up when at the top to quickly return to timeline.\",\n  \"tutorial.scroll_to_exit.dismiss_hint\": \"Click to dismiss\",\n  \"tutorial.scroll_to_exit.title\": \"Scroll up to return\",\n  \"user_button.account\": \"Account\",\n  \"user_button.achievement\": \"Achievements\",\n  \"user_button.actions\": \"Actions\",\n  \"user_button.ai\": \"AI\",\n  \"user_button.download_desktop_app\": \"Download Desktop app\",\n  \"user_button.log_out\": \"Log out\",\n  \"user_button.power\": \"Power\",\n  \"user_button.preferences\": \"Preferences\",\n  \"user_button.profile\": \"Profile\",\n  \"user_profile.about\": \"About\",\n  \"user_profile.close\": \"Close\",\n  \"user_profile.created_lists\": \"Created Lists\",\n  \"user_profile.edit\": \"Edit\",\n  \"user_profile.loading\": \"Loading\",\n  \"user_profile.share\": \"Share\",\n  \"user_profile.subscriptions\": \"Subscriptions\",\n  \"user_profile.toggle_item_style\": \"Toggle Item Style\",\n  \"words.achievement\": \"Achievements\",\n  \"words.actions\": \"Actions\",\n  \"words.add\": \"Add\",\n  \"words.all\": \"All\",\n  \"words.browser\": \"Browser\",\n  \"words.categories\": \"Categories\",\n  \"words.confirm\": \"Confirm\",\n  \"words.discover\": \"Discover\",\n  \"words.email\": \"Email\",\n  \"words.feeds\": \"Feeds\",\n  \"words.import\": \"Import\",\n  \"words.inbox\": \"Inbox\",\n  \"words.items\": \"Items\",\n  \"words.language\": \"Language\",\n  \"words.link\": \"Link\",\n  \"words.lists\": \"Lists\",\n  \"words.load_archived_entries\": \"Load archived entries\",\n  \"words.login\": \"Login\",\n  \"words.mint\": \"Mint\",\n  \"words.newTab\": \"New Tab\",\n  \"words.power\": \"Power\",\n  \"words.rss\": \"RSS\",\n  \"words.rss3\": \"RSS3\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.search\": \"Search\",\n  \"words.show_more\": \"Show more\",\n  \"words.starred\": \"Starred\",\n  \"words.title\": \"Title\",\n  \"words.transform\": \"Transform\",\n  \"words.trending\": \"Trending\",\n  \"words.undo\": \"Undo\",\n  \"words.unread\": \"Unread\",\n  \"words.user\": \"User\",\n  \"words.view\": \"View\",\n  \"words.which.all\": \"all\",\n  \"words.zero_items\": \"Zero items\"\n}\n"
  },
  {
    "path": "locales/app/fr-FR.json",
    "content": "{\n  \"achievement.all_done\": \"Tout est fait !\",\n  \"achievement.alpha_tester\": \"Testeur Alpha\",\n  \"achievement.alpha_tester_description\": \"Vous êtes un testeur alpha de Folo\",\n  \"achievement.description\": \"Soyez un joueur hardcore et mintez des NFT.\",\n  \"achievement.first_claim_feed\": \"Propriétaire de Flux\",\n  \"achievement.first_claim_feed_description\": \"Vous possédez votre flux sur Folo\",\n  \"achievement.first_create_list\": \"Créateur de Liste\",\n  \"achievement.first_create_list_description\": \"Vous avez créé votre première liste sur Folo\",\n  \"achievement.follow_special_feed\": \"Abonné Flux Spécial\",\n  \"achievement.follow_special_feed_description\": \"Vous avez suivi le flux spécial sur Folo\",\n  \"achievement.list_subscribe_100\": \"100 Abonnés Liste\",\n  \"achievement.list_subscribe_100_description\": \"100 abonnés se sont abonnés à la liste que vous avez créée\",\n  \"achievement.list_subscribe_50\": \"50 Abonnés Liste\",\n  \"achievement.list_subscribe_500\": \"500 Abonnés Liste\",\n  \"achievement.list_subscribe_500_description\": \"500 abonnés se sont abonnés à la liste que vous avez créée\",\n  \"achievement.list_subscribe_50_description\": \"50 abonnés se sont abonnés à la liste que vous avez créée\",\n  \"achievement.nft_coming_soon\": \"Vous ne pouvez pas minter de NFT pour le moment. Une fois prêts, ils seront automatiquement crédités sur votre compte.\",\n  \"achievement.product_hunt_vote\": \"Product Hunt Upvoter\",\n  \"achievement.product_hunt_vote_description\": \"Vous avez soutenu Folo sur Product Hunt\",\n  \"activation.activate\": \"Activer\",\n  \"activation.description\": \"Pendant la phase de test public, vous avez besoin d'un code d'invitation pour utiliser cette fonctionnalité.\",\n  \"activation.plan.description\": \"Vous utilisez actuellement le plan gratuit. Démarrez l'essai gratuit pour débloquer plus de fonctionnalités.\",\n  \"activation.plan.title\": \"Mettre à niveau le plan (Essai gratuit)\",\n  \"activation.plan.upgrade\": \"Essai gratuit\",\n  \"activation.title\": \"Code d'invitation\",\n  \"ai.summary_not_available\": \"Résumé non disponible\",\n  \"ai.summary_upgrade_required_description\": \"Débloquez les résumés IA illimités, les traductions et plus de fonctionnalités intelligentes avec le plan Pro.\",\n  \"ai.summary_upgrade_required_title\": \"Démarrez l'essai gratuit pour continuer à utiliser le résumé IA\",\n  \"ai.summary_upgrade_view_plans\": \"Voir les plans\",\n  \"app.copy_logo_svg\": \"Copier le logo SVG\",\n  \"app.copy_logo_text_svg\": \"Copier le texte du logo SVG\",\n  \"app.toggle_sidebar\": \"Basculer la barre latérale\",\n  \"discover.any_url_or_keyword\": \"N'importe quelle URL ou mot-clé\",\n  \"discover.default_option\": \" (défaut)\",\n  \"discover.feed_description\": \"La description de ce flux est la suivante, et vous pouvez remplir le formulaire de paramètres avec les informations pertinentes.\",\n  \"discover.feed_maintainers\": \"Ce flux est fourni par RSSHub, crédit à <maintainers />\",\n  \"discover.import.click_to_upload\": \"Cliquez pour télécharger le fichier OPML\",\n  \"discover.import.conflictItems\": \"Éléments en conflit\",\n  \"discover.import.import_completed_with_issues\": \"Importation terminée avec quelques problèmes\",\n  \"discover.import.import_successful\": \"Importation terminée avec succès\",\n  \"discover.import.noItems\": \"Aucun élément\",\n  \"discover.import.no_feeds_found\": \"Aucun flux trouvé correspondant à votre recherche.\",\n  \"discover.import.opml\": \"Fichier OPML\",\n  \"discover.import.opml_step1\": \"Exporter l'OPML depuis votre lecteur RSS\",\n  \"discover.import.opml_step1_feedly\": \"Exporter l'OPML depuis Feedly\",\n  \"discover.import.opml_step1_feedly_step1\": \"Ouvrir <Link />.\",\n  \"discover.import.opml_step1_feedly_step2\": \"Cliquez sur le bouton \\\"Download Your Feedly OPML\\\".\",\n  \"discover.import.opml_step1_inoreader\": \"Exporter l'OPML depuis Inoreader\",\n  \"discover.import.opml_step1_inoreader_step1\": \"Ouvrir <Link />.\",\n  \"discover.import.opml_step1_inoreader_step2\": \"Passez à l'onglet \\\"SYSTEM FOLDERS\\\".\",\n  \"discover.import.opml_step1_inoreader_step3\": \"Cliquez sur le bouton \\\"OPML\\\" sur le côté droit de \\\"Newsfeed\\\".\",\n  \"discover.import.opml_step1_other\": \"Exporter l'OPML depuis d'autres lecteurs\",\n  \"discover.import.opml_step1_other_step1\": \"OPML est un format ouvert largement pris en charge et est essentiellement la norme pour partager des listes d'abonnement aux flux. Presque tous les lecteurs RSS permettent l'importation et l'exportation OPML. Si vous ne savez pas comment faire, veuillez consulter le manuel d'aide de votre lecteur RSS ou rejoindre notre communauté pour obtenir de l'aide.\",\n  \"discover.import.opml_step2\": \"Importer l'OPML vers Folo\",\n  \"discover.import.parse_opml\": \"Analyser l'OPML\",\n  \"discover.import.parsedErrorItems\": \"Éléments en erreur d'analyse\",\n  \"discover.import.preview_opml_content\": \"Aperçu du contenu OPML\",\n  \"discover.import.quota_exceeded\": \"Quota dépassé\",\n  \"discover.import.quota_exceeded_warning\": \"Vous avez sélectionné plus de flux que votre quota restant ne le permet. Veuillez désélectionner certains flux pour continuer.\",\n  \"discover.import.quota_limit_reached\": \"Limite de quota atteinte\",\n  \"discover.import.quota_status\": \"Quota d'importation :\",\n  \"discover.import.quota_warning\": \"Il vous reste {{remaining}} flux dans votre quota.\",\n  \"discover.import.remaining_quota\": \"Vous pouvez importer {{remaining}} flux supplémentaires\",\n  \"discover.import.result\": \"<SuccessfulNum /> flux ont été importés avec succès.<br /><ConflictNum /> étaient déjà abonnés.<br /><ErrorNum /> ont échoué à l'importation.\",\n  \"discover.import.search_feeds_placeholder\": \"Rechercher des flux...\",\n  \"discover.import.select_all_feeds\": \"Sélectionner tous les flux\",\n  \"discover.import.select_all_filtered\": \"Sélectionner tous les filtrés\",\n  \"discover.import.select_feeds_description\": \"Vérifiez et sélectionnez les flux que vous souhaitez importer. Tous les flux sont sélectionnés par défaut.\",\n  \"discover.import.select_feeds_to_import\": \"Sélectionner les flux à importer\",\n  \"discover.import.successfulItems\": \"Éléments réussis\",\n  \"discover.inbox.actions\": \"Actions\",\n  \"discover.inbox.description\": \"Vous pouvez recevoir des informations par email et webhooks via la boîte de réception.\",\n  \"discover.inbox.email\": \"Email\",\n  \"discover.inbox.handle\": \"Identifiant\",\n  \"discover.inbox.no_inbox\": \"Vous n'avez actuellement aucune boîte de réception, cliquez sur le bouton ci-dessous pour créer votre première boîte de réception.\",\n  \"discover.inbox.secret\": \"Secret\",\n  \"discover.inbox.title\": \"Titre\",\n  \"discover.inbox.webhooks_docs\": \"Docs Webhooks\",\n  \"discover.inbox_create\": \"Nouvelle boîte de réception\",\n  \"discover.inbox_create_description\": \"Vous n'avez pas encore de boîte de réception. Créez une boîte de réception pour recevoir des informations.\",\n  \"discover.inbox_create_error\": \"Échec de la création de la boîte de réception.\",\n  \"discover.inbox_create_success\": \"Boîte de réception créée avec succès.\",\n  \"discover.inbox_destroy\": \"Détruire\",\n  \"discover.inbox_destroy_confirm\": \"Confirmer la destruction de la boîte de réception ?\",\n  \"discover.inbox_destroy_error\": \"Échec de la destruction de la boîte de réception.\",\n  \"discover.inbox_destroy_success\": \"Boîte de réception détruite avec succès.\",\n  \"discover.inbox_destroy_warning\": \"Attention : une fois détruite, l'email ne sera plus disponible, et tout le contenu sera définitivement supprimé et irrécupérable.\",\n  \"discover.inbox_handle\": \"Identifiant\",\n  \"discover.inbox_title\": \"Titre\",\n  \"discover.inbox_update\": \"Mettre à jour\",\n  \"discover.inbox_update_error\": \"Échec de la modification de la boîte de réception.\",\n  \"discover.inbox_update_success\": \"Boîte de réception mise à jour avec succès.\",\n  \"discover.popular\": \"Populaire\",\n  \"discover.preview\": \"Aperçu\",\n  \"discover.rss_hub_route\": \"Route RSSHub\",\n  \"discover.rss_url\": \"URL RSS\",\n  \"discover.search.results_one\": \"Trouvé {{count}} flux\",\n  \"discover.search.results_other\": \"Trouvé {{count}} flux\",\n  \"discover.search.results_zero\": \"Aucun flux trouvé\",\n  \"discover.select_placeholder\": \"Sélectionner\",\n  \"discover.target.feeds\": \"Flux\",\n  \"discover.target.label\": \"Rechercher\",\n  \"discover.target.lists\": \"Listes\",\n  \"discover.tips.auto_detect\": \"Détection auto du type d'entrée\",\n  \"discover.tips.search_keyword\": \"Recherche par mot-clé, URL, flux RSS ou route RSSHub\",\n  \"discover.tools.description\": \"Plus d'outils\",\n  \"discover.tools.import\": \"Importer OPML\",\n  \"discover.tools.inbox\": \"Boîte de réception\",\n  \"discover.tools.transform\": \"HTML vers RSS\",\n  \"discover.tools.user\": \"Suivre l'utilisateur\",\n  \"entry.click_to_return\": \"Faites défiler vers le haut ou cliquez ici pour revenir\",\n  \"entry.exit_detail\": \"Revenir à la chronologie\",\n  \"entry.scroll_up_to_exit\": \"Faites défiler vers le haut pour revenir à la chronologie\",\n  \"entry_actions.copied_notify\": \"{{which}} copié dans le presse-papier.\",\n  \"entry_actions.copy_link\": \"Copier le lien\",\n  \"entry_actions.copy_title\": \"Copier le titre\",\n  \"entry_actions.delete\": \"Supprimer\",\n  \"entry_actions.deleted\": \"Supprimé.\",\n  \"entry_actions.export_as_pdf\": \"Exporter en PDF\",\n  \"entry_actions.failed_to_delete\": \"Échec de la suppression.\",\n  \"entry_actions.failed_to_login_to_qbittorrent\": \"Échec de la connexion à qBittorrent\",\n  \"entry_actions.failed_to_save_to_cubox\": \"Échec de l'enregistrement dans Cubox.\",\n  \"entry_actions.failed_to_save_to_eagle\": \"Échec de l'enregistrement dans Eagle.\",\n  \"entry_actions.failed_to_save_to_instapaper\": \"Échec de l'enregistrement dans Instapaper.\",\n  \"entry_actions.failed_to_save_to_obsidian\": \"Échec de l'enregistrement dans Obsidian\",\n  \"entry_actions.failed_to_save_to_outline\": \"Échec de l'enregistrement dans Outline.\",\n  \"entry_actions.failed_to_save_to_qbittorrent\": \"Échec du téléchargement avec qBittorrent\",\n  \"entry_actions.failed_to_save_to_readeck\": \"Échec de l'enregistrement dans Readeck.\",\n  \"entry_actions.failed_to_save_to_readwise\": \"Échec de l'enregistrement dans Readwise.\",\n  \"entry_actions.failed_to_save_to_zotero\": \"Échec de l'enregistrement dans Zotero.\",\n  \"entry_actions.image_gallery\": \"Galerie d'images\",\n  \"entry_actions.image_gallery_description\": \"Lorsqu'il y a plusieurs grandes images dans l'article, vous pouvez parcourir toutes les images de l'article dans la modale Galerie\",\n  \"entry_actions.mark_above_as_read\": \"Marquer ci-dessus comme lu\",\n  \"entry_actions.mark_as_read\": \"Marquer comme Lu / Non lu\",\n  \"entry_actions.mark_as_unread\": \"Marquer comme Non lu\",\n  \"entry_actions.mark_below_as_read\": \"Marquer ci-dessous comme lu\",\n  \"entry_actions.no_bittorrent_urls_found\": \"Aucune URL BitTorrent trouvée dans cette entrée.\",\n  \"entry_actions.open_in_browser\": \"Ouvrir dans {{which}}\",\n  \"entry_actions.recent_reader\": \"Lecteur récent :\",\n  \"entry_actions.save_media_to_eagle\": \"Enregistrer le média dans Eagle\",\n  \"entry_actions.save_to_cubox\": \"Enregistrer dans Cubox\",\n  \"entry_actions.save_to_instapaper\": \"Enregistrer dans Instapaper\",\n  \"entry_actions.save_to_obsidian\": \"Enregistrer dans Obsidian\",\n  \"entry_actions.save_to_outline\": \"Enregistrer dans Outline\",\n  \"entry_actions.save_to_qbittorrent\": \"Télécharger avec qBittorrent\",\n  \"entry_actions.save_to_readeck\": \"Enregistrer dans Readeck\",\n  \"entry_actions.save_to_readwise\": \"Enregistrer dans Readwise\",\n  \"entry_actions.save_to_zotero\": \"Enregistrer dans Zotero\",\n  \"entry_actions.saved_to_cubox\": \"Enregistré dans Cubox\",\n  \"entry_actions.saved_to_eagle\": \"Enregistré dans Eagle.\",\n  \"entry_actions.saved_to_instapaper\": \"Enregistré dans Instapaper.\",\n  \"entry_actions.saved_to_obsidian\": \"Enregistré dans Obsidian\",\n  \"entry_actions.saved_to_outline\": \"Enregistré dans Outline.\",\n  \"entry_actions.saved_to_qbittorrent\": \"Torrents ajoutés à qBittorrent.\",\n  \"entry_actions.saved_to_readeck\": \"Enregistré dans Readeck.\",\n  \"entry_actions.saved_to_readwise\": \"Enregistré dans Readwise.\",\n  \"entry_actions.saved_to_zotero\": \"Enregistré dans Zotero\",\n  \"entry_actions.share\": \"Partager\",\n  \"entry_actions.star\": \"Favori / Non favori\",\n  \"entry_actions.starred\": \"Favori.\",\n  \"entry_actions.toggle_ai_summary\": \"Résumé IA\",\n  \"entry_actions.toggle_ai_translation\": \"Traduction IA\",\n  \"entry_actions.unstar\": \"Retirer des favoris\",\n  \"entry_actions.unstarred\": \"Retiré des favoris.\",\n  \"entry_actions.view_source_content\": \"Voir le contenu source\",\n  \"entry_actions.view_source_content_description\": \"Essayez de lire le contenu de cet article dans le style du site source\",\n  \"entry_actions.warn_info_ai_chat_pinned_tip\": \"Cette fonctionnalité est actuellement en test gris, et seul un petit nombre d'utilisateurs a les permissions d'accès.\",\n  \"entry_actions.warn_info_for_desktop\": \"Cette fonctionnalité est disponible uniquement dans l'application de bureau\",\n  \"entry_column.filtered_content_tip\": \"Vous avez du contenu filtré masqué.\",\n  \"entry_column.filtered_content_tip_2\": \"En plus des entrées affichées ci-dessus, il y a aussi du contenu filtré.\",\n  \"entry_column.refreshing\": \"Actualisation des nouvelles entrées...\",\n  \"entry_content.ai_summary\": \"Résumé IA\",\n  \"entry_content.fetching_content\": \"Récupération du contenu original et traitement...\",\n  \"entry_content.fetching_content_failed\": \"Lisibilité : Échec de la récupération du contenu original, réessayez plus tard ou revenez au contenu RSS.\",\n  \"entry_content.header.play_tts\": \"Lire TTS\",\n  \"entry_content.header.play_tts_description\": \"Sélectionnez une voix TTS dans les paramètres et démarrez TTS pour convertir en contenu audio\",\n  \"entry_content.header.readability\": \"Lisibilité\",\n  \"entry_content.header.readability_description\": \"Utiliser la capacité de lisibilité pour obtenir le contenu original du site web et l'analyser peut permettre un contenu plus lisible. Recommandé lorsque le contenu RSS n'est pas disponible ou incomplet.\",\n  \"entry_content.no_content\": \"Aucun média disponible\",\n  \"entry_content.readability_notice\": \"Ce contenu est fourni par Readability. Si vous trouvez des anomalies typographiques, veuillez vous rendre sur le site source pour voir le contenu original.\",\n  \"entry_content.render_error\": \"Erreur de rendu :\",\n  \"entry_content.report_issue\": \"Signaler un problème\",\n  \"entry_content.selection_toolbar.ask_ai\": \"Demander à l'IA\",\n  \"entry_content.selection_toolbar.copied\": \"Copié\",\n  \"entry_content.selection_toolbar.copy\": \"Copier\",\n  \"entry_content.selection_toolbar.copy_image\": \"Copier l'image\",\n  \"entry_content.selection_toolbar.copying\": \"Copie...\",\n  \"entry_content.selection_toolbar.generating\": \"Génération...\",\n  \"entry_content.selection_toolbar.poster_copied\": \"Affiche copiée dans le presse-papier\",\n  \"entry_content.selection_toolbar.poster_copy_failed\": \"Échec de la copie de l'affiche\",\n  \"entry_content.selection_toolbar.share\": \"Partager\",\n  \"entry_content.selection_toolbar.share_poster\": \"Partager l'affiche\",\n  \"entry_content.web_app_notice\": \"L'application web ne prend peut-être pas en charge ce type de contenu. Mais vous pouvez télécharger l'application de bureau.\",\n  \"entry_list.zero_unread\": \"Zéro non lu\",\n  \"entry_list_header.ai_timeline\": \"Chronologie IA\",\n  \"entry_list_header.ai_timeline_loading\": \"Réorganisation de la chronologie avec l'IA...\",\n  \"entry_list_header.ai_timeline_prompt_required\": \"Définissez d'abord votre invite de tri de chronologie IA\",\n  \"entry_list_header.grid\": \"Grille\",\n  \"entry_list_header.image_only\": \"Image uniquement\",\n  \"entry_list_header.items\": \"éléments\",\n  \"entry_list_header.masonry\": \"Maçonnerie\",\n  \"entry_list_header.masonry_column\": \"Colonne Maçonnerie\",\n  \"entry_list_header.preview_mode\": \"Mode Aperçu\",\n  \"entry_list_header.refetch\": \"Rafraîchir\",\n  \"entry_list_header.refresh\": \"Actualiser\",\n  \"entry_list_header.show_all\": \"Tout afficher\",\n  \"entry_list_header.show_unread_only\": \"Afficher non lus uniquement\",\n  \"entry_list_header.timeline_summary\": \"Résumer la chronologie\",\n  \"entry_list_header.unread\": \"non lu\",\n  \"feed.actions.follow\": \"Suivre\",\n  \"feed.actions.followed\": \"Suivi\",\n  \"feed.followsAndFeeds\": \"{{subscriptionCount}} {{subscriptionNoun}} et {{feedsCount}} {{feedsNoun}} sur {{appName}}\",\n  \"feed.read_one\": \"lu\",\n  \"feed.read_other\": \"lus\",\n  \"feed_category.onboarding_feed\": \"Cette catégorie contient des flux d'intégration\",\n  \"feed_claim_modal.choose_verification_method\": \"Il y a trois façons de choisir, vous pouvez en choisir une pour vérifier.\",\n  \"feed_claim_modal.claim_button\": \"Réclamer\",\n  \"feed_claim_modal.content_instructions\": \"Copiez le contenu ci-dessous et publiez-le sur votre dernier flux RSS.\",\n  \"feed_claim_modal.description_current\": \"Description actuelle :\",\n  \"feed_claim_modal.description_instructions\": \"Copiez le contenu suivant et collez-le dans le champ <code /> de votre flux RSS.\",\n  \"feed_claim_modal.failed_to_load\": \"Échec du chargement du message de réclamation\",\n  \"feed_claim_modal.rss_format_choice\": \"Les générateurs RSS ont généralement deux formats au choix. Veuillez copier les formats XML et JSON ci-dessous selon vos besoins.\",\n  \"feed_claim_modal.rss_instructions\": \"Copiez le code ci-dessous et collez-le dans votre générateur RSS.\",\n  \"feed_claim_modal.rss_json_format\": \"Format JSON\",\n  \"feed_claim_modal.rss_xml_format\": \"Format XML\",\n  \"feed_claim_modal.rsshub_notice\": \"Ce flux est fourni par RSSHub avec un temps de cache d'1 heure. Veuillez prévoir jusqu'à 1 heure pour que les changements apparaissent après la publication du contenu.\",\n  \"feed_claim_modal.tab_content\": \"Contenu\",\n  \"feed_claim_modal.tab_description\": \"Description\",\n  \"feed_claim_modal.tab_rss\": \"Tag RSS\",\n  \"feed_claim_modal.title\": \"Réclamation de flux\",\n  \"feed_claim_modal.verify_ownership\": \"Pour réclamer ce flux comme le vôtre, vous devez vérifier la propriété.\",\n  \"feed_form.add_feed\": \"Ajouter un flux\",\n  \"feed_form.add_follow\": \"Ajouter un suivi\",\n  \"feed_form.category\": \"Catégorie\",\n  \"feed_form.category_description\": \"Par défaut, vos suivis seront groupés par site web.\",\n  \"feed_form.error_fetching_feed\": \"Erreur lors de la récupération du flux.\",\n  \"feed_form.fee\": \"Frais de suivi\",\n  \"feed_form.fee_description\": \"Pour suivre cette liste, vous devez payer des frais au créateur de la liste.\",\n  \"feed_form.feed_not_found\": \"Flux non trouvé.\",\n  \"feed_form.feedback\": \"Retour\",\n  \"feed_form.fill_default\": \"Remplir\",\n  \"feed_form.follow\": \"Suivre\",\n  \"feed_form.follow_with_fee\": \"Suivre avec {{fee}} Puissance\",\n  \"feed_form.followed\": \"🎉 Suivi.\",\n  \"feed_form.hide_from_timeline\": \"Masquer de la chronologie\",\n  \"feed_form.hide_from_timeline_description\": \"Si les entrées de cet abonnement sont visibles sur votre chronologie principale.\",\n  \"feed_form.private_follow\": \"Suivi privé\",\n  \"feed_form.private_follow_description\": \"Si ce suivi est visible publiquement sur votre page de profil.\",\n  \"feed_form.retry\": \"Réessayer\",\n  \"feed_form.title\": \"Titre\",\n  \"feed_form.title_description\": \"Titre personnalisé pour ce flux. Laissez vide pour utiliser le défaut.\",\n  \"feed_form.unfollow\": \"Ne plus suivre\",\n  \"feed_form.update\": \"Mettre à jour\",\n  \"feed_form.update_follow\": \"Mettre à jour le suivi\",\n  \"feed_form.updated\": \"🎉 Mis à jour.\",\n  \"feed_form.view\": \"Vue\",\n  \"feed_item.claimed_by_owner\": \"Ce flux est réclamé par\",\n  \"feed_item.claimed_by_unknown\": \"son propriétaire.\",\n  \"feed_item.claimed_by_you\": \"Réclamé par vous\",\n  \"feed_item.claimed_feed\": \"Flux réclamé\",\n  \"feed_item.claimed_list\": \"Liste réclamée\",\n  \"feed_item.error_since\": \"Erreur depuis\",\n  \"feed_item.not_publicly_visible\": \"Non visible publiquement sur votre page de profil\",\n  \"feed_item.onboarding_feed\": \"Ceci est un flux d'intégration\",\n  \"login.agree_to\": \"En continuant, vous acceptez nos\",\n  \"login.back\": \"Retour\",\n  \"login.confirm_password.label\": \"Confirmer le mot de passe\",\n  \"login.continueWith\": \"Continuer avec {{provider}}\",\n  \"login.email\": \"Email\",\n  \"login.enter_token\": \"Entrez le jeton d'autorisation pour continuer\",\n  \"login.forget_password.note\": \"Mot de passe oublié ?\",\n  \"login.have_account\": \"Vous avez déjà un compte ? <strong>Se connecter</strong>\",\n  \"login.lastUsed\": \"Dernier utilisé\",\n  \"login.magic_link_sent\": \"Lien magique envoyé ! Vérifiez votre email.\",\n  \"login.no_account\": \"Pas de compte ? <strong>S'inscrire</strong>\",\n  \"login.or\": \"OU\",\n  \"login.password\": \"Mot de passe\",\n  \"login.password_optional\": \"Optionnel\",\n  \"login.privacy\": \"Politique de confidentialité\",\n  \"login.send_magic_link\": \"Envoyer le lien magique\",\n  \"login.signUp\": \"S'inscrire avec un email\",\n  \"login.submit\": \"Soumettre\",\n  \"login.terms\": \"Conditions d'utilisation\",\n  \"login.title\": \"Bienvenue sur Folo\",\n  \"login.with_email.title\": \"Connexion avec Email\",\n  \"mark_all_read_button.auto_confirm_info\": \"Sera confirmé automatiquement après {{countdown}}s.\",\n  \"mark_all_read_button.confirm\": \"Confirmer\",\n  \"mark_all_read_button.confirm_mark_all\": \"Marquer <which /> comme lu ?\",\n  \"mark_all_read_button.confirm_mark_all_info\": \"Confirmer tout marquer comme lu ?\",\n  \"mark_all_read_button.done\": \"Marqué\",\n  \"mark_all_read_button.mark_all_as_read\": \"Tout marquer comme lu\",\n  \"mark_all_read_button.mark_as_read\": \"Marquer <which /> comme lu\",\n  \"mark_all_read_button.undo\": \"Annuler\",\n  \"new_user_dialog.actions.close\": \"Fermer le dialogue\",\n  \"new_user_dialog.actions.finish\": \"Explorer !\",\n  \"new_user_dialog.ai.description\": \"Répondez à quelques questions et l'IA de Folo sélectionnera des flux, des listes et des plans de lecture pour vous.\",\n  \"new_user_dialog.ai.highlight_1\": \"Décrivez ce dont vous avez besoin en langage clair.\",\n  \"new_user_dialog.ai.highlight_2\": \"Recevez des sources recommandées avec le contexte de leur importance.\",\n  \"new_user_dialog.ai.highlight_3\": \"Continuez à discuter pour affiner les résumés, le ton et la fréquence.\",\n  \"new_user_dialog.ai.primary\": \"Lancer l'intégration IA\",\n  \"new_user_dialog.ai.title\": \"Laissez l'AI Copilot construire votre pile de départ\",\n  \"new_user_dialog.import.description\": \"Importez des exports OPML de n'importe quel lecteur et prévisualisez chaque flux avant de vous abonner.\",\n  \"new_user_dialog.import.highlight_1\": \"Téléchargez des fichiers OPML de Feedly, Inoreader ou tout autre lecteur RSS.\",\n  \"new_user_dialog.import.highlight_2\": \"Prévisualisez et choisissez les flux à conserver.\",\n  \"new_user_dialog.import.highlight_3\": \"Organisez immédiatement les flux importés dans des dossiers et des listes.\",\n  \"new_user_dialog.import.primary\": \"Importer OPML\",\n  \"new_user_dialog.import.title\": \"Apportez tout ce que vous suivez déjà\",\n  \"new_user_dialog.overview.description\": \"Folo est un lecteur RSS IA qui rassemble flux, newsletters, podcasts et mises à jour sociales dans un seul endroit ciblé.\",\n  \"new_user_dialog.overview.highlight_1\": \"Épinglez des flux, des listes et des sujets pour changer de contexte instantanément.\",\n  \"new_user_dialog.overview.highlight_2\": \"Utilisez la mise en page à deux colonnes pour parcourir les mises à jour tout en gardant l'entrée actuelle ouverte.\",\n  \"new_user_dialog.overview.highlight_3\": \"Les raccourcis clavier et les filtres vous maintiennent dans le flux.\",\n  \"new_user_dialog.overview.primary\": \"Parcourir Découvrir\",\n  \"new_user_dialog.overview.title\": \"Voyez tout ce qui vous intéresse dans une vue d'accueil calme\",\n  \"new_user_dialog.replay_video\": \"Relancer la vidéo\",\n  \"new_user_dialog.step_label.ai\": \"Étape 2 · AI Copilot\",\n  \"new_user_dialog.step_label.import\": \"Étape 3 · Importer\",\n  \"new_user_dialog.step_label.overview\": \"Étape 1 · Aperçu\",\n  \"new_user_dialog.title\": \"Bienvenue sur Folo\",\n  \"new_user_guide.actions.back\": \"Retour\",\n  \"new_user_guide.actions.finish\": \"Terminer\",\n  \"new_user_guide.actions.next\": \"Suivant\",\n  \"new_user_guide.ai_chat.intro\": \"Bienvenue sur Folo, le lecteur IA qui lit Internet pour vous.\\n\\nParlez-moi de vous.\",\n  \"new_user_guide.ai_chat.reroll\": \"Relancer\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\": \"J'apprends la réglementation de l'IA dans l'Union Européenne\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\": \"J'écris une newsletter sur les technologies climatiques et j'ai besoin de sources quotidiennes\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\": \"Je suis l'actualité de la cybersécurité et des vulnérabilités open-source\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\": \"J'étudie l'administration de médicaments et je veux connaître les nouvelles approbations de la FDA\",\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\": \"Je suis créateur de mode\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\": \"Je suis investisseur et je dois suivre le marché boursier et les nouvelles des entreprises\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\": \"Je prévois un voyage au Japon et je veux des conseils locaux\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\": \"Je fais de la recherche en nano-ingénierie\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\": \"Je suis fan de la NASA\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\": \"Je construis un suivi de finances personnelles et j'ai besoin de meilleures pratiques\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\": \"J'explore les tendances de la cuisine végétale\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\": \"J'ai besoin de résumés pour de longs podcasts sur l'économie\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\": \"J'entraîne une équipe de robotique de lycée et je cherche des idées de projets\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\": \"Je suis responsable marketing lançant un produit SaaS\",\n  \"new_user_guide.ai_chat.you_can_say\": \"Vous pouvez dire...\",\n  \"new_user_guide.confirm_skip.message\": \"Êtes-vous sûr de vouloir ignorer la configuration d'intégration ?\",\n  \"new_user_guide.confirm_skip.title\": \"Ignorer l'intégration ?\",\n  \"new_user_guide.intro.description\": \"Ce guide vous aidera à démarrer avec l'application.\",\n  \"new_user_guide.intro.title\": \"Lecture Vibe avec IA\",\n  \"new_user_guide.selection.empty_description\": \"Décrivez ce que vous recherchez dans le chat IA et nous vous recommanderons des flux.\",\n  \"new_user_guide.selection.empty_title\": \"Aucun flux sélectionné pour le moment\",\n  \"notify.store.default\": \"le magasin\",\n  \"notify.store.mas\": \"App Store\",\n  \"notify.store.mss\": \"Microsoft Store\",\n  \"notify.unfollow_feed\": \"<FeedItem /> ont été désabonnés.\",\n  \"notify.unfollow_feed_many\": \"Tous les flux sélectionnés ont été désabonnés.\",\n  \"notify.update_info\": \"{{app_name}} est prêt à être mis à jour !\",\n  \"notify.update_info_1\": \"Cliquez pour redémarrer\",\n  \"notify.update_info_2\": \"Cliquez pour recharger la page\",\n  \"notify.update_info_3\": \"Touchez pour recharger la page\",\n  \"notify.update_info_store\": \"Cliquez pour ouvrir {{store}}\",\n  \"player.back_10s\": \"Reculer 10s\",\n  \"player.close\": \"Fermer\",\n  \"player.download\": \"Télécharger\",\n  \"player.exit_full_screen\": \"Quitter Plein Écran\",\n  \"player.forward_10s\": \"Avancer 10s\",\n  \"player.full_screen\": \"Plein Écran\",\n  \"player.mute\": \"Muet\",\n  \"player.open_entry\": \"Ouvrir l'entrée\",\n  \"player.pause\": \"Pause\",\n  \"player.play\": \"Lecture\",\n  \"player.playback_rate\": \"Vitesse de lecture\",\n  \"player.unmute\": \"Rétablir le son\",\n  \"player.volume\": \"Volume\",\n  \"quick_add.placeholder\": \"Suivi rapide, tapez l'url du flux ici...\",\n  \"quick_add.title\": \"Suivi Rapide\",\n  \"register.confirm_password\": \"Confirmer le mot de passe\",\n  \"register.email\": \"Email\",\n  \"register.login\": \"Connexion\",\n  \"register.magic_link_sent\": \"Lien magique envoyé ! Vérifiez votre email.\",\n  \"register.password\": \"Mot de passe\",\n  \"register.password_optional\": \"Optionnel\",\n  \"register.referral.days\": \"Inscrivez-vous avec ce code de parrainage pour obtenir {{days}} jours d'Aperçu Pro\",\n  \"register.referral.description\": \"Inscrivez-vous avec un code de parrainage pour obtenir des jours supplémentaires d'Aperçu Pro.\",\n  \"register.referral.invalid\": \"Code de parrainage invalide\",\n  \"register.referral.label\": \"Code de parrainage\",\n  \"register.send_magic_link\": \"Envoyer le lien magique\",\n  \"register.submit\": \"Créer un compte\",\n  \"resize.tooltip.double_click_to_collapse\": \"<b>Double-cliquez</b> pour réinitialiser à la taille par défaut\",\n  \"resize.tooltip.drag_to_resize\": \"<b>Glisser</b> pour redimensionner\",\n  \"search.empty.no_results\": \"Aucun résultat trouvé.\",\n  \"search.group.entries\": \"Entrées\",\n  \"search.group.feeds\": \"Flux\",\n  \"search.options.all\": \"Tout\",\n  \"search.options.entry\": \"Entrée\",\n  \"search.options.feed\": \"Flux\",\n  \"search.options.search_type\": \"Type de recherche\",\n  \"search.placeholder\": \"Rechercher...\",\n  \"search.result_count_local_mode\": \"(Mode local)\",\n  \"search.tooltip.local_search\": \"Cette recherche couvre les données disponibles localement. Essayez un Rafraîchissement pour inclure les dernières données.\",\n  \"share.actions\": \"Actions\",\n  \"share.copy_failed\": \"Échec de la copie du lien\",\n  \"share.copy_link\": \"Copier le lien\",\n  \"share.default_description\": \"Découvrez cette entrée\",\n  \"share.default_title\": \"Partage d'entrée\",\n  \"share.discover_more\": \"Découvrez plus sur Folo\",\n  \"share.link_copied\": \"Lien copié dans le presse-papier\",\n  \"share.social_media\": \"Réseaux sociaux\",\n  \"share.system_share\": \"Partage système\",\n  \"share.title\": \"Partager l'entrée\",\n  \"shortcuts.guide.title\": \"Guide des raccourcis\",\n  \"sidebar.add_more_feeds\": \"Ajouter plus de flux\",\n  \"sidebar.already_on_discover_page\": \"Vous êtes déjà sur la page Découvrir ! Ajoutez le contenu que vous souhaitez suivre dans le panneau de droite.\",\n  \"sidebar.category_remove_dialog.cancel\": \"Annuler\",\n  \"sidebar.category_remove_dialog.continue\": \"Dissocier\",\n  \"sidebar.category_remove_dialog.description\": \"Les flux de cette catégorie seront dissociés et ramenés à leur groupement par défaut.\",\n  \"sidebar.category_remove_dialog.error\": \"Échec de la dissociation de la catégorie\",\n  \"sidebar.category_remove_dialog.success\": \"Catégorie dissociée avec succès\",\n  \"sidebar.category_remove_dialog.title\": \"Dissocier la catégorie\",\n  \"sidebar.category_unsubscribe_dialog.cancel\": \"Annuler\",\n  \"sidebar.category_unsubscribe_dialog.confirm\": \"Se désabonner de {{count}} flux\",\n  \"sidebar.category_unsubscribe_dialog.confirm_one\": \"Se désabonner d'1 flux\",\n  \"sidebar.category_unsubscribe_dialog.confirm_other\": \"Se désabonner de {{count}} flux\",\n  \"sidebar.category_unsubscribe_dialog.confirm_zero\": \"Se désabonner de 0 flux\",\n  \"sidebar.category_unsubscribe_dialog.description\": \"Vous êtes sur le point de vous désabonner de {{count}} flux dans {{category}}. Cette action est irréversible.\",\n  \"sidebar.category_unsubscribe_dialog.description_one\": \"Vous êtes sur le point de vous désabonner du seul flux dans {{category}}. Cette action est irréversible.\",\n  \"sidebar.category_unsubscribe_dialog.description_other\": \"Vous êtes sur le point de vous désabonner de {{count}} flux dans {{category}}. Cette action est irréversible.\",\n  \"sidebar.category_unsubscribe_dialog.description_zero\": \"Il n'y a aucun flux dans {{category}} dont se désabonner.\",\n  \"sidebar.category_unsubscribe_dialog.error\": \"Échec du désabonnement de la catégorie\",\n  \"sidebar.category_unsubscribe_dialog.success\": \"Désabonné de {{count}} flux dans {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.success_one\": \"Désabonné d'1 flux dans {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.success_other\": \"Désabonné de {{count}} flux dans {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.success_zero\": \"Aucun flux n'a été désabonné dans {{category}}.\",\n  \"sidebar.category_unsubscribe_dialog.title\": \"Se désabonner de {{folderName}}\",\n  \"sidebar.feed_actions.claim\": \"Réclamer\",\n  \"sidebar.feed_actions.claim_feed\": \"Réclamer le flux\",\n  \"sidebar.feed_actions.copy_email_address\": \"Copier l'adresse email\",\n  \"sidebar.feed_actions.copy_feed_badge\": \"Copier le badge du flux\",\n  \"sidebar.feed_actions.copy_feed_id\": \"Copier l'ID du flux\",\n  \"sidebar.feed_actions.copy_feed_url\": \"Copier l'URL du flux\",\n  \"sidebar.feed_actions.copy_list_id\": \"Copier l'ID de la liste\",\n  \"sidebar.feed_actions.copy_list_url\": \"Copier l'URL de la liste\",\n  \"sidebar.feed_actions.create_list\": \"Créer une nouvelle liste\",\n  \"sidebar.feed_actions.edit\": \"Modifier\",\n  \"sidebar.feed_actions.edit_feed\": \"Modifier le flux\",\n  \"sidebar.feed_actions.edit_inbox\": \"Modifier la boîte de réception\",\n  \"sidebar.feed_actions.edit_list\": \"Modifier la liste\",\n  \"sidebar.feed_actions.feed_owned_by_you\": \"Ce flux vous appartient\",\n  \"sidebar.feed_actions.list_owned_by_you\": \"Cette liste vous appartient\",\n  \"sidebar.feed_actions.mark_all_as_read\": \"Marquer comme lu\",\n  \"sidebar.feed_actions.navigate_to_feed\": \"Naviguer vers le flux\",\n  \"sidebar.feed_actions.navigate_to_list\": \"Naviguer vers la liste\",\n  \"sidebar.feed_actions.new_inbox\": \"Nouvelle boîte de réception\",\n  \"sidebar.feed_actions.open_feed_in_browser\": \"Ouvrir le flux dans {{which}}\",\n  \"sidebar.feed_actions.open_list_in_browser\": \"Ouvrir la liste dans {{which}}\",\n  \"sidebar.feed_actions.open_site_in_browser\": \"Ouvrir le site dans {{which}}\",\n  \"sidebar.feed_actions.reset_feed\": \"Réinitialiser le flux\",\n  \"sidebar.feed_actions.reset_feed_error\": \"Échec de la réinitialisation du flux.\",\n  \"sidebar.feed_actions.reset_feed_success\": \"Flux réinitialisé avec succès.\",\n  \"sidebar.feed_actions.resetting_feed\": \"Réinitialisation du flux...\",\n  \"sidebar.feed_actions.unfollow\": \"Ne plus suivre\",\n  \"sidebar.feed_actions.unfollow_feed\": \"Ne plus suivre le flux\",\n  \"sidebar.feed_actions.unfollow_feed_many\": \"Ne plus suivre tous les flux sélectionnés\",\n  \"sidebar.feed_actions.unfollow_feed_many_confirm\": \"Confirmer le désabonnement de tous les flux sélectionnés ?\",\n  \"sidebar.feed_actions.unfollow_feed_many_warning\": \"Attention : Cette opération désabonnera tous les flux sélectionnés et est irréversible.\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_category\": \"Déplacer vers la catégorie\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_list\": \"Ajouter des flux à la liste\",\n  \"sidebar.feed_column.context_menu.change_to_other_view\": \"Passer à une autre vue\",\n  \"sidebar.feed_column.context_menu.create_category\": \"Nouvelle catégorie\",\n  \"sidebar.feed_column.context_menu.mark_as_read\": \"Marquer comme lu\",\n  \"sidebar.feed_column.context_menu.new_category_modal.category_name\": \"Nom de la catégorie\",\n  \"sidebar.feed_column.context_menu.new_category_modal.create\": \"Créer\",\n  \"sidebar.feed_column.context_menu.rename_category\": \"Renommer la catégorie\",\n  \"sidebar.feed_column.context_menu.rename_category_error\": \"Échec du renommage de la catégorie\",\n  \"sidebar.feed_column.context_menu.rename_category_success\": \"Catégorie renommée avec succès\",\n  \"sidebar.feed_column.context_menu.title\": \"Déplacer vers une nouvelle catégorie\",\n  \"sidebar.feed_column.context_menu.ungroup_category\": \"Dissocier la catégorie\",\n  \"sidebar.feed_column.context_menu.ungroup_category_confirmation\": \"Dissocier la catégorie {{folderName}} ?\",\n  \"sidebar.feed_column.context_menu.unsubscribe_category\": \"Se désabonner de tout dans la catégorie\",\n  \"sidebar.select_sort_method\": \"Sélectionner une méthode de tri\",\n  \"sidebar.timeline_tabs.customize\": \"Personnaliser les onglets de vue...\",\n  \"sidebar.timeline_tabs.drag_tab\": \"Faire glisser l'onglet de la chronologie\",\n  \"sidebar.timeline_tabs.empty_hidden\": \"Faites glisser des onglets ici pour les masquer de la barre latérale.\",\n  \"sidebar.timeline_tabs.empty_visible\": \"Faites glisser des onglets ici pour les afficher dans la barre latérale.\",\n  \"sidebar.timeline_tabs.hidden\": \"Masqué\",\n  \"sidebar.timeline_tabs.hide_tab\": \"Masquer cette vue\",\n  \"sidebar.timeline_tabs.instructions\": \"Faites glisser les onglets entre les sections pour les réorganiser, les afficher ou les masquer.\",\n  \"sidebar.timeline_tabs.reset\": \"Réinitialiser par défaut\",\n  \"sidebar.timeline_tabs.visible\": \"Visible\",\n  \"signin.continue_with\": \"Continuer avec {{provider}}\",\n  \"signin.sign_in_to\": \"Se connecter à\",\n  \"signin.sign_up_to\": \"S'inscrire à\",\n  \"subscription_limit_warning\": \"Limite d'abonnement atteinte : <br /><b>{{feedCount}}/{{feedLimit}} flux</b>, <br /><b>{{rsshubCount}}/{{rsshubLimit}} RSSHub</b>. <br /> Veuillez démarrer l'essai gratuit pour débloquer plus de quota ou nettoyer les flux inutilisés.\",\n  \"sync_indicator.disabled\": \"Pour des raisons de sécurité, la synchronisation est désactivée.\",\n  \"sync_indicator.offline\": \"Hors ligne\",\n  \"sync_indicator.synced\": \"Synchronisé avec le serveur\",\n  \"trending.entry\": \"Entrées tendance\",\n  \"trending.entry_no_results\": \"Aucune entrée tendance trouvée\",\n  \"trending.feed\": \"Flux tendance\",\n  \"trending.list\": \"Listes tendance\",\n  \"trending.user\": \"Utilisateurs tendance\",\n  \"tutorial.scroll_to_exit.description\": \"Faites défiler vers le haut lorsque vous êtes en haut pour revenir rapidement à la chronologie.\",\n  \"tutorial.scroll_to_exit.dismiss_hint\": \"Cliquez pour fermer\",\n  \"tutorial.scroll_to_exit.title\": \"Faites défiler pour revenir\",\n  \"user_button.account\": \"Compte\",\n  \"user_button.achievement\": \"Missions\",\n  \"user_button.actions\": \"Actions\",\n  \"user_button.ai\": \"IA\",\n  \"user_button.download_desktop_app\": \"Télécharger appli bureau\",\n  \"user_button.log_out\": \"Déconnexion\",\n  \"user_button.power\": \"Puissance\",\n  \"user_button.preferences\": \"Préférences\",\n  \"user_button.profile\": \"Profil\",\n  \"user_profile.about\": \"À propos\",\n  \"user_profile.close\": \"Fermer\",\n  \"user_profile.created_lists\": \"Listes créées\",\n  \"user_profile.edit\": \"Modifier\",\n  \"user_profile.loading\": \"Chargement\",\n  \"user_profile.share\": \"Partager\",\n  \"user_profile.subscriptions\": \"Abonnements\",\n  \"user_profile.toggle_item_style\": \"Basculer le style d'élément\",\n  \"words.achievement\": \"Réalisations\",\n  \"words.actions\": \"Actions\",\n  \"words.add\": \"Ajouter\",\n  \"words.all\": \"Tout\",\n  \"words.browser\": \"Navigateur\",\n  \"words.categories\": \"Catégories\",\n  \"words.confirm\": \"Confirmer\",\n  \"words.discover\": \"Découvrir\",\n  \"words.email\": \"Email\",\n  \"words.feeds\": \"Flux\",\n  \"words.import\": \"Importer\",\n  \"words.inbox\": \"Boîte de réception\",\n  \"words.items\": \"Éléments\",\n  \"words.language\": \"Langue\",\n  \"words.link\": \"Lien\",\n  \"words.lists\": \"Listes\",\n  \"words.load_archived_entries\": \"Charger les entrées archivées\",\n  \"words.login\": \"Connexion\",\n  \"words.mint\": \"Mint\",\n  \"words.newTab\": \"Nouvel onglet\",\n  \"words.power\": \"Puissance\",\n  \"words.rss\": \"RSS\",\n  \"words.rss3\": \"RSS3\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.search\": \"Recherche\",\n  \"words.show_more\": \"Afficher plus\",\n  \"words.starred\": \"Favoris\",\n  \"words.title\": \"Titre\",\n  \"words.transform\": \"Transformer\",\n  \"words.trending\": \"Tendance\",\n  \"words.undo\": \"Annuler\",\n  \"words.unread\": \"Non lu\",\n  \"words.user\": \"Utilisateur\",\n  \"words.view\": \"Vue\",\n  \"words.which.all\": \"tout\",\n  \"words.zero_items\": \"Zéro élément\"\n}\n"
  },
  {
    "path": "locales/app/ja.json",
    "content": "{\n  \"achievement.all_done\": \"すべて完了！\",\n  \"achievement.alpha_tester\": \"アルファ テスター\",\n  \"achievement.alpha_tester_description\": \"あなたは Folo のアルファ テスターです\",\n  \"achievement.description\": \"ハードコア プレイヤーになって NFT をミントしよう。\",\n  \"achievement.first_claim_feed\": \"フィードの所有者\",\n  \"achievement.first_claim_feed_description\": \"あなたは Folo のフィード所有者です\",\n  \"achievement.first_create_list\": \"リスト作成者\",\n  \"achievement.first_create_list_description\": \"あなたは Folo のリスト作成者です\",\n  \"achievement.follow_special_feed\": \"スペシャル フィード フォロワーです\",\n  \"achievement.follow_special_feed_description\": \"あなたは Folo でスペシャルフィードをフォローしています\",\n  \"achievement.list_subscribe_100\": \"100 リスト購読者\",\n  \"achievement.list_subscribe_100_description\": \"あなたが作成したリストの購読者数が 100 人を超えました\",\n  \"achievement.list_subscribe_50\": \"あなたが作成したリストの購読者数が 50 人を超えました\",\n  \"achievement.list_subscribe_500\": \"500 リスト購読者\",\n  \"achievement.list_subscribe_500_description\": \"あなたが作成したリストの購読者数が 500 人を超えました\",\n  \"achievement.list_subscribe_50_description\": \"あなたが作成したリストの購読者数が 50 人を超えました\",\n  \"achievement.nft_coming_soon\": \"今は NFT をミントすることはできません。準備ができたらあなたのアカウントに自動でクレジットされます。\",\n  \"achievement.product_hunt_vote\": \"Product Hunt 投票者\",\n  \"achievement.product_hunt_vote_description\": \"あなたは Product Hunt での Folo サポーターです\",\n  \"activation.activate\": \"有効化\",\n  \"activation.description\": \"パブリック ベータ テストフェーズ期間中、この機能を使用するには招待コードが必要です。\",\n  \"activation.plan.description\": \"現在、無料プランを使用しています。プランをアップグレードして、さらに多くの機能をアンロックしてください。\",\n  \"activation.plan.title\": \"プランのアップグレード\",\n  \"activation.plan.upgrade\": \"アップグレード\",\n  \"activation.title\": \"招待コード\",\n  \"ai.summary_not_available\": \"要約は利用できません\",\n  \"ai.summary_upgrade_required_description\": \"Pro プランにアップグレードして、無制限の AI サマリー、翻訳、およびその他のインテリジェント機能をアンロックしましょう。\",\n  \"ai.summary_upgrade_required_title\": \"AI サマリーを引き続き使用するにはプランをアップグレードしてください\",\n  \"ai.summary_upgrade_view_plans\": \"プランを表示\",\n  \"app.copy_logo_svg\": \"ロゴ SVG をコピー\",\n  \"app.copy_logo_text_svg\": \"ロゴテキスト SVG をコピー\",\n  \"app.toggle_sidebar\": \"サイドバーを切り替え\",\n  \"discover.any_url_or_keyword\": \"任意の URL またはキーワード\",\n  \"discover.default_option\": \" (デフォルト)\",\n  \"discover.feed_description\": \"このフィードの説明は Folo で表示されます、またパラメータ フォームに関連する情報を記入することができます。\",\n  \"discover.feed_maintainers\": \"フィードは RSSHub が提供し <maintainers /> とともにクレジットされます。\",\n  \"discover.import.click_to_upload\": \"OPML ファイルをアップロードするにはクリック\",\n  \"discover.import.conflictItems\": \"重複アイテム\",\n  \"discover.import.import_completed_with_issues\": \"インポートは一部の問題で完了しました\",\n  \"discover.import.import_successful\": \"インポートは成功しました\",\n  \"discover.import.noItems\": \"アイテムなし\",\n  \"discover.import.no_feeds_found\": \"検索条件に一致するフィードが見つかりませんでした。\",\n  \"discover.import.opml\": \"OPML ファイル\",\n  \"discover.import.opml_step1\": \"ご利用のRSSリーダーからOPMLをエクスポートしてください\",\n  \"discover.import.opml_step1_feedly\": \"FeedlyからOPMLをエクスポートしてください\",\n  \"discover.import.opml_step1_feedly_step1\": \"<Link /> を開いてください。\",\n  \"discover.import.opml_step1_feedly_step2\": \"「Download Your Feedly OPML」ボタンをクリックしてください。\",\n  \"discover.import.opml_step1_inoreader\": \"InoreaderからOPMLをエクスポートしてください\",\n  \"discover.import.opml_step1_inoreader_step1\": \"<Link /> を開いてください。\",\n  \"discover.import.opml_step1_inoreader_step2\": \"「SYSTEM FOLDERS」タブに切り替えてください。\",\n  \"discover.import.opml_step1_inoreader_step3\": \"「Newsfeed」の右側にある「OPML」ボタンをクリックしてください。\",\n  \"discover.import.opml_step1_other\": \"その他のリーダーからOPMLをエクスポートしてください\",\n  \"discover.import.opml_step1_other_step1\": \"OPMLは広くサポートされているオープンフォーマットであり、フィード購読リストの共有標準です。ほとんどのRSSリーダーでOPMLのインポート・エクスポートが可能です。方法が分からない場合は、ご利用のRSSリーダーのヘルプマニュアルを参照するか、コミュニティにご相談ください。\",\n  \"discover.import.opml_step2\": \"OPMLをFoloにインポートしてください\",\n  \"discover.import.parse_opml\": \"OPMLを解析\",\n  \"discover.import.parsedErrorItems\": \"パースエラーのアイテム\",\n  \"discover.import.preview_opml_content\": \"OPMLコンテンツのプレビュー\",\n  \"discover.import.quota_exceeded\": \"枠を超過\",\n  \"discover.import.quota_exceeded_warning\": \"選択したフィード数が残り枠を超えています。続行するには一部のフィードの選択を解除してください。\",\n  \"discover.import.quota_limit_reached\": \"枠制限に達しました\",\n  \"discover.import.quota_status\": \"インポート枠：\",\n  \"discover.import.quota_warning\": \"残り {{remaining}} フィードまでインポートできます。\",\n  \"discover.import.remaining_quota\": \"あと {{remaining}} フィードをインポートできます\",\n  \"discover.import.result\": \"<SuccessfulNum /> フィードのインポートに成功しました、 <ConflictNum /> 件を購読中、インポート失敗は <ErrorNum /> 件。\",\n  \"discover.import.search_feeds_placeholder\": \"フィードを検索...\",\n  \"discover.import.select_all_feeds\": \"すべてのフィードを選択\",\n  \"discover.import.select_all_filtered\": \"フィルタ結果をすべて選択\",\n  \"discover.import.select_feeds_description\": \"インポートするフィードを確認して選択してください。デフォルトですべて選択されています。\",\n  \"discover.import.select_feeds_to_import\": \"インポートするフィードを選択\",\n  \"discover.import.successfulItems\": \"成功したアイテム\",\n  \"discover.inbox.actions\": \"アクション\",\n  \"discover.inbox.description\": \"情報は email か webhooks を通して受信箱に配信されます。\",\n  \"discover.inbox.email\": \"Email\",\n  \"discover.inbox.handle\": \"ハンドル\",\n  \"discover.inbox.no_inbox\": \"現在受信箱はありません。下のボタンをクリックして最初の受信箱を作成してください。\",\n  \"discover.inbox.secret\": \"シークレット\",\n  \"discover.inbox.title\": \"タイトル\",\n  \"discover.inbox.webhooks_docs\": \"Webhooks ドキュメント\",\n  \"discover.inbox_create\": \"新規受信箱\",\n  \"discover.inbox_create_description\": \"まだ受信箱を作成していません、受信箱を作成すると様々な情報を受信できます。\",\n  \"discover.inbox_create_error\": \"受信箱の作成に失敗しました。\",\n  \"discover.inbox_create_success\": \"受信箱の作成に成功しました。\",\n  \"discover.inbox_destroy\": \"削除\",\n  \"discover.inbox_destroy_confirm\": \"受信箱を削除してもよろしいですか？\",\n  \"discover.inbox_destroy_error\": \"受信箱の削除に失敗しました。\",\n  \"discover.inbox_destroy_success\": \"受信箱の削除に成功しました・\",\n  \"discover.inbox_destroy_warning\": \"警告: 一度削除するとその email は利用できなくなり、すべてのコンテンツは永久に削除され復帰もできません。\",\n  \"discover.inbox_handle\": \"ハンドル\",\n  \"discover.inbox_title\": \"タイトル\",\n  \"discover.inbox_update\": \"更新\",\n  \"discover.inbox_update_error\": \"受信箱の変更に失敗しました。\",\n  \"discover.inbox_update_success\": \"受信箱の更新に成功しました。\",\n  \"discover.popular\": \"人気\",\n  \"discover.preview\": \"プレビュー\",\n  \"discover.rss_hub_route\": \"RSSHub ルート\",\n  \"discover.rss_url\": \"RSS URL\",\n  \"discover.search.results_one\": \"フィードが {{count}} 件みつかりました\",\n  \"discover.search.results_other\": \"フィードが {{count}} 件みつかりました\",\n  \"discover.search.results_zero\": \"フィードが見つかりませんでした\",\n  \"discover.select_placeholder\": \"選択\",\n  \"discover.target.feeds\": \"フィード\",\n  \"discover.target.label\": \"検索対象\",\n  \"discover.target.lists\": \"リスト\",\n  \"discover.tips.auto_detect\": \"入力タイプを自動検出\",\n  \"discover.tips.search_keyword\": \"キーワード、URL、RSS フィード、または RSSHub ルートで検索\",\n  \"discover.tools.description\": \"その他のツール\",\n  \"discover.tools.import\": \"OPML をインポート\",\n  \"discover.tools.inbox\": \"受信トレイ\",\n  \"discover.tools.transform\": \"HTML を RSS に変換\",\n  \"discover.tools.user\": \"ユーザーをフォロー\",\n  \"entry.click_to_return\": \"上にスクロールまたはこの領域をクリックして戻る\",\n  \"entry.exit_detail\": \"時間軸に戻る\",\n  \"entry.scroll_up_to_exit\": \"上にスクロールして時間軸に戻る\",\n  \"entry_actions.copied_notify\": \"{{which}} をクリップボードにコピーしました。\",\n  \"entry_actions.copy_link\": \"リンクをコピー\",\n  \"entry_actions.copy_title\": \"タイトルをコピー\",\n  \"entry_actions.delete\": \"削除\",\n  \"entry_actions.deleted\": \"削除済み\",\n  \"entry_actions.export_as_pdf\": \"PDF としてエクスポート\",\n  \"entry_actions.failed_to_delete\": \"削除に失敗しました。\",\n  \"entry_actions.failed_to_login_to_qbittorrent\": \"qBittorrent へのログインに失敗しました。\",\n  \"entry_actions.failed_to_save_to_cubox\": \"Cubox への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_eagle\": \"Eagle への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_instapaper\": \"Instapaper への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_obsidian\": \"Obsidian への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_outline\": \"Outline への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_qbittorrent\": \"qBittorrent でのダウンロードに失敗しました。\",\n  \"entry_actions.failed_to_save_to_readeck\": \"Readeck への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_readwise\": \"Readwise への保存に失敗しました。\",\n  \"entry_actions.failed_to_save_to_zotero\": \"Zotero への保存に失敗しました。\",\n  \"entry_actions.image_gallery\": \"画像ギャラリー\",\n  \"entry_actions.image_gallery_description\": \"記事に複数の大きな画像がある場合、ギャラリーモーダルで記事内のすべての画像を閲覧できます\",\n  \"entry_actions.mark_above_as_read\": \"上記を既読にする\",\n  \"entry_actions.mark_as_read\": \"既読/未読にする\",\n  \"entry_actions.mark_as_unread\": \"未読にする\",\n  \"entry_actions.mark_below_as_read\": \"下を既読にする\",\n  \"entry_actions.no_bittorrent_urls_found\": \"このエントリには BitTorrent URL が見つかりませんでした。\",\n  \"entry_actions.open_in_browser\": \"{{which}} で開く\",\n  \"entry_actions.recent_reader\": \"最近の購読者:\",\n  \"entry_actions.save_media_to_eagle\": \"メディアを Eagle に保存\",\n  \"entry_actions.save_to_cubox\": \"Cubox に保存\",\n  \"entry_actions.save_to_instapaper\": \"Instapaper に保存\",\n  \"entry_actions.save_to_obsidian\": \"Obsidian に保存\",\n  \"entry_actions.save_to_outline\": \"Outline に保存\",\n  \"entry_actions.save_to_qbittorrent\": \"qBittorrent でダウンロード\",\n  \"entry_actions.save_to_readeck\": \"Readeck に保存\",\n  \"entry_actions.save_to_readwise\": \"Readwise に保存\",\n  \"entry_actions.save_to_zotero\": \"Zotero に保存\",\n  \"entry_actions.saved_to_cubox\": \"Cubox に保存されました。\",\n  \"entry_actions.saved_to_eagle\": \"Eagle に保存されました。\",\n  \"entry_actions.saved_to_instapaper\": \"Instapaper に保存されました。\",\n  \"entry_actions.saved_to_obsidian\": \"Obsidian に保存されました。\",\n  \"entry_actions.saved_to_outline\": \"Outline に保存されました。\",\n  \"entry_actions.saved_to_qbittorrent\": \"トレントを qBittorrent に追加しました。\",\n  \"entry_actions.saved_to_readeck\": \"Readeck に保存されました。\",\n  \"entry_actions.saved_to_readwise\": \"Readwise に保存されました。\",\n  \"entry_actions.saved_to_zotero\": \"Zotero に保存されました。\",\n  \"entry_actions.share\": \"共有\",\n  \"entry_actions.star\": \"スター\",\n  \"entry_actions.starred\": \"スターに追加されました。\",\n  \"entry_actions.toggle_ai_summary\": \"AI 要約に切り替え\",\n  \"entry_actions.toggle_ai_translation\": \"AI 翻訳に切り替え\",\n  \"entry_actions.unstar\": \"スターを解除\",\n  \"entry_actions.unstarred\": \"スターが解除されました。\",\n  \"entry_actions.view_source_content\": \"元のコンテンツを表示\",\n  \"entry_actions.view_source_content_description\": \"元のウェブサイトのスタイルでこの記事の内容を読んでみることができます。\",\n  \"entry_actions.warn_info_ai_chat_pinned_tip\": \"この機能は現在グレー テスト中であり、ごく少数のユーザーのみがアクセス権を持っています。\",\n  \"entry_actions.warn_info_for_desktop\": \"この機能はデスクトップアプリでのみ利用可能です\",\n  \"entry_column.filtered_content_tip\": \"コンテンツをフィルタリングして非表示にします。\",\n  \"entry_column.filtered_content_tip_2\": \"上記のエントリーに加え、フィルタリングされたコンテンツもあります。\",\n  \"entry_column.refreshing\": \"新しいエントリーを更新中...\",\n  \"entry_content.ai_summary\": \"AI による要約\",\n  \"entry_content.fetching_content\": \"元のコンテンツを取得し、処理中です...\",\n  \"entry_content.fetching_content_failed\": \"Readability: 元のコンテンツの取得に失敗しました。後で再試行するか、RSS コンテンツに戻ってください。\",\n  \"entry_content.header.play_tts\": \"TTS を再生\",\n  \"entry_content.header.play_tts_description\": \"設定でTTS音声を選択し、TTSを開始して音声コンテンツに変換します\",\n  \"entry_content.header.readability\": \"読みやすさ\",\n  \"entry_content.header.readability_description\": \"記事の読みやすさを向上させるために、フォントサイズや行間を調整します。\",\n  \"entry_content.no_content\": \"コンテンツがありません\",\n  \"entry_content.readability_notice\": \"このコンテンツは Readability により提供されています。誤字などを発見した場合は、元のサイトでオリジナルのコンテンツをご覧ください。\",\n  \"entry_content.render_error\": \"レンダリングエラー:\",\n  \"entry_content.report_issue\": \"問題を報告\",\n  \"entry_content.selection_toolbar.ask_ai\": \"AI に質問\",\n  \"entry_content.selection_toolbar.copied\": \"コピーしました\",\n  \"entry_content.selection_toolbar.copy\": \"コピー\",\n  \"entry_content.selection_toolbar.copy_image\": \"画像をコピー\",\n  \"entry_content.selection_toolbar.copying\": \"コピー中...\",\n  \"entry_content.selection_toolbar.generating\": \"生成中...\",\n  \"entry_content.selection_toolbar.poster_copied\": \"ポスターをクリップボードにコピーしました\",\n  \"entry_content.selection_toolbar.poster_copy_failed\": \"ポスターのコピーに失敗しました\",\n  \"entry_content.selection_toolbar.share\": \"共有\",\n  \"entry_content.selection_toolbar.share_poster\": \"ポスターを共有\",\n  \"entry_content.web_app_notice\": \"このコンテンツタイプはウェブアプリではサポートされていないかもしれません。デスクトップアプリをダウンロードしてください。\",\n  \"entry_list.zero_unread\": \"未読ゼロ\",\n  \"entry_list_header.ai_timeline\": \"AIタイムライン\",\n  \"entry_list_header.ai_timeline_loading\": \"AI でタイムラインを並び替えています…\",\n  \"entry_list_header.ai_timeline_prompt_required\": \"AIタイムラインの並び替えプロンプトを先に設定してください\",\n  \"entry_list_header.grid\": \"グリッド\",\n  \"entry_list_header.image_only\": \"イメージのみ\",\n  \"entry_list_header.items\": \"項目\",\n  \"entry_list_header.masonry\": \"メイソンリー\",\n  \"entry_list_header.masonry_column\": \"メイソンリー列\",\n  \"entry_list_header.preview_mode\": \"プレビューモード\",\n  \"entry_list_header.refetch\": \"再取得\",\n  \"entry_list_header.refresh\": \"更新\",\n  \"entry_list_header.show_all\": \"すべて表示\",\n  \"entry_list_header.show_unread_only\": \"未読のみ表示\",\n  \"entry_list_header.timeline_summary\": \"タイムラインを要約\",\n  \"entry_list_header.unread\": \"未読\",\n  \"feed.actions.follow\": \"フォロー\",\n  \"feed.actions.followed\": \"フォロー済み\",\n  \"feed.followsAndFeeds\": \"{{subscriptionCount}} {{subscriptionNoun}} と {{feedsCount}} {{feedsNoun}} on {{appName}}\",\n  \"feed.read_one\": \"読む\",\n  \"feed.read_other\": \"読む\",\n  \"feed_category.onboarding_feed\": \"このカテゴリーにはオンボーディングフィードが含まれています\",\n  \"feed_claim_modal.choose_verification_method\": \"選択肢は 3 つあります。そのうち 1 つを選んで確認してください。\",\n  \"feed_claim_modal.claim_button\": \"クレーム\",\n  \"feed_claim_modal.content_instructions\": \"以下の内容をコピーして、最新の RSS フィードに投稿してください。\",\n  \"feed_claim_modal.description_current\": \"現在の説明：\",\n  \"feed_claim_modal.description_instructions\": \"次の内容をコピーし、RSS フィードの<code />フィールドに貼り付けてください。\",\n  \"feed_claim_modal.failed_to_load\": \"メッセージの読み込みに失敗しました\",\n  \"feed_claim_modal.rss_format_choice\": \"RSS ジェネレータには通常 2 つのフォーマットがあります。必要に応じて以下の XML および JSON フォーマットをコピーしてください。\",\n  \"feed_claim_modal.rss_instructions\": \"以下のコードをコピーし、RSS ジェネレータに貼り付けてください。\",\n  \"feed_claim_modal.rss_json_format\": \"JSON フォーマット\",\n  \"feed_claim_modal.rss_xml_format\": \"XML フォーマット\",\n  \"feed_claim_modal.rsshub_notice\": \"このフィードは RSSHub によって提供されており、キャッシュ時間は 1 時間です。コンテンツの変更が反映されるまで最大 1 時間かかる場合があります。\",\n  \"feed_claim_modal.tab_content\": \"コンテンツ\",\n  \"feed_claim_modal.tab_description\": \"説明\",\n  \"feed_claim_modal.tab_rss\": \"RSS タグ\",\n  \"feed_claim_modal.title\": \"フィードをクレーム\",\n  \"feed_claim_modal.verify_ownership\": \"このフィードを自分のものとしてクレームするには、所有権を確認する必要があります。\",\n  \"feed_form.add_feed\": \"フィードを追加\",\n  \"feed_form.add_follow\": \"フォローを追加\",\n  \"feed_form.category\": \"カテゴリー\",\n  \"feed_form.category_description\": \"デフォルトでは、フォローはウェブサイトごとにグループ化されます。\",\n  \"feed_form.error_fetching_feed\": \"フィードの取得に失敗しました。\",\n  \"feed_form.fee\": \"Folo 手数料\",\n  \"feed_form.fee_description\": \"このリストをフォローするにはリスト作成者が設定した手数料が必要です。\",\n  \"feed_form.feed_not_found\": \"フィードが見つかりません。\",\n  \"feed_form.feedback\": \"フィードバック\",\n  \"feed_form.fill_default\": \"入力する\",\n  \"feed_form.follow\": \"フォロー\",\n  \"feed_form.follow_with_fee\": \" {{fee}} Power で購読できます。\",\n  \"feed_form.followed\": \"🎉 フォローしました。\",\n  \"feed_form.hide_from_timeline\": \"タイムラインから非表示\",\n  \"feed_form.hide_from_timeline_description\": \"このサブスクリプションのエントリーがメインビューのタイムラインに表示されるかどうか。\",\n  \"feed_form.private_follow\": \"プライベートフォロー\",\n  \"feed_form.private_follow_description\": \"このフォローがあなたのプロフィールページに公開されるかどうか。\",\n  \"feed_form.retry\": \"再試行\",\n  \"feed_form.title\": \"タイトル\",\n  \"feed_form.title_description\": \"このフィードのカスタムタイトル。デフォルトを使用するには空白のままにしてください。\",\n  \"feed_form.unfollow\": \"フォロー解除\",\n  \"feed_form.update\": \"更新\",\n  \"feed_form.update_follow\": \"フォローを更新\",\n  \"feed_form.updated\": \"🎉 更新されました。\",\n  \"feed_form.view\": \"表示\",\n  \"feed_item.claimed_by_owner\": \"このフィードは\",\n  \"feed_item.claimed_by_unknown\": \"その所有者によってクレームされています。\",\n  \"feed_item.claimed_by_you\": \"あなたによってクレームされています\",\n  \"feed_item.claimed_feed\": \"クレームされたフィード\",\n  \"feed_item.claimed_list\": \"クレームされたリスト\",\n  \"feed_item.error_since\": \"エラー発生時刻\",\n  \"feed_item.not_publicly_visible\": \"プロフィールページに公開されていません\",\n  \"feed_item.onboarding_feed\": \"これはオンボーディングフィードです\",\n  \"login.agree_to\": \" 続行することで、あなたは私たちの\",\n  \"login.back\": \"戻る\",\n  \"login.confirm_password.label\": \"パスワードの確認\",\n  \"login.continueWith\": \"{{provider}} で続ける\",\n  \"login.email\": \"Email\",\n  \"login.enter_token\": \"認証トークンを入力して続行\",\n  \"login.forget_password.note\": \"パスワードをお忘れですか？\",\n  \"login.have_account\": \"すでにアカウントをお持ちですか？ <strong>サインイン</strong>\",\n  \"login.lastUsed\": \"前回利用\",\n  \"login.magic_link_sent\": \"マジックリンクを送信しました！メールをご確認ください。\",\n  \"login.no_account\": \"アカウントをお持ちでないですか？ <strong>サインアップ</strong>\",\n  \"login.or\": \"または\",\n  \"login.password\": \"パスワード\",\n  \"login.password_optional\": \"オプション\",\n  \"login.privacy\": \"プライバシーポリシー\",\n  \"login.send_magic_link\": \"マジックリンクを送信\",\n  \"login.signUp\": \"メールでサインアップ\",\n  \"login.submit\": \"ログイン\",\n  \"login.terms\": \"利用規約\",\n  \"login.title\": \"Folo へようこそ\",\n  \"login.with_email.title\": \"メールでログイン\",\n  \"mark_all_read_button.auto_confirm_info\": \"{{countdown}} 秒後に自動的に確認されます。\",\n  \"mark_all_read_button.confirm\": \"確認\",\n  \"mark_all_read_button.confirm_mark_all\": \"<which />を既読にしますか？\",\n  \"mark_all_read_button.confirm_mark_all_info\": \"すべてを既読にしますか？\",\n  \"mark_all_read_button.done\": \"既読にしました\",\n  \"mark_all_read_button.mark_all_as_read\": \"すべてを既読にする\",\n  \"mark_all_read_button.mark_as_read\": \"<which />を既読にする\",\n  \"mark_all_read_button.undo\": \"元に戻す\",\n  \"new_user_dialog.actions.close\": \"閉じる\",\n  \"new_user_dialog.actions.finish\": \"探索を始める\",\n  \"new_user_dialog.ai.description\": \"いくつかの質問に答えるだけで、Folo の AI がフィードやリスト、読み方を提案します。\",\n  \"new_user_dialog.ai.highlight_1\": \"知りたいことを自然な言葉で伝えるだけ。\",\n  \"new_user_dialog.ai.highlight_2\": \"おすすめの情報源とその理由が返ってきます。\",\n  \"new_user_dialog.ai.highlight_3\": \"チャットを続けて要約のトーンや頻度を調整。\",\n  \"new_user_dialog.ai.primary\": \"AI オンボーディングを開始\",\n  \"new_user_dialog.ai.title\": \"AI コパイロットに初期セットアップを任せる\",\n  \"new_user_dialog.import.description\": \"どのリーダーからの OPML でもインポートし、追加前にプレビューできます。\",\n  \"new_user_dialog.import.highlight_1\": \"Feedly や Inoreader などの OPML ファイルに対応。\",\n  \"new_user_dialog.import.highlight_2\": \"インポート前に残したいフィードを選別。\",\n  \"new_user_dialog.import.highlight_3\": \"新しいフィードをすぐにフォルダーやリストへ整理。\",\n  \"new_user_dialog.import.primary\": \"OPML をインポート\",\n  \"new_user_dialog.import.title\": \"既存の購読をすべて持ち込む\",\n  \"new_user_dialog.overview.description\": \"Folo はフィードやニュースレター、ポッドキャスト、SNS をひとつの集中レイアウトにまとめる AI RSS リーダーです。\",\n  \"new_user_dialog.overview.highlight_1\": \"よく使うフィードやリストをピン留めして即座に切り替え。\",\n  \"new_user_dialog.overview.highlight_2\": \"2 カラム表示で記事を開いたまま最新更新を流し見できます。\",\n  \"new_user_dialog.overview.highlight_3\": \"キーボードショートカットとフィルターで集中をキープ。\",\n  \"new_user_dialog.overview.primary\": \"Discover を開く\",\n  \"new_user_dialog.overview.title\": \"落ち着いたホームで必要な情報をすべて確認\",\n  \"new_user_dialog.replay_video\": \"動画を再生\",\n  \"new_user_dialog.step_label.ai\": \"ステップ2 · AI コパイロット\",\n  \"new_user_dialog.step_label.import\": \"ステップ3 · インポート\",\n  \"new_user_dialog.step_label.overview\": \"ステップ1 · 概要\",\n  \"new_user_dialog.title\": \"Folo へようこそ\",\n  \"new_user_guide.actions.back\": \"戻る\",\n  \"new_user_guide.actions.finish\": \"完了\",\n  \"new_user_guide.actions.import_opml\": \"OPML をインポート\",\n  \"new_user_guide.actions.next\": \"次へ\",\n  \"new_user_guide.ai_chat.intro\": \"Folo へようこそ。Folo は AI がインターネットを読み解いてくれるリーダーです。\\n\\nあなたについて教えてください。\",\n  \"new_user_guide.ai_chat.reroll\": \"入れ替える\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\": \"EUのAI規制について学んでいます\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\": \"私は気候テックのニュースレターを書いていて、毎日の情報源が必要です\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\": \"私はサイバーセキュリティとオープンソースの脆弱性ニュースを追っています\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\": \"私はドラッグデリバリーを学んでおり、最新のFDA承認を知りたいです\",\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\": \"私はファッションデザイナーです\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\": \"私は投資家で、株式市場と企業ニュースを追っています\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\": \"日本旅行を計画しており、現地のヒントが欲しいです\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\": \"私はナノ工学の研究をしています\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\": \"私はNASAのファンです\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\": \"個人資産管理トラッカーを作っており、ベストプラクティスを知りたいです\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\": \"私はプラントベース料理のトレンドを探っています\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\": \"経済に関する長尺ポッドキャストの要約が欲しいです\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\": \"高校のロボティクスチームを指導しており、プロジェクトのアイデアを探しています\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\": \"SaaSプロダクトをローンチするマーケティングマネージャーです\",\n  \"new_user_guide.ai_chat.you_can_say\": \"こんなことを話してみてください...\",\n  \"new_user_guide.confirm_skip.message\": \"オンボーディング設定をスキップしてもよろしいですか？\",\n  \"new_user_guide.confirm_skip.title\": \"オンボーディングをスキップしますか？\",\n  \"new_user_guide.intro.description\": \"このガイドはアプリを快適に始めるためのガイドです。\",\n  \"new_user_guide.intro.title\": \"AI と楽しむ読書体験\",\n  \"new_user_guide.selection.empty_description\": \"AI チャットで知りたいテーマを伝えると、おすすめのフィードを提案します。\",\n  \"new_user_guide.selection.empty_title\": \"まだフィードが選択されていません\",\n  \"notify.store.default\": \"ストア\",\n  \"notify.store.mas\": \"App Store\",\n  \"notify.store.mss\": \"Microsoft Store\",\n  \"notify.unfollow_feed\": \"<FeedItem /> のフォローを解除しました\",\n  \"notify.unfollow_feed_many\": \"選択したすべてのフィードのフォローを解除しました。\",\n  \"notify.update_info\": \"{{app_name}} は更新可能です！\",\n  \"notify.update_info_1\": \"クリックして再起動\",\n  \"notify.update_info_2\": \"クリックして再読み込み\",\n  \"notify.update_info_3\": \"タッチして再読み込み\",\n  \"notify.update_info_store\": \"クリックして{{store}}を開く\",\n  \"player.back_10s\": \"10 秒戻る\",\n  \"player.close\": \"閉じる\",\n  \"player.download\": \"ダウンロード\",\n  \"player.exit_full_screen\": \"フルスクリーンを終了\",\n  \"player.forward_10s\": \"10 秒進む\",\n  \"player.full_screen\": \"フルスクリーン\",\n  \"player.mute\": \"ミュート\",\n  \"player.open_entry\": \"エントリを開く\",\n  \"player.pause\": \"一時停止\",\n  \"player.play\": \"再生\",\n  \"player.playback_rate\": \"再生速度\",\n  \"player.unmute\": \"ミュート解除\",\n  \"player.volume\": \"音量\",\n  \"quick_add.placeholder\": \"フィードをクイックフォローします、フィードのurlをここに入力してください...\",\n  \"quick_add.title\": \"クイックフォロー\",\n  \"register.confirm_password\": \"パスワードを確認する\",\n  \"register.email\": \"Email\",\n  \"register.login\": \"ログイン\",\n  \"register.magic_link_sent\": \"マジックリンクを送信しました！メールをご確認ください。\",\n  \"register.password\": \"パスワード\",\n  \"register.password_optional\": \"オプション\",\n  \"register.referral.days\": \"この紹介コードを使用してサインアップすると、{{days}} 日間の Pro Preview が得られます。\",\n  \"register.referral.description\": \"紹介コードを使用してサインアップすると、追加の Pro preview 日数が得られます。\",\n  \"register.referral.invalid\": \"無効な紹介コード\",\n  \"register.referral.label\": \"紹介コード\",\n  \"register.send_magic_link\": \"マジックリンクを送信\",\n  \"register.submit\": \"作成\",\n  \"resize.tooltip.double_click_to_collapse\": \"<b>ダブルクリック</b> してデフォルトサイズにリセットします\",\n  \"resize.tooltip.drag_to_resize\": \"<b>ドラッグ</b> してリサイズできます\",\n  \"search.empty.no_results\": \"結果が見つかりませんでした。\",\n  \"search.group.entries\": \"エントリー\",\n  \"search.group.feeds\": \"フィード\",\n  \"search.options.all\": \"すべて\",\n  \"search.options.entry\": \"エントリー\",\n  \"search.options.feed\": \"フィード\",\n  \"search.options.search_type\": \"検索の種類\",\n  \"search.placeholder\": \"検索...\",\n  \"search.result_count_local_mode\": \"（ローカルモード）\",\n  \"search.tooltip.local_search\": \"この検索は、ローカルで利用可能なデータを対象としています。最新データを含めるために再取得を試してください。\",\n  \"share.actions\": \"アクション\",\n  \"share.copy_failed\": \"リンクのコピーに失敗しました\",\n  \"share.copy_link\": \"リンクをコピー\",\n  \"share.default_description\": \"このエントリをチェックしてください\",\n  \"share.default_title\": \"エントリ共有\",\n  \"share.discover_more\": \"Folo でさらに「発見」する\",\n  \"share.link_copied\": \"リンクがクリップボードにコピーされました\",\n  \"share.social_media\": \"ソーシャルメディア\",\n  \"share.system_share\": \"システム共有\",\n  \"share.title\": \"エントリを共有\",\n  \"shortcuts.guide.title\": \"ショートカットガイドライン\",\n  \"sidebar.add_more_feeds\": \"さらにフィードを追加\",\n  \"sidebar.already_on_discover_page\": \"すでに 発見 ページにいます！フォローしたいコンテンツを右側のパネルに追加してください。\",\n  \"sidebar.category_remove_dialog.cancel\": \"キャンセル\",\n  \"sidebar.category_remove_dialog.continue\": \"グループ解除\",\n  \"sidebar.category_remove_dialog.description\": \"この操作によりカテゴリーのグループが解除され、含まれるフィードは既定のグループに戻ります。\",\n  \"sidebar.category_remove_dialog.error\": \"カテゴリーのグループ解除に失敗しました\",\n  \"sidebar.category_remove_dialog.success\": \"カテゴリーのグループ解除に成功しました\",\n  \"sidebar.category_remove_dialog.title\": \"カテゴリーのグループ解除\",\n  \"sidebar.category_unsubscribe_dialog.cancel\": \"キャンセル\",\n  \"sidebar.category_unsubscribe_dialog.confirm\": \"{{count}} 件のフィードを解除\",\n  \"sidebar.category_unsubscribe_dialog.confirm_one\": \"1 件のフィードを解除\",\n  \"sidebar.category_unsubscribe_dialog.confirm_other\": \"{{count}} 件のフィードを解除\",\n  \"sidebar.category_unsubscribe_dialog.confirm_zero\": \"0 件のフィードを解除\",\n  \"sidebar.category_unsubscribe_dialog.description\": \"この操作によりカテゴリー「{{category}}」内の {{count}} 件のフィードの購読を解除します。この操作は元に戻せません。\",\n  \"sidebar.category_unsubscribe_dialog.description_one\": \"この操作によりカテゴリー「{{category}}」内の 1 件のフィードの購読を解除します。この操作は元に戻せません。\",\n  \"sidebar.category_unsubscribe_dialog.description_other\": \"この操作によりカテゴリー「{{category}}」内の {{count}} 件のフィードの購読を解除します。この操作は元に戻せません。\",\n  \"sidebar.category_unsubscribe_dialog.description_zero\": \"カテゴリー「{{category}}」には解除できるフィードがありません。\",\n  \"sidebar.category_unsubscribe_dialog.error\": \"カテゴリーの購読解除に失敗しました\",\n  \"sidebar.category_unsubscribe_dialog.success\": \"カテゴリー「{{category}}」内の {{count}} 件のフィードを解除しました。\",\n  \"sidebar.category_unsubscribe_dialog.success_one\": \"カテゴリー「{{category}}」内の 1 件のフィードを解除しました。\",\n  \"sidebar.category_unsubscribe_dialog.success_other\": \"カテゴリー「{{category}}」内の {{count}} 件のフィードを解除しました。\",\n  \"sidebar.category_unsubscribe_dialog.success_zero\": \"カテゴリー「{{category}}」で解除されたフィードはありません。\",\n  \"sidebar.category_unsubscribe_dialog.title\": \"「{{folderName}}」の購読を解除\",\n  \"sidebar.feed_actions.claim\": \"クレーム\",\n  \"sidebar.feed_actions.claim_feed\": \"フィードをクレーム\",\n  \"sidebar.feed_actions.copy_email_address\": \"Email アドレスをコピー\",\n  \"sidebar.feed_actions.copy_feed_badge\": \"フィードバッジをコピー\",\n  \"sidebar.feed_actions.copy_feed_id\": \"フィード ID をコピー\",\n  \"sidebar.feed_actions.copy_feed_url\": \"フィード URL をコピー\",\n  \"sidebar.feed_actions.copy_list_id\": \"リスト ID をコピー\",\n  \"sidebar.feed_actions.copy_list_url\": \"リスト URL をコピー\",\n  \"sidebar.feed_actions.create_list\": \"新しいリストを作成\",\n  \"sidebar.feed_actions.edit\": \"編集\",\n  \"sidebar.feed_actions.edit_feed\": \"フィードを編集\",\n  \"sidebar.feed_actions.edit_inbox\": \"受信箱を編集\",\n  \"sidebar.feed_actions.edit_list\": \"リストを編集\",\n  \"sidebar.feed_actions.feed_owned_by_you\": \"このフィードはあなたが所有しています\",\n  \"sidebar.feed_actions.list_owned_by_you\": \"このリストはあなたが所収しています\",\n  \"sidebar.feed_actions.mark_all_as_read\": \"すべてを既読にする\",\n  \"sidebar.feed_actions.navigate_to_feed\": \"フィードに移動\",\n  \"sidebar.feed_actions.navigate_to_list\": \"リストに移動\",\n  \"sidebar.feed_actions.new_inbox\": \"新規受信箱\",\n  \"sidebar.feed_actions.open_feed_in_browser\": \"フィードを {{which}} で開く\",\n  \"sidebar.feed_actions.open_list_in_browser\": \"リストを {{which}} で開く\",\n  \"sidebar.feed_actions.open_site_in_browser\": \"サイトを {{which}} で開く\",\n  \"sidebar.feed_actions.reset_feed\": \"フィードをリセット\",\n  \"sidebar.feed_actions.reset_feed_error\": \"フィードのリセットに失敗しました\",\n  \"sidebar.feed_actions.reset_feed_success\": \"フィードのリセットに成功しました\",\n  \"sidebar.feed_actions.resetting_feed\": \"フィードをリセットしています...\",\n  \"sidebar.feed_actions.unfollow\": \"フォロー解除\",\n  \"sidebar.feed_actions.unfollow_feed\": \"フィードのフォローを解除\",\n  \"sidebar.feed_actions.unfollow_feed_many\": \"選択したすべてのフィードを解除\",\n  \"sidebar.feed_actions.unfollow_feed_many_confirm\": \"選択したすべてのフィードを解除してもよろしいですか？\",\n  \"sidebar.feed_actions.unfollow_feed_many_warning\": \"警告: この操作は選択したすべてのフィードのフォローを解除し元には戻せません。\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_category\": \"カテゴリーに移動\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_list\": \"フィードをリストに追加\",\n  \"sidebar.feed_column.context_menu.change_to_other_view\": \"他のビューに切り替え\",\n  \"sidebar.feed_column.context_menu.create_category\": \"新規カテゴリーを作成\",\n  \"sidebar.feed_column.context_menu.mark_as_read\": \"既読にする\",\n  \"sidebar.feed_column.context_menu.new_category_modal.category_name\": \"カテゴリー名\",\n  \"sidebar.feed_column.context_menu.new_category_modal.create\": \"作成\",\n  \"sidebar.feed_column.context_menu.rename_category\": \"カテゴリーをリネーム\",\n  \"sidebar.feed_column.context_menu.rename_category_error\": \"カテゴリーのリネームに失敗しました\",\n  \"sidebar.feed_column.context_menu.rename_category_success\": \"カテゴリーのリネームに成功しました\",\n  \"sidebar.feed_column.context_menu.title\": \"新規カテゴリーに移動する\",\n  \"sidebar.feed_column.context_menu.ungroup_category\": \"カテゴリーのグループ解除\",\n  \"sidebar.feed_column.context_menu.ungroup_category_confirmation\": \"カテゴリー「{{folderName}}」のグループを解除しますか？\",\n  \"sidebar.feed_column.context_menu.unsubscribe_category\": \"カテゴリー内の購読をすべて解除\",\n  \"sidebar.select_sort_method\": \"並べ替え方法を選択\",\n  \"sidebar.timeline_tabs.customize\": \"ビュータブをカスタマイズ...\",\n  \"sidebar.timeline_tabs.drag_tab\": \"タイムラインタブをドラッグ\",\n  \"sidebar.timeline_tabs.empty_hidden\": \"ここにドラッグするとサイドバーから非表示になります。\",\n  \"sidebar.timeline_tabs.empty_visible\": \"ここにドラッグするとサイドバーに表示されます。\",\n  \"sidebar.timeline_tabs.hidden\": \"非表示\",\n  \"sidebar.timeline_tabs.hide_tab\": \"このビューを非表示にする\",\n  \"sidebar.timeline_tabs.instructions\": \"セクション間でタブをドラッグして、並び替え、表示、非表示を切り替えます。\",\n  \"sidebar.timeline_tabs.reset\": \"デフォルトに戻す\",\n  \"sidebar.timeline_tabs.visible\": \"表示中\",\n  \"signin.continue_with\": \"{{provider}} で続ける\",\n  \"signin.sign_in_to\": \"サインイン\",\n  \"signin.sign_up_to\": \"サインアップ\",\n  \"subscription_limit_warning\": \"購読制限に達しました：<br /><b>{{feedCount}}/{{feedLimit}} フィード</b>、<br /><b>{{rsshubCount}}/{{rsshubLimit}} RSSHub</b>。<br />プランをアップグレードするか、未使用のフィードをクリーンアップしてください。\",\n  \"sync_indicator.disabled\": \"セキュリティ上の理由により、同期は無効になっています。\",\n  \"sync_indicator.offline\": \"オフライン\",\n  \"sync_indicator.synced\": \"サーバーと同期済み\",\n  \"trending.entry\": \"人気エントリー\",\n  \"trending.entry_no_results\": \"人気のエントリーは見つかりませんでした\",\n  \"trending.feed\": \"人気フィード\",\n  \"trending.list\": \"人気リスト\",\n  \"trending.user\": \"人気ユーザー\",\n  \"tutorial.scroll_to_exit.description\": \"上部にいるときはスクロールアップしてタイムラインに素早く戻る。\",\n  \"tutorial.scroll_to_exit.dismiss_hint\": \"クリックして閉じる\",\n  \"tutorial.scroll_to_exit.title\": \"上にスクロールして戻る\",\n  \"user_button.account\": \"アカウント\",\n  \"user_button.achievement\": \"実績\",\n  \"user_button.actions\": \"アクション\",\n  \"user_button.ai\": \"AI\",\n  \"user_button.download_desktop_app\": \"アプリをダウンロード\",\n  \"user_button.log_out\": \"ログアウト\",\n  \"user_button.power\": \"Power\",\n  \"user_button.preferences\": \"設定\",\n  \"user_button.profile\": \"プロフィール\",\n  \"user_profile.about\": \"About\",\n  \"user_profile.close\": \"閉じる\",\n  \"user_profile.created_lists\": \"作成したリスト\",\n  \"user_profile.edit\": \"編集\",\n  \"user_profile.loading\": \"読み込み中\",\n  \"user_profile.share\": \"共有\",\n  \"user_profile.subscriptions\": \"サブスクリプション\",\n  \"user_profile.toggle_item_style\": \"アイテムスタイルを切り替え\",\n  \"words.achievement\": \"実績\",\n  \"words.actions\": \"アクション\",\n  \"words.add\": \"追加\",\n  \"words.all\": \"すべて\",\n  \"words.browser\": \"ブラウザー\",\n  \"words.categories\": \"カテゴリー\",\n  \"words.confirm\": \"確認\",\n  \"words.discover\": \"発見\",\n  \"words.email\": \"Email\",\n  \"words.feeds\": \"フィード\",\n  \"words.import\": \"インポート\",\n  \"words.inbox\": \"受信トレイ\",\n  \"words.items\": \"アイテム\",\n  \"words.language\": \"言語\",\n  \"words.link\": \"リンク\",\n  \"words.lists\": \"リスト\",\n  \"words.load_archived_entries\": \"アーカイブされたエントリーを読み込む\",\n  \"words.login\": \"ログイン\",\n  \"words.mint\": \"ミント\",\n  \"words.newTab\": \"新しいタブ\",\n  \"words.power\": \"Power\",\n  \"words.rss\": \"RSS\",\n  \"words.rss3\": \"RSS3\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.search\": \"検索\",\n  \"words.show_more\": \"さらに表示\",\n  \"words.starred\": \"スター付き\",\n  \"words.title\": \"タイトル\",\n  \"words.transform\": \"カスタマイズ\",\n  \"words.trending\": \"トレンド\",\n  \"words.undo\": \"もとに戻す\",\n  \"words.unread\": \"未読\",\n  \"words.user\": \"ユーザー\",\n  \"words.view\": \"表示\",\n  \"words.which.all\": \"すべて\",\n  \"words.zero_items\": \"アイテムがありません\"\n}\n"
  },
  {
    "path": "locales/app/zh-CN.json",
    "content": "{\n  \"achievement.all_done\": \"大功告成！\",\n  \"achievement.alpha_tester\": \"Alpha 内测用户\",\n  \"achievement.alpha_tester_description\": \"早期 Folo Alpha 版本的内测用户\",\n  \"achievement.description\": \"成为硬核玩家，赚取 NFT。\",\n  \"achievement.first_claim_feed\": \"订阅源所有者\",\n  \"achievement.first_claim_feed_description\": \"在 Folo 上认证订阅源\",\n  \"achievement.first_create_list\": \"列表创作者\",\n  \"achievement.first_create_list_description\": \"在 Folo 上创建一个列表\",\n  \"achievement.follow_special_feed\": \"特别订阅\",\n  \"achievement.follow_special_feed_description\": \"在 Folo 上订阅一个特别的订阅源\",\n  \"achievement.list_subscribe_100\": \"100 个列表订阅者\",\n  \"achievement.list_subscribe_100_description\": \"创建的列表有 100 人订阅\",\n  \"achievement.list_subscribe_50\": \"50 个列表订阅者\",\n  \"achievement.list_subscribe_500\": \"500 个列表订阅者\",\n  \"achievement.list_subscribe_500_description\": \"创建的列表有 500 人订阅\",\n  \"achievement.list_subscribe_50_description\": \"创建的列表有 50 人订阅\",\n  \"achievement.nft_coming_soon\": \"目前你无法赚取 NFT。一旦我们准备好，它们将自动记入你的帐户。\",\n  \"achievement.product_hunt_vote\": \"Product Hunt 支持者\",\n  \"achievement.product_hunt_vote_description\": \"在 Product Hunt 上给 Folo 投票\",\n  \"activation.activate\": \"激活\",\n  \"activation.description\": \"在公测阶段，预览版用户使用此功能会受限。\",\n  \"activation.plan.description\": \"您当前正在使用免费版本。开启免费试用以解锁更多功能。\",\n  \"activation.plan.title\": \"升级套餐（免费试用）\",\n  \"activation.plan.upgrade\": \"免费试用\",\n  \"activation.title\": \"邀请码\",\n  \"ai.summary_not_available\": \"摘要不可用\",\n  \"ai.summary_upgrade_required_description\": \"升级到 Pro 计划，解锁无限 AI 摘要、翻译和更多智能功能。\",\n  \"ai.summary_upgrade_required_title\": \"开启免费试用以继续使用 AI 摘要\",\n  \"ai.summary_upgrade_view_plans\": \"查看计划\",\n  \"app.copy_logo_svg\": \"复制 Logo SVG\",\n  \"app.copy_logo_text_svg\": \"复制徽标 SVG 文本\",\n  \"app.toggle_sidebar\": \"切换侧边栏\",\n  \"discover.any_url_or_keyword\": \"任意链接或关键词\",\n  \"discover.default_option\": \"（默认）\",\n  \"discover.feed_description\": \"根据描述完善目标订阅源的相关信息。\",\n  \"discover.feed_maintainers\": \"由 RSSHub 提供，感谢贡献者 <maintainers />\",\n  \"discover.import.click_to_upload\": \"导入 OPML 文件\",\n  \"discover.import.conflictItems\": \"冲突项目\",\n  \"discover.import.import_completed_with_issues\": \"导入完成，但存在一些问题\",\n  \"discover.import.import_successful\": \"导入成功完成\",\n  \"discover.import.noItems\": \"没有项目\",\n  \"discover.import.no_feeds_found\": \"未找到匹配的订阅源。\",\n  \"discover.import.opml\": \"OPML 文件\",\n  \"discover.import.opml_step1\": \"从你的 RSS 阅读器导出 OPML 文件\",\n  \"discover.import.opml_step1_feedly\": \"从 Feedly 导出 OPML 文件\",\n  \"discover.import.opml_step1_feedly_step1\": \"打开 <Link />。\",\n  \"discover.import.opml_step1_feedly_step2\": \"点击 \\\"Download Your Feedly OPML\\\" 按钮。\",\n  \"discover.import.opml_step1_inoreader\": \"从 Inoreader 导出 OPML 文件\",\n  \"discover.import.opml_step1_inoreader_step1\": \"打开 <Link />。\",\n  \"discover.import.opml_step1_inoreader_step2\": \"切换到 \\\"SYSTEM FOLDERS\\\" 标签。\",\n  \"discover.import.opml_step1_inoreader_step3\": \"点击 \\\"Newsfeed\\\" 右侧的 \\\"OPML\\\" 按钮。\",\n  \"discover.import.opml_step1_other\": \"从其他阅读器导出 OPML 文件\",\n  \"discover.import.opml_step1_other_step1\": \"OPML 是一种广泛支持的开放格式，基本上是共享订阅源列表的标准。几乎所有的 RSS 阅读器都允许导入和导出 OPML。如果您不确定如何操作，请参考您的 RSS 阅读器的帮助手册或加入我们的社区以获取帮助。\",\n  \"discover.import.opml_step2\": \"导入 OPML 到 Folo\",\n  \"discover.import.parse_opml\": \"解析 OPML\",\n  \"discover.import.parsedErrorItems\": \"解析错误项目\",\n  \"discover.import.preview_opml_content\": \"预览 OPML 内容\",\n  \"discover.import.quota_exceeded\": \"超出额度\",\n  \"discover.import.quota_exceeded_warning\": \"你选择的订阅源数量超过了剩余额度。请取消选择一些订阅源以继续。\",\n  \"discover.import.quota_limit_reached\": \"已达额度限制\",\n  \"discover.import.quota_status\": \"导入额度：\",\n  \"discover.import.quota_warning\": \"你的额度还剩 {{remaining}} 个订阅源。\",\n  \"discover.import.remaining_quota\": \"你还可以导入 {{remaining}} 个订阅源\",\n  \"discover.import.result\": \"<SuccessfulNum /> 个订阅源导入成功。<br /><ConflictNum /> 个订阅源已订阅过。<br /><ErrorNum /> 个订阅源导入失败。\",\n  \"discover.import.search_feeds_placeholder\": \"搜索订阅源...\",\n  \"discover.import.select_all_feeds\": \"全选订阅源\",\n  \"discover.import.select_all_filtered\": \"全选筛选结果\",\n  \"discover.import.select_feeds_description\": \"检查并选择你想要导入的订阅源。默认全部选中。\",\n  \"discover.import.select_feeds_to_import\": \"选择要导入的订阅源\",\n  \"discover.import.successfulItems\": \"成功项目\",\n  \"discover.inbox.actions\": \"操作\",\n  \"discover.inbox.description\": \"你可以通过邮件和 Webhook 在收件箱接收信息。\",\n  \"discover.inbox.email\": \"邮件地址\",\n  \"discover.inbox.handle\": \"唯一标识\",\n  \"discover.inbox.no_inbox\": \"你目前没有收件箱，请点击下方按钮创建第一个收件箱。\",\n  \"discover.inbox.secret\": \"密钥\",\n  \"discover.inbox.title\": \"标题\",\n  \"discover.inbox.webhooks_docs\": \"Webhook 文档\",\n  \"discover.inbox_create\": \"新建收件箱\",\n  \"discover.inbox_create_description\": \"你还没有收件箱。创建一个收件箱以通过收件箱接收信息。\",\n  \"discover.inbox_create_error\": \"创建收件箱失败。\",\n  \"discover.inbox_create_success\": \"收件箱创建成功。\",\n  \"discover.inbox_destroy\": \"删除\",\n  \"discover.inbox_destroy_confirm\": \"确认删除收件箱？\",\n  \"discover.inbox_destroy_error\": \"删除收件箱失败。\",\n  \"discover.inbox_destroy_success\": \"收件箱删除成功。\",\n  \"discover.inbox_destroy_warning\": \"警告：一旦删除，邮件地址将不再可用，所有内容将被永久删除且无法恢复。\",\n  \"discover.inbox_handle\": \"唯一标识\",\n  \"discover.inbox_title\": \"标题\",\n  \"discover.inbox_update\": \"更新\",\n  \"discover.inbox_update_error\": \"修改收件箱失败。\",\n  \"discover.inbox_update_success\": \"收件箱更新成功。\",\n  \"discover.popular\": \"热门\",\n  \"discover.preview\": \"预览\",\n  \"discover.rss_hub_route\": \"RSSHub 路由\",\n  \"discover.rss_url\": \"RSS URL\",\n  \"discover.search.results_one\": \"找到 {{count}} 个订阅源\",\n  \"discover.search.results_other\": \"找到 {{count}} 个订阅源\",\n  \"discover.search.results_zero\": \"搜索结果为空\",\n  \"discover.select_placeholder\": \"选择\",\n  \"discover.target.feeds\": \"订阅源\",\n  \"discover.target.label\": \"搜索\",\n  \"discover.target.lists\": \"列表\",\n  \"discover.tips.auto_detect\": \"自动识别输入类型\",\n  \"discover.tips.search_keyword\": \"支持关键词、URL、RSS 订阅源或 RSSHub 路由\",\n  \"discover.tools.description\": \"更多工具\",\n  \"discover.tools.import\": \"导入 OPML\",\n  \"discover.tools.inbox\": \"收件箱\",\n  \"discover.tools.transform\": \"HTML 转 RSS\",\n  \"discover.tools.user\": \"关注用户\",\n  \"entry.click_to_return\": \"向上滚动或点击此区域返回\",\n  \"entry.exit_detail\": \"返回时间线\",\n  \"entry.scroll_up_to_exit\": \"向上滚动返回时间线\",\n  \"entry_actions.copied_notify\": \"{{which}}已复制到剪贴板。\",\n  \"entry_actions.copy_link\": \"复制链接\",\n  \"entry_actions.copy_title\": \"复制标题\",\n  \"entry_actions.delete\": \"删除\",\n  \"entry_actions.deleted\": \"已删除\",\n  \"entry_actions.export_as_pdf\": \"导出为 PDF\",\n  \"entry_actions.failed_to_delete\": \"删除失败\",\n  \"entry_actions.failed_to_login_to_qbittorrent\": \"登录 qBittorrent 失败\",\n  \"entry_actions.failed_to_save_to_cubox\": \"保存到 Cubox 失败。\",\n  \"entry_actions.failed_to_save_to_eagle\": \"保存到 Eagle 失败。\",\n  \"entry_actions.failed_to_save_to_instapaper\": \"保存到 Instapaper 失败。\",\n  \"entry_actions.failed_to_save_to_obsidian\": \"保存到 Obsidian 失败。\",\n  \"entry_actions.failed_to_save_to_outline\": \"保存到 Outline 失败。\",\n  \"entry_actions.failed_to_save_to_qbittorrent\": \"使用 qBittorrent 下载失败\",\n  \"entry_actions.failed_to_save_to_readeck\": \"保存到 Readeck 失败。\",\n  \"entry_actions.failed_to_save_to_readwise\": \"保存到 Readwise 失败。\",\n  \"entry_actions.failed_to_save_to_zotero\": \"保存到 Zotero 失败\",\n  \"entry_actions.image_gallery\": \"图片库\",\n  \"entry_actions.image_gallery_description\": \"当文章中存在多张大图时，可以在 Gallery Modal 中浏览文章中的全部图片\",\n  \"entry_actions.mark_above_as_read\": \"将以上标记为已读\",\n  \"entry_actions.mark_as_read\": \"标记为已读/未读\",\n  \"entry_actions.mark_as_unread\": \"标记为未读\",\n  \"entry_actions.mark_below_as_read\": \"将以下标记为已读\",\n  \"entry_actions.no_bittorrent_urls_found\": \"此条目中未找到 BitTorrent 链接\",\n  \"entry_actions.open_in_browser\": \"在{{which}}打开\",\n  \"entry_actions.recent_reader\": \"最近阅读者：\",\n  \"entry_actions.save_media_to_eagle\": \"保存到 Eagle\",\n  \"entry_actions.save_to_cubox\": \"保存到 Cubox\",\n  \"entry_actions.save_to_instapaper\": \"保存到 Instapaper\",\n  \"entry_actions.save_to_obsidian\": \"保存到 Obsidian\",\n  \"entry_actions.save_to_outline\": \"保存到 Outline\",\n  \"entry_actions.save_to_qbittorrent\": \"使用 qBittorrent 下载\",\n  \"entry_actions.save_to_readeck\": \"保存到 Readeck\",\n  \"entry_actions.save_to_readwise\": \"保存到 Readwise\",\n  \"entry_actions.save_to_zotero\": \"保存到 Zotero\",\n  \"entry_actions.saved_to_cubox\": \"已保存到 Cubox\",\n  \"entry_actions.saved_to_eagle\": \"已保存到 Eagle。\",\n  \"entry_actions.saved_to_instapaper\": \"已保存到 Instapaper。\",\n  \"entry_actions.saved_to_obsidian\": \"已保存到 Obsidian。\",\n  \"entry_actions.saved_to_outline\": \"已保存到 Outline。\",\n  \"entry_actions.saved_to_qbittorrent\": \"种子已添加到 qBittorrent\",\n  \"entry_actions.saved_to_readeck\": \"已保存到 Readeck。\",\n  \"entry_actions.saved_to_readwise\": \"已保存到 Readwise。\",\n  \"entry_actions.saved_to_zotero\": \"已保存到 Zotero。\",\n  \"entry_actions.share\": \"分享\",\n  \"entry_actions.star\": \"收藏\",\n  \"entry_actions.starred\": \"已收藏\",\n  \"entry_actions.toggle_ai_summary\": \"切换 AI 总结\",\n  \"entry_actions.toggle_ai_translation\": \"切换 AI 翻译\",\n  \"entry_actions.unstar\": \"取消收藏\",\n  \"entry_actions.unstarred\": \"取消收藏\",\n  \"entry_actions.view_source_content\": \"查看原文\",\n  \"entry_actions.view_source_content_description\": \"尝试以源网站的风格阅读此文章的内容\",\n  \"entry_actions.warn_info_ai_chat_pinned_tip\": \"此功能目前处仍处于测试阶段，仅少数用户可使用。\",\n  \"entry_actions.warn_info_for_desktop\": \"此功能只在桌面端可用\",\n  \"entry_column.filtered_content_tip\": \"部分内容已被过滤隐藏。\",\n  \"entry_column.filtered_content_tip_2\": \"除了上面显示的内容外，还有一些被过滤的内容。\",\n  \"entry_column.refreshing\": \"正在刷新\",\n  \"entry_content.ai_summary\": \"AI 总结\",\n  \"entry_content.fetching_content\": \"正在获取原始内容并处理\",\n  \"entry_content.fetching_content_failed\": \"获取原始内容失败\",\n  \"entry_content.header.play_tts\": \"播放文本转语音\",\n  \"entry_content.header.play_tts_description\": \"在设置中选择一个 TTS 语音，启动 TTS 转换为有声内容\",\n  \"entry_content.header.readability\": \"阅读模式\",\n  \"entry_content.header.readability_description\": \"利用 Readability 获取原始网站内容并解析的能力或可提升内容可读性。建议在 RSS 内容不可用或不完整时使用。\",\n  \"entry_content.no_content\": \"没有内容\",\n  \"entry_content.readability_notice\": \"此内容由 Readability 提供。如果你发现排版异常，请访问源站查看原始内容。\",\n  \"entry_content.render_error\": \"渲染错误：\",\n  \"entry_content.report_issue\": \"反馈问题\",\n  \"entry_content.selection_toolbar.ask_ai\": \"询问 AI\",\n  \"entry_content.selection_toolbar.copied\": \"已复制\",\n  \"entry_content.selection_toolbar.copy\": \"复制\",\n  \"entry_content.selection_toolbar.copy_image\": \"复制图片\",\n  \"entry_content.selection_toolbar.copying\": \"复制中...\",\n  \"entry_content.selection_toolbar.generating\": \"生成中...\",\n  \"entry_content.selection_toolbar.poster_copied\": \"海报已复制到剪贴板\",\n  \"entry_content.selection_toolbar.poster_copy_failed\": \"复制海报失败\",\n  \"entry_content.selection_toolbar.share\": \"分享\",\n  \"entry_content.selection_toolbar.share_poster\": \"分享海报\",\n  \"entry_content.web_app_notice\": \"网页版不支持展示此类型，请下载客户端查看。\",\n  \"entry_list.zero_unread\": \"全部已读\",\n  \"entry_list_header.ai_timeline\": \"AI 时间线\",\n  \"entry_list_header.ai_timeline_loading\": \"正在用 AI 重新排序时间线…\",\n  \"entry_list_header.ai_timeline_prompt_required\": \"请先设置 AI 时间线排序提示\",\n  \"entry_list_header.grid\": \"网格布局\",\n  \"entry_list_header.image_only\": \"仅图片\",\n  \"entry_list_header.items\": \"内容\",\n  \"entry_list_header.masonry\": \"瀑布流布局\",\n  \"entry_list_header.masonry_column\": \"布局列数\",\n  \"entry_list_header.preview_mode\": \"预览模式\",\n  \"entry_list_header.refetch\": \"刷新\",\n  \"entry_list_header.refresh\": \"刷新\",\n  \"entry_list_header.show_all\": \"显示全部\",\n  \"entry_list_header.show_unread_only\": \"仅显示未读\",\n  \"entry_list_header.timeline_summary\": \"总结时间线\",\n  \"entry_list_header.unread\": \"未读\",\n  \"feed.actions.follow\": \"订阅\",\n  \"feed.actions.followed\": \"已订阅\",\n  \"feed.followsAndFeeds\": \"在 {{appName}} 上有 {{subscriptionCount}} 个{{subscriptionNoun}}和 {{feedsCount}} 个{{feedsNoun}}\",\n  \"feed.read_one\": \"阅读\",\n  \"feed.read_other\": \"阅读\",\n  \"feed_category.onboarding_feed\": \"此分类包含入门引导订阅源\",\n  \"feed_claim_modal.choose_verification_method\": \"有三种认证方式，可任选其中一种进行认证。\",\n  \"feed_claim_modal.claim_button\": \"认证\",\n  \"feed_claim_modal.content_instructions\": \"复制以下内容，发布到需要认证的订阅源。\",\n  \"feed_claim_modal.description_current\": \"当前描述：\",\n  \"feed_claim_modal.description_instructions\": \"复制以下内容，添加到需要认证的订阅源的 <code /> 字段内。\",\n  \"feed_claim_modal.failed_to_load\": \"认证数据加载失败\",\n  \"feed_claim_modal.rss_format_choice\": \"RSS 生成器通常有两种格式可供选择，根据需要复制下面内容。\",\n  \"feed_claim_modal.rss_instructions\": \"复制以下内容并粘贴到对应的 RSS 生成工具。\",\n  \"feed_claim_modal.rss_json_format\": \"JSON 格式\",\n  \"feed_claim_modal.rss_xml_format\": \"XML 格式\",\n  \"feed_claim_modal.rsshub_notice\": \"此订阅源由 RSSHub 提供，缓存时间为 1 小时，信息可能会有最多 1 小时的延迟。\",\n  \"feed_claim_modal.tab_content\": \"内容\",\n  \"feed_claim_modal.tab_description\": \"描述\",\n  \"feed_claim_modal.tab_rss\": \"RSS 标签\",\n  \"feed_claim_modal.title\": \"认证订阅源\",\n  \"feed_claim_modal.verify_ownership\": \"要证明你是此订阅源的所有者，你需要完成所有权认证。\",\n  \"feed_form.add_feed\": \"添加订阅源\",\n  \"feed_form.add_follow\": \"新增订阅\",\n  \"feed_form.category\": \"分类\",\n  \"feed_form.category_description\": \"默认情况下，你的订阅将按网站域名分组。\",\n  \"feed_form.error_fetching_feed\": \"获取订阅源出错。\",\n  \"feed_form.fee\": \"订阅费\",\n  \"feed_form.fee_description\": \"如需订阅此列表，需支付订阅费用。\",\n  \"feed_form.feed_not_found\": \"未找到订阅源\",\n  \"feed_form.feedback\": \"反馈\",\n  \"feed_form.fill_default\": \"填充\",\n  \"feed_form.follow\": \"订阅\",\n  \"feed_form.follow_with_fee\": \"使用 {{fee}} Power 订阅\",\n  \"feed_form.followed\": \"🎉 订阅成功\",\n  \"feed_form.hide_from_timeline\": \"在时间线上隐藏\",\n  \"feed_form.hide_from_timeline_description\": \"开启后，此订阅将不再显示在主时间线中\",\n  \"feed_form.private_follow\": \"私密订阅\",\n  \"feed_form.private_follow_description\": \"开启后，此订阅不再显示在个人资料页面。\",\n  \"feed_form.retry\": \"重试\",\n  \"feed_form.title\": \"标题\",\n  \"feed_form.title_description\": \"此订阅源的自定义标题，留空则使用默认标题。\",\n  \"feed_form.unfollow\": \"取消订阅\",\n  \"feed_form.update\": \"更新\",\n  \"feed_form.update_follow\": \"更新订阅\",\n  \"feed_form.updated\": \"🎉 订阅更新成功\",\n  \"feed_form.view\": \"视图\",\n  \"feed_item.claimed_by_owner\": \"订阅源所有者\",\n  \"feed_item.claimed_by_unknown\": \"未知所有者\",\n  \"feed_item.claimed_by_you\": \"订阅源由你提供\",\n  \"feed_item.claimed_feed\": \"已认证源\",\n  \"feed_item.claimed_list\": \"已认证列表\",\n  \"feed_item.error_since\": \"源失效：\",\n  \"feed_item.not_publicly_visible\": \"在个人页面上隐藏\",\n  \"feed_item.onboarding_feed\": \"这是一个入门引导订阅源\",\n  \"login.agree_to\": \"继续即表示您同意我们的\",\n  \"login.back\": \"返回\",\n  \"login.confirm_password.label\": \"确认密码\",\n  \"login.continueWith\": \"使用 {{provider}} 继续\",\n  \"login.email\": \"邮件地址\",\n  \"login.enter_token\": \"输入授权令牌以继续\",\n  \"login.forget_password.note\": \"忘记了密码？\",\n  \"login.have_account\": \"已有账户？<strong>登录</strong>\",\n  \"login.lastUsed\": \"上次使用\",\n  \"login.magic_link_sent\": \"魔法链接已发送！请查看您的邮箱。\",\n  \"login.no_account\": \"没有账户？<strong>注册</strong>\",\n  \"login.or\": \"或\",\n  \"login.password\": \"密码\",\n  \"login.password_optional\": \"可选\",\n  \"login.privacy\": \"隐私政策\",\n  \"login.send_magic_link\": \"发送魔法链接\",\n  \"login.signUp\": \"使用邮件地址注册\",\n  \"login.submit\": \"提交\",\n  \"login.terms\": \"服务条款\",\n  \"login.title\": \"欢迎使用 Folo\",\n  \"login.with_email.title\": \"使用邮件地址登录\",\n  \"mark_all_read_button.auto_confirm_info\": \"{{countdown}} 秒后自动确认。\",\n  \"mark_all_read_button.confirm\": \"确认\",\n  \"mark_all_read_button.confirm_mark_all\": \"将 <which /> 标记为已读？\",\n  \"mark_all_read_button.confirm_mark_all_info\": \"确认将全部标记为已读？\",\n  \"mark_all_read_button.done\": \"已标记\",\n  \"mark_all_read_button.mark_all_as_read\": \"全部标记为已读\",\n  \"mark_all_read_button.mark_as_read\": \"标记 <which /> 为已读\",\n  \"mark_all_read_button.undo\": \"撤销\",\n  \"new_user_dialog.actions.close\": \"关闭\",\n  \"new_user_dialog.actions.finish\": \"即刻体验\",\n  \"new_user_dialog.ai.description\": \"回答几个提示，Folo 的 AI 会为你挑选订阅、列表与阅读计划。\",\n  \"new_user_dialog.ai.highlight_1\": \"用自然语言描述你想了解的领域。\",\n  \"new_user_dialog.ai.highlight_2\": \"获得带有推荐理由的精选来源。\",\n  \"new_user_dialog.ai.highlight_3\": \"通过对话持续微调摘要风格与频率。\",\n  \"new_user_dialog.ai.primary\": \"启动 AI 引导\",\n  \"new_user_dialog.ai.title\": \"让 AI Copilot 为你搭建起点\",\n  \"new_user_dialog.import.description\": \"导入任何阅读器导出的 OPML，并在订阅前逐条预览。\",\n  \"new_user_dialog.import.highlight_1\": \"支持 Feedly、Inoreader 等任意 RSS 阅读器的 OPML 文件。\",\n  \"new_user_dialog.import.highlight_2\": \"导入前先预览与筛选想要保留的订阅。\",\n  \"new_user_dialog.import.highlight_3\": \"将新订阅快速整理进文件夹与列表。\",\n  \"new_user_dialog.import.primary\": \"导入 OPML\",\n  \"new_user_dialog.import.title\": \"带上你已经关注的所有内容\",\n  \"new_user_dialog.overview.description\": \"Folo 是一款 AI RSS 阅读器，把资讯流、新闻简报、播客和社交更新集中在同一个沉浸式阅读空间。\",\n  \"new_user_dialog.overview.highlight_1\": \"固定常用的订阅、列表与主题，随时切换上下文。\",\n  \"new_user_dialog.overview.highlight_2\": \"双栏布局让你一边阅读条目一边浏览最新动态。\",\n  \"new_user_dialog.overview.highlight_3\": \"键盘快捷键与筛选器让专注阅读不被打断。\",\n  \"new_user_dialog.overview.primary\": \"前往发现页\",\n  \"new_user_dialog.overview.title\": \"在同一个舒适空间看到一切\",\n  \"new_user_dialog.replay_video\": \"重播视频\",\n  \"new_user_dialog.step_label.ai\": \"步骤二 · AI 助手\",\n  \"new_user_dialog.step_label.import\": \"步骤三 · 导入\",\n  \"new_user_dialog.step_label.overview\": \"步骤一 · 概览\",\n  \"new_user_dialog.title\": \"欢迎使用 Folo\",\n  \"new_user_guide.actions.back\": \"上一步\",\n  \"new_user_guide.actions.finish\": \"完成\",\n  \"new_user_guide.actions.import_opml\": \"导入 OPML\",\n  \"new_user_guide.actions.next\": \"下一步\",\n  \"new_user_guide.ai_chat.intro\": \"欢迎来到 Folo，这款 AI 阅读器可以替你阅读整个互联网。\\n\\n告诉我一些关于你的事情。\",\n  \"new_user_guide.ai_chat.reroll\": \"换一批\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\": \"我在学习欧盟的 AI 监管动态\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\": \"我写一份气候科技通讯，需要每日素材\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\": \"我关注网络安全和开源漏洞新闻\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\": \"我学习药物递送，想了解最新的 FDA 批准\",\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\": \"我是一名时装设计师\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\": \"我是投资人，需要关注股市和公司新闻\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\": \"我计划去日本旅行，想要本地建议\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\": \"我做纳米工程研究\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\": \"我是 NASA 的粉丝\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\": \"我在搭建个人理财追踪器，需要最佳实践\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\": \"我在探索植物性烹饪的新趋势\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\": \"我需要经济类长播客的摘要\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\": \"我指导高中机器人团队，寻找项目灵感\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\": \"我是 SaaS 产品的市场经理，准备发布\",\n  \"new_user_guide.ai_chat.you_can_say\": \"你可以这样说...\",\n  \"new_user_guide.confirm_skip.message\": \"确定要跳过引导设置吗？\",\n  \"new_user_guide.confirm_skip.title\": \"跳过新手引导？\",\n  \"new_user_guide.intro.description\": \"本指南将帮助你快速上手这款应用。\",\n  \"new_user_guide.intro.title\": \"和 AI 一起沉浸式阅读\",\n  \"new_user_guide.selection.empty_description\": \"在 AI 对话里描述你的需求，我们会为你推荐来源。\",\n  \"new_user_guide.selection.empty_title\": \"还没有选择任何订阅源\",\n  \"notify.store.default\": \"商店\",\n  \"notify.store.mas\": \"App Store\",\n  \"notify.store.mss\": \"Microsoft Store\",\n  \"notify.unfollow_feed\": \"已取消订阅 <FeedItem />\",\n  \"notify.unfollow_feed_many\": \"已取消订阅选中的订阅源。\",\n  \"notify.update_info\": \"{{app_name}} 已准备好更新！\",\n  \"notify.update_info_1\": \"点击重新启动\",\n  \"notify.update_info_2\": \"点击重新加载页面\",\n  \"notify.update_info_3\": \"点按重新加载页面\",\n  \"notify.update_info_store\": \"点击前往 {{store}}\",\n  \"player.back_10s\": \"后退 10s\",\n  \"player.close\": \"关闭\",\n  \"player.download\": \"下载\",\n  \"player.exit_full_screen\": \"退出全屏\",\n  \"player.forward_10s\": \"快进 10s\",\n  \"player.full_screen\": \"进入全屏\",\n  \"player.mute\": \"静音\",\n  \"player.open_entry\": \"跳转到条目\",\n  \"player.pause\": \"暂停\",\n  \"player.play\": \"播放\",\n  \"player.playback_rate\": \"播放速度\",\n  \"player.unmute\": \"取消静音\",\n  \"player.volume\": \"音量\",\n  \"quick_add.placeholder\": \"在此输入订阅源地址以快速订阅…\",\n  \"quick_add.title\": \"快速订阅\",\n  \"register.confirm_password\": \"确认密码\",\n  \"register.email\": \"邮件地址\",\n  \"register.login\": \"登录\",\n  \"register.magic_link_sent\": \"魔法链接已发送！请查看您的邮箱。\",\n  \"register.password\": \"密码\",\n  \"register.password_optional\": \"可选\",\n  \"register.referral.days\": \"使用推荐码注册以获得{{days}}天的专业版预览\",\n  \"register.referral.description\": \"使用推荐码注册以获得几日额外的专业版预览\",\n  \"register.referral.invalid\": \"推荐码无效\",\n  \"register.referral.label\": \"推荐码\",\n  \"register.send_magic_link\": \"发送魔法链接\",\n  \"register.submit\": \"创建账户\",\n  \"resize.tooltip.double_click_to_collapse\": \"<b>双击</b>重置为默认大小\",\n  \"resize.tooltip.drag_to_resize\": \"<b>拖动</b>以调整大小\",\n  \"search.empty.no_results\": \"搜索结果为空\",\n  \"search.group.entries\": \"条目\",\n  \"search.group.feeds\": \"订阅源\",\n  \"search.options.all\": \"全部\",\n  \"search.options.entry\": \"条目\",\n  \"search.options.feed\": \"订阅源\",\n  \"search.options.search_type\": \"搜索类型\",\n  \"search.placeholder\": \"搜索...\",\n  \"search.result_count_local_mode\": \"（本地模式）\",\n  \"search.tooltip.local_search\": \"当前搜索仅包含本地可用数据，尝试重新搜索得到更多结果。\",\n  \"share.actions\": \"操作\",\n  \"share.copy_failed\": \"复制链接失败\",\n  \"share.copy_link\": \"复制链接\",\n  \"share.default_description\": \"查看这个精彩内容\",\n  \"share.default_title\": \"内容分享\",\n  \"share.discover_more\": \"在 Folo 上发现更多精彩内容\",\n  \"share.link_copied\": \"链接已复制到剪贴板\",\n  \"share.social_media\": \"社交媒体\",\n  \"share.system_share\": \"系统分享\",\n  \"share.title\": \"分享内容\",\n  \"shortcuts.guide.title\": \"快捷键指南\",\n  \"sidebar.add_more_feeds\": \"添加订阅源\",\n  \"sidebar.already_on_discover_page\": \"你已位于发现页面！请将想要订阅的内容添加到右侧面板。\",\n  \"sidebar.category_remove_dialog.cancel\": \"取消\",\n  \"sidebar.category_remove_dialog.continue\": \"取消分组\",\n  \"sidebar.category_remove_dialog.description\": \"此操作会取消当前分类，将其中的订阅恢复为默认分组。\",\n  \"sidebar.category_remove_dialog.error\": \"取消分类分组失败\",\n  \"sidebar.category_remove_dialog.success\": \"分类取消分组成功\",\n  \"sidebar.category_remove_dialog.title\": \"取消分类分组\",\n  \"sidebar.category_unsubscribe_dialog.cancel\": \"取消\",\n  \"sidebar.category_unsubscribe_dialog.confirm\": \"取消订阅 {{count}} 个源\",\n  \"sidebar.category_unsubscribe_dialog.confirm_one\": \"取消订阅 1 个源\",\n  \"sidebar.category_unsubscribe_dialog.confirm_other\": \"取消订阅 {{count}} 个源\",\n  \"sidebar.category_unsubscribe_dialog.confirm_zero\": \"取消订阅 0 个源\",\n  \"sidebar.category_unsubscribe_dialog.description\": \"此操作将取消订阅分类「{{category}}」中的 {{count}} 个源，且无法撤销。\",\n  \"sidebar.category_unsubscribe_dialog.description_one\": \"此操作将取消订阅分类「{{category}}」中的 1 个源，且无法撤销。\",\n  \"sidebar.category_unsubscribe_dialog.description_other\": \"此操作将取消订阅分类「{{category}}」中的 {{count}} 个源，且无法撤销。\",\n  \"sidebar.category_unsubscribe_dialog.description_zero\": \"分类「{{category}}」中没有可取消订阅的源。\",\n  \"sidebar.category_unsubscribe_dialog.error\": \"取消分类订阅失败\",\n  \"sidebar.category_unsubscribe_dialog.success\": \"已取消订阅分类「{{category}}」中的 {{count}} 个源。\",\n  \"sidebar.category_unsubscribe_dialog.success_one\": \"已取消订阅分类「{{category}}」中的 1 个源。\",\n  \"sidebar.category_unsubscribe_dialog.success_other\": \"已取消订阅分类「{{category}}」中的 {{count}} 个源。\",\n  \"sidebar.category_unsubscribe_dialog.success_zero\": \"分类「{{category}}」中没有源被取消订阅。\",\n  \"sidebar.category_unsubscribe_dialog.title\": \"取消订阅 {{folderName}}\",\n  \"sidebar.feed_actions.claim\": \"认证\",\n  \"sidebar.feed_actions.claim_feed\": \"认证订阅\",\n  \"sidebar.feed_actions.copy_email_address\": \"复制邮件地址\",\n  \"sidebar.feed_actions.copy_feed_badge\": \"复制订阅源徽章\",\n  \"sidebar.feed_actions.copy_feed_id\": \"复制 ID\",\n  \"sidebar.feed_actions.copy_feed_url\": \"复制链接\",\n  \"sidebar.feed_actions.copy_list_id\": \"复制列表 ID\",\n  \"sidebar.feed_actions.copy_list_url\": \"复制列表链接\",\n  \"sidebar.feed_actions.create_list\": \"创建列表\",\n  \"sidebar.feed_actions.edit\": \"编辑\",\n  \"sidebar.feed_actions.edit_feed\": \"编辑订阅\",\n  \"sidebar.feed_actions.edit_inbox\": \"编辑收件箱\",\n  \"sidebar.feed_actions.edit_list\": \"编辑列表\",\n  \"sidebar.feed_actions.feed_owned_by_you\": \"此订阅源归你所有\",\n  \"sidebar.feed_actions.list_owned_by_you\": \"此列表归你所有\",\n  \"sidebar.feed_actions.mark_all_as_read\": \"全部标记为已读\",\n  \"sidebar.feed_actions.navigate_to_feed\": \"跳转至\",\n  \"sidebar.feed_actions.navigate_to_list\": \"跳转至列表\",\n  \"sidebar.feed_actions.new_inbox\": \"新建收件箱\",\n  \"sidebar.feed_actions.open_feed_in_browser\": \"在{{which}}打开订阅源\",\n  \"sidebar.feed_actions.open_list_in_browser\": \"在{{which}}打开列表\",\n  \"sidebar.feed_actions.open_site_in_browser\": \"在{{which}}打开网站\",\n  \"sidebar.feed_actions.reset_feed\": \"重置订阅源\",\n  \"sidebar.feed_actions.reset_feed_error\": \"重置订阅源失败。\",\n  \"sidebar.feed_actions.reset_feed_success\": \"订阅源重置成功。\",\n  \"sidebar.feed_actions.resetting_feed\": \"正在重置订阅源…\",\n  \"sidebar.feed_actions.unfollow\": \"取消订阅\",\n  \"sidebar.feed_actions.unfollow_feed\": \"取消订阅\",\n  \"sidebar.feed_actions.unfollow_feed_many\": \"取消订阅选中的订阅源\",\n  \"sidebar.feed_actions.unfollow_feed_many_confirm\": \"确认要取消订阅选中的订阅源？\",\n  \"sidebar.feed_actions.unfollow_feed_many_warning\": \"警告：这个操作将取消订阅所有选中的订阅源，并且不可恢复。\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_category\": \"移动至分类\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_list\": \"添加订阅源到列表\",\n  \"sidebar.feed_column.context_menu.change_to_other_view\": \"更改为其他视图\",\n  \"sidebar.feed_column.context_menu.create_category\": \"新分类\",\n  \"sidebar.feed_column.context_menu.mark_as_read\": \"标记为已读\",\n  \"sidebar.feed_column.context_menu.new_category_modal.category_name\": \"分类名称\",\n  \"sidebar.feed_column.context_menu.new_category_modal.create\": \"创建\",\n  \"sidebar.feed_column.context_menu.rename_category\": \"重命名分类\",\n  \"sidebar.feed_column.context_menu.rename_category_error\": \"重命名分类失败\",\n  \"sidebar.feed_column.context_menu.rename_category_success\": \"分类重命名成功\",\n  \"sidebar.feed_column.context_menu.title\": \"移动至新分类\",\n  \"sidebar.feed_column.context_menu.ungroup_category\": \"取消分类分组\",\n  \"sidebar.feed_column.context_menu.ungroup_category_confirmation\": \"取消分类「{{folderName}}」的分组？\",\n  \"sidebar.feed_column.context_menu.unsubscribe_category\": \"取消分类内所有订阅\",\n  \"sidebar.select_sort_method\": \"选择排序方法\",\n  \"sidebar.timeline_tabs.customize\": \"自定义视图标签...\",\n  \"sidebar.timeline_tabs.drag_tab\": \"拖动时间线标签\",\n  \"sidebar.timeline_tabs.empty_hidden\": \"将标签拖到这里即可从侧栏隐藏。\",\n  \"sidebar.timeline_tabs.empty_visible\": \"将标签拖到这里即可在侧栏显示。\",\n  \"sidebar.timeline_tabs.hidden\": \"已隐藏\",\n  \"sidebar.timeline_tabs.hide_tab\": \"隐藏此视图\",\n  \"sidebar.timeline_tabs.instructions\": \"在两个区域之间拖动标签，即可重新排序、显示或隐藏它们。\",\n  \"sidebar.timeline_tabs.reset\": \"恢复默认\",\n  \"sidebar.timeline_tabs.visible\": \"已显示\",\n  \"signin.continue_with\": \"使用 {{provider}} 登录\",\n  \"signin.sign_in_to\": \"登录\",\n  \"signin.sign_up_to\": \"注册\",\n  \"subscription_limit_warning\": \"订阅数量已达上限：<br /><b>{{feedCount}}/{{feedLimit}} 个订阅源</b>，<br /><b>{{rsshubCount}}/{{rsshubLimit}} 个 RSSHub</b>。<br />请开启免费试用以解锁更多订阅额度，或清理未使用的订阅源。\",\n  \"sync_indicator.disabled\": \"出于安全原因，已禁用同步\",\n  \"sync_indicator.offline\": \"离线\",\n  \"sync_indicator.synced\": \"已同步至云端\",\n  \"trending.entry\": \"热门条目\",\n  \"trending.entry_no_results\": \"没有找到热门条目\",\n  \"trending.feed\": \"热门订阅源\",\n  \"trending.list\": \"热门列表\",\n  \"trending.user\": \"热门用户\",\n  \"tutorial.scroll_to_exit.description\": \"当在顶部时，向上滚动即可快速返回时间线。\",\n  \"tutorial.scroll_to_exit.dismiss_hint\": \"点击关闭\",\n  \"tutorial.scroll_to_exit.title\": \"向上滚动返回\",\n  \"user_button.account\": \"账号\",\n  \"user_button.achievement\": \"成就\",\n  \"user_button.actions\": \"自动化\",\n  \"user_button.ai\": \"AI\",\n  \"user_button.download_desktop_app\": \"下载客户端\",\n  \"user_button.log_out\": \"登出\",\n  \"user_button.power\": \"Power\",\n  \"user_button.preferences\": \"设置\",\n  \"user_button.profile\": \"个人资料\",\n  \"user_profile.about\": \"关于\",\n  \"user_profile.close\": \"关闭\",\n  \"user_profile.created_lists\": \"创建的列表\",\n  \"user_profile.edit\": \"编辑\",\n  \"user_profile.loading\": \"加载中\",\n  \"user_profile.share\": \"分享\",\n  \"user_profile.subscriptions\": \"订阅\",\n  \"user_profile.toggle_item_style\": \"切换列表样式\",\n  \"words.achievement\": \"成就\",\n  \"words.actions\": \"自动化\",\n  \"words.add\": \"添加\",\n  \"words.all\": \"全部\",\n  \"words.browser\": \"浏览器\",\n  \"words.categories\": \"分类\",\n  \"words.confirm\": \"确认\",\n  \"words.discover\": \"发现\",\n  \"words.email\": \"邮件地址\",\n  \"words.feeds\": \"订阅源\",\n  \"words.import\": \"导入\",\n  \"words.inbox\": \"收件箱\",\n  \"words.items\": \"内容\",\n  \"words.language\": \"语言\",\n  \"words.link\": \"链接\",\n  \"words.lists\": \"列表\",\n  \"words.load_archived_entries\": \"加载已归档条目\",\n  \"words.login\": \"登录\",\n  \"words.mint\": \"Mint\",\n  \"words.newTab\": \"新标签页\",\n  \"words.power\": \"Power\",\n  \"words.rss\": \"RSS\",\n  \"words.rss3\": \"RSS3\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.search\": \"搜索\",\n  \"words.show_more\": \"显示更多\",\n  \"words.starred\": \"收藏\",\n  \"words.title\": \"标题\",\n  \"words.transform\": \"转换\",\n  \"words.trending\": \"趋势\",\n  \"words.undo\": \"撤销\",\n  \"words.unread\": \"未读\",\n  \"words.user\": \"用户\",\n  \"words.view\": \"视图\",\n  \"words.which.all\": \"全部\",\n  \"words.zero_items\": \"没有内容\"\n}\n"
  },
  {
    "path": "locales/app/zh-TW.json",
    "content": "{\n  \"achievement.all_done\": \"大功告成！\",\n  \"achievement.alpha_tester\": \"Alpha 測試者\",\n  \"achievement.alpha_tester_description\": \"您參與了 Folo 的 Alpha 測試。\",\n  \"achievement.description\": \"成為硬派玩家，鑄造 NFT。\",\n  \"achievement.first_claim_feed\": \"RSS 摘要作者\",\n  \"achievement.first_claim_feed_description\": \"在 Folo 上認證 RSS 摘要\",\n  \"achievement.first_create_list\": \"列表建立者\",\n  \"achievement.first_create_list_description\": \"在 Folo 上建立一個列表\",\n  \"achievement.follow_special_feed\": \"特別 RSS 摘要跟隨者\",\n  \"achievement.follow_special_feed_description\": \"在 Folo 上跟隨了一個特別的 RSS 摘要\",\n  \"achievement.list_subscribe_100\": \"100 名列表訂閱者\",\n  \"achievement.list_subscribe_100_description\": \"建立的列表有 100 名訂閱者\",\n  \"achievement.list_subscribe_50\": \"50 名列表訂閱者\",\n  \"achievement.list_subscribe_500\": \"500 名列表訂閱者\",\n  \"achievement.list_subscribe_500_description\": \"建立的列表有 500 名訂閱者\",\n  \"achievement.list_subscribe_50_description\": \"建立的列表有 50 名訂閱者\",\n  \"achievement.nft_coming_soon\": \"目前您無法鑄造 NFT。一旦我們準備好，NFT 將自動轉移到您的帳戶。\",\n  \"achievement.product_hunt_vote\": \"Product Hunt 投票\",\n  \"achievement.product_hunt_vote_description\": \"您在 Product Hunt 上投票給了 Folo\",\n  \"activation.activate\": \"啟用\",\n  \"activation.description\": \"在公開測試階段，您需要邀請碼才能使用此功能。\",\n  \"activation.plan.description\": \"您目前使用的是免費方案。升級方案以解鎖更多功能。\",\n  \"activation.plan.title\": \"升級方案\",\n  \"activation.plan.upgrade\": \"升級\",\n  \"activation.title\": \"邀請碼\",\n  \"ai.summary_not_available\": \"暫無摘要\",\n  \"ai.summary_upgrade_required_description\": \"升級到 Pro 方案，解鎖無限 AI 摘要、翻譯與更多智慧功能。\",\n  \"ai.summary_upgrade_required_title\": \"開啟免費試用以繼續使用 AI 摘要\",\n  \"ai.summary_upgrade_view_plans\": \"查看方案\",\n  \"app.copy_logo_svg\": \"複製 Logo SVG\",\n  \"app.copy_logo_text_svg\": \"複製圖標 SVG 文字\",\n  \"app.toggle_sidebar\": \"切換側邊欄\",\n  \"discover.any_url_or_keyword\": \"任何 URL 或關鍵字\",\n  \"discover.default_option\": \"（預設）\",\n  \"discover.feed_description\": \"根據描述完成目標 RSS 摘要的相關資訊。\",\n  \"discover.feed_maintainers\": \"由 RSSHub 提供，感謝 <maintainers /> 的支持\",\n  \"discover.import.click_to_upload\": \"點擊上傳 OPML 文件\",\n  \"discover.import.conflictItems\": \"衝突項目\",\n  \"discover.import.import_completed_with_issues\": \"匯入完成，但存在一些問題\",\n  \"discover.import.import_successful\": \"匯入成功完成\",\n  \"discover.import.noItems\": \"沒有項目\",\n  \"discover.import.no_feeds_found\": \"未找到符合搜尋條件的 RSS 摘要。\",\n  \"discover.import.opml\": \"OPML 檔案\",\n  \"discover.import.opml_step1\": \"從你的 RSS 閱讀器匯出 OPML 檔案\",\n  \"discover.import.opml_step1_feedly\": \"從 Feedly 匯出 OPML 檔案\",\n  \"discover.import.opml_step1_feedly_step1\": \"打開 <Link />。\",\n  \"discover.import.opml_step1_feedly_step2\": \"點擊 \\\"Download Your Feedly OPML\\\" 按鈕。\",\n  \"discover.import.opml_step1_inoreader\": \"從 Inoreader 匯出 OPML 檔案\",\n  \"discover.import.opml_step1_inoreader_step1\": \"打開 <Link />。\",\n  \"discover.import.opml_step1_inoreader_step2\": \"切換到 \\\"SYSTEM FOLDERS\\\" 標籤。\",\n  \"discover.import.opml_step1_inoreader_step3\": \"點擊 \\\"Newsfeed\\\" 右側的 \\\"OPML\\\" 按鈕。\",\n  \"discover.import.opml_step1_other\": \"從其他閱讀器匯出 OPML 檔案\",\n  \"discover.import.opml_step1_other_step1\": \"OPML 是一種廣泛支援的開放格式，基本上是分享 RSS摘要列表的標準。幾乎所有 RSS 閱讀器都允許匯入與匯出 OPML。如果你不確定如何操作，請參考你的 RSS 閱讀器說明文件或加入我們的社群尋求協助。\",\n  \"discover.import.opml_step2\": \"匯入 OPML 到 Folo\",\n  \"discover.import.parse_opml\": \"解析 OPML\",\n  \"discover.import.parsedErrorItems\": \"解析錯誤項目\",\n  \"discover.import.preview_opml_content\": \"預覽 OPML 內容\",\n  \"discover.import.quota_exceeded\": \"超出額度\",\n  \"discover.import.quota_exceeded_warning\": \"您選擇的 RSS 摘要數量超過了剩餘額度。請取消選擇一些 RSS 摘要以繼續。\",\n  \"discover.import.quota_limit_reached\": \"已達額度限制\",\n  \"discover.import.quota_status\": \"匯入額度：\",\n  \"discover.import.quota_warning\": \"您的額度還剩 {{remaining}} 個 RSS 摘要。\",\n  \"discover.import.remaining_quota\": \"你還可以匯入 {{remaining}} 個 RSS 摘要\",\n  \"discover.import.result\": \"<SuccessfulNum /> 個 RSS 摘要匯入成功。<br /><ConflictNum /> 已訂閱。<br /><ErrorNum /> 匯入失敗。\",\n  \"discover.import.search_feeds_placeholder\": \"搜尋 RSS 摘要...\",\n  \"discover.import.select_all_feeds\": \"全選 RSS 摘要\",\n  \"discover.import.select_all_filtered\": \"全選篩選結果\",\n  \"discover.import.select_feeds_description\": \"檢視並選擇您想要匯入的 RSS 摘要。預設全部選中。\",\n  \"discover.import.select_feeds_to_import\": \"選擇要匯入的 RSS 摘要\",\n  \"discover.import.successfulItems\": \"成功項目\",\n  \"discover.inbox.actions\": \"操作\",\n  \"discover.inbox.description\": \"你可以透過電子信箱和 Webhooks 在收件匣接收資訊。\",\n  \"discover.inbox.email\": \"電子信箱\",\n  \"discover.inbox.handle\": \"名稱\",\n  \"discover.inbox.no_inbox\": \"你目前沒有收件匣，請點擊下方按鈕建立第一個收件匣。\",\n  \"discover.inbox.secret\": \"密鑰\",\n  \"discover.inbox.title\": \"標題\",\n  \"discover.inbox.webhooks_docs\": \"Webhooks 文件\",\n  \"discover.inbox_create\": \"新增收件匣\",\n  \"discover.inbox_create_description\": \"你還沒有收件匣，建立一個收件匣以透過收件匣接收資訊。\",\n  \"discover.inbox_create_error\": \"建立收件匣失敗\",\n  \"discover.inbox_create_success\": \"收件匣建立成功\",\n  \"discover.inbox_destroy\": \"刪除\",\n  \"discover.inbox_destroy_confirm\": \"確認刪除收件匣？\",\n  \"discover.inbox_destroy_error\": \"刪除收件匣失敗\",\n  \"discover.inbox_destroy_success\": \"收件匣刪除成功\",\n  \"discover.inbox_destroy_warning\": \"警告：一旦刪除，電子信箱將不再可用，所有項目將會永久刪除且無法恢復。\",\n  \"discover.inbox_handle\": \"名稱\",\n  \"discover.inbox_title\": \"標題\",\n  \"discover.inbox_update\": \"更新\",\n  \"discover.inbox_update_error\": \"更改收件匣失敗\",\n  \"discover.inbox_update_success\": \"收件匣更新成功\",\n  \"discover.popular\": \"熱門\",\n  \"discover.preview\": \"預覽\",\n  \"discover.rss_hub_route\": \"RSSHub 路由\",\n  \"discover.rss_url\": \"RSS URL\",\n  \"discover.search.results_one\": \"找到 {{count}} 個 RSS 摘要\",\n  \"discover.search.results_other\": \"找到 {{count}} 個 RSS 摘要\",\n  \"discover.search.results_zero\": \"未搜尋到任何 RSS 摘要\",\n  \"discover.select_placeholder\": \"選擇\",\n  \"discover.target.feeds\": \"RSS 摘要\",\n  \"discover.target.label\": \"搜尋\",\n  \"discover.target.lists\": \"列表\",\n  \"discover.tips.auto_detect\": \"自動辨識輸入類型\",\n  \"discover.tips.search_keyword\": \"支援關鍵字、URL、RSS 訂閱源或 RSSHub 路由\",\n  \"discover.tools.description\": \"更多工具\",\n  \"discover.tools.import\": \"匯入 OPML\",\n  \"discover.tools.inbox\": \"收件匣\",\n  \"discover.tools.transform\": \"HTML 轉 RSS\",\n  \"discover.tools.user\": \"追蹤使用者\",\n  \"entry.click_to_return\": \"向上捲動或點擊此處返回\",\n  \"entry.exit_detail\": \"返回時間軸\",\n  \"entry.scroll_up_to_exit\": \"向上捲動返回時間軸\",\n  \"entry_actions.copied_notify\": \"{{which}}已複製到剪貼簿\",\n  \"entry_actions.copy_link\": \"複製連結\",\n  \"entry_actions.copy_title\": \"複製標題\",\n  \"entry_actions.delete\": \"刪除\",\n  \"entry_actions.deleted\": \"已刪除。\",\n  \"entry_actions.export_as_pdf\": \"匯出為 PDF\",\n  \"entry_actions.failed_to_delete\": \"刪除失敗。\",\n  \"entry_actions.failed_to_login_to_qbittorrent\": \"無法登入 qBittorrent\",\n  \"entry_actions.failed_to_save_to_cubox\": \"無法儲存到 Cubox。\",\n  \"entry_actions.failed_to_save_to_eagle\": \"無法儲存到 Eagle。\",\n  \"entry_actions.failed_to_save_to_instapaper\": \"無法儲存到 Instapaper。\",\n  \"entry_actions.failed_to_save_to_obsidian\": \"無法儲存到 Obsidian。\",\n  \"entry_actions.failed_to_save_to_outline\": \"無法儲存到 Outline。\",\n  \"entry_actions.failed_to_save_to_qbittorrent\": \"無法使用 qBittorrent 下載\",\n  \"entry_actions.failed_to_save_to_readeck\": \"無法儲存到 Readeck。\",\n  \"entry_actions.failed_to_save_to_readwise\": \"無法儲存到 Readwise。\",\n  \"entry_actions.failed_to_save_to_zotero\": \"無法儲存到 Zotero。\",\n  \"entry_actions.image_gallery\": \"影像圖庫\",\n  \"entry_actions.image_gallery_description\": \"當文章中存在多張大圖時，可以在 Gallery Modal 中瀏覽文章中的全部圖片\",\n  \"entry_actions.mark_above_as_read\": \"將以上標記為已讀\",\n  \"entry_actions.mark_as_read\": \"標記為已讀 / 未讀\",\n  \"entry_actions.mark_as_unread\": \"標記為未讀\",\n  \"entry_actions.mark_below_as_read\": \"將以下標記為已讀\",\n  \"entry_actions.no_bittorrent_urls_found\": \"此條目中未找到 BitTorrent 連結。\",\n  \"entry_actions.open_in_browser\": \"在{{which}}中打開\",\n  \"entry_actions.recent_reader\": \"最近閲讀者：\",\n  \"entry_actions.save_media_to_eagle\": \"儲存媒體至 Eagle\",\n  \"entry_actions.save_to_cubox\": \"儲存到 Cubox\",\n  \"entry_actions.save_to_instapaper\": \"儲存到 Instapaper\",\n  \"entry_actions.save_to_obsidian\": \"儲存到 Obsidian\",\n  \"entry_actions.save_to_outline\": \"儲存到 Outline\",\n  \"entry_actions.save_to_qbittorrent\": \"使用 qBittorrent 下載\",\n  \"entry_actions.save_to_readeck\": \"儲存到 Readeck\",\n  \"entry_actions.save_to_readwise\": \"儲存到 Readwise\",\n  \"entry_actions.save_to_zotero\": \"儲存到 Zotero\",\n  \"entry_actions.saved_to_cubox\": \"已儲存到 Cubox。\",\n  \"entry_actions.saved_to_eagle\": \"已儲存到 Eagle。\",\n  \"entry_actions.saved_to_instapaper\": \"已儲存到 Instapaper。\",\n  \"entry_actions.saved_to_obsidian\": \"已儲存到 Obsidian。\",\n  \"entry_actions.saved_to_outline\": \"已儲存到 Outline。\",\n  \"entry_actions.saved_to_qbittorrent\": \"種子已加入 qBittorrent。\",\n  \"entry_actions.saved_to_readeck\": \"已儲存到 Readeck。\",\n  \"entry_actions.saved_to_readwise\": \"已儲存到 Readwise。\",\n  \"entry_actions.saved_to_zotero\": \"已儲存到 Zotero。\",\n  \"entry_actions.share\": \"分享\",\n  \"entry_actions.star\": \"收藏\",\n  \"entry_actions.starred\": \"已收藏\",\n  \"entry_actions.toggle_ai_summary\": \"切換 AI 總結\",\n  \"entry_actions.toggle_ai_translation\": \"切換 AI 翻譯\",\n  \"entry_actions.unstar\": \"取消收藏\",\n  \"entry_actions.unstarred\": \"已取消收藏\",\n  \"entry_actions.view_source_content\": \"查看原始內容\",\n  \"entry_actions.view_source_content_description\": \"嘗試以源網站的風格閱讀此文章的內容\",\n  \"entry_actions.warn_info_ai_chat_pinned_tip\": \"此功能目前為灰度測試，僅少數用戶可用。\",\n  \"entry_actions.warn_info_for_desktop\": \"此功能只在桌面端可用\",\n  \"entry_column.filtered_content_tip\": \"您已隱藏部分過濾的內容。\",\n  \"entry_column.filtered_content_tip_2\": \"除了上方顯示的條目外，還有一些被過濾的內容。\",\n  \"entry_column.refreshing\": \"正在更新\",\n  \"entry_content.ai_summary\": \"AI 總結\",\n  \"entry_content.fetching_content\": \"正在獲取原始內容並處理\",\n  \"entry_content.fetching_content_failed\": \"獲取原始內容失敗\",\n  \"entry_content.header.play_tts\": \"播放文字轉語音\",\n  \"entry_content.header.play_tts_description\": \"在設定中選擇一個 TTS 語音，啟動 TTS 轉換為有聲內容\",\n  \"entry_content.header.readability\": \"可讀模式\",\n  \"entry_content.header.readability_description\": \"利用 Readability 獲取原始網站內容並解析的能力或許可提升內容可讀性。建議於 RSS 內容不可用或不完整時使用。\",\n  \"entry_content.no_content\": \"無內容\",\n  \"entry_content.readability_notice\": \"此內容由'可讀模式'提供。如果您發現排版異常，請前往網站查看原始內容。\",\n  \"entry_content.render_error\": \"渲染錯誤：\",\n  \"entry_content.report_issue\": \"報告問題\",\n  \"entry_content.selection_toolbar.ask_ai\": \"詢問 AI\",\n  \"entry_content.selection_toolbar.copied\": \"已複製\",\n  \"entry_content.selection_toolbar.copy\": \"複製\",\n  \"entry_content.selection_toolbar.copy_image\": \"複製圖片\",\n  \"entry_content.selection_toolbar.copying\": \"複製中...\",\n  \"entry_content.selection_toolbar.generating\": \"生成中...\",\n  \"entry_content.selection_toolbar.poster_copied\": \"海報已複製到剪貼簿\",\n  \"entry_content.selection_toolbar.poster_copy_failed\": \"複製海報失敗\",\n  \"entry_content.selection_toolbar.share\": \"分享\",\n  \"entry_content.selection_toolbar.share_poster\": \"分享海報\",\n  \"entry_content.web_app_notice\": \"Web 應用程式不支援此類型內容。您可以下載桌面應用程式。\",\n  \"entry_list.zero_unread\": \"全部已讀\",\n  \"entry_list_header.ai_timeline\": \"AI 時間線\",\n  \"entry_list_header.ai_timeline_loading\": \"正在用 AI 重新排序時間線…\",\n  \"entry_list_header.ai_timeline_prompt_required\": \"請先設定 AI 時間線排序提示\",\n  \"entry_list_header.grid\": \"格線佈局\",\n  \"entry_list_header.image_only\": \"僅圖片\",\n  \"entry_list_header.items\": \"內容\",\n  \"entry_list_header.masonry\": \"瀑布式佈局\",\n  \"entry_list_header.masonry_column\": \"佈局列數\",\n  \"entry_list_header.preview_mode\": \"預覽模式\",\n  \"entry_list_header.refetch\": \"重新獲取\",\n  \"entry_list_header.refresh\": \"重新整理\",\n  \"entry_list_header.show_all\": \"顯示全部\",\n  \"entry_list_header.show_unread_only\": \"僅顯示未讀\",\n  \"entry_list_header.timeline_summary\": \"摘要時間線\",\n  \"entry_list_header.unread\": \"未讀\",\n  \"feed.actions.follow\": \"跟隨\",\n  \"feed.actions.followed\": \"已跟隨\",\n  \"feed.followsAndFeeds\": \"在 {{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{feedsCount}} 個 {{feedsNoun}}\",\n  \"feed.read_one\": \"閱讀\",\n  \"feed.read_other\": \"閱讀\",\n  \"feed_category.onboarding_feed\": \"此分類包含入門引導訂閱源\",\n  \"feed_claim_modal.choose_verification_method\": \"有三種驗證方式，您可以選擇其中一種進行驗證。\",\n  \"feed_claim_modal.claim_button\": \"認領\",\n  \"feed_claim_modal.content_instructions\": \"複製以下內容，發佈到需要驗證的 RSS 摘要。\",\n  \"feed_claim_modal.description_current\": \"當前描述：\",\n  \"feed_claim_modal.description_instructions\": \"複製以下內容，新增到需要驗證的 RSS 摘要 <code /> 欄位內。\",\n  \"feed_claim_modal.failed_to_load\": \"認領資料讀取失敗\",\n  \"feed_claim_modal.rss_format_choice\": \"RSS 產生工具通常有兩種格式可供選擇。請根據需要複製以下內容。\",\n  \"feed_claim_modal.rss_instructions\": \"複製以下內容並將其黏貼到您的 RSS 產生工具。\",\n  \"feed_claim_modal.rss_json_format\": \"JSON 格式\",\n  \"feed_claim_modal.rss_xml_format\": \"XML 格式\",\n  \"feed_claim_modal.rsshub_notice\": \"此 RSS 摘要由 RSSHub 提供，快取時間為 1 小時，最久可能會有將近 1 小時的延遲。\",\n  \"feed_claim_modal.tab_content\": \"內容\",\n  \"feed_claim_modal.tab_description\": \"描述\",\n  \"feed_claim_modal.tab_rss\": \"RSS 標籤\",\n  \"feed_claim_modal.title\": \"認領 RSS 摘要\",\n  \"feed_claim_modal.verify_ownership\": \"要證明你是此 RSS 摘要的作者，您需要完成驗證。\",\n  \"feed_form.add_feed\": \"新增 RSS 摘要\",\n  \"feed_form.add_follow\": \"新增跟隨\",\n  \"feed_form.category\": \"分類\",\n  \"feed_form.category_description\": \"預設情形下，您的跟隨將依網站網域分組。\",\n  \"feed_form.error_fetching_feed\": \"獲取 RSS 摘要出錯。\",\n  \"feed_form.fee\": \"跟隨費用\",\n  \"feed_form.fee_description\": \"若需跟隨此列表，需支付費用\",\n  \"feed_form.feed_not_found\": \"未找到 RSS 摘要\",\n  \"feed_form.feedback\": \"回饋\",\n  \"feed_form.fill_default\": \"填充\",\n  \"feed_form.follow\": \"跟隨\",\n  \"feed_form.follow_with_fee\": \"使用 {{fee}} Power 跟隨\",\n  \"feed_form.followed\": \"🎉 跟隨成功。\",\n  \"feed_form.hide_from_timeline\": \"從時間軸隱藏\",\n  \"feed_form.hide_from_timeline_description\": \"開啟後，此訂閱將不再顯示在主時間軸中\",\n  \"feed_form.private_follow\": \"私人跟隨\",\n  \"feed_form.private_follow_description\": \"啟用後，此跟隨將不再顯示於個人資料頁面上。\",\n  \"feed_form.retry\": \"重試\",\n  \"feed_form.title\": \"標題\",\n  \"feed_form.title_description\": \"此 RSS 摘要的自訂標題。留空則使用預設標題。\",\n  \"feed_form.unfollow\": \"取消跟隨\",\n  \"feed_form.update\": \"更新\",\n  \"feed_form.update_follow\": \"更新跟隨\",\n  \"feed_form.updated\": \"🎉 更新成功\",\n  \"feed_form.view\": \"視圖\",\n  \"feed_item.claimed_by_owner\": \"RSS 摘要作者\",\n  \"feed_item.claimed_by_unknown\": \"未知作者\",\n  \"feed_item.claimed_by_you\": \"RSS 摘要由你認領\",\n  \"feed_item.claimed_feed\": \"已認領 RSS 摘要\",\n  \"feed_item.claimed_list\": \"已認領列表\",\n  \"feed_item.error_since\": \"RSS 摘要失效：\",\n  \"feed_item.not_publicly_visible\": \"未公開顯示於您的個人頁面\",\n  \"feed_item.onboarding_feed\": \"這是一個入門引導訂閱源\",\n  \"login.agree_to\": \"繼續即表示您同意我們的\",\n  \"login.back\": \"返回\",\n  \"login.confirm_password.label\": \"確認密碼\",\n  \"login.continueWith\": \"透過 {{provider}} 繼續\",\n  \"login.email\": \"電子信箱\",\n  \"login.enter_token\": \"請輸入授權令牌以繼續\",\n  \"login.forget_password.note\": \"忘記密碼了嗎？\",\n  \"login.have_account\": \"已有帳號？<strong>登入</strong>\",\n  \"login.lastUsed\": \"最近使用\",\n  \"login.magic_link_sent\": \"魔法連結已發送！請檢查您的信箱。\",\n  \"login.no_account\": \"沒有帳號？<strong>註冊</strong>\",\n  \"login.or\": \"或\",\n  \"login.password\": \"密碼\",\n  \"login.password_optional\": \"可選\",\n  \"login.privacy\": \"隱私政策\",\n  \"login.send_magic_link\": \"發送魔法連結\",\n  \"login.signUp\": \"使用電子信箱註冊\",\n  \"login.submit\": \"送出\",\n  \"login.terms\": \"服務條款\",\n  \"login.title\": \"歡迎使用 Folo\",\n  \"login.with_email.title\": \"使用電子信箱登入\",\n  \"mark_all_read_button.auto_confirm_info\": \"將在 {{countdown}} 秒後自動確認。\",\n  \"mark_all_read_button.confirm\": \"確認\",\n  \"mark_all_read_button.confirm_mark_all\": \"將 <which /> 標記為已讀？\",\n  \"mark_all_read_button.confirm_mark_all_info\": \"確認將全部標記為已讀？\",\n  \"mark_all_read_button.done\": \"已標記\",\n  \"mark_all_read_button.mark_all_as_read\": \"全部標記為已讀\",\n  \"mark_all_read_button.mark_as_read\": \"標記 <which /> 為已讀\",\n  \"mark_all_read_button.undo\": \"復原\",\n  \"new_user_dialog.actions.close\": \"關閉\",\n  \"new_user_dialog.actions.finish\": \"立即探索\",\n  \"new_user_dialog.ai.description\": \"回答幾個提示，Folo 的 AI 會幫你挑選訂閱、清單與閱讀計畫。\",\n  \"new_user_dialog.ai.highlight_1\": \"用自然語言描述你的需求。\",\n  \"new_user_dialog.ai.highlight_2\": \"收到附上推薦理由的精選來源。\",\n  \"new_user_dialog.ai.highlight_3\": \"透過對話不斷微調摘要風格與頻率。\",\n  \"new_user_dialog.ai.primary\": \"啟動 AI 引導\",\n  \"new_user_dialog.ai.title\": \"交給 AI Copilot 建立你的起手式\",\n  \"new_user_dialog.import.description\": \"匯入任何閱讀器輸出的 OPML，在訂閱前逐條預覽。\",\n  \"new_user_dialog.import.highlight_1\": \"支援 Feedly、Inoreader 等所有 RSS 閱讀器的 OPML 檔。\",\n  \"new_user_dialog.import.highlight_2\": \"匯入前先預覽並挑選想保留的訂閱。\",\n  \"new_user_dialog.import.highlight_3\": \"把新訂閱立即整理進資料夾與清單。\",\n  \"new_user_dialog.import.primary\": \"匯入 OPML\",\n  \"new_user_dialog.import.title\": \"帶上你原本追蹤的所有內容\",\n  \"new_user_dialog.overview.description\": \"Folo 是一款 AI RSS 閱讀器，把資訊流、電子報、Podcast 與社群更新集中在同一個沉浸式閱讀空間。\",\n  \"new_user_dialog.overview.highlight_1\": \"釘選常用訂閱、清單與主題，立即切換情境。\",\n  \"new_user_dialog.overview.highlight_2\": \"雙欄版面讓你邊閱讀內容邊瀏覽最新更新。\",\n  \"new_user_dialog.overview.highlight_3\": \"鍵盤快捷鍵與篩選讓專注力不被打斷。\",\n  \"new_user_dialog.overview.primary\": \"前往發現頁\",\n  \"new_user_dialog.overview.title\": \"在同一個舒適空間掌握所有資訊\",\n  \"new_user_dialog.replay_video\": \"重播影片\",\n  \"new_user_dialog.step_label.ai\": \"步驟二 · AI 助手\",\n  \"new_user_dialog.step_label.import\": \"步驟三 · 匯入\",\n  \"new_user_dialog.step_label.overview\": \"步驟一 · 概覽\",\n  \"new_user_dialog.title\": \"歡迎使用 Folo\",\n  \"new_user_guide.actions.back\": \"上一步\",\n  \"new_user_guide.actions.finish\": \"完成\",\n  \"new_user_guide.actions.import_opml\": \"匯入 OPML\",\n  \"new_user_guide.actions.next\": \"下一步\",\n  \"new_user_guide.ai_chat.intro\": \"歡迎來到 Folo，這款 AI 閱讀器會為您讀遍整個網路。\\n\\n跟我分享一些關於您的事吧。\",\n  \"new_user_guide.ai_chat.reroll\": \"換一批\",\n  \"new_user_guide.ai_chat.suggestions.ai_regulation_learner\": \"我在學習歐盟的 AI 監管最新動向\",\n  \"new_user_guide.ai_chat.suggestions.climate_newsletter_writer\": \"我撰寫氣候科技電子報，需要每日素材\",\n  \"new_user_guide.ai_chat.suggestions.cybersecurity_tracker\": \"我關注資安與開源漏洞新聞\",\n  \"new_user_guide.ai_chat.suggestions.drug_delivery_student\": \"我學習藥物傳遞，想追蹤最新 FDA 核准\",\n  \"new_user_guide.ai_chat.suggestions.fashion_designer\": \"我是一名時裝設計師\",\n  \"new_user_guide.ai_chat.suggestions.investor_market_news\": \"我是投資人，需要掌握股市與公司新聞\",\n  \"new_user_guide.ai_chat.suggestions.japan_trip_planner\": \"我計畫去日本旅行，想要在地建議\",\n  \"new_user_guide.ai_chat.suggestions.nano_engineering_researcher\": \"我研究奈米工程\",\n  \"new_user_guide.ai_chat.suggestions.nasa_fan\": \"我是 NASA 的粉絲\",\n  \"new_user_guide.ai_chat.suggestions.personal_finance_builder\": \"我正在打造個人理財追蹤器，需要最佳實務\",\n  \"new_user_guide.ai_chat.suggestions.plant_based_cooking\": \"我在探索植物性料理的新趨勢\",\n  \"new_user_guide.ai_chat.suggestions.podcast_summary_seeker\": \"我需要經濟類長篇 Podcast 的摘要\",\n  \"new_user_guide.ai_chat.suggestions.robotics_coach\": \"我指導高中機器人隊，尋找專案靈感\",\n  \"new_user_guide.ai_chat.suggestions.saas_marketing_manager\": \"我是 SaaS 產品的行銷經理，準備發佈\",\n  \"new_user_guide.ai_chat.you_can_say\": \"您可以這樣說...\",\n  \"new_user_guide.confirm_skip.message\": \"確定要跳過引導設定嗎？\",\n  \"new_user_guide.confirm_skip.title\": \"要跳過新手引導嗎？\",\n  \"new_user_guide.intro.description\": \"這份指南將幫助您快速上手這款應用程式。\",\n  \"new_user_guide.intro.title\": \"和 AI 一起沉浸式閱讀\",\n  \"new_user_guide.selection.empty_description\": \"在 AI 對話中描述您的需求，我們會推薦合適的來源。\",\n  \"new_user_guide.selection.empty_title\": \"尚未選擇任何來源\",\n  \"notify.store.default\": \"商店\",\n  \"notify.store.mas\": \"App Store\",\n  \"notify.store.mss\": \"Microsoft Store\",\n  \"notify.unfollow_feed\": \"已取消跟隨 <FeedItem />\",\n  \"notify.unfollow_feed_many\": \"已取消跟隨正在選擇的 RSS 摘要。\",\n  \"notify.update_info\": \"{{app_name}} 已準備好更新！\",\n  \"notify.update_info_1\": \"點擊重新啟動\",\n  \"notify.update_info_2\": \"點擊重新整理頁面\",\n  \"notify.update_info_3\": \"點選重新整理頁面\",\n  \"notify.update_info_store\": \"點擊前往 {{store}}\",\n  \"player.back_10s\": \"後退 10 秒\",\n  \"player.close\": \"關閉\",\n  \"player.download\": \"下載\",\n  \"player.exit_full_screen\": \"退出全螢幕\",\n  \"player.forward_10s\": \"前進 10 秒\",\n  \"player.full_screen\": \"全螢幕\",\n  \"player.mute\": \"靜音\",\n  \"player.open_entry\": \"開啟條目\",\n  \"player.pause\": \"暫停\",\n  \"player.play\": \"播放\",\n  \"player.playback_rate\": \"播放速率\",\n  \"player.unmute\": \"取消靜音\",\n  \"player.volume\": \"音量\",\n  \"quick_add.placeholder\": \"在此輸入 RSS 摘要網址已快速跟隨...\",\n  \"quick_add.title\": \"快速跟隨\",\n  \"register.confirm_password\": \"確認密碼\",\n  \"register.email\": \"電子信箱\",\n  \"register.login\": \"登入\",\n  \"register.magic_link_sent\": \"魔法連結已發送！請檢查您的信箱。\",\n  \"register.password\": \"密碼\",\n  \"register.password_optional\": \"可選\",\n  \"register.referral.days\": \"使用此推薦碼註冊可獲得 {{days}} 天的專業版預覽\",\n  \"register.referral.description\": \"使用推薦碼註冊可獲得額外幾日的專業版預覽。\",\n  \"register.referral.invalid\": \"無效的推薦碼\",\n  \"register.referral.label\": \"推薦碼\",\n  \"register.send_magic_link\": \"發送魔法連結\",\n  \"register.submit\": \"創建帳號\",\n  \"resize.tooltip.double_click_to_collapse\": \"<b>雙擊</b>重置為預設大小\",\n  \"resize.tooltip.drag_to_resize\": \"<b>拖動</b>以調整大小\",\n  \"search.empty.no_results\": \"搜尋結果為空\",\n  \"search.group.entries\": \"條目\",\n  \"search.group.feeds\": \"RSS 摘要\",\n  \"search.options.all\": \"全部\",\n  \"search.options.entry\": \"條目\",\n  \"search.options.feed\": \"RSS 摘要\",\n  \"search.options.search_type\": \"搜尋類型\",\n  \"search.placeholder\": \"搜尋...\",\n  \"search.result_count_local_mode\": \"（本地模式）\",\n  \"search.tooltip.local_search\": \"此搜尋涵蓋本地可用資料。嘗試重新獲取以獲得更多結果。\",\n  \"share.actions\": \"操作\",\n  \"share.copy_failed\": \"複製連結失敗\",\n  \"share.copy_link\": \"複製連結\",\n  \"share.default_description\": \"查看這個精彩內容\",\n  \"share.default_title\": \"內容分享\",\n  \"share.discover_more\": \"在 Folo 上探索更多精彩內容\",\n  \"share.link_copied\": \"連結已複製到剪貼簿\",\n  \"share.social_media\": \"社群媒體\",\n  \"share.system_share\": \"系統分享\",\n  \"share.title\": \"分享內容\",\n  \"shortcuts.guide.title\": \"快捷鍵指南\",\n  \"sidebar.add_more_feeds\": \"新增 RSS 摘要\",\n  \"sidebar.already_on_discover_page\": \"你已位於探索頁面！請將想要訂閱的內容加入右側面板。\",\n  \"sidebar.category_remove_dialog.cancel\": \"取消\",\n  \"sidebar.category_remove_dialog.continue\": \"取消分組\",\n  \"sidebar.category_remove_dialog.description\": \"此操作會取消分類分組，並將其中的訂閱恢復為預設分組。\",\n  \"sidebar.category_remove_dialog.error\": \"取消分類分組失敗\",\n  \"sidebar.category_remove_dialog.success\": \"分類取消分組成功\",\n  \"sidebar.category_remove_dialog.title\": \"取消分類分組\",\n  \"sidebar.category_unsubscribe_dialog.cancel\": \"取消\",\n  \"sidebar.category_unsubscribe_dialog.confirm\": \"取消訂閱 {{count}} 個來源\",\n  \"sidebar.category_unsubscribe_dialog.confirm_one\": \"取消訂閱 1 個來源\",\n  \"sidebar.category_unsubscribe_dialog.confirm_other\": \"取消訂閱 {{count}} 個來源\",\n  \"sidebar.category_unsubscribe_dialog.confirm_zero\": \"取消訂閱 0 個來源\",\n  \"sidebar.category_unsubscribe_dialog.description\": \"此操作將取消訂閱分類「{{category}}」中的 {{count}} 個來源，且無法復原。\",\n  \"sidebar.category_unsubscribe_dialog.description_one\": \"此操作將取消訂閱分類「{{category}}」中的 1 個來源，且無法復原。\",\n  \"sidebar.category_unsubscribe_dialog.description_other\": \"此操作將取消訂閱分類「{{category}}」中的 {{count}} 個來源，且無法復原。\",\n  \"sidebar.category_unsubscribe_dialog.description_zero\": \"分類「{{category}}」中沒有可取消訂閱的來源。\",\n  \"sidebar.category_unsubscribe_dialog.error\": \"取消分類訂閱失敗\",\n  \"sidebar.category_unsubscribe_dialog.success\": \"已取消訂閱分類「{{category}}」中的 {{count}} 個來源。\",\n  \"sidebar.category_unsubscribe_dialog.success_one\": \"已取消訂閱分類「{{category}}」中的 1 個來源。\",\n  \"sidebar.category_unsubscribe_dialog.success_other\": \"已取消訂閱分類「{{category}}」中的 {{count}} 個來源。\",\n  \"sidebar.category_unsubscribe_dialog.success_zero\": \"分類「{{category}}」中沒有來源被取消訂閱。\",\n  \"sidebar.category_unsubscribe_dialog.title\": \"取消訂閱 {{folderName}}\",\n  \"sidebar.feed_actions.claim\": \"認領\",\n  \"sidebar.feed_actions.claim_feed\": \"認領RSS 摘要\",\n  \"sidebar.feed_actions.copy_email_address\": \"複製電子信箱\",\n  \"sidebar.feed_actions.copy_feed_badge\": \"複製 RSS 摘要徽章\",\n  \"sidebar.feed_actions.copy_feed_id\": \"複製 RSS 摘要 ID\",\n  \"sidebar.feed_actions.copy_feed_url\": \"複製 RSS 摘要 URL\",\n  \"sidebar.feed_actions.copy_list_id\": \"複製列表 ID\",\n  \"sidebar.feed_actions.copy_list_url\": \"複製列表連結\",\n  \"sidebar.feed_actions.create_list\": \"新增列表\",\n  \"sidebar.feed_actions.edit\": \"編輯\",\n  \"sidebar.feed_actions.edit_feed\": \"編輯 RSS 摘要\",\n  \"sidebar.feed_actions.edit_inbox\": \"編輯收件匣\",\n  \"sidebar.feed_actions.edit_list\": \"編輯列表\",\n  \"sidebar.feed_actions.feed_owned_by_you\": \"此 RSS 摘要由你所擁有\",\n  \"sidebar.feed_actions.list_owned_by_you\": \"此列表由你所擁有\",\n  \"sidebar.feed_actions.mark_all_as_read\": \"全部標記為已讀\",\n  \"sidebar.feed_actions.navigate_to_feed\": \"導航到 RSS 摘要\",\n  \"sidebar.feed_actions.navigate_to_list\": \"導航到列表\",\n  \"sidebar.feed_actions.new_inbox\": \"新建收件匣\",\n  \"sidebar.feed_actions.open_feed_in_browser\": \"在{{which}}開啟 RSS 摘要\",\n  \"sidebar.feed_actions.open_list_in_browser\": \"在{{which}}開啟列表\",\n  \"sidebar.feed_actions.open_site_in_browser\": \"在{{which}}開啟網站\",\n  \"sidebar.feed_actions.reset_feed\": \"重置 RSS 摘要\",\n  \"sidebar.feed_actions.reset_feed_error\": \"重置 RSS 摘要失敗。\",\n  \"sidebar.feed_actions.reset_feed_success\": \"RSS 摘要重置成功。\",\n  \"sidebar.feed_actions.resetting_feed\": \"正在重置 RSS 摘要…\",\n  \"sidebar.feed_actions.unfollow\": \"取消跟隨\",\n  \"sidebar.feed_actions.unfollow_feed\": \"取消跟隨 RSS 摘要\",\n  \"sidebar.feed_actions.unfollow_feed_many\": \"取消跟隨所有選取的 RSS 摘要\",\n  \"sidebar.feed_actions.unfollow_feed_many_confirm\": \"確認取消跟隨所有選取的 RSS 摘要嗎？\",\n  \"sidebar.feed_actions.unfollow_feed_many_warning\": \"警告：此操作將取消跟隨所有選取的 RSS 摘要，且無法復原。\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_category\": \"移動到類別\",\n  \"sidebar.feed_column.context_menu.add_feeds_to_list\": \"新增 RSS 摘要到列表\",\n  \"sidebar.feed_column.context_menu.change_to_other_view\": \"切換到其他視圖\",\n  \"sidebar.feed_column.context_menu.create_category\": \"新增分類\",\n  \"sidebar.feed_column.context_menu.mark_as_read\": \"標記為已讀\",\n  \"sidebar.feed_column.context_menu.new_category_modal.category_name\": \"分類名稱\",\n  \"sidebar.feed_column.context_menu.new_category_modal.create\": \"創建\",\n  \"sidebar.feed_column.context_menu.rename_category\": \"重新命名分類\",\n  \"sidebar.feed_column.context_menu.rename_category_error\": \"重命名分類失敗\",\n  \"sidebar.feed_column.context_menu.rename_category_success\": \"分類重新命名成功\",\n  \"sidebar.feed_column.context_menu.title\": \"移動到新分類\",\n  \"sidebar.feed_column.context_menu.ungroup_category\": \"取消分類分組\",\n  \"sidebar.feed_column.context_menu.ungroup_category_confirmation\": \"取消分類「{{folderName}}」的分組？\",\n  \"sidebar.feed_column.context_menu.unsubscribe_category\": \"取消分類內所有訂閱\",\n  \"sidebar.select_sort_method\": \"選擇排序方式\",\n  \"sidebar.timeline_tabs.customize\": \"自訂檢視分頁...\",\n  \"sidebar.timeline_tabs.drag_tab\": \"拖曳時間軸標籤\",\n  \"sidebar.timeline_tabs.empty_hidden\": \"將標籤拖曳到這裡即可從側欄隱藏。\",\n  \"sidebar.timeline_tabs.empty_visible\": \"將標籤拖曳到這裡即可在側欄顯示。\",\n  \"sidebar.timeline_tabs.hidden\": \"已隱藏\",\n  \"sidebar.timeline_tabs.hide_tab\": \"隱藏此檢視\",\n  \"sidebar.timeline_tabs.instructions\": \"在兩個區域之間拖曳標籤，即可重新排序、顯示或隱藏它們。\",\n  \"sidebar.timeline_tabs.reset\": \"恢復預設\",\n  \"sidebar.timeline_tabs.visible\": \"已顯示\",\n  \"signin.continue_with\": \"透過 {{provider}} 登入\",\n  \"signin.sign_in_to\": \"登入\",\n  \"signin.sign_up_to\": \"註冊\",\n  \"subscription_limit_warning\": \"訂閱數量已達上限：<br /><b>{{feedCount}}/{{feedLimit}} 個訂閱源</b>，<br /><b>{{rsshubCount}}/{{rsshubLimit}} 個 RSSHub</b>。<br />請開啟免費試用以解鎖更多配額，或清理未使用的訂閱源。\",\n  \"sync_indicator.disabled\": \"由於安全原因，同步功能已停用。\",\n  \"sync_indicator.offline\": \"離線\",\n  \"sync_indicator.synced\": \"已與伺服器同步\",\n  \"trending.entry\": \"熱門條目\",\n  \"trending.entry_no_results\": \"沒有找到熱門條目\",\n  \"trending.feed\": \"熱門 RSS 摘要\",\n  \"trending.list\": \"熱門列表\",\n  \"trending.user\": \"熱門使用者\",\n  \"tutorial.scroll_to_exit.description\": \"在頂部向上捲動可快速返回時間軸。\",\n  \"tutorial.scroll_to_exit.dismiss_hint\": \"點擊關閉\",\n  \"tutorial.scroll_to_exit.title\": \"向上捲動返回\",\n  \"user_button.account\": \"帳號\",\n  \"user_button.achievement\": \"成就\",\n  \"user_button.actions\": \"自動化操作\",\n  \"user_button.ai\": \"AI\",\n  \"user_button.download_desktop_app\": \"下載桌面應用程式\",\n  \"user_button.log_out\": \"登出\",\n  \"user_button.power\": \"Power\",\n  \"user_button.preferences\": \"偏好設定\",\n  \"user_button.profile\": \"個人檔案\",\n  \"user_profile.about\": \"關於\",\n  \"user_profile.close\": \"關閉\",\n  \"user_profile.created_lists\": \"已創建列表\",\n  \"user_profile.edit\": \"編輯\",\n  \"user_profile.loading\": \"載入中\",\n  \"user_profile.share\": \"分享\",\n  \"user_profile.subscriptions\": \"訂閱\",\n  \"user_profile.toggle_item_style\": \"切換項目樣式\",\n  \"words.achievement\": \"成就\",\n  \"words.actions\": \"自動化操作\",\n  \"words.add\": \"新增\",\n  \"words.all\": \"全部\",\n  \"words.browser\": \"瀏覽器\",\n  \"words.categories\": \"分類\",\n  \"words.confirm\": \"確認\",\n  \"words.discover\": \"發現\",\n  \"words.email\": \"電子信箱\",\n  \"words.feeds\": \"RSS 摘要\",\n  \"words.import\": \"匯入\",\n  \"words.inbox\": \"收件匣\",\n  \"words.items\": \"內容\",\n  \"words.language\": \"語言\",\n  \"words.link\": \"連結\",\n  \"words.lists\": \"列表\",\n  \"words.load_archived_entries\": \"載入已封存項目\",\n  \"words.login\": \"登入\",\n  \"words.mint\": \"Mint\",\n  \"words.newTab\": \"新分頁\",\n  \"words.power\": \"Power\",\n  \"words.rss\": \"RSS\",\n  \"words.rss3\": \"RSS3\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.search\": \"搜尋\",\n  \"words.show_more\": \"顯示更多\",\n  \"words.starred\": \"收藏\",\n  \"words.title\": \"標題\",\n  \"words.transform\": \"轉換\",\n  \"words.trending\": \"趨勢\",\n  \"words.undo\": \"復原\",\n  \"words.unread\": \"未讀\",\n  \"words.user\": \"使用者\",\n  \"words.view\": \"視圖\",\n  \"words.which.all\": \"全部\",\n  \"words.zero_items\": \"沒有內容\"\n}\n"
  },
  {
    "path": "locales/common/en.json",
    "content": "{\n  \"app.copied_to_clipboard\": \"Copied to clipboard\",\n  \"discover.category.all\": \"Popular\",\n  \"discover.category.anime\": \"ACG\",\n  \"discover.category.bbs\": \"BBS\",\n  \"discover.category.blog\": \"Blog\",\n  \"discover.category.design\": \"Design\",\n  \"discover.category.finance\": \"Finance\",\n  \"discover.category.forecast\": \"Forecast\",\n  \"discover.category.game\": \"Gaming\",\n  \"discover.category.government\": \"Government\",\n  \"discover.category.journal\": \"Scientific Journal\",\n  \"discover.category.live\": \"Live\",\n  \"discover.category.multimedia\": \"Multimedia\",\n  \"discover.category.new-media\": \"New Media\",\n  \"discover.category.picture\": \"Picture\",\n  \"discover.category.program-update\": \"Application Updates\",\n  \"discover.category.programming\": \"Programming\",\n  \"discover.category.reading\": \"Reading\",\n  \"discover.category.shopping\": \"Shopping\",\n  \"discover.category.social-media\": \"Social Media\",\n  \"discover.category.study\": \"Study\",\n  \"discover.category.traditional-media\": \"News\",\n  \"discover.category.travel\": \"Travel\",\n  \"discover.category.university\": \"University\",\n  \"discover.empty.no_content\": \"No content found in this category.\",\n  \"discover.empty.try_another_category_or_language\": \"Try selecting another category or language.\",\n  \"discover.search.results_one\": \"Found {{count}} result\",\n  \"discover.search.results_other\": \"Found {{count}} results\",\n  \"discover.search.results_zero\": \"No results found.\",\n  \"error_screen.crashed\": \"{{appName}} crashed!\",\n  \"error_screen.list_try_later\": \"There was a problem loading the list. Please try again later.\",\n  \"error_screen.list_unable_to_load\": \"Unable to load content\",\n  \"error_screen.page_failed\": \"This page went wrong, go back and try again.\",\n  \"error_screen.unexpected\": \"An unexpected error occurred.\",\n  \"error_screen.unknown\": \"Unknown error\",\n  \"feed.actions.follow\": \"Follow\",\n  \"feed.actions.followed\": \"Followed\",\n  \"feed.entry_week_one\": \"{{count}} entry/week\",\n  \"feed.entry_week_other\": \"{{count}} entries/week\",\n  \"feed.follower_one\": \"follower\",\n  \"feed.follower_other\": \"followers\",\n  \"feed.updated_at\": \"Updated\",\n  \"feed_view_type.all\": \"All\",\n  \"feed_view_type.articles\": \"Articles\",\n  \"feed_view_type.audios\": \"Audios\",\n  \"feed_view_type.notifications\": \"Notifications\",\n  \"feed_view_type.pictures\": \"Pictures\",\n  \"feed_view_type.social_media\": \"Social Media\",\n  \"feed_view_type.videos\": \"Videos\",\n  \"ok\": \"OK\",\n  \"quantifier.piece\": \"\",\n  \"retry\": \"Retry\",\n  \"search.empty.no_results\": \"No results found.\",\n  \"space\": \" \",\n  \"time.last_night\": \"Last Night\",\n  \"time.the_night_before_last\": \"The Night Before Last\",\n  \"time.today\": \"Today\",\n  \"time.yesterday\": \"Yesterday\",\n  \"tips.load-lng-error\": \"Failed to load language pack\",\n  \"words.actions\": \"Actions\",\n  \"words.ago\": \"ago\",\n  \"words.all\": \"All\",\n  \"words.back\": \"Back\",\n  \"words.cancel\": \"Cancel\",\n  \"words.categories\": \"Categories\",\n  \"words.chinese\": \"Chinese\",\n  \"words.close\": \"Close\",\n  \"words.confirm\": \"Confirm\",\n  \"words.copy\": \"Copy\",\n  \"words.create\": \"Create\",\n  \"words.default\": \"Default\",\n  \"words.delete\": \"Delete\",\n  \"words.documentation\": \"Documentation\",\n  \"words.download\": \"Download\",\n  \"words.edit\": \"Edit\",\n  \"words.english\": \"English\",\n  \"words.entry\": \"Entry\",\n  \"words.expand\": \"Expand\",\n  \"words.feedback\": \"Feedback\",\n  \"words.feeds\": \"Feeds\",\n  \"words.finishSetup\": \"Finish Setup\",\n  \"words.follow\": \"Follow\",\n  \"words.french\": \"French\",\n  \"words.id\": \"ID\",\n  \"words.inbox\": \"Inbox\",\n  \"words.items_one\": \"Item\",\n  \"words.items_other\": \"Items\",\n  \"words.letsGo\": \"Let's Go\",\n  \"words.lists\": \"Lists\",\n  \"words.local\": \"local\",\n  \"words.manage\": \"Manage\",\n  \"words.next\": \"Next\",\n  \"words.record\": \"record\",\n  \"words.record_one\": \"record\",\n  \"words.record_other\": \"records\",\n  \"words.reset\": \"Reset\",\n  \"words.result\": \"result\",\n  \"words.result_one\": \"result\",\n  \"words.result_other\": \"results\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.save\": \"Save\",\n  \"words.search\": \"Search\",\n  \"words.starred\": \"Starred\",\n  \"words.submit\": \"Submit\",\n  \"words.trending\": \"Trending\",\n  \"words.unsaved_changes\": \"Unsaved changes\",\n  \"words.update\": \"Update\",\n  \"words.which.above\": \"above\",\n  \"words.which.all\": \"all\"\n}\n"
  },
  {
    "path": "locales/common/fr-FR.json",
    "content": "{\n  \"app.copied_to_clipboard\": \"Copié dans le presse-papier\",\n  \"discover.category.all\": \"Populaire\",\n  \"discover.category.anime\": \"Anime / Manga\",\n  \"discover.category.bbs\": \"Forum\",\n  \"discover.category.blog\": \"Blog\",\n  \"discover.category.design\": \"Design\",\n  \"discover.category.finance\": \"Finance\",\n  \"discover.category.forecast\": \"Prévisions\",\n  \"discover.category.game\": \"Jeux vidéo\",\n  \"discover.category.government\": \"Gouvernement\",\n  \"discover.category.journal\": \"Journal scientifique\",\n  \"discover.category.live\": \"Direct\",\n  \"discover.category.multimedia\": \"Multimédia\",\n  \"discover.category.new-media\": \"Nouveaux médias\",\n  \"discover.category.picture\": \"Images\",\n  \"discover.category.program-update\": \"Mises à jour logicielles\",\n  \"discover.category.programming\": \"Programmation\",\n  \"discover.category.reading\": \"Lecture\",\n  \"discover.category.shopping\": \"Shopping\",\n  \"discover.category.social-media\": \"Réseaux sociaux\",\n  \"discover.category.study\": \"Études\",\n  \"discover.category.traditional-media\": \"Actualités\",\n  \"discover.category.travel\": \"Voyage\",\n  \"discover.category.university\": \"Université\",\n  \"discover.empty.no_content\": \"Aucun contenu trouvé dans cette catégorie.\",\n  \"discover.empty.try_another_category_or_language\": \"Essayez de sélectionner une autre catégorie ou langue.\",\n  \"discover.search.results_one\": \"{{count}} résultat trouvé\",\n  \"discover.search.results_other\": \"{{count}} résultats trouvés\",\n  \"discover.search.results_zero\": \"Aucun résultat trouvé.\",\n  \"error_screen.crashed\": \"{{appName}} a planté !\",\n  \"error_screen.list_try_later\": \"Un problème est survenu lors du chargement de la liste. Veuillez réessayer plus tard.\",\n  \"error_screen.list_unable_to_load\": \"Impossible de charger le contenu\",\n  \"error_screen.page_failed\": \"Cette page a rencontré un problème. Revenez en arrière et réessayez.\",\n  \"error_screen.unexpected\": \"Une erreur inattendue s'est produite.\",\n  \"error_screen.unknown\": \"Erreur inconnue\",\n  \"feed.actions.follow\": \"Suivre\",\n  \"feed.actions.followed\": \"Suivi\",\n  \"feed.entry_week_one\": \"{{count}} article/semaine\",\n  \"feed.entry_week_other\": \"{{count}} articles/semaine\",\n  \"feed.follower_one\": \"abonné\",\n  \"feed.follower_other\": \"abonnés\",\n  \"feed.updated_at\": \"Mis à jour\",\n  \"feed_view_type.all\": \"Tout\",\n  \"feed_view_type.articles\": \"Articles\",\n  \"feed_view_type.audios\": \"Audios\",\n  \"feed_view_type.notifications\": \"Notifications\",\n  \"feed_view_type.pictures\": \"Images\",\n  \"feed_view_type.social_media\": \"Réseaux sociaux\",\n  \"feed_view_type.videos\": \"Vidéos\",\n  \"ok\": \"OK\",\n  \"quantifier.piece\": \"\",\n  \"retry\": \"Réessayer\",\n  \"search.empty.no_results\": \"Aucun résultat trouvé.\",\n  \"space\": \" \",\n  \"time.last_night\": \"Hier soir\",\n  \"time.the_night_before_last\": \"Avant-hier soir\",\n  \"time.today\": \"Aujourd'hui\",\n  \"time.yesterday\": \"Hier\",\n  \"tips.load-lng-error\": \"Échec du chargement du pack de langue\",\n  \"words.actions\": \"Actions\",\n  \"words.ago\": \"il y a\",\n  \"words.all\": \"Tout\",\n  \"words.back\": \"Retour\",\n  \"words.cancel\": \"Annuler\",\n  \"words.categories\": \"Catégories\",\n  \"words.chinese\": \"Chinois\",\n  \"words.close\": \"Fermer\",\n  \"words.confirm\": \"Confirmer\",\n  \"words.copy\": \"Copier\",\n  \"words.create\": \"Créer\",\n  \"words.default\": \"Défaut\",\n  \"words.delete\": \"Supprimer\",\n  \"words.documentation\": \"Documentation\",\n  \"words.download\": \"Télécharger\",\n  \"words.edit\": \"Modifier\",\n  \"words.english\": \"Anglais\",\n  \"words.entry\": \"Entrée\",\n  \"words.expand\": \"Développer\",\n  \"words.feedback\": \"Feedback\",\n  \"words.feeds\": \"Flux\",\n  \"words.finishSetup\": \"Terminer l'installation\",\n  \"words.follow\": \"S'abonner\",\n  \"words.french\": \"Français\",\n  \"words.id\": \"ID\",\n  \"words.inbox\": \"Boîte de réception\",\n  \"words.items_one\": \"Élément\",\n  \"words.items_other\": \"Éléments\",\n  \"words.letsGo\": \"C'est parti\",\n  \"words.lists\": \"Listes\",\n  \"words.local\": \"local\",\n  \"words.manage\": \"gérer\",\n  \"words.next\": \"Suivant\",\n  \"words.record\": \"enregistrement\",\n  \"words.record_one\": \"enregistrement\",\n  \"words.record_other\": \"enregistrements\",\n  \"words.reset\": \"Réinitialiser\",\n  \"words.result\": \"résultat\",\n  \"words.result_one\": \"résultat\",\n  \"words.result_other\": \"résultats\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.save\": \"Enregistrer\",\n  \"words.search\": \"Rechercher\",\n  \"words.starred\": \"Favoris\",\n  \"words.submit\": \"Soumettre\",\n  \"words.trending\": \"Tendance\",\n  \"words.unsaved_changes\": \"Modifications non enregistrées\",\n  \"words.update\": \"Mettre à jour\",\n  \"words.which.above\": \"ci-dessus\",\n  \"words.which.all\": \"tout\"\n}\n"
  },
  {
    "path": "locales/common/ja.json",
    "content": "{\n  \"app.copied_to_clipboard\": \"クリップボードにコピーしました\",\n  \"discover.category.all\": \"すべて\",\n  \"discover.category.anime\": \"ACG\",\n  \"discover.category.bbs\": \"BBS\",\n  \"discover.category.blog\": \"ブログ\",\n  \"discover.category.design\": \"デザイン\",\n  \"discover.category.finance\": \"金融\",\n  \"discover.category.forecast\": \"予報\",\n  \"discover.category.game\": \"ゲーム\",\n  \"discover.category.government\": \"政府\",\n  \"discover.category.journal\": \"科学/学術\",\n  \"discover.category.live\": \"Live\",\n  \"discover.category.multimedia\": \"マルチメディア\",\n  \"discover.category.new-media\": \"新メディア\",\n  \"discover.category.picture\": \"ピクチャー\",\n  \"discover.category.program-update\": \"アプリ\",\n  \"discover.category.programming\": \"プログラミング\",\n  \"discover.category.reading\": \"読みもの\",\n  \"discover.category.shopping\": \"買い物\",\n  \"discover.category.social-media\": \"ソーシャルメディア\",\n  \"discover.category.study\": \"学習\",\n  \"discover.category.traditional-media\": \"ニュース\",\n  \"discover.category.travel\": \"旅行\",\n  \"discover.category.university\": \"大学\",\n  \"discover.empty.no_content\": \"このカテゴリにはコンテンツがありません。\",\n  \"discover.empty.try_another_category_or_language\": \"別のカテゴリまたは言語を選択してください。\",\n  \"discover.search.results_one\": \"{{count}} 件の結果が見つかりました\",\n  \"discover.search.results_other\": \"{{count}} 件の結果が見つかりました\",\n  \"discover.search.results_zero\": \"結果が見つかりませんでした。\",\n  \"error_screen.crashed\": \"{{appName}} がクラッシュしました。\",\n  \"error_screen.list_try_later\": \"リストの読み込み中に問題が発生しました。しばらくしてから再試行してください。\",\n  \"error_screen.list_unable_to_load\": \"コンテンツを読み込めません\",\n  \"error_screen.page_failed\": \"このページで問題が発生しました。戻ってもう一度お試しください。\",\n  \"error_screen.unexpected\": \"予期しないエラーが発生しました。\",\n  \"error_screen.unknown\": \"不明なエラー\",\n  \"feed.actions.follow\": \"フォロー\",\n  \"feed.actions.followed\": \"フォロー済み\",\n  \"feed.entry_week_one\": \"{{count}} エントリー/週\",\n  \"feed.entry_week_other\": \"{{count}} エントリー/週\",\n  \"feed.follower_one\": \"フォロワー\",\n  \"feed.follower_other\": \"フォロワー\",\n  \"feed.updated_at\": \"更新日時\",\n  \"feed_view_type.all\": \"すべて\",\n  \"feed_view_type.articles\": \"記事\",\n  \"feed_view_type.audios\": \"オーディオ\",\n  \"feed_view_type.notifications\": \"通知\",\n  \"feed_view_type.pictures\": \"画像\",\n  \"feed_view_type.social_media\": \"ソーシャルメディア\",\n  \"feed_view_type.videos\": \"動画\",\n  \"ok\": \"OK\",\n  \"quantifier.piece\": \"個\",\n  \"retry\": \"再試行\",\n  \"search.empty.no_results\": \"結果が見つかりませんでした。\",\n  \"space\": \"\",\n  \"time.last_night\": \"昨夜\",\n  \"time.the_night_before_last\": \"一昨夜\",\n  \"time.today\": \"今日\",\n  \"time.yesterday\": \"昨日\",\n  \"tips.load-lng-error\": \"言語パックの読み込みに失敗しました\",\n  \"words.actions\": \"アクション\",\n  \"words.ago\": \"前\",\n  \"words.all\": \"すべて\",\n  \"words.back\": \"戻る\",\n  \"words.cancel\": \"キャンセル\",\n  \"words.categories\": \"カテゴリー\",\n  \"words.chinese\": \"中国語\",\n  \"words.close\": \"閉じる\",\n  \"words.confirm\": \"確認\",\n  \"words.copy\": \"コピー\",\n  \"words.create\": \"作成\",\n  \"words.default\": \"デフォルト\",\n  \"words.delete\": \"削除\",\n  \"words.documentation\": \"ドキュメンテーション\",\n  \"words.download\": \"ダウンロード\",\n  \"words.edit\": \"編集\",\n  \"words.english\": \"英語\",\n  \"words.entry\": \"エントリー\",\n  \"words.expand\": \"展開\",\n  \"words.feedback\": \"フィードバック\",\n  \"words.feeds\": \"フィード\",\n  \"words.finishSetup\": \"セットアップ完了\",\n  \"words.follow\": \"フォロー\",\n  \"words.french\": \"フランス語\",\n  \"words.id\": \"ID\",\n  \"words.inbox\": \"受信トレイ\",\n  \"words.items_one\": \"アイテム\",\n  \"words.items_other\": \"アイテム\",\n  \"words.letsGo\": \"Let's Go\",\n  \"words.lists\": \"リスト\",\n  \"words.local\": \"ローカル\",\n  \"words.manage\": \"マネージ\",\n  \"words.next\": \"次へ\",\n  \"words.record\": \"記録\",\n  \"words.record_one\": \"記録\",\n  \"words.record_other\": \"記録\",\n  \"words.reset\": \"リセット\",\n  \"words.result\": \"結果\",\n  \"words.result_one\": \"結果\",\n  \"words.result_other\": \"結果\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.save\": \"保存\",\n  \"words.search\": \"検索\",\n  \"words.starred\": \"スター付き\",\n  \"words.submit\": \"送信\",\n  \"words.trending\": \"トレンド\",\n  \"words.unsaved_changes\": \"未保存の変更\",\n  \"words.update\": \"更新\",\n  \"words.which.above\": \"上記\",\n  \"words.which.all\": \"すべて\"\n}\n"
  },
  {
    "path": "locales/common/zh-CN.json",
    "content": "{\n  \"app.copied_to_clipboard\": \"已复制到剪贴板\",\n  \"discover.category.all\": \"全部\",\n  \"discover.category.anime\": \"二次元\",\n  \"discover.category.bbs\": \"论坛\",\n  \"discover.category.blog\": \"博客\",\n  \"discover.category.design\": \"设计\",\n  \"discover.category.finance\": \"金融\",\n  \"discover.category.forecast\": \"预报预警\",\n  \"discover.category.game\": \"游戏\",\n  \"discover.category.government\": \"政务消息\",\n  \"discover.category.journal\": \"科学期刊\",\n  \"discover.category.live\": \"直播\",\n  \"discover.category.multimedia\": \"音视频\",\n  \"discover.category.new-media\": \"新媒体\",\n  \"discover.category.picture\": \"图片\",\n  \"discover.category.program-update\": \"程序更新\",\n  \"discover.category.programming\": \"编程\",\n  \"discover.category.reading\": \"阅读\",\n  \"discover.category.shopping\": \"购物\",\n  \"discover.category.social-media\": \"社交媒体\",\n  \"discover.category.study\": \"学习\",\n  \"discover.category.traditional-media\": \"传统媒体\",\n  \"discover.category.travel\": \"出行旅游\",\n  \"discover.category.university\": \"大学通知\",\n  \"discover.empty.no_content\": \"此分类下暂无内容\",\n  \"discover.empty.try_another_category_or_language\": \"请尝试选择其他分类或语言\",\n  \"discover.search.results_one\": \"找到 {{count}} 条结果\",\n  \"discover.search.results_other\": \"找到 {{count}} 条结果\",\n  \"discover.search.results_zero\": \"搜索结果为空\",\n  \"error_screen.crashed\": \"{{appName}} 崩溃了！\",\n  \"error_screen.list_try_later\": \"加载列表时出现问题，请稍后重试。\",\n  \"error_screen.list_unable_to_load\": \"无法加载内容\",\n  \"error_screen.page_failed\": \"此页面出现异常，请返回后重试。\",\n  \"error_screen.unexpected\": \"发生了未知错误。\",\n  \"error_screen.unknown\": \"未知错误\",\n  \"feed.actions.follow\": \"订阅\",\n  \"feed.actions.followed\": \"已订阅\",\n  \"feed.entry_week_one\": \"{{count}} 条目/周\",\n  \"feed.entry_week_other\": \"{{count}} 条目/周\",\n  \"feed.follower_one\": \"订阅者\",\n  \"feed.follower_other\": \"订阅者\",\n  \"feed.updated_at\": \"更新于\",\n  \"feed_view_type.all\": \"全部\",\n  \"feed_view_type.articles\": \"文章\",\n  \"feed_view_type.audios\": \"音频\",\n  \"feed_view_type.notifications\": \"通知\",\n  \"feed_view_type.pictures\": \"图片\",\n  \"feed_view_type.social_media\": \"社交媒体\",\n  \"feed_view_type.videos\": \"视频\",\n  \"ok\": \"好\",\n  \"quantifier.piece\": \"条\",\n  \"retry\": \"重试\",\n  \"search.empty.no_results\": \"搜索结果为空\",\n  \"space\": \"\",\n  \"time.last_night\": \"昨晚\",\n  \"time.the_night_before_last\": \"前天晚上\",\n  \"time.today\": \"今天\",\n  \"time.yesterday\": \"昨天\",\n  \"tips.load-lng-error\": \"加载语言包失败\",\n  \"words.actions\": \"自动化\",\n  \"words.ago\": \"前\",\n  \"words.all\": \"全部\",\n  \"words.back\": \"返回\",\n  \"words.cancel\": \"取消\",\n  \"words.categories\": \"分类\",\n  \"words.chinese\": \"中文\",\n  \"words.close\": \"关闭\",\n  \"words.confirm\": \"确认\",\n  \"words.copy\": \"复制\",\n  \"words.create\": \"创建\",\n  \"words.default\": \"默认\",\n  \"words.delete\": \"删除\",\n  \"words.documentation\": \"文档\",\n  \"words.download\": \"下载\",\n  \"words.edit\": \"编辑\",\n  \"words.english\": \"英语\",\n  \"words.entry\": \"条目\",\n  \"words.expand\": \"展开\",\n  \"words.feedback\": \"反馈\",\n  \"words.feeds\": \"订阅源\",\n  \"words.finishSetup\": \"完成设置\",\n  \"words.follow\": \"订阅\",\n  \"words.french\": \"法语\",\n  \"words.id\": \"ID\",\n  \"words.inbox\": \"收件箱\",\n  \"words.items_one\": \"内容\",\n  \"words.items_other\": \"内容\",\n  \"words.letsGo\": \"开始使用\",\n  \"words.lists\": \"列表\",\n  \"words.local\": \"本地\",\n  \"words.manage\": \"管理\",\n  \"words.next\": \"下一步\",\n  \"words.record\": \"记录\",\n  \"words.record_one\": \"记录\",\n  \"words.record_other\": \"记录\",\n  \"words.reset\": \"重置\",\n  \"words.result\": \"结果\",\n  \"words.result_one\": \"结果\",\n  \"words.result_other\": \"结果\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.save\": \"保存\",\n  \"words.search\": \"搜索\",\n  \"words.starred\": \"收藏\",\n  \"words.submit\": \"提交\",\n  \"words.trending\": \"趋势\",\n  \"words.unsaved_changes\": \"未保存的更改\",\n  \"words.update\": \"更新\",\n  \"words.which.above\": \"以上\",\n  \"words.which.all\": \"全部\"\n}\n"
  },
  {
    "path": "locales/common/zh-TW.json",
    "content": "{\n  \"app.copied_to_clipboard\": \"已複製到剪貼簿\",\n  \"discover.category.all\": \"全部\",\n  \"discover.category.anime\": \"動漫\",\n  \"discover.category.bbs\": \"論壇\",\n  \"discover.category.blog\": \"部落格\",\n  \"discover.category.design\": \"設計\",\n  \"discover.category.finance\": \"金融\",\n  \"discover.category.forecast\": \"預報預警\",\n  \"discover.category.game\": \"遊戲\",\n  \"discover.category.government\": \"政府資訊\",\n  \"discover.category.journal\": \"科學期刊\",\n  \"discover.category.live\": \"直播\",\n  \"discover.category.multimedia\": \"多媒體\",\n  \"discover.category.new-media\": \"新媒體\",\n  \"discover.category.picture\": \"圖片\",\n  \"discover.category.program-update\": \"程式更新\",\n  \"discover.category.programming\": \"程式設計\",\n  \"discover.category.reading\": \"閱讀\",\n  \"discover.category.shopping\": \"購物\",\n  \"discover.category.social-media\": \"社群媒體\",\n  \"discover.category.study\": \"學習\",\n  \"discover.category.traditional-media\": \"傳統媒體\",\n  \"discover.category.travel\": \"出行旅遊\",\n  \"discover.category.university\": \"大學資訊\",\n  \"discover.empty.no_content\": \"此分類下暫無內容\",\n  \"discover.empty.try_another_category_or_language\": \"請嘗試選擇其他分類或語言\",\n  \"discover.search.results_one\": \"找到 {{count}} 筆結果\",\n  \"discover.search.results_other\": \"找到 {{count}} 筆結果\",\n  \"discover.search.results_zero\": \"搜尋結果為空\",\n  \"error_screen.crashed\": \"{{appName}} 當機了！\",\n  \"error_screen.list_try_later\": \"載入列表時發生問題，請稍後再試。\",\n  \"error_screen.list_unable_to_load\": \"無法載入內容\",\n  \"error_screen.page_failed\": \"此頁面發生錯誤，請返回後重試。\",\n  \"error_screen.unexpected\": \"發生未預期的錯誤。\",\n  \"error_screen.unknown\": \"未知錯誤\",\n  \"feed.actions.follow\": \"跟隨\",\n  \"feed.actions.followed\": \"已跟隨\",\n  \"feed.entry_week_one\": \"{{count}} 條目/週\",\n  \"feed.entry_week_other\": \"{{count}} 條目/週\",\n  \"feed.follower_one\": \"跟隨者\",\n  \"feed.follower_other\": \"跟隨者\",\n  \"feed.updated_at\": \"已更新\",\n  \"feed_view_type.all\": \"全部\",\n  \"feed_view_type.articles\": \"文章\",\n  \"feed_view_type.audios\": \"音訊\",\n  \"feed_view_type.notifications\": \"通知\",\n  \"feed_view_type.pictures\": \"圖片\",\n  \"feed_view_type.social_media\": \"社群媒體\",\n  \"feed_view_type.videos\": \"影片\",\n  \"ok\": \"確定\",\n  \"quantifier.piece\": \"條\",\n  \"retry\": \"重試\",\n  \"search.empty.no_results\": \"搜尋結果為空\",\n  \"space\": \"\",\n  \"time.last_night\": \"昨晚\",\n  \"time.the_night_before_last\": \"前晚\",\n  \"time.today\": \"今天\",\n  \"time.yesterday\": \"昨天\",\n  \"tips.load-lng-error\": \"語言套件讀取失敗\",\n  \"words.actions\": \"操作\",\n  \"words.ago\": \"前\",\n  \"words.all\": \"全部\",\n  \"words.back\": \"返回\",\n  \"words.cancel\": \"取消\",\n  \"words.categories\": \"分類\",\n  \"words.chinese\": \"中文\",\n  \"words.close\": \"關閉\",\n  \"words.confirm\": \"確認\",\n  \"words.copy\": \"複製\",\n  \"words.create\": \"創建\",\n  \"words.default\": \"預設\",\n  \"words.delete\": \"刪除\",\n  \"words.documentation\": \"文件\",\n  \"words.download\": \"下載\",\n  \"words.edit\": \"編輯\",\n  \"words.english\": \"英文\",\n  \"words.entry\": \"條目\",\n  \"words.expand\": \"展開\",\n  \"words.feedback\": \"回饋\",\n  \"words.feeds\": \"RSS 摘要\",\n  \"words.finishSetup\": \"完成設置\",\n  \"words.follow\": \"跟隨\",\n  \"words.french\": \"法文\",\n  \"words.id\": \"ID\",\n  \"words.inbox\": \"收件匣\",\n  \"words.items_one\": \"內容\",\n  \"words.items_other\": \"內容\",\n  \"words.letsGo\": \"開始吧\",\n  \"words.lists\": \"列表\",\n  \"words.local\": \"本地\",\n  \"words.manage\": \"管理\",\n  \"words.next\": \"下一步\",\n  \"words.record\": \"記錄\",\n  \"words.record_one\": \"記錄\",\n  \"words.record_other\": \"記錄\",\n  \"words.reset\": \"重置\",\n  \"words.result\": \"結果\",\n  \"words.result_one\": \"結果\",\n  \"words.result_other\": \"結果\",\n  \"words.rsshub\": \"RSSHub\",\n  \"words.save\": \"儲存\",\n  \"words.search\": \"搜尋\",\n  \"words.starred\": \"收藏\",\n  \"words.submit\": \"提交\",\n  \"words.trending\": \"趨勢\",\n  \"words.unsaved_changes\": \"尚未儲存的變更\",\n  \"words.update\": \"更新\",\n  \"words.which.above\": \"以上\",\n  \"words.which.all\": \"全部\"\n}\n"
  },
  {
    "path": "locales/errors/en.json",
    "content": "{\n  \"1\": \"Previous operation is not completed\",\n  \"3\": \"Unprocessable content\",\n  \"4\": \"Invalid input\",\n  \"5\": \"Missing configuration\",\n  \"6\": \"Not found\",\n  \"1000\": \"Unauthorized\",\n  \"1001\": \"create session failed\",\n  \"1002\": \"Invalid parameter\",\n  \"1003\": \"Invalid invitation\",\n  \"1004\": \"No permission\",\n  \"1005\": \"Internal error\",\n  \"1006\": \"Email not verified\",\n  \"1007\": \"Captcha verification failed\",\n  \"2000\": \"Only admins can refresh feeds\",\n  \"2001\": \"Feed not found\",\n  \"2002\": \"feedId or url required\",\n  \"2003\": \"Feed fetch error\",\n  \"2004\": \"Feed failed to parse\",\n  \"2010\": \"Ownership challenge failed\",\n  \"2011\": \"Subscription limit exceeded\",\n  \"3000\": \"Entry not found\",\n  \"4000\": \"Already claimed\",\n  \"4001\": \"User wallet error\",\n  \"4002\": \"Insufficient balance\",\n  \"4003\": \"Feed insufficient withdrawable balance\",\n  \"4004\": \"Target user wallet error\",\n  \"4005\": \"Daily power calculation in progress\",\n  \"4006\": \"Invalid boost amount\",\n  \"4007\": \"Invalid two factor code\",\n  \"4008\": \"Two factor code required\",\n  \"4010\": \"Airdrop not eligible\",\n  \"4011\": \"Airdrop is sending\",\n  \"4012\": \"Airdrop already sent\",\n  \"4013\": \"Airdrop not verified\",\n  \"5000\": \"Invitation limit exceeded. Please try again in a few days.\",\n  \"5001\": \"Invitation already exists.\",\n  \"5002\": \"Invitation code already used.\",\n  \"5003\": \"Invitation code does not exist.\",\n  \"6000\": \"User not found\",\n  \"7000\": \"Setting not found\",\n  \"7001\": \"Invalid setting tab\",\n  \"7002\": \"Invalid setting payload\",\n  \"7003\": \"Setting payload too large\",\n  \"8000\": \"List not found\",\n  \"8001\": \"List permission denied\",\n  \"8002\": \"List limit exceeded\",\n  \"8003\": \"Feed already added to list\",\n  \"8004\": \"Cannot delete a list with subscriptions.\",\n  \"9000\": \"Achievement not completed\",\n  \"9001\": \"Achievement already received\",\n  \"9002\": \"Achievement is under audit\",\n  \"9003\": \"Achievement audit not needed\",\n  \"10000\": \"Inbox not found\",\n  \"10001\": \"Inbox already exists\",\n  \"10002\": \"Inbox limit exceeded\",\n  \"10003\": \"Inbox permission denied\",\n  \"12000\": \"Action limit exceeded\",\n  \"13000\": \"RSSHub route not found\",\n  \"13001\": \"You are not the owner of this RSSHub instance\",\n  \"13002\": \"RSSHub is in use\",\n  \"13003\": \"RSSHub not found\",\n  \"13004\": \"RSSHub subscription limit exceeded\",\n  \"13005\": \"RSSHub purchase not found\",\n  \"13006\": \"RSSHub config invalid\",\n  \"13007\": \"This RSSHub instance is unavailable currently\",\n  \"14000\": \"Invalid file\",\n  \"14001\": \"File too large\",\n  \"14002\": \"Upload failed\",\n  \"15000\": \"AI token limit exceeded. Please try again later.\",\n  \"15001\": \"You are not allowed to access AI chat\",\n  \"15002\": \"AI attachment file limit exceeded. Please try again later.\",\n  \"15003\": \"AI memory not found\",\n  \"15004\": \"AI memory limit exceeded\",\n  \"15005\": \"Insufficient AI credits to access this feature.\",\n  \"16000\": \"MCP OAuth error\",\n  \"16001\": \"MCP discovery error\",\n  \"16002\": \"MCP service not found\",\n  \"16003\": \"Invalid or expired OAuth state\",\n  \"16004\": \"Token exchange failed\",\n  \"16005\": \"User MCP service not found\",\n  \"16006\": \"MCP configuration error\",\n  \"16007\": \"MCP tool execution failed\",\n  \"17000\": \"Trial user max feed subscription exceeded\",\n  \"17001\": \"Trial user max list subscription exceeded\",\n  \"17002\": \"Trial user max inbox subscription exceeded\",\n  \"17003\": \"Free user no permission\"\n}\n"
  },
  {
    "path": "locales/errors/fr-FR.json",
    "content": "{\n  \"1\": \"L'opération précédente n'est pas terminée\",\n  \"3\": \"Contenu non traitable\",\n  \"4\": \"Entrée invalide\",\n  \"5\": \"Configuration manquante\",\n  \"6\": \"Introuvable\",\n  \"1000\": \"Non autorisé\",\n  \"1001\": \"Échec de la création de session\",\n  \"1002\": \"Paramètre invalide\",\n  \"1003\": \"Invitation invalide\",\n  \"1004\": \"Aucune permission\",\n  \"1005\": \"Erreur interne\",\n  \"1006\": \"Email non vérifié\",\n  \"1007\": \"Échec de la vérification du captcha\",\n  \"2000\": \"Seuls les administrateurs peuvent actualiser les flux\",\n  \"2001\": \"Flux introuvable\",\n  \"2002\": \"feedId ou url requis\",\n  \"2003\": \"Erreur de récupération du flux\",\n  \"2004\": \"Échec de l'analyse du flux\",\n  \"2010\": \"Échec de la vérification de propriété\",\n  \"2011\": \"Limite d'abonnement dépassée\",\n  \"3000\": \"Entrée introuvable\",\n  \"4000\": \"Déjà réclamé\",\n  \"4001\": \"Erreur de portefeuille utilisateur\",\n  \"4002\": \"Solde insuffisant\",\n  \"4003\": \"Solde retirable du flux insuffisant\",\n  \"4004\": \"Erreur de portefeuille utilisateur cible\",\n  \"4005\": \"Calcul de la puissance quotidienne en cours\",\n  \"4006\": \"Montant de boost invalide\",\n  \"4007\": \"Code à deux facteurs invalide\",\n  \"4008\": \"Code à deux facteurs requis\",\n  \"4010\": \"Airdrop non éligible\",\n  \"4011\": \"Envoi de l'airdrop en cours\",\n  \"4012\": \"Airdrop déjà envoyé\",\n  \"4013\": \"Airdrop non vérifié\",\n  \"5000\": \"Limite d'invitation dépassée. Veuillez réessayer dans quelques jours.\",\n  \"5001\": \"L'invitation existe déjà.\",\n  \"5002\": \"Code d'invitation déjà utilisé.\",\n  \"5003\": \"Le code d'invitation n'existe pas.\",\n  \"6000\": \"Utilisateur introuvable\",\n  \"7000\": \"Paramètre introuvable\",\n  \"7001\": \"Onglet de paramètre invalide\",\n  \"7002\": \"Charge utile de paramètre invalide\",\n  \"7003\": \"Charge utile de paramètre trop volumineuse\",\n  \"8000\": \"Liste introuvable\",\n  \"8001\": \"Permission de liste refusée\",\n  \"8002\": \"Limite de liste dépassée\",\n  \"8003\": \"Flux déjà ajouté à la liste\",\n  \"8004\": \"Impossible de supprimer une liste avec des abonnements.\",\n  \"9000\": \"Succès non terminé\",\n  \"9001\": \"Succès déjà reçu\",\n  \"9002\": \"Succès en cours d'audit\",\n  \"9003\": \"Audit de succès non nécessaire\",\n  \"10000\": \"Boîte de réception introuvable\",\n  \"10001\": \"La boîte de réception existe déjà\",\n  \"10002\": \"Limite de boîte de réception dépassée\",\n  \"10003\": \"Permission de boîte de réception refusée\",\n  \"12000\": \"Limite d'action dépassée\",\n  \"13000\": \"Route RSSHub introuvable\",\n  \"13001\": \"Vous n'êtes pas le propriétaire de cette instance RSSHub\",\n  \"13002\": \"RSSHub est en cours d'utilisation\",\n  \"13003\": \"RSSHub introuvable\",\n  \"13004\": \"Limite d'abonnement RSSHub dépassée\",\n  \"13005\": \"Achat RSSHub introuvable\",\n  \"13006\": \"Configuration RSSHub invalide\",\n  \"13007\": \"Cette instance RSSHub est actuellement indisponible\",\n  \"14000\": \"Fichier invalide\",\n  \"14001\": \"Fichier trop volumineux\",\n  \"14002\": \"Échec du téléchargement\",\n  \"15000\": \"Limite de jetons IA dépassée. Veuillez réessayer plus tard.\",\n  \"15001\": \"Vous n'êtes pas autorisé à accéder au chat IA\",\n  \"15002\": \"Limite de fichier joint IA dépassée. Veuillez réessayer plus tard.\",\n  \"15003\": \"Mémoire IA introuvable\",\n  \"15004\": \"Limite de mémoire IA dépassée\",\n  \"15005\": \"Crédits IA insuffisants pour accéder à cette fonctionnalité.\",\n  \"16000\": \"Erreur OAuth MCP\",\n  \"16001\": \"Erreur de découverte MCP\",\n  \"16002\": \"Service MCP introuvable\",\n  \"16003\": \"État OAuth invalide ou expiré\",\n  \"16004\": \"Échec de l'échange de jeton\",\n  \"16005\": \"Service MCP utilisateur introuvable\",\n  \"16006\": \"Erreur de configuration MCP\",\n  \"16007\": \"Échec de l'exécution de l'outil MCP\",\n  \"17000\": \"Limite d'abonnement aux flux max pour utilisateur d'essai dépassée\",\n  \"17001\": \"Limite d'abonnement aux listes max pour utilisateur d'essai dépassée\",\n  \"17002\": \"Limite d'abonnement aux boîtes de réception max pour utilisateur d'essai dépassée\",\n  \"17003\": \"Aucune permission pour l'utilisateur gratuit\"\n}\n"
  },
  {
    "path": "locales/errors/ja.json",
    "content": "{\n  \"1\": \"前の操作が完了していません\",\n  \"3\": \"処理できないコンテンツ\",\n  \"4\": \"無効な入力\",\n  \"5\": \"設定が欠落しています\",\n  \"6\": \"見つかりません\",\n  \"1000\": \"認証されていません\",\n  \"1001\": \"セッションの作成に失敗しました\",\n  \"1002\": \"無効なパラメータ\",\n  \"1003\": \"無効な招待状\",\n  \"1004\": \"権限がありません\",\n  \"1005\": \"内部エラー\",\n  \"1006\": \"メールが確認されていません\",\n  \"1007\": \"Captchaの確認に失敗しました\",\n  \"2000\": \"管理者のみがフィードを更新できます\",\n  \"2001\": \"フィードが見つかりません\",\n  \"2002\": \"feedIdまたはurlが必要です\",\n  \"2003\": \"フィード取得エラー\",\n  \"2004\": \"フィードの解析に失敗しました\",\n  \"2010\": \"所有権の確認に失敗しました\",\n  \"2011\": \"購読制限を超えました\",\n  \"3000\": \"エントリが見つかりません\",\n  \"4000\": \"すでに請求されています\",\n  \"4001\": \"ユーザーウォレットエラー\",\n  \"4002\": \"残高不足\",\n  \"4003\": \"フィードの引き出し可能残高が不足しています\",\n  \"4004\": \"対象ユーザーのウォレットエラー\",\n  \"4005\": \"日次電力計算中\",\n  \"4006\": \"無効なブースト金額\",\n  \"4007\": \"無効な二要素コード\",\n  \"4008\": \"二要素コードが必要です\",\n  \"4010\": \"エアドロップの資格がありません\",\n  \"4011\": \"エアドロップを送信中です\",\n  \"4012\": \"エアドロップはすでに送信されています\",\n  \"4013\": \"エアドロップが確認されていません\",\n  \"5000\": \"招待制限を超えました。数日後に再試行してください。\",\n  \"5001\": \"招待状はすでに存在します。\",\n  \"5002\": \"招待コードはすでに使用されています。\",\n  \"5003\": \"招待コードは存在しません。\",\n  \"6000\": \"ユーザーが見つかりません\",\n  \"7000\": \"設定が見つかりません\",\n  \"7001\": \"無効な設定タブ\",\n  \"7002\": \"無効な設定ペイロード\",\n  \"7003\": \"設定ペイロードが大きすぎます\",\n  \"8000\": \"リストが見つかりません\",\n  \"8001\": \"リストの権限が拒否されました\",\n  \"8002\": \"リスト制限を超えました\",\n  \"8003\": \"フィードはすでにリストに追加されています\",\n  \"8004\": \"購読があるリストを削除できません。\",\n  \"9000\": \"達成が完了していません\",\n  \"9001\": \"達成はすでに受け取られています\",\n  \"9002\": \"達成は監査中です\",\n  \"9003\": \"達成の監査は必要ありません\",\n  \"10000\": \"受信箱が見つかりません\",\n  \"10001\": \"受信箱はすでに存在します\",\n  \"10002\": \"受信箱制限を超えました\",\n  \"10003\": \"受信箱の権限が拒否されました\",\n  \"12000\": \"アクション制限を超えました\",\n  \"13000\": \"RSSHubルートが見つかりません\",\n  \"13001\": \"このRSSHubインスタンスの所有者ではありません\",\n  \"13002\": \"RSSHubが使用中です\",\n  \"13003\": \"RSSHubが見つかりません\",\n  \"13004\": \"RSSHubのサブスクリプション数制限を超えました\",\n  \"13005\": \"RSSHubの購入が見つかりません\",\n  \"13006\": \"RSSHubの設定が無効です\",\n  \"13007\": \"このRSSHubインスタンスは現在利用できません\",\n  \"14000\": \"無効なファイル\",\n  \"14001\": \"ファイルが大きすぎます\",\n  \"14002\": \"アップロードに失敗しました\",\n  \"15000\": \"AIトークン制限を超えました。後で再試行してください。\",\n  \"15001\": \"AIチャットにアクセスすることは許可されていません\",\n  \"15002\": \"AI添付ファイル制限を超えました。後で再試行してください。\",\n  \"15003\": \"AIメモリが見つかりません\",\n  \"15004\": \"AIメモリ制限を超えました\",\n  \"15005\": \"この機能にアクセスするためのAIクレジットが不足しています。\",\n  \"16000\": \"MCP OAuthエラー\",\n  \"16001\": \"MCP発見エラー\",\n  \"16002\": \"MCPサービスが見つかりません\",\n  \"16003\": \"無効または期限切れのOAuth状態\",\n  \"16004\": \"トークン交換に失敗しました\",\n  \"16005\": \"ユーザーMCPサービスが見つかりません\",\n  \"16006\": \"MCP設定エラー\",\n  \"16007\": \"MCPツールの実行に失敗しました\",\n  \"17000\": \"トライアルユーザーの最大フィード購読数を超えました\",\n  \"17001\": \"トライアルユーザーの最大リスト購読数を超えました\",\n  \"17002\": \"トライアルユーザーの最大受信箱購読数を超えました\",\n  \"17003\": \"無料ユーザーには権限がありません\"\n}\n"
  },
  {
    "path": "locales/errors/zh-CN.json",
    "content": "{\n  \"1\": \"上一个操作尚未完成\",\n  \"3\": \"无法处理的内容\",\n  \"4\": \"无效输入\",\n  \"5\": \"缺少配置\",\n  \"6\": \"未找到\",\n  \"1000\": \"未经授权\",\n  \"1001\": \"创建会话失败\",\n  \"1002\": \"无效参数\",\n  \"1003\": \"无效邀请\",\n  \"1004\": \"没有权限\",\n  \"1005\": \"内部错误\",\n  \"1006\": \"邮箱未验证\",\n  \"1007\": \"验证码验证失败\",\n  \"2000\": \"仅管理员可以刷新订阅源\",\n  \"2001\": \"未找到订阅源\",\n  \"2002\": \"需要 feedId 或 url\",\n  \"2003\": \"订阅源获取错误\",\n  \"2004\": \"订阅源解析失败\",\n  \"2010\": \"所有权挑战失败\",\n  \"2011\": \"超出订阅限制\",\n  \"3000\": \"未找到条目\",\n  \"4000\": \"已被认领\",\n  \"4001\": \"用户钱包错误\",\n  \"4002\": \"余额不足\",\n  \"4003\": \"订阅源可提取余额不足\",\n  \"4004\": \"目标用户钱包错误\",\n  \"4005\": \"每日电力计算进行中\",\n  \"4006\": \"无效的提升金额\",\n  \"4007\": \"无效的双重验证代码\",\n  \"4008\": \"需要双重验证代码\",\n  \"4010\": \"不符合空投资格\",\n  \"4011\": \"空投正在发送中\",\n  \"4012\": \"空投已发送\",\n  \"4013\": \"空投未验证\",\n  \"5000\": \"邀请限制已超出。请几天后再试。\",\n  \"5001\": \"邀请已存在。\",\n  \"5002\": \"邀请代码已使用。\",\n  \"5003\": \"邀请代码不存在。\",\n  \"6000\": \"未找到用户\",\n  \"7000\": \"未找到设置\",\n  \"7001\": \"无效的设置选项卡\",\n  \"7002\": \"无效的设置有效负载\",\n  \"7003\": \"设置有效负载过大\",\n  \"8000\": \"未找到列表\",\n  \"8001\": \"列表权限被拒绝\",\n  \"8002\": \"列表限制已超出\",\n  \"8003\": \"订阅源已添加到列表中\",\n  \"8004\": \"无法删除有订阅的列表。\",\n  \"9000\": \"成就未完成\",\n  \"9001\": \"成就已领取\",\n  \"9002\": \"成就正在审核中\",\n  \"9003\": \"不需要成就审核\",\n  \"10000\": \"未找到收件箱\",\n  \"10001\": \"收件箱已存在\",\n  \"10002\": \"收件箱限制已超出\",\n  \"10003\": \"收件箱权限被拒绝\",\n  \"12000\": \"操作限制已超出\",\n  \"13000\": \"未找到 RSSHub 路由\",\n  \"13001\": \"您不是此 RSSHub 实例的所有者\",\n  \"13002\": \"RSSHub 正在使用中\",\n  \"13003\": \"未找到 RSSHub\",\n  \"13004\": \"RSSHub 订阅源数量限制已超出\",\n  \"13005\": \"未找到 RSSHub 购买记录\",\n  \"13006\": \"RSSHub 配置无效\",\n  \"13007\": \"此 RSSHub 实例当前不可用\",\n  \"14000\": \"无效文件\",\n  \"14001\": \"文件过大\",\n  \"14002\": \"上传失败\",\n  \"15000\": \"AI 令牌限制已超出。请稍后再试。\",\n  \"15001\": \"您无权访问 AI 聊天\",\n  \"15002\": \"AI 附件文件限制已超出。请稍后再试。\",\n  \"15003\": \"未找到 AI 内存\",\n  \"15004\": \"AI 内存限制已超出\",\n  \"15005\": \"访问此功能的 AI 额度不足。\",\n  \"16000\": \"MCP OAuth 错误\",\n  \"16001\": \"MCP 发现错误\",\n  \"16002\": \"未找到 MCP 服务\",\n  \"16003\": \"无效或过期的 OAuth 状态\",\n  \"16004\": \"令牌交换失败\",\n  \"16005\": \"用户 MCP 服务未找到\",\n  \"16006\": \"MCP 配置错误\",\n  \"16007\": \"MCP 工具执行失败\",\n  \"17000\": \"试用用户最大订阅源数量超出\",\n  \"17001\": \"试用用户最大列表订阅数量超出\",\n  \"17002\": \"试用用户最大收件箱订阅数量超出\",\n  \"17003\": \"免费用户没有权限\"\n}\n"
  },
  {
    "path": "locales/errors/zh-TW.json",
    "content": "{\n  \"1\": \"前一操作尚未完成\",\n  \"3\": \"無法處理的內容\",\n  \"4\": \"無效的輸入\",\n  \"5\": \"缺少配置\",\n  \"6\": \"未找到\",\n  \"1000\": \"未授權\",\n  \"1001\": \"創建會話失敗\",\n  \"1002\": \"無效的參數\",\n  \"1003\": \"無效的邀請\",\n  \"1004\": \"無權限\",\n  \"1005\": \"內部錯誤\",\n  \"1006\": \"電子郵件未驗證\",\n  \"1007\": \"驗證碼驗證失敗\",\n  \"2000\": \"僅管理員可以刷新訂閱源\",\n  \"2001\": \"未找到訂閱源\",\n  \"2002\": \"需要 feedId 或 url\",\n  \"2003\": \"訂閱源獲取錯誤\",\n  \"2004\": \"訂閱源解析失敗\",\n  \"2010\": \"所有權挑戰失敗\",\n  \"2011\": \"超過訂閱限制\",\n  \"3000\": \"條目未找到\",\n  \"4000\": \"已被認領\",\n  \"4001\": \"用戶錢包錯誤\",\n  \"4002\": \"餘額不足\",\n  \"4003\": \"訂閱源可提取餘額不足\",\n  \"4004\": \"目標用戶錢包錯誤\",\n  \"4005\": \"每日電力計算中\",\n  \"4006\": \"無效的增強金額\",\n  \"4007\": \"無效的雙重驗證碼\",\n  \"4008\": \"需要雙重驗證碼\",\n  \"4010\": \"不符合空投資格\",\n  \"4011\": \"空投正在發送中\",\n  \"4012\": \"空投已發送\",\n  \"4013\": \"空投未驗證\",\n  \"5000\": \"邀請限制已超過。請幾天後再試。\",\n  \"5001\": \"邀請已存在。\",\n  \"5002\": \"邀請碼已使用。\",\n  \"5003\": \"邀請碼不存在。\",\n  \"6000\": \"未找到用戶\",\n  \"7000\": \"未找到設置\",\n  \"7001\": \"無效的設置選項卡\",\n  \"7002\": \"無效的設置有效載荷\",\n  \"7003\": \"設置有效載荷過大\",\n  \"8000\": \"未找到列表\",\n  \"8001\": \"列表權限被拒絕\",\n  \"8002\": \"列表限制已超過\",\n  \"8003\": \"訂閱源已添加到列表\",\n  \"8004\": \"無法刪除有訂閱的列表。\",\n  \"9000\": \"成就未完成\",\n  \"9001\": \"成就已獲得\",\n  \"9002\": \"成就正在審核中\",\n  \"9003\": \"無需成就審核\",\n  \"10000\": \"收件箱未找到\",\n  \"10001\": \"收件箱已存在\",\n  \"10002\": \"收件箱限制已超過\",\n  \"10003\": \"收件箱權限被拒絕\",\n  \"12000\": \"操作限制已超過\",\n  \"13000\": \"未找到 RSSHub 路由\",\n  \"13001\": \"您不是此 RSSHub 實例的擁有者\",\n  \"13002\": \"RSSHub 正在使用中\",\n  \"13003\": \"未找到 RSSHub\",\n  \"13004\": \"RSSHub 訂閱源數量限制已超過\",\n  \"13005\": \"未找到 RSSHub 購買記錄\",\n  \"13006\": \"RSSHub 配置無效\",\n  \"13007\": \"此 RSSHub 實例目前不可用\",\n  \"14000\": \"無效的文件\",\n  \"14001\": \"文件過大\",\n  \"14002\": \"上傳失敗\",\n  \"15000\": \"AI 令牌限制已超過。請稍後再試。\",\n  \"15001\": \"您無權訪問 AI 聊天\",\n  \"15002\": \"AI 附件文件限制已超過。請稍後再試。\",\n  \"15003\": \"未找到 AI 記憶\",\n  \"15004\": \"AI 記憶限制已超過\",\n  \"15005\": \"訪問此功能的 AI 積分不足。\",\n  \"16000\": \"MCP OAuth 錯誤\",\n  \"16001\": \"MCP 發現錯誤\",\n  \"16002\": \"未找到 MCP 服務\",\n  \"16003\": \"無效或過期的 OAuth 狀態\",\n  \"16004\": \"令牌交換失敗\",\n  \"16005\": \"用戶 MCP 服務未找到\",\n  \"16006\": \"MCP 配置錯誤\",\n  \"16007\": \"MCP 工具執行失敗\",\n  \"17000\": \"試用用戶最大訂閱源數量已超過\",\n  \"17001\": \"試用用戶最大列表訂閱數量已超過\",\n  \"17002\": \"試用用戶最大收件箱訂閱數量已超過\",\n  \"17003\": \"免費用戶無權限\"\n}\n"
  },
  {
    "path": "locales/external/en.json",
    "content": "{\n  \"copied_link\": \"Copied link to clipboard\",\n  \"feed.actions.follow\": \"Follow\",\n  \"feed.actions.followed\": \"Followed\",\n  \"feed.actions.open\": \"Open in {{which}}\",\n  \"feed.copy_feed_url\": \"Copy Feed URL\",\n  \"feed.entry_week_one\": \"{{count}} entry/week\",\n  \"feed.entry_week_other\": \"{{count}} entries/week\",\n  \"feed.feeds_one\": \"feed\",\n  \"feed.feeds_other\": \"feeds\",\n  \"feed.follow_to_view_all\": \"Follow to view all {{count}} feeds...\",\n  \"feed.follower_one\": \"follower\",\n  \"feed.follower_other\": \"followers\",\n  \"feed.followsAndFeeds\": \"{{subscriptionCount}} {{subscriptionNoun}} and {{feedsCount}} {{feedsNoun}} on {{appName}}\",\n  \"feed.madeby\": \"Made by\",\n  \"feed.preview\": \"Preview\",\n  \"feed.read_one\": \"read\",\n  \"feed.read_other\": \"reads\",\n  \"feed.updated_at\": \"Updated\",\n  \"feed.view_feed_url\": \"View Feed URL\",\n  \"feed_item.claimed_by_owner\": \"This feed is claimed by\",\n  \"feed_item.claimed_by_unknown\": \"its owner.\",\n  \"feed_item.claimed_by_you\": \"Claimed by you\",\n  \"feed_item.claimed_feed\": \"Claimed Feed\",\n  \"feed_item.claimed_list\": \"Claimed List\",\n  \"feed_item.error_since\": \"Error since\",\n  \"feed_item.not_publicly_visible\": \"Not publicly visible on your profile page\",\n  \"header.app\": \"App\",\n  \"header.download\": \"Download\",\n  \"invitation.activate\": \"Activate\",\n  \"invitation.codeOptions.1\": \"Looking for any alpha test user to invite you.\",\n  \"invitation.codeOptions.2\": \"Join our Discord server for occasional giveaways.\",\n  \"invitation.codeOptions.3\": \"Follow our X account for occasional giveaways.\",\n  \"invitation.earlyAccess\": \"Folo is currently requires an invitation code to use.\",\n  \"invitation.earlyAccessMessage\": \"😰 Sorry, Folo is currently requires an invitation code to use.\",\n  \"invitation.generateButton\": \"Generate new code\",\n  \"invitation.generateCost\": \"You can spend {{INVITATION_PRICE}} Power to generate an invitation code for your friends.\",\n  \"invitation.getCodeMessage\": \"You can get an invitation code in the following ways:\",\n  \"invitation.title\": \"Invitation Code\",\n  \"login.backToWebApp\": \"Back To Web App\",\n  \"login.confirm_password.label\": \"Confirm Password\",\n  \"login.continueWith\": \"Continue with {{provider}}\",\n  \"login.email\": \"Email\",\n  \"login.enter_token\": \"If you've installed the client but aren't redirected, copy the token below and paste it in the \\\"Enter authorization token to continue\\\" form on the client (v0.5.0+).\",\n  \"login.errors.unknown\": \"Errors Unknown\",\n  \"login.forget_password.description\": \"Enter the email address associated with your account and we'll send you an email about how to reset your password.\",\n  \"login.forget_password.email_invalid\": \"Invalid email\",\n  \"login.forget_password.email_required\": \"Email required\",\n  \"login.forget_password.label\": \"Forget Password\",\n  \"login.forget_password.note\": \"Forgot your password?\",\n  \"login.forget_password.success\": \"Email has been sent successfully\",\n  \"login.have_account\": \"Already have an account? <strong>Sign in</strong>\",\n  \"login.lastUsed\": \"Last used\",\n  \"login.logInTo\": \"Sign in to\",\n  \"login.logInWithEmail\": \"Sign in with email\",\n  \"login.new_password.label\": \"New Password\",\n  \"login.no_account\": \"Don't have an account? <strong>Sign up</strong>\",\n  \"login.no_client\": \"No client detected, you can <weblink>continue using the Web App</weblink>.\",\n  \"login.openApp\": \"Open App\",\n  \"login.or\": \"Or\",\n  \"login.password\": \"Password\",\n  \"login.redirecting\": \"Redirecting\",\n  \"login.register\": \"Create one\",\n  \"login.reset_password.description\": \"Enter new password and confirm it to reset your password.\",\n  \"login.reset_password.label\": \"Reset Password\",\n  \"login.reset_password.success\": \"Password has been successfully reset\",\n  \"login.signOut\": \"Sign out\",\n  \"login.signUp\": \"Sign up with email\",\n  \"login.signUpTo\": \"Sign up to\",\n  \"login.submit\": \"Submit\",\n  \"login.two_factor.code\": \"Two Factor Code\",\n  \"login.two_factor.verify\": \"Complete Verification\",\n  \"login.welcomeTo\": \"Welcome to \",\n  \"redirect.continueInBrowser\": \"Continue in Browser\",\n  \"redirect.instruction\": \"Now is the time to open {{app_name}} and safely close this page.\",\n  \"redirect.openApp\": \"Open {{app_name}}\",\n  \"redirect.successMessage\": \"You have successfully connected to {{app_name}} Account.\",\n  \"register.confirm_password\": \"Confirm Password\",\n  \"register.email\": \"Email\",\n  \"register.label\": \"Sign up to {{app_name}}\",\n  \"register.login\": \"Sign in\",\n  \"register.password\": \"Password\",\n  \"register.referral.days\": \"Sign up with this referral code to get {{days}} days of Pro Preview\",\n  \"register.referral.description\": \"Sign up with referral code to get extra days of Pro Preview.\",\n  \"register.referral.invalid\": \"Invalid referral code\",\n  \"register.referral.label\": \"Referral Code\",\n  \"register.submit\": \"Create account\",\n  \"words.email\": \"Email\"\n}\n"
  },
  {
    "path": "locales/external/fr-FR.json",
    "content": "{\n  \"copied_link\": \"Lien copié dans le presse-papiers\",\n  \"feed.actions.follow\": \"S'abonner\",\n  \"feed.actions.followed\": \"Abonné\",\n  \"feed.actions.open\": \"Ouvrir dans {{which}}\",\n  \"feed.copy_feed_url\": \"Copier l'URL du flux\",\n  \"feed.entry_week_one\": \"{{count}} entrée/semaine\",\n  \"feed.entry_week_other\": \"{{count}} entrées/semaine\",\n  \"feed.feeds_one\": \"flux\",\n  \"feed.feeds_other\": \"flux\",\n  \"feed.follow_to_view_all\": \"Abonnez-vous pour voir tous les {{count}} flux...\",\n  \"feed.follower_one\": \"abonné\",\n  \"feed.follower_other\": \"abonnés\",\n  \"feed.followsAndFeeds\": \"{{subscriptionCount}} {{subscriptionNoun}} et {{feedsCount}} {{feedsNoun}} sur {{appName}}\",\n  \"feed.madeby\": \"Fait par\",\n  \"feed.preview\": \"Aperçu\",\n  \"feed.read_one\": \"lu\",\n  \"feed.read_other\": \"lus\",\n  \"feed.updated_at\": \"Mis à jour\",\n  \"feed.view_feed_url\": \"Voir l'URL du flux\",\n  \"feed_item.claimed_by_owner\": \"Ce flux est revendiqué par\",\n  \"feed_item.claimed_by_unknown\": \"son propriétaire.\",\n  \"feed_item.claimed_by_you\": \"Revendiqué par vous\",\n  \"feed_item.claimed_feed\": \"Flux revendiqué\",\n  \"feed_item.claimed_list\": \"Liste revendiquée\",\n  \"feed_item.error_since\": \"Erreur depuis\",\n  \"feed_item.not_publicly_visible\": \"Non visible publiquement sur votre page de profil\",\n  \"header.app\": \"App\",\n  \"header.download\": \"Télécharger\",\n  \"invitation.activate\": \"Activer\",\n  \"invitation.codeOptions.1\": \"Cherchez un utilisateur alpha test pour vous inviter.\",\n  \"invitation.codeOptions.2\": \"Rejoignez notre serveur Discord pour des giveaways occasionnels.\",\n  \"invitation.codeOptions.3\": \"Suivez notre compte X pour des giveaways occasionnels.\",\n  \"invitation.earlyAccess\": \"Folo nécessite actuellement un code d'invitation.\",\n  \"invitation.earlyAccessMessage\": \"😰 Désolé, Folo nécessite actuellement un code d'invitation pour être utilisé.\",\n  \"invitation.generateButton\": \"Générer un nouveau code\",\n  \"invitation.generateCost\": \"Vous pouvez dépenser {{INVITATION_PRICE}} Power pour générer un code d'invitation pour vos amis.\",\n  \"invitation.getCodeMessage\": \"Vous pouvez obtenir un code d'invitation des manières suivantes :\",\n  \"invitation.title\": \"Code d'invitation\",\n  \"login.backToWebApp\": \"Retour à l'application Web\",\n  \"login.confirm_password.label\": \"Confirmer le mot de passe\",\n  \"login.continueWith\": \"Continuer avec {{provider}}\",\n  \"login.email\": \"E-mail\",\n  \"login.enter_token\": \"Si vous avez installé le client mais n'êtes pas redirigé, copiez le jeton ci-dessous et collez-le dans le formulaire \\\"Entrez le jeton d'autorisation pour continuer\\\" sur le client (v0.5.0+).\",\n  \"login.errors.unknown\": \"Erreurs inconnues\",\n  \"login.forget_password.description\": \"Entrez l'adresse e-mail associée à votre compte et nous vous enverrons un e-mail expliquant comment réinitialiser votre mot de passe.\",\n  \"login.forget_password.email_invalid\": \"E-mail invalide\",\n  \"login.forget_password.email_required\": \"E-mail requis\",\n  \"login.forget_password.label\": \"Mot de passe oublié\",\n  \"login.forget_password.note\": \"Mot de passe oublié ?\",\n  \"login.forget_password.success\": \"L'e-mail a été envoyé avec succès\",\n  \"login.have_account\": \"Vous avez déjà un compte ? <strong>Se connecter</strong>\",\n  \"login.lastUsed\": \"Dernière utilisation\",\n  \"login.logInTo\": \"Se connecter à\",\n  \"login.logInWithEmail\": \"Se connecter avec un e-mail\",\n  \"login.new_password.label\": \"Nouveau mot de passe\",\n  \"login.no_account\": \"Vous n'avez pas de compte ? <strong>S'inscrire</strong>\",\n  \"login.no_client\": \"Aucun client détecté, vous pouvez <weblink>continuer à utiliser l'application Web</weblink>.\",\n  \"login.openApp\": \"Ouvrir l'application\",\n  \"login.or\": \"Ou\",\n  \"login.password\": \"Mot de passe\",\n  \"login.redirecting\": \"Redirection\",\n  \"login.register\": \"En créer un\",\n  \"login.reset_password.description\": \"Entrez un nouveau mot de passe et confirmez-le pour réinitialiser votre mot de passe.\",\n  \"login.reset_password.label\": \"Réinitialiser le mot de passe\",\n  \"login.reset_password.success\": \"Le mot de passe a été réinitialisé avec succès\",\n  \"login.signOut\": \"Se déconnecter\",\n  \"login.signUp\": \"S'inscrire avec un e-mail\",\n  \"login.signUpTo\": \"S'inscrire à\",\n  \"login.submit\": \"Soumettre\",\n  \"login.two_factor.code\": \"Code d'authentification à deux facteurs\",\n  \"login.two_factor.verify\": \"Terminer la vérification\",\n  \"login.welcomeTo\": \"Bienvenue sur \",\n  \"redirect.continueInBrowser\": \"Continuer dans le navigateur\",\n  \"redirect.instruction\": \"Il est maintenant temps d'ouvrir {{app_name}} et de fermer cette page en toute sécurité.\",\n  \"redirect.openApp\": \"Ouvrir {{app_name}}\",\n  \"redirect.successMessage\": \"Vous vous êtes connecté avec succès au compte {{app_name}}.\",\n  \"register.confirm_password\": \"Confirmer le mot de passe\",\n  \"register.email\": \"E-mail\",\n  \"register.label\": \"S'inscrire à {{app_name}}\",\n  \"register.login\": \"Se connecter\",\n  \"register.password\": \"Mot de passe\",\n  \"register.referral.days\": \"Inscrivez-vous avec ce code de parrainage pour obtenir {{days}} jours d'essai Pro\",\n  \"register.referral.description\": \"Inscrivez-vous avec un code de parrainage pour obtenir des jours supplémentaires d'essai Pro.\",\n  \"register.referral.invalid\": \"Code de parrainage invalide\",\n  \"register.referral.label\": \"Code de parrainage\",\n  \"register.submit\": \"Créer un compte\",\n  \"words.email\": \"E-mail\"\n}\n"
  },
  {
    "path": "locales/external/ja.json",
    "content": "{\n  \"copied_link\": \"リンクをクリップボードにコピーしました\",\n  \"feed.actions.follow\": \"フォロー\",\n  \"feed.actions.followed\": \"フォロー済み\",\n  \"feed.actions.open\": \"{{which}} で開く\",\n  \"feed.copy_feed_url\": \"フィードの URL をコピー\",\n  \"feed.entry_week_one\": \"{{count}} エントリー数/週\",\n  \"feed.entry_week_other\": \"{{count}} エントリー数/週\",\n  \"feed.feeds_one\": \"フィード\",\n  \"feed.feeds_other\": \"フィード\",\n  \"feed.follow_to_view_all\": \" {{count}} 件のフィードをすべてフォローします...\",\n  \"feed.follower_one\": \"フォロワー\",\n  \"feed.follower_other\": \"フォロワー\",\n  \"feed.followsAndFeeds\": \"{{subscriptionCount}} {{subscriptionNoun}} 、 {{feedsCount}} {{feedsNoun}} on {{appName}}\",\n  \"feed.madeby\": \"作成者\",\n  \"feed.preview\": \"プレビュー\",\n  \"feed.read_one\": \"読む\",\n  \"feed.read_other\": \"読む\",\n  \"feed.updated_at\": \"更新日時\",\n  \"feed.view_feed_url\": \"フィードの URL を表示\",\n  \"feed_item.claimed_by_owner\": \"このフィードのクレーム者\",\n  \"feed_item.claimed_by_unknown\": \"所有者によるクレーム\",\n  \"feed_item.claimed_by_you\": \"あなたのクレーム\",\n  \"feed_item.claimed_feed\": \"フィードをクレーム\",\n  \"feed_item.claimed_list\": \"リストをクレーム\",\n  \"feed_item.error_since\": \"エラー\",\n  \"feed_item.not_publicly_visible\": \"あなたのプロファイルページでは非公開です\",\n  \"header.app\": \"アプリ\",\n  \"header.download\": \"ダウンロード\",\n  \"invitation.activate\": \"有効化\",\n  \"invitation.codeOptions.1\": \"招待してくれるアルファテストユーザーを探しましょう。\",\n  \"invitation.codeOptions.2\": \"時折行われるプレゼント企画のために、私たちの Discord サーバーに参加しましょう。\",\n  \"invitation.codeOptions.3\": \"時折行われるプレゼント企画のために、私たちの X アカウントをフォローしましょう。\",\n  \"invitation.earlyAccess\": \"現在、Folo はアーリーアクセス中で、利用には招待コードが必要です。\",\n  \"invitation.earlyAccessMessage\": \"😰 申し訳ありませんが、Folo は現在アーリーアクセス中で、招待コードが必要です。\",\n  \"invitation.generateButton\": \"新しいコードを生成\",\n  \"invitation.generateCost\": \"友達のために招待コードを生成するには、{{INVITATION_PRICE}} Power を消費できます。\",\n  \"invitation.getCodeMessage\": \"以下の方法で招待コードを入手できます：\",\n  \"invitation.title\": \"招待コード\",\n  \"login.backToWebApp\": \"ウェブアプリに戻る\",\n  \"login.confirm_password.label\": \"パスワードの確認\",\n  \"login.continueWith\": \"{{provider}} で続ける\",\n  \"login.email\": \"Email\",\n  \"login.enter_token\": \"自動的にリダイレクトされない場合は、以下のトークンをコピーして、デスクトップアプリ (v0.5.0+) の「認証トークンを入力して続行」フォームに貼り付けてください。\",\n  \"login.errors.unknown\": \"不明なエラー\",\n  \"login.forget_password.description\": \"あなたのアカウントに関連付けられたメールを入力してください。パスワードのリセット方法を記したメールを送信します。\",\n  \"login.forget_password.email_invalid\": \"無効なメールアドレス\",\n  \"login.forget_password.email_required\": \"メールアドレスは必須です\",\n  \"login.forget_password.label\": \"パスワードを忘れた場合\",\n  \"login.forget_password.note\": \"パスワードを忘れましたか？\",\n  \"login.forget_password.success\": \"メールの送信に成功しました。\",\n  \"login.have_account\": \"すでにアカウントをお持ちですか？ <strong>サインイン</strong>\",\n  \"login.lastUsed\": \"前回使用\",\n  \"login.logInTo\": \"ログイン \",\n  \"login.logInWithEmail\": \"メールでログイン\",\n  \"login.new_password.label\": \"新しいパスワード\",\n  \"login.no_account\": \"アカウントをお持ちでないですか？ <strong>サインアップ</strong>\",\n  \"login.no_client\": \"クライアントが検出されませんでした。<weblink>Web アプリを使用し続ける</weblink>ことができます。\",\n  \"login.openApp\": \"アプリを開く\",\n  \"login.or\": \"または\",\n  \"login.password\": \"パスワード\",\n  \"login.redirecting\": \"リダイレクト中\",\n  \"login.register\": \"アカウントを作成\",\n  \"login.reset_password.description\": \"パスワードをリセットするには新しいパスワードを入力して確認を押してください。\",\n  \"login.reset_password.label\": \"パスワードをリセット\",\n  \"login.reset_password.success\": \"パスワードは正常にリセットされました\",\n  \"login.signOut\": \"サインアウト\",\n  \"login.signUp\": \"メールでサインアップ\",\n  \"login.signUpTo\": \"次でサインアップ\",\n  \"login.submit\": \"送信\",\n  \"login.two_factor.code\": \"2要素コード\",\n  \"login.two_factor.verify\": \"認証を完了\",\n  \"login.welcomeTo\": \"ようこそ \",\n  \"redirect.continueInBrowser\": \"ブラウザーで続行\",\n  \"redirect.instruction\": \"今が{{app_name}}を開き、このページを安全に閉じる時です。\",\n  \"redirect.openApp\": \"{{app_name}}を開く\",\n  \"redirect.successMessage\": \"{{app_name}}アカウントに正常に接続されました。\",\n  \"register.confirm_password\": \"パスワードを確認\",\n  \"register.email\": \"Email\",\n  \"register.label\": \"{{app_name}} のアカウントを作成する\",\n  \"register.login\": \"ログイン\",\n  \"register.password\": \"パスワード\",\n  \"register.referral.days\": \"この招待コードを使用して、{{days}} 日間の Pro Preview を取得します\",\n  \"register.referral.description\": \"招待コードを使用して、Pro preview の追加日数を取得します。\",\n  \"register.referral.invalid\": \"無効な招待コード\",\n  \"register.referral.label\": \"招待コード\",\n  \"register.submit\": \"アカウントを作成\",\n  \"words.email\": \"Email\"\n}\n"
  },
  {
    "path": "locales/external/zh-CN.json",
    "content": "{\n  \"copied_link\": \"链接已复制到剪贴板\",\n  \"feed.actions.follow\": \"订阅\",\n  \"feed.actions.followed\": \"已订阅\",\n  \"feed.actions.open\": \"在 {{which}} 中打开\",\n  \"feed.copy_feed_url\": \"复制链接\",\n  \"feed.entry_week_one\": \"{{count}} 条目/周\",\n  \"feed.entry_week_other\": \"{{count}} 条目/周\",\n  \"feed.feeds_one\": \"订阅源\",\n  \"feed.feeds_other\": \"订阅源\",\n  \"feed.follow_to_view_all\": \"订阅以查看所有的 {{count}} 个订阅源…\",\n  \"feed.follower_one\": \"订阅者\",\n  \"feed.follower_other\": \"订阅者\",\n  \"feed.followsAndFeeds\": \"在 {{appName}} 上有 {{subscriptionCount}} 个{{subscriptionNoun}}和 {{feedsCount}} 个{{feedsNoun}}\",\n  \"feed.madeby\": \"创建者\",\n  \"feed.preview\": \"预览\",\n  \"feed.read_one\": \"阅读\",\n  \"feed.read_other\": \"阅读\",\n  \"feed.updated_at\": \"更新于\",\n  \"feed.view_feed_url\": \"查看链接\",\n  \"feed_item.claimed_by_owner\": \"订阅源所有者\",\n  \"feed_item.claimed_by_unknown\": \"未知所有者\",\n  \"feed_item.claimed_by_you\": \"订阅源由你提供\",\n  \"feed_item.claimed_feed\": \"已认证源\",\n  \"feed_item.claimed_list\": \"已认证列表\",\n  \"feed_item.error_since\": \"源失效：\",\n  \"feed_item.not_publicly_visible\": \"在个人资料页面上隐藏\",\n  \"header.app\": \"应用\",\n  \"header.download\": \"下载\",\n  \"invitation.activate\": \"激活\",\n  \"invitation.codeOptions.1\": \"寻找内测用户帮你生成邀请码。\",\n  \"invitation.codeOptions.2\": \"加入官方 Discord 频道，不定期发放邀请码。\",\n  \"invitation.codeOptions.3\": \"关注官方 X 账号动态，不定期发放邀请码。\",\n  \"invitation.earlyAccess\": \"Folo 目前处于早期体验阶段，需要邀请码才能使用。\",\n  \"invitation.earlyAccessMessage\": \"😰 抱歉，Folo 目前处于早期体验阶段，需要邀请码才能使用。\",\n  \"invitation.generateButton\": \"生成邀请码\",\n  \"invitation.generateCost\": \"花费 {{INVITATION_PRICE}} Power 生成一个邀请码给你的朋友。\",\n  \"invitation.getCodeMessage\": \"通过以下方式获取：\",\n  \"invitation.title\": \"邀请码\",\n  \"login.backToWebApp\": \"返回网页版\",\n  \"login.confirm_password.label\": \"确认密码\",\n  \"login.continueWith\": \"使用 {{provider}} 登录\",\n  \"login.email\": \"邮件地址\",\n  \"login.enter_token\": \"如果你已经安装了客户端但未自动重定向，请复制下方令牌并粘贴到客户端（v0.5.0 以上）的“输入授权令牌以继续”表单中。\",\n  \"login.errors.unknown\": \"未知错误\",\n  \"login.forget_password.description\": \"请输入与你的帐户关联的邮件地址，我们将向你发送一封关于如何重置密码的邮件。\",\n  \"login.forget_password.email_invalid\": \"无效的邮箱地址\",\n  \"login.forget_password.email_required\": \"请输入邮箱\",\n  \"login.forget_password.label\": \"忘记密码\",\n  \"login.forget_password.note\": \"忘记了密码？\",\n  \"login.forget_password.success\": \"邮件已成功发送。\",\n  \"login.have_account\": \"已有账户？<strong>登录</strong>\",\n  \"login.lastUsed\": \"上次使用\",\n  \"login.logInTo\": \"登录 \",\n  \"login.logInWithEmail\": \"使用邮件地址登录\",\n  \"login.new_password.label\": \"新密码\",\n  \"login.no_account\": \"没有账户？<strong>注册</strong>\",\n  \"login.no_client\": \"未检测到客户端，你可以<weblink>在 Web App 中继续</weblink>。\",\n  \"login.openApp\": \"打开应用\",\n  \"login.or\": \"或\",\n  \"login.password\": \"密码\",\n  \"login.redirecting\": \"正在重定向\",\n  \"login.register\": \"创建账户\",\n  \"login.reset_password.description\": \"请输入新密码并确认以重置你的密码。\",\n  \"login.reset_password.label\": \"重置密码\",\n  \"login.reset_password.success\": \"密码已成功重置。\",\n  \"login.signOut\": \"登出\",\n  \"login.signUp\": \"使用邮件地址注册\",\n  \"login.signUpTo\": \"注册\",\n  \"login.submit\": \"提交\",\n  \"login.two_factor.code\": \"双重身份验证码\",\n  \"login.two_factor.verify\": \"完成验证\",\n  \"login.welcomeTo\": \"欢迎来到 \",\n  \"redirect.continueInBrowser\": \"在浏览器中继续\",\n  \"redirect.instruction\": \"现在可以打开 {{app_name}} 并关闭此页面。\",\n  \"redirect.openApp\": \"打开 {{app_name}}\",\n  \"redirect.successMessage\": \"已成功连接到 {{app_name}} 账户。\",\n  \"register.confirm_password\": \"确认密码\",\n  \"register.email\": \"邮件地址\",\n  \"register.label\": \"创建 {{app_name}} 账户\",\n  \"register.login\": \"登录\",\n  \"register.password\": \"密码\",\n  \"register.referral.days\": \"使用此推荐码注册可获得 {{days}} 天 Pro 预览\",\n  \"register.referral.description\": \"使用推荐码注册可获得额外的 Pro 预览天数。\",\n  \"register.referral.invalid\": \"无效的推荐码\",\n  \"register.referral.label\": \"推荐码\",\n  \"register.submit\": \"创建账户\",\n  \"words.email\": \"邮件地址\"\n}\n"
  },
  {
    "path": "locales/external/zh-TW.json",
    "content": "{\n  \"copied_link\": \"連結已複製到剪貼簿\",\n  \"feed.actions.follow\": \"跟隨\",\n  \"feed.actions.followed\": \"已跟隨\",\n  \"feed.actions.open\": \"在 {{which}} 中打開\",\n  \"feed.copy_feed_url\": \"複製鏈接\",\n  \"feed.entry_week_one\": \"{{count}} 條目/周\",\n  \"feed.entry_week_other\": \"{{count}} 條目/周\",\n  \"feed.feeds_one\": \"RSS 摘要\",\n  \"feed.feeds_other\": \"RSS 摘要\",\n  \"feed.follow_to_view_all\": \"跟隨以查看所有的 {{count}} 個 RSS 摘要...\",\n  \"feed.follower_one\": \"跟隨者\",\n  \"feed.follower_other\": \"跟隨者\",\n  \"feed.followsAndFeeds\": \"在 {{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{feedsCount}} 個 {{feedsNoun}}\",\n  \"feed.madeby\": \"作者：\",\n  \"feed.preview\": \"預覽\",\n  \"feed.read_one\": \"閱讀\",\n  \"feed.read_other\": \"閱讀\",\n  \"feed.updated_at\": \"更新於\",\n  \"feed.view_feed_url\": \"查看鏈接\",\n  \"feed_item.claimed_by_owner\": \"RSS 摘要作者\",\n  \"feed_item.claimed_by_unknown\": \"未知作者\",\n  \"feed_item.claimed_by_you\": \"RSS 摘要由你認領\",\n  \"feed_item.claimed_feed\": \"已認領 RSS 摘要\",\n  \"feed_item.claimed_list\": \"已認領列表\",\n  \"feed_item.error_since\": \"RSS 摘要失效：\",\n  \"feed_item.not_publicly_visible\": \"在您的個人頁面上隱藏\",\n  \"header.app\": \"應用程式\",\n  \"header.download\": \"下載\",\n  \"invitation.activate\": \"啟用\",\n  \"invitation.codeOptions.1\": \"尋找任何內部測試使用者邀請您。\",\n  \"invitation.codeOptions.2\": \"加入我們的 Discord 伺服器，不定期贈送邀請碼。\",\n  \"invitation.codeOptions.3\": \"關注我們的 X 帳號，不定期贈送邀請碼。\",\n  \"invitation.earlyAccess\": \"Folo 目前處於搶先體驗階段，需要邀請碼才能使用。\",\n  \"invitation.earlyAccessMessage\": \"😰 抱歉，Folo 目前處於搶先體驗階段，需要邀請碼才能使用。\",\n  \"invitation.generateButton\": \"產生新邀請碼\",\n  \"invitation.generateCost\": \"您可以花費 {{INVITATION_PRICE}} Power 為您的朋友產生邀請碼。\",\n  \"invitation.getCodeMessage\": \"您可以通過以下方式獲取邀請碼：\",\n  \"invitation.title\": \"邀請碼\",\n  \"login.backToWebApp\": \"返回網頁應用程式\",\n  \"login.confirm_password.label\": \"確認密碼\",\n  \"login.continueWith\": \"透過 {{provider}} 登入\",\n  \"login.email\": \"電子信箱\",\n  \"login.enter_token\": \"如果你已安裝客戶端但未自動跳轉，請複製下方 token 並貼到客戶端（v0.5.0+）的「請輸入授權令牌以繼續」欄位。\",\n  \"login.errors.unknown\": \"未知錯誤\",\n  \"login.forget_password.description\": \"輸入與您的帳號關聯的電子信箱，我們將向您發送有關如何重設密碼的電子郵件。\",\n  \"login.forget_password.email_invalid\": \"無效的電子信箱地址\",\n  \"login.forget_password.email_required\": \"請輸入電子信箱\",\n  \"login.forget_password.label\": \"忘記密碼\",\n  \"login.forget_password.note\": \"忘記密碼了嗎？\",\n  \"login.forget_password.success\": \"信件已成功發送\",\n  \"login.have_account\": \"已有帳號？<strong>登入</strong>\",\n  \"login.lastUsed\": \"最近使用\",\n  \"login.logInTo\": \"登入至 \",\n  \"login.logInWithEmail\": \"使用電子信箱登入\",\n  \"login.new_password.label\": \"新密碼\",\n  \"login.no_account\": \"沒有帳號？<strong>註冊</strong>\",\n  \"login.no_client\": \"未偵測到客戶端，你可以<weblink>繼續使用網頁版</weblink>。\",\n  \"login.openApp\": \"開啟應用程式\",\n  \"login.or\": \"或\",\n  \"login.password\": \"密碼\",\n  \"login.redirecting\": \"正在重定向\",\n  \"login.register\": \"創建帳號\",\n  \"login.reset_password.description\": \"輸入新密碼並確認以重設您的密碼\",\n  \"login.reset_password.label\": \"重設密碼\",\n  \"login.reset_password.success\": \"密碼已成功重設\",\n  \"login.signOut\": \"登出\",\n  \"login.signUp\": \"使用電子信箱註冊\",\n  \"login.signUpTo\": \"註冊\",\n  \"login.submit\": \"送出\",\n  \"login.two_factor.code\": \"雙因素驗證碼\",\n  \"login.two_factor.verify\": \"完成驗證\",\n  \"login.welcomeTo\": \"歡迎來到 \",\n  \"redirect.continueInBrowser\": \"在瀏覽器中繼續\",\n  \"redirect.instruction\": \"現在是時候打開 {{app_name}} 並安全地關閉此頁面。\",\n  \"redirect.openApp\": \"開啟 {{app_name}}\",\n  \"redirect.successMessage\": \"您已成功連接至 {{app_name}} 帳戶。\",\n  \"register.confirm_password\": \"確認密碼\",\n  \"register.email\": \"電子信箱\",\n  \"register.label\": \"創建 {{app_name}} 帳號\",\n  \"register.login\": \"登入\",\n  \"register.password\": \"密碼\",\n  \"register.referral.days\": \"使用此推薦碼註冊可獲得 {{days}} 天專業版預覽\",\n  \"register.referral.description\": \"使用推薦碼註冊可獲得幾日專業版預覽。\",\n  \"register.referral.invalid\": \"無效的推薦碼\",\n  \"register.referral.label\": \"推薦碼\",\n  \"register.submit\": \"創建帳號\",\n  \"words.email\": \"電子信箱\"\n}\n"
  },
  {
    "path": "locales/lang/en.json",
    "content": "{\n  \"name\": \"English\"\n}\n"
  },
  {
    "path": "locales/lang/fr-FR.json",
    "content": "{\n  \"name\": \"Français (France)\"\n}\n"
  },
  {
    "path": "locales/lang/ja.json",
    "content": "{\n  \"name\": \"日本語\"\n}\n"
  },
  {
    "path": "locales/lang/zh-CN.json",
    "content": "{\n  \"name\": \"简体中文\"\n}\n"
  },
  {
    "path": "locales/lang/zh-TW.json",
    "content": "{\n  \"name\": \"繁體中文\"\n}\n"
  },
  {
    "path": "locales/mobile/default/en.json",
    "content": "{\n  \"ai.summary_generating\": \"Generating AI summary...\",\n  \"ai.summary_not_available\": \"Summary not available\",\n  \"ai.summary_upgrade_required_description\": \"Unlock unlimited AI summaries, translations, and more intelligent features with Pro plan.\",\n  \"ai.summary_upgrade_required_title\": \"Start free trial to continue using AI summary\",\n  \"ai.summary_upgrade_view_plans\": \"View Plans\",\n  \"entry.pull_up_to_next_entry\": \"Pull up to go to the next entry\",\n  \"entry.release_to_next_entry\": \"Release to go to the next entry\",\n  \"entry_actions.toggle_ai_summary\": \"AI Summary\",\n  \"entry_content.ai_summary\": \"AI Summary\",\n  \"entry_content.no_content\": \"No media available\",\n  \"entry_content.no_video_url\": \"No video URL found\",\n  \"entry_list.zero_unread\": \"Zero Unread\",\n  \"feed.follow.success\": \"Feed followed.\",\n  \"feed.follow.update_success\": \"Feed updated.\",\n  \"feed.not_found\": \"Feed ({{id}}) not found.\",\n  \"feed.unfollow.confirm_description\": \"This will remove the feed from your subscriptions.\",\n  \"feed.unfollow.confirm_title\": \"Unsubscribe?\",\n  \"feed.unfollow.success\": \"Feed unfollowed.\",\n  \"login.agree_to\": \"By continuing, you agree to our\",\n  \"login.back\": \"Back\",\n  \"login.confirm_password.label\": \"Confirm Password\",\n  \"login.continueWith\": \"Continue with {{provider}}\",\n  \"login.email\": \"Email\",\n  \"login.forget_password.note\": \"Forgot your password?\",\n  \"login.forgot_password.continue\": \"Continue\",\n  \"login.forgot_password.description\": \"Enter the email address associated with your account to continue.\",\n  \"login.forgot_password.success\": \"We sent you an email with instructions to reset your password.\",\n  \"login.forgot_password.title\": \"Forgot password?\",\n  \"login.have_account\": \"Already have an account? <strong>Sign in</strong>\",\n  \"login.invalid_email_or_password\": \"Invalid email or password\",\n  \"login.no_account\": \"Don't have an account? <strong>Sign up</strong>\",\n  \"login.password\": \"Password\",\n  \"login.passwords_do_not_match\": \"Passwords don't match\",\n  \"login.privacy\": \"Privacy Policy\",\n  \"login.sign_up_successful\": \"Sign up successful\",\n  \"login.submit\": \"Submit\",\n  \"login.terms\": \"Terms of Service\",\n  \"onboarding.edit_profile\": \"Edit Profile\",\n  \"onboarding.edit_profile_description\": \"Change your name, email, and profile picture\",\n  \"onboarding.finished_description\": \"You have completed the guide. Enjoy your journey!\",\n  \"onboarding.finished_title\": \"You're all set!\",\n  \"onboarding.import_content\": \"Import Your Content\",\n  \"onboarding.import_description\": \"If you have used RSS before, you can import an OPML file\",\n  \"onboarding.interests_description\": \"Subscribe to feeds that match your interests.\",\n  \"onboarding.interests_title\": \"Discover Interests\",\n  \"onboarding.language_description\": \"Choose the language you want to use in Folo\",\n  \"onboarding.preferences_description\": \"Set your preferences to make Folo work best for you. You can always change these later in Settings.\",\n  \"onboarding.preferences_title\": \"Personalize Your Experience\",\n  \"onboarding.reading_balanced\": \"Balanced: $t(onboarding.reading_balanced_description)\",\n  \"onboarding.reading_balanced_description\": \"Automatically mark entries as read when scrolled out of view\",\n  \"onboarding.reading_conservative\": \"Conservative: $t(onboarding.reading_conservative_description)\",\n  \"onboarding.reading_conservative_description\": \"Mark entries as read only when clicked\",\n  \"onboarding.reading_preferences\": \"Reading Preferences\",\n  \"onboarding.reading_radical\": \"Radical: $t(onboarding.reading_radical_description)\",\n  \"onboarding.reading_radical_description\": \"Automatically mark entries as read when displayed\",\n  \"onboarding.shuffle\": \"Shuffle\",\n  \"onboarding.suggestions_feed\": \"Suggestions feed\",\n  \"onboarding.welcome_guide\": \"This guide will help you get started with the app.\",\n  \"onboarding.welcome_title\": \"Welcome to Folo!\",\n  \"operation.add_feeds_to_category\": \"Move to Category\",\n  \"operation.change_to_other_view\": \"Switch to Another View\",\n  \"operation.copy.email_address\": \"Email Address\",\n  \"operation.copy.link\": \"Link\",\n  \"operation.copy_which\": \"Copy {{which}}\",\n  \"operation.copy_which_success\": \"{{which}} Copied to Clipboard\",\n  \"operation.delete_category\": \"Delete Category\",\n  \"operation.delete_category_confirm\": \"This operation will delete your category, but the feeds it contains will be retained and grouped by website.\",\n  \"operation.delete_category_which\": \"Delete Category {{category}}\",\n  \"operation.edit\": \"Edit\",\n  \"operation.enter_new_name_for_category\": \"Enter new name for {{category}}\",\n  \"operation.error_since\": \"Error since\",\n  \"operation.mark_all_as_read\": \"Mark all as read\",\n  \"operation.mark_all_as_read_confirm\": \"Confirm mark all as read?\",\n  \"operation.mark_all_as_read_which\": \"Mark {{which}} as Read\",\n  \"operation.mark_all_as_read_which_above\": \"Above\",\n  \"operation.mark_all_as_read_which_below\": \"Below\",\n  \"operation.mark_as_read\": \"Mark as Read\",\n  \"operation.mark_as_unread\": \"Mark as Unread\",\n  \"operation.open_link\": \"Open Link\",\n  \"operation.rename_category\": \"Rename Category\",\n  \"operation.share\": \"Share\",\n  \"operation.show_error_message\": \"Show Error Message\",\n  \"operation.star\": \"Star\",\n  \"operation.toggle_unread_only.show_all.label\": \"Show All\",\n  \"operation.toggle_unread_only.show_all.success\": \"Showing all entries\",\n  \"operation.toggle_unread_only.show_unread_only.label\": \"Show Unread Only\",\n  \"operation.toggle_unread_only.show_unread_only.success\": \"Showing unread entries\",\n  \"operation.unfollow\": \"Unfollow\",\n  \"operation.unstar\": \"Unstar\",\n  \"profile.title\": \"{{name}}'s Profile\",\n  \"profile.uncategorized_feeds\": \"Uncategorized feeds\",\n  \"settings.sign_in_cta\": \"Sign in to your account\",\n  \"signin.sign_in_to\": \"Sign in to\",\n  \"signin.sign_up_to\": \"Sign up to\",\n  \"subscription_form.category\": \"Category\",\n  \"subscription_form.category_description\": \"By default, your follows will be grouped by website.\",\n  \"subscription_form.hide_from_timeline\": \"Hide from Timeline\",\n  \"subscription_form.hide_from_timeline_description\": \"Whether this subscription's entries are visible on your main view timeline.\",\n  \"subscription_form.private_follow\": \"Private Follow\",\n  \"subscription_form.private_follow_description\": \"Whether this follow is publicly visible on your profile page.\",\n  \"subscription_form.title\": \"Title\",\n  \"subscription_form.title_description\": \"Custom title for this Feed. Leave empty to use the default.\",\n  \"subscription_form.view\": \"View\",\n  \"tabs.discover\": \"Discover\",\n  \"tabs.home\": \"Home\",\n  \"tabs.settings\": \"Settings\",\n  \"tabs.subscriptions\": \"Subscriptions\"\n}\n"
  },
  {
    "path": "locales/mobile/default/fr-FR.json",
    "content": "{\n  \"ai.summary_generating\": \"Génération du résumé IA...\",\n  \"ai.summary_not_available\": \"Résumé non disponible\",\n  \"ai.summary_upgrade_required_description\": \"Débloquez des résumés, des traductions et des fonctionnalités intelligentes illimités avec le plan Pro.\",\n  \"ai.summary_upgrade_required_title\": \"Commencez l'essai gratuit pour continuer à utiliser le résumé IA\",\n  \"ai.summary_upgrade_view_plans\": \"Voir les plans\",\n  \"entry.pull_up_to_next_entry\": \"Tirez vers le haut pour passer à l'entrée suivante\",\n  \"entry.release_to_next_entry\": \"Relâchez pour passer à l'entrée suivante\",\n  \"entry_actions.toggle_ai_summary\": \"Résumé IA\",\n  \"entry_content.ai_summary\": \"Résumé IA\",\n  \"entry_content.no_content\": \"Aucun média disponible\",\n  \"entry_content.no_video_url\": \"Aucune URL vidéo trouvée\",\n  \"entry_list.zero_unread\": \"Zéro non lu\",\n  \"login.agree_to\": \"En continuant, vous acceptez nos\",\n  \"login.back\": \"Retour\",\n  \"login.confirm_password.label\": \"Confirmer le mot de passe\",\n  \"login.continueWith\": \"Continuer avec {{provider}}\",\n  \"login.email\": \"Email\",\n  \"login.forget_password.note\": \"Mot de passe oublié ?\",\n  \"login.have_account\": \"Vous avez déjà un compte ? <strong>Se connecter</strong>\",\n  \"login.invalid_email_or_password\": \"Email ou mot de passe invalide\",\n  \"login.no_account\": \"Vous n'avez pas de compte ? <strong>S'inscrire</strong>\",\n  \"login.password\": \"Mot de passe\",\n  \"login.passwords_do_not_match\": \"Les mots de passe ne correspondent pas\",\n  \"login.privacy\": \"Politique de confidentialité\",\n  \"login.sign_up_successful\": \"Inscription réussie\",\n  \"login.submit\": \"Soumettre\",\n  \"login.terms\": \"Conditions d'utilisation\",\n  \"onboarding.edit_profile\": \"Modifier le profil\",\n  \"onboarding.edit_profile_description\": \"Modifiez votre nom, votre email et votre photo de profil\",\n  \"onboarding.finished_description\": \"Vous avez terminé le guide. Profitez de votre expérience !\",\n  \"onboarding.finished_title\": \"Tout est prêt !\",\n  \"onboarding.import_content\": \"Importer votre contenu\",\n  \"onboarding.import_description\": \"Si vous avez déjà utilisé RSS, vous pouvez importer un fichier OPML\",\n  \"onboarding.interests_description\": \"Abonnez-vous aux flux qui correspondent à vos intérêts.\",\n  \"onboarding.interests_title\": \"Découvrez vos intérêts\",\n  \"onboarding.language_description\": \"Choisissez la langue que vous souhaitez utiliser dans Folo\",\n  \"onboarding.preferences_description\": \"Définissez vos préférences pour que Folo fonctionne au mieux pour vous. Vous pourrez toujours les modifier plus tard dans les paramètres.\",\n  \"onboarding.preferences_title\": \"Personnalisez votre expérience\",\n  \"onboarding.reading_balanced\": \"Équilibré : $t(onboarding.reading_balanced_description)\",\n  \"onboarding.reading_balanced_description\": \"Marquer automatiquement les entrées comme lues lorsqu'elles ne sont plus visibles\",\n  \"onboarding.reading_conservative\": \"Conservateur : $t(onboarding.reading_conservative_description)\",\n  \"onboarding.reading_conservative_description\": \"Marquer les entrées comme lues uniquement lors d'un clic\",\n  \"onboarding.reading_preferences\": \"Préférences de lecture\",\n  \"onboarding.reading_radical\": \"Radical : $t(onboarding.reading_radical_description)\",\n  \"onboarding.reading_radical_description\": \"Marquer automatiquement les entrées comme lues lorsqu'elles sont affichées\",\n  \"onboarding.shuffle\": \"Mélanger\",\n  \"onboarding.suggestions_feed\": \"Flux de suggestions\",\n  \"onboarding.welcome_guide\": \"Ce guide vous aidera à démarrer avec l'application.\",\n  \"onboarding.welcome_title\": \"Bienvenue sur Folo !\",\n  \"operation.add_feeds_to_category\": \"Déplacer vers la catégorie\",\n  \"operation.change_to_other_view\": \"Passer à une autre vue\",\n  \"operation.copy.email_address\": \"Adresse e-mail\",\n  \"operation.copy.link\": \"Lien\",\n  \"operation.copy_which\": \"Copier {{which}}\",\n  \"operation.copy_which_success\": \"{{which}} copié dans le presse-papiers\",\n  \"operation.delete_category\": \"Supprimer la catégorie\",\n  \"operation.delete_category_confirm\": \"Cette opération supprimera votre catégorie, mais les flux qu'elle contient seront conservés et regroupés par site web.\",\n  \"operation.delete_category_which\": \"Supprimer la catégorie {{category}}\",\n  \"operation.edit\": \"Modifier\",\n  \"operation.enter_new_name_for_category\": \"Entrez un nouveau nom pour {{category}}\",\n  \"operation.error_since\": \"Erreur depuis\",\n  \"operation.mark_all_as_read\": \"Tout marquer comme lu\",\n  \"operation.mark_all_as_read_confirm\": \"Confirmer tout marquer comme lu ?\",\n  \"operation.mark_all_as_read_which\": \"Marquer {{which}} comme lu\",\n  \"operation.mark_all_as_read_which_above\": \"Au-dessus\",\n  \"operation.mark_all_as_read_which_below\": \"En dessous\",\n  \"operation.mark_as_read\": \"Marquer comme lu\",\n  \"operation.mark_as_unread\": \"Marquer comme non lu\",\n  \"operation.open_link\": \"Ouvrir le lien\",\n  \"operation.rename_category\": \"Renommer la catégorie\",\n  \"operation.share\": \"Partager\",\n  \"operation.show_error_message\": \"Afficher le message d'erreur\",\n  \"operation.star\": \"Favoris\",\n  \"operation.toggle_unread_only.show_all.label\": \"Tout afficher\",\n  \"operation.toggle_unread_only.show_all.success\": \"Affichage de toutes les entrées\",\n  \"operation.toggle_unread_only.show_unread_only.label\": \"Afficher uniquement les non lus\",\n  \"operation.toggle_unread_only.show_unread_only.success\": \"Affichage des entrées non lues\",\n  \"operation.unfollow\": \"Se désabonner\",\n  \"operation.unstar\": \"Retirer des favoris\",\n  \"profile.title\": \"Profil de {{name}}\",\n  \"profile.uncategorized_feeds\": \"Flux non classés\",\n  \"signin.sign_in_to\": \"Se connecter à\",\n  \"signin.sign_up_to\": \"S'inscrire à\",\n  \"subscription_form.category\": \"Catégorie\",\n  \"subscription_form.category_description\": \"Par défaut, vos abonnements seront regroupés par site web.\",\n  \"subscription_form.hide_from_timeline\": \"Masquer de la timeline\",\n  \"subscription_form.hide_from_timeline_description\": \"Si les entrées de cet abonnement sont visibles sur votre timeline principale.\",\n  \"subscription_form.private_follow\": \"Suivi privé\",\n  \"subscription_form.private_follow_description\": \"Si ce suivi est visible publiquement sur votre page de profil.\",\n  \"subscription_form.title\": \"Titre\",\n  \"subscription_form.title_description\": \"Titre personnalisé pour ce flux. Laissez vide pour utiliser celui par défaut.\",\n  \"subscription_form.view\": \"Vue\",\n  \"tabs.discover\": \"Découvrir\",\n  \"tabs.home\": \"Accueil\",\n  \"tabs.settings\": \"Paramètres\",\n  \"tabs.subscriptions\": \"Abonnements\"\n}\n"
  },
  {
    "path": "locales/mobile/default/ja.json",
    "content": "{\n  \"ai.summary_generating\": \"AI サマリーを生成中...\",\n  \"ai.summary_not_available\": \"サマリーは利用できません\",\n  \"ai.summary_upgrade_required_description\": \"Pro プランにアップグレードして、無制限の AI サマリー、翻訳、およびその他のインテリジェント機能をアンロックしましょう。\",\n  \"ai.summary_upgrade_required_title\": \"AI サマリーを引き続き使用するにはプランをアップグレードしてください\",\n  \"ai.summary_upgrade_view_plans\": \"プランを表示\",\n  \"entry.pull_up_to_next_entry\": \"引っ張って次のエントリーに移動\",\n  \"entry.release_to_next_entry\": \"離して次のエントリーに移動\",\n  \"entry_actions.toggle_ai_summary\": \"AI 要約に切り替え\",\n  \"entry_content.ai_summary\": \"AI 要約\",\n  \"entry_content.no_content\": \"コンテンツがありません\",\n  \"entry_content.no_video_url\": \"動画 URL が見つかりません\",\n  \"entry_list.zero_unread\": \"未読ゼロ\",\n  \"feed.follow.success\": \"フィードをフォローしました。\",\n  \"feed.follow.update_success\": \"フィードを更新しました。\",\n  \"feed.not_found\": \"フィード（{{id}}）が見つかりません。\",\n  \"feed.unfollow.confirm_description\": \"このフィードは購読一覧から削除されます。\",\n  \"feed.unfollow.confirm_title\": \"購読を解除しますか？\",\n  \"feed.unfollow.success\": \"フィードのフォローを解除しました。\",\n  \"login.agree_to\": \" 続行することで、あなたは私たちの\",\n  \"login.back\": \"戻る\",\n  \"login.confirm_password.label\": \"パスワードの確認\",\n  \"login.continueWith\": \"{{provider}} で続行\",\n  \"login.email\": \"Email\",\n  \"login.forget_password.note\": \"パスワードをお忘れですか？\",\n  \"login.forgot_password.continue\": \"続行\",\n  \"login.forgot_password.description\": \"続行するには、アカウントに紐づくメールアドレスを入力してください。\",\n  \"login.forgot_password.success\": \"パスワード再設定手順を記載したメールを送信しました。\",\n  \"login.forgot_password.title\": \"パスワードをお忘れですか？\",\n  \"login.have_account\": \"すでにアカウントをお持ちですか？ <strong>サインイン</strong>\",\n  \"login.invalid_email_or_password\": \"メールアドレスまたはパスワードが正しくありません\",\n  \"login.no_account\": \"アカウントをお持ちでないですか？ <strong>サインアップ</strong>\",\n  \"login.password\": \"パスワード\",\n  \"login.passwords_do_not_match\": \"パスワードが一致しません\",\n  \"login.privacy\": \"プライバシーポリシー\",\n  \"login.sign_up_successful\": \"サインアップに成功しました\",\n  \"login.submit\": \"ログイン\",\n  \"login.terms\": \"利用規約\",\n  \"onboarding.edit_profile\": \"プロフィールを編集\",\n  \"onboarding.edit_profile_description\": \"名前、メールアドレス、プロフィール写真を変更します\",\n  \"onboarding.finished_description\": \"ガイドを完了しました。旅をお楽しみください！\",\n  \"onboarding.finished_title\": \"すべて準備完了です！\",\n  \"onboarding.import_content\": \"コンテンツをインポート\",\n  \"onboarding.import_description\": \"以前にRSSを使用していた場合は、OPMLファイルをインポートできます\",\n  \"onboarding.interests_description\": \"興味に合ったフィードを購読します。\",\n  \"onboarding.interests_title\": \"興味を発見\",\n  \"onboarding.language_description\": \"Foloで使用する言語を選択してください\",\n  \"onboarding.preferences_description\": \"Foloを最適に動作させるために、設定を行います。これらは後で設定で変更できます。\",\n  \"onboarding.preferences_title\": \"体験をパーソナライズ\",\n  \"onboarding.reading_balanced\": \"バランス型: $t(onboarding.reading_balanced_description)\",\n  \"onboarding.reading_balanced_description\": \"表示されなくなったときにエントリーを自動的に既読にします\",\n  \"onboarding.reading_conservative\": \"保守型: $t(onboarding.reading_conservative_description)\",\n  \"onboarding.reading_conservative_description\": \"クリックされたときのみエントリーを既読にします\",\n  \"onboarding.reading_preferences\": \"読書の好み\",\n  \"onboarding.reading_radical\": \"ラディカル: $t(onboarding.reading_radical_description)\",\n  \"onboarding.reading_radical_description\": \"表示されたときにエントリーを自動的に既読にします\",\n  \"onboarding.shuffle\": \"シャッフル\",\n  \"onboarding.suggestions_feed\": \"おすすめフィード\",\n  \"onboarding.welcome_guide\": \"このガイドは、アプリの使い方をサポートします。\",\n  \"onboarding.welcome_title\": \"Foloへようこそ！\",\n  \"operation.add_feeds_to_category\": \"カテゴリに移動\",\n  \"operation.change_to_other_view\": \"別のビューに切り替え\",\n  \"operation.copy.email_address\": \"メールアドレス\",\n  \"operation.copy.link\": \"リンク\",\n  \"operation.copy_which\": \"コピー {{which}}\",\n  \"operation.copy_which_success\": \"{{which}} をクリップボードにコピーしました\",\n  \"operation.delete_category\": \"カテゴリを削除\",\n  \"operation.delete_category_confirm\": \"この操作により、カテゴリが削除されますが、含まれているフィードは保持され、ウェブサイトごとにグループ化されます。\",\n  \"operation.delete_category_which\": \"カテゴリ {{category}} を削除\",\n  \"operation.edit\": \"編集\",\n  \"operation.enter_new_name_for_category\": \"カテゴリ {{category}} の新しい名前を入力\",\n  \"operation.error_since\": \"エラー発生以来\",\n  \"operation.mark_all_as_read\": \"すべてを既読にマーク\",\n  \"operation.mark_all_as_read_confirm\": \"すべてを既読にマークしてもよろしいですか？\",\n  \"operation.mark_all_as_read_which\": \"{{which}} を既読にマーク\",\n  \"operation.mark_all_as_read_which_above\": \"上\",\n  \"operation.mark_all_as_read_which_below\": \"下\",\n  \"operation.mark_as_read\": \"既読にマーク\",\n  \"operation.mark_as_unread\": \"未読にマーク\",\n  \"operation.open_link\": \"リンクを開く\",\n  \"operation.rename_category\": \"カテゴリの名前を変更\",\n  \"operation.share\": \"共有\",\n  \"operation.show_error_message\": \"エラーメッセージを表示\",\n  \"operation.star\": \"スター\",\n  \"operation.toggle_unread_only.show_all.label\": \"すべて表示\",\n  \"operation.toggle_unread_only.show_all.success\": \"すべてのエントリーを表示中\",\n  \"operation.toggle_unread_only.show_unread_only.label\": \"未読のみ表示\",\n  \"operation.toggle_unread_only.show_unread_only.success\": \"未読のエントリーを表示中\",\n  \"operation.unfollow\": \"フォロー解除\",\n  \"operation.unstar\": \"スター解除\",\n  \"profile.title\": \"{{name}}のプロフィール\",\n  \"profile.uncategorized_feeds\": \"未分類のフィード\",\n  \"settings.sign_in_cta\": \"アカウントにサインイン\",\n  \"signin.sign_in_to\": \"サインインする\",\n  \"signin.sign_up_to\": \"サインアップする\",\n  \"subscription_form.category\": \"カテゴリ\",\n  \"subscription_form.category_description\": \"デフォルトでは、フォローはウェブサイトごとにグループ化されます。\",\n  \"subscription_form.hide_from_timeline\": \"タイムラインから非表示\",\n  \"subscription_form.hide_from_timeline_description\": \"このサブスクリプションのエントリーがメインビューのタイムラインに表示されるかどうか。\",\n  \"subscription_form.private_follow\": \"プライベートフォロー\",\n  \"subscription_form.private_follow_description\": \"このフォローがプロフィールページに公開されるかどうか。\",\n  \"subscription_form.title\": \"タイトル\",\n  \"subscription_form.title_description\": \"このフィードのカスタムタイトル。デフォルトを使用するには空白のままにします。\",\n  \"subscription_form.view\": \"ビュー\",\n  \"tabs.discover\": \"発見\",\n  \"tabs.home\": \"ホーム\",\n  \"tabs.settings\": \"設定\",\n  \"tabs.subscriptions\": \"サブスクリプション\"\n}\n"
  },
  {
    "path": "locales/mobile/default/zh-CN.json",
    "content": "{\n  \"ai.summary_generating\": \"正在生成 AI 摘要...\",\n  \"ai.summary_not_available\": \"摘要不可用\",\n  \"ai.summary_upgrade_required_description\": \"升级到 Pro 计划，解锁无限 AI 摘要、翻译和更多智能功能。\",\n  \"ai.summary_upgrade_required_title\": \"开启免费试用以继续使用 AI 摘要\",\n  \"ai.summary_upgrade_view_plans\": \"查看计划\",\n  \"entry.pull_up_to_next_entry\": \"继续上拉查看下一条内容\",\n  \"entry.release_to_next_entry\": \"松开查看下一条内容\",\n  \"entry_actions.toggle_ai_summary\": \"切换 AI 总结\",\n  \"entry_content.ai_summary\": \"AI 摘要\",\n  \"entry_content.no_content\": \"没有内容\",\n  \"entry_content.no_video_url\": \"未找到视频链接\",\n  \"entry_list.zero_unread\": \"全部已读\",\n  \"feed.follow.success\": \"已关注此订阅源。\",\n  \"feed.follow.update_success\": \"订阅源已更新。\",\n  \"feed.not_found\": \"未找到订阅源（{{id}}）。\",\n  \"feed.unfollow.confirm_description\": \"此操作会将该订阅源从你的订阅列表中移除。\",\n  \"feed.unfollow.confirm_title\": \"取消订阅？\",\n  \"feed.unfollow.success\": \"已取消订阅该订阅源。\",\n  \"login.agree_to\": \"继续即表示您同意我们的\",\n  \"login.back\": \"返回\",\n  \"login.confirm_password.label\": \"确认密码\",\n  \"login.continueWith\": \"使用 {{provider}} 继续\",\n  \"login.email\": \"邮件地址\",\n  \"login.forget_password.note\": \"忘记了密码？\",\n  \"login.forgot_password.continue\": \"继续\",\n  \"login.forgot_password.description\": \"输入与你账户关联的邮箱地址以继续。\",\n  \"login.forgot_password.success\": \"我们已向你发送重置密码说明邮件。\",\n  \"login.forgot_password.title\": \"忘记密码？\",\n  \"login.have_account\": \"已有账户？<strong>登录</strong>\",\n  \"login.invalid_email_or_password\": \"邮箱或密码无效\",\n  \"login.no_account\": \"没有账户？<strong>注册</strong>\",\n  \"login.password\": \"密码\",\n  \"login.passwords_do_not_match\": \"两次输入的密码不一致\",\n  \"login.privacy\": \"隐私政策\",\n  \"login.sign_up_successful\": \"注册成功\",\n  \"login.submit\": \"提交\",\n  \"login.terms\": \"服务条款\",\n  \"onboarding.edit_profile\": \"编辑个人资料\",\n  \"onboarding.edit_profile_description\": \"更改你的姓名、电子邮件和头像\",\n  \"onboarding.finished_description\": \"你已完成所有设置步骤。开始享受 Folo 带来的精彩体验吧！\",\n  \"onboarding.finished_title\": \"一切就绪！\",\n  \"onboarding.import_content\": \"导入你的内容\",\n  \"onboarding.import_description\": \"如果你之前使用过 RSS，可以导入 OPML 文件\",\n  \"onboarding.interests_description\": \"订阅与你兴趣相符的内容源\",\n  \"onboarding.interests_title\": \"发现兴趣\",\n  \"onboarding.language_description\": \"选择你想在 Folo 中使用的语言\",\n  \"onboarding.preferences_description\": \"设置你的偏好，使 Folo 为你提供最佳体验。你可以随时在设置中更改这些选项。\",\n  \"onboarding.preferences_title\": \"个性化你的体验\",\n  \"onboarding.reading_balanced\": \"平衡：$t(onboarding.reading_balanced_description)\",\n  \"onboarding.reading_balanced_description\": \"条目滚动出视图时自动标记为已读\",\n  \"onboarding.reading_conservative\": \"保守：$t(onboarding.reading_conservative_description)\",\n  \"onboarding.reading_conservative_description\": \"仅在点击时标记条目为已读\",\n  \"onboarding.reading_preferences\": \"阅读偏好\",\n  \"onboarding.reading_radical\": \"激进：$t(onboarding.reading_radical_description)\",\n  \"onboarding.reading_radical_description\": \"显示条目时自动标记为已读\",\n  \"onboarding.shuffle\": \"换一批\",\n  \"onboarding.suggestions_feed\": \"推荐内容源\",\n  \"onboarding.welcome_guide\": \"让我们帮你快速了解如何使用这款应用\",\n  \"onboarding.welcome_title\": \"欢迎使用 Folo！\",\n  \"operation.add_feeds_to_category\": \"移动至分类\",\n  \"operation.change_to_other_view\": \"更改为其他视图\",\n  \"operation.copy.email_address\": \"邮件地址\",\n  \"operation.copy.link\": \"链接\",\n  \"operation.copy_which\": \"复制{{which}}\",\n  \"operation.copy_which_success\": \"{{which}}已复制到剪贴板\",\n  \"operation.delete_category\": \"删除分类\",\n  \"operation.delete_category_confirm\": \"此操作将删除分类，但其中包含的订阅源将被保留并按网站分组。\",\n  \"operation.delete_category_which\": \"删除分类「{{category}}」\",\n  \"operation.edit\": \"编辑\",\n  \"operation.enter_new_name_for_category\": \"为「{{category}}」输入新名称\",\n  \"operation.error_since\": \"源失效：\",\n  \"operation.mark_all_as_read\": \"全部标记为已读\",\n  \"operation.mark_all_as_read_confirm\": \"确认将全部标记为已读？\",\n  \"operation.mark_all_as_read_which\": \"将{{which}}标记为已读\",\n  \"operation.mark_all_as_read_which_above\": \"以上\",\n  \"operation.mark_all_as_read_which_below\": \"以下\",\n  \"operation.mark_as_read\": \"标记为已读\",\n  \"operation.mark_as_unread\": \"标记为未读\",\n  \"operation.open_link\": \"打开链接\",\n  \"operation.rename_category\": \"重命名分类\",\n  \"operation.share\": \"分享\",\n  \"operation.show_error_message\": \"显示错误信息\",\n  \"operation.star\": \"收藏\",\n  \"operation.toggle_unread_only.show_all.label\": \"显示全部\",\n  \"operation.toggle_unread_only.show_all.success\": \"正在显示所有条目\",\n  \"operation.toggle_unread_only.show_unread_only.label\": \"仅显示未读\",\n  \"operation.toggle_unread_only.show_unread_only.success\": \"仅显示未读条目\",\n  \"operation.unfollow\": \"取消订阅\",\n  \"operation.unstar\": \"取消收藏\",\n  \"profile.title\": \"{{name}}的个人资料\",\n  \"profile.uncategorized_feeds\": \"未分类的订阅源\",\n  \"settings.sign_in_cta\": \"登录你的账户\",\n  \"signin.sign_in_to\": \"登录\",\n  \"signin.sign_up_to\": \"注册\",\n  \"subscription_form.category\": \"分类\",\n  \"subscription_form.category_description\": \"默认情况下，你的订阅将按网站域名分组。\",\n  \"subscription_form.hide_from_timeline\": \"在时间线中隐藏\",\n  \"subscription_form.hide_from_timeline_description\": \"开启后，此订阅将不再显示在主时间线中\",\n  \"subscription_form.private_follow\": \"私密订阅\",\n  \"subscription_form.private_follow_description\": \"开启后，此订阅将不再显示在个人资料页面。\",\n  \"subscription_form.title\": \"标题\",\n  \"subscription_form.title_description\": \"此订阅源的自定义标题，留空则使用默认标题。\",\n  \"subscription_form.view\": \"视图\",\n  \"tabs.discover\": \"发现\",\n  \"tabs.home\": \"首页\",\n  \"tabs.settings\": \"设置\",\n  \"tabs.subscriptions\": \"订阅\"\n}\n"
  },
  {
    "path": "locales/mobile/default/zh-TW.json",
    "content": "{\n  \"ai.summary_generating\": \"正在產生 AI 摘要...\",\n  \"ai.summary_not_available\": \"暫無摘要\",\n  \"ai.summary_upgrade_required_description\": \"升級到 Pro 方案，解鎖無限次的 AI 摘要、翻譯以及更多智慧功能。\",\n  \"ai.summary_upgrade_required_title\": \"升級方案以繼續使用 AI 摘要\",\n  \"ai.summary_upgrade_view_plans\": \"查看方案\",\n  \"entry.pull_up_to_next_entry\": \"繼續上拉查看下一條內容\",\n  \"entry.release_to_next_entry\": \"鬆開查看下一條內容\",\n  \"entry_actions.toggle_ai_summary\": \"切換 AI 總結\",\n  \"entry_content.ai_summary\": \"AI 摘要\",\n  \"entry_content.no_content\": \"無內容\",\n  \"entry_content.no_video_url\": \"未找到影片連結\",\n  \"entry_list.zero_unread\": \"全部已讀\",\n  \"login.agree_to\": \"繼續即表示您同意我們的\",\n  \"login.back\": \"返回\",\n  \"login.confirm_password.label\": \"確認密碼\",\n  \"login.continueWith\": \"透過 {{provider}} 繼續\",\n  \"login.email\": \"電子信箱\",\n  \"login.forget_password.note\": \"忘記密碼了嗎？\",\n  \"login.have_account\": \"已有帳號？<strong>登入</strong>\",\n  \"login.invalid_email_or_password\": \"電子郵件或密碼無效\",\n  \"login.no_account\": \"沒有帳號？<strong>註冊</strong>\",\n  \"login.password\": \"密碼\",\n  \"login.passwords_do_not_match\": \"兩次輸入的密碼不一致\",\n  \"login.privacy\": \"隱私政策\",\n  \"login.sign_up_successful\": \"註冊成功\",\n  \"login.submit\": \"送出\",\n  \"login.terms\": \"服務條款\",\n  \"onboarding.edit_profile\": \"編輯個人資料\",\n  \"onboarding.edit_profile_description\": \"更改你的姓名、電子郵件和頭像\",\n  \"onboarding.finished_description\": \"你已完成所有設定步驟。開始享受 Folo 帶來的精彩體驗吧！\",\n  \"onboarding.finished_title\": \"一切就緒！\",\n  \"onboarding.import_content\": \"匯入你的內容\",\n  \"onboarding.import_description\": \"如果你之前使用過 RSS，可以匯入 OPML 檔案\",\n  \"onboarding.interests_description\": \"訂閱與你興趣相符的內容來源\",\n  \"onboarding.interests_title\": \"探索興趣\",\n  \"onboarding.language_description\": \"選擇你想在 Folo 中使用的語言\",\n  \"onboarding.preferences_description\": \"設定你的偏好，使 Folo 為你提供最佳體驗。你可以隨時在設定中更改這些選項。\",\n  \"onboarding.preferences_title\": \"個人化你的體驗\",\n  \"onboarding.reading_balanced\": \"平衡：$t(onboarding.reading_balanced_description)\",\n  \"onboarding.reading_balanced_description\": \"當項目捲動出視窗時自動標記為已讀\",\n  \"onboarding.reading_conservative\": \"保守：$t(onboarding.reading_conservative_description)\",\n  \"onboarding.reading_conservative_description\": \"僅在點擊時標記項目為已讀\",\n  \"onboarding.reading_preferences\": \"閱讀偏好\",\n  \"onboarding.reading_radical\": \"激進：$t(onboarding.reading_radical_description)\",\n  \"onboarding.reading_radical_description\": \"顯示項目時自動標記為已讀\",\n  \"onboarding.shuffle\": \"換一批\",\n  \"onboarding.suggestions_feed\": \"推薦內容來源\",\n  \"onboarding.welcome_guide\": \"讓我們幫你快速了解如何使用這款應用\",\n  \"onboarding.welcome_title\": \"歡迎使用 Folo！\",\n  \"operation.add_feeds_to_category\": \"新增 RSS 摘要到類別\",\n  \"operation.change_to_other_view\": \"切換到其他視圖\",\n  \"operation.copy.email_address\": \"電子信箱地址\",\n  \"operation.copy.link\": \"連結\",\n  \"operation.copy_which\": \"複製{{which}}\",\n  \"operation.copy_which_success\": \"{{which}}已複製到剪貼簿\",\n  \"operation.delete_category\": \"刪除類別\",\n  \"operation.delete_category_confirm\": \"此操作將刪除你的類別，但其中的 RSS 摘要會被保留並按網站分組。\",\n  \"operation.delete_category_which\": \"刪除類別 {{category}}\",\n  \"operation.edit\": \"編輯\",\n  \"operation.enter_new_name_for_category\": \"請輸入 {{category}} 的新名稱\",\n  \"operation.error_since\": \"RSS 摘要失效：\",\n  \"operation.mark_all_as_read\": \"全部標記為已讀\",\n  \"operation.mark_all_as_read_confirm\": \"確認將全部標記為已讀？\",\n  \"operation.mark_all_as_read_which\": \"將 {{which}} 標記為已讀\",\n  \"operation.mark_all_as_read_which_above\": \"以上\",\n  \"operation.mark_all_as_read_which_below\": \"以下\",\n  \"operation.mark_as_read\": \"標記為已讀\",\n  \"operation.mark_as_unread\": \"標記為未讀\",\n  \"operation.open_link\": \"開啟連結\",\n  \"operation.rename_category\": \"重新命名類別\",\n  \"operation.share\": \"分享\",\n  \"operation.show_error_message\": \"顯示錯誤訊息\",\n  \"operation.star\": \"收藏\",\n  \"operation.toggle_unread_only.show_all.label\": \"顯示全部\",\n  \"operation.toggle_unread_only.show_all.success\": \"顯示全部條目\",\n  \"operation.toggle_unread_only.show_unread_only.label\": \"只顯示未讀\",\n  \"operation.toggle_unread_only.show_unread_only.success\": \"顯示未讀條目\",\n  \"operation.unfollow\": \"取消跟隨\",\n  \"operation.unstar\": \"取消收藏\",\n  \"profile.title\": \"{{name}} 的個人資料\",\n  \"profile.uncategorized_feeds\": \"未分類的 RSS 摘要\",\n  \"signin.sign_in_to\": \"登入\",\n  \"signin.sign_up_to\": \"註冊\",\n  \"subscription_form.category\": \"類別\",\n  \"subscription_form.category_description\": \"預設情況下，您的跟隨將按網站域名分組。\",\n  \"subscription_form.hide_from_timeline\": \"從時間軸隱藏\",\n  \"subscription_form.hide_from_timeline_description\": \"開啟後，此訂閱將不再顯示在主時間軸中\",\n  \"subscription_form.private_follow\": \"私人跟隨\",\n  \"subscription_form.private_follow_description\": \"啟用後，此跟隨將不再顯示於個人資料頁面上。\",\n  \"subscription_form.title\": \"標題\",\n  \"subscription_form.title_description\": \"此 RSS 摘要的自定義標題。留空以使用預設標題。\",\n  \"subscription_form.view\": \"視圖\",\n  \"tabs.discover\": \"探索\",\n  \"tabs.home\": \"首頁\",\n  \"tabs.settings\": \"設定\",\n  \"tabs.subscriptions\": \"訂閱\"\n}\n"
  },
  {
    "path": "locales/native/en.json",
    "content": "{\n  \"contextMenu.copy\": \"Copy\",\n  \"contextMenu.copyImage\": \"Copy Image\",\n  \"contextMenu.copyImageAddress\": \"Copy Image Address\",\n  \"contextMenu.copyLink\": \"Copy Link\",\n  \"contextMenu.copyVideoAddress\": \"Copy Video Address\",\n  \"contextMenu.cut\": \"Cut\",\n  \"contextMenu.inspect\": \"Inspect Element\",\n  \"contextMenu.learnSpelling\": \"Learn Spelling\",\n  \"contextMenu.lookUpSelection\": \"Look Up Selection\",\n  \"contextMenu.openImageInBrowser\": \"Open Image in Browser\",\n  \"contextMenu.openLinkInBrowser\": \"Open Link in Browser\",\n  \"contextMenu.paste\": \"Paste\",\n  \"contextMenu.saveImage\": \"Save Image\",\n  \"contextMenu.saveImageAs\": \"Save Image As...\",\n  \"contextMenu.saveLinkAs\": \"Save Link As...\",\n  \"contextMenu.saveVideo\": \"Save Video\",\n  \"contextMenu.saveVideoAs\": \"Save Video As...\",\n  \"contextMenu.searchWithGoogle\": \"Search with Google\",\n  \"contextMenu.selectAll\": \"Select All\",\n  \"contextMenu.services\": \"Services\",\n  \"dialog.cancel\": \"Cancel\",\n  \"dialog.clearAllData\": \"Are you sure you want to clear all data?\",\n  \"dialog.no\": \"No\",\n  \"dialog.open\": \"Open\",\n  \"dialog.openExternalApp.message\": \"Are you sure you want to open \\\"{{url}}\\\" with other apps?\",\n  \"dialog.openExternalApp.title\": \"Open External App?\",\n  \"dialog.yes\": \"Yes\",\n  \"menu.about\": \"About {{name}}\",\n  \"menu.actualSize\": \"Actual Size\",\n  \"menu.bringAllToFront\": \"Bring All to Front\",\n  \"menu.checkForUpdates\": \"Check for Updates\",\n  \"menu.clearAllData\": \"Clear All Data\",\n  \"menu.close\": \"Close\",\n  \"menu.copy\": \"Copy\",\n  \"menu.cut\": \"Cut\",\n  \"menu.debug\": \"Debug\",\n  \"menu.delete\": \"Delete\",\n  \"menu.discover\": \"Discover\",\n  \"menu.edit\": \"Edit\",\n  \"menu.file\": \"File\",\n  \"menu.followReleases\": \"Folo releases\",\n  \"menu.forceReload\": \"Force Reload\",\n  \"menu.front\": \"Bring to Front\",\n  \"menu.help\": \"Help\",\n  \"menu.hide\": \"Hide {{name}}\",\n  \"menu.hideOthers\": \"Hide Others\",\n  \"menu.minimize\": \"Minimize\",\n  \"menu.open\": \"Open {{name}}\",\n  \"menu.openLogFile\": \"Open log file\",\n  \"menu.paste\": \"Paste\",\n  \"menu.pasteAndMatchStyle\": \"Paste and Match Style\",\n  \"menu.quickAdd\": \"Quick Add\",\n  \"menu.quit\": \"Quit {{name}}\",\n  \"menu.quitAndInstallUpdate\": \"Debug: Quit and Install Update\",\n  \"menu.redo\": \"Redo\",\n  \"menu.reload\": \"Reload\",\n  \"menu.search\": \"Search\",\n  \"menu.selectAll\": \"Select All\",\n  \"menu.services\": \"Services\",\n  \"menu.settings\": \"Settings...\",\n  \"menu.speech\": \"Speech\",\n  \"menu.startSpeaking\": \"Start Speaking\",\n  \"menu.stopSpeaking\": \"Stop Speaking\",\n  \"menu.toggleDevTools\": \"Toggle Developer Tools\",\n  \"menu.toggleFullScreen\": \"Toggle Full Screen\",\n  \"menu.undo\": \"Undo\",\n  \"menu.unread\": \"Unread\",\n  \"menu.view\": \"View\",\n  \"menu.window\": \"Window\",\n  \"menu.zoom\": \"Zoom\",\n  \"menu.zoomIn\": \"Zoom In\",\n  \"menu.zoomOut\": \"Zoom Out\"\n}\n"
  },
  {
    "path": "locales/native/fr-FR.json",
    "content": "{\n  \"contextMenu.copy\": \"Copier\",\n  \"contextMenu.copyImage\": \"Copier l'image\",\n  \"contextMenu.copyImageAddress\": \"Copier l'adresse de l'image\",\n  \"contextMenu.copyLink\": \"Copier le lien\",\n  \"contextMenu.copyVideoAddress\": \"Copier l'adresse de la vidéo\",\n  \"contextMenu.cut\": \"Couper\",\n  \"contextMenu.inspect\": \"Inspecter l'élément\",\n  \"contextMenu.learnSpelling\": \"Apprendre l'orthographe\",\n  \"contextMenu.lookUpSelection\": \"Rechercher la sélection\",\n  \"contextMenu.openImageInBrowser\": \"Ouvrir l'image dans le navigateur\",\n  \"contextMenu.openLinkInBrowser\": \"Ouvrir le lien dans le navigateur\",\n  \"contextMenu.paste\": \"Coller\",\n  \"contextMenu.saveImage\": \"Enregistrer l'image\",\n  \"contextMenu.saveImageAs\": \"Enregistrer l'image sous...\",\n  \"contextMenu.saveLinkAs\": \"Enregistrer le lien sous...\",\n  \"contextMenu.saveVideo\": \"Enregistrer la vidéo\",\n  \"contextMenu.saveVideoAs\": \"Enregistrer la vidéo sous...\",\n  \"contextMenu.searchWithGoogle\": \"Rechercher avec Google\",\n  \"contextMenu.selectAll\": \"Tout sélectionner\",\n  \"contextMenu.services\": \"Services\",\n  \"dialog.cancel\": \"Annuler\",\n  \"dialog.clearAllData\": \"Êtes-vous sûr de vouloir effacer toutes les données ?\",\n  \"dialog.no\": \"Non\",\n  \"dialog.open\": \"Ouvrir\",\n  \"dialog.openExternalApp.message\": \"Êtes-vous sûr de vouloir ouvrir \\\"{{url}}\\\" avec une autre application ?\",\n  \"dialog.openExternalApp.title\": \"Ouvrir une application externe ?\",\n  \"dialog.yes\": \"Oui\",\n  \"menu.about\": \"À propos de {{name}}\",\n  \"menu.actualSize\": \"Taille réelle\",\n  \"menu.bringAllToFront\": \"Tout mettre au premier plan\",\n  \"menu.checkForUpdates\": \"Vérifier les mises à jour\",\n  \"menu.clearAllData\": \"Effacer toutes les données\",\n  \"menu.close\": \"Fermer\",\n  \"menu.copy\": \"Copier\",\n  \"menu.cut\": \"Couper\",\n  \"menu.debug\": \"Déboguer\",\n  \"menu.delete\": \"Supprimer\",\n  \"menu.discover\": \"Découvrir\",\n  \"menu.edit\": \"Édition\",\n  \"menu.file\": \"Fichier\",\n  \"menu.followReleases\": \"Versions Folo\",\n  \"menu.forceReload\": \"Forcer le rechargement\",\n  \"menu.front\": \"Mettre au premier plan\",\n  \"menu.help\": \"Aide\",\n  \"menu.hide\": \"Masquer {{name}}\",\n  \"menu.hideOthers\": \"Masquer les autres\",\n  \"menu.minimize\": \"Réduire\",\n  \"menu.open\": \"Ouvrir {{name}}\",\n  \"menu.openLogFile\": \"Ouvrir le fichier de journal\",\n  \"menu.paste\": \"Coller\",\n  \"menu.pasteAndMatchStyle\": \"Coller et adapter le style\",\n  \"menu.quickAdd\": \"Ajout rapide\",\n  \"menu.quit\": \"Quitter {{name}}\",\n  \"menu.quitAndInstallUpdate\": \"Debug : Quitter et installer la mise à jour\",\n  \"menu.redo\": \"Rétablir\",\n  \"menu.reload\": \"Recharger\",\n  \"menu.search\": \"Rechercher\",\n  \"menu.selectAll\": \"Tout sélectionner\",\n  \"menu.services\": \"Services\",\n  \"menu.settings\": \"Paramètres...\",\n  \"menu.speech\": \"Parole\",\n  \"menu.startSpeaking\": \"Commencer la lecture\",\n  \"menu.stopSpeaking\": \"Arrêter la lecture\",\n  \"menu.toggleDevTools\": \"Basculer les outils de développement\",\n  \"menu.toggleFullScreen\": \"Basculer en plein écran\",\n  \"menu.undo\": \"Annuler\",\n  \"menu.unread\": \"Non lu\",\n  \"menu.view\": \"Affichage\",\n  \"menu.window\": \"Fenêtre\",\n  \"menu.zoom\": \"Zoom\",\n  \"menu.zoomIn\": \"Zoom avant\",\n  \"menu.zoomOut\": \"Zoom arrière\"\n}\n"
  },
  {
    "path": "locales/native/ja.json",
    "content": "{\n  \"contextMenu.copy\": \"コピー\",\n  \"contextMenu.copyImage\": \"画像をコピー\",\n  \"contextMenu.copyImageAddress\": \"画像アドレスをコピー\",\n  \"contextMenu.copyLink\": \"リンクをコピー\",\n  \"contextMenu.copyVideoAddress\": \"動画アドレスをコピー\",\n  \"contextMenu.cut\": \"切り取り\",\n  \"contextMenu.inspect\": \"要素を検証\",\n  \"contextMenu.learnSpelling\": \"スペルを学ぶ\",\n  \"contextMenu.lookUpSelection\": \"選択を探す\",\n  \"contextMenu.openImageInBrowser\": \"ブラウザで画像を開く\",\n  \"contextMenu.openLinkInBrowser\": \"ブラウザでリンクを開く\",\n  \"contextMenu.paste\": \"貼り付け\",\n  \"contextMenu.saveImage\": \"画像を保存\",\n  \"contextMenu.saveImageAs\": \"画像を名前を付けて保存...\",\n  \"contextMenu.saveLinkAs\": \"名前をつけてリンクを保存...\",\n  \"contextMenu.saveVideo\": \"動画を保存\",\n  \"contextMenu.saveVideoAs\": \"動画を名前を付けて保存...\",\n  \"contextMenu.searchWithGoogle\": \"Google で検索\",\n  \"contextMenu.selectAll\": \"すべて選択\",\n  \"contextMenu.services\": \"サービス\",\n  \"dialog.cancel\": \"キャンセル\",\n  \"dialog.clearAllData\": \"本当にすべてのデータを消去してもよろしいですか？\",\n  \"dialog.no\": \"いいえ\",\n  \"dialog.open\": \"開く\",\n  \"dialog.openExternalApp.message\": \"本当に他の外部アプリで \\\"{{url}}\\\" を開いてもよろしいですか？\",\n  \"dialog.openExternalApp.title\": \"外部のアプリで開きますか？\",\n  \"dialog.yes\": \"はい\",\n  \"menu.about\": \"{{name}} について\",\n  \"menu.actualSize\": \"実際のサイズ\",\n  \"menu.bringAllToFront\": \"すべてを前面に移動\",\n  \"menu.checkForUpdates\": \"アップデートを確認\",\n  \"menu.clearAllData\": \"すべてのデータを消去\",\n  \"menu.close\": \"閉じる\",\n  \"menu.copy\": \"コピー\",\n  \"menu.cut\": \"切り取り\",\n  \"menu.debug\": \"デバッグ\",\n  \"menu.delete\": \"削除\",\n  \"menu.discover\": \"発見\",\n  \"menu.edit\": \"編集\",\n  \"menu.file\": \"ファイル\",\n  \"menu.followReleases\": \"リリースをフォロー\",\n  \"menu.forceReload\": \"強制再読み込み\",\n  \"menu.front\": \"前面に表示\",\n  \"menu.help\": \"ヘルプ\",\n  \"menu.hide\": \"{{name}} を隠す\",\n  \"menu.hideOthers\": \"ほかを隠す\",\n  \"menu.minimize\": \"最小化\",\n  \"menu.open\": \"{{name}} を開く\",\n  \"menu.openLogFile\": \"ログファイルを開く\",\n  \"menu.paste\": \"貼り付け\",\n  \"menu.pasteAndMatchStyle\": \"ペーストしてスタイルを合わせる\",\n  \"menu.quickAdd\": \"クイック追加\",\n  \"menu.quit\": \"{{name}} を終了\",\n  \"menu.quitAndInstallUpdate\": \"デバッグ：終了してアップデートをインストール\",\n  \"menu.redo\": \"やり直す\",\n  \"menu.reload\": \"再読み込み\",\n  \"menu.search\": \"検索\",\n  \"menu.selectAll\": \"すべてを選択\",\n  \"menu.services\": \"サービス\",\n  \"menu.settings\": \"設定...\",\n  \"menu.speech\": \"スピーチ\",\n  \"menu.startSpeaking\": \"読み上げを開始\",\n  \"menu.stopSpeaking\": \"読み上げを停止\",\n  \"menu.toggleDevTools\": \"開発者ツールの切り替え\",\n  \"menu.toggleFullScreen\": \"フルスクリーンの切り替え\",\n  \"menu.undo\": \"元に戻す\",\n  \"menu.unread\": \"未読\",\n  \"menu.view\": \"表示\",\n  \"menu.window\": \"ウィンドウ\",\n  \"menu.zoom\": \"ズーム\",\n  \"menu.zoomIn\": \"拡大\",\n  \"menu.zoomOut\": \"縮小\"\n}\n"
  },
  {
    "path": "locales/native/zh-CN.json",
    "content": "{\n  \"contextMenu.copy\": \"复制\",\n  \"contextMenu.copyImage\": \"复制图片\",\n  \"contextMenu.copyImageAddress\": \"复制图片地址\",\n  \"contextMenu.copyLink\": \"复制链接\",\n  \"contextMenu.copyVideoAddress\": \"复制视频地址\",\n  \"contextMenu.cut\": \"剪切\",\n  \"contextMenu.inspect\": \"检查元素\",\n  \"contextMenu.learnSpelling\": \"学习拼写\",\n  \"contextMenu.lookUpSelection\": \"在词典中查找\",\n  \"contextMenu.openImageInBrowser\": \"在浏览器中打开图片\",\n  \"contextMenu.openLinkInBrowser\": \"在浏览器中打开链接\",\n  \"contextMenu.paste\": \"粘贴\",\n  \"contextMenu.saveImage\": \"保存图片\",\n  \"contextMenu.saveImageAs\": \"图片另存为...\",\n  \"contextMenu.saveLinkAs\": \"链接另存为...\",\n  \"contextMenu.saveVideo\": \"保存视频\",\n  \"contextMenu.saveVideoAs\": \"视频另存为...\",\n  \"contextMenu.searchWithGoogle\": \"在谷歌中搜索\",\n  \"contextMenu.selectAll\": \"全选\",\n  \"contextMenu.services\": \"服务\",\n  \"dialog.cancel\": \"取消\",\n  \"dialog.clearAllData\": \"你确定要清除所有数据吗？\",\n  \"dialog.no\": \"否\",\n  \"dialog.open\": \"打开\",\n  \"dialog.openExternalApp.message\": \"确定要使用其他应用打开 {{url}} 吗？\",\n  \"dialog.openExternalApp.title\": \"打开外部应用？\",\n  \"dialog.yes\": \"是\",\n  \"menu.about\": \"关于 {{name}}\",\n  \"menu.actualSize\": \"实际大小\",\n  \"menu.bringAllToFront\": \"全部置于顶层\",\n  \"menu.checkForUpdates\": \"检查更新\",\n  \"menu.clearAllData\": \"清除所有数据\",\n  \"menu.close\": \"关闭\",\n  \"menu.copy\": \"复制\",\n  \"menu.cut\": \"剪切\",\n  \"menu.debug\": \"调试\",\n  \"menu.delete\": \"删除\",\n  \"menu.discover\": \"发现\",\n  \"menu.edit\": \"编辑\",\n  \"menu.file\": \"文件\",\n  \"menu.followReleases\": \"Folo 发行日志\",\n  \"menu.forceReload\": \"强制重新加载\",\n  \"menu.front\": \"置于顶层\",\n  \"menu.help\": \"帮助\",\n  \"menu.hide\": \"隐藏 {{name}}\",\n  \"menu.hideOthers\": \"隐藏其他\",\n  \"menu.minimize\": \"最小化\",\n  \"menu.open\": \"打开 {{name}}\",\n  \"menu.openLogFile\": \"打开日志文件\",\n  \"menu.paste\": \"粘贴\",\n  \"menu.pasteAndMatchStyle\": \"粘贴并匹配样式\",\n  \"menu.quickAdd\": \"快速添加\",\n  \"menu.quit\": \"退出 {{name}}\",\n  \"menu.quitAndInstallUpdate\": \"调试：退出并安装更新\",\n  \"menu.redo\": \"重做\",\n  \"menu.reload\": \"重新加载\",\n  \"menu.search\": \"搜索\",\n  \"menu.selectAll\": \"全选\",\n  \"menu.services\": \"服务\",\n  \"menu.settings\": \"设置...\",\n  \"menu.speech\": \"语音\",\n  \"menu.startSpeaking\": \"开始朗读\",\n  \"menu.stopSpeaking\": \"停止朗读\",\n  \"menu.toggleDevTools\": \"切换开发者工具\",\n  \"menu.toggleFullScreen\": \"切换全屏\",\n  \"menu.undo\": \"撤销\",\n  \"menu.unread\": \"未读\",\n  \"menu.view\": \"视图\",\n  \"menu.window\": \"窗口\",\n  \"menu.zoom\": \"缩放\",\n  \"menu.zoomIn\": \"放大\",\n  \"menu.zoomOut\": \"缩小\"\n}\n"
  },
  {
    "path": "locales/native/zh-TW.json",
    "content": "{\n  \"contextMenu.copy\": \"複製\",\n  \"contextMenu.copyImage\": \"複製圖片\",\n  \"contextMenu.copyImageAddress\": \"複製圖片網址\",\n  \"contextMenu.copyLink\": \"複製連結\",\n  \"contextMenu.copyVideoAddress\": \"複製影片網址\",\n  \"contextMenu.cut\": \"剪下\",\n  \"contextMenu.inspect\": \"檢查元素\",\n  \"contextMenu.learnSpelling\": \"學習拼寫\",\n  \"contextMenu.lookUpSelection\": \"在字典中查詢\",\n  \"contextMenu.openImageInBrowser\": \"在瀏覽器中開啟圖片\",\n  \"contextMenu.openLinkInBrowser\": \"在瀏覽器中開啟連結\",\n  \"contextMenu.paste\": \"貼上\",\n  \"contextMenu.saveImage\": \"儲存圖片\",\n  \"contextMenu.saveImageAs\": \"圖片另存為...\",\n  \"contextMenu.saveLinkAs\": \"連結另存為...\",\n  \"contextMenu.saveVideo\": \"儲存影片\",\n  \"contextMenu.saveVideoAs\": \"影片另存為...\",\n  \"contextMenu.searchWithGoogle\": \"使用 Google 搜尋\",\n  \"contextMenu.selectAll\": \"全選\",\n  \"contextMenu.services\": \"服務\",\n  \"dialog.cancel\": \"取消\",\n  \"dialog.clearAllData\": \"您確定要清除所有資料嗎？\",\n  \"dialog.no\": \"否\",\n  \"dialog.open\": \"開啟\",\n  \"dialog.openExternalApp.message\": \"確定要使用其他App開啟 {{url}} 嗎？\",\n  \"dialog.openExternalApp.title\": \"開啟外部App？\",\n  \"dialog.yes\": \"是\",\n  \"menu.about\": \"關於 {{name}}\",\n  \"menu.actualSize\": \"實際大小\",\n  \"menu.bringAllToFront\": \"將所有視窗移至最上層\",\n  \"menu.checkForUpdates\": \"檢查更新\",\n  \"menu.clearAllData\": \"清除所有資料\",\n  \"menu.close\": \"關閉\",\n  \"menu.copy\": \"複製\",\n  \"menu.cut\": \"剪下\",\n  \"menu.debug\": \"除錯\",\n  \"menu.delete\": \"刪除\",\n  \"menu.discover\": \"探索\",\n  \"menu.edit\": \"編輯\",\n  \"menu.file\": \"檔案\",\n  \"menu.followReleases\": \"Folo 發佈\",\n  \"menu.forceReload\": \"強制重新載入\",\n  \"menu.front\": \"置於最上層\",\n  \"menu.help\": \"說明\",\n  \"menu.hide\": \"隱藏 {{name}}\",\n  \"menu.hideOthers\": \"隱藏其他\",\n  \"menu.minimize\": \"最小化\",\n  \"menu.open\": \"開啟 {{name}}\",\n  \"menu.openLogFile\": \"開啟記錄檔\",\n  \"menu.paste\": \"貼上\",\n  \"menu.pasteAndMatchStyle\": \"貼上並符合樣式\",\n  \"menu.quickAdd\": \"快速新增\",\n  \"menu.quit\": \"結束 {{name}}\",\n  \"menu.quitAndInstallUpdate\": \"除錯：結束並安裝更新\",\n  \"menu.redo\": \"重做\",\n  \"menu.reload\": \"重新載入\",\n  \"menu.search\": \"搜尋\",\n  \"menu.selectAll\": \"全選\",\n  \"menu.services\": \"服務\",\n  \"menu.settings\": \"設定...\",\n  \"menu.speech\": \"語音\",\n  \"menu.startSpeaking\": \"開始朗讀\",\n  \"menu.stopSpeaking\": \"停止朗讀\",\n  \"menu.toggleDevTools\": \"切換開發者工具\",\n  \"menu.toggleFullScreen\": \"切換全螢幕\",\n  \"menu.undo\": \"復原\",\n  \"menu.unread\": \"未讀\",\n  \"menu.view\": \"檢視\",\n  \"menu.window\": \"視窗\",\n  \"menu.zoom\": \"縮放\",\n  \"menu.zoomIn\": \"放大\",\n  \"menu.zoomOut\": \"縮小\"\n}\n"
  },
  {
    "path": "locales/settings/en.json",
    "content": "{\n  \"about.aiOnboardingDescription\": \"Revisit the interactive onboarding tour.\",\n  \"about.appTip\": \"App Features\",\n  \"about.appTipDescription\": \"App features introduction and usage guide.\",\n  \"about.changelog\": \"Changelog\",\n  \"about.changelogDescription\": \"See what's new in each version\",\n  \"about.checkForUpdates\": \"Check for Updates\",\n  \"about.checkNow\": \"Check now\",\n  \"about.checkingForUpdates\": \"Checking for updates...\",\n  \"about.copyEnvironment\": \"Copy environment\",\n  \"about.environmentCopied\": \"Environment info copied\",\n  \"about.feedbackInfo\": \"{{appName}} ({{commitSha}}) is open-source and actively developed on GitHub. If you have any feedback or suggestions, please feel free to <OpenIssueLink>open an issue</OpenIssueLink> on our GitHub.\",\n  \"about.iconLibrary\": \"The icon library used is copyrighted by <IconLibraryLink />  and cannot be redistributed.\",\n  \"about.legal\": \"Legal\",\n  \"about.licenseInfo\": \"Copyright © {{currentYear}} {{appName}}. All rights reserved.\",\n  \"about.noUpdateAvailable\": \"You're on the latest version\",\n  \"about.privacyPolicy\": \"Privacy Policy\",\n  \"about.projectLicense\": \"{{appName}} is licensed under the GNU Affero General Public License version 3 with additional exceptions.\",\n  \"about.rateFolo\": \"Rate Folo\",\n  \"about.rateFoloDescription\": \"Leave a rating to support the product.\",\n  \"about.resources\": \"Resources & Contributions\",\n  \"about.sendFeedback\": \"Send Feedback\",\n  \"about.sendFeedbackDescription\": \"Tell us what you would like improved.\",\n  \"about.sidebar_title\": \"About\",\n  \"about.socialMedia\": \"Social Media\",\n  \"about.support\": \"Support\",\n  \"about.termsOfService\": \"Terms of Service\",\n  \"about.updateAvailable\": \"Update available!\",\n  \"about.updateCheckFailed\": \"Failed to check for updates\",\n  \"about.updateDescription\": \"Keep your app up to date with the latest features and improvements\",\n  \"about.viewChangelog\": \"View changelog\",\n  \"actions.actionName\": \"Action {{number}}\",\n  \"actions.action_card.add\": \"Add\",\n  \"actions.action_card.all\": \"All\",\n  \"actions.action_card.and\": \"And\",\n  \"actions.action_card.block\": \"Block\",\n  \"actions.action_card.block_rules\": \"Block Rules\",\n  \"actions.action_card.custom_filters\": \"Custom Filters\",\n  \"actions.action_card.empty.cta\": \"Create your first rule\",\n  \"actions.action_card.empty.description\": \"Create your first action rule to automatically process your feeds.\",\n  \"actions.action_card.empty.start\": \"Start here!\",\n  \"actions.action_card.empty.title\": \"No Actions Yet\",\n  \"actions.action_card.enable_readability\": \"Enable Readability\",\n  \"actions.action_card.feed_options.entry_attachments_duration\": \"Entry Video Length\",\n  \"actions.action_card.feed_options.entry_author\": \"Entry Author\",\n  \"actions.action_card.feed_options.entry_content\": \"Entry Content\",\n  \"actions.action_card.feed_options.entry_media_length\": \"Entry Media Length\",\n  \"actions.action_card.feed_options.entry_title\": \"Entry Title\",\n  \"actions.action_card.feed_options.entry_url\": \"Entry URL\",\n  \"actions.action_card.feed_options.feed_category\": \"Feed Category\",\n  \"actions.action_card.feed_options.feed_title\": \"Feed Title\",\n  \"actions.action_card.feed_options.feed_url\": \"Feed URL\",\n  \"actions.action_card.feed_options.site_url\": \"Site URL\",\n  \"actions.action_card.feed_options.status\": \"Status\",\n  \"actions.action_card.feed_options.subscription_view\": \"Subscription View\",\n  \"actions.action_card.field\": \"Field\",\n  \"actions.action_card.from\": \"From\",\n  \"actions.action_card.generate_summary\": \"Generate Summary Using AI\",\n  \"actions.action_card.name\": \"Name\",\n  \"actions.action_card.new_entry_notification\": \"Notification of New Entry\",\n  \"actions.action_card.no_translation\": \"No Translation\",\n  \"actions.action_card.operation_options.contains\": \"Contains\",\n  \"actions.action_card.operation_options.does_not_contain\": \"Does Not Contain\",\n  \"actions.action_card.operation_options.is_equal_to\": \"Is Equal to\",\n  \"actions.action_card.operation_options.is_greater_than\": \"Is Greater Than\",\n  \"actions.action_card.operation_options.is_less_than\": \"Is Less Than\",\n  \"actions.action_card.operation_options.is_not_equal_to\": \"Is Not Equal to\",\n  \"actions.action_card.operation_options.matches_regex\": \"Matches Regex\",\n  \"actions.action_card.operator\": \"Operator\",\n  \"actions.action_card.or\": \"Or\",\n  \"actions.action_card.rewrite_rules\": \"Rewrite Rules\",\n  \"actions.action_card.settings\": \"Settings\",\n  \"actions.action_card.silence\": \"Silence\",\n  \"actions.action_card.source_content\": \"View Source Content\",\n  \"actions.action_card.star\": \"Star\",\n  \"actions.action_card.summary.action_count\": \"{{count}} actions enabled\",\n  \"actions.action_card.summary.active\": \"Active\",\n  \"actions.action_card.summary.copy\": \"Copy to Clipboard\",\n  \"actions.action_card.summary.delete\": \"Delete\",\n  \"actions.action_card.summary.delete_message\": \"Are you sure you want to delete this rule? This action cannot be undone.\",\n  \"actions.action_card.summary.delete_title\": \"Delete Rule\",\n  \"actions.action_card.summary.disabled\": \"Disabled\",\n  \"actions.action_card.summary.empty\": \"No rules yet\",\n  \"actions.action_card.summary.export\": \"Export to File\",\n  \"actions.action_card.summary.helper\": \"Automate your workflow with precise triggers and actions.\",\n  \"actions.action_card.summary.import\": \"Import\",\n  \"actions.action_card.summary.import_clipboard\": \"Import from Clipboard\",\n  \"actions.action_card.summary.import_file\": \"Import from File\",\n  \"actions.action_card.summary.no_actions\": \"No actions configured yet\",\n  \"actions.action_card.summary.rule_count\": \"{{count}} rules\",\n  \"actions.action_card.summary.share\": \"Share\",\n  \"actions.action_card.summary.toggle\": \"Toggle rule status\",\n  \"actions.action_card.then_do\": \"Then Do…\",\n  \"actions.action_card.to\": \"To\",\n  \"actions.action_card.translate_into\": \"Translate\",\n  \"actions.action_card.value\": \"Value\",\n  \"actions.action_card.webhooks\": \"Webhooks\",\n  \"actions.action_card.when_feeds_match\": \"When Feeds Match…\",\n  \"actions.condition\": \"Condition\",\n  \"actions.conditions\": \"Conditions\",\n  \"actions.edit_condition\": \"Edit Condition\",\n  \"actions.edit_rewrite_rule\": \"Edit Rewrite Rule\",\n  \"actions.edit_rule\": \"Edit Rule\",\n  \"actions.edit_webhook\": \"Edit Webhook\",\n  \"actions.info\": \"Actions Are Collections of Rules That You Can Automate to Perform Tasks on Server or Client Side.\",\n  \"actions.navigate.prompt\": \"You Have Unsaved Action Changes. Are You Sure You Want to Leave?\",\n  \"actions.newRule\": \"New Rule\",\n  \"actions.save\": \"Save\",\n  \"actions.saveSuccess\": \"🎉 Actions Saved.\",\n  \"actions.sidebar_title\": \"Actions\",\n  \"actions.title\": \"Actions\",\n  \"ai.personalize.prompt.description\": \"Tell Folo about yourself and how you like to read things.\",\n  \"ai.personalize.prompt.label\": \"Personalize Prompt\",\n  \"ai.personalize.title\": \"Personalize\",\n  \"ai.shortcuts.title\": \"Shortcuts\",\n  \"appearance.accent_color.description\": \"Choose the accent color for the app interface.\",\n  \"appearance.accent_color.label\": \"Accent Color\",\n  \"appearance.code_highlight_theme.description\": \"Adjust the code highlight theme.\",\n  \"appearance.code_highlight_theme.label\": \"Code Highlight Theme\",\n  \"appearance.code_highlighting.title\": \"Code Highlighting\",\n  \"appearance.common.title\": \"Common\",\n  \"appearance.content\": \"Content\",\n  \"appearance.content_display.title\": \"Content Display\",\n  \"appearance.content_font.default\": \"Default (UI Font)\",\n  \"appearance.content_font.description\": \"Adjust the font used for the reading content.\",\n  \"appearance.content_font.label\": \"Content Font\",\n  \"appearance.content_font_size\": \"Content Font Size\",\n  \"appearance.content_line_height.description\": \"Adjust spacing between lines of text in articles.\",\n  \"appearance.content_line_height.label\": \"Content Line Height\",\n  \"appearance.content_line_height.loose\": \"Loose\",\n  \"appearance.content_line_height.normal\": \"Normal\",\n  \"appearance.content_line_height.relaxed\": \"Relaxed\",\n  \"appearance.content_line_height.snug\": \"Snug\",\n  \"appearance.content_line_height.tight\": \"Tight\",\n  \"appearance.custom_css.button\": \"Edit\",\n  \"appearance.custom_css.description\": \"Custom CSS style for content.\",\n  \"appearance.custom_css.label\": \"Custom CSS\",\n  \"appearance.custom_font\": \"Custom Font\",\n  \"appearance.customization.title\": \"Customization\",\n  \"appearance.customize_sub_tabs.description\": \"Customize the subscription tabs to your liking.\",\n  \"appearance.customize_sub_tabs.label\": \"Customize View Tabs\",\n  \"appearance.customize_toolbar.description\": \"Customize the entry content toolbar to your liking.\",\n  \"appearance.customize_toolbar.label\": \"Customize Toolbar\",\n  \"appearance.date_format.description\": \"Adjust the display date format.\",\n  \"appearance.date_format.label\": \"Date Format\",\n  \"appearance.font.custom\": \"Custom\",\n  \"appearance.font.system\": \"System UI\",\n  \"appearance.font_scaling.content_different.description\": \"Set independent font size for article content.\",\n  \"appearance.font_scaling.content_different.label\": \"Use Different Font Size for Content\",\n  \"appearance.font_scaling.content_size.description\": \"Set the font size for article content.\",\n  \"appearance.font_scaling.content_size.l\": \"Large\",\n  \"appearance.font_scaling.content_size.label\": \"Content Font Size\",\n  \"appearance.font_scaling.content_size.m\": \"Default\",\n  \"appearance.font_scaling.content_size.s\": \"Small\",\n  \"appearance.font_scaling.content_size.xl\": \"Larger\",\n  \"appearance.font_scaling.content_size.xs\": \"Smaller\",\n  \"appearance.font_scaling.scale.description\": \"Adjust the font size scaling factor.\",\n  \"appearance.font_scaling.scale.label\": \"Font Scale\",\n  \"appearance.font_scaling.size.l\": \"Large\",\n  \"appearance.font_scaling.size.m\": \"Default\",\n  \"appearance.font_scaling.size.s\": \"Small\",\n  \"appearance.font_scaling.size.xl\": \"Larger\",\n  \"appearance.font_scaling.size.xs\": \"Smaller\",\n  \"appearance.font_scaling.system.description\": \"Follow system accessibility font size settings.\",\n  \"appearance.font_scaling.system.label\": \"Use System Font Scaling\",\n  \"appearance.font_scaling.title\": \"Font Scaling\",\n  \"appearance.fonts\": \"Fonts\",\n  \"appearance.general\": \"General\",\n  \"appearance.global_font.default\": \"Follow System\",\n  \"appearance.global_font_size.description\": \"Adjust the overall text size.\",\n  \"appearance.global_font_size.label\": \"Global Font Size\",\n  \"appearance.guess_code_language.description\": \"Major programming languages that use models to infer unlabeled code blocks.\",\n  \"appearance.guess_code_language.label\": \"Guess Code Language\",\n  \"appearance.hide_extra_badge.description\": \"Hide the special badges of the feed in the sidebar, e.g. Boost, Claimed.\",\n  \"appearance.hide_extra_badge.label\": \"Hide Special Badges\",\n  \"appearance.hide_recent_reader.description\": \"Hide the recent readers and view count in the entry.\",\n  \"appearance.hide_recent_reader.label\": \"Hide Recent Readers & View Counts\",\n  \"appearance.interface_window.title\": \"Interface & Window\",\n  \"appearance.misc\": \"Misc\",\n  \"appearance.modal_overlay.description\": \"Show modal overlay.\",\n  \"appearance.modal_overlay.label\": \"Show Modal Overlay\",\n  \"appearance.opaque_sidebars.description\": \"Make the sidebar background transparent.\",\n  \"appearance.opaque_sidebars.label\": \"Opaque Sidebars\",\n  \"appearance.reader_render_inline_style.description\": \"Allows rendering of the inline style of the original HTML.\",\n  \"appearance.reader_render_inline_style.label\": \"Render Inline Style\",\n  \"appearance.reading_view.title\": \"Reading View\",\n  \"appearance.reduce_motion.description\": \"Reducing the motion of elements to improve performance and reduce energy consumption.\",\n  \"appearance.reduce_motion.label\": \"Reduce Motion\",\n  \"appearance.save\": \"Save\",\n  \"appearance.sidebar\": \"Sidebar\",\n  \"appearance.sidebar_title\": \"Appearance\",\n  \"appearance.subscription_list.title\": \"Subscription List\",\n  \"appearance.subscriptions\": \"Subscriptions\",\n  \"appearance.system_integration.title\": \"System Integration\",\n  \"appearance.text_size.default\": \"Default\",\n  \"appearance.text_size.label\": \"Global Text Size\",\n  \"appearance.text_size.large\": \"Large\",\n  \"appearance.text_size.medium\": \"Medium\",\n  \"appearance.text_size.smaller\": \"Smaller\",\n  \"appearance.theme.dark\": \"Dark\",\n  \"appearance.theme.description\": \"Adjust the overall theme of the app.\",\n  \"appearance.theme.label\": \"Theme\",\n  \"appearance.theme.light\": \"Light\",\n  \"appearance.theme.system\": \"System\",\n  \"appearance.thumbnail_ratio.description\": \"The ratio of the thumbnail in the entry list.\",\n  \"appearance.thumbnail_ratio.original\": \"Original\",\n  \"appearance.thumbnail_ratio.square\": \"Square\",\n  \"appearance.thumbnail_ratio.title\": \"Thumbnail Ratio\",\n  \"appearance.title\": \"Appearance\",\n  \"appearance.typography.title\": \"Typography\",\n  \"appearance.ui_font.description\": \"Adjust the font used for the UI elements.\",\n  \"appearance.ui_font.label\": \"Global Font\",\n  \"appearance.unread_count.badge.description\": \"Show the unread count as a badge in the dock icon.\",\n  \"appearance.unread_count.badge.label\": \"Show as Badge\",\n  \"appearance.unread_count.label\": \"Unread Count\",\n  \"appearance.unread_count.sidebar.description\": \"Display unread counts next to feeds and groups.\",\n  \"appearance.unread_count.sidebar.title\": \"Show unread count\",\n  \"appearance.unread_count.view_and_subscription.description\": \"Show the unread count in the view and subscription list.\",\n  \"appearance.unread_count.view_and_subscription.label\": \"Show in View and Subscription\",\n  \"appearance.use_pointer_cursor.description\": \"When the mouse hovers over any interactive element, the cursor appears as a hand.\",\n  \"appearance.use_pointer_cursor.label\": \"Use Hand Cursor\",\n  \"appearance.words.customize\": \"Customize\",\n  \"cli.description\": \"Run Folo CLI from any terminal with npx using folocli@latest, without a global install. Desktop can sync your current login with one click.\",\n  \"cli.desktop_sync\": \"Desktop sync command\",\n  \"cli.global_install\": \"Run latest with npx\",\n  \"cli.install\": \"Sync Desktop Login\",\n  \"cli.install_failed\": \"Failed to sync desktop login to CLI\",\n  \"cli.install_success\": \"Desktop login synced to CLI\",\n  \"cli.installed\": \"Connected\",\n  \"cli.not_available\": \"npx is not available. Install Node.js and npm first.\",\n  \"cli.not_installed\": \"Not connected\",\n  \"cli.package\": \"Package\",\n  \"cli.path\": \"Config path\",\n  \"cli.require_login\": \"Sign in to Folo Desktop first to sync your CLI login.\",\n  \"cli.runtime_missing\": \"Node.js/npm required\",\n  \"cli.runtime_ready\": \"Node.js/npm ready\",\n  \"cli.title\": \"Folo CLI\",\n  \"cli.uninstall\": \"Clear CLI Login\",\n  \"cli.uninstall_failed\": \"Failed to clear CLI login\",\n  \"cli.uninstall_success\": \"CLI login cleared\",\n  \"common.give_star\": \"<HeartIcon />Love our product? <Link>Give us a star on GitHub!</Link>\",\n  \"control.paid_badge.basic_or_higher\": \"This feature requires a Basic plan or higher to use\",\n  \"control.paid_badge.free_limited\": \"This feature is limited for free plan\",\n  \"customizeToolbar.more_actions.description\": \"Will be shown in the dropdown menu.\",\n  \"customizeToolbar.more_actions.title\": \"More Actions\",\n  \"customizeToolbar.quick_actions.description\": \"Customize and reorder your frequently used actions.\",\n  \"customizeToolbar.quick_actions.title\": \"Quick Actions\",\n  \"customizeToolbar.reset_layout\": \"Reset to Default Layout\",\n  \"customizeToolbar.title\": \"Customize Toolbar\",\n  \"data_control.app_cache_limit.description\": \"The maximum size of the app cache. Once the cache reaches this size, the oldest items will be deleted to free up space.\",\n  \"data_control.app_cache_limit.label\": \"App Cache Limit\",\n  \"data_control.clean_cache.button\": \"Clean Cache\",\n  \"data_control.clean_cache.cancel\": \"Cancel\",\n  \"data_control.clean_cache.clear\": \"Clear\",\n  \"data_control.clean_cache.description\": \"Clean the app cache to free up space.\",\n  \"data_control.clean_cache.description_web\": \"Clean the web app service worker cache to free up space.\",\n  \"data_control.clean_cache.success\": \"Cache cleaned successfully.\",\n  \"data_control.data_sources\": \"Data Sources\",\n  \"data_control.export_local_database.label\": \"Export local database\",\n  \"data_control.import_opml.label\": \"Import subscriptions from OPML\",\n  \"data_control.utils\": \"Utils\",\n  \"discoverFilters.filters\": \"Filters\",\n  \"discoverFilters.language\": \"Language\",\n  \"discoverFilters.title\": \"Discover Filters\",\n  \"feeds.claim\": \"Claim Feeds\",\n  \"feeds.claimTips\": \"To claim your feeds and receive tips, right-click on the feed in your subscription list and select Claim.\",\n  \"feeds.filter.all\": \"All ({{count}})\",\n  \"feeds.filter.rsshub\": \"RSSHub ({{count}})\",\n  \"feeds.noFeeds\": \"No claimed feeds\",\n  \"feeds.subscription\": \"Subscribed Feeds\",\n  \"feeds.tableHeaders.date\": \"Subscribed Date\",\n  \"feeds.tableHeaders.followers\": \"Followers\",\n  \"feeds.tableHeaders.name\": \"Name\",\n  \"feeds.tableHeaders.subscriptionCount\": \"Subs\",\n  \"feeds.tableHeaders.tipAmount\": \"Tips\",\n  \"feeds.tableHeaders.updatesPerWeek\": \"Updates\",\n  \"feeds.tableHeaders.view\": \"View\",\n  \"feeds.tableSelected.clear\": \"Clear\",\n  \"feeds.tableSelected.item\": \"{{count}} item(s) selected\",\n  \"feeds.tableSelected.moveToView.action\": \"Move to View\",\n  \"feeds.tableSelected.moveToView.confirm\": \"Are you sure you want to move these feeds to {{view}}?\",\n  \"feeds.tableSelected.moveToView.confirmTitle\": \"Confirm\",\n  \"feeds.tableSelected.unsubscribe\": \"Unsubscribe\",\n  \"general.action.summary.description\": \"Generate a summary of the entry using AI.\",\n  \"general.action.summary.label\": \"AI Summary\",\n  \"general.action.title\": \"AI Actions\",\n  \"general.action.translation.description\": \"Translate the entry into the selected language.\",\n  \"general.action.translation.label\": \"AI Translation\",\n  \"general.action_language.default\": \"Default (UI Language)\",\n  \"general.action_language.description\": \"Choose the language for the AI actions, e.g. AI Summary, AI Translation.\",\n  \"general.action_language.label\": \"AI Output Language\",\n  \"general.advanced\": \"Advanced\",\n  \"general.app\": \"App\",\n  \"general.auto_expand_long_social_media.description\": \"Automatically expand social media entries containing long text.\",\n  \"general.auto_expand_long_social_media.label\": \"Expand long social media\",\n  \"general.auto_group.description\": \"Group feeds from the same website domain together.\",\n  \"general.auto_group.label\": \"Auto Group feeds by site\",\n  \"general.cache\": \"Cache\",\n  \"general.content\": \"Content\",\n  \"general.data\": \"Data\",\n  \"general.data_file.label\": \"Data File\",\n  \"general.dim_read.description\": \"Dim the color of entries in the timeline that have been read.\",\n  \"general.dim_read.label\": \"Fade Read Items\",\n  \"general.enhanced.description\": \"Enabling the enhanced settings offers more customization options, but may also introduce unforeseen issues. !!! Keep this as the gateway to truly experimental/complex things if needed later, otherwise it might be removable if other settings cover specifics.\",\n  \"general.enhanced.disabled.tip\": \"Enhanced settings are disabled, you can enable them in the General settings - Advanced.\",\n  \"general.enhanced.enable.modal.cancel\": \"Cancel\",\n  \"general.enhanced.enable.modal.confirm\": \"Enable\",\n  \"general.enhanced.enable.modal.description\": \"Enabling the enhanced settings offers more customization options, but may also introduce unforeseen issues. Do not enable this unless you know what you are doing.\",\n  \"general.enhanced.enable.modal.title\": \"Enable Enhanced Settings\",\n  \"general.enhanced.enabled.tip\": \"Enhanced settings are enabled, you can disable them in the General settings - Advanced.\",\n  \"general.enhanced.label\": \"Enhanced Settings\",\n  \"general.export.button\": \"Export\",\n  \"general.export.description\": \"Export your list of feed subscriptions.\",\n  \"general.export.folder_mode.description\": \"Decide how you want to organize your export folders.\",\n  \"general.export.folder_mode.label\": \"Folder Mode\",\n  \"general.export.folder_mode.option.category\": \"Category\",\n  \"general.export.folder_mode.option.view\": \"View\",\n  \"general.export.label\": \"Export Feeds (OPML)\",\n  \"general.export.rsshub_url.description\": \"Default base URL for RSSHub route, leave it empty to use https://rsshub.app.\",\n  \"general.export.rsshub_url.label\": \"RSSHub URL\",\n  \"general.export_data.title\": \"Export Data\",\n  \"general.export_database.button\": \"Export\",\n  \"general.export_database.description\": \"Export all your data, including articles (Full Backup).\",\n  \"general.export_database.label\": \"Export Database\",\n  \"general.group_by_date.description\": \"Group entries by date.\",\n  \"general.group_by_date.label\": \"Group by date\",\n  \"general.hide_all_read_subscriptions.description\": \"Hide subscriptions without unread entries in the subscription list.\",\n  \"general.hide_all_read_subscriptions.label\": \"Hide read\",\n  \"general.hide_private_subscriptions_in_timeline.description\": \"Hide private subscriptions from your subscriptions list and hide their entries from your timeline (they are always invisible to the public regardless of this setting).\",\n  \"general.hide_private_subscriptions_in_timeline.label\": \"Hide private\",\n  \"general.language.description\": \"Choose the display language for the app.\",\n  \"general.language.title\": \"Language\",\n  \"general.launch_at_login\": \"Launch at login\",\n  \"general.log_file.button\": \"Reveal\",\n  \"general.log_file.description\": \"Reveal the log file in the system.\",\n  \"general.log_file.label\": \"Log File\",\n  \"general.maintenance.title\": \"Maintenance\",\n  \"general.mark_as_read.hover.description\": \"Automatically mark entries as read when hovered.\",\n  \"general.mark_as_read.hover.label\": \"When hovering over article\",\n  \"general.mark_as_read.render.description\": \"Mark items like social posts or images as read immediately.\",\n  \"general.mark_as_read.render.label\": \"Single items when they enter view\",\n  \"general.mark_as_read.scroll.description\": \"Automatically mark entries as read when scrolled out of the view.\",\n  \"general.mark_as_read.scroll.label\": \"When scrolling past article\",\n  \"general.mark_as_read.title\": \"Mark as read\",\n  \"general.minimize_to_tray.description\": \"Minimize to system tray when closing window.\",\n  \"general.minimize_to_tray.label\": \"Minimize to tray\",\n  \"general.network\": \"Network\",\n  \"general.open_links_in_external_app.label\": \"Open links in external app\",\n  \"general.privacy\": \"Privacy\",\n  \"general.proxy.description\": \"Set proxy for network traffic routing, e.g., socks://proxy.example.com:1080.\",\n  \"general.proxy.label\": \"Proxy\",\n  \"general.rebuild_database.button\": \"Rebuild\",\n  \"general.rebuild_database.cancel\": \"Cancel\",\n  \"general.rebuild_database.description\": \"If you are experiencing rendering issues, rebuilding the database may solve them.\",\n  \"general.rebuild_database.label\": \"Rebuild Database\",\n  \"general.rebuild_database.title\": \"Rebuild Database\",\n  \"general.rebuild_database.warning.line1\": \"Rebuilding the database will clear all your local data.\",\n  \"general.rebuild_database.warning.line2\": \"Are you sure you want to continue?\",\n  \"general.send_anonymous_data.description\": \"By opting to send anonymized telemetry data, you contribute to improving the overall user experience of Folo.\",\n  \"general.send_anonymous_data.label\": \"Send anonymous data\",\n  \"general.show_quick_timeline.description\": \"Show the quick timeline at the top of the feed list.\",\n  \"general.show_quick_timeline.label\": \"Show feed list timeline\",\n  \"general.show_unread_on_launch.description\": \"Automatically filter to unread content when the app starts.\",\n  \"general.show_unread_on_launch.label\": \"Unread only on launch\",\n  \"general.sidebar_title\": \"General\",\n  \"general.subscription\": \"Subscription\",\n  \"general.subscriptions\": \"Subscriptions\",\n  \"general.timeline\": \"Timeline\",\n  \"general.translation_mode.bilingual\": \"Bilingual Comparison\",\n  \"general.translation_mode.description\": \"Choose how the translated text is displayed in the entry list.\",\n  \"general.translation_mode.label\": \"AI Translation Mode\",\n  \"general.translation_mode.translation-only\": \"Only the translation\",\n  \"general.voices\": \"Voices\",\n  \"integration.builtin.title\": \"Built-in Integration\",\n  \"integration.categories.custom_integrations\": \"Custom Actions\",\n  \"integration.categories.download_tools\": \"Download Tools\",\n  \"integration.categories.knowledge_management\": \"Knowledge Management\",\n  \"integration.categories.media_tools\": \"Media Tools\",\n  \"integration.categories.reading_services\": \"Reading Services\",\n  \"integration.cubox.autoMemo.description\": \"Automatically use Memo mode when text is selected to save to Cubox.\",\n  \"integration.cubox.autoMemo.label\": \"Auto Memo Mode\",\n  \"integration.cubox.enable.description\": \"Show 'Save to Cubox' button if available.\",\n  \"integration.cubox.enable.label\": \"Enable\",\n  \"integration.cubox.title\": \"Cubox\",\n  \"integration.cubox.token.description\": \"Please enter the complete Cubox API URL, format: https://cubox.pro/c/api/save/xxxxxxxxx. You can get it here:\",\n  \"integration.cubox.token.label\": \"Cubox API URL\",\n  \"integration.custom_integrations.actions.delete\": \"Delete integration\",\n  \"integration.custom_integrations.actions.disable\": \"Disable integration\",\n  \"integration.custom_integrations.actions.edit\": \"Edit integration\",\n  \"integration.custom_integrations.actions.enable\": \"Enable integration\",\n  \"integration.custom_integrations.add.button\": \"Add New Integration\",\n  \"integration.custom_integrations.create.error\": \"Failed to create custom integration\",\n  \"integration.custom_integrations.create.success\": \"Custom integration created successfully\",\n  \"integration.custom_integrations.create.title\": \"Create Custom Integration\",\n  \"integration.custom_integrations.delete.success\": \"Custom integration deleted successfully\",\n  \"integration.custom_integrations.edit.error\": \"Failed to update custom integration\",\n  \"integration.custom_integrations.edit.success\": \"Custom integration updated successfully\",\n  \"integration.custom_integrations.edit.title\": \"Edit Custom Integration\",\n  \"integration.custom_integrations.enable.description\": \"Allow creating custom sharing integrations with fetch templates supporting various HTTP methods and configurations.\",\n  \"integration.custom_integrations.enable.label\": \"Enable Custom Integrations\",\n  \"integration.custom_integrations.form.body.description\": \"Request body for POST/PUT/PATCH methods. Supports placeholders and JSON format.\",\n  \"integration.custom_integrations.form.body.label\": \"Request Body\",\n  \"integration.custom_integrations.form.body.placeholder\": \"{\\\"title\\\": \\\"[title]\\\", \\\"url\\\": \\\"[url]\\\", \\\"content\\\": \\\"[content_markdown]\\\"}\",\n  \"integration.custom_integrations.form.fetch_template.label\": \"Fetch Template\",\n  \"integration.custom_integrations.form.headers.add\": \"Add Header\",\n  \"integration.custom_integrations.form.headers.description\": \"Add custom headers as key-value pairs. Values support placeholders\",\n  \"integration.custom_integrations.form.headers.key_placeholder\": \"Header name\",\n  \"integration.custom_integrations.form.headers.label\": \"Headers\",\n  \"integration.custom_integrations.form.headers.value_placeholder\": \"Header value\",\n  \"integration.custom_integrations.form.icon.description\": \"Choose an icon to represent this integration.\",\n  \"integration.custom_integrations.form.icon.label\": \"Icon\",\n  \"integration.custom_integrations.form.method.description\": \"Select the HTTP method for the request.\",\n  \"integration.custom_integrations.form.method.label\": \"HTTP Method\",\n  \"integration.custom_integrations.form.name.label\": \"Integration Name\",\n  \"integration.custom_integrations.form.name.placeholder\": \"Enter integration name\",\n  \"integration.custom_integrations.form.scheme.description\": \"Enter the URL scheme for the external application. Use placeholders like [title], [url], [content_markdown], etc.\",\n  \"integration.custom_integrations.form.scheme.examples.title\": \"Common Examples\",\n  \"integration.custom_integrations.form.scheme.label\": \"URL Scheme\",\n  \"integration.custom_integrations.form.scheme.placeholder\": \"e.g., obsidian://new?vault=MyVault&name=[title]&content=[content_markdown]\",\n  \"integration.custom_integrations.form.type.description\": \"Choose between HTTP API requests or URL scheme redirects to external applications.\",\n  \"integration.custom_integrations.form.type.http\": \"HTTP Request\",\n  \"integration.custom_integrations.form.type.label\": \"Integration Type\",\n  \"integration.custom_integrations.form.type.url_scheme\": \"URL Scheme\",\n  \"integration.custom_integrations.form.url.description\": \"Use placeholders: [title], [url], [content_html], [summary], [content_markdown].\",\n  \"integration.custom_integrations.form.url.label\": \"URL\",\n  \"integration.custom_integrations.form.url.placeholder\": \"https://example.com/api/share\",\n  \"integration.custom_integrations.icons.bookmark\": \"Bookmark\",\n  \"integration.custom_integrations.icons.document\": \"Document\",\n  \"integration.custom_integrations.icons.download\": \"Download\",\n  \"integration.custom_integrations.icons.external_link\": \"External Link\",\n  \"integration.custom_integrations.icons.link\": \"Link\",\n  \"integration.custom_integrations.icons.picture\": \"Picture\",\n  \"integration.custom_integrations.icons.save\": \"Save\",\n  \"integration.custom_integrations.icons.send\": \"Send\",\n  \"integration.custom_integrations.icons.share\": \"Share\",\n  \"integration.custom_integrations.icons.star\": \"Star\",\n  \"integration.custom_integrations.list.empty.button\": \"Create First Integration\",\n  \"integration.custom_integrations.list.empty.description\": \"Create custom sharing integrations with fetch templates to integrate with any service using HTTP requests\",\n  \"integration.custom_integrations.list.empty.title\": \"No custom integrations yet\",\n  \"integration.custom_integrations.list.title\": \"Custom Integrations\",\n  \"integration.custom_integrations.modal.description\": \"Create custom sharing integrations using fetch templates with HTTP methods, URLs, headers, and body. Use placeholders like [title], [url], [content_html], [summary], and [content_markdown].\",\n  \"integration.custom_integrations.placeholders.click_to_copy\": \"Click to copy\",\n  \"integration.custom_integrations.placeholders.description\": \"Click on any placeholder to copy it to your clipboard.\",\n  \"integration.custom_integrations.placeholders.help\": \"Available Placeholders\",\n  \"integration.custom_integrations.preview.body\": \"Request Body\",\n  \"integration.custom_integrations.preview.failed\": \"Failed to generate preview\",\n  \"integration.custom_integrations.preview.generating\": \"Generating preview...\",\n  \"integration.custom_integrations.preview.headers\": \"Headers\",\n  \"integration.custom_integrations.preview.placeholders\": \"Available Placeholders\",\n  \"integration.custom_integrations.preview.request\": \"Request\",\n  \"integration.custom_integrations.preview.title\": \"Preview Request\",\n  \"integration.custom_integrations.status.disabled\": \"Disabled\",\n  \"integration.custom_integrations.title\": \"Custom Integrations\",\n  \"integration.custom_integrations.validation.invalid\": \"Template has errors\",\n  \"integration.custom_integrations.validation.valid\": \"Template is valid\",\n  \"integration.eagle.enable.description\": \"Display 'Save media to Eagle' button when available.\",\n  \"integration.eagle.enable.label\": \"Enable\",\n  \"integration.eagle.title\": \"Eagle\",\n  \"integration.export.button\": \"Export Settings\",\n  \"integration.export.error\": \"Failed to export integration settings\",\n  \"integration.export.success\": \"Integration settings exported successfully\",\n  \"integration.general\": \"General\",\n  \"integration.import.button\": \"Import Settings\",\n  \"integration.import.error\": \"Failed to import integration settings\",\n  \"integration.import.invalid\": \"Invalid integration settings file\",\n  \"integration.import.success\": \"Integration settings imported successfully\",\n  \"integration.instapaper.enable.description\": \"Display 'Save to Instapaper' button when available.\",\n  \"integration.instapaper.enable.label\": \"Enable\",\n  \"integration.instapaper.password.label\": \"Instapaper Password\",\n  \"integration.instapaper.title\": \"Instapaper\",\n  \"integration.instapaper.username.label\": \"Instapaper Username\",\n  \"integration.obsidian.enable.description\": \"Display 'Save to Obsidian' button when available.\",\n  \"integration.obsidian.enable.label\": \"Enable\",\n  \"integration.obsidian.title\": \"Obsidian\",\n  \"integration.obsidian.vaultPath.description\": \"The path to your Obsidian vault.\",\n  \"integration.obsidian.vaultPath.label\": \"Obsidian Vault Path\",\n  \"integration.outline.collection.description\": \"The UUID or urlId of the collection where the documents is saved.\",\n  \"integration.outline.collection.label\": \"Outline Collection\",\n  \"integration.outline.enable.description\": \"Display 'Save to Outline' button when available.\",\n  \"integration.outline.enable.label\": \"Enable\",\n  \"integration.outline.endpoint.description\": \"The URL is 'https://<YOUR_OUTLINE_DOMAIN>/api'.\",\n  \"integration.outline.endpoint.label\": \"Outline API Base URL\",\n  \"integration.outline.title\": \"Outline\",\n  \"integration.outline.token.description\": \"You can get it from your Outline account settings.\",\n  \"integration.outline.token.label\": \"Outline API Key\",\n  \"integration.qbittorrent.enable.description\": \"Display 'Download with qBittorrent' button when available.\",\n  \"integration.qbittorrent.enable.label\": \"Enable\",\n  \"integration.qbittorrent.host.description\": \"The URL of your qBittorrent WebUI, e.g. http://localhost:8080.\",\n  \"integration.qbittorrent.host.label\": \"qBittorrent Host\",\n  \"integration.qbittorrent.password.label\": \"qBittorrent Password\",\n  \"integration.qbittorrent.title\": \"qBittorrent\",\n  \"integration.qbittorrent.username.label\": \"qBittorrent Username\",\n  \"integration.readeck.enable.description\": \"Display 'Save to Readeck' button when available.\",\n  \"integration.readeck.enable.label\": \"Enable\",\n  \"integration.readeck.endpoint.description\": \"The URL is 'https://<YOUR_READECK_DOMAIN>'.\",\n  \"integration.readeck.endpoint.label\": \"Readeck API Base URL\",\n  \"integration.readeck.title\": \"Readeck\",\n  \"integration.readeck.token.description\": \"You can get it from your Readeck account settings.\",\n  \"integration.readeck.token.label\": \"Readeck API Token\",\n  \"integration.readwise.enable.description\": \"Display 'Save to Readwise' button when available.\",\n  \"integration.readwise.enable.label\": \"Enable\",\n  \"integration.readwise.title\": \"Readwise\",\n  \"integration.readwise.token.description\": \"You can get it here:\",\n  \"integration.readwise.token.label\": \"Readwise Access Token\",\n  \"integration.save_ai_summary_as_description.label\": \"Save AI Summary as description\",\n  \"integration.search.placeholder\": \"Search integrations...\",\n  \"integration.sidebar_title\": \"Integration\",\n  \"integration.status.configured\": \"Configured\",\n  \"integration.status.enabled\": \"Enabled\",\n  \"integration.tip\": \"Tip: Your sensitive data is stored locally and is not uploaded to the server.\",\n  \"integration.title\": \"Integration\",\n  \"integration.use_browser_fetch.description\": \"Use browser fetch API for custom integrations instead of Electron's native fetch. Enable for better web compatibility, disable for enhanced security.\",\n  \"integration.use_browser_fetch.label\": \"Use Browser Fetch\",\n  \"integration.zotero.enable.description\": \"Show 'Save to Zotero' button if avilable.\",\n  \"integration.zotero.enable.label\": \"Enable\",\n  \"integration.zotero.title\": \"Zotero\",\n  \"integration.zotero.token.description\": \"Zotero API token, you can get it here:\",\n  \"integration.zotero.token.label\": \"Zotero API Token\",\n  \"integration.zotero.userID.description\": \"Zotero User ID, You can get it here:\",\n  \"integration.zotero.userID.label\": \"Zotero User ID\",\n  \"invitation.activate\": \"Activate\",\n  \"invitation.codeOptions.betaUser\": \"1. Find a beta user who invites you.\",\n  \"invitation.codeOptions.discord\": \"2. Join our Discord server and occasionally get gifts.\",\n  \"invitation.codeOptions.xAccount\": \"3. Follow our X account, and get gifts from time to time.\",\n  \"invitation.confirmModal.cancel\": \"Cancel\",\n  \"invitation.confirmModal.confirm\": \"Do you want to continue?\",\n  \"invitation.confirmModal.continue\": \"Continue\",\n  \"invitation.confirmModal.message\": \"Generating an invitation code will cost you {{INVITATION_PRICE}} <PowerIcon /> Power.\",\n  \"invitation.confirmModal.title\": \"Confirm\",\n  \"invitation.created_at\": \"Created at\",\n  \"invitation.earlyAccess\": \"Folo is currently requires an invitation code to use.\",\n  \"invitation.earlyAccessMessage\": \"😰 Sorry, Folo is currently requires an invitation code to use.\",\n  \"invitation.generate\": \"Generate\",\n  \"invitation.generateButton\": \"Generate New Code\",\n  \"invitation.generateCost\": \"You can spend {{INVITATION_PRICE}} <PowerIcon /> Power to generate an invitation code for your friends.\",\n  \"invitation.getCodeMessage\": \"You can get an invitation code through the following methods:\",\n  \"invitation.limitationMessage\": \"Based on your usage time, you can generate up to {{limitation}} invitation codes.\",\n  \"invitation.newInvitationSuccess\": \"🎉 New invitation generated, invite code is copied\",\n  \"invitation.noInvitations\": \"No invitations\",\n  \"invitation.notUsed\": \"Not used\",\n  \"invitation.sidebar_title\": \"Invitations\",\n  \"invitation.tableHeaders.code\": \"Code\",\n  \"invitation.tableHeaders.creationTime\": \"Creation Time\",\n  \"invitation.tableHeaders.usedBy\": \"Used by\",\n  \"invitation.title\": \"Invitation Code\",\n  \"lists.create\": \"Create New List\",\n  \"lists.created.error\": \"Failed to create list.\",\n  \"lists.created.success\": \"List created successfully!\",\n  \"lists.delete.confirm\": \"Confirm deletion of the list?\",\n  \"lists.delete.error\": \"Failed to delete list.\",\n  \"lists.delete.success\": \"List deleted successfully!\",\n  \"lists.delete.warning\": \"Warning: Once deleted, the list will no longer be available and all content will be permanently deleted and unrecoverable!..\",\n  \"lists.description\": \"Description\",\n  \"lists.earnings\": \"Earn\",\n  \"lists.edit.error\": \"Failed to edit list.\",\n  \"lists.edit.label\": \"Edit\",\n  \"lists.edit.success\": \"List edited successfully!\",\n  \"lists.fee.description\": \"The fee others need to pay you to subscribe to this list.\",\n  \"lists.fee.label\": \"Fee\",\n  \"lists.feeds.actions\": \"Actions\",\n  \"lists.feeds.add.error\": \"Failed to add feed to list.\",\n  \"lists.feeds.add.label\": \"Add\",\n  \"lists.feeds.add.success\": \"Feed added to list.\",\n  \"lists.feeds.delete.error\": \"Failed to remove feed from list.\",\n  \"lists.feeds.delete.success\": \"Feed removed from list.\",\n  \"lists.feeds.id\": \"Feed ID\",\n  \"lists.feeds.label\": \"Feeds\",\n  \"lists.feeds.manage\": \"Manage Feeds\",\n  \"lists.feeds.owner\": \"Owner\",\n  \"lists.feeds.search\": \"Search for feed\",\n  \"lists.feeds.title\": \"Title\",\n  \"lists.image\": \"Image\",\n  \"lists.info\": \"Lists are collections of feeds that you can share or sell for others to subscribe to. Subscribers will synchronize and access all feeds in the list.\",\n  \"lists.manage_list\": \"Manage List\",\n  \"lists.noLists\": \"No lists\",\n  \"lists.select_feeds\": \"Select feeds to add to the current list\",\n  \"lists.submit\": \"Submit\",\n  \"lists.subscriptions\": \"Subs\",\n  \"lists.title\": \"Title\",\n  \"lists.view\": \"View\",\n  \"notifications.channel\": \"Channel\",\n  \"notifications.current\": \"(current client)\",\n  \"notifications.empty.description\": \"Notification channels will appear here after you enable notifications on this device.\",\n  \"notifications.empty.title\": \"No notification channels\",\n  \"notifications.info\": \"Folo offers robust and versatile notification features through <ActionsLink>Actions</ActionsLink>. You can customize notification for specific feeds, views, or keywords. Below are your registered notification channels.\",\n  \"notifications.test\": \"Test Notification\",\n  \"notifications.test_success\": \"Test notification sent successfully.\",\n  \"notifications.token\": \"Client Token\",\n  \"plan.canceled_expires\": \"Canceled - Expires {{date}}\",\n  \"plan.current_plan\": \"Current Plan\",\n  \"plan.descriptions.basic\": \"More feeds plus daily AI summaries and translations.\",\n  \"plan.descriptions.free\": \"Great for beginners.\",\n  \"plan.descriptions.plus\": \"Unlimited AI features with more feeds and automations.\",\n  \"plan.descriptions.pro\": \"Highest limits, full AI access, and top performance.\",\n  \"plan.featureValues.AI_MODEL_SELECTION.curated\": \"Curated models\",\n  \"plan.featureValues.AI_MODEL_SELECTION.high_performance\": \"All high-end models\",\n  \"plan.featureValues.AI_MODEL_SELECTION.none\": \"—\",\n  \"plan.features.AI_BRING_YOUR_OWN_KEY\": \"AI Bring Your Own Key\",\n  \"plan.features.AI_CREDIT\": \"AI Chat Credits\",\n  \"plan.features.AI_MODEL_SELECTION\": \"AI Model Selection\",\n  \"plan.features.BOOSTS\": \"Feed Refresh Acceleration\",\n  \"plan.features.INTEGRATION_SUPPORTED\": \"Third-Party Integrations\",\n  \"plan.features.MAX_ACTIONS\": \"Actions\",\n  \"plan.features.MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"AI Summaries Per Day\",\n  \"plan.features.MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"AI Translations Per Day\",\n  \"plan.features.MAX_AI_REQUESTS_PER_DAY\": \"AI Chats Per Day\",\n  \"plan.features.MAX_AI_REQUESTS_PER_MONTH\": \"AI Chats Per Month\",\n  \"plan.features.MAX_AI_TASKS\": \"AI Tasks\",\n  \"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"AI Text-to-Speech Per Day\",\n  \"plan.features.MAX_INBOXES\": \"Inboxes\",\n  \"plan.features.MAX_LISTS\": \"Lists\",\n  \"plan.features.MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub Subscriptions\",\n  \"plan.features.MAX_SUBSCRIPTIONS\": \"Feed Subscriptions\",\n  \"plan.features.PRIORITY_SUPPORT\": \"Priority Support\",\n  \"plan.features.PRIVATE_SUBSCRIPTION\": \"Private Subscriptions\",\n  \"plan.features.SECURE_IMAGE_PROXY\": \"Secure Image Proxy\",\n  \"plan.manage_subscription\": \"Manage Subscription\",\n  \"plan.renews\": \"Renews {{date}}\",\n  \"plan.trial_ends\": \"Trial ends {{date}}\",\n  \"privacy.privacy\": \"Privacy\",\n  \"privacy.terms\": \"Terms\",\n  \"profile.avatar.cropInstructions\": \"Drag the crop area to adjust your avatar\",\n  \"profile.avatar.dropZoneSubtext\": \"or click to select from your computer\",\n  \"profile.avatar.dropZoneText\": \"Drag and drop an image here\",\n  \"profile.avatar.fileTooLarge\": \"File size must be less than {{size}}\",\n  \"profile.avatar.invalidFileType\": \"Please select a valid image file\",\n  \"profile.avatar.label\": \"Avatar\",\n  \"profile.avatar.processingError\": \"Error processing image\",\n  \"profile.avatar.selectAnother\": \"Select Another\",\n  \"profile.avatar.selectFile\": \"Select File\",\n  \"profile.avatar.uploadError\": \"Failed to upload avatar\",\n  \"profile.avatar.uploadSuccess\": \"Avatar uploaded successfully\",\n  \"profile.avatar.uploadTitle\": \"Upload Avatar\",\n  \"profile.change_password.email_required\": \"You need to sign in with email first.\",\n  \"profile.change_password.label\": \"Change Password\",\n  \"profile.confirm_password.label\": \"Confirm Password\",\n  \"profile.current_password.label\": \"Current Password\",\n  \"profile.danger_zone\": \"Danger Zone\",\n  \"profile.delete_account.confirm_description\": \"Are you sure you want to delete your account?\\nThis action is irreversible and may take up to two days to take effect.\",\n  \"profile.delete_account.confirm_title\": \"Delete account\",\n  \"profile.delete_account.label\": \"Delete Account\",\n  \"profile.edit_email\": \"Edit Email\",\n  \"profile.edit_profile\": \"Edit Profile\",\n  \"profile.email.change\": \"Change Email\",\n  \"profile.email.change_note\": \"If you want to change your email, you should verify your new email.\",\n  \"profile.email.changed\": \"Email changed.\",\n  \"profile.email.changed_verification_sent\": \"Email to verify the new email has been sent.\",\n  \"profile.email.label\": \"Email\",\n  \"profile.email.send_verification\": \"Send Verification Email\",\n  \"profile.email.unverified\": \"Unverified\",\n  \"profile.email.verification_sent\": \"Email verification sent\",\n  \"profile.email.verified\": \"Verified\",\n  \"profile.email.verify_email\": \"Please verify your email ({{email_address}}) to continue\",\n  \"profile.email.verify_status\": \"Your email is {{status}}\",\n  \"profile.handle.description\": \"Your unique identifier.\",\n  \"profile.handle.label\": \"Handle\",\n  \"profile.link_social.authentication\": \"Authentication\",\n  \"profile.link_social.link\": \"Link\",\n  \"profile.link_social.link_failed\": \"Failed to link account.\",\n  \"profile.link_social.unlink.action\": \"Unlink\",\n  \"profile.link_social.unlink.confirm\": \"Are you sure you want to unlink your account?\",\n  \"profile.link_social.unlink.success\": \"Social account unlinked.\",\n  \"profile.link_social.unlink.title\": \"Unlink account\",\n  \"profile.name.description\": \"Your public display name.\",\n  \"profile.name.label\": \"Display Name\",\n  \"profile.new_password.label\": \"New Password\",\n  \"profile.no_password\": \"<Link>Reset</Link> your password to set a new one.\",\n  \"profile.password.label\": \"Password\",\n  \"profile.profile.bio\": \"Bio\",\n  \"profile.profile.bio_placeholder\": \"Tell us about yourself...\",\n  \"profile.profile.changed\": \"Profile updated\",\n  \"profile.profile.save\": \"Save\",\n  \"profile.profile.social_links\": \"Social Links\",\n  \"profile.profile.social_links_discord\": \"Discord ID\",\n  \"profile.profile.social_links_facebook\": \"Facebook ID\",\n  \"profile.profile.social_links_github\": \"Github ID\",\n  \"profile.profile.social_links_instagram\": \"Instagram ID\",\n  \"profile.profile.social_links_twitter\": \"Twitter ID\",\n  \"profile.profile.social_links_youtube\": \"Youtube ID\",\n  \"profile.profile.website\": \"Website\",\n  \"profile.reset_password_mail_sent\": \"Reset password mail sent.\",\n  \"profile.security\": \"Security\",\n  \"profile.set_avatar\": \"Set Avatar\",\n  \"profile.sidebar_title\": \"Profile\",\n  \"profile.sign_out.confirm_message\": \"Are you sure you want to sign out?\",\n  \"profile.sign_out.confirm_title\": \"Confirm sign out\",\n  \"profile.submit\": \"Submit\",\n  \"profile.title\": \"Profile Settings\",\n  \"profile.totp_code.init\": \"Scan the QR code with your TOTP app\",\n  \"profile.totp_code.invalid\": \"Invalid TOTP code.\",\n  \"profile.totp_code.label\": \"TOTP Code\",\n  \"profile.totp_code.title\": \"Enter TOTP Code\",\n  \"profile.two_factor.disable\": \"Disable 2FA\",\n  \"profile.two_factor.disabled\": \"Two-factor authentication disabled.\",\n  \"profile.two_factor.enable\": \"Enable 2FA\",\n  \"profile.two_factor.enable_failed\": \"Failed to enable 2FA.\",\n  \"profile.two_factor.enable_notice\": \"You need to enable two-factor authentication to perform this action.\",\n  \"profile.two_factor.enabled\": \"Two-factor authentication enabled.\",\n  \"profile.two_factor.invalid_password\": \"Invalid password or something went wrong.\",\n  \"profile.two_factor.label\": \"Two Factor\",\n  \"profile.two_factor.no_password\": \"You need to <Link>set</Link> a password before enabling 2FA.\",\n  \"profile.two_factor.setup.description\": \"Scan the QR code above with your authenticator app, then enter the 6-digit code shown in the app.\",\n  \"profile.two_factor.setup.title\": \"2FA Setup\",\n  \"profile.two_factor.verify_failed\": \"Failed to verify code\",\n  \"profile.updateSuccess\": \"Profile updated.\",\n  \"profile.update_password_success\": \"Password updated.\",\n  \"referral.description\": \"Share Folo with a friend! Extend your Pro Preview and future benefits, and your friends can also get a 45-day trial period. <Link>Learn more</Link>.\",\n  \"referral.invited_friend_status.pending\": \"Pending validation\",\n  \"referral.invited_friend_status.valid\": \"Valid\",\n  \"referral.link\": \"Your Invite Link:\",\n  \"referral.pro_status.preview\": \"Your Pro Preview Status: Expires {{dateString}} ({{daysLeft}} days left)\",\n  \"referral.pro_status.trial\": \"Your Current Tier: Free\",\n  \"referral.pro_status.user\": \"Your Pro Preview Status: Active\",\n  \"reviewPrompt.description\": \"If Folo has been helpful, leaving a rating really helps. If not, tell us what we can improve.\",\n  \"reviewPrompt.loveIt\": \"Yes, I like it\",\n  \"reviewPrompt.notReally\": \"Not really\",\n  \"reviewPrompt.title\": \"Enjoying Folo?\",\n  \"rsshub.addModal.access_key_label\": \"Access Key (Optional)\",\n  \"rsshub.addModal.add\": \"Add\",\n  \"rsshub.addModal.base_url_label\": \"Base URL\",\n  \"rsshub.addModal.description\": \"To use your own instance in Folo, you must add the following environment variables to it.\",\n  \"rsshub.add_new_instance\": \"Add New Instance\",\n  \"rsshub.description\": \"RSSHub is a community-driven open-source RSS network. Folo provides a built-in dedicated instance and uses it to support thousands of subscription contents, you can also achieve more stable content acquisition by using your own or third-party instances.\",\n  \"rsshub.public_instances\": \"Available Instances\",\n  \"rsshub.table.delete.confirm\": \"Are you sure you want to delete this instance?\",\n  \"rsshub.table.delete.label\": \"Delete\",\n  \"rsshub.table.delete.success\": \"Instance deleted successfully.\",\n  \"rsshub.table.description\": \"Description\",\n  \"rsshub.table.edit\": \"Edit\",\n  \"rsshub.table.inuse\": \"In Use\",\n  \"rsshub.table.limit_reached\": \"Limit Reached\",\n  \"rsshub.table.official\": \"Official\",\n  \"rsshub.table.owner\": \"Owner\",\n  \"rsshub.table.price\": \"Monthly Price\",\n  \"rsshub.table.private\": \"Private\",\n  \"rsshub.table.unavailable\": \"Unavailable\",\n  \"rsshub.table.unlimited\": \"Unlimited\",\n  \"rsshub.table.use\": \"Use\",\n  \"rsshub.table.userCount\": \"User Count\",\n  \"rsshub.table.userLimit\": \"User Limit\",\n  \"rsshub.table.yours\": \"Yours\",\n  \"rsshub.useModal.about\": \"About this Instance\",\n  \"rsshub.useModal.month\": \"month\",\n  \"rsshub.useModal.months_label\": \"The number of months you want to purchase\",\n  \"rsshub.useModal.purchase_expires_at\": \"You have purchased this Instance, and your purchase expires at\",\n  \"rsshub.useModal.title\": \"RSSHub Instance\",\n  \"rsshub.useModal.useWith\": \"Use with {{amount}} <Power />\",\n  \"subscription.actions.comingSoon\": \"Coming soon\",\n  \"subscription.actions.current\": \"Current plan\",\n  \"subscription.actions.manage_error\": \"Something went wrong while opening subscription management.\",\n  \"subscription.actions.restore\": \"Restore Purchases\",\n  \"subscription.actions.restore_error\": \"Something went wrong while restoring your subscription.\",\n  \"subscription.actions.restore_not_found\": \"No active Apple subscription was found to restore.\",\n  \"subscription.actions.restore_success\": \"Subscription restored.\",\n  \"subscription.actions.upgrade\": \"Upgrade\",\n  \"subscription.actions.upgrade_error\": \"Something went wrong while starting checkout.\",\n  \"subscription.badge.popular\": \"Most popular\",\n  \"subscription.billing.monthly\": \"Monthly\",\n  \"subscription.billing.yearly\": \"Yearly\",\n  \"subscription.billing.yearly_savings\": \"Save {{value}}%\",\n  \"subscription.discount.tag\": \"Save {{value}}%\",\n  \"subscription.feature.included\": \"Included\",\n  \"subscription.price.free\": \"Free\",\n  \"subscription.price.per_month\": \"per month\",\n  \"subscription.price.per_month_billed_yearly\": \"per month, billed yearly\",\n  \"subscription.price.per_year\": \"per year\",\n  \"subscription.summary.active\": \"You have an active subscription.\",\n  \"subscription.summary.current\": \"{{plan}} plan\",\n  \"subscription.summary.free\": \"Free plan\",\n  \"subscription.summary.free_description\": \"Upgrade to unlock more feeds, actions, and AI features.\",\n  \"subscription.summary.title\": \"Your subscription\",\n  \"subscription.summary.trial_expiring\": \"Trial ends {{date}} ({{days}} days left)\",\n  \"subscription.unavailable\": \"Subscriptions are not available right now.\",\n  \"titles.about\": \"About\",\n  \"titles.account\": \"Account\",\n  \"titles.actions\": \"Actions\",\n  \"titles.ai\": \"AI\",\n  \"titles.appearance\": \"Appearance\",\n  \"titles.cli\": \"CLI\",\n  \"titles.data_control\": \"Data Control\",\n  \"titles.feeds\": \"Feeds\",\n  \"titles.general\": \"General\",\n  \"titles.integration\": \"Integration\",\n  \"titles.invitations\": \"Invitations\",\n  \"titles.lists\": \"Lists\",\n  \"titles.notifications\": \"Notifications\",\n  \"titles.plan.long\": \"Upgrade your plan\",\n  \"titles.plan.short\": \"Plan\",\n  \"titles.power\": \"Power\",\n  \"titles.privacy\": \"Privacy\",\n  \"titles.referral.long\": \"Invite Friends & Extend Pro\",\n  \"titles.referral.short\": \"Invite & Earn\",\n  \"titles.shortcuts\": \"Shortcuts\",\n  \"titles.sign_out\": \"Sign Out\",\n  \"titles.subscription.long\": \"Manage your subscription\",\n  \"titles.subscription.short\": \"Subscription\",\n  \"titles.token_usage\": \"AI Credits Usage\",\n  \"wallet.balance.activePoints\": \"Active Points\",\n  \"wallet.balance.dailyReward\": \"Your Daily Reward\",\n  \"wallet.balance.title\": \"Your Balance\",\n  \"wallet.balance.withdrawable\": \"Withdrawable\",\n  \"wallet.balance.withdrawableTooltip\": \"Withdrawable Power includes both the tips you've received and the Power you've recharged.\",\n  \"wallet.claim.button.claim\": \"Claim Daily Power\",\n  \"wallet.claim.button.claimed\": \"Claimed today\",\n  \"wallet.claim.tooltip.alreadyClaimed\": \"You have already claimed today.\",\n  \"wallet.claim.tooltip.canClaim\": \"Claim your {{amount}} Daily Power now!\",\n  \"wallet.create.button\": \"Create Wallet\",\n  \"wallet.create.description\": \"Create a free wallet to receive <PowerIcon /> <strong>Power</strong>, which can be used to reward creators and also get rewarded for your content contributions.\",\n  \"wallet.power.dailyClaim\": \"You can claim {{amount}} free Power daily, which can be used to tip RSS entries on Folo.\",\n  \"wallet.power.rewardDescription\": \"All active users on Folo are eligible for daily power rewards.\",\n  \"wallet.power.rewardDescription2\": \"Based on your level and past activities, you can receive a <Balance /> reward today. <Link>Learn more.</Link>\",\n  \"wallet.ranking.level\": \"Level\",\n  \"wallet.ranking.name\": \"Name\",\n  \"wallet.ranking.power\": \"Power\",\n  \"wallet.ranking.rank\": \"Rank\",\n  \"wallet.ranking.title\": \"Power Ranking\",\n  \"wallet.rewardDescription.description1\": \"The daily rewards for each user are based on two factors: user level and user activity points.\",\n  \"wallet.rewardDescription.description2\": \"User level: Determined by the user's Power ranking compared to all other users.\",\n  \"wallet.rewardDescription.description3\": \"User Activity: Engaging with various Folo features can boost activity. Rewards range from a minimum of 1x to a maximum of 5x.\",\n  \"wallet.rewardDescription.level\": \"User Level\",\n  \"wallet.rewardDescription.percentage\": \"Ranking Percentage\",\n  \"wallet.rewardDescription.reward\": \"Reward Multiplier\",\n  \"wallet.rewardDescription.title\": \"Reward Description\",\n  \"wallet.rewardDescription.total\": \"Total Reward Per Day\",\n  \"wallet.sidebar_title\": \"Power\",\n  \"wallet.transactions.amount\": \"Amount\",\n  \"wallet.transactions.date\": \"Date\",\n  \"wallet.transactions.empty.description\": \"Tips, purchases, withdrawals, and airdrops will appear here once they happen.\",\n  \"wallet.transactions.empty.title\": \"No transactions yet\",\n  \"wallet.transactions.from\": \"From\",\n  \"wallet.transactions.more\": \"View more through the blockchain explorer.\",\n  \"wallet.transactions.noTransactions\": \"No transactions\",\n  \"wallet.transactions.title\": \"Transactions\",\n  \"wallet.transactions.to\": \"To\",\n  \"wallet.transactions.tx\": \"Tx\",\n  \"wallet.transactions.type\": \"Type\",\n  \"wallet.transactions.types.airdrop\": \"Airdrop\",\n  \"wallet.transactions.types.all\": \"All\",\n  \"wallet.transactions.types.burn\": \"Burn\",\n  \"wallet.transactions.types.mint\": \"Mint\",\n  \"wallet.transactions.types.purchase\": \"Purchase\",\n  \"wallet.transactions.types.tip\": \"Tip\",\n  \"wallet.transactions.types.withdraw\": \"Withdraw\",\n  \"wallet.transactions.you\": \"You\",\n  \"wallet.withdraw.addressLabel\": \"Your Ethereum Address\",\n  \"wallet.withdraw.amountLabel\": \"Amount\",\n  \"wallet.withdraw.availableBalance\": \"You have <Balance></Balance> withdrawable Power in your wallet.\",\n  \"wallet.withdraw.button\": \"Withdraw\",\n  \"wallet.withdraw.error\": \"Withdrawal failed: {{error}}\",\n  \"wallet.withdraw.modalTitle\": \"Withdraw Power\",\n  \"wallet.withdraw.receiveRSS3\": \"You will receive {{amount}} RSS3\",\n  \"wallet.withdraw.submitButton\": \"Submit\",\n  \"wallet.withdraw.success\": \"Withdrawal successful!\",\n  \"wallet.withdraw.toRss3Label\": \"Withdraw as RSS3\"\n}\n"
  },
  {
    "path": "locales/settings/fr-FR.json",
    "content": "{\n  \"about.aiOnboardingDescription\": \"Revisiter la visite d'intégration interactive.\",\n  \"about.appTip\": \"Fonctionnalités de l'appli\",\n  \"about.appTipDescription\": \"Introduction aux fonctionnalités de l'appli et guide d'utilisation.\",\n  \"about.changelog\": \"Journal des modifications\",\n  \"about.changelogDescription\": \"Voir les nouveautés de chaque version\",\n  \"about.checkForUpdates\": \"Vérifier les mises à jour\",\n  \"about.checkNow\": \"Vérifier maintenant\",\n  \"about.checkingForUpdates\": \"Vérification des mises à jour...\",\n  \"about.copyEnvironment\": \"Copier l'environnement\",\n  \"about.environmentCopied\": \"Infos d'environnement copiées\",\n  \"about.feedbackInfo\": \"{{appName}} ({{commitSha}}) est open-source et activement développé sur GitHub. Si vous avez des commentaires ou des suggestions, n'hésitez pas à <OpenIssueLink>ouvrir un ticket</OpenIssueLink> sur notre GitHub.\",\n  \"about.iconLibrary\": \"La bibliothèque d'icônes utilisée est protégée par les droits d'auteur de <IconLibraryLink /> et ne peut pas être redistribuée.\",\n  \"about.legal\": \"Légal\",\n  \"about.licenseInfo\": \"Copyright © {{currentYear}} {{appName}}. Tous droits réservés.\",\n  \"about.noUpdateAvailable\": \"Vous avez la dernière version\",\n  \"about.privacyPolicy\": \"Politique de confidentialité\",\n  \"about.projectLicense\": \"{{appName}} est sous licence GNU General Public License version 3 avec des exceptions supplémentaires.\",\n  \"about.rateFolo\": \"Noter Folo\",\n  \"about.rateFoloDescription\": \"Laissez une note pour soutenir le produit.\",\n  \"about.resources\": \"Ressources & Contributions\",\n  \"about.sendFeedback\": \"Envoyer un retour\",\n  \"about.sendFeedbackDescription\": \"Dites-nous ce qui pourrait être amélioré.\",\n  \"about.sidebar_title\": \"À propos\",\n  \"about.socialMedia\": \"Réseaux sociaux\",\n  \"about.support\": \"Support\",\n  \"about.termsOfService\": \"Conditions d'utilisation\",\n  \"about.updateAvailable\": \"Mise à jour disponible !\",\n  \"about.updateCheckFailed\": \"Échec de la vérification des mises à jour\",\n  \"about.updateDescription\": \"Maintenez votre appli à jour avec les dernières fonctionnalités et améliorations\",\n  \"about.viewChangelog\": \"Voir le journal des modifications\",\n  \"actions.actionName\": \"Action {{number}}\",\n  \"actions.action_card.add\": \"Ajouter\",\n  \"actions.action_card.all\": \"Tout\",\n  \"actions.action_card.and\": \"Et\",\n  \"actions.action_card.block\": \"Bloquer\",\n  \"actions.action_card.block_rules\": \"Règles de blocage\",\n  \"actions.action_card.custom_filters\": \"Filtres personnalisés\",\n  \"actions.action_card.empty.cta\": \"Créer votre première règle\",\n  \"actions.action_card.empty.description\": \"Créez votre première règle d'action pour traiter automatiquement vos flux.\",\n  \"actions.action_card.empty.start\": \"Commencez ici !\",\n  \"actions.action_card.empty.title\": \"Aucune action pour le moment\",\n  \"actions.action_card.enable_readability\": \"Activer la lisibilité\",\n  \"actions.action_card.feed_options.entry_attachments_duration\": \"Durée de la vidéo\",\n  \"actions.action_card.feed_options.entry_author\": \"Auteur\",\n  \"actions.action_card.feed_options.entry_content\": \"Contenu\",\n  \"actions.action_card.feed_options.entry_media_length\": \"Longueur du média\",\n  \"actions.action_card.feed_options.entry_title\": \"Titre\",\n  \"actions.action_card.feed_options.entry_url\": \"URL\",\n  \"actions.action_card.feed_options.feed_category\": \"Catégorie du flux\",\n  \"actions.action_card.feed_options.feed_title\": \"Titre du flux\",\n  \"actions.action_card.feed_options.feed_url\": \"URL du flux\",\n  \"actions.action_card.feed_options.site_url\": \"URL du site\",\n  \"actions.action_card.feed_options.status\": \"Statut\",\n  \"actions.action_card.feed_options.subscription_view\": \"Vue d'abonnement\",\n  \"actions.action_card.field\": \"Champ\",\n  \"actions.action_card.from\": \"De\",\n  \"actions.action_card.generate_summary\": \"Générer un résumé avec l'IA\",\n  \"actions.action_card.name\": \"Nom\",\n  \"actions.action_card.new_entry_notification\": \"Notification de nouvelle entrée\",\n  \"actions.action_card.no_translation\": \"Pas de traduction\",\n  \"actions.action_card.operation_options.contains\": \"Contient\",\n  \"actions.action_card.operation_options.does_not_contain\": \"Ne contient pas\",\n  \"actions.action_card.operation_options.is_equal_to\": \"Est égal à\",\n  \"actions.action_card.operation_options.is_greater_than\": \"Est plus grand que\",\n  \"actions.action_card.operation_options.is_less_than\": \"Est plus petit que\",\n  \"actions.action_card.operation_options.is_not_equal_to\": \"N'est pas égal à\",\n  \"actions.action_card.operation_options.matches_regex\": \"Correspond à l'expression régulière\",\n  \"actions.action_card.operator\": \"Opérateur\",\n  \"actions.action_card.or\": \"Ou\",\n  \"actions.action_card.rewrite_rules\": \"Règles de réécriture\",\n  \"actions.action_card.settings\": \"Paramètres\",\n  \"actions.action_card.silence\": \"Silence\",\n  \"actions.action_card.source_content\": \"Voir le contenu source\",\n  \"actions.action_card.star\": \"Favori\",\n  \"actions.action_card.summary.action_count\": \"{{count}} actions activées\",\n  \"actions.action_card.summary.active\": \"Actif\",\n  \"actions.action_card.summary.copy\": \"Copier dans le presse-papier\",\n  \"actions.action_card.summary.delete\": \"Supprimer\",\n  \"actions.action_card.summary.delete_message\": \"Êtes-vous sûr de vouloir supprimer cette règle ? Cette action est irréversible.\",\n  \"actions.action_card.summary.delete_title\": \"Supprimer la règle\",\n  \"actions.action_card.summary.disabled\": \"Désactivé\",\n  \"actions.action_card.summary.empty\": \"Aucune règle pour le moment\",\n  \"actions.action_card.summary.export\": \"Exporter vers un fichier\",\n  \"actions.action_card.summary.helper\": \"Automatisez votre flux de travail avec des déclencheurs et des actions précis.\",\n  \"actions.action_card.summary.import\": \"Importer\",\n  \"actions.action_card.summary.import_clipboard\": \"Importer depuis le presse-papier\",\n  \"actions.action_card.summary.import_file\": \"Importer depuis un fichier\",\n  \"actions.action_card.summary.no_actions\": \"Aucune action configurée pour le moment\",\n  \"actions.action_card.summary.rule_count\": \"{{count}} règles\",\n  \"actions.action_card.summary.share\": \"Partager\",\n  \"actions.action_card.summary.toggle\": \"Basculer le statut de la règle\",\n  \"actions.action_card.then_do\": \"Alors faire...\",\n  \"actions.action_card.to\": \"À\",\n  \"actions.action_card.translate_into\": \"Traduire\",\n  \"actions.action_card.value\": \"Valeur\",\n  \"actions.action_card.webhooks\": \"Webhooks\",\n  \"actions.action_card.when_feeds_match\": \"Quand les flux correspondent...\",\n  \"actions.condition\": \"Condition\",\n  \"actions.conditions\": \"Conditions\",\n  \"actions.edit_condition\": \"Modifier la condition\",\n  \"actions.edit_rewrite_rule\": \"Modifier la règle de réécriture\",\n  \"actions.edit_rule\": \"Modifier la règle\",\n  \"actions.edit_webhook\": \"Modifier le webhook\",\n  \"actions.info\": \"Les actions sont des collections de règles que vous pouvez automatiser pour effectuer des tâches côté serveur ou client.\",\n  \"actions.navigate.prompt\": \"Vous avez des modifications d'action non enregistrées. Êtes-vous sûr de vouloir quitter ?\",\n  \"actions.newRule\": \"Nouvelle règle\",\n  \"actions.save\": \"Enregistrer\",\n  \"actions.saveSuccess\": \"🎉 Actions enregistrées.\",\n  \"actions.sidebar_title\": \"Actions\",\n  \"actions.title\": \"Actions\",\n  \"ai.personalize.prompt.description\": \"Parlez de vous à Folo et de votre façon de lire.\",\n  \"ai.personalize.prompt.label\": \"Invite de personnalisation\",\n  \"ai.personalize.title\": \"Personnaliser\",\n  \"ai.shortcuts.title\": \"Raccourcis\",\n  \"appearance.accent_color.description\": \"Choisissez la couleur d'accentuation pour l'interface de l'application.\",\n  \"appearance.accent_color.label\": \"Couleur d'accentuation\",\n  \"appearance.code_highlight_theme.description\": \"Ajustez le thème de mise en évidence du code.\",\n  \"appearance.code_highlight_theme.label\": \"Thème du code\",\n  \"appearance.code_highlighting.title\": \"Mise en évidence du code\",\n  \"appearance.common.title\": \"Commun\",\n  \"appearance.content\": \"Contenu\",\n  \"appearance.content_display.title\": \"Affichage du contenu\",\n  \"appearance.content_font.default\": \"Défaut (Police UI)\",\n  \"appearance.content_font.description\": \"Ajustez la police utilisée pour le contenu de lecture.\",\n  \"appearance.content_font.label\": \"Police du contenu\",\n  \"appearance.content_font_size\": \"Taille de police du contenu\",\n  \"appearance.content_line_height.description\": \"Ajustez l'espacement entre les lignes de texte dans les articles.\",\n  \"appearance.content_line_height.label\": \"Hauteur de ligne\",\n  \"appearance.content_line_height.loose\": \"Lâche\",\n  \"appearance.content_line_height.normal\": \"Normal\",\n  \"appearance.content_line_height.relaxed\": \"Relaxé\",\n  \"appearance.content_line_height.snug\": \"Serré\",\n  \"appearance.content_line_height.tight\": \"Étroit\",\n  \"appearance.custom_css.button\": \"Modifier\",\n  \"appearance.custom_css.description\": \"Style CSS personnalisé pour le contenu.\",\n  \"appearance.custom_css.label\": \"CSS personnalisé\",\n  \"appearance.custom_font\": \"Police personnalisée\",\n  \"appearance.customization.title\": \"Personnalisation\",\n  \"appearance.customize_sub_tabs.description\": \"Personnalisez les onglets d'abonnement à votre goût.\",\n  \"appearance.customize_sub_tabs.label\": \"Personnaliser les onglets\",\n  \"appearance.customize_toolbar.description\": \"Personnalisez la barre d'outils de contenu à votre goût.\",\n  \"appearance.customize_toolbar.label\": \"Personnaliser la barre d'outils\",\n  \"appearance.date_format.description\": \"Ajustez le format d'affichage de la date.\",\n  \"appearance.date_format.label\": \"Format de date\",\n  \"appearance.font.custom\": \"Personnalisé\",\n  \"appearance.font.system\": \"Système UI\",\n  \"appearance.font_scaling.content_different.description\": \"Définir une taille de police indépendante pour le contenu des articles.\",\n  \"appearance.font_scaling.content_different.label\": \"Utiliser une taille différente pour le contenu\",\n  \"appearance.font_scaling.content_size.description\": \"Définir la taille de police pour le contenu des articles.\",\n  \"appearance.font_scaling.content_size.l\": \"Grand\",\n  \"appearance.font_scaling.content_size.label\": \"Taille de police du contenu\",\n  \"appearance.font_scaling.content_size.m\": \"Défaut\",\n  \"appearance.font_scaling.content_size.s\": \"Petit\",\n  \"appearance.font_scaling.content_size.xl\": \"Plus grand\",\n  \"appearance.font_scaling.content_size.xs\": \"Plus petit\",\n  \"appearance.font_scaling.scale.description\": \"Ajustez le facteur d'échelle de la taille de police.\",\n  \"appearance.font_scaling.scale.label\": \"Échelle de police\",\n  \"appearance.font_scaling.size.l\": \"Grand\",\n  \"appearance.font_scaling.size.m\": \"Défaut\",\n  \"appearance.font_scaling.size.s\": \"Petit\",\n  \"appearance.font_scaling.size.xl\": \"Plus grand\",\n  \"appearance.font_scaling.size.xs\": \"Plus petit\",\n  \"appearance.font_scaling.system.description\": \"Suivre les paramètres de taille de police d'accessibilité du système.\",\n  \"appearance.font_scaling.system.label\": \"Utiliser l'échelle système\",\n  \"appearance.font_scaling.title\": \"Mise à l'échelle de la police\",\n  \"appearance.fonts\": \"Polices\",\n  \"appearance.general\": \"Général\",\n  \"appearance.global_font.default\": \"Suivre le système\",\n  \"appearance.global_font_size.description\": \"Ajustez la taille globale du texte.\",\n  \"appearance.global_font_size.label\": \"Taille de police globale\",\n  \"appearance.guess_code_language.description\": \"Principaux langages de programmation qui utilisent des modèles pour déduire les blocs de code non étiquetés.\",\n  \"appearance.guess_code_language.label\": \"Deviner le langage du code\",\n  \"appearance.hide_extra_badge.description\": \"Masquer les badges spéciaux du flux dans la barre latérale, ex. Boost, Réclamé.\",\n  \"appearance.hide_extra_badge.label\": \"Masquer les badges spéciaux\",\n  \"appearance.hide_recent_reader.description\": \"Masquer les lecteurs récents et le nombre de vues dans l'entrée.\",\n  \"appearance.hide_recent_reader.label\": \"Masquer lecteurs récents & vues\",\n  \"appearance.interface_window.title\": \"Interface & Fenêtre\",\n  \"appearance.misc\": \"Divers\",\n  \"appearance.modal_overlay.description\": \"Afficher la superposition modale.\",\n  \"appearance.modal_overlay.label\": \"Afficher la superposition modale\",\n  \"appearance.opaque_sidebars.description\": \"Rendre l'arrière-plan de la barre latérale transparent.\",\n  \"appearance.opaque_sidebars.label\": \"Barres latérales opaques\",\n  \"appearance.reader_render_inline_style.description\": \"Permet le rendu du style en ligne du HTML original.\",\n  \"appearance.reader_render_inline_style.label\": \"Rendre le style en ligne\",\n  \"appearance.reading_view.title\": \"Vue de lecture\",\n  \"appearance.reduce_motion.description\": \"Réduire le mouvement des éléments pour améliorer les performances et réduire la consommation d'énergie.\",\n  \"appearance.reduce_motion.label\": \"Réduire le mouvement\",\n  \"appearance.save\": \"Enregistrer\",\n  \"appearance.sidebar\": \"Barre latérale\",\n  \"appearance.sidebar_title\": \"Apparence\",\n  \"appearance.subscription_list.title\": \"Liste d'abonnements\",\n  \"appearance.subscriptions\": \"Abonnements\",\n  \"appearance.system_integration.title\": \"Intégration système\",\n  \"appearance.text_size.default\": \"Défaut\",\n  \"appearance.text_size.label\": \"Taille du texte global\",\n  \"appearance.text_size.large\": \"Grand\",\n  \"appearance.text_size.medium\": \"Moyen\",\n  \"appearance.text_size.smaller\": \"Plus petit\",\n  \"appearance.theme.dark\": \"Sombre\",\n  \"appearance.theme.description\": \"Ajustez le thème global de l'application.\",\n  \"appearance.theme.label\": \"Thème\",\n  \"appearance.theme.light\": \"Clair\",\n  \"appearance.theme.system\": \"Système\",\n  \"appearance.thumbnail_ratio.description\": \"Le ratio de la vignette dans la liste des entrées.\",\n  \"appearance.thumbnail_ratio.original\": \"Original\",\n  \"appearance.thumbnail_ratio.square\": \"Carré\",\n  \"appearance.thumbnail_ratio.title\": \"Ratio de vignette\",\n  \"appearance.title\": \"Apparence\",\n  \"appearance.typography.title\": \"Typographie\",\n  \"appearance.ui_font.description\": \"Ajustez la police utilisée pour les éléments de l'interface utilisateur.\",\n  \"appearance.ui_font.label\": \"Police globale\",\n  \"appearance.unread_count.badge.description\": \"Afficher le nombre de non lus comme un badge dans l'icône du dock.\",\n  \"appearance.unread_count.badge.label\": \"Afficher comme badge\",\n  \"appearance.unread_count.label\": \"Nombre de non lus\",\n  \"appearance.unread_count.sidebar.description\": \"Afficher le nombre de non lus à côté des flux et des groupes.\",\n  \"appearance.unread_count.sidebar.title\": \"Afficher le nombre de non lus\",\n  \"appearance.unread_count.view_and_subscription.description\": \"Afficher le nombre de non lus dans la vue et la liste d'abonnement.\",\n  \"appearance.unread_count.view_and_subscription.label\": \"Afficher dans Vue et Abonnement\",\n  \"appearance.use_pointer_cursor.description\": \"Lorsque la souris survole un élément interactif, le curseur apparaît comme une main.\",\n  \"appearance.use_pointer_cursor.label\": \"Utiliser le curseur main\",\n  \"appearance.words.customize\": \"Personnaliser\",\n  \"cli.description\": \"Exécutez Folo CLI depuis n'importe quel terminal avec npx en utilisant folocli@latest, sans installation globale. L'application Desktop peut synchroniser votre connexion actuelle en un clic.\",\n  \"cli.desktop_sync\": \"Commande de synchronisation Desktop\",\n  \"cli.global_install\": \"Exécuter la dernière version avec npx\",\n  \"cli.install\": \"Synchroniser la connexion Desktop\",\n  \"cli.install_failed\": \"Échec de la synchronisation de la connexion Desktop vers le CLI\",\n  \"cli.install_success\": \"Connexion Desktop synchronisée vers le CLI\",\n  \"cli.installed\": \"Connecté\",\n  \"cli.not_available\": \"npx n'est pas disponible. Installez d'abord Node.js et npm.\",\n  \"cli.not_installed\": \"Non connecté\",\n  \"cli.package\": \"Package\",\n  \"cli.path\": \"Chemin du fichier de configuration\",\n  \"cli.require_login\": \"Connectez-vous d'abord à Folo Desktop pour synchroniser la connexion CLI.\",\n  \"cli.runtime_missing\": \"Node.js/npm requis\",\n  \"cli.runtime_ready\": \"Node.js/npm prêt\",\n  \"cli.title\": \"Folo CLI\",\n  \"cli.uninstall\": \"Effacer la connexion CLI\",\n  \"cli.uninstall_failed\": \"Échec de l'effacement de la connexion CLI\",\n  \"cli.uninstall_success\": \"Connexion CLI effacée\",\n  \"common.give_star\": \"<HeartIcon />Vous aimez notre produit ? <Link>Donnez-nous une étoile sur GitHub !</Link>\",\n  \"control.paid_badge.basic_or_higher\": \"Cette fonctionnalité nécessite un plan Basique ou supérieur\",\n  \"control.paid_badge.free_limited\": \"Cette fonctionnalité est limitée pour le plan gratuit\",\n  \"customizeToolbar.more_actions.description\": \"Sera affiché dans le menu déroulant.\",\n  \"customizeToolbar.more_actions.title\": \"Plus d'actions\",\n  \"customizeToolbar.quick_actions.description\": \"Personnalisez et réorganisez vos actions fréquemment utilisées.\",\n  \"customizeToolbar.quick_actions.title\": \"Actions rapides\",\n  \"customizeToolbar.reset_layout\": \"Réinitialiser la disposition par défaut\",\n  \"customizeToolbar.title\": \"Personnaliser la barre d'outils\",\n  \"data_control.app_cache_limit.description\": \"La taille maximale du cache de l'application. Une fois le cache atteint cette taille, les éléments les plus anciens seront supprimés pour libérer de l'espace.\",\n  \"data_control.app_cache_limit.label\": \"Limite de cache de l'application\",\n  \"data_control.clean_cache.button\": \"Nettoyer le cache\",\n  \"data_control.clean_cache.cancel\": \"Annuler\",\n  \"data_control.clean_cache.clear\": \"Effacer\",\n  \"data_control.clean_cache.description\": \"Nettoyez le cache de l'application pour libérer de l'espace.\",\n  \"data_control.clean_cache.description_web\": \"Nettoyez le cache du service worker de l'application web pour libérer de l'espace.\",\n  \"data_control.clean_cache.success\": \"Cache nettoyé avec succès.\",\n  \"data_control.data_sources\": \"Sources de données\",\n  \"data_control.export_local_database.label\": \"Exporter la base de données locale\",\n  \"data_control.import_opml.label\": \"Importer des abonnements depuis OPML\",\n  \"data_control.utils\": \"Utilitaires\",\n  \"discoverFilters.filters\": \"Filtres\",\n  \"discoverFilters.language\": \"Langue\",\n  \"discoverFilters.title\": \"Filtres de découverte\",\n  \"feeds.claim\": \"Réclamer des flux\",\n  \"feeds.claimTips\": \"Pour réclamer vos flux et recevoir des pourboires, faites un clic droit sur le flux dans votre liste d'abonnement et sélectionnez Réclamer.\",\n  \"feeds.filter.all\": \"Tout ({{count}})\",\n  \"feeds.filter.rsshub\": \"RSSHub ({{count}})\",\n  \"feeds.noFeeds\": \"Aucun flux réclamé\",\n  \"feeds.subscription\": \"Flux abonnés\",\n  \"feeds.tableHeaders.date\": \"Date d'abonnement\",\n  \"feeds.tableHeaders.followers\": \"Abonnés\",\n  \"feeds.tableHeaders.name\": \"Nom\",\n  \"feeds.tableHeaders.subscriptionCount\": \"Abos\",\n  \"feeds.tableHeaders.tipAmount\": \"Pourboires\",\n  \"feeds.tableHeaders.updatesPerWeek\": \"Mises à jour\",\n  \"feeds.tableHeaders.view\": \"Vue\",\n  \"feeds.tableSelected.clear\": \"Effacer\",\n  \"feeds.tableSelected.item\": \"{{count}} élément(s) sélectionné(s)\",\n  \"feeds.tableSelected.moveToView.action\": \"Déplacer vers la vue\",\n  \"feeds.tableSelected.moveToView.confirm\": \"Êtes-vous sûr de vouloir déplacer ces flux vers {{view}} ?\",\n  \"feeds.tableSelected.moveToView.confirmTitle\": \"Confirmer\",\n  \"feeds.tableSelected.unsubscribe\": \"Se désabonner\",\n  \"general.action.summary.description\": \"Générer un résumé de l'entrée en utilisant l'IA.\",\n  \"general.action.summary.label\": \"Résumé IA\",\n  \"general.action.title\": \"Actions IA\",\n  \"general.action.translation.description\": \"Traduire l'entrée dans la langue sélectionnée.\",\n  \"general.action.translation.label\": \"Traduction IA\",\n  \"general.action_language.default\": \"Défaut (Langue UI)\",\n  \"general.action_language.description\": \"Choisissez la langue pour les actions IA, ex. Résumé IA, Traduction IA.\",\n  \"general.action_language.label\": \"Langue de sortie IA\",\n  \"general.advanced\": \"Avancé\",\n  \"general.app\": \"Appli\",\n  \"general.auto_expand_long_social_media.description\": \"Développer automatiquement les entrées de réseaux sociaux contenant de longs textes.\",\n  \"general.auto_expand_long_social_media.label\": \"Développer longs réseaux sociaux\",\n  \"general.auto_group.description\": \"Grouper les flux du même domaine de site web ensemble.\",\n  \"general.auto_group.label\": \"Grouper auto. les flux par site\",\n  \"general.cache\": \"Cache\",\n  \"general.content\": \"Contenu\",\n  \"general.data\": \"Données\",\n  \"general.data_file.label\": \"Fichier de données\",\n  \"general.dim_read.description\": \"Atténuer la couleur des entrées lues dans la chronologie.\",\n  \"general.dim_read.label\": \"Estomper les éléments lus\",\n  \"general.enhanced.description\": \"L'activation des paramètres améliorés offre plus d'options de personnalisation, mais peut également introduire des problèmes imprévus. !!! Gardez cela comme porte d'entrée vers des choses vraiment expérimentales/complexes si nécessaire plus tard, sinon cela pourrait être supprimable si d'autres paramètres couvrent les spécificités.\",\n  \"general.enhanced.disabled.tip\": \"Les paramètres améliorés sont désactivés, vous pouvez les activer dans Paramètres généraux - Avancé.\",\n  \"general.enhanced.enable.modal.cancel\": \"Annuler\",\n  \"general.enhanced.enable.modal.confirm\": \"Activer\",\n  \"general.enhanced.enable.modal.description\": \"L'activation des paramètres améliorés offre plus d'options de personnalisation, mais peut également introduire des problèmes imprévus. N'activez pas cela à moins de savoir ce que vous faites.\",\n  \"general.enhanced.enable.modal.title\": \"Activer les paramètres améliorés\",\n  \"general.enhanced.enabled.tip\": \"Les paramètres améliorés sont activés, vous pouvez les désactiver dans Paramètres généraux - Avancé.\",\n  \"general.enhanced.label\": \"Paramètres améliorés\",\n  \"general.export.button\": \"Exporter\",\n  \"general.export.description\": \"Exporter votre liste d'abonnements aux flux.\",\n  \"general.export.folder_mode.description\": \"Décidez comment vous souhaitez organiser vos dossiers d'exportation.\",\n  \"general.export.folder_mode.label\": \"Mode dossier\",\n  \"general.export.folder_mode.option.category\": \"Catégorie\",\n  \"general.export.folder_mode.option.view\": \"Vue\",\n  \"general.export.label\": \"Exporter les flux (OPML)\",\n  \"general.export.rsshub_url.description\": \"URL de base par défaut pour la route RSSHub, laissez vide pour utiliser https://rsshub.app.\",\n  \"general.export.rsshub_url.label\": \"URL RSSHub\",\n  \"general.export_data.title\": \"Exporter les données\",\n  \"general.export_database.button\": \"Exporter\",\n  \"general.export_database.description\": \"Exporter toutes vos données, y compris les articles (Sauvegarde complète).\",\n  \"general.export_database.label\": \"Exporter la base de données\",\n  \"general.group_by_date.description\": \"Grouper les entrées par date.\",\n  \"general.group_by_date.label\": \"Grouper par date\",\n  \"general.hide_all_read_subscriptions.description\": \"Masquer les abonnements sans entrées non lues dans la liste d'abonnements.\",\n  \"general.hide_all_read_subscriptions.label\": \"Masquer les lus\",\n  \"general.hide_private_subscriptions_in_timeline.description\": \"Masquer les abonnements privés de votre liste d'abonnements et masquer leurs entrées de votre chronologie (ils sont toujours invisibles au public indépendamment de ce paramètre).\",\n  \"general.hide_private_subscriptions_in_timeline.label\": \"Masquer les privés\",\n  \"general.language.description\": \"Choisissez la langue d'affichage de l'application.\",\n  \"general.language.title\": \"Langue\",\n  \"general.launch_at_login\": \"Lancer à la connexion\",\n  \"general.log_file.button\": \"Révéler\",\n  \"general.log_file.description\": \"Révéler le fichier journal dans le système.\",\n  \"general.log_file.label\": \"Fichier journal\",\n  \"general.maintenance.title\": \"Maintenance\",\n  \"general.mark_as_read.hover.description\": \"Marquer automatiquement les entrées comme lues au survol.\",\n  \"general.mark_as_read.hover.label\": \"Au survol de l'article\",\n  \"general.mark_as_read.render.description\": \"Marquer les éléments comme les publications sociales ou les images comme lus immédiatement.\",\n  \"general.mark_as_read.render.label\": \"Éléments simples lorsqu'ils entrent dans la vue\",\n  \"general.mark_as_read.scroll.description\": \"Marquer automatiquement les entrées comme lues lorsqu'elles défilent hors de la vue.\",\n  \"general.mark_as_read.scroll.label\": \"En faisant défiler l'article\",\n  \"general.mark_as_read.title\": \"Marquer comme lu\",\n  \"general.minimize_to_tray.description\": \"Minimiser dans la barre d'état système lors de la fermeture de la fenêtre.\",\n  \"general.minimize_to_tray.label\": \"Minimiser dans la barre d'état\",\n  \"general.network\": \"Réseau\",\n  \"general.open_links_in_external_app.label\": \"Ouvrir les liens dans une appli externe\",\n  \"general.privacy\": \"Confidentialité\",\n  \"general.proxy.description\": \"Définir le proxy pour le routage du trafic réseau, ex. socks://proxy.example.com:1080.\",\n  \"general.proxy.label\": \"Proxy\",\n  \"general.rebuild_database.button\": \"Reconstruire\",\n  \"general.rebuild_database.cancel\": \"Annuler\",\n  \"general.rebuild_database.description\": \"Si vous rencontrez des problèmes de rendu, la reconstruction de la base de données peut les résoudre.\",\n  \"general.rebuild_database.label\": \"Reconstruire la base de données\",\n  \"general.rebuild_database.title\": \"Reconstruire la base de données\",\n  \"general.rebuild_database.warning.line1\": \"La reconstruction de la base de données effacera toutes vos données locales.\",\n  \"general.rebuild_database.warning.line2\": \"Êtes-vous sûr de vouloir continuer ?\",\n  \"general.send_anonymous_data.description\": \"En choisissant d'envoyer des données de télémétrie anonymisées, vous contribuez à améliorer l'expérience utilisateur globale de Folo.\",\n  \"general.send_anonymous_data.label\": \"Envoyer des données anonymes\",\n  \"general.show_quick_timeline.description\": \"Afficher la chronologie rapide en haut de la liste des flux.\",\n  \"general.show_quick_timeline.label\": \"Afficher la chronologie de la liste des flux\",\n  \"general.show_unread_on_launch.description\": \"Filtrer automatiquement sur le contenu non lu au démarrage de l'application.\",\n  \"general.show_unread_on_launch.label\": \"Non lu uniquement au démarrage\",\n  \"general.sidebar_title\": \"Général\",\n  \"general.subscription\": \"Abonnement\",\n  \"general.subscriptions\": \"Abonnements\",\n  \"general.timeline\": \"Chronologie\",\n  \"general.translation_mode.bilingual\": \"Comparaison bilingue\",\n  \"general.translation_mode.description\": \"Choisissez comment le texte traduit est affiché dans la liste des entrées.\",\n  \"general.translation_mode.label\": \"Mode de traduction IA\",\n  \"general.translation_mode.translation-only\": \"Seulement la traduction\",\n  \"general.voices\": \"Voix\",\n  \"integration.builtin.title\": \"Intégration intégrée\",\n  \"integration.categories.custom_integrations\": \"Actions personnalisées\",\n  \"integration.categories.download_tools\": \"Outils de téléchargement\",\n  \"integration.categories.knowledge_management\": \"Gestion des connaissances\",\n  \"integration.categories.media_tools\": \"Outils multimédias\",\n  \"integration.categories.reading_services\": \"Services de lecture\",\n  \"integration.cubox.autoMemo.description\": \"Utiliser automatiquement le mode Mémo lorsque du texte est sélectionné pour enregistrer dans Cubox.\",\n  \"integration.cubox.autoMemo.label\": \"Mode Mémo Auto\",\n  \"integration.cubox.enable.description\": \"Afficher le bouton 'Enregistrer dans Cubox' si disponible.\",\n  \"integration.cubox.enable.label\": \"Activer\",\n  \"integration.cubox.title\": \"Cubox\",\n  \"integration.cubox.token.description\": \"Veuillez entrer l'URL complète de l'API Cubox, format : https://cubox.pro/c/api/save/xxxxxxxxx. Vous pouvez l'obtenir ici :\",\n  \"integration.cubox.token.label\": \"URL API Cubox\",\n  \"integration.custom_integrations.actions.delete\": \"Supprimer l'intégration\",\n  \"integration.custom_integrations.actions.disable\": \"Désactiver l'intégration\",\n  \"integration.custom_integrations.actions.edit\": \"Modifier l'intégration\",\n  \"integration.custom_integrations.actions.enable\": \"Activer l'intégration\",\n  \"integration.custom_integrations.add.button\": \"Ajouter une nouvelle intégration\",\n  \"integration.custom_integrations.create.error\": \"Échec de la création de l'intégration personnalisée\",\n  \"integration.custom_integrations.create.success\": \"Intégration personnalisée créée avec succès\",\n  \"integration.custom_integrations.create.title\": \"Créer une intégration personnalisée\",\n  \"integration.custom_integrations.delete.success\": \"Intégration personnalisée supprimée avec succès\",\n  \"integration.custom_integrations.edit.error\": \"Échec de la mise à jour de l'intégration personnalisée\",\n  \"integration.custom_integrations.edit.success\": \"Intégration personnalisée mise à jour avec succès\",\n  \"integration.custom_integrations.edit.title\": \"Modifier l'intégration personnalisée\",\n  \"integration.custom_integrations.enable.description\": \"Autoriser la création d'intégrations de partage personnalisées avec des modèles de récupération prenant en charge diverses méthodes et configurations HTTP.\",\n  \"integration.custom_integrations.enable.label\": \"Activer les intégrations personnalisées\",\n  \"integration.custom_integrations.form.body.description\": \"Corps de la requête pour les méthodes POST/PUT/PATCH. Prend en charge les espaces réservés et le format JSON.\",\n  \"integration.custom_integrations.form.body.label\": \"Corps de la requête\",\n  \"integration.custom_integrations.form.body.placeholder\": \"{\\\"title\\\": \\\"[title]\\\", \\\"url\\\": \\\"[url]\\\", \\\"content\\\": \\\"[content_markdown]\\\"}\",\n  \"integration.custom_integrations.form.fetch_template.label\": \"Modèle de récupération\",\n  \"integration.custom_integrations.form.headers.add\": \"Ajouter un en-tête\",\n  \"integration.custom_integrations.form.headers.description\": \"Ajouter des en-têtes personnalisés sous forme de paires clé-valeur. Les valeurs prennent en charge les espaces réservés\",\n  \"integration.custom_integrations.form.headers.key_placeholder\": \"Nom de l'en-tête\",\n  \"integration.custom_integrations.form.headers.label\": \"En-têtes\",\n  \"integration.custom_integrations.form.headers.value_placeholder\": \"Valeur de l'en-tête\",\n  \"integration.custom_integrations.form.icon.description\": \"Choisissez une icône pour représenter cette intégration.\",\n  \"integration.custom_integrations.form.icon.label\": \"Icône\",\n  \"integration.custom_integrations.form.method.description\": \"Sélectionnez la méthode HTTP pour la requête.\",\n  \"integration.custom_integrations.form.method.label\": \"Méthode HTTP\",\n  \"integration.custom_integrations.form.name.label\": \"Nom de l'intégration\",\n  \"integration.custom_integrations.form.name.placeholder\": \"Entrez le nom de l'intégration\",\n  \"integration.custom_integrations.form.scheme.description\": \"Entrez le schéma d'URL pour l'application externe. Utilisez des espaces réservés comme [title], [url], [content_markdown], etc.\",\n  \"integration.custom_integrations.form.scheme.examples.title\": \"Exemples courants\",\n  \"integration.custom_integrations.form.scheme.label\": \"Schéma d'URL\",\n  \"integration.custom_integrations.form.scheme.placeholder\": \"ex., obsidian://new?vault=MonCoffre&name=[title]&content=[content_markdown]\",\n  \"integration.custom_integrations.form.type.description\": \"Choisissez entre des requêtes API HTTP ou des redirections de schéma d'URL vers des applications externes.\",\n  \"integration.custom_integrations.form.type.http\": \"Requête HTTP\",\n  \"integration.custom_integrations.form.type.label\": \"Type d'intégration\",\n  \"integration.custom_integrations.form.type.url_scheme\": \"Schéma d'URL\",\n  \"integration.custom_integrations.form.url.description\": \"Utilisez des espaces réservés : [title], [url], [content_html], [summary], [content_markdown].\",\n  \"integration.custom_integrations.form.url.label\": \"URL\",\n  \"integration.custom_integrations.form.url.placeholder\": \"https://example.com/api/share\",\n  \"integration.custom_integrations.icons.bookmark\": \"Signet\",\n  \"integration.custom_integrations.icons.document\": \"Document\",\n  \"integration.custom_integrations.icons.download\": \"Télécharger\",\n  \"integration.custom_integrations.icons.external_link\": \"Lien externe\",\n  \"integration.custom_integrations.icons.link\": \"Lien\",\n  \"integration.custom_integrations.icons.picture\": \"Image\",\n  \"integration.custom_integrations.icons.save\": \"Enregistrer\",\n  \"integration.custom_integrations.icons.send\": \"Envoyer\",\n  \"integration.custom_integrations.icons.share\": \"Partager\",\n  \"integration.custom_integrations.icons.star\": \"Étoile\",\n  \"integration.custom_integrations.list.empty.button\": \"Créer la première intégration\",\n  \"integration.custom_integrations.list.empty.description\": \"Créez des intégrations de partage personnalisées avec des modèles de récupération pour intégrer n'importe quel service utilisant des requêtes HTTP\",\n  \"integration.custom_integrations.list.empty.title\": \"Aucune intégration personnalisée pour le moment\",\n  \"integration.custom_integrations.list.title\": \"Intégrations personnalisées\",\n  \"integration.custom_integrations.modal.description\": \"Créez des intégrations de partage personnalisées en utilisant des modèles de récupération avec des méthodes HTTP, des URL, des en-têtes et un corps. Utilisez des espaces réservés comme [title], [url], [content_html], [summary] et [content_markdown].\",\n  \"integration.custom_integrations.placeholders.click_to_copy\": \"Cliquez pour copier\",\n  \"integration.custom_integrations.placeholders.description\": \"Cliquez sur n'importe quel espace réservé pour le copier dans votre presse-papier.\",\n  \"integration.custom_integrations.placeholders.help\": \"Espaces réservés disponibles\",\n  \"integration.custom_integrations.preview.body\": \"Corps de la requête\",\n  \"integration.custom_integrations.preview.failed\": \"Échec de la génération de l'aperçu\",\n  \"integration.custom_integrations.preview.generating\": \"Génération de l'aperçu...\",\n  \"integration.custom_integrations.preview.headers\": \"En-têtes\",\n  \"integration.custom_integrations.preview.placeholders\": \"Espaces réservés disponibles\",\n  \"integration.custom_integrations.preview.request\": \"Requête\",\n  \"integration.custom_integrations.preview.title\": \"Aperçu de la requête\",\n  \"integration.custom_integrations.status.disabled\": \"Désactivé\",\n  \"integration.custom_integrations.title\": \"Intégrations personnalisées\",\n  \"integration.custom_integrations.validation.invalid\": \"Le modèle contient des erreurs\",\n  \"integration.custom_integrations.validation.valid\": \"Le modèle est valide\",\n  \"integration.eagle.enable.description\": \"Afficher le bouton 'Enregistrer le média dans Eagle' si disponible.\",\n  \"integration.eagle.enable.label\": \"Activer\",\n  \"integration.eagle.title\": \"Eagle\",\n  \"integration.export.button\": \"Exporter les paramètres\",\n  \"integration.export.error\": \"Échec de l'exportation des paramètres d'intégration\",\n  \"integration.export.success\": \"Paramètres d'intégration exportés avec succès\",\n  \"integration.general\": \"Général\",\n  \"integration.import.button\": \"Importer les paramètres\",\n  \"integration.import.error\": \"Échec de l'importation des paramètres d'intégration\",\n  \"integration.import.invalid\": \"Fichier de paramètres d'intégration invalide\",\n  \"integration.import.success\": \"Paramètres d'intégration importés avec succès\",\n  \"integration.instapaper.enable.description\": \"Afficher le bouton 'Enregistrer dans Instapaper' si disponible.\",\n  \"integration.instapaper.enable.label\": \"Activer\",\n  \"integration.instapaper.password.label\": \"Mot de passe Instapaper\",\n  \"integration.instapaper.title\": \"Instapaper\",\n  \"integration.instapaper.username.label\": \"Nom d'utilisateur Instapaper\",\n  \"integration.obsidian.enable.description\": \"Afficher le bouton 'Enregistrer dans Obsidian' si disponible.\",\n  \"integration.obsidian.enable.label\": \"Activer\",\n  \"integration.obsidian.title\": \"Obsidian\",\n  \"integration.obsidian.vaultPath.description\": \"Le chemin vers votre coffre Obsidian.\",\n  \"integration.obsidian.vaultPath.label\": \"Chemin du coffre Obsidian\",\n  \"integration.outline.collection.description\": \"L'UUID ou l'urlId de la collection où les documents sont enregistrés.\",\n  \"integration.outline.collection.label\": \"Collection Outline\",\n  \"integration.outline.enable.description\": \"Afficher le bouton 'Enregistrer dans Outline' si disponible.\",\n  \"integration.outline.enable.label\": \"Activer\",\n  \"integration.outline.endpoint.description\": \"L'URL est 'https://<VOTRE_DOMAINE_OUTLINE>/api'.\",\n  \"integration.outline.endpoint.label\": \"URL de base de l'API Outline\",\n  \"integration.outline.title\": \"Outline\",\n  \"integration.outline.token.description\": \"Vous pouvez l'obtenir dans les paramètres de votre compte Outline.\",\n  \"integration.outline.token.label\": \"Clé API Outline\",\n  \"integration.qbittorrent.enable.description\": \"Afficher le bouton 'Télécharger avec qBittorrent' si disponible.\",\n  \"integration.qbittorrent.enable.label\": \"Activer\",\n  \"integration.qbittorrent.host.description\": \"L'URL de votre WebUI qBittorrent, ex. http://localhost:8080.\",\n  \"integration.qbittorrent.host.label\": \"Hôte qBittorrent\",\n  \"integration.qbittorrent.password.label\": \"Mot de passe qBittorrent\",\n  \"integration.qbittorrent.title\": \"qBittorrent\",\n  \"integration.qbittorrent.username.label\": \"Nom d'utilisateur qBittorrent\",\n  \"integration.readeck.enable.description\": \"Afficher le bouton 'Enregistrer dans Readeck' si disponible.\",\n  \"integration.readeck.enable.label\": \"Activer\",\n  \"integration.readeck.endpoint.description\": \"L'URL est 'https://<VOTRE_DOMAINE_READECK>'.\",\n  \"integration.readeck.endpoint.label\": \"URL de base de l'API Readeck\",\n  \"integration.readeck.title\": \"Readeck\",\n  \"integration.readeck.token.description\": \"Vous pouvez l'obtenir dans les paramètres de votre compte Readeck.\",\n  \"integration.readeck.token.label\": \"Jeton API Readeck\",\n  \"integration.readwise.enable.description\": \"Afficher le bouton 'Enregistrer dans Readwise' si disponible.\",\n  \"integration.readwise.enable.label\": \"Activer\",\n  \"integration.readwise.title\": \"Readwise\",\n  \"integration.readwise.token.description\": \"Vous pouvez l'obtenir ici :\",\n  \"integration.readwise.token.label\": \"Jeton d'accès Readwise\",\n  \"integration.save_ai_summary_as_description.label\": \"Enregistrer le résumé IA comme description\",\n  \"integration.search.placeholder\": \"Rechercher des intégrations...\",\n  \"integration.sidebar_title\": \"Intégration\",\n  \"integration.status.configured\": \"Configuré\",\n  \"integration.status.enabled\": \"Activé\",\n  \"integration.tip\": \"Conseil : Vos données sensibles sont stockées localement et ne sont pas téléchargées sur le serveur.\",\n  \"integration.title\": \"Intégration\",\n  \"integration.use_browser_fetch.description\": \"Utiliser l'API fetch du navigateur pour les intégrations personnalisées au lieu du fetch natif d'Electron. Activer pour une meilleure compatibilité web, désactiver pour une sécurité renforcée.\",\n  \"integration.use_browser_fetch.label\": \"Utiliser Browser Fetch\",\n  \"integration.zotero.enable.description\": \"Afficher le bouton 'Enregistrer dans Zotero' si disponible.\",\n  \"integration.zotero.enable.label\": \"Activer\",\n  \"integration.zotero.title\": \"Zotero\",\n  \"integration.zotero.token.description\": \"Jeton API Zotero, vous pouvez l'obtenir ici :\",\n  \"integration.zotero.token.label\": \"Jeton API Zotero\",\n  \"integration.zotero.userID.description\": \"ID utilisateur Zotero, vous pouvez l'obtenir ici :\",\n  \"integration.zotero.userID.label\": \"ID utilisateur Zotero\",\n  \"invitation.activate\": \"Activer\",\n  \"invitation.codeOptions.betaUser\": \"1. Trouvez un utilisateur bêta qui vous invite.\",\n  \"invitation.codeOptions.discord\": \"2. Rejoignez notre serveur Discord et obtenez occasionnellement des cadeaux.\",\n  \"invitation.codeOptions.xAccount\": \"3. Suivez notre compte X, et obtenez des cadeaux de temps en temps.\",\n  \"invitation.confirmModal.cancel\": \"Annuler\",\n  \"invitation.confirmModal.confirm\": \"Voulez-vous continuer ?\",\n  \"invitation.confirmModal.continue\": \"Continuer\",\n  \"invitation.confirmModal.message\": \"Générer un code d'invitation vous coûtera {{INVITATION_PRICE}} <PowerIcon /> Puissance.\",\n  \"invitation.confirmModal.title\": \"Confirmer\",\n  \"invitation.created_at\": \"Créé le\",\n  \"invitation.earlyAccess\": \"Folo nécessite actuellement un code d'invitation pour être utilisé.\",\n  \"invitation.earlyAccessMessage\": \"😰 Désolé, Folo nécessite actuellement un code d'invitation pour être utilisé.\",\n  \"invitation.generate\": \"Générer\",\n  \"invitation.generateButton\": \"Générer un nouveau code\",\n  \"invitation.generateCost\": \"Vous pouvez dépenser {{INVITATION_PRICE}} <PowerIcon /> Puissance pour générer un code d'invitation pour vos amis.\",\n  \"invitation.getCodeMessage\": \"Vous pouvez obtenir un code d'invitation via les méthodes suivantes :\",\n  \"invitation.limitationMessage\": \"En fonction de votre temps d'utilisation, vous pouvez générer jusqu'à {{limitation}} codes d'invitation.\",\n  \"invitation.newInvitationSuccess\": \"🎉 Nouvelle invitation générée, code copié\",\n  \"invitation.noInvitations\": \"Aucune invitation\",\n  \"invitation.notUsed\": \"Non utilisé\",\n  \"invitation.sidebar_title\": \"Invitations\",\n  \"invitation.tableHeaders.code\": \"Code\",\n  \"invitation.tableHeaders.creationTime\": \"Date de création\",\n  \"invitation.tableHeaders.usedBy\": \"Utilisé par\",\n  \"invitation.title\": \"Code d'invitation\",\n  \"lists.create\": \"Créer une nouvelle liste\",\n  \"lists.created.error\": \"Échec de la création de la liste.\",\n  \"lists.created.success\": \"Liste créée avec succès !\",\n  \"lists.delete.confirm\": \"Confirmer la suppression de la liste ?\",\n  \"lists.delete.error\": \"Échec de la suppression de la liste.\",\n  \"lists.delete.success\": \"Liste supprimée avec succès !\",\n  \"lists.delete.warning\": \"Attention : Une fois supprimée, la liste ne sera plus disponible et tout le contenu sera définitivement supprimé et irrécupérable !..\",\n  \"lists.description\": \"Description\",\n  \"lists.earnings\": \"Gagner\",\n  \"lists.edit.error\": \"Échec de la modification de la liste.\",\n  \"lists.edit.label\": \"Modifier\",\n  \"lists.edit.success\": \"Liste modifiée avec succès !\",\n  \"lists.fee.description\": \"Les frais que les autres doivent vous payer pour s'abonner à cette liste.\",\n  \"lists.fee.label\": \"Frais\",\n  \"lists.feeds.actions\": \"Actions\",\n  \"lists.feeds.add.error\": \"Échec de l'ajout du flux à la liste.\",\n  \"lists.feeds.add.label\": \"Ajouter\",\n  \"lists.feeds.add.success\": \"Flux ajouté à la liste.\",\n  \"lists.feeds.delete.error\": \"Échec de la suppression du flux de la liste.\",\n  \"lists.feeds.delete.success\": \"Flux supprimé de la liste.\",\n  \"lists.feeds.id\": \"ID du flux\",\n  \"lists.feeds.label\": \"Flux\",\n  \"lists.feeds.manage\": \"Gérer les flux\",\n  \"lists.feeds.owner\": \"Propriétaire\",\n  \"lists.feeds.search\": \"Rechercher un flux\",\n  \"lists.feeds.title\": \"Titre\",\n  \"lists.image\": \"Image\",\n  \"lists.info\": \"Les listes sont des collections de flux que vous pouvez partager ou vendre pour que d'autres s'y abonnent. Les abonnés synchroniseront et accéderont à tous les flux de la liste.\",\n  \"lists.manage_list\": \"Gérer la liste\",\n  \"lists.noLists\": \"Aucune liste\",\n  \"lists.select_feeds\": \"Sélectionner les flux à ajouter à la liste actuelle\",\n  \"lists.submit\": \"Soumettre\",\n  \"lists.subscriptions\": \"Membres\",\n  \"lists.title\": \"Titre\",\n  \"lists.view\": \"Vue\",\n  \"notifications.channel\": \"Canal\",\n  \"notifications.current\": \"(client actuel)\",\n  \"notifications.empty.description\": \"Les canaux de notification apparaîtront ici une fois les notifications activées sur cet appareil.\",\n  \"notifications.empty.title\": \"Aucun canal de notification\",\n  \"notifications.info\": \"Folo offre des fonctionnalités de notification robustes et polyvalentes via <ActionsLink>Actions</ActionsLink>. Vous pouvez personnaliser la notification pour des flux, des vues ou des mots-clés spécifiques. Ci-dessous vos canaux de notification enregistrés.\",\n  \"notifications.test\": \"Notification de test\",\n  \"notifications.test_success\": \"Notification de test envoyée avec succès.\",\n  \"notifications.token\": \"Jeton client\",\n  \"plan.canceled_expires\": \"Annulé - Expire le {{date}}\",\n  \"plan.current_plan\": \"Forfait actuel\",\n  \"plan.descriptions.basic\": \"Plus de flux sans fonctionnalités IA.\",\n  \"plan.descriptions.free\": \"Idéal pour les débutants.\",\n  \"plan.descriptions.plus\": \"Débloquez les fonctionnalités IA et plus de flux.\",\n  \"plan.descriptions.pro\": \"Accès complet au meilleur de Folo.\",\n  \"plan.featureValues.AI_MODEL_SELECTION.curated\": \"Modèles sélectionnés\",\n  \"plan.featureValues.AI_MODEL_SELECTION.high_performance\": \"Tous les modèles haut de gamme\",\n  \"plan.featureValues.AI_MODEL_SELECTION.none\": \"—\",\n  \"plan.features.AI_BRING_YOUR_OWN_KEY\": \"IA Apportez votre propre clé\",\n  \"plan.features.AI_CREDIT\": \"Crédits de discussion IA\",\n  \"plan.features.AI_MODEL_SELECTION\": \"Sélection du modèle IA\",\n  \"plan.features.BOOSTS\": \"Accélération de l'actualisation du flux\",\n  \"plan.features.INTEGRATION_SUPPORTED\": \"Intégrations tierces\",\n  \"plan.features.MAX_ACTIONS\": \"Actions\",\n  \"plan.features.MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"Résumés IA par jour\",\n  \"plan.features.MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"Traductions IA par jour\",\n  \"plan.features.MAX_AI_REQUESTS_PER_DAY\": \"Discussions IA par jour\",\n  \"plan.features.MAX_AI_REQUESTS_PER_MONTH\": \"Discussions IA par mois\",\n  \"plan.features.MAX_AI_TASKS\": \"Tâches IA\",\n  \"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"Synthèse vocale IA par jour\",\n  \"plan.features.MAX_INBOXES\": \"Boîte de réception\",\n  \"plan.features.MAX_LISTS\": \"Liste\",\n  \"plan.features.MAX_RSSHUB_SUBSCRIPTIONS\": \"Abonnements RSSHub\",\n  \"plan.features.MAX_SUBSCRIPTIONS\": \"Abonnements aux flux\",\n  \"plan.features.PRIORITY_SUPPORT\": \"Support prioritaire\",\n  \"plan.features.PRIVATE_SUBSCRIPTION\": \"Abonnements privés\",\n  \"plan.features.SECURE_IMAGE_PROXY\": \"Proxy d'image sécurisé\",\n  \"plan.manage_subscription\": \"Gérer l'abonnement\",\n  \"plan.renews\": \"Renouvellement le {{date}}\",\n  \"plan.trial_ends\": \"L'essai se termine le {{date}}\",\n  \"privacy.privacy\": \"Confidentialité\",\n  \"privacy.terms\": \"Conditions\",\n  \"profile.avatar.cropInstructions\": \"Faites glisser la zone de recadrage pour ajuster votre avatar\",\n  \"profile.avatar.dropZoneSubtext\": \"ou cliquez pour sélectionner depuis votre ordinateur\",\n  \"profile.avatar.dropZoneText\": \"Glissez et déposez une image ici\",\n  \"profile.avatar.fileTooLarge\": \"La taille du fichier doit être inférieure à {{size}}\",\n  \"profile.avatar.invalidFileType\": \"Veuillez sélectionner un fichier image valide\",\n  \"profile.avatar.label\": \"Avatar\",\n  \"profile.avatar.processingError\": \"Erreur de traitement de l'image\",\n  \"profile.avatar.selectAnother\": \"Sélectionner une autre\",\n  \"profile.avatar.selectFile\": \"Sélectionner un fichier\",\n  \"profile.avatar.uploadError\": \"Échec du téléchargement de l'avatar\",\n  \"profile.avatar.uploadSuccess\": \"Avatar téléchargé avec succès\",\n  \"profile.avatar.uploadTitle\": \"Télécharger un avatar\",\n  \"profile.change_password.label\": \"Changer le mot de passe\",\n  \"profile.confirm_password.label\": \"Confirmer le mot de passe\",\n  \"profile.current_password.label\": \"Mot de passe actuel\",\n  \"profile.danger_zone\": \"Zone de danger\",\n  \"profile.delete_account.label\": \"Supprimer le compte\",\n  \"profile.edit_email\": \"Modifier l'email\",\n  \"profile.edit_profile\": \"Modifier le profil\",\n  \"profile.email.change\": \"Changer d'email\",\n  \"profile.email.change_note\": \"Si vous souhaitez changer d'email, vous devez vérifier votre nouvel email.\",\n  \"profile.email.changed\": \"Email changé.\",\n  \"profile.email.changed_verification_sent\": \"L'email de vérification pour le nouvel email a été envoyé.\",\n  \"profile.email.label\": \"Email\",\n  \"profile.email.send_verification\": \"Envoyer l'email de vérification\",\n  \"profile.email.unverified\": \"Non vérifié\",\n  \"profile.email.verification_sent\": \"Email de vérification envoyé\",\n  \"profile.email.verified\": \"Vérifié\",\n  \"profile.email.verify_email\": \"Veuillez vérifier votre email ({{email_address}}) pour continuer\",\n  \"profile.email.verify_status\": \"Votre email est {{status}}\",\n  \"profile.handle.description\": \"Votre identifiant unique.\",\n  \"profile.handle.label\": \"Identifiant\",\n  \"profile.link_social.authentication\": \"Authentication\",\n  \"profile.link_social.link\": \"Lier\",\n  \"profile.link_social.unlink.success\": \"Compte social délié.\",\n  \"profile.name.description\": \"Votre nom d'affichage public.\",\n  \"profile.name.label\": \"Nom d'affichage\",\n  \"profile.new_password.label\": \"Nouveau mot de passe\",\n  \"profile.no_password\": \"<Link>Réinitialisez</Link> votre mot de passe pour en définir un nouveau.\",\n  \"profile.password.label\": \"Mot de passe\",\n  \"profile.profile.bio\": \"Bio\",\n  \"profile.profile.bio_placeholder\": \"Parlez-nous de vous...\",\n  \"profile.profile.changed\": \"Profil mis à jour\",\n  \"profile.profile.save\": \"Enregistrer\",\n  \"profile.profile.social_links\": \"Liens sociaux\",\n  \"profile.profile.social_links_discord\": \"discord ID\",\n  \"profile.profile.social_links_facebook\": \"Facebook ID\",\n  \"profile.profile.social_links_github\": \"Github ID\",\n  \"profile.profile.social_links_instagram\": \"Instagram ID\",\n  \"profile.profile.social_links_twitter\": \"Twitter ID\",\n  \"profile.profile.social_links_youtube\": \"Youtube ID\",\n  \"profile.profile.website\": \"Site web\",\n  \"profile.reset_password_mail_sent\": \"Email de réinitialisation de mot de passe envoyé.\",\n  \"profile.security\": \"Sécurité\",\n  \"profile.set_avatar\": \"Définir l'avatar\",\n  \"profile.sidebar_title\": \"Profil\",\n  \"profile.submit\": \"Soumettre\",\n  \"profile.title\": \"Paramètres de profil\",\n  \"profile.totp_code.init\": \"Scannez le code QR avec votre application TOTP\",\n  \"profile.totp_code.invalid\": \"Code TOTP invalide.\",\n  \"profile.totp_code.label\": \"Code TOTP\",\n  \"profile.totp_code.title\": \"Entrer le code TOTP\",\n  \"profile.two_factor.disable\": \"Désactiver 2FA\",\n  \"profile.two_factor.disabled\": \"Authentification à deux facteurs désactivée.\",\n  \"profile.two_factor.enable\": \"Activer 2FA\",\n  \"profile.two_factor.enable_notice\": \"Vous devez activer l'authentification à deux facteurs pour effectuer cette action.\",\n  \"profile.two_factor.enabled\": \"Authentification à deux facteurs activée.\",\n  \"profile.two_factor.label\": \"Deux facteurs\",\n  \"profile.two_factor.no_password\": \"Vous devez <Link>définir</Link> un mot de passe avant d'activer le 2FA.\",\n  \"profile.updateSuccess\": \"Profil mis à jour.\",\n  \"profile.update_password_success\": \"Mot de passe mis à jour.\",\n  \"referral.description\": \"Partagez Folo avec un ami ! Prolongez votre aperçu Pro et vos avantages futurs, et vos amis pourront également bénéficier d'une période d'essai de 45 jours. <Link>En savoir plus</Link>.\",\n  \"referral.invited_friend_status.pending\": \"En attente de validation\",\n  \"referral.invited_friend_status.valid\": \"Valide\",\n  \"referral.link\": \"Votre lien d'invitation :\",\n  \"referral.pro_status.preview\": \"Votre statut Aperçu Pro : Expire le {{dateString}} ({{daysLeft}} jours restants)\",\n  \"referral.pro_status.trial\": \"Votre niveau actuel : Gratuit\",\n  \"referral.pro_status.user\": \"Votre statut Aperçu Pro : Actif\",\n  \"reviewPrompt.description\": \"Si Folo vous aide, une note dans le store nous aide beaucoup. Sinon, dites-nous ce que nous devons améliorer.\",\n  \"reviewPrompt.loveIt\": \"Oui, j'aime bien\",\n  \"reviewPrompt.notReally\": \"Pas vraiment\",\n  \"reviewPrompt.title\": \"Vous aimez Folo ?\",\n  \"rsshub.addModal.access_key_label\": \"Clé d'accès (Optionnel)\",\n  \"rsshub.addModal.add\": \"Ajouter\",\n  \"rsshub.addModal.base_url_label\": \"URL de base\",\n  \"rsshub.addModal.description\": \"Pour utiliser votre propre instance dans Folo, vous devez lui ajouter les variables d'environnement suivantes.\",\n  \"rsshub.add_new_instance\": \"Ajouter une nouvelle instance\",\n  \"rsshub.description\": \"RSSHub est un réseau RSS open-source géré par la communauté. Folo fournit une instance dédiée intégrée et l'utilise pour prendre en charge des milliers de contenus d'abonnement, vous pouvez également obtenir une acquisition de contenu plus stable en utilisant vos propres instances ou des instances tierces.\",\n  \"rsshub.public_instances\": \"Instances disponibles\",\n  \"rsshub.table.delete.confirm\": \"Êtes-vous sûr de vouloir supprimer cette instance ?\",\n  \"rsshub.table.delete.label\": \"Supprimer\",\n  \"rsshub.table.delete.success\": \"Instance supprimée avec succès.\",\n  \"rsshub.table.description\": \"Description\",\n  \"rsshub.table.edit\": \"Modifier\",\n  \"rsshub.table.inuse\": \"En cours d'utilisation\",\n  \"rsshub.table.limit_reached\": \"Limite atteinte\",\n  \"rsshub.table.official\": \"Officiel\",\n  \"rsshub.table.owner\": \"Propriétaire\",\n  \"rsshub.table.price\": \"Prix mensuel\",\n  \"rsshub.table.private\": \"Privé\",\n  \"rsshub.table.unavailable\": \"Indisponible\",\n  \"rsshub.table.unlimited\": \"Illimité\",\n  \"rsshub.table.use\": \"Utiliser\",\n  \"rsshub.table.userCount\": \"Nombre d'utilisateurs\",\n  \"rsshub.table.userLimit\": \"Limite d'utilisateurs\",\n  \"rsshub.table.yours\": \"Le vôtre\",\n  \"rsshub.useModal.about\": \"À propos de cette instance\",\n  \"rsshub.useModal.month\": \"mois\",\n  \"rsshub.useModal.months_label\": \"Le nombre de mois que vous souhaitez acheter\",\n  \"rsshub.useModal.purchase_expires_at\": \"Vous avez acheté cette instance, et votre achat expire le\",\n  \"rsshub.useModal.title\": \"Instance RSSHub\",\n  \"rsshub.useModal.useWith\": \"Utiliser avec {{amount}} <Power />\",\n  \"subscription.actions.comingSoon\": \"Bientôt disponible\",\n  \"subscription.actions.current\": \"Plan actuel\",\n  \"subscription.actions.manage_error\": \"Une erreur s'est produite lors de l'ouverture de la gestion de l'abonnement.\",\n  \"subscription.actions.upgrade\": \"Mettre à niveau\",\n  \"subscription.actions.upgrade_error\": \"Une erreur s'est produite lors du démarrage du paiement.\",\n  \"subscription.badge.popular\": \"Le plus populaire\",\n  \"subscription.billing.monthly\": \"Mensuel\",\n  \"subscription.billing.yearly\": \"Annuel\",\n  \"subscription.billing.yearly_savings\": \"Économisez {{value}}%\",\n  \"subscription.discount.tag\": \"Économisez {{value}}%\",\n  \"subscription.feature.included\": \"Inclus\",\n  \"subscription.price.free\": \"Gratuit\",\n  \"subscription.price.per_month\": \"par mois\",\n  \"subscription.price.per_month_billed_yearly\": \"par mois, facturé annuellement\",\n  \"subscription.summary.active\": \"Vous avez un abonnement actif.\",\n  \"subscription.summary.current\": \"Plan {{plan}}\",\n  \"subscription.summary.free\": \"Plan gratuit\",\n  \"subscription.summary.free_description\": \"Mettez à niveau pour débloquer plus de flux, d'actions et de fonctionnalités IA.\",\n  \"subscription.summary.title\": \"Votre abonnement\",\n  \"subscription.summary.trial_expiring\": \"L'essai se termine le {{date}} ({{days}} jours restants)\",\n  \"subscription.unavailable\": \"Les abonnements ne sont pas disponibles pour le moment.\",\n  \"titles.about\": \"À propos\",\n  \"titles.account\": \"Compte\",\n  \"titles.actions\": \"Actions\",\n  \"titles.ai\": \"IA\",\n  \"titles.appearance\": \"Apparence\",\n  \"titles.cli\": \"CLI\",\n  \"titles.data_control\": \"Contrôle des données\",\n  \"titles.feeds\": \"Flux\",\n  \"titles.general\": \"Général\",\n  \"titles.integration\": \"Intégration\",\n  \"titles.invitations\": \"Invitations\",\n  \"titles.lists\": \"Listes\",\n  \"titles.notifications\": \"Notifications\",\n  \"titles.plan.long\": \"Mettre à niveau votre plan\",\n  \"titles.plan.short\": \"Plan\",\n  \"titles.power\": \"Puissance\",\n  \"titles.privacy\": \"Confidentialité\",\n  \"titles.referral.long\": \"Inviter des amis & Prolongez Pro\",\n  \"titles.referral.short\": \"Inviter & Gagner\",\n  \"titles.shortcuts\": \"Raccourcis\",\n  \"titles.sign_out\": \"Se déconnecter\",\n  \"titles.subscription.long\": \"Gérer votre abonnement\",\n  \"titles.subscription.short\": \"Abonnement\",\n  \"titles.token_usage\": \"Utilisation des crédits IA\",\n  \"wallet.balance.activePoints\": \"Points actifs\",\n  \"wallet.balance.dailyReward\": \"Votre récompense quotidienne\",\n  \"wallet.balance.title\": \"Votre solde\",\n  \"wallet.balance.withdrawable\": \"Retirable\",\n  \"wallet.balance.withdrawableTooltip\": \"La puissance retirable comprend à la fois les pourboires que vous avez reçus et la puissance que vous avez rechargée.\",\n  \"wallet.claim.button.claim\": \"Réclamer la puissance quotidienne\",\n  \"wallet.claim.button.claimed\": \"Réclamé aujourd'hui\",\n  \"wallet.claim.tooltip.alreadyClaimed\": \"Vous avez déjà réclamé aujourd'hui.\",\n  \"wallet.claim.tooltip.canClaim\": \"Réclamez votre {{amount}} Puissance quotidienne maintenant !\",\n  \"wallet.create.button\": \"Créer un portefeuille\",\n  \"wallet.create.description\": \"Créez un portefeuille gratuit pour recevoir de la <PowerIcon /> <strong>Puissance</strong>, qui peut être utilisée pour récompenser les créateurs et aussi être récompensé pour vos contributions de contenu.\",\n  \"wallet.power.dailyClaim\": \"Vous pouvez réclamer {{amount}} Puissance gratuite quotidiennement, qui peut être utilisée pour donner des pourboires aux entrées RSS sur Folo.\",\n  \"wallet.power.rewardDescription\": \"Tous les utilisateurs actifs sur Folo sont éligibles aux récompenses quotidiennes de puissance.\",\n  \"wallet.power.rewardDescription2\": \"En fonction de votre niveau et de vos activités passées, vous pouvez recevoir une <Balance /> récompense aujourd'hui. <Link>En savoir plus.</Link>\",\n  \"wallet.ranking.level\": \"Niveau\",\n  \"wallet.ranking.name\": \"Nom\",\n  \"wallet.ranking.power\": \"Puissance\",\n  \"wallet.ranking.rank\": \"Rang\",\n  \"wallet.ranking.title\": \"Classement de puissance\",\n  \"wallet.rewardDescription.description1\": \"Les récompenses quotidiennes pour chaque utilisateur sont basées sur deux facteurs : le niveau de l'utilisateur et les points d'activité de l'utilisateur.\",\n  \"wallet.rewardDescription.description2\": \"Niveau utilisateur : Déterminé par le classement de puissance de l'utilisateur par rapport à tous les autres utilisateurs.\",\n  \"wallet.rewardDescription.description3\": \"Activité utilisateur : S'engager avec diverses fonctionnalités de Folo peut augmenter l'activité. Les récompenses vont d'un minimum de 1x à un maximum de 5x.\",\n  \"wallet.rewardDescription.level\": \"Niveau utilisateur\",\n  \"wallet.rewardDescription.percentage\": \"Pourcentage de classement\",\n  \"wallet.rewardDescription.reward\": \"Multiplicateur de récompense\",\n  \"wallet.rewardDescription.title\": \"Description de la récompense\",\n  \"wallet.rewardDescription.total\": \"Récompense totale par jour\",\n  \"wallet.sidebar_title\": \"Puissance\",\n  \"wallet.transactions.amount\": \"Montant\",\n  \"wallet.transactions.date\": \"Date\",\n  \"wallet.transactions.empty.description\": \"Les pourboires, achats, retraits et airdrops apparaîtront ici lorsqu'ils auront lieu.\",\n  \"wallet.transactions.empty.title\": \"Aucune transaction pour le moment\",\n  \"wallet.transactions.from\": \"De\",\n  \"wallet.transactions.more\": \"Voir plus via l'explorateur de blockchain.\",\n  \"wallet.transactions.noTransactions\": \"Aucune transaction\",\n  \"wallet.transactions.title\": \"Transactions\",\n  \"wallet.transactions.to\": \"À\",\n  \"wallet.transactions.tx\": \"Tx\",\n  \"wallet.transactions.type\": \"Type\",\n  \"wallet.transactions.types.airdrop\": \"Airdrop\",\n  \"wallet.transactions.types.all\": \"Tout\",\n  \"wallet.transactions.types.burn\": \"Brûler\",\n  \"wallet.transactions.types.mint\": \"Mint\",\n  \"wallet.transactions.types.purchase\": \"Achat\",\n  \"wallet.transactions.types.tip\": \"Pourboire\",\n  \"wallet.transactions.types.withdraw\": \"Retrait\",\n  \"wallet.transactions.you\": \"Vous\",\n  \"wallet.withdraw.addressLabel\": \"Votre adresse Ethereum\",\n  \"wallet.withdraw.amountLabel\": \"Montant\",\n  \"wallet.withdraw.availableBalance\": \"Vous avez <Balance></Balance> puissance retirable dans votre portefeuille.\",\n  \"wallet.withdraw.button\": \"Retirer\",\n  \"wallet.withdraw.error\": \"Retrait échoué : {{error}}\",\n  \"wallet.withdraw.modalTitle\": \"Retirer de la puissance\",\n  \"wallet.withdraw.receiveRSS3\": \"Vous recevrez {{amount}} RSS3\",\n  \"wallet.withdraw.submitButton\": \"Soumettre\",\n  \"wallet.withdraw.success\": \"Retrait réussi !\",\n  \"wallet.withdraw.toRss3Label\": \"Retirer en tant que RSS3\"\n}\n"
  },
  {
    "path": "locales/settings/ja.json",
    "content": "{\n  \"about.aiOnboardingDescription\": \"インタラクティブなオンボーディングをもう一度体験できます。\",\n  \"about.appTip\": \"App 機能\",\n  \"about.appTipDescription\": \"App 機能紹介と使用ガイド。\",\n  \"about.changelog\": \"変更履歴\",\n  \"about.changelogDescription\": \"各バージョンの新機能を確認\",\n  \"about.checkForUpdates\": \"アップデートを確認\",\n  \"about.checkNow\": \"今すぐ確認\",\n  \"about.checkingForUpdates\": \"アップデートを確認中...\",\n  \"about.copyEnvironment\": \"環境をコピー\",\n  \"about.environmentCopied\": \"環境情報をコピーしました\",\n  \"about.feedbackInfo\": \"{{appName}} ({{commitSha}}) は開発の初期段階にあります。フィードバックや提案があれば、気軽に <OpenIssueLink>GitHub で課題を報告してください</OpenIssueLink> 。\",\n  \"about.iconLibrary\": \"使用されているアイコンライブラリは <IconLibraryLink />  によって著作権が保護されており、再配布できません。\",\n  \"about.legal\": \"法的情報\",\n  \"about.licenseInfo\": \"Copyright © {{currentYear}} {{appName}}. All rights reserved.\",\n  \"about.noUpdateAvailable\": \"最新版を使用しています\",\n  \"about.privacyPolicy\": \"プライバシーポリシー\",\n  \"about.projectLicense\": \"{{appName}} は GNU Affero General Public License バージョン3に追加の例外条項を加えたライセンスで提供されています。\",\n  \"about.rateFolo\": \"Folo を評価\",\n  \"about.rateFoloDescription\": \"評価を残してプロダクトを応援してください。\",\n  \"about.resources\": \"リソースと貢献\",\n  \"about.sendFeedback\": \"フィードバックを送る\",\n  \"about.sendFeedbackDescription\": \"改善してほしい点を教えてください。\",\n  \"about.sidebar_title\": \"About\",\n  \"about.socialMedia\": \"ソーシャルメディア\",\n  \"about.support\": \"サポート\",\n  \"about.termsOfService\": \"利用規約\",\n  \"about.updateAvailable\": \"アップデートがあります！\",\n  \"about.updateCheckFailed\": \"アップデートの確認に失敗しました\",\n  \"about.updateDescription\": \"最新の機能と改善を取得するため、アプリを最新の状態に保ちましょう\",\n  \"about.viewChangelog\": \"変更履歴を表示\",\n  \"actions.actionName\": \"アクション {{number}}\",\n  \"actions.action_card.add\": \"追加\",\n  \"actions.action_card.all\": \"すべて\",\n  \"actions.action_card.and\": \"と\",\n  \"actions.action_card.block\": \"ブロック\",\n  \"actions.action_card.block_rules\": \"ブロックルール\",\n  \"actions.action_card.custom_filters\": \"カスタムフィルター\",\n  \"actions.action_card.empty.cta\": \"最初のルールを作成\",\n  \"actions.action_card.empty.description\": \"最初のアクションルールを作成して、フィードを自動的に処理します。\",\n  \"actions.action_card.empty.start\": \"ここから始めましょう！\",\n  \"actions.action_card.empty.title\": \"アクションはまだありません\",\n  \"actions.action_card.enable_readability\": \"読みやすさを有効化\",\n  \"actions.action_card.feed_options.entry_attachments_duration\": \"エントリー動画の長さ\",\n  \"actions.action_card.feed_options.entry_author\": \"エントリー作成者\",\n  \"actions.action_card.feed_options.entry_content\": \"エントリーコンテンツ\",\n  \"actions.action_card.feed_options.entry_media_length\": \"エントリーメディアの長さ\",\n  \"actions.action_card.feed_options.entry_title\": \"エントリータイトル\",\n  \"actions.action_card.feed_options.entry_url\": \"エントリーURL\",\n  \"actions.action_card.feed_options.feed_category\": \"フィードカテゴリー\",\n  \"actions.action_card.feed_options.feed_title\": \"フィードタイトル\",\n  \"actions.action_card.feed_options.feed_url\": \"フィードURL\",\n  \"actions.action_card.feed_options.site_url\": \"サイトURL\",\n  \"actions.action_card.feed_options.status\": \"ステータス\",\n  \"actions.action_card.feed_options.subscription_view\": \"購読ビュー\",\n  \"actions.action_card.field\": \"フィールド\",\n  \"actions.action_card.from\": \"から\",\n  \"actions.action_card.generate_summary\": \"AI を使って要約を生成\",\n  \"actions.action_card.name\": \"名前\",\n  \"actions.action_card.new_entry_notification\": \"新たなエントリーを通知する\",\n  \"actions.action_card.no_translation\": \"翻訳なし\",\n  \"actions.action_card.operation_options.contains\": \"含む\",\n  \"actions.action_card.operation_options.does_not_contain\": \"含まない\",\n  \"actions.action_card.operation_options.is_equal_to\": \"等しい\",\n  \"actions.action_card.operation_options.is_greater_than\": \"より大きい\",\n  \"actions.action_card.operation_options.is_less_than\": \"より小さい\",\n  \"actions.action_card.operation_options.is_not_equal_to\": \"等しくない\",\n  \"actions.action_card.operation_options.matches_regex\": \"正規表現に一致する\",\n  \"actions.action_card.operator\": \"オペレーター\",\n  \"actions.action_card.or\": \"または\",\n  \"actions.action_card.rewrite_rules\": \"リライトルール\",\n  \"actions.action_card.settings\": \"設定\",\n  \"actions.action_card.silence\": \"サイレント\",\n  \"actions.action_card.source_content\": \"ソースコンテンツを表示する\",\n  \"actions.action_card.star\": \"スター\",\n  \"actions.action_card.summary.action_count\": \"有効なアクション {{count}} 件\",\n  \"actions.action_card.summary.active\": \"有効\",\n  \"actions.action_card.summary.copy\": \"クリップボードにコピー\",\n  \"actions.action_card.summary.delete\": \"削除\",\n  \"actions.action_card.summary.delete_message\": \"このルールを削除してもよろしいですか？この操作は元に戻せません。\",\n  \"actions.action_card.summary.delete_title\": \"ルールを削除\",\n  \"actions.action_card.summary.disabled\": \"無効\",\n  \"actions.action_card.summary.empty\": \"ルールはまだありません\",\n  \"actions.action_card.summary.export\": \"ファイルにエクスポート\",\n  \"actions.action_card.summary.helper\": \"条件とアクションを組み合わせてワークフローを自動化しましょう。\",\n  \"actions.action_card.summary.import\": \"インポート\",\n  \"actions.action_card.summary.import_clipboard\": \"クリップボードからインポート\",\n  \"actions.action_card.summary.import_file\": \"ファイルからインポート\",\n  \"actions.action_card.summary.no_actions\": \"アクションはまだ設定されていません\",\n  \"actions.action_card.summary.rule_count\": \"ルール {{count}} 件\",\n  \"actions.action_card.summary.share\": \"共有\",\n  \"actions.action_card.summary.toggle\": \"ルールの有効・無効を切り替える\",\n  \"actions.action_card.then_do\": \"次に行う…\",\n  \"actions.action_card.to\": \"へ\",\n  \"actions.action_card.translate_into\": \"翻訳する\",\n  \"actions.action_card.value\": \"値\",\n  \"actions.action_card.webhooks\": \"Webhooks\",\n  \"actions.action_card.when_feeds_match\": \"フィードが一致した場合…\",\n  \"actions.condition\": \"条件\",\n  \"actions.conditions\": \"条件\",\n  \"actions.edit_condition\": \"条件を編集\",\n  \"actions.edit_rewrite_rule\": \"リライトルールを編集\",\n  \"actions.edit_rule\": \"ルールを編集\",\n  \"actions.edit_webhook\": \"Webhookを編集\",\n  \"actions.info\": \"アクションは、サーバーまたはクライアント側でタスクを実行するために自動化できるルールのコレクションです。\",\n  \"actions.navigate.prompt\": \"保存されていないアクションの変更があります。本当に離れますか？\",\n  \"actions.newRule\": \"新しいルール\",\n  \"actions.save\": \"保存\",\n  \"actions.saveSuccess\": \"🎉 アクションが保存されました。\",\n  \"actions.sidebar_title\": \"アクション\",\n  \"actions.title\": \"アクション\",\n  \"ai.personalize.prompt.description\": \"Folo にあなた自身と、どのように物事を読みたいかを教えてください。\",\n  \"ai.personalize.prompt.label\": \"パーソナライズプロンプト\",\n  \"ai.personalize.title\": \"パーソナライズ\",\n  \"ai.shortcuts.title\": \"ショートカット\",\n  \"appearance.accent_color.description\": \"アプリインターフェースのアクセントカラーを選択\",\n  \"appearance.accent_color.label\": \"アクセントカラー\",\n  \"appearance.code_highlight_theme.description\": \"コードハイライトテーマを調整\",\n  \"appearance.code_highlight_theme.label\": \"コードハイライトテーマ\",\n  \"appearance.code_highlighting.title\": \"コードハイライト\",\n  \"appearance.common.title\": \"共通\",\n  \"appearance.content\": \"コンテンツ\",\n  \"appearance.content_display.title\": \"コンテンツ表示\",\n  \"appearance.content_font.default\": \"デフォルト（UIフォント）\",\n  \"appearance.content_font.description\": \"読み取りコンテンツに使用されるフォントを調整します。\",\n  \"appearance.content_font.label\": \"コンテンツフォント\",\n  \"appearance.content_font_size\": \"コンテンツフォントサイズ\",\n  \"appearance.content_line_height.description\": \"記事内の文字の行間を調整\",\n  \"appearance.content_line_height.label\": \"コンテンツ行の高さ\",\n  \"appearance.content_line_height.loose\": \"ゆるい\",\n  \"appearance.content_line_height.normal\": \"普通\",\n  \"appearance.content_line_height.relaxed\": \"リラックス\",\n  \"appearance.content_line_height.snug\": \"ぴったり\",\n  \"appearance.content_line_height.tight\": \"タイト\",\n  \"appearance.custom_css.button\": \"編集\",\n  \"appearance.custom_css.description\": \"コンテンツに Custom CSS を適用できます。\",\n  \"appearance.custom_css.label\": \"Custom CSS\",\n  \"appearance.custom_font\": \"カスタムフォント\",\n  \"appearance.customization.title\": \"カスタマイズ\",\n  \"appearance.customize_sub_tabs.description\": \"購読タブを好みに合わせてカスタマイズします。\",\n  \"appearance.customize_sub_tabs.label\": \"購読タブをカスタマイズ\",\n  \"appearance.customize_toolbar.description\": \"エントリーコンテンツツールバーを好みに合わせてカスタマイズします。\",\n  \"appearance.customize_toolbar.label\": \"ツールバーをカスタマイズ\",\n  \"appearance.date_format.description\": \"表示日付形式を調整します。\",\n  \"appearance.date_format.label\": \"日付形式\",\n  \"appearance.font.custom\": \"カスタム\",\n  \"appearance.font.system\": \"システムUI\",\n  \"appearance.font_scaling.content_different.description\": \"記事コンテンツに独立したフォントサイズを設定\",\n  \"appearance.font_scaling.content_different.label\": \"コンテンツに独立したフォントサイズを使用\",\n  \"appearance.font_scaling.content_size.description\": \"記事コンテンツのフォントサイズを設定\",\n  \"appearance.font_scaling.content_size.l\": \"大きく\",\n  \"appearance.font_scaling.content_size.label\": \"コンテンツフォントサイズ\",\n  \"appearance.font_scaling.content_size.m\": \"デフォルト\",\n  \"appearance.font_scaling.content_size.s\": \"小さく\",\n  \"appearance.font_scaling.content_size.xl\": \"より大きく\",\n  \"appearance.font_scaling.content_size.xs\": \"より小さく\",\n  \"appearance.font_scaling.scale.description\": \"フォントサイズのスケール係数を調整\",\n  \"appearance.font_scaling.scale.label\": \"フォントスケール\",\n  \"appearance.font_scaling.size.l\": \"大きく\",\n  \"appearance.font_scaling.size.m\": \"デフォルト\",\n  \"appearance.font_scaling.size.s\": \"小さく\",\n  \"appearance.font_scaling.size.xl\": \"より大きく\",\n  \"appearance.font_scaling.size.xs\": \"より小さく\",\n  \"appearance.font_scaling.system.description\": \"システムのアクセシビリティフォントサイズ設定に従う。\",\n  \"appearance.font_scaling.system.label\": \"システムフォントスケールを使用\",\n  \"appearance.font_scaling.title\": \"フォントスケール\",\n  \"appearance.fonts\": \"フォント\",\n  \"appearance.general\": \"一般\",\n  \"appearance.global_font.default\": \"Follow System\",\n  \"appearance.global_font_size.description\": \"全体的なテキストサイズを調整\",\n  \"appearance.global_font_size.label\": \"グローバルフォントサイズ\",\n  \"appearance.guess_code_language.description\": \"ラベルがないコードブロックの言語を推測するためにモデルを使用する主要なプログラミング言語。\",\n  \"appearance.guess_code_language.label\": \"コード言語を推測\",\n  \"appearance.hide_extra_badge.description\": \"サイドバーにあるフィードのスペシャルバッジを非表示にする、例： ブースト、クレーム済み。\",\n  \"appearance.hide_extra_badge.label\": \"スペシャルバッジを非表示にする\",\n  \"appearance.hide_recent_reader.description\": \"エントリーヘッダの最近の購読者を非表示にする。\",\n  \"appearance.hide_recent_reader.label\": \"最近の購読者を非表示にする\",\n  \"appearance.interface_window.title\": \"インターフェース＆ウィンドウ\",\n  \"appearance.misc\": \"その他\",\n  \"appearance.modal_overlay.description\": \"モーダルオーバーレイを表示\",\n  \"appearance.modal_overlay.label\": \"モーダルオーバーレイを表示\",\n  \"appearance.opaque_sidebars.description\": \"サイドバーの背景を透明にします。\",\n  \"appearance.opaque_sidebars.label\": \"不透明なサイドバー\",\n  \"appearance.reader_render_inline_style.description\": \"オリジナル HTML のインラインスタイルをレンダリングします。\",\n  \"appearance.reader_render_inline_style.label\": \"インラインスタイルをレンダリング\",\n  \"appearance.reading_view.title\": \"リーディングビュー\",\n  \"appearance.reduce_motion.description\": \"要素の動きを減らして、パフォーマンスを向上させ、エネルギー消費を抑えます。\",\n  \"appearance.reduce_motion.label\": \"動きを減らす\",\n  \"appearance.save\": \"保存\",\n  \"appearance.sidebar\": \"サイドバー\",\n  \"appearance.sidebar_title\": \"外観\",\n  \"appearance.subscription_list.title\": \"購読リスト\",\n  \"appearance.subscriptions\": \"購読\",\n  \"appearance.system_integration.title\": \"システム統合\",\n  \"appearance.text_size.default\": \"デフォルト\",\n  \"appearance.text_size.label\": \"テキストサイズ\",\n  \"appearance.text_size.large\": \"大\",\n  \"appearance.text_size.medium\": \"中\",\n  \"appearance.text_size.smaller\": \"小\",\n  \"appearance.theme.dark\": \"ダーク\",\n  \"appearance.theme.description\": \"アプリの全体的なテーマを調整\",\n  \"appearance.theme.label\": \"テーマ\",\n  \"appearance.theme.light\": \"ライト\",\n  \"appearance.theme.system\": \"システム\",\n  \"appearance.thumbnail_ratio.description\": \"エントリーリスト内のサムネイル比率を選択します。\",\n  \"appearance.thumbnail_ratio.original\": \"オリジナル\",\n  \"appearance.thumbnail_ratio.square\": \"正方形\",\n  \"appearance.thumbnail_ratio.title\": \"サムネイル比率\",\n  \"appearance.title\": \"外観\",\n  \"appearance.typography.title\": \"タイポグラフィ\",\n  \"appearance.ui_font.description\": \"UI 要素に使用されるフォントを調整します。\",\n  \"appearance.ui_font.label\": \"UI フォント\",\n  \"appearance.unread_count.badge.description\": \"未読数をドックアイコンのバッジとして表示します。\",\n  \"appearance.unread_count.badge.label\": \"Dockバッジとして表示\",\n  \"appearance.unread_count.label\": \"未読数\",\n  \"appearance.unread_count.sidebar.description\": \"フィードやグループの横に未読数を表示します\",\n  \"appearance.unread_count.sidebar.title\": \"未読数を表示\",\n  \"appearance.unread_count.view_and_subscription.description\": \"表示と購読リストに未読数を表示します。\",\n  \"appearance.unread_count.view_and_subscription.label\": \"サイドバーに表示\",\n  \"appearance.use_pointer_cursor.description\": \"マウスがインタラクティブな要素の上にあるとき、カーソルは手の形になります。\",\n  \"appearance.use_pointer_cursor.label\": \"手の形のカーソルを使用する\",\n  \"appearance.words.customize\": \"カスタマイズ\",\n  \"cli.description\": \"グローバルインストールなしで、folocli@latest を指定した npx からどのターミナルでも Folo CLI を実行できます。デスクトップアプリから現在のログイン状態をワンクリックで同期できます。\",\n  \"cli.desktop_sync\": \"Desktop sync command\",\n  \"cli.global_install\": \"Run latest with npx\",\n  \"cli.install\": \"デスクトップのログインを同期\",\n  \"cli.install_failed\": \"デスクトップのログインを CLI に同期できませんでした\",\n  \"cli.install_success\": \"デスクトップのログインを CLI に同期しました\",\n  \"cli.installed\": \"接続済み\",\n  \"cli.not_available\": \"npx が見つかりません。先に Node.js と npm をインストールしてください。\",\n  \"cli.not_installed\": \"未接続\",\n  \"cli.package\": \"Package\",\n  \"cli.path\": \"設定ファイルのパス\",\n  \"cli.require_login\": \"先に Folo Desktop にログインしてから CLI ログインを同期してください。\",\n  \"cli.runtime_missing\": \"Node.js/npm が必要です\",\n  \"cli.runtime_ready\": \"Node.js/npm 利用可\",\n  \"cli.title\": \"Folo CLI\",\n  \"cli.uninstall\": \"CLI ログインをクリア\",\n  \"cli.uninstall_failed\": \"CLI ログインをクリアできませんでした\",\n  \"cli.uninstall_success\": \"CLI ログインをクリアしました\",\n  \"common.give_star\": \"<HeartIcon />私たちの製品が好きですか？<Link>GitHub で Star を付けましょう！</Link>\",\n  \"control.paid_badge.basic_or_higher\": \"この機能を利用するには Basic プラン以上が必要です\",\n  \"control.paid_badge.free_limited\": \"この機能は無料プランでは制限されています\",\n  \"customizeToolbar.more_actions.description\": \"ドロップダウンメニューに表示されます\",\n  \"customizeToolbar.more_actions.title\": \"その他のアクション\",\n  \"customizeToolbar.quick_actions.description\": \"よく使用するアクションをカスタマイズして並べ替える。\",\n  \"customizeToolbar.quick_actions.title\": \"クイックアクション\",\n  \"customizeToolbar.reset_layout\": \"デフォルトレイアウトにリセット\",\n  \"customizeToolbar.title\": \"ツールバーをカスタマイズ\",\n  \"data_control.app_cache_limit.description\": \"アプリの最大キャッシュを設定します。 このサイズに達すると空き容量を確保するために古いアイテムから削除されます。\",\n  \"data_control.app_cache_limit.label\": \"キャッシュリミット\",\n  \"data_control.clean_cache.button\": \"キャッシュをクリア\",\n  \"data_control.clean_cache.cancel\": \"キャンセル\",\n  \"data_control.clean_cache.clear\": \"クリア\",\n  \"data_control.clean_cache.description\": \"空き容量を確保するためにキャッシュをクリアします。\",\n  \"data_control.clean_cache.description_web\": \"サービスワーカーのキャッシュを削除して空き容量を確保します。\",\n  \"data_control.clean_cache.success\": \"キャッシュが正常にクリアされました。\",\n  \"data_control.data_sources\": \"データソース\",\n  \"data_control.export_local_database.label\": \"ローカルデータベースをエクスポート\",\n  \"data_control.import_opml.label\": \"OPMLから購読をインポート\",\n  \"data_control.utils\": \"ユーティリティ\",\n  \"discoverFilters.filters\": \"フィルタ\",\n  \"discoverFilters.language\": \"言語\",\n  \"discoverFilters.title\": \"発見フィルタ\",\n  \"feeds.claim\": \"フィードを認証\",\n  \"feeds.claimTips\": \"フィードを認証してチップを受け取るには、購読リストのフィードを右クリックして「フィードをクレーム」を選択してください。\",\n  \"feeds.filter.all\": \"すべて ({{count}})\",\n  \"feeds.filter.rsshub\": \"RSSHub ({{count}})\",\n  \"feeds.noFeeds\": \"認証されたフィードはありません\",\n  \"feeds.subscription\": \"購読済みフィード\",\n  \"feeds.tableHeaders.date\": \"購読日\",\n  \"feeds.tableHeaders.followers\": \"フォロワー\",\n  \"feeds.tableHeaders.name\": \"名前\",\n  \"feeds.tableHeaders.subscriptionCount\": \"購読者数\",\n  \"feeds.tableHeaders.tipAmount\": \"チップ\",\n  \"feeds.tableHeaders.updatesPerWeek\": \"週間更新\",\n  \"feeds.tableHeaders.view\": \"表示\",\n  \"feeds.tableSelected.clear\": \"クリア\",\n  \"feeds.tableSelected.item\": \"{{count}} アイテム選択中\",\n  \"feeds.tableSelected.moveToView.action\": \"表示に移動\",\n  \"feeds.tableSelected.moveToView.confirm\": \"これらのフィードを {{view}} に移動してもよろしいですか？\",\n  \"feeds.tableSelected.moveToView.confirmTitle\": \"確認\",\n  \"feeds.tableSelected.unsubscribe\": \"購読解除\",\n  \"general.action.summary.description\": \"AIを使用してエントリの要約を生成します。\",\n  \"general.action.summary.label\": \"AI要約\",\n  \"general.action.title\": \"AIアクション\",\n  \"general.action.translation.description\": \"エントリを選択した言語に翻訳します。\",\n  \"general.action.translation.label\": \"AI翻訳\",\n  \"general.action_language.default\": \"デフォルト（UI言語）\",\n  \"general.action_language.description\": \"AI アクションの言語を選択します。\",\n  \"general.action_language.label\": \"AI ターゲット言語\",\n  \"general.advanced\": \"高度\",\n  \"general.app\": \"アプリ\",\n  \"general.auto_expand_long_social_media.description\": \"ソーシャルメディアに含まれる長いテキストを展開して表示します。\",\n  \"general.auto_expand_long_social_media.label\": \"ソーシャルメディアを展開\",\n  \"general.auto_group.description\": \"サイトのドメインごとに自動でグループ化する。\",\n  \"general.auto_group.label\": \"自動グループ化\",\n  \"general.cache\": \"キャッシュ\",\n  \"general.content\": \"コンテンツ\",\n  \"general.data\": \"データ\",\n  \"general.data_file.label\": \"データファイル\",\n  \"general.dim_read.description\": \"タイムラインで既読のエントリの色を薄くします。\",\n  \"general.dim_read.label\": \"既読アイテムをフェード\",\n  \"general.enhanced.description\": \"強化された設定を有効にすると、より多くのカスタマイズオプションが提供されますが、予期しない問題が発生する可能性もあります。\",\n  \"general.enhanced.disabled.tip\": \"強化された設定は無効になっています。一般設定 - 詳細設定で有効にできます。\",\n  \"general.enhanced.enable.modal.cancel\": \"キャンセル\",\n  \"general.enhanced.enable.modal.confirm\": \"有効にする\",\n  \"general.enhanced.enable.modal.description\": \"強化された設定を有効にすると、より多くのカスタマイズオプションが提供されますが、予期しない問題が発生する可能性もあります。これを有効にする前に、何をしているのかを理解していることを確認してください。\",\n  \"general.enhanced.enable.modal.title\": \"強化された設定を有効にする\",\n  \"general.enhanced.enabled.tip\": \"強化された設定が有効になっています。一般設定 - 詳細設定で無効にできます。\",\n  \"general.enhanced.label\": \"強化された設定\",\n  \"general.export.button\": \"エクスポート\",\n  \"general.export.description\": \"あなたのフィードを OPML ファイルにエクスポートします。\",\n  \"general.export.folder_mode.description\": \"エクスポートするフォルダーを決めて管理します。\",\n  \"general.export.folder_mode.label\": \"フォルダーモード\",\n  \"general.export.folder_mode.option.category\": \"カテゴリー\",\n  \"general.export.folder_mode.option.view\": \"表示\",\n  \"general.export.label\": \"フィードをエクスポート\",\n  \"general.export.rsshub_url.description\": \"RSSHub ルートの基準となるデフォルト URL を指定します、空欄だと https://rsshub.app で設定します。\",\n  \"general.export.rsshub_url.label\": \"RSSHub URL\",\n  \"general.export_data.title\": \"データをエクスポート\",\n  \"general.export_database.button\": \"エクスポート\",\n  \"general.export_database.description\": \"データベースを JSON ファイルでエクスポート。\",\n  \"general.export_database.label\": \"データベースをエクスポート\",\n  \"general.group_by_date.description\": \"エントリを日付ごとにグループ化します。\",\n  \"general.group_by_date.label\": \"日付ごとにグループ化\",\n  \"general.hide_all_read_subscriptions.description\": \"購読リストで未読エントリのない購読を非表示にします。\",\n  \"general.hide_all_read_subscriptions.label\": \"既読を非表示\",\n  \"general.hide_private_subscriptions_in_timeline.description\": \"購読リストからプライベート購読を非表示にし、タイムラインからそれらのエントリを非表示にします（この設定に関係なく、それらは常に公開されません）。\",\n  \"general.hide_private_subscriptions_in_timeline.label\": \"プライベートを非表示\",\n  \"general.language.description\": \"アプリの表示言語を選択します。\",\n  \"general.language.title\": \"言語\",\n  \"general.launch_at_login\": \"ログイン時に起動\",\n  \"general.log_file.button\": \"ログ\",\n  \"general.log_file.description\": \"システムにログファイルがあると表示します。\",\n  \"general.log_file.label\": \"ログファイル\",\n  \"general.maintenance.title\": \"メンテナンス\",\n  \"general.mark_as_read.hover.description\": \"ホバー時にエントリを自動的に既読にします。\",\n  \"general.mark_as_read.hover.label\": \"記事にホバーしたとき\",\n  \"general.mark_as_read.render.description\": \"ソーシャルメディアの投稿や画像などの単一項目を即座に既読にマークします。\",\n  \"general.mark_as_read.render.label\": \"単一項目がビューに入ったとき\",\n  \"general.mark_as_read.scroll.description\": \"表示からスクロールアウトしたときにエントリを自動的に既読にします。\",\n  \"general.mark_as_read.scroll.label\": \"記事をスクロールして通り過ぎたとき\",\n  \"general.mark_as_read.title\": \"既読にする\",\n  \"general.minimize_to_tray.description\": \"ウィンドウを閉じるとシステムトレイに最小化します\",\n  \"general.minimize_to_tray.label\": \"トレイに最小化\",\n  \"general.network\": \"ネットワーク\",\n  \"general.open_links_in_external_app.label\": \"外部アプリでリンクを開く\",\n  \"general.privacy\": \"プライバシー\",\n  \"general.proxy.description\": \"ネットワークリクエストを代理します。例: socks://proxy.example.com:1080。\",\n  \"general.proxy.label\": \"プロキシ\",\n  \"general.rebuild_database.button\": \"再構築\",\n  \"general.rebuild_database.cancel\": \"キャンセル\",\n  \"general.rebuild_database.description\": \"レンダリングに問題がある場合、データベースの再構築が解決するかもしれません。\",\n  \"general.rebuild_database.label\": \"データベースを再構築\",\n  \"general.rebuild_database.title\": \"データベースを再構築\",\n  \"general.rebuild_database.warning.line1\": \"データベースの再構築により、すべてのローカルデータが消去されます。\",\n  \"general.rebuild_database.warning.line2\": \"本当に続行しますか？\",\n  \"general.send_anonymous_data.description\": \"匿名化されたテレメトリーデータを送信することにより、Folo の全体的なユーザーエクスペリエンスの向上に貢献します。\",\n  \"general.send_anonymous_data.label\": \"匿名データを送信\",\n  \"general.show_quick_timeline.description\": \"フィードリストの最上部にクイックタイムラインを表示する。\",\n  \"general.show_quick_timeline.label\": \"フィードリストにタイムラインを表示する\",\n  \"general.show_unread_on_launch.description\": \"アプリ起動時に未読コンテンツに自動的にフィルタリングします。\",\n  \"general.show_unread_on_launch.label\": \"起動時に未読のみ表示\",\n  \"general.sidebar_title\": \"一般\",\n  \"general.subscription\": \"購読\",\n  \"general.subscriptions\": \"購読\",\n  \"general.timeline\": \"タイムライン\",\n  \"general.translation_mode.bilingual\": \"バイリンガル比較\",\n  \"general.translation_mode.description\": \"エントリリストで翻訳されたテキストの表示方法を選択します。\",\n  \"general.translation_mode.label\": \"AI翻訳モード\",\n  \"general.translation_mode.translation-only\": \"翻訳のみ\",\n  \"general.voices\": \"音声\",\n  \"integration.builtin.title\": \"組み込み統合\",\n  \"integration.categories.custom_integrations\": \"カスタムアクション\",\n  \"integration.categories.download_tools\": \"ダウンロードツール\",\n  \"integration.categories.knowledge_management\": \"ナレッジマネジメント\",\n  \"integration.categories.media_tools\": \"メディアツール\",\n  \"integration.categories.reading_services\": \"リーディングサービス\",\n  \"integration.cubox.autoMemo.description\": \"テキストを選択してCuboxに保存する際に、自動的にメモモードを使用します。\",\n  \"integration.cubox.autoMemo.label\": \"自動メモモード\",\n  \"integration.cubox.enable.description\": \"利用可能な場合、'Cuboxに保存'ボタンを表示します。\",\n  \"integration.cubox.enable.label\": \"有効\",\n  \"integration.cubox.title\": \"Cubox\",\n  \"integration.cubox.token.description\": \"完全なCubox API URLを入力してください。形式：https://cubox.pro/c/api/save/xxxxxxxxx。こちらで取得できます：\",\n  \"integration.cubox.token.label\": \"Cubox API URL\",\n  \"integration.custom_integrations.actions.delete\": \"統合を削除\",\n  \"integration.custom_integrations.actions.disable\": \"統合を無効にする\",\n  \"integration.custom_integrations.actions.edit\": \"統合を編集\",\n  \"integration.custom_integrations.actions.enable\": \"統合を有効にする\",\n  \"integration.custom_integrations.add.button\": \"新しい統合を追加\",\n  \"integration.custom_integrations.create.error\": \"カスタム統合の作成に失敗しました\",\n  \"integration.custom_integrations.create.success\": \"カスタム統合が正常に作成されました\",\n  \"integration.custom_integrations.create.title\": \"カスタム統合の作成\",\n  \"integration.custom_integrations.delete.success\": \"カスタム統合が正常に削除されました\",\n  \"integration.custom_integrations.edit.error\": \"カスタム統合の更新に失敗しました\",\n  \"integration.custom_integrations.edit.success\": \"カスタム統合が正常に更新されました\",\n  \"integration.custom_integrations.edit.title\": \"カスタム統合の編集\",\n  \"integration.custom_integrations.enable.description\": \"さまざまなHTTPメソッドと構成をサポートするフェッチテンプレートを使用して、カスタム共有統合を作成できるようにします。\",\n  \"integration.custom_integrations.enable.label\": \"カスタム統合を有効にする\",\n  \"integration.custom_integrations.form.body.description\": \"POST/PUT/PATCHメソッドのリクエストボディ。プレースホルダーとJSON形式をサポートします。\",\n  \"integration.custom_integrations.form.body.label\": \"リクエストボディ\",\n  \"integration.custom_integrations.form.body.placeholder\": \"{\\\"title\\\": \\\"[title]\\\", \\\"url\\\": \\\"[url]\\\", \\\"content\\\": \\\"[content_markdown]\\\"}\",\n  \"integration.custom_integrations.form.fetch_template.label\": \"テンプレートを取得する\",\n  \"integration.custom_integrations.form.headers.add\": \"ヘッダーを追加\",\n  \"integration.custom_integrations.form.headers.description\": \"キーと値のペアとしてカスタムヘッダーを追加します。値はプレースホルダーをサポートします\",\n  \"integration.custom_integrations.form.headers.key_placeholder\": \"ヘッダー名\",\n  \"integration.custom_integrations.form.headers.label\": \"ヘッダー\",\n  \"integration.custom_integrations.form.headers.value_placeholder\": \"ヘッダー値\",\n  \"integration.custom_integrations.form.icon.description\": \"この統合を表すアイコンを選択してください\",\n  \"integration.custom_integrations.form.icon.label\": \"アイコン\",\n  \"integration.custom_integrations.form.method.description\": \"リクエストのHTTPメソッドを選択してください\",\n  \"integration.custom_integrations.form.method.label\": \"HTTPメソッド\",\n  \"integration.custom_integrations.form.name.label\": \"統合名\",\n  \"integration.custom_integrations.form.name.placeholder\": \"統合名を入力してください\",\n  \"integration.custom_integrations.form.scheme.description\": \"外部アプリケーションのURLスキームを入力してください。プレースホルダーとして [title], [url], [content_markdown] などを使用できます。\",\n  \"integration.custom_integrations.form.scheme.examples.title\": \"一般的な例\",\n  \"integration.custom_integrations.form.scheme.label\": \"URLスキーム\",\n  \"integration.custom_integrations.form.scheme.placeholder\": \"例: obsidian://new?vault=MyVault&name=[title]&content=[content_markdown]\",\n  \"integration.custom_integrations.form.type.description\": \"HTTP APIリクエストまたはURLスキームリダイレクトのいずれかを選択してください。\",\n  \"integration.custom_integrations.form.type.http\": \"HTTPリクエスト\",\n  \"integration.custom_integrations.form.type.label\": \"統合タイプ\",\n  \"integration.custom_integrations.form.type.url_scheme\": \"URLスキーム\",\n  \"integration.custom_integrations.form.url.description\": \"プレースホルダーを使用: [title], [url], [content_html], [summary], [content_markdown]。\",\n  \"integration.custom_integrations.form.url.label\": \"URL\",\n  \"integration.custom_integrations.form.url.placeholder\": \"https://example.com/api/share\",\n  \"integration.custom_integrations.icons.bookmark\": \"ブックマーク\",\n  \"integration.custom_integrations.icons.document\": \"ドキュメント\",\n  \"integration.custom_integrations.icons.download\": \"ダウンロード\",\n  \"integration.custom_integrations.icons.external_link\": \"外部リンク\",\n  \"integration.custom_integrations.icons.link\": \"リンク\",\n  \"integration.custom_integrations.icons.picture\": \"画像\",\n  \"integration.custom_integrations.icons.save\": \"保存\",\n  \"integration.custom_integrations.icons.send\": \"送信\",\n  \"integration.custom_integrations.icons.share\": \"共有\",\n  \"integration.custom_integrations.icons.star\": \"スター\",\n  \"integration.custom_integrations.list.empty.button\": \"最初の統合を作成\",\n  \"integration.custom_integrations.list.empty.description\": \"Fetchテンプレートを使用してカスタム共有統合を作成し、HTTPリクエストを使用して任意のサービスと統合します\",\n  \"integration.custom_integrations.list.empty.title\": \"カスタム統合はまだありません\",\n  \"integration.custom_integrations.list.title\": \"カスタム統合\",\n  \"integration.custom_integrations.modal.description\": \"HTTPメソッド、URL、ヘッダー、およびボディを使用してFetchテンプレートを使用してカスタム共有統合を作成します。[title]、[url]、[content_html]、[summary]、および[content_markdown]などのプレースホルダーを使用します。\",\n  \"integration.custom_integrations.placeholders.click_to_copy\": \"クリックしてコピー\",\n  \"integration.custom_integrations.placeholders.description\": \"任意のプレースホルダーをクリックしてクリップボードにコピーします。\",\n  \"integration.custom_integrations.placeholders.help\": \"利用可能なプレースホルダー\",\n  \"integration.custom_integrations.preview.body\": \"リクエストボディ\",\n  \"integration.custom_integrations.preview.failed\": \"プレビューの生成に失敗しました\",\n  \"integration.custom_integrations.preview.generating\": \"プレビューを生成中...\",\n  \"integration.custom_integrations.preview.headers\": \"ヘッダー\",\n  \"integration.custom_integrations.preview.placeholders\": \"利用可能なプレースホルダー\",\n  \"integration.custom_integrations.preview.request\": \"リクエスト\",\n  \"integration.custom_integrations.preview.title\": \"プレビューリクエスト\",\n  \"integration.custom_integrations.status.disabled\": \"無効\",\n  \"integration.custom_integrations.title\": \"カスタム統合\",\n  \"integration.custom_integrations.validation.invalid\": \"テンプレートにエラーがあります\",\n  \"integration.custom_integrations.validation.valid\": \"テンプレートは有効です\",\n  \"integration.eagle.enable.description\": \"利用可能な場合、'Eagle にメディアを保存' ボタンを表示します。\",\n  \"integration.eagle.enable.label\": \"有効化\",\n  \"integration.eagle.title\": \"Eagle\",\n  \"integration.export.button\": \"設定をエクスポート\",\n  \"integration.export.error\": \"統合設定のエクスポートに失敗しました\",\n  \"integration.export.success\": \"統合設定のエクスポートが成功しました\",\n  \"integration.general\": \"一般\",\n  \"integration.import.button\": \"設定をインポート\",\n  \"integration.import.error\": \"統合設定のインポートに失敗しました\",\n  \"integration.import.invalid\": \"無効な統合設定ファイルです\",\n  \"integration.import.success\": \"統合設定のインポートが成功しました\",\n  \"integration.instapaper.enable.description\": \"利用可能な場合、'Instapaper に保存' ボタンを表示します。\",\n  \"integration.instapaper.enable.label\": \"有効化\",\n  \"integration.instapaper.password.label\": \"Instapaper パスワード\",\n  \"integration.instapaper.title\": \"Instapaper\",\n  \"integration.instapaper.username.label\": \"Instapaper ユーザー名\",\n  \"integration.obsidian.enable.description\": \"利用可能な場合 'Obsidian に保存' ボタンを表示する。\",\n  \"integration.obsidian.enable.label\": \"有効\",\n  \"integration.obsidian.title\": \"Obsidian\",\n  \"integration.obsidian.vaultPath.description\": \"Obsidian vaultのパスを指定します\",\n  \"integration.obsidian.vaultPath.label\": \"Obsidian Vault パス\",\n  \"integration.outline.collection.description\": \"ドキュメントに保存された UUID または urlId コレクションです。\",\n  \"integration.outline.collection.label\": \"Outline コレクション\",\n  \"integration.outline.enable.description\": \"利用可能なとき 'Outline に保存' ボタンを表示します。\",\n  \"integration.outline.enable.label\": \"有効化\",\n  \"integration.outline.endpoint.description\": \"URL は 'https://<YOUR_OUTLINE_DOMAIN>/api'。\",\n  \"integration.outline.endpoint.label\": \"Outline API のベース URL\",\n  \"integration.outline.title\": \"Outline\",\n  \"integration.outline.token.description\": \"Outline のアカウント設定で取得できます。\",\n  \"integration.outline.token.label\": \"Outline API Key\",\n  \"integration.qbittorrent.enable.description\": \"利用可能な場合 'Download with qBittorrent' ボタンを表示します。\",\n  \"integration.qbittorrent.enable.label\": \"有効化\",\n  \"integration.qbittorrent.host.description\": \"QBittorrent WebUI の URL、例：http://localhost:8080。\",\n  \"integration.qbittorrent.host.label\": \"qBittorrent ホスト\",\n  \"integration.qbittorrent.password.label\": \"qBittorrent パスワード\",\n  \"integration.qbittorrent.title\": \"qBittorrent\",\n  \"integration.qbittorrent.username.label\": \"qBittorrent ユーザー名\",\n  \"integration.readeck.enable.description\": \"利用可能なとき 'Readeck に保存' ボタンを表示します。\",\n  \"integration.readeck.enable.label\": \"有効化\",\n  \"integration.readeck.endpoint.description\": \"URL は 'https://<YOUR_READECK_DOMAIN>'。\",\n  \"integration.readeck.endpoint.label\": \"Readeck API Base URL\",\n  \"integration.readeck.title\": \"Readeck\",\n  \"integration.readeck.token.description\": \"Readeck のアカウント設定で取得できます。\",\n  \"integration.readeck.token.label\": \"Readeck API Key\",\n  \"integration.readwise.enable.description\": \"利用可能な場合、'Readwise に保存' ボタンを表示します。\",\n  \"integration.readwise.enable.label\": \"有効化\",\n  \"integration.readwise.title\": \"Readwise\",\n  \"integration.readwise.token.description\": \"こちらで取得できます：\",\n  \"integration.readwise.token.label\": \"Readwise アクセス トークン\",\n  \"integration.save_ai_summary_as_description.label\": \"AI要約を説明として保存\",\n  \"integration.search.placeholder\": \"統合を検索...\",\n  \"integration.sidebar_title\": \"統合\",\n  \"integration.status.configured\": \"設定済み\",\n  \"integration.status.enabled\": \"有効\",\n  \"integration.tip\": \"ヒント：あなたの機密データはローカルに保存され、サーバーにアップロードされません。\",\n  \"integration.title\": \"統合\",\n  \"integration.use_browser_fetch.description\": \"カスタム統合のために、Electron のネイティブフェッチの代わりにブラウザのフェッチ API を使用します。ウェブ互換性を向上させるために有効にし、セキュリティを強化するために無効にします。\",\n  \"integration.use_browser_fetch.label\": \"ブラウザフェッチを使用\",\n  \"integration.zotero.enable.description\": \"利用可能な場合、'Zoteroに保存'ボタンを表示します。\",\n  \"integration.zotero.enable.label\": \"有効\",\n  \"integration.zotero.title\": \"Zotero\",\n  \"integration.zotero.token.description\": \"Zotero APIトークン。こちらで取得できます：\",\n  \"integration.zotero.token.label\": \"Zotero APIトークン\",\n  \"integration.zotero.userID.description\": \"Zotero ユーザーID。こちらで取得できます：\",\n  \"integration.zotero.userID.label\": \"Zotero ユーザーID\",\n  \"invitation.activate\": \"アクティベート\",\n  \"invitation.codeOptions.betaUser\": \"1. ベータユーザーから招待を受ける。\",\n  \"invitation.codeOptions.discord\": \"2. Discord サーバーに参加して、時々プレゼントをもらいましょう。\",\n  \"invitation.codeOptions.xAccount\": \"3. X アカウントをフォローして、時々プレゼントをもらいましょう。\",\n  \"invitation.confirmModal.cancel\": \"キャンセル\",\n  \"invitation.confirmModal.confirm\": \"続けますか？\",\n  \"invitation.confirmModal.continue\": \"続行\",\n  \"invitation.confirmModal.message\": \"招待コードを生成するには、{{INVITATION_PRICE}} <PowerIcon /> Power が必要です。\",\n  \"invitation.confirmModal.title\": \"確認\",\n  \"invitation.created_at\": \"作成日\",\n  \"invitation.earlyAccess\": \"現在、Folo は<strong>アーリーアクセス</strong>中で、招待コードが必要です。\",\n  \"invitation.earlyAccessMessage\": \"😰 申し訳ありません。Folo は現在アーリーアクセス中で、招待コードが必要です。\",\n  \"invitation.generate\": \"生成\",\n  \"invitation.generateButton\": \"新しいコードを生成\",\n  \"invitation.generateCost\": \"{{INVITATION_PRICE}} <PowerIcon /> Power を消費して、友達のために招待コードを生成できます。\",\n  \"invitation.getCodeMessage\": \"以下の方法で招待コードを取得できます：\",\n  \"invitation.limitationMessage\": \"あなたの使用時間に応じて、最大 {{limitation}} 個の招待コードを生成できます。\",\n  \"invitation.newInvitationSuccess\": \"🎉 新しい招待コードが生成され、クリップボードにコピーされました\",\n  \"invitation.noInvitations\": \"招待はありません\",\n  \"invitation.notUsed\": \"未使用\",\n  \"invitation.sidebar_title\": \"招待\",\n  \"invitation.tableHeaders.code\": \"コード\",\n  \"invitation.tableHeaders.creationTime\": \"作成時間\",\n  \"invitation.tableHeaders.usedBy\": \"使用者\",\n  \"invitation.title\": \"招待コード\",\n  \"lists.create\": \"リスト新規作成\",\n  \"lists.created.error\": \"リストの作成に失敗しました\",\n  \"lists.created.success\": \"リストを作成しました\",\n  \"lists.delete.confirm\": \"リストを削除しようとしていますよろしいですか？\",\n  \"lists.delete.error\": \"リストの削除に失敗しました\",\n  \"lists.delete.success\": \"リストを削除しました！\",\n  \"lists.delete.warning\": \"警告: 一度リストを削除するとそのリストは永久に利用できず、元にも戻せません！..\",\n  \"lists.description\": \"説明\",\n  \"lists.earnings\": \"収益\",\n  \"lists.edit.error\": \"リストの編集に失敗しました\",\n  \"lists.edit.label\": \"編集\",\n  \"lists.edit.success\": \"リストを編集しました\",\n  \"lists.fee.description\": \"他の人がこのリストを購読する際に支払う料金\",\n  \"lists.fee.label\": \"料金\",\n  \"lists.feeds.actions\": \"編集\",\n  \"lists.feeds.add.error\": \"リストにフィードを追加できませんでした\",\n  \"lists.feeds.add.label\": \"追加\",\n  \"lists.feeds.add.success\": \"フィードがリストに追加されました\",\n  \"lists.feeds.delete.error\": \"リストからフィードを削除できませんでした\",\n  \"lists.feeds.delete.success\": \"フィードがリストから削除されました\",\n  \"lists.feeds.id\": \"フィード ID\",\n  \"lists.feeds.label\": \"フィード\",\n  \"lists.feeds.manage\": \"フィードを管理\",\n  \"lists.feeds.owner\": \"所有者\",\n  \"lists.feeds.search\": \"フィードを検索\",\n  \"lists.feeds.title\": \"タイトル\",\n  \"lists.image\": \"画像\",\n  \"lists.info\": \"リストは、他の人と共有したり販売したりできる定期購読のリストです。購読者はこのリストにあるすべてのフィードを同期してアクセスできます。\",\n  \"lists.manage_list\": \"リストを管理\",\n  \"lists.noLists\": \"リストがありません\",\n  \"lists.select_feeds\": \"現在のリストに追加するフィードを選択\",\n  \"lists.submit\": \"送信\",\n  \"lists.subscriptions\": \"購読者\",\n  \"lists.title\": \"タイトル\",\n  \"lists.view\": \"表示\",\n  \"notifications.channel\": \"チャンネル\",\n  \"notifications.current\": \"（現在のクライアント）\",\n  \"notifications.empty.description\": \"このデバイスで通知を有効にすると、通知チャンネルがここに表示されます。\",\n  \"notifications.empty.title\": \"通知チャンネルはありません\",\n  \"notifications.info\": \"Foloは<ActionsLink>アクション</ActionsLink>を通じて堅牢で多機能な通知機能を提供します。特定のフィード、ビュー、キーワードの通知をカスタマイズできます。以下は登録された通知チャンネルです。\",\n  \"notifications.test\": \"テスト通知\",\n  \"notifications.test_success\": \"テスト通知が正常に送信されました。\",\n  \"notifications.token\": \"クライアントトークン\",\n  \"plan.canceled_expires\": \"キャンセル済み - {{date}}に終了\",\n  \"plan.current_plan\": \"現在のプラン\",\n  \"plan.descriptions.basic\": \"より多くのフィードに加えて、毎日のAI要約と翻訳を利用できます。\",\n  \"plan.descriptions.free\": \"初心者に最適です。\",\n  \"plan.descriptions.plus\": \"無制限のAI機能と、より多くのフィードや自動化を利用できます。\",\n  \"plan.descriptions.pro\": \"最高の上限、完全なAI機能、そして最上位のパフォーマンス。\",\n  \"plan.featureValues.AI_MODEL_SELECTION.curated\": \"厳選モデル\",\n  \"plan.featureValues.AI_MODEL_SELECTION.high_performance\": \"高性能すべて\",\n  \"plan.featureValues.AI_MODEL_SELECTION.none\": \"—\",\n  \"plan.features.AI_BRING_YOUR_OWN_KEY\": \"AI Bring Your Own Key\",\n  \"plan.features.AI_CREDIT\": \"AI Chat クレジット\",\n  \"plan.features.AI_MODEL_SELECTION\": \"AI モデル選択\",\n  \"plan.features.BOOSTS\": \"購読フィードの加速\",\n  \"plan.features.INTEGRATION_SUPPORTED\": \"サードパーティ統合\",\n  \"plan.features.MAX_ACTIONS\": \"自動化アクション\",\n  \"plan.features.MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"1日あたりのAI要約数\",\n  \"plan.features.MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"1日あたりのAI翻訳数\",\n  \"plan.features.MAX_AI_REQUESTS_PER_DAY\": \"1日あたりのAIリクエスト数\",\n  \"plan.features.MAX_AI_REQUESTS_PER_MONTH\": \"1ヶ月あたりのAIリクエスト数\",\n  \"plan.features.MAX_AI_TASKS\": \"AI タスク\",\n  \"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"1日あたりのAI音声合成数\",\n  \"plan.features.MAX_INBOXES\": \"受信箱\",\n  \"plan.features.MAX_LISTS\": \"カスタムリスト\",\n  \"plan.features.MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub サブスクリプション\",\n  \"plan.features.MAX_SUBSCRIPTIONS\": \"フィードサブスクリプション\",\n  \"plan.features.PRIORITY_SUPPORT\": \"優先サポート\",\n  \"plan.features.PRIVATE_SUBSCRIPTION\": \"プライベートサブスクリプション\",\n  \"plan.features.SECURE_IMAGE_PROXY\": \"セキュア画像プロキシ\",\n  \"plan.manage_subscription\": \"サブスクリプションを管理\",\n  \"plan.renews\": \"更新日 {{date}}\",\n  \"plan.trial_ends\": \"トライアル終了日 {{date}}\",\n  \"privacy.privacy\": \"プライバシー\",\n  \"privacy.terms\": \"利用規約\",\n  \"profile.avatar.cropInstructions\": \"ドラッグしてトリミングエリアを調整します\",\n  \"profile.avatar.dropZoneSubtext\": \"またはクリックしてコンピュータから選択します\",\n  \"profile.avatar.dropZoneText\": \"ここに画像をドラッグ＆ドロップします\",\n  \"profile.avatar.fileTooLarge\": \"ファイルサイズは{{size}}未満である必要があります\",\n  \"profile.avatar.invalidFileType\": \"有効な画像ファイルを選択してください\",\n  \"profile.avatar.label\": \"アバター\",\n  \"profile.avatar.processingError\": \"画像の処理中にエラーが発生しました\",\n  \"profile.avatar.selectAnother\": \"別の画像を選択\",\n  \"profile.avatar.selectFile\": \"ファイルを選択\",\n  \"profile.avatar.uploadError\": \"アバターのアップロードに失敗しました\",\n  \"profile.avatar.uploadSuccess\": \"アバターが正常にアップロードされました\",\n  \"profile.avatar.uploadTitle\": \"アバターをアップロード\",\n  \"profile.change_password.email_required\": \"最初にメールアドレスでサインインしてください。\",\n  \"profile.change_password.label\": \"パスワードを変更\",\n  \"profile.confirm_password.label\": \"パスワードの確認\",\n  \"profile.current_password.label\": \"現在のパスワード\",\n  \"profile.danger_zone\": \"危険ゾーン\",\n  \"profile.delete_account.confirm_description\": \"アカウントを削除してもよろしいですか？\\nこの操作は元に戻せず、反映まで最大 2 日かかる場合があります。\",\n  \"profile.delete_account.confirm_title\": \"アカウントを削除\",\n  \"profile.delete_account.label\": \"アカウントを削除\",\n  \"profile.edit_email\": \"メールを編集\",\n  \"profile.edit_profile\": \"プロフィールを編集\",\n  \"profile.email.change\": \"Email を変更\",\n  \"profile.email.change_note\": \"メールを変更したい場合は、新しいメールを確認する必要があります。\",\n  \"profile.email.changed\": \"Email を変更しました。\",\n  \"profile.email.changed_verification_sent\": \"新たな Email に確認メールを送信しました。\",\n  \"profile.email.label\": \"Email\",\n  \"profile.email.send_verification\": \"確認メールを送信する\",\n  \"profile.email.unverified\": \"未確認\",\n  \"profile.email.verification_sent\": \"確認メールを送信しました\",\n  \"profile.email.verified\": \"確認済み\",\n  \"profile.email.verify_email\": \"続行するにはメール（{{email_address}}）を確認してください\",\n  \"profile.email.verify_status\": \"あなたのメールは{{status}}です\",\n  \"profile.handle.description\": \"あなた個人の識別子です\",\n  \"profile.handle.label\": \"ハンドル\",\n  \"profile.link_social.authentication\": \"認証\",\n  \"profile.link_social.link\": \"リンク\",\n  \"profile.link_social.link_failed\": \"アカウントの連携に失敗しました。\",\n  \"profile.link_social.unlink.action\": \"連携を解除\",\n  \"profile.link_social.unlink.confirm\": \"このアカウント連携を解除してもよろしいですか？\",\n  \"profile.link_social.unlink.success\": \"ソーシャルアカウントのリンクを解除しました。\",\n  \"profile.link_social.unlink.title\": \"アカウント連携を解除\",\n  \"profile.name.description\": \"公開表示名\",\n  \"profile.name.label\": \"表示名\",\n  \"profile.new_password.label\": \"新しいパスワード\",\n  \"profile.no_password\": \"パスワードを <Link>リセット</Link> します。\",\n  \"profile.password.label\": \"パスワード\",\n  \"profile.profile.bio\": \"自己紹介\",\n  \"profile.profile.bio_placeholder\": \"あなたについて教えてください...\",\n  \"profile.profile.changed\": \"プロフィールが更新されました\",\n  \"profile.profile.save\": \"保存\",\n  \"profile.profile.social_links\": \"Social Links\",\n  \"profile.profile.social_links_discord\": \"Discord ID\",\n  \"profile.profile.social_links_facebook\": \"Facebook ID\",\n  \"profile.profile.social_links_github\": \"Github ID\",\n  \"profile.profile.social_links_instagram\": \"Instagram ID\",\n  \"profile.profile.social_links_twitter\": \"Twitter ID\",\n  \"profile.profile.social_links_youtube\": \"Youtube ID\",\n  \"profile.profile.website\": \"Website\",\n  \"profile.reset_password_mail_sent\": \"パスワードリセットメールを送信しました\",\n  \"profile.security\": \"セキュリティ\",\n  \"profile.set_avatar\": \"アバターを設定\",\n  \"profile.sidebar_title\": \"プロフィール\",\n  \"profile.sign_out.confirm_message\": \"サインアウトしてもよろしいですか？\",\n  \"profile.sign_out.confirm_title\": \"サインアウトを確認\",\n  \"profile.submit\": \"送信\",\n  \"profile.title\": \"プロフィール設定\",\n  \"profile.totp_code.init\": \"TOTP アプリで QR コードをスキャンしてください\",\n  \"profile.totp_code.invalid\": \"TOTP コードが正しくありません。\",\n  \"profile.totp_code.label\": \"TOTP コード\",\n  \"profile.totp_code.title\": \"TOTP コードを入力\",\n  \"profile.two_factor.disable\": \"2FA を無効にする\",\n  \"profile.two_factor.disabled\": \"2FA を無効化しました\",\n  \"profile.two_factor.enable\": \"2FA を有効にする \",\n  \"profile.two_factor.enable_failed\": \"2FA を有効化できませんでした。\",\n  \"profile.two_factor.enable_notice\": \"このアクションを実行するには 2FA の有効化が必要です。\",\n  \"profile.two_factor.enabled\": \"2FA が有効になりました\",\n  \"profile.two_factor.invalid_password\": \"パスワードが正しくないか、別の問題が発生しました。\",\n  \"profile.two_factor.label\": \"2FA\",\n  \"profile.two_factor.no_password\": \"2FA を有効化する前にパスワードの <Link>設定</Link> が必要です。\",\n  \"profile.two_factor.setup.description\": \"認証アプリで上の QR コードをスキャンし、アプリに表示された 6 桁のコードを入力してください。\",\n  \"profile.two_factor.setup.title\": \"2FA の設定\",\n  \"profile.two_factor.verify_failed\": \"コードを検証できませんでした\",\n  \"profile.updateSuccess\": \"プロフィールが更新されました。\",\n  \"profile.update_password_success\": \"パスワードが更新されました。\",\n  \"referral.description\": \"Folo を友達と共有しましょう！ Pro Preview と今後の特典を延長でき、友達も 45 日間のトライアル期間を得ることができます。 <Link>詳細はこちら</Link>。\",\n  \"referral.invited_friend_status.pending\": \"保留中の検証\",\n  \"referral.invited_friend_status.valid\": \"有効\",\n  \"referral.link\": \"あなたの招待リンク:\",\n  \"referral.pro_status.preview\": \"あなたの Pro Preview ステータス: {{dateString}} まで有効 (残り {{daysLeft}} 日)\",\n  \"referral.pro_status.trial\": \"あなたの現在のティア: 無料\",\n  \"referral.pro_status.user\": \"あなたの Pro Preview ステータス: アクティブ\",\n  \"reviewPrompt.description\": \"Folo が役に立っているなら、ストアでの評価をお願いします。まだ不満があれば、改善点を教えてください。\",\n  \"reviewPrompt.loveIt\": \"気に入っています\",\n  \"reviewPrompt.notReally\": \"まだ微妙です\",\n  \"reviewPrompt.title\": \"Folo を気に入っていますか？\",\n  \"rsshub.addModal.access_key_label\": \"アクセスキー (オプション)\",\n  \"rsshub.addModal.add\": \"追加\",\n  \"rsshub.addModal.base_url_label\": \"Base URL\",\n  \"rsshub.addModal.description\": \"Folo であなた所有のインスタンスを使うには、以下の環境変数を追加する必要があります。\",\n  \"rsshub.add_new_instance\": \"新たなインスタンスを追加\",\n  \"rsshub.description\": \"RSSHub コミュニティ駆動のオープンソース RSS ネットワークです。Folo は内蔵の専用インスタンスを提供し、そのインスタンスを使って数千のサブスクリプションコンテンツをサポートします。また独自あるいはサードパーティのインスタンスを使用することで、より安定したコンテンツ取得を実現できます。\",\n  \"rsshub.public_instances\": \"利用可能なインスタンス\",\n  \"rsshub.table.delete.confirm\": \"このインスタンスを削除してもよろしいですか？\",\n  \"rsshub.table.delete.label\": \"削除\",\n  \"rsshub.table.delete.success\": \"インスタンスが正常に削除されました。\",\n  \"rsshub.table.description\": \"説明\",\n  \"rsshub.table.edit\": \"編集\",\n  \"rsshub.table.inuse\": \"利用中\",\n  \"rsshub.table.limit_reached\": \"制限に達しました\",\n  \"rsshub.table.official\": \"公式\",\n  \"rsshub.table.owner\": \"所有者\",\n  \"rsshub.table.price\": \"月額の費用\",\n  \"rsshub.table.private\": \"プライベート\",\n  \"rsshub.table.unavailable\": \"利用不可\",\n  \"rsshub.table.unlimited\": \"無制限\",\n  \"rsshub.table.use\": \"利用\",\n  \"rsshub.table.userCount\": \"ユーザー数\",\n  \"rsshub.table.userLimit\": \"ユーザー制限\",\n  \"rsshub.table.yours\": \"あなたの\",\n  \"rsshub.useModal.about\": \"このインスタンスについて\",\n  \"rsshub.useModal.month\": \"月\",\n  \"rsshub.useModal.months_label\": \"購入したい月数\",\n  \"rsshub.useModal.purchase_expires_at\": \"このインスタンスを購入しました、利用期限は\",\n  \"rsshub.useModal.title\": \"RSSHub インスタンス\",\n  \"rsshub.useModal.useWith\": \"使用する {{amount}} <Power />\",\n  \"subscription.actions.comingSoon\": \"近日公開\",\n  \"subscription.actions.current\": \"現在のプラン\",\n  \"subscription.actions.manage_error\": \"サブスクリプション管理を開けませんでした。\",\n  \"subscription.actions.restore\": \"購入を復元\",\n  \"subscription.actions.restore_error\": \"サブスクリプションの復元に失敗しました。\",\n  \"subscription.actions.restore_not_found\": \"復元できる Apple サブスクリプションが見つかりませんでした。\",\n  \"subscription.actions.restore_success\": \"サブスクリプションを復元しました。\",\n  \"subscription.actions.upgrade\": \"アップグレード\",\n  \"subscription.actions.upgrade_error\": \"チェックアウトを開始できませんでした。\",\n  \"subscription.badge.popular\": \"人気No.1\",\n  \"subscription.billing.monthly\": \"月払い\",\n  \"subscription.billing.yearly\": \"年払い\",\n  \"subscription.billing.yearly_savings\": \"{{value}}%お得\",\n  \"subscription.discount.tag\": \"{{value}}%お得\",\n  \"subscription.feature.included\": \"含まれています\",\n  \"subscription.price.free\": \"無料\",\n  \"subscription.price.per_month\": \"／月\",\n  \"subscription.price.per_month_billed_yearly\": \"年払い（月額換算）\",\n  \"subscription.price.per_year\": \"／年\",\n  \"subscription.summary.active\": \"現在アクティブなサブスクリプションです。\",\n  \"subscription.summary.current\": \"{{plan}} プラン\",\n  \"subscription.summary.free\": \"無料プラン\",\n  \"subscription.summary.free_description\": \"アップグレードして、より多くのフィードやAI機能を利用しましょう。\",\n  \"subscription.summary.title\": \"現在のサブスクリプション\",\n  \"subscription.summary.trial_expiring\": \"体験版は {{date}} に終了（残り {{days}} 日）\",\n  \"subscription.unavailable\": \"モバイルでは現在サブスクリプションをご利用いただけません。\",\n  \"titles.about\": \"About\",\n  \"titles.account\": \"アカウント\",\n  \"titles.actions\": \"アクション\",\n  \"titles.ai\": \"AI\",\n  \"titles.appearance\": \"外観\",\n  \"titles.cli\": \"CLI\",\n  \"titles.data_control\": \"データコントロール\",\n  \"titles.feeds\": \"フィード\",\n  \"titles.general\": \"一般設定\",\n  \"titles.integration\": \"統合\",\n  \"titles.invitations\": \"招待\",\n  \"titles.lists\": \"リスト\",\n  \"titles.notifications\": \"通知\",\n  \"titles.plan.long\": \"プランをアップグレード\",\n  \"titles.plan.short\": \"プラン\",\n  \"titles.power\": \"Power\",\n  \"titles.privacy\": \"プライバシー\",\n  \"titles.referral.long\": \"友達を招待して Pro を延長\",\n  \"titles.referral.short\": \"招待 & 収益\",\n  \"titles.shortcuts\": \"ショートカット\",\n  \"titles.sign_out\": \"サインアウト\",\n  \"titles.subscription.long\": \"サブスクリプション管理\",\n  \"titles.subscription.short\": \"サブスクリプション\",\n  \"titles.token_usage\": \"トークン使用状況\",\n  \"wallet.balance.activePoints\": \"アクティブなポイント\",\n  \"wallet.balance.dailyReward\": \"デイリー報酬\",\n  \"wallet.balance.title\": \"残高\",\n  \"wallet.balance.withdrawable\": \"引き出し可能\",\n  \"wallet.balance.withdrawableTooltip\": \"引き出し可能な Power には、受け取ったチップと、再チャージした Power の両方が含まれます。\",\n  \"wallet.claim.button.claim\": \"デイリー Power を取得\",\n  \"wallet.claim.button.claimed\": \"今日取得済み\",\n  \"wallet.claim.tooltip.alreadyClaimed\": \"今日はすでに取得済みです。\",\n  \"wallet.claim.tooltip.canClaim\": \"{{amount}} のデイリー Power を今すぐ取得しましょう！\",\n  \"wallet.create.button\": \"ウォレットを作成\",\n  \"wallet.create.description\": \"<PowerIcon /><strong>Power</strong>を受け取るための無料ウォレットを作成し、クリエイターに報酬を与えたり、コンテンツ貢献で報酬を得ることができます。\",\n  \"wallet.power.dailyClaim\": \"毎日 {{amount}} の無料 Power を取得でき、それを使って Folo の RSS エントリにチップを送ることができます。\",\n  \"wallet.power.rewardDescription\": \"Folo のすべてのアクティブ ユーザーは、デイリー Power 特典の対象となります。\",\n  \"wallet.power.rewardDescription2\": \"あなたのレベルや日々のアクティビティによって <Balance /> が本日の報酬として提供されます。<Link>さらなる情報はこちら</Link>\",\n  \"wallet.ranking.level\": \"レベル\",\n  \"wallet.ranking.name\": \"名前\",\n  \"wallet.ranking.power\": \"Power\",\n  \"wallet.ranking.rank\": \"ランク\",\n  \"wallet.ranking.title\": \"Power ランキング\",\n  \"wallet.rewardDescription.description1\": \"各ユーザーの毎日の報酬はユーザー レベルとユーザー アクティビティ ポイントの2つの要素に基づいています。\",\n  \"wallet.rewardDescription.description2\": \"ユーザー レベル: 他のすべてのユーザーと比較したユーザーの Power ランキングによって決定されます。\",\n  \"wallet.rewardDescription.description3\": \"ユーザーのアクティビティ: 様々な Folo 機能に参加することでアクティビティを高めることができます。報酬は最低1倍から最高10倍まで。\",\n  \"wallet.rewardDescription.level\": \"ユーザー レベル\",\n  \"wallet.rewardDescription.percentage\": \"ランキングの割合\",\n  \"wallet.rewardDescription.reward\": \"報酬の割合\",\n  \"wallet.rewardDescription.title\": \"実績の説明\",\n  \"wallet.rewardDescription.total\": \"一日に得られる実績数\",\n  \"wallet.sidebar_title\": \"Power\",\n  \"wallet.transactions.amount\": \"金額\",\n  \"wallet.transactions.date\": \"日付\",\n  \"wallet.transactions.empty.description\": \"チップ、購入、出金、エアドロップの履歴が発生するとここに表示されます。\",\n  \"wallet.transactions.empty.title\": \"まだ取引はありません\",\n  \"wallet.transactions.from\": \"送信元\",\n  \"wallet.transactions.more\": \"blockchain explorerで詳細を表示する\",\n  \"wallet.transactions.noTransactions\": \"トランザクションなし\",\n  \"wallet.transactions.title\": \"トランザクション\",\n  \"wallet.transactions.to\": \"送信先\",\n  \"wallet.transactions.tx\": \"取引 ID\",\n  \"wallet.transactions.type\": \"タイプ\",\n  \"wallet.transactions.types.airdrop\": \"エアドロップ\",\n  \"wallet.transactions.types.all\": \"すべて\",\n  \"wallet.transactions.types.burn\": \"バーン\",\n  \"wallet.transactions.types.mint\": \"ミント\",\n  \"wallet.transactions.types.purchase\": \"購入\",\n  \"wallet.transactions.types.tip\": \"チップ\",\n  \"wallet.transactions.types.withdraw\": \"引き出し\",\n  \"wallet.transactions.you\": \"あなた\",\n  \"wallet.withdraw.addressLabel\": \"あなたの Ethereum アドレス\",\n  \"wallet.withdraw.amountLabel\": \"金額\",\n  \"wallet.withdraw.availableBalance\": \"引き出し可能な Power は<Balance></Balance>です。\",\n  \"wallet.withdraw.button\": \"引き出し\",\n  \"wallet.withdraw.error\": \"引き出しに失敗しました：{{error}}\",\n  \"wallet.withdraw.modalTitle\": \"Power を引き出す\",\n  \"wallet.withdraw.receiveRSS3\": \"{{amount}} RSS3を受け取ります\",\n  \"wallet.withdraw.submitButton\": \"送信\",\n  \"wallet.withdraw.success\": \"引き出しが成功しました！\",\n  \"wallet.withdraw.toRss3Label\": \"RSS3 で引き出す\"\n}\n"
  },
  {
    "path": "locales/settings/zh-CN.json",
    "content": "{\n  \"about.aiOnboardingDescription\": \"重新体验交互式新手引导。\",\n  \"about.appTip\": \"App 功能\",\n  \"about.appTipDescription\": \"App 功能介绍与使用指南。\",\n  \"about.changelog\": \"更新日志\",\n  \"about.changelogDescription\": \"查看每个版本的新功能\",\n  \"about.checkForUpdates\": \"检查更新\",\n  \"about.checkNow\": \"立即检查\",\n  \"about.checkingForUpdates\": \"正在检查更新...\",\n  \"about.copyEnvironment\": \"复制环境\",\n  \"about.environmentCopied\": \"环境信息已复制\",\n  \"about.feedbackInfo\": \"{{appName}}（{{commitSha}}）正处于开发的早期阶段。如果你有任何反馈或建议，请随时在我们的 GitHub 上<OpenIssueLink>提出</OpenIssueLink> 。\",\n  \"about.iconLibrary\": \"使用的图标库受版权保护，版权所有者为 <IconLibraryLink />，不得重新分发。\",\n  \"about.legal\": \"法律信息\",\n  \"about.licenseInfo\": \"Copyright © {{currentYear}} {{appName}}. 保留所有权利。\",\n  \"about.noUpdateAvailable\": \"已是最新版本\",\n  \"about.privacyPolicy\": \"隐私政策\",\n  \"about.projectLicense\": \"{{appName}} 采用 GNU Affero 通用公共许可证第3版授权，并附有额外的例外条款。\",\n  \"about.rateFolo\": \"给 Folo 评分\",\n  \"about.rateFoloDescription\": \"留下评分，支持产品继续发展。\",\n  \"about.resources\": \"资源与贡献\",\n  \"about.sendFeedback\": \"发送反馈\",\n  \"about.sendFeedbackDescription\": \"告诉我们哪些地方还可以改进。\",\n  \"about.sidebar_title\": \"关于\",\n  \"about.socialMedia\": \"社交媒体\",\n  \"about.support\": \"支持与反馈\",\n  \"about.termsOfService\": \"服务条款\",\n  \"about.updateAvailable\": \"有可用更新！\",\n  \"about.updateCheckFailed\": \"检查更新失败\",\n  \"about.updateDescription\": \"保持应用程序更新以获得最新功能和改进\",\n  \"about.viewChangelog\": \"查看更新日志\",\n  \"actions.actionName\": \"规则 {{number}}\",\n  \"actions.action_card.add\": \"添加\",\n  \"actions.action_card.all\": \"全部\",\n  \"actions.action_card.and\": \"并且\",\n  \"actions.action_card.block\": \"屏蔽\",\n  \"actions.action_card.block_rules\": \"阻止规则\",\n  \"actions.action_card.custom_filters\": \"指定条件\",\n  \"actions.action_card.empty.cta\": \"创建第一条规则\",\n  \"actions.action_card.empty.description\": \"创建首个自动化规则以自动处理你的订阅源\",\n  \"actions.action_card.empty.start\": \"从此处开始！\",\n  \"actions.action_card.empty.title\": \"尚无自动化规则\",\n  \"actions.action_card.enable_readability\": \"启用阅读模式\",\n  \"actions.action_card.feed_options.entry_attachments_duration\": \"条目视频时长\",\n  \"actions.action_card.feed_options.entry_author\": \"条目作者\",\n  \"actions.action_card.feed_options.entry_content\": \"条目内容\",\n  \"actions.action_card.feed_options.entry_media_length\": \"条目媒体个数\",\n  \"actions.action_card.feed_options.entry_title\": \"条目标题\",\n  \"actions.action_card.feed_options.entry_url\": \"条目地址\",\n  \"actions.action_card.feed_options.feed_category\": \"订阅源分类\",\n  \"actions.action_card.feed_options.feed_title\": \"订阅源标题\",\n  \"actions.action_card.feed_options.feed_url\": \"订阅源地址\",\n  \"actions.action_card.feed_options.site_url\": \"网站链接\",\n  \"actions.action_card.feed_options.status\": \"状态\",\n  \"actions.action_card.feed_options.subscription_view\": \"订阅视图\",\n  \"actions.action_card.field\": \"字段\",\n  \"actions.action_card.from\": \"从\",\n  \"actions.action_card.generate_summary\": \"使用 AI 生成总结\",\n  \"actions.action_card.name\": \"名称\",\n  \"actions.action_card.new_entry_notification\": \"新条目通知\",\n  \"actions.action_card.no_translation\": \"无翻译\",\n  \"actions.action_card.operation_options.contains\": \"包含\",\n  \"actions.action_card.operation_options.does_not_contain\": \"不包含\",\n  \"actions.action_card.operation_options.is_equal_to\": \"等于\",\n  \"actions.action_card.operation_options.is_greater_than\": \"大于\",\n  \"actions.action_card.operation_options.is_less_than\": \"小于\",\n  \"actions.action_card.operation_options.is_not_equal_to\": \"不等于\",\n  \"actions.action_card.operation_options.matches_regex\": \"匹配正则表达式\",\n  \"actions.action_card.operator\": \"运算符\",\n  \"actions.action_card.or\": \"或者\",\n  \"actions.action_card.rewrite_rules\": \"重写规则\",\n  \"actions.action_card.settings\": \"设置\",\n  \"actions.action_card.silence\": \"静音\",\n  \"actions.action_card.source_content\": \"查看原始内容\",\n  \"actions.action_card.star\": \"收藏\",\n  \"actions.action_card.summary.action_count\": \"已启用 {{count}} 个动作\",\n  \"actions.action_card.summary.active\": \"启用\",\n  \"actions.action_card.summary.copy\": \"复制到剪贴板\",\n  \"actions.action_card.summary.delete\": \"删除\",\n  \"actions.action_card.summary.delete_message\": \"确定要删除此规则吗？该操作无法撤销。\",\n  \"actions.action_card.summary.delete_title\": \"删除规则\",\n  \"actions.action_card.summary.disabled\": \"已禁用\",\n  \"actions.action_card.summary.empty\": \"暂时还没有规则\",\n  \"actions.action_card.summary.export\": \"导出到文件\",\n  \"actions.action_card.summary.helper\": \"用精准的触发条件和动作自动化你的工作流。\",\n  \"actions.action_card.summary.import\": \"导入\",\n  \"actions.action_card.summary.import_clipboard\": \"从剪贴板导入\",\n  \"actions.action_card.summary.import_file\": \"从文件导入\",\n  \"actions.action_card.summary.no_actions\": \"尚未配置任何动作\",\n  \"actions.action_card.summary.rule_count\": \"{{count}} 条规则\",\n  \"actions.action_card.summary.share\": \"分享\",\n  \"actions.action_card.summary.toggle\": \"切换规则状态\",\n  \"actions.action_card.then_do\": \"执行动作\",\n  \"actions.action_card.to\": \"到\",\n  \"actions.action_card.translate_into\": \"翻译成\",\n  \"actions.action_card.value\": \"值\",\n  \"actions.action_card.webhooks\": \"Webhook\",\n  \"actions.action_card.when_feeds_match\": \"当订阅源满足\",\n  \"actions.condition\": \"条件\",\n  \"actions.conditions\": \"条件\",\n  \"actions.edit_condition\": \"编辑条件\",\n  \"actions.edit_rewrite_rule\": \"编辑重写规则\",\n  \"actions.edit_rule\": \"编辑规则\",\n  \"actions.edit_webhook\": \"编辑 Webhook\",\n  \"actions.info\": \"自动化是可以在服务器或客户端自动执行任务的规则集。\",\n  \"actions.navigate.prompt\": \"有未保存的自动化规则更改，确定要离开吗？\",\n  \"actions.newRule\": \"新规则\",\n  \"actions.save\": \"保存\",\n  \"actions.saveSuccess\": \"🎉 规则已保存\",\n  \"actions.sidebar_title\": \"自动化\",\n  \"actions.title\": \"自动化\",\n  \"ai.personalize.prompt.description\": \"自定义 AI 生成内容时的提示词。\",\n  \"ai.personalize.prompt.label\": \"个性化提示词\",\n  \"ai.personalize.title\": \"AI 个性化设置\",\n  \"ai.shortcuts.title\": \"AI 快捷操作\",\n  \"appearance.accent_color.description\": \"选择应用界面的强调色\",\n  \"appearance.accent_color.label\": \"强调色\",\n  \"appearance.code_highlight_theme.description\": \"调整代码高亮主题\",\n  \"appearance.code_highlight_theme.label\": \"代码高亮主题\",\n  \"appearance.code_highlighting.title\": \"代码高亮\",\n  \"appearance.common.title\": \"通用\",\n  \"appearance.content\": \"内容\",\n  \"appearance.content_display.title\": \"内容显示\",\n  \"appearance.content_font.default\": \"默认（界面字体）\",\n  \"appearance.content_font.description\": \"调整阅读内容使用的字体。\",\n  \"appearance.content_font.label\": \"正文字体\",\n  \"appearance.content_font_size\": \"正文字体大小\",\n  \"appearance.content_line_height.description\": \"调整文章中文本行间距\",\n  \"appearance.content_line_height.label\": \"正文行高\",\n  \"appearance.content_line_height.loose\": \"松散\",\n  \"appearance.content_line_height.normal\": \"正常\",\n  \"appearance.content_line_height.relaxed\": \"宽松\",\n  \"appearance.content_line_height.snug\": \"紧凑\",\n  \"appearance.content_line_height.tight\": \"紧密\",\n  \"appearance.custom_css.button\": \"编辑\",\n  \"appearance.custom_css.description\": \"用于条目内容的自定义 CSS 样式。\",\n  \"appearance.custom_css.label\": \"自定义 CSS\",\n  \"appearance.custom_font\": \"自定义字体\",\n  \"appearance.customization.title\": \"自定义\",\n  \"appearance.customize_sub_tabs.description\": \"按你的偏好自定义订阅视图标签。\",\n  \"appearance.customize_sub_tabs.label\": \"自定义视图标签\",\n  \"appearance.customize_toolbar.description\": \"按你的偏好自定义条目内容工具栏。\",\n  \"appearance.customize_toolbar.label\": \"自定义工具栏\",\n  \"appearance.date_format.description\": \"调整显示使用的日期格式。\",\n  \"appearance.date_format.label\": \"日期格式\",\n  \"appearance.font.custom\": \"自定义\",\n  \"appearance.font.system\": \"系统字体\",\n  \"appearance.font_scaling.content_different.description\": \"为文章内容设置独立的字体大小\",\n  \"appearance.font_scaling.content_different.label\": \"内容使用独立字体大小\",\n  \"appearance.font_scaling.content_size.description\": \"设置文章内容的字体大小\",\n  \"appearance.font_scaling.content_size.l\": \"大号\",\n  \"appearance.font_scaling.content_size.label\": \"内容字体大小\",\n  \"appearance.font_scaling.content_size.m\": \"中号\",\n  \"appearance.font_scaling.content_size.s\": \"小号\",\n  \"appearance.font_scaling.content_size.xl\": \"特大号\",\n  \"appearance.font_scaling.content_size.xs\": \"极小号\",\n  \"appearance.font_scaling.scale.description\": \"调整字体大小缩放比例\",\n  \"appearance.font_scaling.scale.label\": \"字体缩放\",\n  \"appearance.font_scaling.size.l\": \"大\",\n  \"appearance.font_scaling.size.m\": \"默认\",\n  \"appearance.font_scaling.size.s\": \"小\",\n  \"appearance.font_scaling.size.xl\": \"较大\",\n  \"appearance.font_scaling.size.xs\": \"较小\",\n  \"appearance.font_scaling.system.description\": \"跟随系统无障碍字体大小设置\",\n  \"appearance.font_scaling.system.label\": \"使用系统字体缩放\",\n  \"appearance.font_scaling.title\": \"字体缩放\",\n  \"appearance.fonts\": \"字体\",\n  \"appearance.general\": \"通用\",\n  \"appearance.global_font.default\": \"默认\",\n  \"appearance.global_font_size.description\": \"调整整体文字大小\",\n  \"appearance.global_font_size.label\": \"全局字体大小\",\n  \"appearance.guess_code_language.description\": \"使用模型推断未标记代码块的编程语言。\",\n  \"appearance.guess_code_language.label\": \"代码语言检测\",\n  \"appearance.hide_extra_badge.description\": \"将侧边栏中订阅源的特殊徽章隐藏，例如：已助力，已认证。\",\n  \"appearance.hide_extra_badge.label\": \"隐藏徽章\",\n  \"appearance.hide_recent_reader.description\": \"隐藏条目标题显示的最近阅读者与阅读数。\",\n  \"appearance.hide_recent_reader.label\": \"隐藏最近阅读者与阅读数\",\n  \"appearance.interface_window.title\": \"界面与窗口\",\n  \"appearance.misc\": \"杂项\",\n  \"appearance.modal_overlay.description\": \"显示遮罩以获得更好的使用体验，推荐开启。\",\n  \"appearance.modal_overlay.label\": \"显示遮罩\",\n  \"appearance.opaque_sidebars.description\": \"使侧边栏背景透明。\",\n  \"appearance.opaque_sidebars.label\": \"不透明侧边栏\",\n  \"appearance.reader_render_inline_style.description\": \"允许渲染原始 HTML 的内联样式。\",\n  \"appearance.reader_render_inline_style.label\": \"渲染内联样式\",\n  \"appearance.reading_view.title\": \"阅读视图\",\n  \"appearance.reduce_motion.description\": \"减弱动态效果以提高整体性能并减少电量消耗。\",\n  \"appearance.reduce_motion.label\": \"减弱动态效果\",\n  \"appearance.save\": \"保存\",\n  \"appearance.sidebar\": \"侧边栏\",\n  \"appearance.sidebar_title\": \"外观\",\n  \"appearance.subscription_list.title\": \"订阅列表\",\n  \"appearance.subscriptions\": \"订阅\",\n  \"appearance.system_integration.title\": \"系统集成\",\n  \"appearance.text_size.default\": \"默认\",\n  \"appearance.text_size.label\": \"界面字体大小\",\n  \"appearance.text_size.large\": \"较大\",\n  \"appearance.text_size.medium\": \"中等\",\n  \"appearance.text_size.smaller\": \"较小\",\n  \"appearance.theme.dark\": \"深色\",\n  \"appearance.theme.description\": \"调整应用的整体主题\",\n  \"appearance.theme.label\": \"主题\",\n  \"appearance.theme.light\": \"亮色\",\n  \"appearance.theme.system\": \"跟随系统\",\n  \"appearance.thumbnail_ratio.description\": \"文章列表缩略图比例。\",\n  \"appearance.thumbnail_ratio.original\": \"原始比例\",\n  \"appearance.thumbnail_ratio.square\": \"正方形\",\n  \"appearance.thumbnail_ratio.title\": \"缩略比例\",\n  \"appearance.title\": \"外观\",\n  \"appearance.typography.title\": \"排版\",\n  \"appearance.ui_font.description\": \"调整用于界面元素的字体。\",\n  \"appearance.ui_font.label\": \"界面字体\",\n  \"appearance.unread_count.badge.description\": \"在 Dock 图标上以徽章形式显示未读数。\",\n  \"appearance.unread_count.badge.label\": \"Dock 图标显示未读数\",\n  \"appearance.unread_count.label\": \"未读数\",\n  \"appearance.unread_count.sidebar.description\": \"在订阅源和分组旁显示未读数\",\n  \"appearance.unread_count.sidebar.title\": \"显示未读数\",\n  \"appearance.unread_count.view_and_subscription.description\": \"在视图和订阅列表中显示未读数。\",\n  \"appearance.unread_count.view_and_subscription.label\": \"在视图和订阅源上显示\",\n  \"appearance.use_pointer_cursor.description\": \"当鼠标悬停在任何可交互元素上时，光标会显示为手型光标。\",\n  \"appearance.use_pointer_cursor.label\": \"使用手型光标\",\n  \"appearance.words.customize\": \"自定义\",\n  \"cli.description\": \"你可以直接通过 npx 使用 folocli@latest 在任意终端里运行 Folo CLI，无需全局安装。桌面端可以一键同步当前登录状态。\",\n  \"cli.desktop_sync\": \"桌面端同步命令\",\n  \"cli.global_install\": \"通过 npx 运行最新版本\",\n  \"cli.install\": \"同步桌面端登录\",\n  \"cli.install_failed\": \"同步桌面端登录到 CLI 失败\",\n  \"cli.install_success\": \"已将桌面端登录同步到 CLI\",\n  \"cli.installed\": \"已连接\",\n  \"cli.not_available\": \"未检测到 npx，请先安装 Node.js 和 npm。\",\n  \"cli.not_installed\": \"未连接\",\n  \"cli.package\": \"包名\",\n  \"cli.path\": \"配置路径\",\n  \"cli.require_login\": \"请先登录 Folo Desktop，再同步 CLI 登录。\",\n  \"cli.runtime_missing\": \"需要 Node.js/npm\",\n  \"cli.runtime_ready\": \"Node.js/npm 已就绪\",\n  \"cli.title\": \"Folo CLI\",\n  \"cli.uninstall\": \"清除 CLI 登录\",\n  \"cli.uninstall_failed\": \"清除 CLI 登录失败\",\n  \"cli.uninstall_success\": \"已清除 CLI 登录\",\n  \"common.give_star\": \"<HeartIcon />喜欢我们的产品吗？ <Link>在 GitHub 上给我们「Star」吧！</Link>\",\n  \"control.paid_badge.basic_or_higher\": \"此功能需要 Basic 计划或更高级别才能使用\",\n  \"control.paid_badge.free_limited\": \"此功能在免费计划中有限制\",\n  \"customizeToolbar.more_actions.description\": \"将显示在下拉菜单中\",\n  \"customizeToolbar.more_actions.title\": \"更多操作\",\n  \"customizeToolbar.quick_actions.description\": \"自定义并重新排列您常用的操作\",\n  \"customizeToolbar.quick_actions.title\": \"快捷操作\",\n  \"customizeToolbar.reset_layout\": \"重置为默认布局\",\n  \"customizeToolbar.title\": \"自定义工具栏\",\n  \"data_control.app_cache_limit.description\": \"应用缓存大小的上限。一旦缓存达到此上限，最早的项目将被删除以释放空间。\",\n  \"data_control.app_cache_limit.label\": \"应用缓存限制\",\n  \"data_control.clean_cache.button\": \"清理缓存\",\n  \"data_control.clean_cache.cancel\": \"取消\",\n  \"data_control.clean_cache.clear\": \"清除\",\n  \"data_control.clean_cache.description\": \"清理应用缓存以释放空间。\",\n  \"data_control.clean_cache.description_web\": \"清理 Web 应用的 Service Worker 缓存以释放空间。\",\n  \"data_control.clean_cache.success\": \"缓存清理成功。\",\n  \"data_control.data_sources\": \"数据源\",\n  \"data_control.export_local_database.label\": \"导出本地数据库\",\n  \"data_control.import_opml.label\": \"从 OPML 导入订阅\",\n  \"data_control.utils\": \"工具\",\n  \"discoverFilters.filters\": \"筛选\",\n  \"discoverFilters.language\": \"语言\",\n  \"discoverFilters.title\": \"发现筛选\",\n  \"feeds.claim\": \"认证订阅源\",\n  \"feeds.claimTips\": \"要认证你的订阅源并接收打赏，请在订阅列表中右键点击订阅源并选择「认证」。\",\n  \"feeds.filter.all\": \"全部 ({{count}})\",\n  \"feeds.filter.rsshub\": \"RSSHub ({{count}})\",\n  \"feeds.noFeeds\": \"没有已认证的订阅源\",\n  \"feeds.subscription\": \"已订阅的订阅源\",\n  \"feeds.tableHeaders.date\": \"订阅日期\",\n  \"feeds.tableHeaders.followers\": \"订阅人数\",\n  \"feeds.tableHeaders.name\": \"名称\",\n  \"feeds.tableHeaders.subscriptionCount\": \"订阅数\",\n  \"feeds.tableHeaders.tipAmount\": \"收到的打赏\",\n  \"feeds.tableHeaders.updatesPerWeek\": \"更新频率\",\n  \"feeds.tableHeaders.view\": \"视图\",\n  \"feeds.tableSelected.clear\": \"清空\",\n  \"feeds.tableSelected.item\": \"{{count}} 项已选中\",\n  \"feeds.tableSelected.moveToView.action\": \"移动视图\",\n  \"feeds.tableSelected.moveToView.confirm\": \"确定要将这些订阅源移动到 {{view}} 吗？\",\n  \"feeds.tableSelected.moveToView.confirmTitle\": \"确认\",\n  \"feeds.tableSelected.unsubscribe\": \"取消订阅\",\n  \"general.action.summary.description\": \"使用 AI 生成条目总结。\",\n  \"general.action.summary.label\": \"AI 总结\",\n  \"general.action.title\": \"自动化\",\n  \"general.action.translation.description\": \"将条目翻译成所选语言。\",\n  \"general.action.translation.label\": \"AI 翻译\",\n  \"general.action_language.default\": \"默认（界面语言）\",\n  \"general.action_language.description\": \"选择 AI 操作的语言，例如 AI 总结、AI 翻译。\",\n  \"general.action_language.label\": \"AI 输出语言\",\n  \"general.advanced\": \"高级\",\n  \"general.app\": \"应用程序\",\n  \"general.auto_expand_long_social_media.description\": \"自动展开包含长文本的社交媒体条目。\",\n  \"general.auto_expand_long_social_media.label\": \"展开长社交媒体\",\n  \"general.auto_group.description\": \"自动按网站域名分组订阅源。\",\n  \"general.auto_group.label\": \"自动分组\",\n  \"general.cache\": \"缓存\",\n  \"general.content\": \"内容\",\n  \"general.data\": \"数据\",\n  \"general.data_file.label\": \"数据文件\",\n  \"general.dim_read.description\": \"淡化时间线中已读条目的显示颜色。\",\n  \"general.dim_read.label\": \"淡化已读条目\",\n  \"general.enhanced.description\": \"启用增强设置可提供更多自定义选项，但也可能引发不可预见的问题。\",\n  \"general.enhanced.disabled.tip\": \"增强设置已停用，你可以在「通用」设置的「高级」选项中将其启用。\",\n  \"general.enhanced.enable.modal.cancel\": \"取消\",\n  \"general.enhanced.enable.modal.confirm\": \"启用\",\n  \"general.enhanced.enable.modal.description\": \"启用增强设置可提供更多自定义选项，但也可能引发不可预见的问题。除非你清楚自己在做什么，否则请不要启用此选项。\",\n  \"general.enhanced.enable.modal.title\": \"启用增强设置\",\n  \"general.enhanced.enabled.tip\": \"增强设置已启用，你可以在「通用」设置的「高级」选项中将其停用。\",\n  \"general.enhanced.label\": \"增强设置\",\n  \"general.export.button\": \"导出\",\n  \"general.export.description\": \"导出你的订阅源列表。\",\n  \"general.export.folder_mode.description\": \"决定你想要如何组织导出文件夹。\",\n  \"general.export.folder_mode.label\": \"文件夹模式\",\n  \"general.export.folder_mode.option.category\": \"分类\",\n  \"general.export.folder_mode.option.view\": \"视图\",\n  \"general.export.label\": \"导出订阅源 (OPML)\",\n  \"general.export.rsshub_url.description\": \"RSSHub 路由的默认根 URL，留空则使用 https://rsshub.app。\",\n  \"general.export.rsshub_url.label\": \"RSSHub URL\",\n  \"general.export_data.title\": \"导出数据\",\n  \"general.export_database.button\": \"导出\",\n  \"general.export_database.description\": \"导出你的所有数据，包括文章（完整备份）。\",\n  \"general.export_database.label\": \"导出数据库\",\n  \"general.group_by_date.description\": \"按日期对条目进行分组。\",\n  \"general.group_by_date.label\": \"按日期分组\",\n  \"general.hide_all_read_subscriptions.description\": \"在订阅列表中隐藏没有未读条目的订阅。\",\n  \"general.hide_all_read_subscriptions.label\": \"隐藏已读\",\n  \"general.hide_private_subscriptions_in_timeline.description\": \"从你的订阅列表中隐藏私密订阅，并从你的时间线上隐藏它们的条目（无论此设置如何，它们对公众始终是不可见的）。\",\n  \"general.hide_private_subscriptions_in_timeline.label\": \"隐藏私密\",\n  \"general.language.description\": \"选择应用的显示语言。\",\n  \"general.language.title\": \"语言\",\n  \"general.launch_at_login\": \"开机时启动\",\n  \"general.log_file.button\": \"显示\",\n  \"general.log_file.description\": \"在系统中显示日志文件。\",\n  \"general.log_file.label\": \"日志文件\",\n  \"general.maintenance.title\": \"维护\",\n  \"general.mark_as_read.hover.description\": \"当鼠标悬停在条目时自动将其标记为已读。\",\n  \"general.mark_as_read.hover.label\": \"悬停在文章上时\",\n  \"general.mark_as_read.render.description\": \"将社交媒体贴文或图片等单项内容立即标记为已读。\",\n  \"general.mark_as_read.render.label\": \"单项内容进入视图时\",\n  \"general.mark_as_read.scroll.description\": \"当条目滚动出可视区域时自动将其标记为已读。\",\n  \"general.mark_as_read.scroll.label\": \"滚动浏览文章时\",\n  \"general.mark_as_read.title\": \"标记已读\",\n  \"general.minimize_to_tray.description\": \"关闭窗口时最小化到系统托盘。\",\n  \"general.minimize_to_tray.label\": \"最小化到托盘\",\n  \"general.network\": \"网络\",\n  \"general.open_links_in_external_app.label\": \"在外部应用内打开链接\",\n  \"general.privacy\": \"隐私\",\n  \"general.proxy.description\": \"代理网络请求，例如：socks://proxy.example.com:1080。\",\n  \"general.proxy.label\": \"代理\",\n  \"general.rebuild_database.button\": \"重建\",\n  \"general.rebuild_database.cancel\": \"取消\",\n  \"general.rebuild_database.description\": \"尝试重建数据库可以解决部分渲染或其他类型问题。\",\n  \"general.rebuild_database.label\": \"重建数据库\",\n  \"general.rebuild_database.title\": \"重建数据库\",\n  \"general.rebuild_database.warning.line1\": \"重建数据库将清除所有本地数据，且无法恢复。\",\n  \"general.rebuild_database.warning.line2\": \"确定继续？\",\n  \"general.send_anonymous_data.description\": \"发送匿名用户数据，帮助产品改进和功能迭代。\",\n  \"general.send_anonymous_data.label\": \"发送匿名数据\",\n  \"general.show_quick_timeline.description\": \"在时间线顶部显示列表和分类的时间线快递导航。\",\n  \"general.show_quick_timeline.label\": \"显示时间线快速导航\",\n  \"general.show_unread_on_launch.description\": \"应用启动时仅显示未读内容。\",\n  \"general.show_unread_on_launch.label\": \"启动时仅显示未读\",\n  \"general.sidebar_title\": \"通用\",\n  \"general.subscription\": \"订阅\",\n  \"general.subscriptions\": \"订阅\",\n  \"general.timeline\": \"时间线\",\n  \"general.translation_mode.bilingual\": \"双语对照\",\n  \"general.translation_mode.description\": \"选择译文在条目列表中的显示方式。\",\n  \"general.translation_mode.label\": \"翻译偏好\",\n  \"general.translation_mode.translation-only\": \"仅译文\",\n  \"general.voices\": \"声音\",\n  \"integration.builtin.title\": \"内置集成\",\n  \"integration.categories.custom_integrations\": \"自定义集成\",\n  \"integration.categories.download_tools\": \"下载工具\",\n  \"integration.categories.knowledge_management\": \"知识管理\",\n  \"integration.categories.media_tools\": \"媒体工具\",\n  \"integration.categories.reading_services\": \"阅读服务\",\n  \"integration.cubox.autoMemo.description\": \"自动使用 Memo 模式保存选中的文本到 Cubox。\",\n  \"integration.cubox.autoMemo.label\": \"自动 Memo 模式\",\n  \"integration.cubox.enable.description\": \"显示「保存到 Cubox」按钮（如果可用）。\",\n  \"integration.cubox.enable.label\": \"启用\",\n  \"integration.cubox.title\": \"Cubox\",\n  \"integration.cubox.token.description\": \"请输入完整的 Cubox API URL，格式如：https://cubox.pro/c/api/save/xxxxxxxxx。你可以在此获取：\",\n  \"integration.cubox.token.label\": \"Cubox API URL\",\n  \"integration.custom_integrations.actions.delete\": \"删除\",\n  \"integration.custom_integrations.actions.disable\": \"禁用\",\n  \"integration.custom_integrations.actions.edit\": \"编辑\",\n  \"integration.custom_integrations.actions.enable\": \"启用\",\n  \"integration.custom_integrations.add.button\": \"添加\",\n  \"integration.custom_integrations.create.error\": \"创建失败\",\n  \"integration.custom_integrations.create.success\": \"创建成功\",\n  \"integration.custom_integrations.create.title\": \"创建自定义集成\",\n  \"integration.custom_integrations.delete.success\": \"删除成功\",\n  \"integration.custom_integrations.edit.error\": \"编辑失败\",\n  \"integration.custom_integrations.edit.success\": \"编辑成功\",\n  \"integration.custom_integrations.edit.title\": \"编辑自定义集成\",\n  \"integration.custom_integrations.enable.description\": \"启用此自定义集成\",\n  \"integration.custom_integrations.enable.label\": \"启用\",\n  \"integration.custom_integrations.form.body.description\": \"请求体内容\",\n  \"integration.custom_integrations.form.body.label\": \"请求体\",\n  \"integration.custom_integrations.form.body.placeholder\": \"输入请求体内容\",\n  \"integration.custom_integrations.form.fetch_template.label\": \"获取模板\",\n  \"integration.custom_integrations.form.headers.add\": \"添加请求头\",\n  \"integration.custom_integrations.form.headers.description\": \"自定义请求头\",\n  \"integration.custom_integrations.form.headers.key_placeholder\": \"键名\",\n  \"integration.custom_integrations.form.headers.label\": \"请求头\",\n  \"integration.custom_integrations.form.headers.value_placeholder\": \"键值\",\n  \"integration.custom_integrations.form.icon.description\": \"选择集成图标\",\n  \"integration.custom_integrations.form.icon.label\": \"图标\",\n  \"integration.custom_integrations.form.method.description\": \"选择 HTTP 方法。\",\n  \"integration.custom_integrations.form.method.label\": \"方法\",\n  \"integration.custom_integrations.form.name.label\": \"名称\",\n  \"integration.custom_integrations.form.name.placeholder\": \"输入集成名称\",\n  \"integration.custom_integrations.form.scheme.description\": \"URL 参数模板\",\n  \"integration.custom_integrations.form.scheme.examples.title\": \"示例\",\n  \"integration.custom_integrations.form.scheme.label\": \"URL 参数\",\n  \"integration.custom_integrations.form.scheme.placeholder\": \"输入 URL 参数模板\",\n  \"integration.custom_integrations.form.type.description\": \"选择集成类型\",\n  \"integration.custom_integrations.form.type.http\": \"HTTP 集成\",\n  \"integration.custom_integrations.form.type.label\": \"类型\",\n  \"integration.custom_integrations.form.type.url_scheme\": \"URL 方案\",\n  \"integration.custom_integrations.form.url.description\": \"目标 URL 地址。\",\n  \"integration.custom_integrations.form.url.label\": \"URL\",\n  \"integration.custom_integrations.form.url.placeholder\": \"输入目标 URL\",\n  \"integration.custom_integrations.icons.bookmark\": \"书签\",\n  \"integration.custom_integrations.icons.document\": \"文档\",\n  \"integration.custom_integrations.icons.download\": \"下载\",\n  \"integration.custom_integrations.icons.external_link\": \"外部链接\",\n  \"integration.custom_integrations.icons.link\": \"链接\",\n  \"integration.custom_integrations.icons.picture\": \"图片\",\n  \"integration.custom_integrations.icons.save\": \"保存\",\n  \"integration.custom_integrations.icons.send\": \"发送\",\n  \"integration.custom_integrations.icons.share\": \"分享\",\n  \"integration.custom_integrations.icons.star\": \"收藏\",\n  \"integration.custom_integrations.list.empty.button\": \"创建首个集成\",\n  \"integration.custom_integrations.list.empty.description\": \"创建自定义集成以扩展功能\",\n  \"integration.custom_integrations.list.empty.title\": \"尚无自定义集成\",\n  \"integration.custom_integrations.list.title\": \"自定义集成列表\",\n  \"integration.custom_integrations.modal.description\": \"配置自定义集成参数\",\n  \"integration.custom_integrations.placeholders.click_to_copy\": \"点击复制\",\n  \"integration.custom_integrations.placeholders.description\": \"描述文本\",\n  \"integration.custom_integrations.placeholders.help\": \"帮助文本\",\n  \"integration.custom_integrations.preview.body\": \"请求体\",\n  \"integration.custom_integrations.preview.failed\": \"预览失败\",\n  \"integration.custom_integrations.preview.generating\": \"正在生成预览...\",\n  \"integration.custom_integrations.preview.headers\": \"请求头\",\n  \"integration.custom_integrations.preview.placeholders\": \"可用占位符\",\n  \"integration.custom_integrations.preview.request\": \"请求\",\n  \"integration.custom_integrations.preview.title\": \"请求预览\",\n  \"integration.custom_integrations.status.disabled\": \"已禁用\",\n  \"integration.custom_integrations.title\": \"自定义集成\",\n  \"integration.custom_integrations.validation.invalid\": \"模板有错误\",\n  \"integration.custom_integrations.validation.valid\": \"模板有效\",\n  \"integration.eagle.enable.description\": \"显示「保存到 Eagle」按钮（如果可用）。\",\n  \"integration.eagle.enable.label\": \"启用\",\n  \"integration.eagle.title\": \"Eagle\",\n  \"integration.export.button\": \"导出设置\",\n  \"integration.export.error\": \"导出集成设置失败\",\n  \"integration.export.success\": \"集成设置导出成功\",\n  \"integration.general\": \"通用\",\n  \"integration.import.button\": \"导入设置\",\n  \"integration.import.error\": \"导入集成设置失败\",\n  \"integration.import.invalid\": \"无效的集成设置文件\",\n  \"integration.import.success\": \"集成设置导入成功\",\n  \"integration.instapaper.enable.description\": \"显示「保存到 Instapaper」按钮（如果可用）。\",\n  \"integration.instapaper.enable.label\": \"启用\",\n  \"integration.instapaper.password.label\": \"密码\",\n  \"integration.instapaper.title\": \"Instapaper\",\n  \"integration.instapaper.username.label\": \"Instapaper 用户名\",\n  \"integration.obsidian.enable.description\": \"显示「保存到 Obsidian」按钮（如果可用）。\",\n  \"integration.obsidian.enable.label\": \"启用\",\n  \"integration.obsidian.title\": \"Obsidian\",\n  \"integration.obsidian.vaultPath.description\": \"你的 Obsidian 仓库的路径。\",\n  \"integration.obsidian.vaultPath.label\": \"Obsidian 仓库路径\",\n  \"integration.outline.collection.description\": \"保存文档的文档集的 UUID 或 urlId。\",\n  \"integration.outline.collection.label\": \"Outline 文档集\",\n  \"integration.outline.enable.description\": \"显示「保存到 Outline」按钮（如果可用）。\",\n  \"integration.outline.enable.label\": \"启用\",\n  \"integration.outline.endpoint.description\": \"此地址为「https://<你的 OUTLINE 域名>/api」。\",\n  \"integration.outline.endpoint.label\": \"Outline 接口地址\",\n  \"integration.outline.title\": \"Outline\",\n  \"integration.outline.token.description\": \"在你的 Outline 账户设置中获取。\",\n  \"integration.outline.token.label\": \"Outline API 密钥\",\n  \"integration.qbittorrent.enable.description\": \"显示「使用 qBittorrent 下载」按钮（如果可用）。\",\n  \"integration.qbittorrent.enable.label\": \"启用\",\n  \"integration.qbittorrent.host.description\": \"你的 qBittorrent WebUI 地址，例如 http://localhost:8080\",\n  \"integration.qbittorrent.host.label\": \"qBittorrent 主机\",\n  \"integration.qbittorrent.password.label\": \"qBittorrent 密码\",\n  \"integration.qbittorrent.title\": \"qBittorrent\",\n  \"integration.qbittorrent.username.label\": \"qBittorrent 用户名\",\n  \"integration.readeck.enable.description\": \"显示「保存到 Readeck」按钮（如果可用）。\",\n  \"integration.readeck.enable.label\": \"启用\",\n  \"integration.readeck.endpoint.description\": \"此地址为「https://<你的 READECK 域名>」。\",\n  \"integration.readeck.endpoint.label\": \"Readeck 接口地址\",\n  \"integration.readeck.title\": \"Readeck\",\n  \"integration.readeck.token.description\": \"在你的 Readeck 账户设置中获取。\",\n  \"integration.readeck.token.label\": \"Readeck API 密钥\",\n  \"integration.readwise.enable.description\": \"显示「保存到 Readwise」按钮（如果可用）。\",\n  \"integration.readwise.enable.label\": \"启用\",\n  \"integration.readwise.title\": \"Readwise\",\n  \"integration.readwise.token.description\": \"在这里获取令牌\",\n  \"integration.readwise.token.label\": \"Readwise 访问令牌\",\n  \"integration.save_ai_summary_as_description.label\": \"将 AI 总结保存为描述\",\n  \"integration.search.placeholder\": \"搜索集成...\",\n  \"integration.sidebar_title\": \"第三方接入\",\n  \"integration.status.configured\": \"已配置\",\n  \"integration.status.enabled\": \"已启用\",\n  \"integration.tip\": \"提示：敏感数据仅在本地存储，不会被收集或上传到云端。\",\n  \"integration.title\": \"第三方接入\",\n  \"integration.use_browser_fetch.description\": \"为自定义集成使用浏览器 fetch API 而不是 Electron 原生 fetch。启用以获得更好的网络兼容性，禁用以提高安全性。\",\n  \"integration.use_browser_fetch.label\": \"使用浏览器发送请求\",\n  \"integration.zotero.enable.description\": \"显示「保存到 Zotero」按钮（如果可用）。\",\n  \"integration.zotero.enable.label\": \"启用\",\n  \"integration.zotero.title\": \"Zotero\",\n  \"integration.zotero.token.description\": \"Zotero API 密钥，你可以在此获取：\",\n  \"integration.zotero.token.label\": \"Zotero API 密钥\",\n  \"integration.zotero.userID.description\": \"Zotero 用户 ID, 你可以在此获取：\",\n  \"integration.zotero.userID.label\": \"Zotero 用户 ID\",\n  \"invitation.activate\": \"激活\",\n  \"invitation.codeOptions.betaUser\": \"1. 寻找并邀请内测用户。\",\n  \"invitation.codeOptions.discord\": \"2. 加入官方 Discord 频道，不定期有赠品发放。\",\n  \"invitation.codeOptions.xAccount\": \"3. 关注官方 X 账号动态，不定期有赠品发放。\",\n  \"invitation.confirmModal.cancel\": \"取消\",\n  \"invitation.confirmModal.confirm\": \"确认继续？\",\n  \"invitation.confirmModal.continue\": \"继续\",\n  \"invitation.confirmModal.message\": \"生成邀请码将花费 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon>。\",\n  \"invitation.confirmModal.title\": \"确认\",\n  \"invitation.created_at\": \"创建于\",\n  \"invitation.earlyAccess\": \"Folo 目前处于<strong>早期开发</strong>状态，需要邀请码才能使用。\",\n  \"invitation.earlyAccessMessage\": \"😰 抱歉，Folo 目前处于抢先体验阶段，需要邀请码才能使用。\",\n  \"invitation.generate\": \"生成\",\n  \"invitation.generateButton\": \"生成邀请码\",\n  \"invitation.generateCost\": \"你可以花费 {{INVITATION_PRICE}} <PowerIcon /> Power 为你的朋友生成邀请码。\",\n  \"invitation.getCodeMessage\": \"通过以下方式获取邀请码：\",\n  \"invitation.limitationMessage\": \"根据你的使用时间，你可以生成最多 {{limitation}} 个邀请码。\",\n  \"invitation.newInvitationSuccess\": \"🎉 邀请码已生成，已复制到剪贴板\",\n  \"invitation.noInvitations\": \"没有邀请\",\n  \"invitation.notUsed\": \"未使用\",\n  \"invitation.sidebar_title\": \"邀请\",\n  \"invitation.tableHeaders.code\": \"代码\",\n  \"invitation.tableHeaders.creationTime\": \"创建时间\",\n  \"invitation.tableHeaders.usedBy\": \"使用者\",\n  \"invitation.title\": \"邀请码\",\n  \"lists.create\": \"创建列表\",\n  \"lists.created.error\": \"创建列表失败\",\n  \"lists.created.success\": \"创建列表成功\",\n  \"lists.delete.confirm\": \"确认删除列表？\",\n  \"lists.delete.error\": \"删除列表失败\",\n  \"lists.delete.success\": \"删除列表成功\",\n  \"lists.delete.warning\": \"警告：一旦删除，列表将不再可用，所有内容将被永久删除且无法恢复。\",\n  \"lists.description\": \"描述\",\n  \"lists.earnings\": \"收入\",\n  \"lists.edit.error\": \"编辑列表失败\",\n  \"lists.edit.label\": \"编辑\",\n  \"lists.edit.success\": \"编辑列表成功\",\n  \"lists.fee.description\": \"其他人订阅这个列表时要支付的费用。\",\n  \"lists.fee.label\": \"费用\",\n  \"lists.feeds.actions\": \"操作\",\n  \"lists.feeds.add.error\": \"添加订阅源到列表时失败\",\n  \"lists.feeds.add.label\": \"添加\",\n  \"lists.feeds.add.success\": \"订阅源已添加到列表\",\n  \"lists.feeds.delete.error\": \"添加订阅源到列表时失败\",\n  \"lists.feeds.delete.success\": \"订阅源已从列表删除\",\n  \"lists.feeds.id\": \"订阅源 ID\",\n  \"lists.feeds.label\": \"订阅源\",\n  \"lists.feeds.manage\": \"管理订阅源\",\n  \"lists.feeds.owner\": \"所有者\",\n  \"lists.feeds.search\": \"搜索订阅源\",\n  \"lists.feeds.title\": \"标题\",\n  \"lists.image\": \"图片\",\n  \"lists.info\": \"列表是可以分享或出售给他人订阅的订阅源集合，订阅者将会同步并访问列表中的所有订阅源。\",\n  \"lists.manage_list\": \"管理列表\",\n  \"lists.noLists\": \"没有列表\",\n  \"lists.select_feeds\": \"选择要添加到当前列表的订阅源。\",\n  \"lists.submit\": \"提交\",\n  \"lists.subscriptions\": \"订阅\",\n  \"lists.title\": \"标题\",\n  \"lists.view\": \"视图\",\n  \"notifications.channel\": \"渠道\",\n  \"notifications.current\": \"（当前客户端）\",\n  \"notifications.empty.description\": \"当你在当前设备上启用通知后，通知渠道会显示在这里。\",\n  \"notifications.empty.title\": \"暂无通知渠道\",\n  \"notifications.info\": \"Folo 通过<ActionsLink>自动化</ActionsLink>提供强大且灵活的通知功能。你可以为特定的订阅源、视图或关键字自定义通知。以下是已注册的通知渠道。\",\n  \"notifications.test\": \"测试通知\",\n  \"notifications.test_success\": \"测试通知发送成功。\",\n  \"notifications.token\": \"客户端令牌\",\n  \"plan.canceled_expires\": \"已取消 - {{date}} 过期\",\n  \"plan.current_plan\": \"当前方案\",\n  \"plan.descriptions.basic\": \"更多订阅源，并包含每日 AI 摘要和翻译额度。\",\n  \"plan.descriptions.free\": \"非常适合初学者。\",\n  \"plan.descriptions.plus\": \"解锁无限 AI 功能，并获得更多订阅与自动化能力。\",\n  \"plan.descriptions.pro\": \"最高额度、完整 AI 能力与最佳性能。\",\n  \"plan.featureValues.AI_MODEL_SELECTION.curated\": \"精选模型\",\n  \"plan.featureValues.AI_MODEL_SELECTION.high_performance\": \"全部高性能模型\",\n  \"plan.featureValues.AI_MODEL_SELECTION.none\": \"—\",\n  \"plan.features.AI_BRING_YOUR_OWN_KEY\": \"AI 自定义 API 密钥\",\n  \"plan.features.AI_CREDIT\": \"AI Chat 积分\",\n  \"plan.features.AI_MODEL_SELECTION\": \"AI 模型选择\",\n  \"plan.features.BOOSTS\": \"订阅源更新加速\",\n  \"plan.features.INTEGRATION_SUPPORTED\": \"第三方集成\",\n  \"plan.features.MAX_ACTIONS\": \"自动化操作\",\n  \"plan.features.MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"每日 AI 摘要次数\",\n  \"plan.features.MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"每日 AI 翻译次数\",\n  \"plan.features.MAX_AI_REQUESTS_PER_DAY\": \"每日 AI 对话次数\",\n  \"plan.features.MAX_AI_REQUESTS_PER_MONTH\": \"每月 AI 对话次数\",\n  \"plan.features.MAX_AI_TASKS\": \"AI 任务\",\n  \"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"每日 AI 语音合成次数\",\n  \"plan.features.MAX_INBOXES\": \"收件箱\",\n  \"plan.features.MAX_LISTS\": \"自定义列表\",\n  \"plan.features.MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub 订阅\",\n  \"plan.features.MAX_SUBSCRIPTIONS\": \"订阅源数量\",\n  \"plan.features.PRIORITY_SUPPORT\": \"优先支持\",\n  \"plan.features.PRIVATE_SUBSCRIPTION\": \"私有订阅\",\n  \"plan.features.SECURE_IMAGE_PROXY\": \"安全图片代理\",\n  \"plan.manage_subscription\": \"管理订阅\",\n  \"plan.renews\": \"续订日期 {{date}}\",\n  \"plan.trial_ends\": \"试用期至 {{date}}\",\n  \"privacy.privacy\": \"隐私政策\",\n  \"privacy.terms\": \"服务条款\",\n  \"profile.avatar.cropInstructions\": \"拖动裁剪区域以调整头像\",\n  \"profile.avatar.dropZoneSubtext\": \"或点击以从电脑中选择\",\n  \"profile.avatar.dropZoneText\": \"将图像拖放到此处\",\n  \"profile.avatar.fileTooLarge\": \"文件大小必须小于 {{size}}\",\n  \"profile.avatar.invalidFileType\": \"请选择有效的图像文件\",\n  \"profile.avatar.label\": \"头像\",\n  \"profile.avatar.processingError\": \"处理图像时遇到错误\",\n  \"profile.avatar.selectAnother\": \"选择其他\",\n  \"profile.avatar.selectFile\": \"选择文件\",\n  \"profile.avatar.uploadError\": \"头像上传失败\",\n  \"profile.avatar.uploadSuccess\": \"头像上传成功\",\n  \"profile.avatar.uploadTitle\": \"上传头像\",\n  \"profile.change_password.email_required\": \"你需要先使用邮箱登录。\",\n  \"profile.change_password.label\": \"更改密码\",\n  \"profile.confirm_password.label\": \"确认密码\",\n  \"profile.current_password.label\": \"当前密码\",\n  \"profile.danger_zone\": \"危险区\",\n  \"profile.delete_account.confirm_description\": \"确定要删除你的账户吗？\\n此操作不可逆，且可能需要最多两天生效。\",\n  \"profile.delete_account.confirm_title\": \"删除账户\",\n  \"profile.delete_account.label\": \"注销账户\",\n  \"profile.edit_email\": \"编辑邮件地址\",\n  \"profile.edit_profile\": \"编辑个人资料\",\n  \"profile.email.change\": \"更改邮件地址\",\n  \"profile.email.change_note\": \"如需更改邮件地址，请先验证你的新邮件地址。\",\n  \"profile.email.changed\": \"邮件地址已更改。\",\n  \"profile.email.changed_verification_sent\": \"已发送验证新邮件地址的邮件。\",\n  \"profile.email.label\": \"邮件地址\",\n  \"profile.email.send_verification\": \"发送验证邮件\",\n  \"profile.email.unverified\": \"未验证\",\n  \"profile.email.verification_sent\": \"验证邮件已发送。\",\n  \"profile.email.verified\": \"已验证\",\n  \"profile.email.verify_email\": \"请验证你的邮件地址 ({{email_address}}) 以继续。\",\n  \"profile.email.verify_status\": \"你的邮件地址{{status}}\",\n  \"profile.handle.description\": \"你的唯一标识。\",\n  \"profile.handle.label\": \"唯一标识\",\n  \"profile.link_social.authentication\": \"身份验证\",\n  \"profile.link_social.link\": \"连接\",\n  \"profile.link_social.link_failed\": \"关联账户失败。\",\n  \"profile.link_social.unlink.action\": \"解除关联\",\n  \"profile.link_social.unlink.confirm\": \"确定要解除当前账户关联吗？\",\n  \"profile.link_social.unlink.success\": \"已解除社交账户连接。\",\n  \"profile.link_social.unlink.title\": \"解除账户关联\",\n  \"profile.name.description\": \"你的公开显示名称。\",\n  \"profile.name.label\": \"显示名称\",\n  \"profile.new_password.label\": \"新密码\",\n  \"profile.no_password\": \"<Link>重置密码</Link>以设置新密码。\",\n  \"profile.password.label\": \"密码\",\n  \"profile.profile.bio\": \"个人简介\",\n  \"profile.profile.bio_placeholder\": \"简单介绍下你自己...\",\n  \"profile.profile.changed\": \"个人资料已更新\",\n  \"profile.profile.save\": \"保存\",\n  \"profile.profile.social_links\": \"社交链接\",\n  \"profile.profile.social_links_discord\": \"Discord 用户名\",\n  \"profile.profile.social_links_facebook\": \"Facebook 用户名\",\n  \"profile.profile.social_links_github\": \"Github 用户名\",\n  \"profile.profile.social_links_instagram\": \"Instagram 用户名\",\n  \"profile.profile.social_links_twitter\": \"Twitter 用户名\",\n  \"profile.profile.social_links_youtube\": \"Youtube 用户名\",\n  \"profile.profile.website\": \"个人网站\",\n  \"profile.reset_password_mail_sent\": \"重置密码邮件已发送。\",\n  \"profile.security\": \"安全\",\n  \"profile.set_avatar\": \"设置头像\",\n  \"profile.sidebar_title\": \"个人资料\",\n  \"profile.sign_out.confirm_message\": \"确定要退出登录吗？\",\n  \"profile.sign_out.confirm_title\": \"确认退出登录\",\n  \"profile.submit\": \"提交\",\n  \"profile.title\": \"个人资料设置\",\n  \"profile.totp_code.init\": \"使用身份验证器应用扫描二维码\",\n  \"profile.totp_code.invalid\": \"无效的双重身份验证码。\",\n  \"profile.totp_code.label\": \"双重身份验证码\",\n  \"profile.totp_code.title\": \"输入双重身份验证码\",\n  \"profile.two_factor.disable\": \"停用双重身份验证\",\n  \"profile.two_factor.disabled\": \"双重身份验证已停用。\",\n  \"profile.two_factor.enable\": \"启用双重身份验证\",\n  \"profile.two_factor.enable_failed\": \"启用双重身份验证失败。\",\n  \"profile.two_factor.enable_notice\": \"需要启用双重身份验证才能执行此操作。\",\n  \"profile.two_factor.enabled\": \"双重身份验证已启用。\",\n  \"profile.two_factor.invalid_password\": \"密码无效或发生了其他错误。\",\n  \"profile.two_factor.label\": \"双重身份验证\",\n  \"profile.two_factor.no_password\": \"启用双重身份验证之前需要<Link>设置</Link>密码。\",\n  \"profile.two_factor.setup.description\": \"使用身份验证器应用扫描上方二维码，然后输入应用中显示的 6 位验证码。\",\n  \"profile.two_factor.setup.title\": \"设置双重身份验证\",\n  \"profile.two_factor.verify_failed\": \"验证码验证失败\",\n  \"profile.updateSuccess\": \"个人资料已更新。\",\n  \"profile.update_password_success\": \"密码已更新。\",\n  \"referral.description\": \"与朋友分享 Folo！延长你的专业版试用期和未来权益，你的朋友也可以获得 45 天的试用期。<Link>了解更多</Link>。\",\n  \"referral.invited_friend_status.pending\": \"待验证\",\n  \"referral.invited_friend_status.valid\": \"已验证\",\n  \"referral.link\": \"你的邀请链接：\",\n  \"referral.pro_status.preview\": \"你的专业版试用状态：将于 {{dateString}} 到期（剩余 {{daysLeft}} 天）\",\n  \"referral.pro_status.trial\": \"当前等级：免费版\",\n  \"referral.pro_status.user\": \"你的专业版试用状态：活跃中\",\n  \"reviewPrompt.description\": \"如果 Folo 对你有帮助，欢迎给我们一个评分；如果还不够好，也欢迎告诉我们哪里需要改进。\",\n  \"reviewPrompt.loveIt\": \"喜欢\",\n  \"reviewPrompt.notReally\": \"还不太满意\",\n  \"reviewPrompt.title\": \"你喜欢 Folo 吗？\",\n  \"rsshub.addModal.access_key_label\": \"访问密钥（可选）\",\n  \"rsshub.addModal.add\": \"添加\",\n  \"rsshub.addModal.base_url_label\": \"根 URL\",\n  \"rsshub.addModal.description\": \"要在 Folo 中使用自己的实例，必须将以下环境变量添加到实例。\",\n  \"rsshub.add_new_instance\": \"添加新实例\",\n  \"rsshub.description\": \"RSSHub 是由社区驱动的开源 RSS 网络。Folo 提供了内建的专用实例来支持数以千计的订阅内容，你也可以通过使用自己的或第三方的实例来实现更稳定的内容获取。\",\n  \"rsshub.public_instances\": \"实例\",\n  \"rsshub.table.delete.confirm\": \"确定要删除此实例吗？\",\n  \"rsshub.table.delete.label\": \"删除\",\n  \"rsshub.table.delete.success\": \"实例删除成功。\",\n  \"rsshub.table.description\": \"描述\",\n  \"rsshub.table.edit\": \"编辑\",\n  \"rsshub.table.inuse\": \"使用中\",\n  \"rsshub.table.limit_reached\": \"达到限制\",\n  \"rsshub.table.official\": \"官方\",\n  \"rsshub.table.owner\": \"所有者\",\n  \"rsshub.table.price\": \"月度价格\",\n  \"rsshub.table.private\": \"私有\",\n  \"rsshub.table.unavailable\": \"不可用\",\n  \"rsshub.table.unlimited\": \"无限制\",\n  \"rsshub.table.use\": \"使用\",\n  \"rsshub.table.userCount\": \"用户数量\",\n  \"rsshub.table.userLimit\": \"用户限制\",\n  \"rsshub.table.yours\": \"你的\",\n  \"rsshub.useModal.about\": \"关于此实例\",\n  \"rsshub.useModal.month\": \"个月\",\n  \"rsshub.useModal.months_label\": \"你想购买的月份数量\",\n  \"rsshub.useModal.purchase_expires_at\": \"你已购买此实例，到期时间为\",\n  \"rsshub.useModal.title\": \"RSSHub 实例\",\n  \"rsshub.useModal.useWith\": \"使用 {{amount}} <Power />\",\n  \"subscription.actions.comingSoon\": \"即将推出\",\n  \"subscription.actions.current\": \"当前计划\",\n  \"subscription.actions.manage_error\": \"打开订阅管理时出现问题。\",\n  \"subscription.actions.restore\": \"恢复购买\",\n  \"subscription.actions.restore_error\": \"恢复订阅时出现问题。\",\n  \"subscription.actions.restore_not_found\": \"没有找到可恢复的 Apple 订阅。\",\n  \"subscription.actions.restore_success\": \"订阅已恢复。\",\n  \"subscription.actions.upgrade\": \"升级\",\n  \"subscription.actions.upgrade_error\": \"启动结账时出现问题。\",\n  \"subscription.badge.popular\": \"最受欢迎\",\n  \"subscription.billing.monthly\": \"按月\",\n  \"subscription.billing.yearly\": \"按年\",\n  \"subscription.billing.yearly_savings\": \"节省 {{value}}%\",\n  \"subscription.discount.tag\": \"节省 {{value}}%\",\n  \"subscription.feature.included\": \"已包含\",\n  \"subscription.price.free\": \"免费\",\n  \"subscription.price.per_month\": \"每月\",\n  \"subscription.price.per_month_billed_yearly\": \"按年计费，每月\",\n  \"subscription.price.per_year\": \"每年\",\n  \"subscription.summary.active\": \"你当前拥有一个有效的订阅。\",\n  \"subscription.summary.current\": \"{{plan}} 计划\",\n  \"subscription.summary.free\": \"免费计划\",\n  \"subscription.summary.free_description\": \"升级即可解锁更多订阅、列表与 AI 功能。\",\n  \"subscription.summary.title\": \"我的订阅\",\n  \"subscription.summary.trial_expiring\": \"试用将于 {{date}} 到期（剩余 {{days}} 天）\",\n  \"subscription.unavailable\": \"当前暂不支持订阅服务。\",\n  \"titles.about\": \"关于\",\n  \"titles.account\": \"账户\",\n  \"titles.actions\": \"自动化\",\n  \"titles.ai\": \"AI\",\n  \"titles.appearance\": \"外观\",\n  \"titles.cli\": \"CLI\",\n  \"titles.data_control\": \"数据控制\",\n  \"titles.feeds\": \"订阅源\",\n  \"titles.general\": \"通用\",\n  \"titles.integration\": \"集成\",\n  \"titles.invitations\": \"邀请\",\n  \"titles.lists\": \"列表\",\n  \"titles.notifications\": \"通知\",\n  \"titles.plan.long\": \"升级你的计划\",\n  \"titles.plan.short\": \"计划\",\n  \"titles.power\": \"Power\",\n  \"titles.privacy\": \"隐私\",\n  \"titles.referral.long\": \"邀请好友以延长专业版有效期\",\n  \"titles.referral.short\": \"邀请并赚取\",\n  \"titles.shortcuts\": \"快捷键\",\n  \"titles.sign_out\": \"登出\",\n  \"titles.subscription.long\": \"管理订阅\",\n  \"titles.subscription.short\": \"订阅\",\n  \"titles.token_usage\": \"AI 积分使用情况\",\n  \"wallet.balance.activePoints\": \"活跃度\",\n  \"wallet.balance.dailyReward\": \"每日奖励\",\n  \"wallet.balance.title\": \"余额\",\n  \"wallet.balance.withdrawable\": \"可提现\",\n  \"wallet.balance.withdrawableTooltip\": \"可提现的 Power 包括你收到的打赏和充值。\",\n  \"wallet.claim.button.claim\": \"领取每日 Power\",\n  \"wallet.claim.button.claimed\": \"今日已领取\",\n  \"wallet.claim.tooltip.alreadyClaimed\": \"已经领取，明日再来\",\n  \"wallet.claim.tooltip.canClaim\": \"立即领取你的 {{amount}} 每日 Power！\",\n  \"wallet.create.button\": \"创建钱包\",\n  \"wallet.create.description\": \"创建一个免费钱包以获得 <PowerIcon /> <strong>Power</strong>。你可以给其他创作者打赏，也可以通过贡献内容来获得奖励。\",\n  \"wallet.power.dailyClaim\": \"每天可以领取 {{amount}} 个免费 Power。Power 可用于在 Folo 上打赏 RSS 条目。\",\n  \"wallet.power.rewardDescription\": \"所有活跃在 Folo 的用户都有资格获得每日 Power 奖励。\",\n  \"wallet.power.rewardDescription2\": \"根据你的等级和过往活跃度，今日可以获得 <Balance /> 的奖励。<Link>了解更多…</Link>\",\n  \"wallet.ranking.level\": \"等级\",\n  \"wallet.ranking.name\": \"用户\",\n  \"wallet.ranking.power\": \"Power\",\n  \"wallet.ranking.rank\": \"排名\",\n  \"wallet.ranking.title\": \"Power 排名\",\n  \"wallet.rewardDescription.description1\": \"每日奖励基于「用户等级」和「用户活跃度」两部分计算。\",\n  \"wallet.rewardDescription.description2\": \"用户等级：由用户在 Power 排行榜的排名决定。\",\n  \"wallet.rewardDescription.description3\": \"用户活跃度：使用 Folo 功能可以提升活跃度，活跃度的奖励倍数范围是 1x ~ 5x。\",\n  \"wallet.rewardDescription.level\": \"用户等级\",\n  \"wallet.rewardDescription.percentage\": \"排名比例\",\n  \"wallet.rewardDescription.reward\": \"奖励倍数\",\n  \"wallet.rewardDescription.title\": \"奖励描述\",\n  \"wallet.rewardDescription.total\": \"每日奖池\",\n  \"wallet.sidebar_title\": \"Power\",\n  \"wallet.transactions.amount\": \"数额\",\n  \"wallet.transactions.date\": \"日期\",\n  \"wallet.transactions.empty.description\": \"打赏、购买、提现和空投等记录发生后会显示在这里。\",\n  \"wallet.transactions.empty.title\": \"暂无交易记录\",\n  \"wallet.transactions.from\": \"发送者\",\n  \"wallet.transactions.more\": \"通过区块链浏览器查看更多交易…\",\n  \"wallet.transactions.noTransactions\": \"无交易记录\",\n  \"wallet.transactions.title\": \"交易记录\",\n  \"wallet.transactions.to\": \"接收者\",\n  \"wallet.transactions.tx\": \"交易\",\n  \"wallet.transactions.type\": \"类型\",\n  \"wallet.transactions.types.airdrop\": \"空投\",\n  \"wallet.transactions.types.all\": \"所有\",\n  \"wallet.transactions.types.burn\": \"销毁\",\n  \"wallet.transactions.types.mint\": \"铸造\",\n  \"wallet.transactions.types.purchase\": \"购买\",\n  \"wallet.transactions.types.tip\": \"打赏\",\n  \"wallet.transactions.types.withdraw\": \"提现\",\n  \"wallet.transactions.you\": \"你\",\n  \"wallet.withdraw.addressLabel\": \"以太坊地址\",\n  \"wallet.withdraw.amountLabel\": \"数额\",\n  \"wallet.withdraw.availableBalance\": \"钱包中有 <Balance></Balance> Power 可提现。\",\n  \"wallet.withdraw.button\": \"提现\",\n  \"wallet.withdraw.error\": \"提现失败：{{error}}\",\n  \"wallet.withdraw.modalTitle\": \"提现 Power\",\n  \"wallet.withdraw.receiveRSS3\": \"你将收到 {{amount}} RSS3\",\n  \"wallet.withdraw.submitButton\": \"提交\",\n  \"wallet.withdraw.success\": \"提现成功！\",\n  \"wallet.withdraw.toRss3Label\": \"提现为 RSS3\"\n}\n"
  },
  {
    "path": "locales/settings/zh-TW.json",
    "content": "{\n  \"about.aiOnboardingDescription\": \"重新體驗互動式新手導覽。\",\n  \"about.appTip\": \"App 功能\",\n  \"about.appTipDescription\": \"App 功能介紹與使用指南。\",\n  \"about.changelog\": \"更新日誌\",\n  \"about.changelogDescription\": \"查看每個版本的新功能\",\n  \"about.checkForUpdates\": \"檢查更新\",\n  \"about.checkNow\": \"立即檢查\",\n  \"about.checkingForUpdates\": \"正在檢查更新...\",\n  \"about.copyEnvironment\": \"複製環境\",\n  \"about.environmentCopied\": \"環境資訊已複製\",\n  \"about.feedbackInfo\": \"{{appName}} ({{commitSha}}) 是開源且持續開發中的專案。如果您有任何回饋或建議，請隨時在我們的 GitHub <OpenIssueLink>提出 issue</OpenIssueLink>。\",\n  \"about.iconLibrary\": \"所使用的圖標庫版權由 <IconLibraryLink />  所有，不得重新分發。\",\n  \"about.legal\": \"法律資訊\",\n  \"about.licenseInfo\": \"版權所有 © {{currentYear}} {{appName}}。保留所有權利。\",\n  \"about.noUpdateAvailable\": \"已是最新版本\",\n  \"about.privacyPolicy\": \"隱私政策\",\n  \"about.projectLicense\": \"{{appName}} 採用 GNU Affero 通用公共授權條款第3版授權，並附有額外的例外條款。\",\n  \"about.rateFolo\": \"為 Folo 評分\",\n  \"about.rateFoloDescription\": \"留下評分，支持產品持續發展。\",\n  \"about.resources\": \"資源與貢獻\",\n  \"about.sendFeedback\": \"傳送回饋\",\n  \"about.sendFeedbackDescription\": \"告訴我們哪些地方還可以改進。\",\n  \"about.sidebar_title\": \"關於\",\n  \"about.socialMedia\": \"社群媒體\",\n  \"about.support\": \"支援與回饋\",\n  \"about.termsOfService\": \"服務條款\",\n  \"about.updateAvailable\": \"有可用更新！\",\n  \"about.updateCheckFailed\": \"檢查更新失敗\",\n  \"about.updateDescription\": \"保持應用程式更新以獲得最新功能和改進\",\n  \"about.viewChangelog\": \"查看更新日誌\",\n  \"actions.actionName\": \"規則 {{number}}\",\n  \"actions.action_card.add\": \"新增\",\n  \"actions.action_card.all\": \"全部\",\n  \"actions.action_card.and\": \"和\",\n  \"actions.action_card.block\": \"封鎖\",\n  \"actions.action_card.block_rules\": \"封鎖規則\",\n  \"actions.action_card.custom_filters\": \"自訂過濾條件\",\n  \"actions.action_card.empty.cta\": \"建立第一條規則\",\n  \"actions.action_card.empty.description\": \"建立首個自動化規則以自動處理您的訂閱內容。\",\n  \"actions.action_card.empty.start\": \"從此處開始！\",\n  \"actions.action_card.empty.title\": \"尚無自動化規則\",\n  \"actions.action_card.enable_readability\": \"啟用可讀模式\",\n  \"actions.action_card.feed_options.entry_attachments_duration\": \"條目視頻時長\",\n  \"actions.action_card.feed_options.entry_author\": \"條目作者\",\n  \"actions.action_card.feed_options.entry_content\": \"條目內容\",\n  \"actions.action_card.feed_options.entry_media_length\": \"條目媒體數量\",\n  \"actions.action_card.feed_options.entry_title\": \"條目標題\",\n  \"actions.action_card.feed_options.entry_url\": \"條目 URL\",\n  \"actions.action_card.feed_options.feed_category\": \"RSS 摘要分類\",\n  \"actions.action_card.feed_options.feed_title\": \"RSS 摘要標題\",\n  \"actions.action_card.feed_options.feed_url\": \"RSS 摘要 URL\",\n  \"actions.action_card.feed_options.site_url\": \"網站 URL\",\n  \"actions.action_card.feed_options.status\": \"狀態\",\n  \"actions.action_card.feed_options.subscription_view\": \"訂閱視圖\",\n  \"actions.action_card.field\": \"欄位\",\n  \"actions.action_card.from\": \"從\",\n  \"actions.action_card.generate_summary\": \"使用 AI 產生總結\",\n  \"actions.action_card.name\": \"名稱\",\n  \"actions.action_card.new_entry_notification\": \"新條目通知\",\n  \"actions.action_card.no_translation\": \"無翻譯\",\n  \"actions.action_card.operation_options.contains\": \"包含\",\n  \"actions.action_card.operation_options.does_not_contain\": \"不包含\",\n  \"actions.action_card.operation_options.is_equal_to\": \"等於\",\n  \"actions.action_card.operation_options.is_greater_than\": \"大於\",\n  \"actions.action_card.operation_options.is_less_than\": \"小於\",\n  \"actions.action_card.operation_options.is_not_equal_to\": \"不等於\",\n  \"actions.action_card.operation_options.matches_regex\": \"符合正規表達式\",\n  \"actions.action_card.operator\": \"運算子\",\n  \"actions.action_card.or\": \"或\",\n  \"actions.action_card.rewrite_rules\": \"重寫規則\",\n  \"actions.action_card.settings\": \"設置\",\n  \"actions.action_card.silence\": \"靜音\",\n  \"actions.action_card.source_content\": \"瀏覽原始內容\",\n  \"actions.action_card.star\": \"收藏\",\n  \"actions.action_card.summary.action_count\": \"已啟用 {{count}} 個動作\",\n  \"actions.action_card.summary.active\": \"啟用中\",\n  \"actions.action_card.summary.copy\": \"複製到剪貼簿\",\n  \"actions.action_card.summary.delete\": \"刪除\",\n  \"actions.action_card.summary.delete_message\": \"確定要刪除此規則嗎？此操作無法復原。\",\n  \"actions.action_card.summary.delete_title\": \"刪除規則\",\n  \"actions.action_card.summary.disabled\": \"已停用\",\n  \"actions.action_card.summary.empty\": \"尚未建立任何規則\",\n  \"actions.action_card.summary.export\": \"匯出成檔案\",\n  \"actions.action_card.summary.helper\": \"透過精準的條件與動作，自動化你的工作流程。\",\n  \"actions.action_card.summary.import\": \"匯入\",\n  \"actions.action_card.summary.import_clipboard\": \"從剪貼簿匯入\",\n  \"actions.action_card.summary.import_file\": \"從檔案匯入\",\n  \"actions.action_card.summary.no_actions\": \"尚未設定任何動作\",\n  \"actions.action_card.summary.rule_count\": \"{{count}} 條規則\",\n  \"actions.action_card.summary.share\": \"分享\",\n  \"actions.action_card.summary.toggle\": \"切換規則狀態\",\n  \"actions.action_card.then_do\": \"然後執行…\",\n  \"actions.action_card.to\": \"到\",\n  \"actions.action_card.translate_into\": \"翻譯成\",\n  \"actions.action_card.value\": \"值\",\n  \"actions.action_card.webhooks\": \"Webhooks\",\n  \"actions.action_card.when_feeds_match\": \"當 RSS 摘要匹配時…\",\n  \"actions.condition\": \"條件\",\n  \"actions.conditions\": \"條件\",\n  \"actions.edit_condition\": \"編輯條件\",\n  \"actions.edit_rewrite_rule\": \"編輯覆寫規則\",\n  \"actions.edit_rule\": \"編輯規則\",\n  \"actions.edit_webhook\": \"編輯 Webhook\",\n  \"actions.info\": \"自動化操作是您可以自動化以在伺服器或客户端執行任務的規則集合。\",\n  \"actions.navigate.prompt\": \"您有未儲存的自動化操作變更。您確定要離開嗎？\",\n  \"actions.newRule\": \"新增規則\",\n  \"actions.save\": \"儲存\",\n  \"actions.saveSuccess\": \"🎉 規則已儲存\",\n  \"actions.sidebar_title\": \"自動化操作\",\n  \"actions.title\": \"自動化操作\",\n  \"ai.personalize.prompt.description\": \"告訴 Folo 你的閲讀偏好與個人資訊。\",\n  \"ai.personalize.prompt.label\": \"個人化提示\",\n  \"ai.personalize.title\": \"個人化\",\n  \"ai.shortcuts.title\": \"快捷鍵\",\n  \"appearance.accent_color.description\": \"選擇應用程式介面的強調色。\",\n  \"appearance.accent_color.label\": \"強調色\",\n  \"appearance.code_highlight_theme.description\": \"調整語法突顯主題\",\n  \"appearance.code_highlight_theme.label\": \"語法突顯主題\",\n  \"appearance.code_highlighting.title\": \"語法突顯\",\n  \"appearance.common.title\": \"通用\",\n  \"appearance.content\": \"內容\",\n  \"appearance.content_display.title\": \"內容顯示\",\n  \"appearance.content_font.default\": \"預設（介面字體）\",\n  \"appearance.content_font.description\": \"調整閲讀內容所用字體。\",\n  \"appearance.content_font.label\": \"內容字體\",\n  \"appearance.content_font_size\": \"內容字體大小\",\n  \"appearance.content_line_height.description\": \"調整文章中文字行間距\",\n  \"appearance.content_line_height.label\": \"內容行高\",\n  \"appearance.content_line_height.loose\": \"鬆散\",\n  \"appearance.content_line_height.normal\": \"一般\",\n  \"appearance.content_line_height.relaxed\": \"寬鬆\",\n  \"appearance.content_line_height.snug\": \"緊湊\",\n  \"appearance.content_line_height.tight\": \"緊密\",\n  \"appearance.custom_css.button\": \"編輯\",\n  \"appearance.custom_css.description\": \"自訂條目渲染中的 CSS 樣式。\",\n  \"appearance.custom_css.label\": \"自訂 CSS\",\n  \"appearance.custom_font\": \"自訂字體\",\n  \"appearance.customization.title\": \"自訂\",\n  \"appearance.customize_sub_tabs.description\": \"自訂你的訂閱分頁。\",\n  \"appearance.customize_sub_tabs.label\": \"自訂訂閱分頁\",\n  \"appearance.customize_toolbar.description\": \"自訂條目內容工具列。\",\n  \"appearance.customize_toolbar.label\": \"自訂工具欄\",\n  \"appearance.date_format.description\": \"調整日期顯示格式。\",\n  \"appearance.date_format.label\": \"日期格式\",\n  \"appearance.font.custom\": \"自訂\",\n  \"appearance.font.system\": \"系統字體\",\n  \"appearance.font_scaling.content_different.description\": \"為文章內容設定獨立的字體大小\",\n  \"appearance.font_scaling.content_different.label\": \"內容使用獨立字體大小\",\n  \"appearance.font_scaling.content_size.description\": \"設定文章內容的字體大小\",\n  \"appearance.font_scaling.content_size.l\": \"大\",\n  \"appearance.font_scaling.content_size.label\": \"內容字體大小\",\n  \"appearance.font_scaling.content_size.m\": \"預設\",\n  \"appearance.font_scaling.content_size.s\": \"小\",\n  \"appearance.font_scaling.content_size.xl\": \"較大\",\n  \"appearance.font_scaling.content_size.xs\": \"較小\",\n  \"appearance.font_scaling.scale.description\": \"調整字體大小縮放比例\",\n  \"appearance.font_scaling.scale.label\": \"字體縮放\",\n  \"appearance.font_scaling.size.l\": \"大\",\n  \"appearance.font_scaling.size.m\": \"預設\",\n  \"appearance.font_scaling.size.s\": \"小\",\n  \"appearance.font_scaling.size.xl\": \"較大\",\n  \"appearance.font_scaling.size.xs\": \"較小\",\n  \"appearance.font_scaling.system.description\": \"跟隨系統無障礙字體大小設定\",\n  \"appearance.font_scaling.system.label\": \"使用系統字體縮放\",\n  \"appearance.font_scaling.title\": \"字體縮放\",\n  \"appearance.fonts\": \"字體\",\n  \"appearance.general\": \"一般\",\n  \"appearance.global_font.default\": \"跟隨系統\",\n  \"appearance.global_font_size.description\": \"調整整體文字大小\",\n  \"appearance.global_font_size.label\": \"全域字體大小\",\n  \"appearance.guess_code_language.description\": \"使用模型來推斷未標記程式碼區塊的主要程式語言\",\n  \"appearance.guess_code_language.label\": \"推測程式碼語言\",\n  \"appearance.hide_extra_badge.description\": \"將側邊欄中 RSS 摘要的特殊徽章隱藏，例如：已加成、已認領。\",\n  \"appearance.hide_extra_badge.label\": \"隱藏徽章\",\n  \"appearance.hide_recent_reader.description\": \"隱藏條目標題顯示的最近閲讀者\",\n  \"appearance.hide_recent_reader.label\": \"隱藏最近閲讀者\",\n  \"appearance.interface_window.title\": \"介面與視窗\",\n  \"appearance.misc\": \"其他\",\n  \"appearance.modal_overlay.description\": \"顯示遮罩效果\",\n  \"appearance.modal_overlay.label\": \"遮罩顯示\",\n  \"appearance.opaque_sidebars.description\": \"讓側邊欄背景透明。\",\n  \"appearance.opaque_sidebars.label\": \"不透明側邊欄\",\n  \"appearance.reader_render_inline_style.description\": \"允許渲染原始 HTML 的行內樣式。\",\n  \"appearance.reader_render_inline_style.label\": \"渲染行內樣式\",\n  \"appearance.reading_view.title\": \"閲讀視圖\",\n  \"appearance.reduce_motion.description\": \"減少元素的動畫效果以提升效能並降低功耗。\",\n  \"appearance.reduce_motion.label\": \"減少動畫\",\n  \"appearance.save\": \"儲存\",\n  \"appearance.sidebar\": \"側邊欄\",\n  \"appearance.sidebar_title\": \"外觀\",\n  \"appearance.subscription_list.title\": \"訂閱列表\",\n  \"appearance.subscriptions\": \"訂閱\",\n  \"appearance.system_integration.title\": \"系統整合\",\n  \"appearance.text_size.default\": \"預設\",\n  \"appearance.text_size.label\": \"介面字體大小\",\n  \"appearance.text_size.large\": \"大\",\n  \"appearance.text_size.medium\": \"中\",\n  \"appearance.text_size.smaller\": \"小\",\n  \"appearance.theme.dark\": \"深色\",\n  \"appearance.theme.description\": \"調整應用程式的整體主題\",\n  \"appearance.theme.label\": \"主題\",\n  \"appearance.theme.light\": \"淺色\",\n  \"appearance.theme.system\": \"跟隨系統\",\n  \"appearance.thumbnail_ratio.description\": \"文章列表縮略圖比例\",\n  \"appearance.thumbnail_ratio.original\": \"原始比例\",\n  \"appearance.thumbnail_ratio.square\": \"正方形\",\n  \"appearance.thumbnail_ratio.title\": \"縮略比例\",\n  \"appearance.title\": \"外觀\",\n  \"appearance.typography.title\": \"排版\",\n  \"appearance.ui_font.description\": \"調整 UI 元素所用字體。\",\n  \"appearance.ui_font.label\": \"介面字體\",\n  \"appearance.unread_count.badge.description\": \"在 Dock 圖示上顯示未讀數量徽章。\",\n  \"appearance.unread_count.badge.label\": \"在 Dock 圖示上顯示\",\n  \"appearance.unread_count.label\": \"未讀數量\",\n  \"appearance.unread_count.sidebar.description\": \"在 RSS 摘要與羣組旁顯示未讀數量。\",\n  \"appearance.unread_count.sidebar.title\": \"顯示未讀數量\",\n  \"appearance.unread_count.view_and_subscription.description\": \"在視圖與訂閱列表中顯示未讀數量。\",\n  \"appearance.unread_count.view_and_subscription.label\": \"在側邊欄中顯示\",\n  \"appearance.use_pointer_cursor.description\": \"當滑鼠懸停在任何互動元素上時，遊標會顯示為手型遊標。\",\n  \"appearance.use_pointer_cursor.label\": \"使用手指遊標\",\n  \"appearance.words.customize\": \"自訂\",\n  \"cli.description\": \"你可以直接透過 npx 使用 folocli@latest 在任何終端機裡執行 Folo CLI，無需全域安裝。桌面端可以一鍵同步目前的登入狀態。\",\n  \"cli.desktop_sync\": \"桌面端同步命令\",\n  \"cli.global_install\": \"透過 npx 執行最新版本\",\n  \"cli.install\": \"同步桌面端登入\",\n  \"cli.install_failed\": \"同步桌面端登入到 CLI 失敗\",\n  \"cli.install_success\": \"已將桌面端登入同步到 CLI\",\n  \"cli.installed\": \"已連線\",\n  \"cli.not_available\": \"找不到 npx，請先安裝 Node.js 與 npm。\",\n  \"cli.not_installed\": \"未連線\",\n  \"cli.package\": \"套件名稱\",\n  \"cli.path\": \"設定檔路徑\",\n  \"cli.require_login\": \"請先登入 Folo Desktop，再同步 CLI 登入。\",\n  \"cli.runtime_missing\": \"需要 Node.js/npm\",\n  \"cli.runtime_ready\": \"Node.js/npm 已就緒\",\n  \"cli.title\": \"Folo CLI\",\n  \"cli.uninstall\": \"清除 CLI 登入\",\n  \"cli.uninstall_failed\": \"清除 CLI 登入失敗\",\n  \"cli.uninstall_success\": \"已清除 CLI 登入\",\n  \"common.give_star\": \"<HeartIcon />喜歡我們的產品嗎？ <Link>在 GitHub 上給我們 Star 吧！</Link>\",\n  \"control.paid_badge.basic_or_higher\": \"此功能需要 Basic 方案或更高級別才能使用\",\n  \"control.paid_badge.free_limited\": \"此功能在免費方案中受限\",\n  \"customizeToolbar.more_actions.description\": \"將顯示在下拉選單中\",\n  \"customizeToolbar.more_actions.title\": \"更多操作\",\n  \"customizeToolbar.quick_actions.description\": \"自訂並重新排列您常用的操作\",\n  \"customizeToolbar.quick_actions.title\": \"快速操作\",\n  \"customizeToolbar.reset_layout\": \"重置為預設版面\",\n  \"customizeToolbar.title\": \"自訂工具欄\",\n  \"data_control.app_cache_limit.description\": \"程式快取大小的上限。一旦快取達到此上限，最早的項目將被刪除以釋放空間。\",\n  \"data_control.app_cache_limit.label\": \"程式快取限制\",\n  \"data_control.clean_cache.button\": \"清理快取\",\n  \"data_control.clean_cache.cancel\": \"取消\",\n  \"data_control.clean_cache.clear\": \"清理\",\n  \"data_control.clean_cache.description\": \"清理程式快取以釋放空間。\",\n  \"data_control.clean_cache.description_web\": \"清理網頁應用服務快取以釋放空間。\",\n  \"data_control.clean_cache.success\": \"快取清理成功。\",\n  \"data_control.data_sources\": \"資料來源\",\n  \"data_control.export_local_database.label\": \"匯出本地資料庫\",\n  \"data_control.import_opml.label\": \"從 OPML 匯入訂閱\",\n  \"data_control.utils\": \"工具\",\n  \"discoverFilters.filters\": \"篩選\",\n  \"discoverFilters.language\": \"語言\",\n  \"discoverFilters.title\": \"發現篩選\",\n  \"feeds.claim\": \"認領 RSS 摘要\",\n  \"feeds.claimTips\": \"要認領您的 RSS 摘要並接收贊助，請在您的訂閱列表中右鍵點擊該 RSS 摘要，然後選擇「認領」。\",\n  \"feeds.filter.all\": \"全部 ({{count}})\",\n  \"feeds.filter.rsshub\": \"RSSHub ({{count}})\",\n  \"feeds.noFeeds\": \"沒有已認領的 RSS 摘要\",\n  \"feeds.subscription\": \"已訂閱的 RSS 摘要\",\n  \"feeds.tableHeaders.date\": \"訂閱日期\",\n  \"feeds.tableHeaders.followers\": \"追隨者\",\n  \"feeds.tableHeaders.name\": \"名稱\",\n  \"feeds.tableHeaders.subscriptionCount\": \"訂閱數\",\n  \"feeds.tableHeaders.tipAmount\": \"收到的贊助\",\n  \"feeds.tableHeaders.updatesPerWeek\": \"更新頻率\",\n  \"feeds.tableHeaders.view\": \"視圖\",\n  \"feeds.tableSelected.clear\": \"清除\",\n  \"feeds.tableSelected.item\": \"{{count}} 項選取\",\n  \"feeds.tableSelected.moveToView.action\": \"移動到視圖\",\n  \"feeds.tableSelected.moveToView.confirm\": \"您確定要將這些訂閱源移動到 {{view}} 嗎？\",\n  \"feeds.tableSelected.moveToView.confirmTitle\": \"確認\",\n  \"feeds.tableSelected.unsubscribe\": \"取消訂閱\",\n  \"general.action.summary.description\": \"使用 AI 生成條目摘要。\",\n  \"general.action.summary.label\": \"AI 總結\",\n  \"general.action.title\": \"自動化操作\",\n  \"general.action.translation.description\": \"將條目翻譯成選定的語言。\",\n  \"general.action.translation.label\": \"AI 翻譯\",\n  \"general.action_language.default\": \"預設（介面語言）\",\n  \"general.action_language.description\": \"選擇 AI 操作的語言，例如 AI 總結、AI 翻譯。\",\n  \"general.action_language.label\": \"AI 輸出語言\",\n  \"general.advanced\": \"進階\",\n  \"general.app\": \"App\",\n  \"general.auto_expand_long_social_media.description\": \"自動擴展包含長文字的社群媒體條目。\",\n  \"general.auto_expand_long_social_media.label\": \"拓展長社群媒體\",\n  \"general.auto_group.description\": \"自動依照網址域名分類 RSS 摘要。\",\n  \"general.auto_group.label\": \"自動分類\",\n  \"general.cache\": \"快取\",\n  \"general.content\": \"內容\",\n  \"general.data\": \"資料\",\n  \"general.data_file.label\": \"資料檔案\",\n  \"general.dim_read.description\": \"淡化時間軸上已讀條目的顏色。\",\n  \"general.dim_read.label\": \"淡化已讀條目\",\n  \"general.enhanced.description\": \"啟用增強設定可提供更多自訂選項，但也可能引入不可預見的問題。\",\n  \"general.enhanced.disabled.tip\": \"增強設定已停用，您可以在「一般設定 - 進階」中啟用。\",\n  \"general.enhanced.enable.modal.cancel\": \"取消\",\n  \"general.enhanced.enable.modal.confirm\": \"啟用\",\n  \"general.enhanced.enable.modal.description\": \"啟用增強設定可提供更多自訂選項，但也可能引入不可預見的問題。請勿在不確定的情況下啟用此選項。\",\n  \"general.enhanced.enable.modal.title\": \"啟用增強設定\",\n  \"general.enhanced.enabled.tip\": \"增強設定已啟用，您可以在「一般設定 - 進階」中停用。\",\n  \"general.enhanced.label\": \"增強設定\",\n  \"general.export.button\": \"匯出\",\n  \"general.export.description\": \"匯出你的 RSS 摘要到 OPML 文件。\",\n  \"general.export.folder_mode.description\": \"決定您想要如何組織匯出資料夾。\",\n  \"general.export.folder_mode.label\": \"資料夾模式\",\n  \"general.export.folder_mode.option.category\": \"類別\",\n  \"general.export.folder_mode.option.view\": \"視圖\",\n  \"general.export.label\": \"匯出 RSS 摘要\",\n  \"general.export.rsshub_url.description\": \"RSSHub 路由的預設基礎 URL，留空則使用 https://rsshub.app。\",\n  \"general.export.rsshub_url.label\": \"RSSHub URL\",\n  \"general.export_data.title\": \"匯出資料\",\n  \"general.export_database.button\": \"匯出\",\n  \"general.export_database.description\": \"將你的資料庫匯出成 JSON 檔案。\",\n  \"general.export_database.label\": \"匯出資料庫\",\n  \"general.group_by_date.description\": \"按日期分組項目\",\n  \"general.group_by_date.label\": \"按日期分組\",\n  \"general.hide_all_read_subscriptions.description\": \"在訂閱列表中隱藏沒有未讀條目的訂閱。\",\n  \"general.hide_all_read_subscriptions.label\": \"隱藏列表\",\n  \"general.hide_private_subscriptions_in_timeline.description\": \"從你的訂閱列表中隱藏私人訂閱，並從你的時間軸上隱藏它們的條目（無論此設置為何，它們對公眾始終是不可見的）。\",\n  \"general.hide_private_subscriptions_in_timeline.label\": \"隱藏私人\",\n  \"general.language.description\": \"選擇應用程式的顯示語言。\",\n  \"general.language.title\": \"語言\",\n  \"general.launch_at_login\": \"登入時啟動\",\n  \"general.log_file.button\": \"顯示\",\n  \"general.log_file.description\": \"在系統中顯示記錄檔案。\",\n  \"general.log_file.label\": \"記錄檔案\",\n  \"general.maintenance.title\": \"維護\",\n  \"general.mark_as_read.hover.description\": \"遊標懸停時自動將條目標記為已讀。\",\n  \"general.mark_as_read.hover.label\": \"滑鼠懸停在文章上時\",\n  \"general.mark_as_read.render.description\": \"將社群媒體貼文或圖片等單項內容立即標記為已讀。\",\n  \"general.mark_as_read.render.label\": \"單項內容進入視圖時\",\n  \"general.mark_as_read.scroll.description\": \"當條目捲動離開視圖時自動標記為已讀。\",\n  \"general.mark_as_read.scroll.label\": \"捲動瀏覽文章時\",\n  \"general.mark_as_read.title\": \"標記為已讀\",\n  \"general.minimize_to_tray.description\": \"關閉視窗時最小化到工作列通知區域\",\n  \"general.minimize_to_tray.label\": \"最小化到通知區域\",\n  \"general.network\": \"網路\",\n  \"general.open_links_in_external_app.label\": \"在外部 app 開啟連結\",\n  \"general.privacy\": \"隱私\",\n  \"general.proxy.description\": \"設定 Proxy 伺服器來處理網路流量，例如：socks://proxy.example.com:1080。\",\n  \"general.proxy.label\": \"Proxy 伺服器\",\n  \"general.rebuild_database.button\": \"重置\",\n  \"general.rebuild_database.cancel\": \"取消\",\n  \"general.rebuild_database.description\": \"如果您遇到渲染問題，重置資料庫可能可以解決。\",\n  \"general.rebuild_database.label\": \"重置資料庫\",\n  \"general.rebuild_database.title\": \"重置資料庫\",\n  \"general.rebuild_database.warning.line1\": \"重置資料庫將會清除您所有的本機資料。\",\n  \"general.rebuild_database.warning.line2\": \"您確定要繼續嗎？\",\n  \"general.send_anonymous_data.description\": \"選擇傳送匿名使用資料，您將幫助改善 Folo 的整體使用體驗。\",\n  \"general.send_anonymous_data.label\": \"傳送匿名資料\",\n  \"general.show_quick_timeline.description\": \"在 RSS 摘要列表頂部顯示快速時間軸。\",\n  \"general.show_quick_timeline.label\": \"顯示 RSS 摘要列表時間軸\",\n  \"general.show_unread_on_launch.description\": \"啟動應用程式時自動篩選未讀內容。\",\n  \"general.show_unread_on_launch.label\": \"啟動時僅顯示未讀\",\n  \"general.sidebar_title\": \"一般\",\n  \"general.subscription\": \"訂閱\",\n  \"general.subscriptions\": \"訂閱\",\n  \"general.timeline\": \"時間軸\",\n  \"general.translation_mode.bilingual\": \"雙語對照\",\n  \"general.translation_mode.description\": \"選擇譯文在條目列表中的顯示方式。\",\n  \"general.translation_mode.label\": \"翻譯偏好\",\n  \"general.translation_mode.translation-only\": \"僅譯文\",\n  \"general.voices\": \"聲音\",\n  \"integration.builtin.title\": \"內建整合\",\n  \"integration.categories.custom_integrations\": \"自訂整合\",\n  \"integration.categories.download_tools\": \"下載工具\",\n  \"integration.categories.knowledge_management\": \"知識管理\",\n  \"integration.categories.media_tools\": \"媒體工具\",\n  \"integration.categories.reading_services\": \"閲讀服務\",\n  \"integration.cubox.autoMemo.description\": \"當選取文字儲存到 Cubox 時，自動使用備忘模式。\",\n  \"integration.cubox.autoMemo.label\": \"自動備忘模式\",\n  \"integration.cubox.enable.description\": \"顯示「儲存到 Cubox」按鈕（如果可用）。\",\n  \"integration.cubox.enable.label\": \"啟用\",\n  \"integration.cubox.title\": \"Cubox\",\n  \"integration.cubox.token.description\": \"請輸入完整的 Cubox API URL，格式為：https://cubox.pro/c/api/save/xxxxxxxxx。您可以在此處獲取：\",\n  \"integration.cubox.token.label\": \"Cubox API 網址\",\n  \"integration.custom_integrations.actions.delete\": \"刪除\",\n  \"integration.custom_integrations.actions.disable\": \"停用\",\n  \"integration.custom_integrations.actions.edit\": \"編輯\",\n  \"integration.custom_integrations.actions.enable\": \"啟用\",\n  \"integration.custom_integrations.add.button\": \"新增\",\n  \"integration.custom_integrations.create.error\": \"建立失敗\",\n  \"integration.custom_integrations.create.success\": \"建立成功\",\n  \"integration.custom_integrations.create.title\": \"建立自訂整合\",\n  \"integration.custom_integrations.delete.success\": \"刪除成功\",\n  \"integration.custom_integrations.edit.error\": \"編輯失敗\",\n  \"integration.custom_integrations.edit.success\": \"編輯成功\",\n  \"integration.custom_integrations.edit.title\": \"編輯自訂整合\",\n  \"integration.custom_integrations.enable.description\": \"允許使用 Fetch 模板建立自訂分享整合，支援各種 HTTP 方法與設定。\",\n  \"integration.custom_integrations.enable.label\": \"啟用自訂整合\",\n  \"integration.custom_integrations.form.body.description\": \"POST/PUT/PATCH 方法的請求主體。支援佔位符與 JSON 格式。\",\n  \"integration.custom_integrations.form.body.label\": \"請求主體\",\n  \"integration.custom_integrations.form.body.placeholder\": \"{\\\"title\\\": \\\"[title]\\\", \\\"url\\\": \\\"[url]\\\", \\\"content\\\": \\\"[content_markdown]\\\"}\",\n  \"integration.custom_integrations.form.fetch_template.label\": \"獲取模板\",\n  \"integration.custom_integrations.form.headers.add\": \"以鍵值對方式新增自訂標頭，值可使用佔位符。\",\n  \"integration.custom_integrations.form.headers.description\": \"自定義標頭\",\n  \"integration.custom_integrations.form.headers.key_placeholder\": \"標頭名稱\",\n  \"integration.custom_integrations.form.headers.label\": \"標頭\",\n  \"integration.custom_integrations.form.headers.value_placeholder\": \"標頭值\",\n  \"integration.custom_integrations.form.icon.description\": \"選擇整合圖示\",\n  \"integration.custom_integrations.form.icon.label\": \"圖示\",\n  \"integration.custom_integrations.form.method.description\": \"選擇請求的 HTTP 方法。\",\n  \"integration.custom_integrations.form.method.label\": \"HTTP 方法\",\n  \"integration.custom_integrations.form.name.label\": \"整合名稱\",\n  \"integration.custom_integrations.form.name.placeholder\": \"輸入整合名稱\",\n  \"integration.custom_integrations.form.scheme.description\": \"輸入外部應用程式的 URL Scheme。可使用佔位符（如 [title]、[url]、[content_markdown] 等）。\",\n  \"integration.custom_integrations.form.scheme.examples.title\": \"範例\",\n  \"integration.custom_integrations.form.scheme.label\": \"URL Scheme\",\n  \"integration.custom_integrations.form.scheme.placeholder\": \"例：obsidian://new?vault=MyVault&name=[title]&content=[content_markdown]\",\n  \"integration.custom_integrations.form.type.description\": \"選擇使用 HTTP API 請求或 URL Scheme 重定向至外部應用程式。\",\n  \"integration.custom_integrations.form.type.http\": \"HTTP 請求\",\n  \"integration.custom_integrations.form.type.label\": \"整合類型\",\n  \"integration.custom_integrations.form.type.url_scheme\": \"URL Scheme\",\n  \"integration.custom_integrations.form.url.description\": \"可使用佔位符：[title]、[url]、[content_html]、[summary]、[content_markdown]。\",\n  \"integration.custom_integrations.form.url.label\": \"URL\",\n  \"integration.custom_integrations.form.url.placeholder\": \"https://example.com/api/share\",\n  \"integration.custom_integrations.icons.bookmark\": \"書籤\",\n  \"integration.custom_integrations.icons.document\": \"文檔\",\n  \"integration.custom_integrations.icons.download\": \"下載\",\n  \"integration.custom_integrations.icons.external_link\": \"外部連結\",\n  \"integration.custom_integrations.icons.link\": \"連結\",\n  \"integration.custom_integrations.icons.picture\": \"圖片\",\n  \"integration.custom_integrations.icons.save\": \"保存\",\n  \"integration.custom_integrations.icons.send\": \"發送\",\n  \"integration.custom_integrations.icons.share\": \"分享\",\n  \"integration.custom_integrations.icons.star\": \"收藏\",\n  \"integration.custom_integrations.list.empty.button\": \"建立首個整合\",\n  \"integration.custom_integrations.list.empty.description\": \"建立自訂整合以擴充功能。\",\n  \"integration.custom_integrations.list.empty.title\": \"尚無自訂整合\",\n  \"integration.custom_integrations.list.title\": \"自定義整合列表\",\n  \"integration.custom_integrations.modal.description\": \"設定自訂整合參數。\",\n  \"integration.custom_integrations.placeholders.click_to_copy\": \"點擊複製\",\n  \"integration.custom_integrations.placeholders.description\": \"點擊任一佔位符以複製到剪貼簿。\",\n  \"integration.custom_integrations.placeholders.help\": \"可用的佔位符\",\n  \"integration.custom_integrations.preview.body\": \"請求主體\",\n  \"integration.custom_integrations.preview.failed\": \"預覽失敗\",\n  \"integration.custom_integrations.preview.generating\": \"正在生成預覽...\",\n  \"integration.custom_integrations.preview.headers\": \"請求標頭\",\n  \"integration.custom_integrations.preview.placeholders\": \"可用佔位符\",\n  \"integration.custom_integrations.preview.request\": \"請求\",\n  \"integration.custom_integrations.preview.title\": \"請求預覽\",\n  \"integration.custom_integrations.status.disabled\": \"已停用\",\n  \"integration.custom_integrations.title\": \"自定義整合\",\n  \"integration.custom_integrations.validation.invalid\": \"模板錯誤\",\n  \"integration.custom_integrations.validation.valid\": \"模板正確\",\n  \"integration.eagle.enable.description\": \"顯示「將媒體儲存到 Eagle」按鈕（如果可用）。\",\n  \"integration.eagle.enable.label\": \"啟用\",\n  \"integration.eagle.title\": \"Eagle\",\n  \"integration.export.button\": \"匯出設定\",\n  \"integration.export.error\": \"匯出整合設定失敗\",\n  \"integration.export.success\": \"整合設定匯出成功\",\n  \"integration.general\": \"通用\",\n  \"integration.import.button\": \"匯入設定\",\n  \"integration.import.error\": \"匯入整合設定失敗\",\n  \"integration.import.invalid\": \"無效的整合設定檔\",\n  \"integration.import.success\": \"整合設定匯入成功\",\n  \"integration.instapaper.enable.description\": \"顯示「儲存到 Instapaper」按鈕（如果可用）。\",\n  \"integration.instapaper.enable.label\": \"啟用\",\n  \"integration.instapaper.password.label\": \"Instapaper 密碼\",\n  \"integration.instapaper.title\": \"Instapaper\",\n  \"integration.instapaper.username.label\": \"Instapaper 使用者\",\n  \"integration.obsidian.enable.description\": \"顯示「儲存到 Obsidian」按鈕（如果可用）。\",\n  \"integration.obsidian.enable.label\": \"啟用\",\n  \"integration.obsidian.title\": \"Obsidian\",\n  \"integration.obsidian.vaultPath.description\": \"您的 Obsidian 儲存庫的路徑。\",\n  \"integration.obsidian.vaultPath.label\": \"Obsidian 儲存庫路徑\",\n  \"integration.outline.collection.description\": \"儲存文檔的文檔集的 UUID 或 urlId。\",\n  \"integration.outline.collection.label\": \"Outline 文檔集\",\n  \"integration.outline.enable.description\": \"顯示「儲存到 Outline」按鈕（如果可用）。\",\n  \"integration.outline.enable.label\": \"啟用\",\n  \"integration.outline.endpoint.description\": \"此網址為 'https://<你的 OUTLINE 域名>/api'。\",\n  \"integration.outline.endpoint.label\": \"Outline API 基礎網址\",\n  \"integration.outline.title\": \"Outline\",\n  \"integration.outline.token.description\": \"在你的 Outline 帳戶設置中獲取。\",\n  \"integration.outline.token.label\": \"Outline API 密鑰\",\n  \"integration.qbittorrent.enable.description\": \"在可用時顯示「使用 qBittorrent 下載」按鈕。\",\n  \"integration.qbittorrent.enable.label\": \"啟用\",\n  \"integration.qbittorrent.host.description\": \"你的 qBittorrent WebUI 的 URL，例如 http://localhost:8080。\",\n  \"integration.qbittorrent.host.label\": \"qBittorrent 主機\",\n  \"integration.qbittorrent.password.label\": \"qBittorrent 密碼\",\n  \"integration.qbittorrent.title\": \"qBittorrent\",\n  \"integration.qbittorrent.username.label\": \"qBittorrent 使用者名稱\",\n  \"integration.readeck.enable.description\": \"顯示「儲存到 Readeck」按鈕（如果可用）。\",\n  \"integration.readeck.enable.label\": \"啟用\",\n  \"integration.readeck.endpoint.description\": \"此網址為 'https://<你的 READECK 域名>'。\",\n  \"integration.readeck.endpoint.label\": \"Readeck API 基礎網址\",\n  \"integration.readeck.title\": \"Readeck\",\n  \"integration.readeck.token.description\": \"在你的 Readeck 帳戶設置中獲取。\",\n  \"integration.readeck.token.label\": \"Readeck API 密鑰\",\n  \"integration.readwise.enable.description\": \"顯示「儲存到 Readwise」按鈕（如果可用）。\",\n  \"integration.readwise.enable.label\": \"啟用\",\n  \"integration.readwise.title\": \"Readwise\",\n  \"integration.readwise.token.description\": \"您可以在這裡獲取：\",\n  \"integration.readwise.token.label\": \"Readwise 存取密鑰\",\n  \"integration.save_ai_summary_as_description.label\": \"將 AI 總結儲存為描述\",\n  \"integration.search.placeholder\": \"搜尋整合功能....\",\n  \"integration.sidebar_title\": \"第三方整合\",\n  \"integration.status.configured\": \"已配置\",\n  \"integration.status.enabled\": \"已啟用\",\n  \"integration.tip\": \"提示：您的敏感資料儲存於本機，不會上傳到伺服器。\",\n  \"integration.title\": \"第三方整合\",\n  \"integration.use_browser_fetch.description\": \"使用瀏覽器的 fetch API 進行自訂整合，而不是 Electron 的原生 fetch。啟用以獲得更好的網頁兼容性，禁用以增強安全性。\",\n  \"integration.use_browser_fetch.label\": \"使用瀏覽器 Fetch\",\n  \"integration.zotero.enable.description\": \"顯示「儲存到 Zotero」按鈕（如果可用）。\",\n  \"integration.zotero.enable.label\": \"啟用\",\n  \"integration.zotero.title\": \"Zotero\",\n  \"integration.zotero.token.description\": \"你可以在此獲取 API 密鑰：\",\n  \"integration.zotero.token.label\": \"Zotero API 密鑰\",\n  \"integration.zotero.userID.description\": \"你可以在此獲取使用者 ID：\",\n  \"integration.zotero.userID.label\": \"Zotero 使用者 ID\",\n  \"invitation.activate\": \"啟用\",\n  \"invitation.codeOptions.betaUser\": \"1. 尋找邀請您的測試版使用者。\",\n  \"invitation.codeOptions.discord\": \"2. 加入我們的 Discord 伺服器，偶爾獲得贈品。\",\n  \"invitation.codeOptions.xAccount\": \"3.關注我們的 X 帳號，不定期有贈品。\",\n  \"invitation.confirmModal.cancel\": \"取消\",\n  \"invitation.confirmModal.confirm\": \"您想繼續嗎？\",\n  \"invitation.confirmModal.continue\": \"繼續\",\n  \"invitation.confirmModal.message\": \"產生邀請碼將會花費您 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon>。\",\n  \"invitation.confirmModal.title\": \"確認\",\n  \"invitation.created_at\": \"建立者：\",\n  \"invitation.earlyAccess\": \"Folo 目前處於<strong>早期開發</strong>狀態，需要邀請碼才能使用。\",\n  \"invitation.earlyAccessMessage\": \"😰 抱歉，關注目前處於搶先體驗階段，需要邀請碼才能使用。\",\n  \"invitation.generate\": \"產生\",\n  \"invitation.generateButton\": \"產生邀請碼\",\n  \"invitation.generateCost\": \"您可以花費 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon> 為您的朋友產生邀請碼。\",\n  \"invitation.getCodeMessage\": \"您可以通過以下方式獲取邀請碼：\",\n  \"invitation.limitationMessage\": \"基於您的使用時間，您最多可以產生 {{limitation}} 個邀請碼。\",\n  \"invitation.newInvitationSuccess\": \"🎉 邀請碼已產生，邀請碼已複製\",\n  \"invitation.noInvitations\": \"沒有邀請\",\n  \"invitation.notUsed\": \"未使用\",\n  \"invitation.sidebar_title\": \"邀請\",\n  \"invitation.tableHeaders.code\": \"邀請碼\",\n  \"invitation.tableHeaders.creationTime\": \"建立時間\",\n  \"invitation.tableHeaders.usedBy\": \"使用者\",\n  \"invitation.title\": \"邀請碼\",\n  \"lists.create\": \"建立新列表\",\n  \"lists.created.error\": \"建立列表失敗。\",\n  \"lists.created.success\": \"列表建立成功！\",\n  \"lists.delete.confirm\": \"您確定要刪除列表嗎？\",\n  \"lists.delete.error\": \"刪除列表失敗。\",\n  \"lists.delete.success\": \"成功刪除列表！\",\n  \"lists.delete.warning\": \"警告：一旦刪除，列表將不再可用，所有內容將被永久刪除且無法恢復\",\n  \"lists.description\": \"描述\",\n  \"lists.earnings\": \"收益\",\n  \"lists.edit.error\": \"編輯列表失敗。\",\n  \"lists.edit.label\": \"編輯\",\n  \"lists.edit.success\": \"列表編輯成功！\",\n  \"lists.fee.description\": \"其他人訂閱此列表需要支付的費用。\",\n  \"lists.fee.label\": \"費用\",\n  \"lists.feeds.actions\": \"操作\",\n  \"lists.feeds.add.error\": \"將 RSS 摘要新增到列表失敗。\",\n  \"lists.feeds.add.label\": \"新增\",\n  \"lists.feeds.add.success\": \"RSS 摘要已新增到列表。\",\n  \"lists.feeds.delete.error\": \"從列表中移除 RSS 摘要失敗。\",\n  \"lists.feeds.delete.success\": \"RSS 摘要已從列表中移除。\",\n  \"lists.feeds.id\": \"RSS 摘要 ID\",\n  \"lists.feeds.label\": \"RSS 摘要\",\n  \"lists.feeds.manage\": \"管理 RSS 摘要\",\n  \"lists.feeds.owner\": \"擁有者\",\n  \"lists.feeds.search\": \"搜尋 RSS 摘要\",\n  \"lists.feeds.title\": \"標題\",\n  \"lists.image\": \"圖片\",\n  \"lists.info\": \"列表是您可以分享或出售給他人訂閱的 RSS 摘要集合。訂閱者將同步並訪問列表中的所有 RSS 摘要。\",\n  \"lists.manage_list\": \"管理列表\",\n  \"lists.noLists\": \"沒有列表\",\n  \"lists.select_feeds\": \"選擇要新增到當前列表的 RSS 摘要\",\n  \"lists.submit\": \"送出\",\n  \"lists.subscriptions\": \"訂閱\",\n  \"lists.title\": \"標題\",\n  \"lists.view\": \"查看\",\n  \"notifications.channel\": \"管道\",\n  \"notifications.current\": \"（當前客户端）\",\n  \"notifications.empty.description\": \"當您在這台裝置上啟用通知後，通知管道會顯示在這裡。\",\n  \"notifications.empty.title\": \"尚無通知管道\",\n  \"notifications.info\": \"Folo 通過<ActionsLink>自動化</ActionsLink>提供強大且靈活的通知功能。你可以為特定的 RSS 摘要、視圖或關鍵字自定義通知。以下是已註冊的通知管道。\",\n  \"notifications.test\": \"測試通知\",\n  \"notifications.test_success\": \"測試通知發送成功。\",\n  \"notifications.token\": \"客户端令牌\",\n  \"plan.canceled_expires\": \"已取消 - {{date}} 到期\",\n  \"plan.current_plan\": \"目前方案\",\n  \"plan.descriptions.basic\": \"更多訂閱源但不含 AI 功能。\",\n  \"plan.descriptions.free\": \"非常適合初學者。\",\n  \"plan.descriptions.plus\": \"解鎖 AI 功能和更多訂閱源。\",\n  \"plan.descriptions.pro\": \"完整享受 Folo 的最佳功能。\",\n  \"plan.featureValues.AI_MODEL_SELECTION.curated\": \"精選模型\",\n  \"plan.featureValues.AI_MODEL_SELECTION.high_performance\": \"全部高效能模型\",\n  \"plan.featureValues.AI_MODEL_SELECTION.none\": \"—\",\n  \"plan.features.AI_BRING_YOUR_OWN_KEY\": \"AI 自訂 API 金鑰\",\n  \"plan.features.AI_CREDIT\": \"AI Chat 點數\",\n  \"plan.features.AI_MODEL_SELECTION\": \"AI 模型選擇\",\n  \"plan.features.BOOSTS\": \"訂閱源更新加速\",\n  \"plan.features.INTEGRATION_SUPPORTED\": \"第三方整合\",\n  \"plan.features.MAX_ACTIONS\": \"自動化操作\",\n  \"plan.features.MAX_AI_ENTRY_SUMMARY_PER_DAY\": \"每日 AI 摘要次數\",\n  \"plan.features.MAX_AI_ENTRY_TRANSLATION_PER_DAY\": \"每日 AI 翻譯次數\",\n  \"plan.features.MAX_AI_REQUESTS_PER_DAY\": \"每日 AI 對話次數\",\n  \"plan.features.MAX_AI_REQUESTS_PER_MONTH\": \"每月 AI 對話次數\",\n  \"plan.features.MAX_AI_TASKS\": \"AI 任務\",\n  \"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY\": \"每日 AI 語音合成次數\",\n  \"plan.features.MAX_INBOXES\": \"收件箱訂閱源\",\n  \"plan.features.MAX_LISTS\": \"自訂列表\",\n  \"plan.features.MAX_RSSHUB_SUBSCRIPTIONS\": \"RSSHub 訂閱\",\n  \"plan.features.MAX_SUBSCRIPTIONS\": \"訂閱源數量\",\n  \"plan.features.PRIORITY_SUPPORT\": \"優先支援\",\n  \"plan.features.PRIVATE_SUBSCRIPTION\": \"私有訂閱\",\n  \"plan.features.SECURE_IMAGE_PROXY\": \"安全圖片代理\",\n  \"plan.manage_subscription\": \"管理訂閱\",\n  \"plan.renews\": \"續訂日期 {{date}}\",\n  \"plan.trial_ends\": \"試用期至 {{date}}\",\n  \"privacy.privacy\": \"隱私政策\",\n  \"privacy.terms\": \"服務條款\",\n  \"profile.avatar.cropInstructions\": \"拖曳裁切區域以調整你的頭像\",\n  \"profile.avatar.dropZoneSubtext\": \"或點擊從你的電腦中選擇\",\n  \"profile.avatar.dropZoneText\": \"將圖片拖放到此處\",\n  \"profile.avatar.fileTooLarge\": \"檔案大小必須小於 {{size}}\",\n  \"profile.avatar.invalidFileType\": \"請選擇有效的圖片檔案\",\n  \"profile.avatar.label\": \"頭像\",\n  \"profile.avatar.processingError\": \"處理圖片時發生錯誤\",\n  \"profile.avatar.selectAnother\": \"選擇其他圖片\",\n  \"profile.avatar.selectFile\": \"選擇檔案\",\n  \"profile.avatar.uploadError\": \"上傳頭像失敗\",\n  \"profile.avatar.uploadSuccess\": \"頭像上傳成功\",\n  \"profile.avatar.uploadTitle\": \"上傳頭像\",\n  \"profile.change_password.label\": \"修改密碼\",\n  \"profile.confirm_password.label\": \"確認密碼\",\n  \"profile.current_password.label\": \"目前密碼\",\n  \"profile.danger_zone\": \"危險操作區域\",\n  \"profile.delete_account.label\": \"刪除帳號\",\n  \"profile.edit_email\": \"編輯電子信箱\",\n  \"profile.edit_profile\": \"編輯個人資料\",\n  \"profile.email.change\": \"變更電子信箱\",\n  \"profile.email.change_note\": \"如果你想更改電子信箱，應該驗證你的新電子信箱。\",\n  \"profile.email.changed\": \"電子信箱已變更。\",\n  \"profile.email.changed_verification_sent\": \"已發送驗證新電子信箱的信件。\",\n  \"profile.email.label\": \"電子信箱\",\n  \"profile.email.send_verification\": \"發送驗證信件\",\n  \"profile.email.unverified\": \"尚未驗證\",\n  \"profile.email.verification_sent\": \"驗證信件已發送\",\n  \"profile.email.verified\": \"已驗證\",\n  \"profile.email.verify_email\": \"請先驗證你的電子信箱 ({{email_address}}) 後繼續。\",\n  \"profile.email.verify_status\": \"你的電子信箱目前狀態為 {{status}}\",\n  \"profile.handle.description\": \"你的獨一無二名稱。\",\n  \"profile.handle.label\": \"名稱\",\n  \"profile.link_social.authentication\": \"驗證\",\n  \"profile.link_social.link\": \"連結\",\n  \"profile.link_social.unlink.success\": \"已中斷社群帳號連結。\",\n  \"profile.name.description\": \"你的公開顯示名稱。\",\n  \"profile.name.label\": \"顯示名稱\",\n  \"profile.new_password.label\": \"新密碼\",\n  \"profile.no_password\": \"<Link>重設密碼</Link>以設定新密碼。\",\n  \"profile.password.label\": \"密碼\",\n  \"profile.profile.bio\": \"個人簡介\",\n  \"profile.profile.bio_placeholder\": \"介紹一下你自己…\",\n  \"profile.profile.changed\": \"個人資料已更新\",\n  \"profile.profile.save\": \"儲存\",\n  \"profile.profile.social_links\": \"社群連結\",\n  \"profile.profile.social_links_discord\": \"Discord ID\",\n  \"profile.profile.social_links_facebook\": \"Facebook ID\",\n  \"profile.profile.social_links_github\": \"Github ID\",\n  \"profile.profile.social_links_instagram\": \"Instagram ID\",\n  \"profile.profile.social_links_twitter\": \"Twitter ID\",\n  \"profile.profile.social_links_youtube\": \"Youtube ID\",\n  \"profile.profile.website\": \"網站\",\n  \"profile.reset_password_mail_sent\": \"重設密碼信件已發送。\",\n  \"profile.security\": \"安全性\",\n  \"profile.set_avatar\": \"設定頭像\",\n  \"profile.sidebar_title\": \"個人資料\",\n  \"profile.submit\": \"送出\",\n  \"profile.title\": \"個人資料設定\",\n  \"profile.totp_code.init\": \"使用 Authenticator 掃描 QR Code\",\n  \"profile.totp_code.invalid\": \"無效的雙因素驗證碼。\",\n  \"profile.totp_code.label\": \"雙因素驗證碼\",\n  \"profile.totp_code.title\": \"輸入雙因素驗證碼\",\n  \"profile.two_factor.disable\": \"停用雙因素驗證\",\n  \"profile.two_factor.disabled\": \"雙因素驗證已停用。\",\n  \"profile.two_factor.enable\": \"啟用雙因素驗證\",\n  \"profile.two_factor.enable_notice\": \"需要啟用雙因素驗證才能執行此操作。\",\n  \"profile.two_factor.enabled\": \"雙因素驗證已啟用。\",\n  \"profile.two_factor.label\": \"雙因素驗證\",\n  \"profile.two_factor.no_password\": \"啟用雙因素驗證之前需要<Link>設定</Link>密碼。\",\n  \"profile.updateSuccess\": \"個人資料已更新。\",\n  \"profile.update_password_success\": \"密碼已更新。\",\n  \"referral.description\": \"將 Folo 分享給朋友！延長你的專業版預覽及未來福利，朋友也可獲得 45 天試用期。<Link>了解更多</Link>。\",\n  \"referral.invited_friend_status.pending\": \"等待驗證\",\n  \"referral.invited_friend_status.valid\": \"有效\",\n  \"referral.link\": \"你的邀請連結：\",\n  \"referral.pro_status.preview\": \"你的專業版預覽狀態：將於 {{dateString}} 到期（剩餘 {{daysLeft}} 天）\",\n  \"referral.pro_status.trial\": \"你目前的方案：免費\",\n  \"referral.pro_status.user\": \"你的專業版預覽狀態：啟用中\",\n  \"reviewPrompt.description\": \"如果 Folo 對你有幫助，歡迎到商店留下評分；如果還不夠好，也歡迎告訴我們哪裡需要改進。\",\n  \"reviewPrompt.loveIt\": \"喜歡\",\n  \"reviewPrompt.notReally\": \"還不太滿意\",\n  \"reviewPrompt.title\": \"你喜歡 Folo 嗎？\",\n  \"rsshub.addModal.access_key_label\": \"存取金鑰（選填）\",\n  \"rsshub.addModal.add\": \"新增\",\n  \"rsshub.addModal.base_url_label\": \"基礎 URL\",\n  \"rsshub.addModal.description\": \"要在 Folo 中使用自己的 RSSHub 實例伺服器，必須將以下環境變數新增到伺服器中。\",\n  \"rsshub.add_new_instance\": \"新增 RSSHub 實例伺服器\",\n  \"rsshub.description\": \"RSSHub 是由社群驅動的開源 RSS 網路。Folo 提供了內建的專用實例伺服器來支持數以千計的訂閱內容，你也可以通過使用自己的或第三方的實例伺服器來實現更穩定的內容獲取。\",\n  \"rsshub.public_instances\": \"實例伺服器\",\n  \"rsshub.table.delete.confirm\": \"確定要刪除此實例伺服器嗎？\",\n  \"rsshub.table.delete.label\": \"刪除\",\n  \"rsshub.table.delete.success\": \"實例伺服器刪除成功。\",\n  \"rsshub.table.description\": \"描述\",\n  \"rsshub.table.edit\": \"編輯\",\n  \"rsshub.table.inuse\": \"使用中\",\n  \"rsshub.table.limit_reached\": \"達到限制\",\n  \"rsshub.table.official\": \"官方\",\n  \"rsshub.table.owner\": \"建立者\",\n  \"rsshub.table.price\": \"每月價格\",\n  \"rsshub.table.private\": \"私人\",\n  \"rsshub.table.unavailable\": \"不可用\",\n  \"rsshub.table.unlimited\": \"無限制\",\n  \"rsshub.table.use\": \"使用\",\n  \"rsshub.table.userCount\": \"使用者數量\",\n  \"rsshub.table.userLimit\": \"使用者限制\",\n  \"rsshub.table.yours\": \"你的\",\n  \"rsshub.useModal.about\": \"關於此實例伺服器\",\n  \"rsshub.useModal.month\": \"個月\",\n  \"rsshub.useModal.months_label\": \"你想購買的月份數量\",\n  \"rsshub.useModal.purchase_expires_at\": \"你已購買此實例伺服器，到期時間為\",\n  \"rsshub.useModal.title\": \"RSSHub 實例伺服器\",\n  \"rsshub.useModal.useWith\": \"使用 {{amount}} <Power />\",\n  \"subscription.actions.comingSoon\": \"即將推出\",\n  \"subscription.actions.current\": \"目前方案\",\n  \"subscription.actions.manage_error\": \"開啟訂閱管理時發生問題。\",\n  \"subscription.actions.upgrade\": \"升級\",\n  \"subscription.actions.upgrade_error\": \"開始結帳時發生問題。\",\n  \"subscription.badge.popular\": \"最受歡迎\",\n  \"subscription.billing.monthly\": \"按月\",\n  \"subscription.billing.yearly\": \"按年\",\n  \"subscription.billing.yearly_savings\": \"節省 {{value}}%\",\n  \"subscription.discount.tag\": \"節省 {{value}}%\",\n  \"subscription.feature.included\": \"已包含\",\n  \"subscription.price.free\": \"免費\",\n  \"subscription.price.per_month\": \"每月\",\n  \"subscription.price.per_month_billed_yearly\": \"按年計費，每月\",\n  \"subscription.summary.active\": \"你目前擁有有效的訂閱。\",\n  \"subscription.summary.current\": \"{{plan}} 方案\",\n  \"subscription.summary.free\": \"免費方案\",\n  \"subscription.summary.free_description\": \"升級即可解鎖更多訂閱、列表與 AI 功能。\",\n  \"subscription.summary.title\": \"我的訂閱\",\n  \"subscription.summary.trial_expiring\": \"試用將於 {{date}} 到期（剩餘 {{days}} 天）\",\n  \"subscription.unavailable\": \"目前暫不支援訂閱功能。\",\n  \"titles.about\": \"關於\",\n  \"titles.account\": \"帳戶\",\n  \"titles.actions\": \"自動化操作\",\n  \"titles.ai\": \"AI\",\n  \"titles.appearance\": \"外觀\",\n  \"titles.cli\": \"CLI\",\n  \"titles.data_control\": \"資料管理\",\n  \"titles.feeds\": \"RSS 摘要\",\n  \"titles.general\": \"一般\",\n  \"titles.integration\": \"整合\",\n  \"titles.invitations\": \"邀請\",\n  \"titles.lists\": \"列表\",\n  \"titles.notifications\": \"通知\",\n  \"titles.plan.long\": \"Upgrade your plan\",\n  \"titles.plan.short\": \"Plan\",\n  \"titles.power\": \"Power\",\n  \"titles.privacy\": \"隱私\",\n  \"titles.referral.long\": \"邀請朋友並延長專業版\",\n  \"titles.referral.short\": \"邀請並賺取\",\n  \"titles.shortcuts\": \"快捷鍵\",\n  \"titles.sign_out\": \"登出\",\n  \"titles.subscription.long\": \"管理訂閱\",\n  \"titles.subscription.short\": \"訂閱\",\n  \"titles.token_usage\": \"令牌使用情況\",\n  \"wallet.balance.activePoints\": \"活躍度\",\n  \"wallet.balance.dailyReward\": \"每日獎勵\",\n  \"wallet.balance.title\": \"餘額\",\n  \"wallet.balance.withdrawable\": \"可提取\",\n  \"wallet.balance.withdrawableTooltip\": \"可提取的 Power 包括你收到的贊助和儲值的 Power。\",\n  \"wallet.claim.button.claim\": \"領取每日 Power\",\n  \"wallet.claim.button.claimed\": \"今日已領取\",\n  \"wallet.claim.tooltip.alreadyClaimed\": \"今天已經領取過了。\",\n  \"wallet.claim.tooltip.canClaim\": \"立即領取你的 {{amount}} 每日 Power！\",\n  \"wallet.create.button\": \"建立錢包\",\n  \"wallet.create.description\": \"建立一個免費錢包以接收 <PowerIcon /> <strong>Power</strong>，可用於獎勵創作者，也可以因您的內容貢獻而獲得獎勵。\",\n  \"wallet.power.dailyClaim\": \"每天可以領取 {{amount}} 個免費 Power，可用於在 Folo 上贊助 RSS 項目。\",\n  \"wallet.power.rewardDescription\": \"所有 Folo 的活躍使用者都可以獲得每日 Power 獎勵\",\n  \"wallet.power.rewardDescription2\": \"根據你的等級和過去活動，您將獲得一份 <Balance /> 獎勵。<Link>更多詳情</Link>\",\n  \"wallet.ranking.level\": \"等級\",\n  \"wallet.ranking.name\": \"使用者\",\n  \"wallet.ranking.power\": \"Power\",\n  \"wallet.ranking.rank\": \"排名\",\n  \"wallet.ranking.title\": \"Power 排名\",\n  \"wallet.rewardDescription.description1\": \"每日獎勵基於「使用者等級」和「活躍度」兩部分計算\",\n  \"wallet.rewardDescription.description2\": \"使用者等級: 由 Power 排行榜的排名決定\",\n  \"wallet.rewardDescription.description3\": \"使用者活躍度: 使用 Folo 功能可以提升活躍度，活躍度獎勵倍數範圍是 1x ~ 5x\",\n  \"wallet.rewardDescription.level\": \"使用者等級\",\n  \"wallet.rewardDescription.percentage\": \"排名比例\",\n  \"wallet.rewardDescription.reward\": \"獎勵倍數\",\n  \"wallet.rewardDescription.title\": \"獎勵描述\",\n  \"wallet.rewardDescription.total\": \"每日獎池\",\n  \"wallet.sidebar_title\": \"Power\",\n  \"wallet.transactions.amount\": \"額度\",\n  \"wallet.transactions.date\": \"日期\",\n  \"wallet.transactions.empty.description\": \"當打賞、購買、提領與空投等記錄發生後，會顯示在這裡。\",\n  \"wallet.transactions.empty.title\": \"尚無交易紀錄\",\n  \"wallet.transactions.from\": \"發送者\",\n  \"wallet.transactions.more\": \"通過區塊鏈瀏覽器查看更多交易…\",\n  \"wallet.transactions.noTransactions\": \"無交易紀錄\",\n  \"wallet.transactions.title\": \"交易紀錄\",\n  \"wallet.transactions.to\": \"To\",\n  \"wallet.transactions.tx\": \"Tx\",\n  \"wallet.transactions.type\": \"Type\",\n  \"wallet.transactions.types.airdrop\": \"Airdrop\",\n  \"wallet.transactions.types.all\": \"All\",\n  \"wallet.transactions.types.burn\": \"Burn\",\n  \"wallet.transactions.types.mint\": \"Mint\",\n  \"wallet.transactions.types.purchase\": \"Purchase\",\n  \"wallet.transactions.types.tip\": \"Tip\",\n  \"wallet.transactions.types.withdraw\": \"提領\",\n  \"wallet.transactions.you\": \"你\",\n  \"wallet.withdraw.addressLabel\": \"以太坊地址\",\n  \"wallet.withdraw.amountLabel\": \"額度\",\n  \"wallet.withdraw.availableBalance\": \"錢包中有 <Balance></Balance> Power 可提領。\",\n  \"wallet.withdraw.button\": \"提領\",\n  \"wallet.withdraw.error\": \"提領失敗：{{error}}\",\n  \"wallet.withdraw.modalTitle\": \"提領 Power\",\n  \"wallet.withdraw.receiveRSS3\": \"你將收到 {{amount}} RSS3\",\n  \"wallet.withdraw.submitButton\": \"送出\",\n  \"wallet.withdraw.success\": \"提領成功！\",\n  \"wallet.withdraw.toRss3Label\": \"提領為 RSS3\"\n}\n"
  },
  {
    "path": "locales/shortcuts/en.json",
    "content": "{\n  \"category.entry\": \"Entry\",\n  \"category.entry_render\": \"Entry Render\",\n  \"category.global\": \"Global\",\n  \"category.integration\": \"Integration\",\n  \"category.layout\": \"Layout\",\n  \"category.list\": \"List\",\n  \"category.settings\": \"Settings\",\n  \"category.subscription\": \"Subscription\",\n  \"category.timeline\": \"Timeline\",\n  \"command.entry.next_entry.description\": \"Switch to the next entry based on the current timeline.\",\n  \"command.entry.next_entry.title\": \"Next Entry\",\n  \"command.entry.previous_entry.description\": \"Switch to the previous entry based on the current timeline.\",\n  \"command.entry.previous_entry.title\": \"Previous Entry\",\n  \"command.entry.scroll_down.description\": \"Scroll down in the entry render.\",\n  \"command.entry.scroll_down.title\": \"Scroll Down\",\n  \"command.entry.scroll_up.description\": \"Scroll up in the entry render.\",\n  \"command.entry.scroll_up.title\": \"Scroll Up\",\n  \"command.global.quick_add.description\": \"Open quick add panel to follow a new feed or others.\",\n  \"command.global.quick_add.title\": \"Quick Add\",\n  \"command.global.quick_search.description\": \"Open the quick search panel.\",\n  \"command.global.quick_search.title\": \"Quick Search\",\n  \"command.global.show_shortcuts.description\": \"Show the shortcuts guideline modal.\",\n  \"command.global.show_shortcuts.title\": \"Show Shortcuts\",\n  \"command.global.toggle_ai_chat.description\": \"Toggle the AI chat panel.\",\n  \"command.global.toggle_ai_chat.title\": \"AI Chat\",\n  \"command.global.toggle_corner_play.description\": \"Play/Pause playing status(If there is currently playing audio in the background).\",\n  \"command.global.toggle_corner_play.title\": \"Toggle Corner Play\",\n  \"command.layout.focus_to_entry_render.title\": \"Focus to Entry Render\",\n  \"command.layout.focus_to_subscription.title\": \"Focus to Subscription\",\n  \"command.layout.focus_to_timeline.title\": \"Focus to Timeline\",\n  \"command.layout.toggle_subscription_column.description\": \"Show/Hide the subscription column in the layout.\",\n  \"command.layout.toggle_subscription_column.title\": \"Toggle Subscription Column\",\n  \"command.subscription.mark_all_as_read.title\": \"Mark All as Read\",\n  \"command.subscription.next_subscription.description\": \"Next Subscription\",\n  \"command.subscription.next_subscription.title\": \"Next Subscription\",\n  \"command.subscription.open_in_browser.title\": \"Open in Browser\",\n  \"command.subscription.open_in_tab.title\": \"Open in Tab\",\n  \"command.subscription.open_site_in_browser.title\": \"Open Site in Browser\",\n  \"command.subscription.open_site_in_tab.title\": \"Open Site in Tab\",\n  \"command.subscription.previous_subscription.description\": \"Previous Subscription\",\n  \"command.subscription.previous_subscription.title\": \"Previous Subscription\",\n  \"command.subscription.switch_between_views.title\": \"Switch Between Views\",\n  \"command.subscription.switch_next_view.title\": \"Switch to Next View\",\n  \"command.subscription.switch_previous_view.title\": \"Switch to Previous View\",\n  \"command.subscription.switch_tab_to_article.description\": \"Switch to the article tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_article.title\": \"Switch to Article Tab\",\n  \"command.subscription.switch_tab_to_audio.description\": \"Switch to the audio tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_audio.title\": \"Switch to Audio Tab\",\n  \"command.subscription.switch_tab_to_next.description\": \"Switch to the next tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_next.title\": \"Switch to Next Tab\",\n  \"command.subscription.switch_tab_to_notification.description\": \"Switch to the notification tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_notification.title\": \"Switch to Notification Tab\",\n  \"command.subscription.switch_tab_to_picture.description\": \"Switch to the picture tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_picture.title\": \"Switch to Picture Tab\",\n  \"command.subscription.switch_tab_to_previous.description\": \"Switch to the previous tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_previous.title\": \"Switch to Previous Tab\",\n  \"command.subscription.switch_tab_to_social.description\": \"Switch to the social tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_social.title\": \"Switch to Social Tab\",\n  \"command.subscription.switch_tab_to_video.description\": \"Switch to the video tab in the subscription view.\",\n  \"command.subscription.switch_tab_to_video.title\": \"Switch to Video Tab\",\n  \"command.subscription.toggle_folder_collapse.description\": \"Expand/Collapse the current selected folder in the Subscription view.\",\n  \"command.subscription.toggle_folder_collapse.title\": \"Toggle Folder Collapse\",\n  \"command.timeline.refetch.description\": \"Refetch the current timeline.\",\n  \"command.timeline.refetch.title\": \"Refetch\",\n  \"command.timeline.switch_to_next.description\": \"Switch to the next timeline item.\",\n  \"command.timeline.switch_to_next.title\": \"Switch to Next Timeline\",\n  \"command.timeline.switch_to_previous.description\": \"Switch to the previous timeline item.\",\n  \"command.timeline.switch_to_previous.title\": \"Switch to Previous Timeline\",\n  \"command.timeline.toggle_unread_only.description\": \"Enable/Disable the unread only mode in the timeline.\",\n  \"command.timeline.toggle_unread_only.title\": \"Toggle Unread Only\",\n  \"settings.shortcuts.conflict\": \"Shortcut Conflict\",\n  \"settings.shortcuts.conflict_command\": \"This shortcut conflicts with command:\",\n  \"settings.shortcuts.custom\": \"Custom\",\n  \"settings.shortcuts.custom_content\": \"This shortcut is customized by you\",\n  \"settings.shortcuts.description\": \"Customize the application's shortcuts. Below is a list of some commands that can be customized.\",\n  \"settings.shortcuts.press_to_record\": \"Press keys to record\",\n  \"settings.shortcuts.reset\": \"Reset\",\n  \"settings.shortcuts.undo\": \"Undo\"\n}\n"
  },
  {
    "path": "locales/shortcuts/fr-FR.json",
    "content": "{\n  \"category.entry\": \"Entrée\",\n  \"category.entry_render\": \"Rendu de l'entrée\",\n  \"category.global\": \"Global\",\n  \"category.integration\": \"Intégration\",\n  \"category.layout\": \"Mise en page\",\n  \"category.list\": \"Liste\",\n  \"category.settings\": \"Paramètres\",\n  \"category.subscription\": \"Abonnement\",\n  \"category.timeline\": \"Chronologie\",\n  \"command.entry.next_entry.description\": \"Passer à l'entrée suivante selon la chronologie actuelle.\",\n  \"command.entry.next_entry.title\": \"Entrée suivante\",\n  \"command.entry.previous_entry.description\": \"Passer à l'entrée précédente selon la chronologie actuelle.\",\n  \"command.entry.previous_entry.title\": \"Entrée précédente\",\n  \"command.entry.scroll_down.description\": \"Faire défiler vers le bas dans le rendu de l'entrée.\",\n  \"command.entry.scroll_down.title\": \"Défiler vers le bas\",\n  \"command.entry.scroll_up.description\": \"Faire défiler vers le haut dans le rendu de l'entrée.\",\n  \"command.entry.scroll_up.title\": \"Défiler vers le haut\",\n  \"command.global.quick_add.description\": \"Ouvrir le panneau d'ajout rapide pour suivre un nouveau flux ou autre.\",\n  \"command.global.quick_add.title\": \"Ajout rapide\",\n  \"command.global.quick_search.description\": \"Ouvrir le panneau de recherche rapide.\",\n  \"command.global.quick_search.title\": \"Recherche rapide\",\n  \"command.global.show_shortcuts.description\": \"Afficher le guide des raccourcis.\",\n  \"command.global.show_shortcuts.title\": \"Afficher les raccourcis\",\n  \"command.global.toggle_ai_chat.description\": \"Basculer le panneau de discussion IA.\",\n  \"command.global.toggle_ai_chat.title\": \"Discussion IA\",\n  \"command.global.toggle_corner_play.description\": \"Lecture/Pause (Si de l'audio est en cours de lecture en arrière-plan).\",\n  \"command.global.toggle_corner_play.title\": \"Basculer la lecture en coin\",\n  \"command.layout.focus_to_entry_render.title\": \"Focus sur le rendu de l'entrée\",\n  \"command.layout.focus_to_subscription.title\": \"Focus sur l'abonnement\",\n  \"command.layout.focus_to_timeline.title\": \"Focus sur la chronologie\",\n  \"command.layout.toggle_subscription_column.description\": \"Afficher/Masquer la colonne d'abonnement dans la mise en page.\",\n  \"command.layout.toggle_subscription_column.title\": \"Basculer la colonne d'abonnement\",\n  \"command.subscription.mark_all_as_read.title\": \"Tout marquer comme lu\",\n  \"command.subscription.next_subscription.description\": \"Abonnement suivant\",\n  \"command.subscription.next_subscription.title\": \"Abonnement suivant\",\n  \"command.subscription.open_in_browser.title\": \"Ouvrir dans le navigateur\",\n  \"command.subscription.open_in_tab.title\": \"Ouvrir dans un onglet\",\n  \"command.subscription.open_site_in_browser.title\": \"Ouvrir le site dans le navigateur\",\n  \"command.subscription.open_site_in_tab.title\": \"Ouvrir le site dans un onglet\",\n  \"command.subscription.previous_subscription.description\": \"Abonnement précédent\",\n  \"command.subscription.previous_subscription.title\": \"Abonnement précédent\",\n  \"command.subscription.switch_between_views.title\": \"Basculer entre les vues\",\n  \"command.subscription.switch_next_view.title\": \"Passer à la vue suivante\",\n  \"command.subscription.switch_previous_view.title\": \"Passer à la vue précédente\",\n  \"command.subscription.switch_tab_to_article.description\": \"Passer à l'onglet article dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_article.title\": \"Passer à l'onglet Article\",\n  \"command.subscription.switch_tab_to_audio.description\": \"Passer à l'onglet audio dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_audio.title\": \"Passer à l'onglet Audio\",\n  \"command.subscription.switch_tab_to_next.description\": \"Passer à l'onglet suivant dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_next.title\": \"Passer à l'onglet Suivant\",\n  \"command.subscription.switch_tab_to_notification.description\": \"Passer à l'onglet notification dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_notification.title\": \"Passer à l'onglet Notification\",\n  \"command.subscription.switch_tab_to_picture.description\": \"Passer à l'onglet image dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_picture.title\": \"Passer à l'onglet Image\",\n  \"command.subscription.switch_tab_to_previous.description\": \"Passer à l'onglet précédent dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_previous.title\": \"Passer à l'onglet Précédent\",\n  \"command.subscription.switch_tab_to_social.description\": \"Passer à l'onglet social dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_social.title\": \"Passer à l'onglet Social\",\n  \"command.subscription.switch_tab_to_video.description\": \"Passer à l'onglet vidéo dans la vue d'abonnement.\",\n  \"command.subscription.switch_tab_to_video.title\": \"Passer à l'onglet Vidéo\",\n  \"command.subscription.toggle_folder_collapse.description\": \"Développer/Réduire le dossier sélectionné dans la vue d'abonnement.\",\n  \"command.subscription.toggle_folder_collapse.title\": \"Basculer le dossier\",\n  \"command.timeline.refetch.description\": \"Rafraîchir la chronologie actuelle.\",\n  \"command.timeline.refetch.title\": \"Rafraîchir\",\n  \"command.timeline.switch_to_next.description\": \"Passer à l'élément suivant de la chronologie.\",\n  \"command.timeline.switch_to_next.title\": \"Passer à la chronologie suivante\",\n  \"command.timeline.switch_to_previous.description\": \"Passer à l'élément précédent de la chronologie.\",\n  \"command.timeline.switch_to_previous.title\": \"Passer à la chronologie précédente\",\n  \"command.timeline.toggle_unread_only.description\": \"Activer/Désactiver le mode non lu uniquement dans la chronologie.\",\n  \"command.timeline.toggle_unread_only.title\": \"Basculer Non lu uniquement\",\n  \"settings.shortcuts.conflict\": \"Conflit de raccourci\",\n  \"settings.shortcuts.conflict_command\": \"Ce raccourci entre en conflit avec la commande :\",\n  \"settings.shortcuts.custom\": \"Personnalisé\",\n  \"settings.shortcuts.custom_content\": \"Ce raccourci est personnalisé par vous\",\n  \"settings.shortcuts.description\": \"Personnalisez les raccourcis de l'application. Vous trouverez ci-dessous une liste de commandes personnalisables.\",\n  \"settings.shortcuts.press_to_record\": \"Appuyez sur les touches pour enregistrer\",\n  \"settings.shortcuts.reset\": \"Réinitialiser\",\n  \"settings.shortcuts.undo\": \"Annuler\"\n}\n"
  },
  {
    "path": "locales/shortcuts/ja.json",
    "content": "{\n  \"category.entry\": \"エントリー\",\n  \"category.entry_render\": \"エントリー レンダリング\",\n  \"category.global\": \"グローバル\",\n  \"category.integration\": \"統合\",\n  \"category.layout\": \"レイアウト\",\n  \"category.list\": \"リスト\",\n  \"category.settings\": \"設定\",\n  \"category.subscription\": \"サブスクリプション\",\n  \"category.timeline\": \"タイムライン\",\n  \"command.entry.next_entry.description\": \"現在のタイムラインに基づいて次のエントリーに切り替えます。\",\n  \"command.entry.next_entry.title\": \"次のエントリー\",\n  \"command.entry.previous_entry.description\": \"現在のタイムラインに基づいて前のエントリーに切り替えます。\",\n  \"command.entry.previous_entry.title\": \"前のエントリー\",\n  \"command.entry.scroll_down.description\": \"エントリー レンダリング内を下にスクロールします\",\n  \"command.entry.scroll_down.title\": \"下にスクロール\",\n  \"command.entry.scroll_up.description\": \"エントリー レンダリング内を上にスクロールします\",\n  \"command.entry.scroll_up.title\": \"上にスクロール\",\n  \"command.global.quick_add.description\": \"新しいフィードやその他をフォローするためのクイック追加パネルを開きます。\",\n  \"command.global.quick_add.title\": \"クイック追加\",\n  \"command.global.quick_search.description\": \"クイック検索パネルを開きます。\",\n  \"command.global.quick_search.title\": \"クイック検索\",\n  \"command.global.show_shortcuts.description\": \"ショートカットガイドラインモーダルを表示します\",\n  \"command.global.show_shortcuts.title\": \"ショートカットを表示\",\n  \"command.global.toggle_ai_chat.description\": \"AI チャットパネルを切り替えます\",\n  \"command.global.toggle_ai_chat.title\": \"AI チャット\",\n  \"command.global.toggle_corner_play.description\": \"再生中のオーディオがある場合、再生/一時停止の状態を切り替えます。\",\n  \"command.global.toggle_corner_play.title\": \"コーナー再生の切り替え\",\n  \"command.layout.focus_to_entry_render.title\": \"エントリー レンダリングにフォーカス\",\n  \"command.layout.focus_to_subscription.title\": \"サブスクリプションにフォーカス\",\n  \"command.layout.focus_to_timeline.title\": \"タイムラインにフォーカス\",\n  \"command.layout.toggle_subscription_column.description\": \"レイアウト内のサブスクリプション列を表示/非表示にします。\",\n  \"command.layout.toggle_subscription_column.title\": \"サブスクリプション列の切り替え\",\n  \"command.subscription.mark_all_as_read.title\": \"すべてを既読にマーク\",\n  \"command.subscription.next_subscription.description\": \"次のサブスクリプション\",\n  \"command.subscription.next_subscription.title\": \"次のサブスクリプション\",\n  \"command.subscription.open_in_browser.title\": \"ブラウザで開く\",\n  \"command.subscription.open_in_tab.title\": \"タブで開く\",\n  \"command.subscription.open_site_in_browser.title\": \"ブラウザでサイトを開く\",\n  \"command.subscription.open_site_in_tab.title\": \"タブでサイトを開く\",\n  \"command.subscription.previous_subscription.description\": \"前のサブスクリプション\",\n  \"command.subscription.previous_subscription.title\": \"前のサブスクリプション\",\n  \"command.subscription.switch_between_views.title\": \"ビュー間の切り替え\",\n  \"command.subscription.switch_next_view.title\": \"次のビューに切り替え\",\n  \"command.subscription.switch_previous_view.title\": \"前のビューに切り替え\",\n  \"command.subscription.switch_tab_to_article.description\": \"サブスクリプションビューのアーティクルタブに切り替え。\",\n  \"command.subscription.switch_tab_to_article.title\": \"アーティクルタブに切り替え\",\n  \"command.subscription.switch_tab_to_audio.description\": \"サブスクリプションビューのオーディオタブに切り替え。\",\n  \"command.subscription.switch_tab_to_audio.title\": \"オーディオタブに切り替え\",\n  \"command.subscription.switch_tab_to_next.description\": \"サブスクリプションビューの次のタブに切り替え\",\n  \"command.subscription.switch_tab_to_next.title\": \"次のタブに切り替え\",\n  \"command.subscription.switch_tab_to_notification.description\": \"サブスクリプションビューの通知タブに切り替え\",\n  \"command.subscription.switch_tab_to_notification.title\": \"通知タブに切り替え\",\n  \"command.subscription.switch_tab_to_picture.description\": \"サブスクリプションビューの画像タブに切り替え\",\n  \"command.subscription.switch_tab_to_picture.title\": \"画像タブに切り替え\",\n  \"command.subscription.switch_tab_to_previous.description\": \"サブスクリプションビューの前のタブに切り替え\",\n  \"command.subscription.switch_tab_to_previous.title\": \"前のタブに切り替え\",\n  \"command.subscription.switch_tab_to_social.description\": \"サブスクリプションビューのソーシャルタブに切り替え。\",\n  \"command.subscription.switch_tab_to_social.title\": \"ソーシャルタブに切り替え\",\n  \"command.subscription.switch_tab_to_video.description\": \"サブスクリプションビューのビデオタブに切り替え\",\n  \"command.subscription.switch_tab_to_video.title\": \"ビデオタブに切り替え\",\n  \"command.subscription.toggle_folder_collapse.description\": \"サブスクリプションビューで現在選択されているフォルダーを展開/折りたたみます。\",\n  \"command.subscription.toggle_folder_collapse.title\": \"フォルダーの折りたたみを切り替え\",\n  \"command.timeline.refetch.description\": \"現在のタイムラインを再取得\",\n  \"command.timeline.refetch.title\": \"再取得\",\n  \"command.timeline.switch_to_next.description\": \"次のタイムラインアイテムに切り替え\",\n  \"command.timeline.switch_to_next.title\": \"次のタイムラインに切り替え\",\n  \"command.timeline.switch_to_previous.description\": \"前のタイムラインアイテムに切り替え\",\n  \"command.timeline.switch_to_previous.title\": \"前のタイムラインに切り替え\",\n  \"command.timeline.toggle_unread_only.description\": \"タイムライン内の未読のみモードを有効/無効にします。\",\n  \"command.timeline.toggle_unread_only.title\": \"未読のみの切り替え\",\n  \"settings.shortcuts.conflict\": \"ショートカットの競合\",\n  \"settings.shortcuts.conflict_command\": \"このショートカットは次のコマンドと競合しています:\",\n  \"settings.shortcuts.custom\": \"カスタム\",\n  \"settings.shortcuts.custom_content\": \"このショートカットはあなたによってカスタマイズされています\",\n  \"settings.shortcuts.description\": \"アプリケーションのショートカットをカスタマイズします。以下はカスタマイズ可能なコマンドのリストです。\",\n  \"settings.shortcuts.press_to_record\": \"記録するにはキーを押してください\",\n  \"settings.shortcuts.reset\": \"リセット\",\n  \"settings.shortcuts.undo\": \"元に戻す\"\n}\n"
  },
  {
    "path": "locales/shortcuts/zh-CN.json",
    "content": "{\n  \"category.entry\": \"条目\",\n  \"category.entry_render\": \"条目内容\",\n  \"category.global\": \"全局\",\n  \"category.integration\": \"集成\",\n  \"category.layout\": \"布局\",\n  \"category.list\": \"列表\",\n  \"category.settings\": \"设置\",\n  \"category.subscription\": \"订阅\",\n  \"category.timeline\": \"时间线\",\n  \"command.entry.next_entry.description\": \"根据当前时间线切换到下一个条目。\",\n  \"command.entry.next_entry.title\": \"下一个条目\",\n  \"command.entry.previous_entry.description\": \"根据当前时间线切换到上一个条目。\",\n  \"command.entry.previous_entry.title\": \"上一个条目\",\n  \"command.entry.scroll_down.description\": \"在条目内容中向下滚动。\",\n  \"command.entry.scroll_down.title\": \"向下滚动\",\n  \"command.entry.scroll_up.description\": \"在条目内容中向上滚动。\",\n  \"command.entry.scroll_up.title\": \"向上滚动\",\n  \"command.global.quick_add.description\": \"打开快速添加面板以订阅新的订阅源或其他内容。\",\n  \"command.global.quick_add.title\": \"快速添加\",\n  \"command.global.quick_search.description\": \"打开快速搜索面板。\",\n  \"command.global.quick_search.title\": \"快速搜索\",\n  \"command.global.show_shortcuts.description\": \"显示快捷键指南。\",\n  \"command.global.show_shortcuts.title\": \"显示快捷键\",\n  \"command.global.toggle_ai_chat.description\": \"切换 AI 聊天面板。\",\n  \"command.global.toggle_ai_chat.title\": \"AI 聊天\",\n  \"command.global.toggle_corner_play.description\": \"播放或暂停播放状态（如果当前有音频在后台播放）。\",\n  \"command.global.toggle_corner_play.title\": \"切换边角播放\",\n  \"command.layout.focus_to_entry_render.title\": \"聚焦到条目内容\",\n  \"command.layout.focus_to_subscription.title\": \"聚焦到订阅\",\n  \"command.layout.focus_to_timeline.title\": \"聚焦到时间线\",\n  \"command.layout.toggle_subscription_column.description\": \"在布局中显示或隐藏订阅栏。\",\n  \"command.layout.toggle_subscription_column.title\": \"切换订阅栏\",\n  \"command.subscription.mark_all_as_read.title\": \"全部标记为已读\",\n  \"command.subscription.next_subscription.description\": \"切换到下一个订阅。\",\n  \"command.subscription.next_subscription.title\": \"下一个订阅\",\n  \"command.subscription.open_in_browser.title\": \"在浏览器中打开\",\n  \"command.subscription.open_in_tab.title\": \"在标签页中打开\",\n  \"command.subscription.open_site_in_browser.title\": \"在浏览器中打开网站\",\n  \"command.subscription.open_site_in_tab.title\": \"在标签页中打开网站\",\n  \"command.subscription.previous_subscription.description\": \"切换到上一个订阅。\",\n  \"command.subscription.previous_subscription.title\": \"上一个订阅\",\n  \"command.subscription.switch_between_views.title\": \"在视图之间切换\",\n  \"command.subscription.switch_next_view.title\": \"切换到下一个视图\",\n  \"command.subscription.switch_previous_view.title\": \"切换到上一个视图\",\n  \"command.subscription.switch_tab_to_article.description\": \"在订阅视图中切换到文章标签页。\",\n  \"command.subscription.switch_tab_to_article.title\": \"切换到文章标签页\",\n  \"command.subscription.switch_tab_to_audio.description\": \"在订阅视图中切换到音频标签页。\",\n  \"command.subscription.switch_tab_to_audio.title\": \"切换到音频标签页\",\n  \"command.subscription.switch_tab_to_next.description\": \"在订阅视图中切换到下一个标签页。\",\n  \"command.subscription.switch_tab_to_next.title\": \"切换到下一个标签页\",\n  \"command.subscription.switch_tab_to_notification.description\": \"在订阅视图中切换到通知标签页。\",\n  \"command.subscription.switch_tab_to_notification.title\": \"切换到通知标签页\",\n  \"command.subscription.switch_tab_to_picture.description\": \"在订阅视图中切换到图片标签页。\",\n  \"command.subscription.switch_tab_to_picture.title\": \"切换到图片标签页\",\n  \"command.subscription.switch_tab_to_previous.description\": \"在订阅视图中切换到上一个标签页。\",\n  \"command.subscription.switch_tab_to_previous.title\": \"切换到上一个标签页\",\n  \"command.subscription.switch_tab_to_social.description\": \"在订阅视图中切换到社交媒体标签页。\",\n  \"command.subscription.switch_tab_to_social.title\": \"切换到社交媒体标签页\",\n  \"command.subscription.switch_tab_to_video.description\": \"在订阅视图中切换到视频标签页。\",\n  \"command.subscription.switch_tab_to_video.title\": \"切换到视频标签页\",\n  \"command.subscription.toggle_folder_collapse.description\": \"在订阅视图中展开或折叠当前选定的文件夹。\",\n  \"command.subscription.toggle_folder_collapse.title\": \"切换文件夹折叠\",\n  \"command.timeline.refetch.description\": \"刷新当前时间线。\",\n  \"command.timeline.refetch.title\": \"刷新\",\n  \"command.timeline.switch_to_next.description\": \"切换到下一个时间线项目。\",\n  \"command.timeline.switch_to_next.title\": \"切换到下一个时间线\",\n  \"command.timeline.switch_to_previous.description\": \"切换到上一个时间线项目。\",\n  \"command.timeline.switch_to_previous.title\": \"切换到上一个时间线\",\n  \"command.timeline.toggle_unread_only.description\": \"在时间线中启用或停用仅显示未读模式。\",\n  \"command.timeline.toggle_unread_only.title\": \"切换仅显示未读\",\n  \"settings.shortcuts.conflict\": \"快捷键冲突\",\n  \"settings.shortcuts.conflict_command\": \"此快捷键与以下命令冲突：\",\n  \"settings.shortcuts.custom\": \"自定义\",\n  \"settings.shortcuts.custom_content\": \"此快捷键由您自定义\",\n  \"settings.shortcuts.description\": \"自定义应用的快捷键。以下是一些可以自定义的命令列表。\",\n  \"settings.shortcuts.press_to_record\": \"按下以记录\",\n  \"settings.shortcuts.reset\": \"重置\",\n  \"settings.shortcuts.undo\": \"撤销\"\n}\n"
  },
  {
    "path": "locales/shortcuts/zh-TW.json",
    "content": "{\n  \"category.entry\": \"條目\",\n  \"category.entry_render\": \"條目內容\",\n  \"category.global\": \"全域\",\n  \"category.integration\": \"整合\",\n  \"category.layout\": \"版面配置\",\n  \"category.list\": \"列表\",\n  \"category.settings\": \"設定\",\n  \"category.subscription\": \"訂閱\",\n  \"category.timeline\": \"時間軸\",\n  \"command.entry.next_entry.description\": \"根據目前時間軸切換到下一個條目。\",\n  \"command.entry.next_entry.title\": \"下一個條目\",\n  \"command.entry.previous_entry.description\": \"根據目前時間軸切換到上一個條目。\",\n  \"command.entry.previous_entry.title\": \"上一個條目\",\n  \"command.entry.scroll_down.description\": \"在條目內容中向下捲動。\",\n  \"command.entry.scroll_down.title\": \"向下捲動\",\n  \"command.entry.scroll_up.description\": \"在條目內容中向上捲動。\",\n  \"command.entry.scroll_up.title\": \"向上捲動\",\n  \"command.global.quick_add.description\": \"開啟快速新增面板以訂閱新的 RSS 摘要或其他內容。\",\n  \"command.global.quick_add.title\": \"快速新增\",\n  \"command.global.quick_search.description\": \"開啟快速搜尋面板。\",\n  \"command.global.quick_search.title\": \"快速搜尋\",\n  \"command.global.show_shortcuts.description\": \"顯示快捷鍵指南。\",\n  \"command.global.show_shortcuts.title\": \"顯示快捷鍵\",\n  \"command.global.toggle_ai_chat.description\": \"切換 AI 聊天面板。\",\n  \"command.global.toggle_ai_chat.title\": \"AI 聊天\",\n  \"command.global.toggle_corner_play.description\": \"播放或暫停播放狀態（若目前有音訊在背景播放）。\",\n  \"command.global.toggle_corner_play.title\": \"切換角落播放\",\n  \"command.layout.focus_to_entry_render.title\": \"聚焦到條目內容\",\n  \"command.layout.focus_to_subscription.title\": \"聚焦到訂閱\",\n  \"command.layout.focus_to_timeline.title\": \"聚焦到時間軸\",\n  \"command.layout.toggle_subscription_column.description\": \"在版面配置中顯示或隱藏訂閱欄。\",\n  \"command.layout.toggle_subscription_column.title\": \"切換訂閱欄\",\n  \"command.subscription.mark_all_as_read.title\": \"全部標記為已讀\",\n  \"command.subscription.next_subscription.description\": \"切換到下一個訂閱。\",\n  \"command.subscription.next_subscription.title\": \"下一個訂閱\",\n  \"command.subscription.open_in_browser.title\": \"在瀏覽器中開啟\",\n  \"command.subscription.open_in_tab.title\": \"在分頁中開啟\",\n  \"command.subscription.open_site_in_browser.title\": \"在瀏覽器中開啟網站\",\n  \"command.subscription.open_site_in_tab.title\": \"在分頁中開啟網站\",\n  \"command.subscription.previous_subscription.description\": \"切換到上一個訂閱。\",\n  \"command.subscription.previous_subscription.title\": \"上一個訂閱\",\n  \"command.subscription.switch_between_views.title\": \"在視圖之間切換\",\n  \"command.subscription.switch_next_view.title\": \"切換到下一個視圖\",\n  \"command.subscription.switch_previous_view.title\": \"切換到上一個視圖\",\n  \"command.subscription.switch_tab_to_article.description\": \"在訂閱視圖中切換到文章分頁。\",\n  \"command.subscription.switch_tab_to_article.title\": \"切換到文章分頁\",\n  \"command.subscription.switch_tab_to_audio.description\": \"在訂閱視圖中切換到音訊分頁。\",\n  \"command.subscription.switch_tab_to_audio.title\": \"切換到音訊分頁\",\n  \"command.subscription.switch_tab_to_next.description\": \"在訂閱視圖中切換到下一個分頁。\",\n  \"command.subscription.switch_tab_to_next.title\": \"切換到下一個分頁\",\n  \"command.subscription.switch_tab_to_notification.description\": \"在訂閱視圖中切換到通知分頁。\",\n  \"command.subscription.switch_tab_to_notification.title\": \"切換到通知分頁\",\n  \"command.subscription.switch_tab_to_picture.description\": \"在訂閱視圖中切換到圖片分頁。\",\n  \"command.subscription.switch_tab_to_picture.title\": \"切換到圖片分頁\",\n  \"command.subscription.switch_tab_to_previous.description\": \"在訂閱視圖中切換到上一個分頁。\",\n  \"command.subscription.switch_tab_to_previous.title\": \"切換到上一個分頁\",\n  \"command.subscription.switch_tab_to_social.description\": \"在訂閱視圖中切換到社群媒體分頁。\",\n  \"command.subscription.switch_tab_to_social.title\": \"切換到社群媒體分頁\",\n  \"command.subscription.switch_tab_to_video.description\": \"在訂閱視圖中切換到影片分頁。\",\n  \"command.subscription.switch_tab_to_video.title\": \"切換到影片分頁\",\n  \"command.subscription.toggle_folder_collapse.description\": \"在訂閱視圖中展開或收合目前選取的資料夾。\",\n  \"command.subscription.toggle_folder_collapse.title\": \"切換資料夾收合\",\n  \"command.timeline.refetch.description\": \"重新整理目前時間軸。\",\n  \"command.timeline.refetch.title\": \"重新整理\",\n  \"command.timeline.switch_to_next.description\": \"切換到下一個時間軸項目。\",\n  \"command.timeline.switch_to_next.title\": \"切換到下一個時間軸\",\n  \"command.timeline.switch_to_previous.description\": \"切換到上一個時間軸項目。\",\n  \"command.timeline.switch_to_previous.title\": \"切換到上一個時間軸\",\n  \"command.timeline.toggle_unread_only.description\": \"在時間軸中啟用或停用僅顯示未讀模式。\",\n  \"command.timeline.toggle_unread_only.title\": \"切換僅顯示未讀\",\n  \"settings.shortcuts.conflict\": \"快捷鍵衝突\",\n  \"settings.shortcuts.conflict_command\": \"此快捷鍵與以下指令衝突：\",\n  \"settings.shortcuts.custom\": \"自訂\",\n  \"settings.shortcuts.custom_content\": \"此快捷鍵由您自訂\",\n  \"settings.shortcuts.description\": \"自訂應用程式的快捷鍵。以下是一些可以自訂的指令清單。\",\n  \"settings.shortcuts.press_to_record\": \"按下以記錄\",\n  \"settings.shortcuts.reset\": \"重設\",\n  \"settings.shortcuts.undo\": \"復原\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@follow/monorepo\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@10.17.0\",\n  \"description\": \"Follow everything in one place\",\n  \"author\": \"Folo Team\",\n  \"license\": \"AGPL-3.0-only\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/follow\",\n    \"type\": \"git\"\n  },\n  \"scripts\": {\n    \"build:packages\": \"turbo run build --filter=\\\"./packages/**/*\\\"\",\n    \"build:web\": \"turbo run Folo#build:web\",\n    \"dedupe:locales\": \"eslint --fix locales/**\",\n    \"depcheck\": \"npx depcheck --quiet\",\n    \"dev:web\": \"turbo run @follow/web#dev @follow/ssr#dev\",\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier --check .\",\n    \"icons:sync\": \"tsx scripts/svg-to-rn.ts && prettier --write apps/mobile/src/icons/**/*.tsx && eslint --fix apps/mobile/src/icons/**/*.tsx\",\n    \"icons:update\": \"tsx scripts/update-icon.ts\",\n    \"lint\": \"pnpm run lint:tsl && eslint\",\n    \"lint:fix\": \"eslint --fix\",\n    \"lint:tsl\": \"tsslint --project apps/*/tsconfig.json\",\n    \"mitproxy\": \"bash scripts/run-proxy.sh\",\n    \"polyfill-optimize\": \"pnpx nolyfill install\",\n    \"postinstall\": \"pnpm run build:packages\",\n    \"prepare\": \"simple-git-hooks && corepack prepare\",\n    \"reinstall\": \"rm -rf node_modules && rm -rf apps/**/node_modules && rm -rf packages/**/node_modules && pnpm install\",\n    \"test\": \"cross-env CI=1 pnpm --recursive run test\",\n    \"typecheck\": \"turbo typecheck\"\n  },\n  \"devDependencies\": {\n    \"@babel/generator\": \"7.29.1\",\n    \"@babel/parser\": \"7.29.0\",\n    \"@babel/traverse\": \"7.29.0\",\n    \"@babel/types\": \"7.29.0\",\n    \"@eslint/compat\": \"1.4.1\",\n    \"@tsslint/cli\": \"2.0.7\",\n    \"@tsslint/config\": \"2.0.7\",\n    \"@tsslint/eslint\": \"2.0.7\",\n    \"@types/node\": \"25.2.3\",\n    \"@types/react\": \"19.1.17\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"@vercel/node\": \"5.6.3\",\n    \"cross-env\": \"10.1.0\",\n    \"eslint\": \"9.39.1\",\n    \"eslint-config-hyoban\": \"4.0.10\",\n    \"eslint-plugin-react-native\": \"5.0.0\",\n    \"fast-glob\": \"3.3.3\",\n    \"glob\": \"11.0.3\",\n    \"lint-staged\": \"16.2.7\",\n    \"prettier\": \"3.8.1\",\n    \"prettier-plugin-tailwindcss\": \"0.7.2\",\n    \"raw-body\": \"3.0.2\",\n    \"react\": \"19.0.0\",\n    \"react-dom\": \"19.0.0\",\n    \"rimraf\": \"6.1.2\",\n    \"serialize-error\": \"2.1.0\",\n    \"simple-git-hooks\": \"2.13.1\",\n    \"svg-parser\": \"2.0.4\",\n    \"tar\": \"7.5.7\",\n    \"tsx\": \"4.21.0\",\n    \"turbo\": \"2.8.7\",\n    \"typescript\": \"catalog:\",\n    \"vite\": \"7.3.1\",\n    \"vitest\": \"3.2.4\"\n  },\n  \"simple-git-hooks\": {\n    \"pre-commit\": \"pnpm exec lint-staged\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"eslint --fix\",\n      \"prettier --ignore-unknown --write\"\n    ],\n    \"apps/mobile/src/**/*\": [\n      \"bash scripts/increment-build-id.sh\"\n    ],\n    \"locales/**/*.json\": [\n      \"npm run dedupe:locales\",\n      \"git add locales\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/configs/package.json",
    "content": "{\n  \"name\": \"@follow/configs\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"exports\": {\n    \"./tailwindcss/web\": \"./tailwindcss/web.ts\",\n    \"./tsconfig.extend.json\": \"./tsconfig.extend.json\"\n  },\n  \"peerDependencies\": {\n    \"tailwindcss\": \">=3 || <4\"\n  },\n  \"dependencies\": {\n    \"@egoist/tailwindcss-icons\": \"1.9.2\",\n    \"@iconify-json/logos\": \"1.2.10\",\n    \"@iconify-json/mingcute\": \"1.2.7\",\n    \"@iconify-json/simple-icons\": \"1.2.70\",\n    \"@iconify/tools\": \"4.1.4\",\n    \"@iconify/utils\": \"3.1.0\",\n    \"@tailwindcss/container-queries\": \"0.1.1\",\n    \"@tailwindcss/typography\": \"0.5.19\",\n    \"es-toolkit\": \"1.44.0\",\n    \"tailwindcss-animate\": \"1.0.7\",\n    \"tailwindcss-motion\": \"1.1.1\",\n    \"tailwindcss-multi\": \"0.4.6\",\n    \"tailwindcss-safe-area\": \"0.8.0\",\n    \"tailwindcss-uikit-colors\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"postcss\": \"8.5.6\",\n    \"postcss-js\": \"5.0.3\",\n    \"workspace-root\": \"3.3.1\"\n  }\n}\n"
  },
  {
    "path": "packages/configs/tailwindcss/ratio-mixing-plugin.js",
    "content": "const plugin = require(\"tailwindcss/plugin\")\n\nconst defaultConfig = {\n  colorSpace: \"srgb\",\n  baseColors: {\n    background: \"hsl(var(--background))\",\n    accent: \"hsl(var(--fo-a))\",\n    red: \"rgb(var(--color-red))\",\n    green: \"rgb(var(--color-green))\",\n    blue: \"rgb(var(--color-blue))\",\n    purple: \"rgb(var(--color-purple))\",\n    yellow: \"rgb(var(--color-yellow))\",\n    orange: \"rgb(var(--color-orange))\",\n    gray: \"rgb(var(--color-gray))\",\n    pink: \"rgb(var(--color-pink))\",\n\n    transparent: \"transparent\",\n    // Map to existing theme colors from UIKit\n  },\n  variants: [\"bg\", \"border\", \"text\"],\n  prefix: \"mix\",\n  implicitBackground: \"background\",\n}\n\nconst ratioMixingPlugin = plugin.withOptions(\n  (options = {}) => {\n    return ({ addUtilities }) => {\n      const config = { ...defaultConfig, ...options }\n      const utilities = {}\n\n      // Generate dynamic ratio-based utilities\n      generateDynamicRatioUtilities(addUtilities, config)\n\n      // Generate percentage-based utilities (fallback)\n      generatePercentageBasedUtilities(utilities, config)\n\n      addUtilities(utilities)\n    }\n  },\n  () => {\n    return {\n      theme: {\n        // Theme extensions if needed\n      },\n    }\n  },\n)\n\nfunction generateDynamicRatioUtilities(addUtilities, config) {\n  const { baseColors, variants, colorSpace } = config\n  const utilities = {}\n\n  // Generate dynamic utilities that parse ratios from class names\n  // Pattern: bg-mix-accent/background-7/3 or bg-mix-accent/background-1.5/2\n  Object.entries(baseColors).forEach(([color1Name, color1Value]) => {\n    Object.entries(baseColors).forEach(([color2Name, color2Value]) => {\n      if (color1Name === color2Name) return // Skip same color mixing\n\n      variants.forEach((variant) => {\n        const property = getPropertyName(variant)\n\n        // Generate a utility that can accept arbitrary ratio values\n        const classPattern = `.${variant}-${config.prefix}-${color1Name}\\\\/${color2Name}-([0-9.]+)\\\\/([0-9.]+)`\n\n        utilities[classPattern] = (match) => {\n          const num = Number.parseFloat(match[1])\n          const denom = Number.parseFloat(match[2])\n\n          if (num <= 0 || denom <= 0) return {}\n\n          const percentage1 = Math.round((num / (num + denom)) * 100)\n          const percentage2 = 100 - percentage1\n\n          const mixedColor = `color-mix(in ${colorSpace}, ${color1Value} ${percentage1}%, ${color2Value} ${percentage2}%)`\n\n          return { [property]: mixedColor }\n        }\n      })\n    })\n  })\n\n  // Since we can't use regex patterns directly with addUtilities,\n  // we'll use a different approach with addComponents\n  const dynamicUtilities = {}\n\n  // Create utilities for common ratios that can be extended\n  const commonRatios = [\n    [1, 1],\n    [1, 2],\n    [1, 3],\n    [1, 4],\n    [2, 1],\n    [2, 3],\n    [3, 1],\n    [3, 2],\n    [3, 4],\n    [4, 1],\n    [4, 3],\n    [4, 6],\n    [5, 1],\n    [7, 3],\n    [8, 2],\n    [9, 1],\n  ]\n\n  Object.entries(baseColors).forEach(([color1Name, color1Value]) => {\n    Object.entries(baseColors).forEach(([color2Name, color2Value]) => {\n      if (color1Name === color2Name) return\n\n      commonRatios.forEach(([num, denom]) => {\n        const percentage1 = Math.round((num / (num + denom)) * 100)\n        const percentage2 = 100 - percentage1\n\n        variants.forEach((variant) => {\n          const className = `.${variant}-${config.prefix}-${color1Name}\\\\/${color2Name}-${num}\\\\/${denom}`\n          const property = getPropertyName(variant)\n          const mixedColor = `color-mix(in ${colorSpace}, ${color1Value} ${percentage1}%, ${color2Value} ${percentage2}%)`\n\n          dynamicUtilities[className] = { [property]: mixedColor }\n        })\n      })\n    })\n  })\n\n  addUtilities(dynamicUtilities)\n}\n\nfunction generatePercentageBasedUtilities(utilities, config) {\n  // Generate: bg-mix-accent-70 (implicit background mixing)\n  const { baseColors, variants, colorSpace, implicitBackground } = config\n  const backgroundValue = baseColors[implicitBackground]\n\n  const percentages = [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95]\n\n  Object.entries(baseColors).forEach(([colorName, colorValue]) => {\n    if (colorName === implicitBackground) return\n\n    percentages.forEach((percentage) => {\n      variants.forEach((variant) => {\n        const className = `.${variant}-${config.prefix}-${colorName}-${percentage}`\n        const property = getPropertyName(variant)\n        const mixedColor = `color-mix(in ${colorSpace}, ${colorValue} ${percentage}%, ${backgroundValue} ${100 - percentage}%)`\n\n        utilities[className] = { [property]: mixedColor }\n      })\n    })\n  })\n}\n\nfunction getPropertyName(variant) {\n  switch (variant) {\n    case \"bg\": {\n      return \"background-color\"\n    }\n    case \"border\": {\n      return \"border-color\"\n    }\n    case \"text\": {\n      return \"color\"\n    }\n    default: {\n      return \"background-color\"\n    }\n  }\n}\n\nmodule.exports = ratioMixingPlugin\n"
  },
  {
    "path": "packages/configs/tailwindcss/tailwind-extend.css",
    "content": "/* This CSS File do not import anywhere, just write atom class for tailwindcss. The tailwindcss intellisense will be work. */\n\n@tailwind components;\n\n@layer components {\n  .drag-region {\n    -webkit-app-region: drag;\n  }\n\n  .no-drag-region {\n    -webkit-app-region: no-drag;\n  }\n  .mask-squircle {\n    mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMTAwIDBDMjAgMCAwIDIwIDAgMTAwczIwIDEwMCAxMDAgMTAwIDEwMC0yMCAxMDAtMTAwUzE4MCAwIDEwMCAwWicvPjwvc3ZnPg==);\n  }\n  .mask {\n    mask-size: contain;\n    mask-repeat: no-repeat;\n    mask-position: center;\n  }\n\n  .center {\n    @apply flex items-center justify-center;\n  }\n\n  .shadow-perfect {\n    /* https://codepen.io/jh3y/pen/yLWgjpd */\n    --tint: 214;\n    --alpha: 3;\n    --base: hsl(var(--tint, 214) 80% 27% / calc(var(--alpha, 4) * 1%));\n    /* Use color-mix instead of relative color syntax for build-tool compatibility. */\n    --shade: color-mix(in srgb, var(--base) 72%, black);\n    --perfect-shadow:\n      0 0 0 1px var(--base), 0 1px 1px -0.5px var(--shade), 0 3px 3px -1.5px var(--shade),\n      0 6px 6px -3px var(--shade), 0 12px 12px -6px var(--base), 0 24px 24px -12px var(--base);\n    box-shadow: var(--perfect-shadow);\n  }\n\n  .perfect-sm {\n    --alpha: 1;\n  }\n\n  .perfect-md {\n    --alpha: 2;\n  }\n\n  [theme=\"dark\"] .shadow-perfect {\n    --tint: 221;\n  }\n\n  .shadow-modal {\n    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);\n  }\n  [theme=\"dark\"] .shadow-modal {\n    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.8);\n  }\n  /* Utils */\n  .no-animation {\n    --btn-focus-scale: 1;\n    --animation-btn: 0;\n    --animation-input: 0;\n  }\n\n  @keyframes radiomark {\n    0% {\n      box-shadow:\n        0 0 0 12px var(--fallback-b1, oklch(var(--b1) / 1)) inset,\n        0 0 0 12px var(--fallback-b1, oklch(var(--b1) / 1)) inset;\n    }\n\n    50% {\n      box-shadow:\n        0 0 0 3px var(--fallback-b1, oklch(var(--b1) / 1)) inset,\n        0 0 0 3px var(--fallback-b1, oklch(var(--b1) / 1)) inset;\n    }\n\n    to {\n      box-shadow:\n        0 0 0 4px var(--fallback-b1, oklch(var(--b1) / 1)) inset,\n        0 0 0 4px var(--fallback-b1, oklch(var(--b1) / 1)) inset;\n    }\n  }\n}\n\n/* KBD */\n@layer components {\n  .kbd {\n    background-image: linear-gradient(\n      to bottom right,\n      rgba(var(--color-background) / 0.95),\n      rgba(var(--color-background) / 0.92)\n    );\n    @apply rounded-[4px] px-1 text-[0.5em];\n    @apply inline-flex items-center justify-center;\n    box-shadow:\n      0 2px 8px rgba(0, 0, 0, 0.06),\n      0 1px 4px rgba(0, 0, 0, 0.04),\n      0 1px 6px hsl(var(--fo-a) / 0.04),\n      0 0 2px rgba(0, 0, 0, 0.02);\n  }\n\n  [data-theme=\"dark\"] .kbd {\n    border: 1px solid hsl(var(--border) / 0.5);\n    box-shadow:\n      0 2px 8px rgba(0, 0, 0, 0.12),\n      0 1px 4px rgba(0, 0, 0, 0.08),\n      0 1px 6px hsl(var(--fo-a) / 0.06),\n      0 0 2px rgba(0, 0, 0, 0.04);\n  }\n}\n\n/* Checkbox */\n@layer components {\n  .checkbox {\n    --chkbg: theme(colors.accent);\n    --chkfg: theme(colors.zinc.100);\n\n    flex-shrink: 0;\n    height: 0.9em;\n    width: 0.9em;\n    cursor: pointer;\n    appearance: none;\n    border-radius: 5px;\n    border-width: 1px;\n    border-color: theme(colors.border);\n    --tw-border-opacity: 0.2;\n  }\n\n  .checkbox:focus {\n    box-shadow: none;\n  }\n\n  .checkbox:focus-visible {\n    outline-style: solid;\n    outline-width: 2px;\n    outline-offset: 2px;\n    outline-color: theme(colors.accent);\n    border-color: theme(colors.accent);\n  }\n\n  .checkbox:disabled {\n    border-width: 0;\n    cursor: not-allowed;\n    border-color: transparent;\n    --tw-bg-opacity: 1;\n    background-color: var(--chkbg);\n    opacity: 0.2;\n  }\n\n  .checkbox:checked,\n  .checkbox[aria-checked=\"true\"] {\n    background-repeat: no-repeat;\n    animation: checkmark var(--animation-input, 0.2s) ease-out;\n    border-color: theme(colors.accent);\n    background-color: var(--chkbg);\n    background-image:\n      linear-gradient(-45deg, transparent 65%, var(--chkbg) 65.99%),\n      linear-gradient(45deg, transparent 75%, var(--chkbg) 75.99%),\n      linear-gradient(-45deg, var(--chkbg) 40%, transparent 40.99%),\n      linear-gradient(\n        45deg,\n        var(--chkbg) 30%,\n        var(--chkfg) 30.99%,\n        var(--chkfg) 40%,\n        transparent 40.99%\n      ),\n      linear-gradient(-45deg, var(--chkfg) 50%, var(--chkbg) 50.99%);\n  }\n\n  @keyframes checkmark {\n    0% {\n      background-position-y: 5px;\n    }\n\n    50% {\n      background-position-y: -2px;\n    }\n\n    to {\n      background-position-y: 0;\n    }\n  }\n}\n\n/* Shadow */\n@layer components {\n  .shadow-context-menu {\n    --shadow-color: rgba(0, 0, 0, 0.067);\n    box-shadow:\n      var(--shadow-color) 0px 3px 8px,\n      var(--shadow-color) 0px 2px 5px,\n      var(--shadow-color) 0px 1px 1px;\n  }\n\n  [data-theme=\"dark\"] .shadow-context-menu {\n    --shadow-color: rgba(255, 255, 255, 0.07);\n  }\n\n  .shadow-ai-chat-floating-panel {\n    --shadow-color: rgba(0, 0, 0, 0.08);\n    box-shadow:\n      -5px -6px 20px 0px var(--shadow-color),\n      10px -5px 20px 0px var(--shadow-color),\n      0px 10px 20px 0px var(--shadow-color);\n  }\n  [data-theme=\"dark\"] .shadow-ai-chat-floating-panel {\n    --shadow-color: rgba(255, 255, 255, 0.04);\n  }\n\n  [data-theme=\"dark\"] .shadow-context-menu {\n    box-shadow:\n      rgba(255, 255, 255, 0.07) 0px 3px 8px,\n      rgba(255, 255, 255, 0.05) 0px 2px 5px,\n      rgba(255, 255, 255, 0.04) 0px 1px 1px;\n  }\n\n  .shadow-ai-chat-floating-panel {\n    box-shadow:\n      -5px -6px 20px 0px rgba(0, 0, 0, 0.08),\n      10px -5px 20px 0px rgba(0, 0, 0, 0.08),\n      0px 10px 20px 0px rgba(0, 0, 0, 0.08);\n  }\n  [data-theme=\"dark\"] .shadow-ai-chat-floating-panel {\n    box-shadow:\n      -5px -6px 20px 0px rgba(255, 255, 255, 0.04),\n      10px -5px 20px 0px rgba(255, 255, 255, 0.04),\n      0px 10px 20px 0px rgba(255, 255, 255, 0.04);\n  }\n}\n\n/* Link */\n@layer components {\n  .follow-link--underline {\n    color: currentColor;\n    background-image: linear-gradient(theme(colors.accent), theme(colors.accent));\n    background-size: 0% 1.5px;\n    background-repeat: no-repeat;\n    /* NOTE: this won't work with background images   */\n\n    transition: all 500ms ease;\n\n    text-decoration: underline;\n    text-underline-offset: 3px;\n\n    @apply decoration-accent/30 hover:no-underline;\n    @apply border-0;\n\n    background-position: left 1.1em;\n\n    &::selection {\n      text-shadow: none !important;\n    }\n\n    &:hover {\n      background-size: 100% 1.5px;\n      text-shadow:\n        0.05em 0 theme(colors.background),\n        -0.05em 0 theme(colors.background);\n\n      transition: all 250ms ease;\n    }\n  }\n}\n\n@layer utilities {\n  .scrollbar-none {\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n  }\n  .scrollbar-none::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n@layer components {\n  .shadow-drawer-to-left {\n    box-shadow:\n      -12px 0 20px -6px rgba(41, 41, 41, 0.1),\n      24px 0 20px -6px rgba(41, 41, 41, 0.1);\n  }\n  [data-theme=\"dark\"] .shadow-drawer-to-left {\n    box-shadow:\n      -12px 0px 20px -6px rgba(0, 0, 0, 0.653),\n      24px 0 20px -6px rgba(0, 0, 0, 0.653);\n  }\n\n  .shadow-drawer-to-right {\n    box-shadow: 12px 0px 20px -6px rgba(41, 41, 41, 0.1);\n  }\n  [data-theme=\"dark\"] .shadow-drawer-to-right {\n    box-shadow: 12px 0px 20px -6px rgba(0, 0, 0, 0.653);\n  }\n}\n\n@layer components {\n  .animate-mask-in {\n    animation: mask-in 0.5s ease-in-out forwards;\n  }\n  @keyframes mask-in {\n    0% {\n      mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 150% 0 / 400% no-repeat;\n      opacity: 0.2;\n    }\n    100% {\n      mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 0 / 400% no-repeat;\n      opacity: 1;\n    }\n  }\n}\n\n@layer components {\n  .shadow-tooltip-bottom {\n    --bg: theme(colors.accent/0.3);\n    box-shadow: 0px 5px 20px -11px var(--bg);\n  }\n\n  .shadow-tooltip-top {\n    --bg: theme(colors.accent/0.3);\n    box-shadow: 0px -5px 20px -11px var(--bg);\n  }\n}\n\n@layer utilities {\n  .easing-spring {\n    animation-timing-function: var(--spring-easing);\n    animation-duration: var(--spring-duration);\n  }\n  .spring-soft {\n    --spring-easing: linear(\n      0,\n      0.0019,\n      0.0073 1.2%,\n      0.0274 2.39%,\n      0.0624,\n      0.1075 5.08%,\n      0.2108 7.62%,\n      0.4632 13.15%,\n      0.5727 15.69%,\n      0.6768 18.38%,\n      0.7617 20.92%,\n      0.8365,\n      0.8963 26.29%,\n      0.921 27.64%,\n      0.9447,\n      0.9647,\n      0.9813 32.12%,\n      1.0028 34.66%,\n      1.0185 37.5%,\n      1.0279 40.64%,\n      1.0315 44.07%,\n      1.0291 49%,\n      1.0105 62.9%,\n      1.0028 71.86%,\n      0.9994 82.62%,\n      0.9993 99.95%\n    );\n    --spring-duration: 1.157s;\n  }\n}\n\n@layer utilities {\n  .animate-flip {\n    animation: flip 0.5s ease-in-out infinite;\n  }\n\n  @keyframes flip {\n    0% {\n      transform: rotateY(0deg);\n    }\n    100% {\n      transform: rotateY(180deg);\n    }\n  }\n\n  @keyframes rocketAnimation {\n    0% {\n      transform: translateY(0);\n    }\n    50% {\n      transform: translate(2px, -1px);\n    }\n    100% {\n      transform: translateY(0);\n    }\n  }\n\n  .animate-rocket {\n    animation: rocketAnimation 1s infinite ease-out;\n  }\n\n  @keyframes radialPulse {\n    0% {\n      background: radial-gradient(\n        circle,\n        var(--highlight-color, hsl(var(--fo-a) / 0.3)) 0%,\n        transparent 0%\n      );\n    }\n    40% {\n      background: radial-gradient(\n        circle,\n        var(--highlight-color, hsl(var(--fo-a) / 0.3)) 0%,\n        transparent 50%\n      );\n      opacity: 1;\n    }\n    100% {\n      background: radial-gradient(\n        circle,\n        var(--highlight-color, oklch(var(--fo-a) / 0.3)) 0%,\n        transparent 80%\n      );\n      opacity: 0;\n    }\n  }\n}\n\n@layer utilities {\n  .animate-mask-left-to-right {\n    animation: mask-left-to-right var(--animation-duration, 0.5s) ease-in-out forwards;\n  }\n  @keyframes mask-left-to-right {\n    0% {\n      mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 150% 0 / 400% no-repeat;\n      opacity: 0.2;\n    }\n    100% {\n      mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 0 / 400% no-repeat;\n      opacity: 1;\n    }\n  }\n}\n@layer components {\n  .mask-b {\n    mask-image: linear-gradient(rgb(255, 255, 255) calc(100% - 20px), rgba(255, 255, 255, 0) 100%);\n  }\n\n  .mask-b-lg {\n    mask-image: linear-gradient(rgb(255, 255, 255) calc(100% - 50px), rgba(255, 255, 255, 0) 100%);\n  }\n\n  .mask-b-xl {\n    mask-image: linear-gradient(rgb(255, 255, 255) calc(100% - 70px), rgba(255, 255, 255, 0) 100%);\n  }\n\n  .mask-b-2xl {\n    mask-image: linear-gradient(rgb(255, 255, 255) calc(100% - 90px), rgba(255, 255, 255, 0) 100%);\n  }\n\n  .mask-horizontal {\n    mask-image: linear-gradient(\n      90deg,\n      rgba(255, 255, 255, 0) 0%,\n      rgba(255, 255, 255, 1) 14%,\n      rgba(255, 255, 255, 1) 86%,\n      rgba(255, 255, 255, 0) 100%\n    );\n  }\n}\n"
  },
  {
    "path": "packages/configs/tailwindcss/tw-css-plugin.js",
    "content": "// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856\n// cssAsPlugin.js\nconst postcss = require(\"postcss\")\nconst postcssJs = require(\"postcss-js\")\nconst { readFileSync } = require(\"node:fs\")\n\nrequire.extensions[\".css\"] = function (module, filename) {\n  const cssAsPlugin = ({ addBase, addComponents, addUtilities }) => {\n    const css = readFileSync(filename, \"utf8\")\n    const root = postcss.parse(css)\n    const jss = postcssJs.objectify(root)\n\n    if (\"@layer base\" in jss) {\n      addBase(jss[\"@layer base\"])\n    }\n    if (\"@layer components\" in jss) {\n      addComponents(jss[\"@layer components\"])\n    }\n    if (\"@layer utilities\" in jss) {\n      addUtilities(jss[\"@layer utilities\"])\n    }\n  }\n  module.exports = cssAsPlugin\n}\n"
  },
  {
    "path": "packages/configs/tailwindcss/web.ts",
    "content": "/* @moduleResolution bundler */\nimport \"./tw-css-plugin\"\n\nimport { getIconCollections, iconsPlugin } from \"@egoist/tailwindcss-icons\"\nimport { cleanupSVG, importDirectorySync, isEmptyColor, parseColors, runSVGO } from \"@iconify/tools\"\nimport { compareColors, stringToColor } from \"@iconify/utils/lib/colors\"\nimport { merge } from \"es-toolkit/compat\"\nimport path, { resolve } from \"pathe\"\nimport { theme } from \"tailwindcss/defaultConfig\"\nimport type { Config } from \"tailwindcss/types/config\"\nimport { withUIKit } from \"tailwindcss-uikit-colors/src/macos/tailwind\"\nimport { workspaceRootSync } from \"workspace-root\"\n\nimport ratioMixingPlugin from \"./ratio-mixing-plugin\"\n\nconst workspaceRoot = workspaceRootSync(__dirname)\nconst twConfig = {\n  darkMode: [\"class\", '[data-theme=\"dark\"]'],\n  content: [],\n  prefix: \"\",\n  theme: {\n    container: {\n      center: true,\n      padding: \"2rem\",\n      screens: {\n        \"2xl\": \"1400px\",\n      },\n    },\n\n    fontSize: {\n      ...theme?.fontSize,\n      largeTitle: [\"1.625rem\", \"2rem\"], // 26px\n      title1: [\"1.375rem\", \"1.625rem\"], // 22px\n      title2: [\"1.0625rem\", \"1.375rem\"], // 17px\n      title3: [\"0.9375rem\", \"1.25rem\"], // 15px\n      headline: [\"0.8125rem\", \"1rem\"], // 13px\n      body: [\"0.8125rem\", \"1rem\"], // 13px\n      callout: [\"0.75rem\", \"0.9375rem\"], // 12px\n      subheadline: [\"0.6875rem\", \"0.875rem\"], // 11px\n      footnote: [\"0.625rem\", \"0.8125rem\"], // 10px\n      caption: [\"0.625rem\", \"0.8125rem\"], // 10px\n    },\n\n    extend: {\n      fontFamily: {\n        theme: \"var(--fo-font-family)\",\n      },\n\n      colors: {\n        border: \"hsl(var(--border) / <alpha-value>)\",\n        background: \"hsl(var(--background) / <alpha-value>)\",\n\n        accent: \"hsl(var(--fo-a) / <alpha-value>)\",\n        folo: \"#FF5C00\",\n\n        theme: {\n          boxShadow: {\n            \"context-menu\":\n              \"0px 0px 1px rgba(0, 0, 0, 0.4), 0px 0px 1.5px rgba(0, 0, 0, 0.3), 0px 7px 22px rgba(0, 0, 0, 0.25)\",\n          },\n\n          item: {\n            active: \"var(--fo-item-active)\",\n            hover: \"var(--fo-item-hover)\",\n          },\n          selection: {\n            active: \"var(--fo-selection-active)\",\n            hover: \"var(--fo-selection-hover)\",\n            foreground: \"var(--fo-selection-foreground)\",\n          },\n\n          inactive: \"hsl(var(--fo-inactive) / <alpha-value>)\",\n          disabled: \"hsl(var(--fo-disabled) / <alpha-value>)\",\n\n          background: \"var(--fo-background)\",\n        },\n      },\n      borderRadius: {\n        lg: \"var(--radius)\",\n        md: \"calc(var(--radius) - 2px)\",\n        sm: \"calc(var(--radius) - 4px)\",\n      },\n      backdropBlur: {\n        background: \"80px\",\n      },\n\n      typography: (theme: any) => ({\n        zinc: {\n          css: {\n            \"--tw-prose-body\": theme(\"colors.zinc.500\"),\n            \"--tw-prose-quotes\": theme(\"colors.zinc.500\"),\n          },\n        },\n      }),\n    },\n  },\n\n  plugins: [\n    iconsPlugin({\n      collections: {\n        ...getIconCollections([\"mingcute\", \"simple-icons\", \"logos\"]),\n        mgc: getCollections(path.resolve(workspaceRoot!, \"./icons/mgc\")),\n      },\n    }),\n    require(\"tailwindcss-animate\"),\n    require(\"@tailwindcss/container-queries\"),\n    require(\"@tailwindcss/typography\"),\n    require(\"tailwindcss-motion\"),\n    require(\"tailwindcss-safe-area\"),\n\n    require(resolve(__dirname, \"./tailwind-extend.css\")),\n\n    ratioMixingPlugin({\n      baseColors: {\n        background: \"hsl(var(--background))\",\n        accent: \"hsl(var(--fo-a))\",\n        red: \"rgb(var(--color-red))\",\n        transparent: \"transparent\",\n      },\n    }),\n  ],\n} satisfies Config\n\nexport const extendConfig = (config: Config) => {\n  const result = merge({}, withUIKit(twConfig), config)\n  if (config.plugins) {\n    // Merge plugin array\n    result.plugins = [...twConfig.plugins, ...config.plugins]\n  }\n  return result\n}\n\nfunction getCollections(dir: string) {\n  // Import icons\n  const iconSet = importDirectorySync(dir, {\n    includeSubDirs: false,\n  })\n\n  // Validate, clean up, fix palette and optimism\n  iconSet.forEachSync((name, type) => {\n    if (type !== \"icon\") {\n      return\n    }\n\n    const svg = iconSet.toSVG(name)\n    if (!svg) {\n      // Invalid icon\n      iconSet.remove(name)\n      return\n    }\n\n    // Clean up and optimize icons\n    try {\n      // Clean up icon code\n      cleanupSVG(svg)\n\n      // Change color to `currentColor`\n      // Skip this step if icon has hardcoded palette\n      const blackColor = stringToColor(\"black\")!\n      const whiteColor = stringToColor(\"white\")!\n      parseColors(svg, {\n        defaultColor: \"currentColor\",\n        callback: (attr, colorStr, color) => {\n          if (!color) {\n            // Color cannot be parsed!\n            throw new Error(`Invalid color: \"${colorStr}\" in attribute ${attr}`)\n          }\n\n          if (isEmptyColor(color)) {\n            // Color is empty: 'none' or 'transparent'. Return as is\n            return color\n          }\n\n          // Change black to 'currentColor'\n          if (compareColors(color, blackColor)) {\n            return \"currentColor\"\n          }\n\n          // Remove shapes with white color\n          if (compareColors(color, whiteColor)) {\n            return \"remove\"\n          }\n\n          // NOTE: MGC icons has default color of #10161F\n          if (compareColors(color, stringToColor(\"#10161F\")!)) {\n            return \"currentColor\"\n          }\n\n          // Icon is not monotone\n          return color\n        },\n      })\n\n      runSVGO(svg)\n    } catch (err) {\n      // Invalid icon\n      console.error(`Error parsing ${name}:`, err)\n      iconSet.remove(name)\n      return\n    }\n\n    // Update icon\n    iconSet.fromSVG(name, svg)\n  })\n\n  // Export\n  return iconSet.export()\n}\n"
  },
  {
    "path": "packages/configs/tsconfig.extend.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"module\": \"esnext\",\n    \"sourceMap\": false,\n    \"jsx\": \"preserve\",\n    \"esModuleInterop\": true,\n    \"moduleResolution\": \"Bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true,\n    \"allowJs\": true\n  }\n}\n"
  },
  {
    "path": "packages/internal/AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides specific guidance for developing shared packages used across all platforms.\n\n## Overview\n\nThe `packages/internal/` directory contains core shared packages that provide common functionality across desktop, mobile, and SSR applications.\n\n## Package Structure\n\n- `atoms/` - Jotai atomic state definitions\n- `components/` - Shared UI components\n- `constants/` - Application constants\n- `database/` - Drizzle ORM database layer\n- `hooks/` - Shared React hooks\n- `models/` - Data models and schemas\n- `shared/` - Cross-platform shared utilities\n- `store/` - Zustand stores\n- `types/` - TypeScript type definitions\n- `utils/` - Utility functions and helpers\n- `tracker/` - Analytics and tracking\n- `logger/` - Logging utilities\n- `legal/` - Legal and compliance utilities\n\n## State Management\n\n- **Jotai** for atomic state management across all platforms\n- **Zustand** for complex state stores (in `packages/internal/store/`)\n- **React Query** for server state management\n\n## Database\n\n- **Drizzle ORM** with SQLite for local data storage\n- Platform-specific database implementations in `packages/internal/database/`\n- Migration system with versioned SQL files\n\n## Component Development Guidelines\n\n- Shared UI components in `packages/internal/components/`\n- Platform-specific components in respective app directories\n- Use TypeScript interfaces for component props\n- Follow cross-platform compatibility patterns\n"
  },
  {
    "path": "packages/internal/atoms/package.json",
    "content": "{\n  \"name\": \"@follow/atoms\",\n  \"private\": true,\n  \"sideEffects\": false,\n  \"exports\": {\n    \"./atoms/*\": {\n      \"import\": \"./src/atoms/*\",\n      \"types\": \"./src/atoms/*\"\n    },\n    \"./helper/*\": {\n      \"import\": \"./src/helper/*\",\n      \"types\": \"./src/helper/*\"\n    }\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@follow/configs\": \"workspace:*\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/hooks\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/store\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"jotai\": \"2.17.1\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/atoms/src/atoms/user.ts",
    "content": "import { createAtomHooks } from \"@follow/utils/jotai\"\nimport { atom } from \"jotai\"\n\nexport const [, , useWhoami, , whoami, setWhoami] =\n  createAtomHooks(\n    atom<\n      Nullable<{\n        id: string\n        name: string | null\n        image: string | null\n        handle: string | null\n        email?: string\n      }>\n    >(),\n  )\n\nexport const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] =\n  createAtomHooks(atom<boolean>(false))\n"
  },
  {
    "path": "packages/internal/atoms/src/helper/setting.ts",
    "content": "import { UserRole } from \"@follow/constants\"\nimport { useRefValue } from \"@follow/hooks\"\nimport { getSettingPaidLevel, SettingPaidLevels } from \"@follow/shared/settings/constants\"\nimport { useUserStore } from \"@follow/store/user/store\"\nimport { EventBus } from \"@follow/utils/event-bus\"\nimport { createAtomHooks } from \"@follow/utils/jotai\"\nimport { getStorageNS } from \"@follow/utils/ns\"\nimport { atom as jotaiAtom, useAtomValue } from \"jotai\"\nimport { atomWithStorage, selectAtom } from \"jotai/utils\"\nimport { useMemo } from \"react\"\nimport { shallow } from \"zustand/shallow\"\n\ndeclare module \"@follow/utils/event-bus\" {\n  interface CustomEvent {\n    SETTING_CHANGE_EVENT: {\n      updated: number\n      payload: Record<string, any>\n      key: string\n    }\n  }\n}\n\nexport const createSettingAtom = <T extends object>(\n  settingKey: string,\n  createDefaultSettings: () => T,\n) => {\n  const atom = atomWithStorage(getStorageNS(settingKey), createDefaultSettings(), undefined, {\n    getOnInit: true,\n  })\n\n  const [, , useSettingValueRaw, , getSettingsRaw, setSettings] = createAtomHooks(atom)\n\n  const initializeDefaultSettings = () => {\n    const currentSettings = getSettingsRaw()\n    const defaultSettings = createDefaultSettings()\n    if (typeof currentSettings !== \"object\") setSettings(defaultSettings)\n    const newSettings = { ...defaultSettings, ...currentSettings }\n    setSettings(newSettings)\n  }\n\n  const selectAtomCacheMap = {} as Record<keyof ReturnType<typeof getSettingsRaw>, any>\n\n  const noopAtom = jotaiAtom(null)\n\n  const canUpdatePaidSetting = (requiredLevel?: SettingPaidLevels) => {\n    if (requiredLevel === undefined) return true\n    if (\n      requiredLevel === SettingPaidLevels.Free ||\n      requiredLevel === SettingPaidLevels.FreeLimited\n    ) {\n      return true\n    }\n    const role = useUserStore.getState().role ?? UserRole.Free\n    return role !== UserRole.Free && role !== UserRole.Trial\n  }\n\n  const resolveAccessibleValue = (\n    key: string,\n    value: unknown,\n    defaults: Record<string, unknown>,\n  ) => {\n    const requiredLevel = getSettingPaidLevel(settingKey, key)\n    if (requiredLevel === undefined || canUpdatePaidSetting(requiredLevel)) {\n      return value\n    }\n    if (Object.prototype.hasOwnProperty.call(defaults, key)) {\n      return defaults[key]\n    }\n    return value\n  }\n\n  const sanitizeSettingsSnapshot = (settings: ReturnType<typeof getSettingsRaw>) => {\n    const defaults = createDefaultSettings() as Record<string, unknown>\n    const raw = settings as Record<string, unknown>\n    let sanitized: Record<string, unknown> | null = null\n\n    for (const key of Object.keys(defaults)) {\n      const safeValue = resolveAccessibleValue(key, raw[key], defaults)\n      if (safeValue !== raw[key]) {\n        if (!sanitized) sanitized = { ...raw }\n        sanitized[key] = safeValue\n      }\n    }\n\n    return (sanitized ?? raw) as ReturnType<typeof getSettingsRaw>\n  }\n\n  const useMaybeSettingKey = <T extends keyof ReturnType<typeof getSettingsRaw>>(\n    key: Nullable<T>,\n  ) => {\n    // @ts-expect-error\n    let selectedAtom: Record<keyof T, any>[T] | null = null\n    if (key) {\n      selectedAtom = selectAtomCacheMap[key]\n      if (!selectedAtom) {\n        selectedAtom = selectAtom(atom, (s) => s[key])\n        selectAtomCacheMap[key] = selectedAtom\n      }\n    } else {\n      selectedAtom = noopAtom\n    }\n\n    const value = useAtomValue(selectedAtom) as ReturnType<typeof getSettingsRaw>[T]\n    if (!key) return value\n    const defaults = createDefaultSettings() as Record<string, unknown>\n    return resolveAccessibleValue(String(key), value, defaults) as ReturnType<\n      typeof getSettingsRaw\n    >[T]\n  }\n\n  const useSettingKey = <T extends keyof ReturnType<typeof getSettingsRaw>>(key: T) => {\n    return useMaybeSettingKey(key) as ReturnType<typeof getSettingsRaw>[T]\n  }\n\n  function useSettingKeys<\n    T extends keyof ReturnType<typeof getSettingsRaw>,\n    K1 extends T,\n    K2 extends T,\n    K3 extends T,\n    K4 extends T,\n    K5 extends T,\n    K6 extends T,\n    K7 extends T,\n    K8 extends T,\n    K9 extends T,\n    K10 extends T,\n  >(keys: [K1, K2?, K3?, K4?, K5?, K6?, K7?, K8?, K9?, K10?]) {\n    return [\n      useMaybeSettingKey(keys[0]),\n      useMaybeSettingKey(keys[1]),\n      useMaybeSettingKey(keys[2]),\n      useMaybeSettingKey(keys[3]),\n      useMaybeSettingKey(keys[4]),\n      useMaybeSettingKey(keys[5]),\n      useMaybeSettingKey(keys[6]),\n      useMaybeSettingKey(keys[7]),\n      useMaybeSettingKey(keys[8]),\n      useMaybeSettingKey(keys[9]),\n    ] as [\n      ReturnType<typeof getSettingsRaw>[K1],\n      ReturnType<typeof getSettingsRaw>[K2],\n      ReturnType<typeof getSettingsRaw>[K3],\n      ReturnType<typeof getSettingsRaw>[K4],\n      ReturnType<typeof getSettingsRaw>[K5],\n      ReturnType<typeof getSettingsRaw>[K6],\n      ReturnType<typeof getSettingsRaw>[K7],\n      ReturnType<typeof getSettingsRaw>[K8],\n      ReturnType<typeof getSettingsRaw>[K9],\n      ReturnType<typeof getSettingsRaw>[K10],\n    ]\n  }\n\n  const useSettingSelector = <\n    T extends keyof ReturnType<typeof getSettingsRaw>,\n    S extends ReturnType<typeof getSettingsRaw>,\n    R = S[T],\n  >(\n    selector: (s: S) => R,\n  ): R => {\n    const stableSelector = useRefValue(selector)\n\n    return useAtomValue(\n      useMemo(\n        () =>\n          selectAtom(\n            atom,\n            (state) => stableSelector.current(sanitizeSettingsSnapshot(state) as S),\n            shallow,\n          ),\n        [stableSelector],\n      ),\n    )\n  }\n\n  const setSetting = <K extends keyof ReturnType<typeof getSettingsRaw>>(\n    key: K,\n    value: ReturnType<typeof getSettingsRaw>[K],\n  ) => {\n    const requiredLevel = getSettingPaidLevel(settingKey, String(key))\n    if (!canUpdatePaidSetting(requiredLevel)) {\n      return\n    }\n    const updated = Date.now()\n    setSettings({\n      ...getSettingsRaw(),\n      [key]: value,\n\n      updated,\n    })\n\n    EventBus.dispatch(\"SETTING_CHANGE_EVENT\", {\n      payload: { [key]: value },\n      updated,\n      key: settingKey,\n    })\n  }\n\n  const clearSettings = () => {\n    setSettings(createDefaultSettings())\n  }\n\n  const useSettingValue = () => {\n    const value = useSettingValueRaw()\n    return useMemo(() => sanitizeSettingsSnapshot(value), [value])\n  }\n\n  const getSettings = () => {\n    return sanitizeSettingsSnapshot(getSettingsRaw())\n  }\n\n  Object.defineProperty(useSettingValue, \"select\", {\n    value: useSettingSelector,\n  })\n\n  return {\n    useSettingKey,\n    useSettingSelector,\n    setSetting,\n    clearSettings,\n    initializeDefaultSettings,\n\n    useSettingValue,\n    useSettingKeys,\n    getSettings,\n\n    settingAtom: atom,\n  } as {\n    useSettingKey: typeof useSettingKey\n    useSettingSelector: typeof useSettingSelector\n    setSetting: typeof setSetting\n    clearSettings: typeof clearSettings\n    initializeDefaultSettings: typeof initializeDefaultSettings\n    useSettingValue: typeof useSettingValue & {\n      select: <T extends keyof ReturnType<() => T>>(key: T) => Awaited<T[T]>\n    }\n    useSettingKeys: typeof useSettingKeys\n    getSettings: typeof getSettings\n    settingAtom: typeof atom\n  }\n}\n"
  },
  {
    "path": "packages/internal/atoms/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"declaration\": true,\n    \"types\": [\"@follow/types/react\", \"@follow/types/global\", \"vite/client\"],\n    \"paths\": {\n      \"@follow/atoms/*\": [\"./src/*\"],\n      \"@pkg\": [\"../../package.json\"]\n    }\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/internal/components/assets/colors-media.css",
    "content": "/* merged from colors.css and tailwind.css */\n:root {\n  --fo-a: 21.6 100% 50%;\n\n  --fo-text-primary: 0 0% 10%;\n\n  --fo-item-active: theme(colors.zinc.400/0.3);\n  --fo-item-hover: theme(colors.zinc.400/0.2);\n\n  --fo-inactive: 0 0% 80%;\n  --fo-disabled: 0 0% 70%;\n\n  --fo-background: theme(colors.background);\n\n  --background: 0 0% 100%;\n  --color-background: 255 255 255;\n\n  --border: 20 5.9% 90%;\n  --radius: 0.5rem;\n\n  --fo-selection-active: theme(colors.accent/90);\n  --fo-selection-hover: theme(colors.accent/80);\n  --fo-selection-foreground: theme(colors.white);\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --fo-a: 21.6 100% 50%;\n\n    --fo-text-primary: 0 0% 80%;\n\n    --fo-item-active: theme(colors.neutral.600/0.4);\n    --fo-item-hover: theme(colors.neutral.700/0.3);\n\n    --fo-inactive: 0 0% 50%;\n    --fo-disabled: 0 0% 35%;\n\n    --fo-background: theme(colors.background);\n\n    --background: 0 0% 7.1%;\n    --color-background: 18 18 18;\n    --border: 0 0% 22.1%;\n  }\n}\n\n:root {\n  --fo-sidebar: 240 4.8% 95.9%;\n  --fo-sidebar-active: 240 4.9% 83.9%;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --fo-sidebar: 220 8.1% 14.5%;\n    --fo-sidebar-active: 198 31.3% 6.3%;\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/assets/colors.css",
    "content": ":root {\n  --fo-selection-active: theme(colors.accent/90);\n  --fo-selection-hover: theme(colors.accent/80);\n  --fo-selection-foreground: theme(colors.white);\n}\n[data-theme=\"light\"] {\n  --fo-a: 21.6 100% 50%;\n\n  --fo-text-primary: 0 0% 10%;\n\n  --fo-item-active: theme(colors.zinc.400/0.3);\n  --fo-item-hover: theme(colors.zinc.400/0.2);\n\n  --fo-inactive: 0 0% 80%;\n  --fo-disabled: 0 0% 70%;\n\n  --fo-background: theme(colors.background);\n}\n\n[data-theme=\"dark\"] {\n  --fo-a: 21.6 100% 50%;\n\n  --fo-text-primary: 0 0% 80%;\n\n  --fo-item-active: theme(colors.neutral.600/0.4);\n  --fo-item-hover: theme(colors.neutral.700/0.3);\n\n  --fo-inactive: 0 0% 50%;\n  --fo-disabled: 0 0% 35%;\n\n  --fo-background: theme(colors.background);\n}\n\n[data-theme=\"light\"] {\n  --fo-sidebar: 240 4.8% 95.9%;\n  --fo-sidebar-active: 240 4.9% 83.9%;\n}\n[data-theme=\"dark\"] {\n  --fo-sidebar: 220 8.1% 14.5%;\n  --fo-sidebar-active: 198 31.3% 6.3%;\n}\n"
  },
  {
    "path": "packages/internal/components/assets/font.css",
    "content": "@import \"@fontsource/sn-pro/200-italic.css\";\n@import \"@fontsource/sn-pro/200.css\";\n@import \"@fontsource/sn-pro/300-italic.css\";\n@import \"@fontsource/sn-pro/300.css\";\n@import \"@fontsource/sn-pro/400-italic.css\";\n@import \"@fontsource/sn-pro/400.css\";\n@import \"@fontsource/sn-pro/500-italic.css\";\n@import \"@fontsource/sn-pro/500.css\";\n@import \"@fontsource/sn-pro/600-italic.css\";\n@import \"@fontsource/sn-pro/600.css\";\n@import \"@fontsource/sn-pro/700-italic.css\";\n@import \"@fontsource/sn-pro/700.css\";\n@import \"@fontsource/sn-pro/800-italic.css\";\n@import \"@fontsource/sn-pro/800.css\";\n@import \"@fontsource/sn-pro/900-italic.css\";\n@import \"@fontsource/sn-pro/900.css\";\n"
  },
  {
    "path": "packages/internal/components/assets/index.css",
    "content": "@import \"./colors.css\";\n@import \"./tailwind.css\";\n@import \"./font.css\";\n@import \"tailwindcss-uikit-colors/macos/selector.css\";\n"
  },
  {
    "path": "packages/internal/components/assets/tailwind.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  /* shadcn colors */\n\n  :root,\n  #shadow-html {\n    --radius: 0.5rem;\n  }\n\n  [data-theme=\"light\"] {\n    --background: 0 0% 100%;\n    --color-background: 255 255 255;\n\n    --border: 20 5.9% 90%;\n    --radius: 0.5rem;\n  }\n\n  [data-theme=\"dark\"] {\n    --background: 0 0% 7.1%;\n    --color-background: 18 18 18;\n\n    --border: 0 0% 22.1%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n\n  body {\n    @apply text-text;\n  }\n}\n\n.prose {\n  table * {\n    font-size: 1rem;\n  }\n\n  table table {\n    margin: 0;\n  }\n\n  p {\n    @apply break-words;\n  }\n\n  word-break: break-word;\n}\n\n@layer utilities {\n  [data-hide-in-print] {\n    @apply print:!hidden;\n  }\n}\n\ni {\n  @apply select-none;\n}\n\n@layer utilities {\n  .autospace-normal {\n    text-autospace: normal;\n  }\n}\n\n@supports (corner-shape: squircle) {\n  .shape-squircle {\n    corner-shape: squircle;\n    border-radius: 5%;\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/package.json",
    "content": "{\n  \"name\": \"@follow/components\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": \"./exports.ts\",\n    \"./assets/*\": \"./assets/*\",\n    \"./atoms/*\": \"./src/atoms/*\",\n    \"./common/*\": \"./src/common/*\",\n    \"./constants/*\": \"./src/constants/*\",\n    \"./dayjs\": \"./src/utils/dayjs.ts\",\n    \"./hooks/*\": \"./src/hooks/*\",\n    \"./icons/*\": \"./src/icons/*\",\n    \"./modules/*\": \"./src/modules/*\",\n    \"./providers/*\": \"./src/providers/*\",\n    \"./tailwind\": \"./assets/index.css\",\n    \"./ui/*\": \"./src/ui/*\",\n    \"./utils/*\": \"./src/utils/*\"\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@essentials/request-timeout\": \"1.3.0\",\n    \"@floating-ui/core\": \"1.7.2\",\n    \"@floating-ui/react\": \"0.27.17\",\n    \"@floating-ui/react-dom\": \"2.1.4\",\n    \"@follow/hooks\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"@headlessui/react\": \"2.2.9\",\n    \"@lexical/code\": \"0.40.0\",\n    \"@lexical/link\": \"0.40.0\",\n    \"@lexical/list\": \"0.40.0\",\n    \"@lexical/mark\": \"0.40.0\",\n    \"@lexical/markdown\": \"0.40.0\",\n    \"@lexical/react\": \"0.40.0\",\n    \"@lexical/rich-text\": \"0.40.0\",\n    \"@microflash/remark-callout-directives\": \"4.4.0\",\n    \"@radix-ui/react-accordion\": \"1.2.12\",\n    \"@radix-ui/react-avatar\": \"1.1.11\",\n    \"@radix-ui/react-checkbox\": \"1.3.3\",\n    \"@radix-ui/react-context-menu\": \"2.2.16\",\n    \"@radix-ui/react-dialog\": \"1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"2.1.16\",\n    \"@radix-ui/react-hover-card\": \"1.1.15\",\n    \"@radix-ui/react-label\": \"2.1.8\",\n    \"@radix-ui/react-navigation-menu\": \"1.2.14\",\n    \"@radix-ui/react-popover\": \"1.1.15\",\n    \"@radix-ui/react-progress\": \"1.1.8\",\n    \"@radix-ui/react-radio-group\": \"1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"1.2.10\",\n    \"@radix-ui/react-select\": \"2.2.6\",\n    \"@radix-ui/react-slider\": \"1.3.6\",\n    \"@radix-ui/react-slot\": \"1.2.4\",\n    \"@radix-ui/react-switch\": \"1.2.6\",\n    \"@radix-ui/react-tabs\": \"1.1.13\",\n    \"@radix-ui/react-toast\": \"1.2.15\",\n    \"@radix-ui/react-tooltip\": \"1.2.8\",\n    \"@react-hook/window-size\": \"3.1.1\",\n    \"@rehookify/datepicker\": \"6.6.8\",\n    \"@types/hast\": \"3.0.4\",\n    \"@types/unist\": \"3.0.3\",\n    \"class-variance-authority\": \"0.7.1\",\n    \"dayjs\": \"1.11.19\",\n    \"foxact\": \"0.2.52\",\n    \"hast-util-to-jsx-runtime\": \"2.3.6\",\n    \"hast-util-to-text\": \"4.0.2\",\n    \"input-otp\": \"1.4.2\",\n    \"jotai\": \"2.17.1\",\n    \"katex\": \"0.16.28\",\n    \"lexical\": \"0.40.0\",\n    \"masonic\": \"4.1.0\",\n    \"motion\": \"12.34.0\",\n    \"react-blurhash\": \"0.3.0\",\n    \"react-fast-marquee\": \"1.6.5\",\n    \"react-hook-form\": \"7.71.1\",\n    \"react-i18next\": \"16.5.4\",\n    \"rehype-infer-description-meta\": \"2.0.0\",\n    \"rehype-parse\": \"9.0.1\",\n    \"rehype-sanitize\": \"6.0.0\",\n    \"rehype-stringify\": \"10.0.1\",\n    \"remark-directive\": \"4.0.0\",\n    \"remark-gfm\": \"4.0.1\",\n    \"remark-gh-alerts\": \"0.0.3\",\n    \"remark-rehype\": \"11.1.2\",\n    \"sonner\": \"2.0.7\",\n    \"tailwindcss-uikit-colors\": \"catalog:\",\n    \"unified\": \"11.0.5\",\n    \"unist-util-visit\": \"5.1.0\",\n    \"unist-util-visit-parents\": \"5.1.3\",\n    \"use-context-selector\": \"2.0.0\",\n    \"usehooks-ts\": \"3.1.1\",\n    \"vaul\": \"1.1.2\",\n    \"vfile\": \"5.3.7\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\",\n    \"@types/katex\": \"0.16.8\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/atoms/mouse.ts",
    "content": "import { atom } from \"jotai\"\n\nexport const mouseAtom = atom({\n  x: 0,\n  y: 0,\n})\n"
  },
  {
    "path": "packages/internal/components/src/atoms/route.ts",
    "content": "import { createAtomHooks } from \"@follow/utils/jotai\"\nimport { atom, useAtomValue } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { useMemo } from \"react\"\nimport type { Location, NavigateFunction, Params } from \"react-router\"\nimport { shallow } from \"zustand/shallow\"\n\ninterface RouteAtom {\n  params: Readonly<Params<string>>\n  searchParams: URLSearchParams\n  location: Location<any>\n}\n\nexport const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(\n  atom<RouteAtom>({\n    params: {},\n    searchParams: new URLSearchParams(),\n\n    location: {\n      pathname: \"\",\n      search: \"\",\n      hash: \"\",\n      state: null,\n      key: \"\",\n    },\n  }),\n)\n\nconst noop: [] = []\nexport const useReadonlyRouteSelector = <T>(\n  selector: (route: RouteAtom) => T,\n  deps: any[] = noop,\n): T =>\n  useAtomValue(useMemo(() => selectAtom(routeAtom, (route) => selector(route), shallow), deps))\nexport const useReadonlyRoute = () => useAtomValue(routeAtom)\n\n// Vite HMR will create new router instance, but RouterProvider always stable\n\nconst [, , , , navigate, setNavigate] = createAtomHooks(\n  atom<{ fn: NavigateFunction | null }>({ fn() {} }),\n)\nconst getStableRouterNavigate = () => navigate().fn\nexport { getStableRouterNavigate, setNavigate }\n"
  },
  {
    "path": "packages/internal/components/src/atoms/viewport.ts",
    "content": "import { atom } from \"jotai\"\n\nconst { innerWidth: w, innerHeight: h } = window\nconst sm = w >= 640\nconst md = w >= 768\nconst lg = w >= 1024\nconst xl = w >= 1280\nconst _2xl = w >= 1536\n\nexport const viewportAtom = atom({\n  /**\n   * 640px\n   */\n  sm,\n\n  /**\n   * 768px\n   */\n  md,\n\n  /**\n   * 1024px\n   */\n  lg,\n\n  /**\n   * 1280px\n   */\n  xl,\n\n  /**\n   * 1536px\n   */\n  \"2xl\": _2xl,\n\n  h,\n  w,\n})\n"
  },
  {
    "path": "packages/internal/components/src/common/Focusable/Focusable.tsx",
    "content": "import * as React from \"react\"\nimport {\n  cloneElement,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport { useEventListener } from \"usehooks-ts\"\n\nimport {\n  FocusableContainerRefContext,\n  FocusableContext,\n  FocusActionsContext,\n  FocusTargetRefContext,\n} from \"./context\"\nimport { useSetGlobalFocusableScope } from \"./hooks\"\nimport { highlightElement } from \"./utils\"\n\nexport interface FocusableProps {\n  scope?: string\n  asChild?: boolean\n}\nexport const Focusable: Component<\n  React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & FocusableProps\n> = ({ ref, scope, asChild, ...props }) => {\n  const { onBlur, onFocus, ...rest } = props\n\n  const [isFocusWithIn, setIsFocusWithIn] = useState(false)\n  const focusTargetRef = useRef<HTMLElement | undefined>(void 0)\n\n  const containerRef = useRef<HTMLDivElement>(null)\n  useImperativeHandle(ref, () => containerRef.current!)\n\n  const highlightBoundary = useCallback(() => {\n    const { activeElement } = document\n    if (!containerRef.current?.contains(activeElement as Node)) {\n      return\n    }\n    const element = containerRef.current\n    if (!element) return\n\n    highlightElement(element)\n  }, [])\n\n  const setGlobalFocusableScope = useSetGlobalFocusableScope()\n  useEffect(() => {\n    if (!scope) {\n      return\n    }\n\n    const $container = containerRef.current\n    if (!$container) return\n\n    const focusIn = () => {\n      setGlobalFocusableScope(scope, \"append\")\n    }\n    $container.addEventListener(\"focusin\", focusIn)\n    const focusOut = () => {\n      if ($container.contains(document.activeElement as Node)) {\n        return\n      }\n      setGlobalFocusableScope(scope, \"remove\")\n    }\n    $container.addEventListener(\"focusout\", focusOut)\n\n    return () => {\n      $container.removeEventListener(\"focus\", focusIn)\n      $container.removeEventListener(\"blur\", focusOut)\n      $container.removeEventListener(\"focusin\", focusIn)\n      $container.removeEventListener(\"focusout\", focusOut)\n    }\n  }, [scope, setGlobalFocusableScope])\n\n  // highlight boundary\n  useEventListener(\"focusin\", (e) => {\n    if (containerRef.current?.contains(e.target as Node)) {\n      setIsFocusWithIn(true)\n      focusTargetRef.current = e.target as HTMLElement\n      if (import.meta.env.DEV) {\n        highlightElement(containerRef.current!, \"14, 165, 233\")\n        console.info(\"[Focusable] focusin\", containerRef.current)\n      }\n    } else {\n      setIsFocusWithIn(false)\n      focusTargetRef.current = undefined\n    }\n  })\n  useEffect(() => {\n    if (!containerRef.current) return\n    setIsFocusWithIn(containerRef.current.contains(document.activeElement as Node))\n  }, [containerRef])\n\n  if (asChild) {\n    assertChildren(rest.children)\n  }\n  return (\n    <FocusableContext value={isFocusWithIn}>\n      <FocusTargetRefContext value={focusTargetRef}>\n        <FocusActionsContext value={useMemo(() => ({ highlightBoundary }), [highlightBoundary])}>\n          <FocusableContainerRefContext value={containerRef}>\n            {asChild ? (\n              cloneElement(\n                rest.children as React.ReactElement<React.HTMLAttributes<HTMLDivElement>>,\n                {\n                  tabIndex: -1,\n                  role: \"region\",\n                  ...rest,\n                },\n              )\n            ) : (\n              <div tabIndex={-1} role=\"region\" ref={containerRef} {...rest} />\n            )}\n          </FocusableContainerRefContext>\n        </FocusActionsContext>\n      </FocusTargetRefContext>\n    </FocusableContext>\n  )\n}\n\nconst assertChildren = (children: React.ReactNode) => {\n  if (!children) {\n    throw new Error(\"[Focusable] `asChild` must have a child\")\n  }\n  const child = React.Children.count(children)\n  if (child !== 1) {\n    throw new Error(\"[Focusable] `asChild` must have exactly one child\")\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx",
    "content": "import { EnhanceSet } from \"@follow/utils\"\nimport { jotaiStore } from \"@follow/utils/jotai\"\nimport { atom } from \"jotai\"\nimport type { PropsWithChildren } from \"react\"\nimport { useEffect, useMemo } from \"react\"\n\nimport { GlobalFocusableContext } from \"./context\"\n\nexport const GlobalFocusableProvider = ({ children }: PropsWithChildren) => {\n  const ctxValue = useMemo(() => {\n    return atom(EnhanceSet.of<string>())\n  }, [])\n\n  if (import.meta.env.DEV) {\n    // eslint-disable-next-line react-hooks/rules-of-hooks\n    useEffect(() => {\n      return jotaiStore.sub(ctxValue, () => {\n        const v = jotaiStore.get(ctxValue)\n        console.info(\"[GlobalFocusableProvider] scope changed to:\", v)\n      })\n    }, [ctxValue])\n  }\n\n  return <GlobalFocusableContext value={ctxValue}>{children}</GlobalFocusableContext>\n}\n"
  },
  {
    "path": "packages/internal/components/src/common/Focusable/context.ts",
    "content": "import type { EnhanceSet } from \"@follow/utils\"\nimport type { PrimitiveAtom } from \"jotai\"\nimport { createContext } from \"react\"\n\nexport const FocusableContext = createContext(false)\nexport const FocusTargetRefContext = createContext<React.RefObject<HTMLElement | undefined>>(null!)\nexport const FocusableContainerRefContext = createContext<React.RefObject<HTMLDivElement | null>>(\n  null!,\n)\nexport const FocusActionsContext = createContext<{\n  highlightBoundary: () => void\n}>(null!)\n\nexport const GlobalFocusableContext = createContext<PrimitiveAtom<EnhanceSet<string>>>(null!)\n"
  },
  {
    "path": "packages/internal/components/src/common/Focusable/hooks.ts",
    "content": "import { EnhanceSet } from \"@follow/utils\"\nimport { jotaiStore } from \"@follow/utils/jotai\"\nimport { useAtomValue, useSetAtom } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { use, useCallback, useMemo } from \"react\"\n\nimport {\n  FocusableContainerRefContext,\n  FocusableContext,\n  FocusActionsContext,\n  FocusTargetRefContext,\n  GlobalFocusableContext,\n} from \"./context\"\n\nexport const useFocusable = () => {\n  return use(FocusableContext)\n}\n\nexport const useFocusTargetRef = () => {\n  return use(FocusTargetRefContext)\n}\n\nexport const useFocusActions = () => {\n  return use(FocusActionsContext)\n}\n\nexport const useFocusableContainerRef = () => {\n  return use(FocusableContainerRefContext)\n}\n\nexport const useGlobalFocusableHasScope = (scope: string) => {\n  return useGlobalFocusableScopeSelector(useCallback((v) => v.has(scope), [scope]))\n}\nexport const useGlobalFocusableScopeSelector = (\n  selector: (scope: EnhanceSet<string>) => boolean,\n) => {\n  const ctx = use(GlobalFocusableContext)\n\n  return useAtomValue(useMemo(() => selectAtom(ctx, selector), [ctx, selector]))\n}\n\nexport const useSetGlobalFocusableScope = () => {\n  const ctx = use(GlobalFocusableContext)\n  const setter = useSetAtom(ctx)\n  return useCallback(\n    (scope: string, mode: \"append\" | \"switch\" | \"remove\") => {\n      const snapshot = jotaiStore.get(ctx)\n      setter((v) => {\n        if (mode === \"append\") {\n          if (v.has(scope)) {\n            return v\n          }\n          const newSet = v.clone()\n          newSet.add(scope)\n          return newSet\n        } else if (mode === \"switch\") {\n          const newSet = v.clone()\n\n          if (newSet.has(scope)) {\n            newSet.delete(scope)\n          } else {\n            newSet.add(scope)\n          }\n          return newSet\n        } else {\n          if (!v.has(scope)) return v\n          const newSet = v.clone()\n          newSet.delete(scope)\n          return newSet\n        }\n      })\n\n      return {\n        original: snapshot,\n        new: jotaiStore.get(ctx),\n      }\n    },\n    [ctx, setter],\n  )\n}\n\nexport const useReplaceGlobalFocusableScope = () => {\n  const ctx = use(GlobalFocusableContext)\n  const setter = useSetAtom(ctx)\n  return useCallback(\n    (...scopes: string[]) => {\n      const snapshot = jotaiStore.get(ctx)\n      setter(() => {\n        const newSet = EnhanceSet.of<string>()\n        for (const scope of scopes) {\n          newSet.add(scope)\n        }\n        return newSet\n      })\n      return {\n        rollback: () => {\n          setter(snapshot)\n        },\n      }\n    },\n    [ctx, setter],\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/common/Focusable/index.ts",
    "content": "export * from \"./Focusable\"\nexport * from \"./hooks\"\n"
  },
  {
    "path": "packages/internal/components/src/common/Focusable/utils.ts",
    "content": "// Grok AI\n// Interface for keyframe properties\ninterface HighlightKeyframe {\n  shadowWidth: number\n  shadowOpacity: number\n  outlineWidth: number\n  outlineOpacity: number\n}\n\n// Cubic-bezier easing function (0.4, 0, 0.2, 1)\nfunction cubicBezier(t: number, p1x: number, p1y: number, p2x: number, p2y: number): number {\n  const cx: number = 3 * p1x\n  const bx: number = 3 * (p2x - p1x) - cx\n  const ax: number = 1 - cx - bx\n  const cy: number = 3 * p1y\n  const by: number = 3 * (p2y - p1y) - cy\n  const ay: number = 1 - cy - by\n\n  function sampleCurveX(t: number): number {\n    return ((ax * t + bx) * t + cx) * t\n  }\n  function sampleCurveY(t: number): number {\n    return ((ay * t + by) * t + cy) * t\n  }\n  function solveCurveX(x: number): number {\n    let t2: number = x\n    for (let i = 0; i < 8; i++) {\n      const x2: number = sampleCurveX(t2) - x\n      if (Math.abs(x2) < 1e-6) return t2\n      const d2: number = (3 * ax * t2 + 2 * bx) * t2 + cx\n      if (Math.abs(d2) < 1e-6) break\n      t2 -= x2 / d2\n    }\n    return t2\n  }\n  return sampleCurveY(solveCurveX(t))\n}\n\n// Shared canvas element for all highlight operations\nlet sharedCanvas: HTMLCanvasElement | null = null\nlet currentAnimationId: number | null = null\n\nexport function highlightElement(element: HTMLElement | null, colorString = \"255, 165, 0\"): void {\n  if (!element) return\n\n  // Cancel any ongoing animation\n  if (currentAnimationId !== null) {\n    cancelAnimationFrame(currentAnimationId)\n    currentAnimationId = null\n  }\n\n  // Get element's bounding rectangle\n  const rect: DOMRect = element.getBoundingClientRect()\n  const padding = 10 // Extra space for shadow effect\n\n  // Create or reuse canvas\n  if (!sharedCanvas) {\n    sharedCanvas = document.createElement(\"canvas\")\n    sharedCanvas.style.position = \"absolute\"\n    sharedCanvas.style.pointerEvents = \"none\"\n    sharedCanvas.style.zIndex = \"999999\"\n    sharedCanvas.id = \"follow-highlight-canvas\"\n    document.body.append(sharedCanvas)\n  }\n\n  // Update canvas position and size\n  sharedCanvas.style.top = `${rect.top + window.scrollY - padding}px`\n  sharedCanvas.style.left = `${rect.left + window.scrollX - padding}px`\n  sharedCanvas.width = rect.width + padding * 2\n  sharedCanvas.height = rect.height + padding * 2\n\n  const ctx: CanvasRenderingContext2D = sharedCanvas.getContext(\"2d\")!\n  if (!ctx) {\n    return\n  }\n\n  const duration = 1000 // 1000ms\n  const startTime: number = performance.now()\n  const borderRadius = 8 // Border radius for rounded corners\n\n  // Keyframes (mimicking CSS)\n  const keyframes: HighlightKeyframe[] = [\n    {\n      shadowWidth: 4,\n      shadowOpacity: 0.8,\n      outlineWidth: 1,\n      outlineOpacity: 0.5,\n    },\n    {\n      shadowWidth: 2,\n      shadowOpacity: 0.4,\n      outlineWidth: 1,\n      outlineOpacity: 0.3,\n    },\n    {\n      shadowWidth: 0,\n      shadowOpacity: 0,\n      outlineWidth: 0,\n      outlineOpacity: 0,\n    },\n  ]\n\n  function interpolate(start: number, end: number, factor: number): number {\n    return start + (end - start) * factor\n  }\n\n  function animate(): void {\n    const elapsed: number = performance.now() - startTime\n    let t: number = elapsed / duration\n    if (t > 1) t = 1\n\n    // Apply cubic-bezier easing\n    t = cubicBezier(t, 0.4, 0, 0.2, 1)\n\n    // Determine which keyframe segment we're in\n    const segmentDuration: number = 1 / (keyframes.length - 1)\n    const segmentIndex: number = Math.floor(t / segmentDuration)\n    const segmentT: number = (t - segmentIndex * segmentDuration) / segmentDuration\n\n    if (segmentIndex >= keyframes.length - 1) {\n      // Animation complete, hide canvas\n      if (sharedCanvas) {\n        sharedCanvas.style.display = \"none\"\n      }\n      currentAnimationId = null\n      return\n    }\n\n    const startFrame: HighlightKeyframe = keyframes[segmentIndex]!\n    const endFrame: HighlightKeyframe = keyframes[segmentIndex + 1]!\n\n    // Interpolate values\n    const shadowWidth: number = interpolate(startFrame.shadowWidth, endFrame.shadowWidth, segmentT)\n    const shadowOpacity: number = interpolate(\n      startFrame.shadowOpacity,\n      endFrame.shadowOpacity,\n      segmentT,\n    )\n    const outlineWidth: number = interpolate(\n      startFrame.outlineWidth,\n      endFrame.outlineWidth,\n      segmentT,\n    )\n    const outlineOpacity: number = interpolate(\n      startFrame.outlineOpacity,\n      endFrame.outlineOpacity,\n      segmentT,\n    )\n\n    // Clear canvas\n    ctx.clearRect(0, 0, sharedCanvas!.width, sharedCanvas!.height)\n\n    // Draw shadow (approximated as thick border with rounded corners)\n    if (shadowWidth > 0) {\n      ctx.strokeStyle = `rgba(${colorString}, ${shadowOpacity})` // Orange color\n      ctx.lineWidth = shadowWidth\n      ctx.beginPath()\n      ctx.roundRect(\n        padding + shadowWidth / 2,\n        padding + shadowWidth / 2,\n        rect.width - shadowWidth,\n        rect.height - shadowWidth,\n        borderRadius,\n      )\n      ctx.stroke()\n    }\n\n    // Draw outline (with rounded corners)\n    if (outlineWidth > 0) {\n      ctx.strokeStyle = `rgba(${colorString}, ${outlineOpacity})`\n      ctx.lineWidth = outlineWidth\n      ctx.beginPath()\n      ctx.roundRect(\n        padding + outlineWidth / 2,\n        padding + outlineWidth / 2,\n        rect.width - outlineWidth,\n        rect.height - outlineWidth,\n        borderRadius,\n      )\n      ctx.stroke()\n    }\n\n    if (t < 1) {\n      currentAnimationId = requestAnimationFrame(animate)\n    } else {\n      if (sharedCanvas) {\n        sharedCanvas.style.display = \"none\"\n      }\n      currentAnimationId = null\n    }\n  }\n\n  // Show canvas before starting animation\n  if (sharedCanvas) {\n    sharedCanvas.style.display = \"block\"\n  }\n\n  currentAnimationId = requestAnimationFrame(animate)\n}\n"
  },
  {
    "path": "packages/internal/components/src/common/Fragment.ts",
    "content": "import * as React from \"react\"\nimport { createElement } from \"react\"\n\nexport const PassviseFragment = ({ children }: { children: React.ReactNode }) => {\n  return createElement(React.Fragment, null, children)\n}\n"
  },
  {
    "path": "packages/internal/components/src/common/MemoedDangerousHTMLStyle.tsx",
    "content": "import type { FC } from \"react\"\nimport { memo, useMemo } from \"react\"\nimport * as React from \"react\"\n\nexport const MemoedDangerousHTMLStyle: FC<\n  {\n    children: string\n  } & React.DetailedHTMLProps<React.StyleHTMLAttributes<HTMLStyleElement>, HTMLStyleElement> &\n    Record<string, unknown>\n> = memo(({ children, ...rest }) => (\n  <style\n    {...rest}\n    dangerouslySetInnerHTML={useMemo(\n      () => ({\n        __html: children,\n      }),\n      [children],\n    )}\n  />\n))\n"
  },
  {
    "path": "packages/internal/components/src/common/MotionProvider.tsx",
    "content": "import { domMax, LazyMotion, MotionConfig } from \"motion/react\"\nimport * as React from \"react\"\n\nexport const MotionProvider = ({ children }: { children: React.ReactNode }) => {\n  return (\n    <LazyMotion features={domMax} strict key=\"framer\">\n      <MotionConfig\n        transition={{\n          type: \"tween\",\n          duration: 0.15,\n          ease: \"easeInOut\",\n        }}\n      >\n        {children}\n      </MotionConfig>\n    </LazyMotion>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/common/ReparentPortal.tsx",
    "content": "import type * as React from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { useLayoutEffect, useMemo, useRef } from \"react\"\nimport { createPortal } from \"react-dom\"\n\ntype Target = HTMLElement | null | string | (() => HTMLElement | null | undefined) | undefined\n\nexport interface ReparentPortalProps {\n  target: Target\n  children: React.ReactNode\n  hostClassName?: string\n  hostStyle?: CSSProperties\n  hostTag?: keyof HTMLElementTagNameMap\n  debugName?: string\n  /**\n   * Behavior when target is null:\n   * - true (default): keep the last parent container, do not unmount the subtree\n   * - false: remove the host from DOM (subtree is unmounted)\n   */\n  keepLastParentOnNull?: boolean\n}\n\nfunction resolveTarget(target: Target): HTMLElement | null {\n  if (target == null) return null\n  if (typeof target === \"string\") return document.querySelector(target) as HTMLElement | null\n  if (typeof target === \"function\") return target() ?? null\n  return target\n}\n\nexport function ReparentPortal({\n  target,\n  children,\n  hostClassName,\n  hostStyle,\n  hostTag = \"div\",\n  debugName,\n  keepLastParentOnNull = true,\n}: ReparentPortalProps) {\n  // Keep Fixed hostEl(Portal's container is always it)\n  const hostEl = useMemo(() => {\n    const el = document.createElement(hostTag)\n    if (debugName) el.dataset.reparentPortal = debugName\n    if (hostClassName != null) el.className = hostClassName\n    if (hostStyle != null) Object.assign(el.style, hostStyle)\n\n    return el\n  }, [hostTag, debugName])\n\n  const lastParentRef = useRef<HTMLElement | null>(null)\n\n  // Sync styles/classes to hostEl\n  useLayoutEffect(() => {\n    if (hostClassName != null) hostEl.className = hostClassName\n    if (hostStyle != null) Object.assign(hostEl.style, hostStyle)\n  }, [hostEl, hostClassName, hostStyle])\n\n  // Move the same hostEl to the target container\n  useLayoutEffect(() => {\n    const nextParent = resolveTarget(target)\n\n    if (nextParent) {\n      if (hostEl.parentNode !== nextParent) {\n        nextParent.append(hostEl)\n        lastParentRef.current = nextParent\n      }\n      return\n    }\n\n    // target is null\n    if (!keepLastParentOnNull) {\n      const prev = lastParentRef.current\n      if (prev && hostEl.parentNode === prev) {\n        hostEl.remove()\n      }\n      lastParentRef.current = null\n    }\n  }, [target, hostEl, keepLastParentOnNull])\n\n  // When unmounting, remove hostEl from DOM\n  useLayoutEffect(() => {\n    return () => {\n      const parent = hostEl.parentNode\n      if (parent) hostEl.remove()\n    }\n  }, [hostEl])\n\n  // Critical fix: no longer depends on \"attached\" secondary rendering, directly render to hostEl\n  // If you want to unmount the subtree when target is null and keepLastParentOnNull=false, you can check here:\n  if (!keepLastParentOnNull && !resolveTarget(target) && !lastParentRef.current) {\n    return null\n  }\n\n  return createPortal(children, hostEl)\n}\n"
  },
  {
    "path": "packages/internal/components/src/constants/spring.ts",
    "content": "import type { Transition } from \"motion/react\"\n/**\n * A smooth spring with a predefined duration and no bounce.\n */\nconst smoothPreset: Transition = {\n  type: \"spring\",\n  duration: 0.4,\n  bounce: 0,\n}\n\n/**\n * A spring with a predefined duration and small amount of bounce that feels more snappy.\n */\nconst snappyPreset: Transition = {\n  type: \"spring\",\n  duration: 0.4,\n  bounce: 0.15,\n}\n\n/**\n * A spring with a predefined duration and higher amount of bounce.\n */\nconst bouncyPreset: Transition = {\n  type: \"spring\",\n  duration: 0.4,\n  bounce: 0.3,\n}\n\nclass SpringStatic {\n  presets = {\n    smooth: smoothPreset,\n    snappy: snappyPreset,\n    bouncy: bouncyPreset,\n  }\n\n  /**\n   * A smooth spring with a predefined duration and no bounce that can be tuned.\n   *\n   * @param duration The perceptual duration, which defines the pace of the spring.\n   * @param extraBounce How much additional bounce should be added to the base bounce of 0.\n   */\n  smooth(duration = 0.4, extraBounce = 0): Transition {\n    return {\n      type: \"spring\",\n      duration,\n      bounce: extraBounce,\n    }\n  }\n\n  /**\n   * A spring with a predefined duration and small amount of bounce that feels more snappy.\n   */\n  snappy(duration = 0.4, extraBounce = 0): Transition {\n    return {\n      type: \"spring\",\n      duration,\n      bounce: 0.15 + extraBounce,\n    }\n  }\n\n  /**\n   * A spring with a predefined duration and higher amount of bounce that can be tuned.\n   */\n  bouncy(duration = 0.4, extraBounce = 0): Transition {\n    return {\n      type: \"spring\",\n      duration,\n      bounce: 0.3 + extraBounce,\n    }\n  }\n}\n\nconst SpringClass = new SpringStatic()\nexport { SpringClass as Spring }\n"
  },
  {
    "path": "packages/internal/components/src/hooks/useMedia.ts",
    "content": "import { useMediaQuery } from \"usehooks-ts\"\n\nexport const useIsPrinting = () => {\n  return useMediaQuery(\"print\")\n}\n"
  },
  {
    "path": "packages/internal/components/src/hooks/useMobile.ts",
    "content": "import { useViewport } from \"./useViewport\"\n\nexport const useMobile = () => {\n  return useViewport((v) => v.w < 1024 && v.w !== 0)\n}\n\nexport const isMobile = () => {\n  const w = window.innerWidth\n  return w < 1024 && w !== 0\n}\n"
  },
  {
    "path": "packages/internal/components/src/hooks/useMouse.ts",
    "content": "import { jotaiStore } from \"@follow/utils\"\nimport { useAtomValue } from \"jotai\"\n\nimport { mouseAtom } from \"../atoms/mouse\"\n\nexport const useMousePosition = () => {\n  return useAtomValue(mouseAtom)\n}\n\nexport const getMousePosition = () => jotaiStore.get(mouseAtom)\n"
  },
  {
    "path": "packages/internal/components/src/hooks/useViewport.ts",
    "content": "import type { ExtractAtomValue, getDefaultStore } from \"jotai\"\nimport { useAtomValue } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { useCallback } from \"react\"\nimport { shallow } from \"zustand/shallow\"\n\nimport { viewportAtom } from \"../atoms/viewport\"\n\nexport const useViewport = <T>(selector: (value: ExtractAtomValue<typeof viewportAtom>) => T): T =>\n  useAtomValue(\n    selectAtom(\n      viewportAtom,\n      useCallback((atomValue) => selector(atomValue), []),\n      shallow,\n    ),\n  )\n\ntype JotaiStore = ReturnType<typeof getDefaultStore>\nexport const getViewport = (store: JotaiStore) => store.get(viewportAtom)\n"
  },
  {
    "path": "packages/internal/components/src/icons/Database.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function MaterialSymbolsDatabaseOutline(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M12 21q-3.775 0-6.387-1.162T3 17V7q0-1.65 2.638-2.825T12 3t6.363 1.175T21 7v10q0 1.675-2.613 2.838T12 21m0-11.975q2.225 0 4.475-.638T19 7.025q-.275-.725-2.512-1.375T12 5q-2.275 0-4.462.638T5 7.025q.35.75 2.538 1.375T12 9.025M12 14q1.05 0 2.025-.1t1.863-.288t1.675-.462T19 12.525v-3q-.65.35-1.437.625t-1.675.463t-1.863.287T12 11t-2.05-.1t-1.888-.288T6.4 10.15T5 9.525v3q.625.35 1.4.625t1.663.463t1.887.287T12 14m0 5q1.15 0 2.338-.175t2.187-.462t1.675-.65t.8-.738v-2.45q-.65.35-1.437.625t-1.675.463t-1.863.287T12 16t-2.05-.1t-1.888-.288T6.4 15.15T5 14.525V17q.125.375.788.725t1.662.638t2.2.462T12 19\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/Meditation.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function MdiMeditation(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M12 4c1.11 0 2 .89 2 2s-.89 2-2 2s-2-.89-2-2s.9-2 2-2m9 12v-2c-2.24 0-4.16-.96-5.6-2.68l-1.34-1.6A1.98 1.98 0 0 0 12.53 9H11.5c-.61 0-1.17.26-1.55.72l-1.34 1.6C7.16 13.04 5.24 14 3 14v2c2.77 0 5.19-1.17 7-3.25V15l-3.88 1.55c-.67.27-1.12.95-1.12 1.66C5 19.2 5.8 20 6.79 20H9v-.5a2.5 2.5 0 0 1 2.5-2.5h3c.28 0 .5.22.5.5s-.22.5-.5.5h-3c-.83 0-1.5.67-1.5 1.5v.5h7.21c.99 0 1.79-.8 1.79-1.79c0-.71-.45-1.39-1.12-1.66L14 15v-2.25c1.81 2.08 4.23 3.25 7 3.25\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/MynauiInboxArchive.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function MynauiInboxArchiveSolid(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M16.76 2.25a2.75 2.75 0 0 1 2.462 1.526a1 1 0 0 1 .051.135l2.163 7.846a8.8 8.8 0 0 1 .314 2.325V19A2.75 2.75 0 0 1 19 21.75H5A2.75 2.75 0 0 1 2.25 19v-4.918c0-.785.106-1.567.314-2.325l2.163-7.846a1 1 0 0 1 .051-.135A2.75 2.75 0 0 1 7.24 2.25zm.31 11.5a1.25 1.25 0 0 0-1.04.557l-.812 1.218a2.75 2.75 0 0 1-2.288 1.225h-1.86a2.75 2.75 0 0 1-2.288-1.225l-.812-1.218a1.25 1.25 0 0 0-1.04-.557H3.758a7 7 0 0 0-.008.332V19A1.25 1.25 0 0 0 5 20.25h14A1.25 1.25 0 0 0 20.25 19v-4.918q0-.165-.008-.332zm-6.57-8a.75.75 0 0 0 0 1.5h3a.75.75 0 0 0 0-1.5zm-1.5 3a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 0-1.5z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/OouiUserAnonymous.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function OouiUserAnonymous(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M6 2.4 4.8 9.6 0 12s2.4 1.2 12 1.2S24 12 24 12l-4.8-2.4L18 2.4Zm1.2 12A3.6 3.6 0 0 0 3.6 18a3.6 3.6 0 0 0 3.6 3.6 3.6 3.6 0 0 0 3.6-3.6h2.4a3.6 3.6 0 0 0 3.6 3.6 3.6 3.6 0 0 0 3.6-3.6 3.6 3.6 0 0 0-3.6-3.6 3.6 3.6 0 0 0-3.384 2.4h-2.822A3.6 3.6 0 0 0 7.2 14.4\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/PhCloudCheck.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function PhCloudCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M15.198 3.198a8.81 8.81 0 0 0-7.873 4.868 6.401 6.401 0 1 0-.929 12.736h8.802a8.802 8.802 0 0 0 0-17.604m0 16.004H6.396a4.801 4.801 0 0 1 0-9.603c.11 0 .22 0 .33.011a8.8 8.8 0 0 0-.33 2.39.8.8 0 0 0 1.6 0 7.202 7.202 0 1 1 7.202 7.202m3.767-9.368a.8.8 0 0 1 0 1.132l-4.801 4.8a.8.8 0 0 1-1.132 0l-2.401-2.4a.8.8 0 0 1 1.132-1.132l1.835 1.835 4.235-4.235a.8.8 0 0 1 1.132 0\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/PhCloudWarning.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function PhCloudWarning(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M15.198 3.198a8.81 8.81 0 0 0-7.873 4.868 6.401 6.401 0 1 0-.929 12.736h8.802a8.802 8.802 0 0 0 0-17.604m0 16.004H6.396a4.801 4.801 0 0 1 0-9.603c.11 0 .22 0 .33.011a8.8 8.8 0 0 0-.33 2.39.8.8 0 0 0 1.6 0 7.202 7.202 0 1 1 7.202 7.202m-.8-7.202V8a.8.8 0 0 1 1.6 0v4a.8.8 0 0 1-1.6 0m2 3.6a1.2 1.2 0 1 1-1.2-1.2 1.2 1.2 0 0 1 1.2 1.2\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/PhCloudX.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function PhCloudX(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M15.2 3.198a8.81 8.81 0 0 0-7.874 4.869 6.402 6.402 0 1 0-.93 12.737H15.2a8.803 8.803 0 0 0 0-17.606m0 16.005H6.397a4.801 4.801 0 0 1 0-9.603c.11 0 .22 0 .329.011a8.8 8.8 0 0 0-.33 2.39.8.8 0 0 0 1.601 0 7.202 7.202 0 1 1 7.202 7.202m2.967-8.237-1.835 1.835 1.835 1.835a.8.8 0 0 1-1.132 1.132l-1.835-1.836-1.834 1.836a.8.8 0 0 1-1.133-1.132l1.836-1.835-1.836-1.835a.8.8 0 0 1 1.133-1.132l1.834 1.836 1.835-1.836a.8.8 0 0 1 1.132 1.132\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/Progress.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function MaterialSymbolsProgressActivity(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M12 22q-2.05 0-3.875-.788t-3.187-2.15q-1.363-1.362-2.15-3.187T2 12q0-2.075.788-3.887t2.15-3.175Q6.3 3.575 8.124 2.788T12 2q.425 0 .713.288T13 3q0 .425-.288.713T12 4Q8.675 4 6.337 6.338T4 12q0 3.325 2.338 5.663T12 20q3.325 0 5.663-2.337T20 12q0-.425.288-.712T21 11q.425 0 .713.288T22 12q0 2.05-.788 3.875t-2.15 3.188q-1.362 1.362-3.175 2.15T12 22\"\n      />\n    </svg>\n  )\n}\n\ninterface CircleProgressProps {\n  percent: number\n  size?: number\n  strokeWidth?: number\n  strokeColor?: string\n  backgroundColor?: string\n  className?: string\n}\n\nexport const CircleProgress: React.FC<CircleProgressProps> = ({\n  percent,\n  size = 100,\n  strokeWidth = 8,\n  strokeColor = \"currentColor\",\n  backgroundColor = \"hsl(var(--background))\",\n  className,\n}) => {\n  const normalizedPercent = Math.min(100, Math.max(0, percent))\n  const radius = (size - strokeWidth) / 2\n  const circumference = radius * 2 * Math.PI\n  const offset = circumference - (normalizedPercent / 100) * circumference\n\n  return (\n    <svg width={size} height={size} className={className}>\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        strokeLinecap=\"round\"\n        fill=\"none\"\n        stroke={backgroundColor}\n        strokeWidth={strokeWidth}\n      />\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        strokeLinecap=\"round\"\n        fill=\"none\"\n        stroke={strokeColor}\n        strokeWidth={strokeWidth}\n        strokeDasharray={circumference}\n        strokeDashoffset={offset}\n        className=\"duration-75\"\n        transform={`rotate(-90 ${size / 2} ${size / 2})`}\n        style={{ transition: \"stroke-dashoffset 0.3s\" }}\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/empty.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nexport const EmptyIcon: Component = ({ className }) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    className={cn(\"fill-current\", className)}\n  >\n    <path d=\"M20.904 4.064a.6.6 0 0 0-.587-.246q-.013-.004-.026-.007l-9.16-1.344a.6.6 0 0 0-.297.03l-7.337 2.69q-.004.001-.01.004-.01.006-.022.01a.63.63 0 0 0-.244.191l-.006.006L.117 9.636a.61.61 0 0 0 .339.948l2.688.694v6.646c0 .267.175.503.43.581l9.843 3.008q.006 0 .01.002a.6.6 0 0 0 .163.024l.005.001q.087 0 .172-.027.026-.01.052-.021c.024-.01.048-.016.071-.028l7.063-3.92a.61.61 0 0 0 .313-.531v-6.645l2.39-1.147a.608.608 0 0 0 .234-.897zm-9.797-.372 7.15 1.05-4.743 2.205-7.51-1.384ZM3.972 6.424l8.605 1.586-2.53 3.795-8.42-2.174Zm.388 11.05v-5.883l5.802 1.498a.608.608 0 0 0 .658-.251l2.167-3.25V20.11Zm15.69-.82L14.202 19.9v-9.6l1.068 2.268a.61.61 0 0 0 .812.29l3.967-1.904zm-3.94-5.159-1.705-3.622L20.2 5.18l2.283 3.257z\" />\n  </svg>\n)\n"
  },
  {
    "path": "packages/internal/components/src/icons/follow.tsx",
    "content": "import * as React from \"react\"\n\nexport const FollowIcon: Component = (props) => (\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" {...props}>\n    <path\n      fill=\"currentColor\"\n      d=\"M20.791.455H6.136a3.207 3.207 0 0 0-3.21 3.206 3.207 3.207 0 0 0 3.21 3.206H20.79A3.207 3.207 0 0 0 24 3.66 3.21 3.21 0 0 0 20.791.455M12.977 8.79H3.209A3.207 3.207 0 0 0 0 11.997a3.207 3.207 0 0 0 3.209 3.205h9.768a3.207 3.207 0 0 0 3.209-3.205 3.207 3.207 0 0 0-3.21-3.207m.945 11.55a3.207 3.207 0 0 0-3.21-3.207 3.207 3.207 0 0 0-3.208 3.206 3.207 3.207 0 0 0 3.209 3.206 3.207 3.207 0 0 0 3.209-3.206\"\n    />\n  </svg>\n)\n"
  },
  {
    "path": "packages/internal/components/src/icons/folo.tsx",
    "content": "import * as React from \"react\"\n\nexport const Folo = ({\n  ref,\n  ...props\n}: React.SVGProps<SVGSVGElement> & { ref?: React.Ref<SVGSVGElement | null> }) => (\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" {...props} ref={ref}>\n    <path\n      fill=\"currentColor\"\n      d=\"M.899 16.997c-.567 0-.899-.358-.899-.994v-7.77c0-.637.36-.996 1.01-.996h4.34c.595 0 .927.29.927.788 0 .497-.332.774-.926.774H1.797v2.336H5.06c.595 0 .927.263.927.76 0 .512-.332.775-.927.775H1.797v3.332c0 .636-.318.996-.898.996m9.035.125c-2.101 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.13 0 3.567 1.534 3.567 3.705 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185m5.266 1.367c-.553 0-.857-.359-.857-.967V7.845c0-.608.304-.968.857-.968s.857.36.857.968v8.185c0 .608-.29.967-.857.967m5.234.125c-2.102 0-3.553-1.52-3.553-3.664 0-2.17 1.438-3.705 3.553-3.705 2.129 0 3.566 1.534 3.566 3.704 0 2.143-1.452 3.664-3.567 3.664m0-1.493c1.134 0 1.825-.899 1.825-2.185 0-1.3-.691-2.198-1.825-2.198s-1.797.899-1.797 2.198c0 1.286.663 2.185 1.797 2.185\"\n    />\n  </svg>\n)\n"
  },
  {
    "path": "packages/internal/components/src/icons/infinify.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function CarbonInfinitySymbol(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M17.25 17.25c-4.242 0-5.893-4.807-5.962-5.013-.013-.038-1.382-3.987-4.538-3.987A3.754 3.754 0 0 0 3 12a3.754 3.754 0 0 0 3.75 3.75c1.191 0 2.26-.549 3.178-1.632l1.144.97C9.873 16.502 8.38 17.25 6.75 17.25A5.256 5.256 0 0 1 1.5 12a5.256 5.256 0 0 1 5.25-5.25c4.242 0 5.894 4.808 5.962 5.013.013.038 1.382 3.987 4.538 3.987A3.754 3.754 0 0 0 21 12a3.754 3.754 0 0 0-3.75-3.75c-1.191 0-2.26.549-3.178 1.632l-1.144-.97C14.127 7.498 15.62 6.75 17.25 6.75A5.256 5.256 0 0 1 22.5 12a5.256 5.256 0 0 1-5.25 5.25\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/logo.tsx",
    "content": "import * as React from \"react\"\n\nexport const Logo = ({\n  ref,\n  ...props\n}: React.SVGProps<SVGSVGElement> & {\n  ref?: React.Ref<SVGSVGElement | null>\n  accentColor?: string\n}) => {\n  const { accentColor } = props\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" {...props} ref={ref}>\n      <title>Folo</title>\n      <path\n        fill={accentColor || \"#ff5c00\"}\n        d=\"M5.382 0h13.236A5.37 5.37 0 0 1 24 5.383v13.235A5.37 5.37 0 0 1 18.618 24H5.382A5.37 5.37 0 0 1 0 18.618V5.383A5.37 5.37 0 0 1 5.382.001Z\"\n      />\n      <path\n        fill=\"#fff\"\n        d=\"M13.269 17.31a1.813 1.813 0 1 0-3.626.002 1.813 1.813 0 0 0 3.626-.002m-.535-6.527H7.213a1.813 1.813 0 1 0 0 3.624h5.521a1.813 1.813 0 1 0 0-3.624m4.417-4.712H8.87a1.813 1.813 0 1 0 0 3.625h8.283a1.813 1.813 0 1 0 0-3.624z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/nft.tsx",
    "content": "import type { SVGProps } from \"react\"\nimport * as React from \"react\"\n\nexport function RiNftFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M9 12a2 2 0 1 0 0-4a2 2 0 0 0 0 4m3-11l9.5 5.5v11L12 23l-9.5-5.5v-11zM4.5 7.653v8.694l2.372 1.373l8.073-5.92l4.555 2.734v-6.88L12 3.31z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/resize.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function LetsIconsResizeDownRightLight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path fill=\"currentColor\" d=\"M12 17h5v-5m-7 8h10V10\" />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/user.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function LucideLogIn(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      {/* Icon from Lucide by Lucide Contributors - https://github.com/lucide-icons/lucide/blob/main/LICENSE */}\n      <path\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"2\"\n        d=\"m10 17l5-5l-5-5m5 5H3m12-9h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/icons/users.tsx",
    "content": "import type { SVGProps } from \"react\"\nimport * as React from \"react\"\n\nexport function PhUsersBold(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M11.734 14.924a6.048 6.048 0 1 0-7.782 0A9.5 9.5 0 0 0 .22 17.948a1.134 1.134 0 0 0 1.828 1.342 7.182 7.182 0 0 1 11.59 0 1.134 1.134 0 0 0 1.83-1.342 9.5 9.5 0 0 0-3.734-3.024M4.063 10.3a3.78 3.78 0 1 1 3.78 3.78 3.78 3.78 0 0 1-3.78-3.78m19.476 9.23a1.134 1.134 0 0 1-1.585-.243 7.21 7.21 0 0 0-5.795-2.939 1.134 1.134 0 0 1 0-2.268 3.78 3.78 0 1 0-.973-7.434 1.134 1.134 0 1 1-.583-2.191 6.048 6.048 0 0 1 5.447 10.47 9.5 9.5 0 0 1 3.732 3.024 1.134 1.134 0 0 1-.243 1.581\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/providers/event-provider.tsx",
    "content": "import { viewportAtom } from \"@follow/components/atoms/viewport.js\"\nimport { throttle } from \"es-toolkit/compat\"\nimport { useIsomorphicLayoutEffect } from \"foxact/use-isomorphic-layout-effect\"\nimport { useStore } from \"jotai\"\nimport type { FC } from \"react\"\n\nimport { mouseAtom } from \"../atoms/mouse\"\n\nexport const EventProvider: FC = () => {\n  const store = useStore()\n  useIsomorphicLayoutEffect(() => {\n    const readViewport = throttle(() => {\n      const { innerWidth: w, innerHeight: h } = window\n      const sm = w >= 640\n      const md = w >= 768\n      const lg = w >= 1024\n      const xl = w >= 1280\n      const _2xl = w >= 1536\n      store.set(viewportAtom, {\n        sm,\n        md,\n        lg,\n        xl,\n        \"2xl\": _2xl,\n        h,\n        w,\n      })\n\n      const isMobile = window.innerWidth < 1024\n      document.documentElement.dataset.viewport = isMobile ? \"mobile\" : \"desktop\"\n    }, 16)\n\n    readViewport()\n\n    window.addEventListener(\"resize\", readViewport)\n    return () => {\n      window.removeEventListener(\"resize\", readViewport)\n    }\n  }, [])\n\n  useIsomorphicLayoutEffect(() => {\n    const handleMouseMove = (e: MouseEvent) => {\n      store.set(mouseAtom, { x: e.clientX, y: e.clientY })\n    }\n    window.addEventListener(\"mousemove\", handleMouseMove)\n    return () => window.removeEventListener(\"mousemove\", handleMouseMove)\n  }, [store])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/providers/stable-router-provider.tsx",
    "content": "import { setNavigate, setRoute } from \"@follow/components/atoms/route.js\"\nimport { useLayoutEffect } from \"react\"\nimport type { NavigateFunction } from \"react-router\"\nimport { useLocation, useNavigate, useParams, useSearchParams } from \"react-router\"\n\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface CustomRoute {}\nexport interface GlobalRoute extends CustomRoute {\n  navigate: NavigateFunction\n}\ndeclare global {\n  interface Window {\n    router: GlobalRoute\n  }\n}\nwindow.router = {\n  navigate() {},\n} as any\n\n/**\n * Why this.\n * Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render.\n * This provider is hold a empty component, to store the router hooks value.\n * And use our router hooks will not re-render the component when the router has any changes.\n * Also it can access values outside of the component and provide a value selector\n */\nexport const StableRouterProvider = () => {\n  const [searchParams] = useSearchParams()\n  const params = useParams()\n  const nav = useNavigate()\n  const location = useLocation()\n\n  // NOTE: This is a hack to expose the navigate function to the window object, avoid to import `router` circular issue.\n  useLayoutEffect(() => {\n    window.router.navigate = nav\n    setRoute({\n      params,\n      searchParams,\n      location,\n    })\n    setNavigate({ fn: nav })\n  }, [searchParams, params, location, nav])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/auto-resize-height/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport { useEffect, useRef, useState } from \"react\"\n\nimport { Spring } from \"../../constants/spring\"\n\ninterface AnimateChangeInHeightProps {\n  children: React.ReactNode\n  className?: string\n  duration?: number\n\n  innerClassName?: string\n}\n\nexport const AutoResizeHeight: React.FC<AnimateChangeInHeightProps> = ({\n  children,\n  className,\n  duration = 0.2,\n\n  innerClassName,\n}) => {\n  const containerRef = useRef<HTMLDivElement | null>(null)\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\")\n\n  useEffect(() => {\n    if (!containerRef.current) return\n    const resizeObserver = new ResizeObserver((entries) => {\n      // We only have one entry, so we can use entries[0].\n      const target = entries[0]!.target as HTMLElement\n      const observedHeight = entries[0]!.contentRect.height\n      const style = getComputedStyle(target)\n\n      const marginHeight =\n        Number.parseFloat(style.marginTop) + Number.parseFloat(style.marginBottom)\n      // add margin top\n      setHeight(observedHeight + marginHeight)\n    })\n\n    resizeObserver.observe(containerRef.current)\n\n    return () => {\n      // Cleanup the observer when the component is unmounted\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  return (\n    <m.div\n      className={cn(\"overflow-hidden\", className)}\n      initial={false}\n      animate={{ height }}\n      transition={Spring.smooth(duration)}\n    >\n      <div className={cn(\"overflow-hidden\", innerClassName)} ref={containerRef}>\n        {children}\n      </div>\n    </m.div>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/avatar/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\nimport * as React from \"react\"\n\nexport const Avatar = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {\n  ref?: React.Ref<React.ElementRef<typeof AvatarPrimitive.Root> | null>\n}) => (\n  <AvatarPrimitive.Root\n    ref={ref}\n    className={cn(\"relative flex size-10 shrink-0 overflow-hidden rounded-full\", className)}\n    {...props}\n  />\n)\nAvatar.displayName = AvatarPrimitive.Root.displayName\n\nexport const AvatarImage = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {\n  ref?: React.Ref<React.ElementRef<typeof AvatarPrimitive.Image> | null>\n}) => (\n  <AvatarPrimitive.Image\n    ref={ref}\n    className={cn(\"aspect-square size-full\", className)}\n    {...props}\n  />\n)\nAvatarImage.displayName = AvatarPrimitive.Image.displayName\n\nexport const AvatarFallback = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> & {\n  ref?: React.Ref<React.ElementRef<typeof AvatarPrimitive.Fallback> | null>\n}) => (\n  <AvatarPrimitive.Fallback\n    ref={ref}\n    className={cn(\n      \"flex size-full items-center justify-center rounded-full bg-material-opaque\",\n      className,\n    )}\n    {...props}\n  />\n)\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName\n"
  },
  {
    "path": "packages/internal/components/src/ui/avatar-group/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { Transition } from \"motion/react\"\nimport { m as motion } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { Spring } from \"../../constants/spring\"\nimport { Tooltip, TooltipTrigger } from \"../tooltip\"\n\ntype AvatarProps = {\n  children: React.ReactNode\n  zIndex: number\n  transition: Transition\n  translate: string | number\n}\n\nconst AvatarContainer = React.memo(({ children, zIndex, transition, translate }: AvatarProps) => {\n  return (\n    <TooltipTrigger asChild>\n      <motion.div\n        data-slot=\"avatar-container\"\n        initial=\"initial\"\n        whileHover=\"hover\"\n        whileTap=\"hover\"\n        className=\"relative\"\n        style={{ zIndex }}\n      >\n        <motion.div\n          variants={{\n            initial: { y: 0 },\n            hover: { y: translate },\n          }}\n          transition={transition}\n        >\n          {children}\n        </motion.div>\n      </motion.div>\n    </TooltipTrigger>\n  )\n})\n\ntype AvatarGroupProps = Omit<React.ComponentProps<\"div\">, \"translate\"> & {\n  children: React.ReactElement[]\n  transition?: Transition\n  translate?: string | number\n}\n\nfunction AvatarGroup({\n  ref,\n  children,\n  className,\n  transition = Spring.presets.smooth,\n  translate = \"-10%\",\n\n  ...props\n}: AvatarGroupProps) {\n  return (\n    <div\n      ref={ref}\n      data-slot=\"avatar-group\"\n      className={cn(\"flex h-8 flex-row items-center -space-x-2\", className)}\n      {...props}\n    >\n      {children?.map((child, index) => (\n        <Tooltip delayDuration={0} key={index}>\n          <AvatarContainer zIndex={index} transition={transition} translate={translate}>\n            {child}\n          </AvatarContainer>\n        </Tooltip>\n      ))}\n    </div>\n  )\n}\n\nexport { AvatarGroup, type AvatarGroupProps }\n"
  },
  {
    "path": "packages/internal/components/src/ui/button/action-button.tsx",
    "content": "import {\n  useFocusable,\n  useGlobalFocusableScopeSelector,\n} from \"@follow/components/common/Focusable/index.js\"\nimport type { EnhanceSet } from \"@follow/utils\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn, getOS } from \"@follow/utils/utils\"\nimport * as React from \"react\"\nimport { useCallback, useState } from \"react\"\nimport type { Options } from \"react-hotkeys-hook\"\nimport { useHotkeys } from \"react-hotkeys-hook\"\n\nimport { KbdCombined } from \"../kbd/Kbd\"\nimport { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from \"../tooltip\"\n\nexport interface ActionButtonProps {\n  icon?: React.ReactNode | ((props: { isActive?: boolean; className: string }) => React.ReactNode)\n  tooltip?: React.ReactNode\n  tooltipDescription?: React.ReactNode\n  tooltipSide?: \"top\" | \"bottom\"\n  active?: boolean\n  disabled?: boolean\n  clickableDisabled?: boolean\n  shortcut?: string\n  disableTriggerShortcut?: boolean\n  enableHoverableContent?: boolean\n  size?: \"xs\" | \"sm\" | \"base\" | \"lg\"\n  id?: string\n  /**\n   * Use motion effects to prompt and guide users to pay attention or click this button\n   */\n  highlightMotion?: boolean\n  /**\n   * @description only trigger shortcut when focus with in `<Focusable />`\n   * @default false\n   */\n  shortcutOnlyFocusWithIn?: boolean\n  /**\n   * @description only trigger shortcut when in the scope, if not provided, the shortcut will be triggered in any scope\n   * @default undefined\n   */\n  shortcutScope?: string | ((scope: EnhanceSet<string>) => boolean)\n}\n\nconst actionButtonStyleVariant = {\n  size: {\n    lg: tw`text-xl size-10`,\n    base: tw`text-xl size-8`,\n    sm: tw`text-lg size-7`,\n    xs: tw`text-base size-[1.3rem]`,\n  },\n}\n\nexport const ActionButton = ({\n  ref,\n  icon,\n  id,\n  tooltip,\n  tooltipDescription,\n  className,\n  tooltipSide,\n  highlightMotion,\n  children,\n  active,\n  shortcut,\n  disabled,\n  clickableDisabled,\n  disableTriggerShortcut,\n  enableHoverableContent,\n  size = \"base\",\n  shortcutOnlyFocusWithIn,\n  onClick,\n  shortcutScope,\n  ...rest\n}: ComponentType<ActionButtonProps> &\n  React.HTMLAttributes<HTMLButtonElement> & {\n    ref?: React.Ref<HTMLButtonElement | null>\n  }) => {\n  const finalShortcut =\n    getOS() === \"Windows\" ? shortcut?.replace(\"meta\", \"ctrl\").replace(\"Meta\", \"Ctrl\") : shortcut\n  const buttonRef = React.useRef<HTMLButtonElement>(null)\n  React.useImperativeHandle(ref, () => buttonRef.current!)\n\n  const [shouldHighlightMotion, setShouldHighlightMotion] = useState(highlightMotion)\n  React.useEffect(() => {\n    setShouldHighlightMotion(highlightMotion)\n  }, [highlightMotion])\n\n  const inScope = useGlobalFocusableScopeSelector(\n    useCallback(\n      (scope) =>\n        shortcutScope\n          ? typeof shortcutScope === \"function\"\n            ? shortcutScope(scope)\n            : scope.has(shortcutScope)\n          : true,\n      [shortcutScope],\n    ),\n  )\n\n  const [loading, setLoading] = useState(false)\n\n  const Trigger = (\n    <button\n      ref={buttonRef}\n      // @see https://github.com/radix-ui/primitives/issues/2248#issuecomment-2147056904\n      onFocusCapture={stopPropagation}\n      className={cn(\n        \"no-drag-region pointer-events-auto inline-flex items-center justify-center\",\n        active && typeof icon !== \"function\" && \"bg-zinc-500/15 hover:bg-zinc-500/20\",\n        \"rounded-md duration-200 hover:bg-theme-item-hover data-[state=open]:bg-theme-item-active\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2\",\n        \"disabled:cursor-not-allowed disabled:opacity-50\",\n        clickableDisabled && \"cursor-not-allowed opacity-50\",\n        shouldHighlightMotion &&\n          \"relative after:absolute after:inset-0 after:animate-[radialPulse_3s_ease-in-out_infinite] after:rounded-md after:bg-center after:bg-no-repeat after:content-['']\",\n        actionButtonStyleVariant.size[size],\n        className,\n      )}\n      style={{\n        ...rest.style,\n        ...(shouldHighlightMotion\n          ? ({\n              \"--tw-accent-opacity\": \"0.3\",\n              \"--highlight-color\": \"hsl(var(--fo-a) / var(--tw-accent-opacity))\",\n            } as React.CSSProperties)\n          : {}),\n      }}\n      type=\"button\"\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      aria-disabled={disabled || clickableDisabled || undefined}\n      onClick={\n        onClick\n          ? async (e) => {\n              setShouldHighlightMotion(false)\n              if (loading) return\n              setLoading(true)\n              try {\n                await (onClick(e) as void | Promise<void>)\n              } finally {\n                setLoading(false)\n              }\n            }\n          : void 0\n      }\n      id={id}\n      {...rest}\n    >\n      {loading ? (\n        <i className=\"i-mgc-loading-3-cute-re animate-spin\" />\n      ) : typeof icon === \"function\" ? (\n        React.createElement(icon, {\n          className: \"size-4 grayscale text-current\",\n\n          isActive: active,\n        })\n      ) : (\n        icon\n      )}\n\n      {children}\n    </button>\n  )\n\n  return (\n    <>\n      {finalShortcut && !disableTriggerShortcut && inScope && (\n        <HotKeyTrigger\n          shortcut={finalShortcut}\n          fn={() => buttonRef.current?.click()}\n          shortcutOnlyFocusWithIn={shortcutOnlyFocusWithIn}\n        />\n      )}\n      {tooltip ? (\n        <Tooltip disableHoverableContent={!enableHoverableContent}>\n          <TooltipTrigger aria-label={typeof tooltip === \"string\" ? tooltip : undefined} asChild>\n            {Trigger}\n          </TooltipTrigger>\n          <TooltipPortal>\n            <TooltipContent className=\"max-w-[300px] flex-col gap-1\" side={tooltipSide ?? \"bottom\"}>\n              <div className=\"flex items-center gap-1\">\n                {tooltip}\n                {!!finalShortcut && (\n                  <div className=\"ml-1\">\n                    <KbdCombined className=\"text-text\">{finalShortcut}</KbdCombined>\n                  </div>\n                )}\n              </div>\n              {tooltipDescription ? (\n                <div className=\"text-body text-text-secondary\">{tooltipDescription}</div>\n              ) : null}\n            </TooltipContent>\n          </TooltipPortal>\n        </Tooltip>\n      ) : (\n        Trigger\n      )}\n    </>\n  )\n}\n\nconst HotKeyTrigger = ({\n  shortcut,\n  fn,\n  options,\n  shortcutOnlyFocusWithIn,\n}: {\n  shortcut: string\n  fn: () => void\n  options?: Options\n  shortcutOnlyFocusWithIn?: boolean\n}) => {\n  const isFocusWithIn = useFocusable()\n  const enabledInOptions = options?.enabled || true\n\n  useHotkeys(replaceShortcut(shortcut), fn, {\n    preventDefault: true,\n    enabled: shortcutOnlyFocusWithIn\n      ? isFocusWithIn\n        ? enabledInOptions\n        : false\n      : enabledInOptions,\n    ...options,\n  })\n  return null\n}\n\nconst os = getOS()\n\nconst replaceShortcut = (shortcut: string) => {\n  return shortcut.replace(\"$mod\", os === \"macOS\" ? \"Meta\" : \"Ctrl\")\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/button/index.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { LoadingCircle } from \"@follow/components/ui/loading/index.jsx\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { styledButtonVariant } from \"./variants\"\n\nexport interface BaseButtonProps {\n  isLoading?: boolean\n}\n\n// BIZ buttons\n\nconst motionBaseMap = {\n  pc: {\n    whileFocus: { scale: 1.02 },\n    whileTap: { scale: 0.97 },\n    whileHover: { translateY: -1 },\n  },\n  mobile: {\n    whileFocus: { opacity: 0.8 },\n    whileTap: { opacity: 0.6 },\n  },\n} as const\nexport const MotionButtonBase = ({\n  ref,\n  children,\n  disabled,\n  ...rest\n}: HTMLMotionProps<\"button\"> & { ref?: React.Ref<HTMLButtonElement | null> }) => {\n  const isMobile = useMobile()\n  return (\n    <m.button\n      layout=\"size\"\n      initial\n      disabled={disabled}\n      {...(disabled ? {} : motionBaseMap[isMobile ? \"mobile\" : \"pc\"])}\n      {...rest}\n      ref={ref}\n    >\n      {children}\n    </m.button>\n  )\n}\n\nMotionButtonBase.displayName = \"MotionButtonBase\"\n\nexport const Button = ({\n  ref,\n  buttonClassName,\n  disabled,\n  isLoading,\n  variant,\n  status,\n  size,\n  textClassName,\n  ...props\n}: React.PropsWithChildren<\n  Omit<HTMLMotionProps<\"button\">, \"children\" | \"className\"> &\n    BaseButtonProps &\n    VariantProps<typeof styledButtonVariant> & {\n      buttonClassName?: string\n      textClassName?: string\n    }\n> & { ref?: React.Ref<HTMLButtonElement | null> }) => {\n  const handleClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(\n    (e) => {\n      if (isLoading || disabled) {\n        e.preventDefault()\n        return\n      }\n\n      props.onClick?.(e)\n    },\n    [disabled, isLoading, props],\n  )\n  return (\n    <MotionButtonBase\n      ref={ref}\n      className={cn(\n        styledButtonVariant({\n          variant,\n          status: isLoading || disabled ? \"disabled\" : undefined,\n          size,\n        }),\n        buttonClassName,\n      )}\n      disabled={isLoading || disabled}\n      {...props}\n      onClick={handleClick}\n    >\n      <span className=\"contents\">\n        {typeof isLoading === \"boolean\" && (\n          <m.span\n            className=\"center overflow-hidden\"\n            animate={{\n              width: isLoading ? \"1.2em\" : \"0px\",\n              marginRight: isLoading ? \"0.5rem\" : \"0\",\n              opacity: isLoading ? 1 : 0,\n            }}\n            transition={{ duration: 0.2 }}\n          >\n            {isLoading && <LoadingCircle size=\"small\" className=\"center\" />}\n          </m.span>\n        )}\n        <span className={cn(\"center\", textClassName)}>{props.children}</span>\n      </span>\n    </MotionButtonBase>\n  )\n}\n\nexport const IconButton = ({\n  ref,\n  ...props\n}: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> &\n  React.PropsWithChildren<{\n    icon: React.JSX.Element\n  }> & { ref?: React.Ref<HTMLButtonElement | null> }) => {\n  const { icon, ...rest } = props\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      {...rest}\n      className={cn(\n        styledButtonVariant({\n          variant: \"ghost\",\n        }),\n        \"group relative gap-2 bg-accent/10 px-4 transition-all duration-300 hover:bg-accent/20 active:bg-accent/30 dark:bg-accent/20 dark:hover:bg-accent/30 dark:active:bg-accent/40\",\n        rest.className,\n      )}\n    >\n      <span className=\"center\">\n        {React.cloneElement(icon, {\n          className: cn(\"invisible\", icon.props.className),\n        })}\n\n        {React.cloneElement(icon, {\n          className: cn(\n            \"group-hover:text-white dark:group-hover:text-inherit\",\n            \"absolute left-4 top-1/2 -translate-y-1/2 duration-200 group-hover:left-1/2 group-hover:-translate-x-1/2\",\n            icon.props.className,\n          ),\n        })}\n      </span>\n      <span className=\"duration-200 group-hover:opacity-0\">{props.children}</span>\n    </button>\n  )\n}\n\nexport { ActionButton, type ActionButtonProps } from \"./action-button\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/button/interface.ts",
    "content": "import type * as React from \"react\"\n\nexport interface BaseButtonProps {\n  isLoading?: boolean\n}\n\n// BIZ buttons\n\nexport interface ActionButtonProps {\n  icon?: React.ReactNode | React.FC<ComponentType>\n  tooltip?: React.ReactNode\n  tooltipSide?: \"top\" | \"bottom\"\n  active?: boolean\n  disabled?: boolean\n  shortcut?: string\n  disableTriggerShortcut?: boolean\n  enableHoverableContent?: boolean\n  size?: \"sm\" | \"md\" | \"base\"\n\n  /**\n   * @description only trigger shortcut when focus with in `<Focusable />`\n   * @default false\n   */\n  shortcutOnlyFocusWithIn?: boolean\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/button/variants.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { cva } from \"class-variance-authority\"\n\n// Design\n// @see https://x.com/uiuxadrian/status/1822947443186504176\n\nexport const styledButtonVariant = cva(\n  [\n    \"inline-flex cursor-button select-none items-center justify-center outline-offset-2 transition-colors active:transition-none disabled:cursor-not-allowed\",\n    \"duration-200 disabled:ring-0\",\n    \"align-middle\",\n    \"focus:outline-none focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2\",\n  ],\n  {\n    compoundVariants: [\n      {\n        variant: \"primary\",\n        status: \"disabled\",\n        className: \"text-zinc-50 bg-theme-disabled\",\n      },\n      {\n        variant: \"outline\",\n        status: \"disabled\",\n        className:\n          \"text-theme-disabled border-theme-inactive dark:border-zinc-800 hover:!bg-theme-background\",\n      },\n      {\n        variant: \"text\",\n        status: \"disabled\",\n        className: \"opacity-60\",\n      },\n      {\n        variant: \"ghost\",\n        status: \"disabled\",\n        className: \"opacity-50 hover:!bg-transparent\",\n      },\n    ],\n    variants: {\n      size: {\n        sm: \"px-3 py-1 rounded-lg text-sm font-medium\",\n        default: \"px-4 py-1.5 rounded-lg text-sm font-semibold\",\n        lg: \"px-5 py-2 rounded-lg text-base font-semibold\",\n      },\n\n      status: {\n        disabled: \"cursor-not-allowed !ring-0\",\n      },\n      variant: {\n        primary: cn(\n          \"bg-accent\",\n          \"hover:contrast-[1.10] hover:shadow-md hover:shadow-accent/20 active:contrast-125 active:shadow-none\",\n          \"disabled:bg-theme-disabled disabled:dark:text-zinc-50 disabled:shadow-none\",\n          \"text-zinc-100\",\n          \"focus-visible:ring-accent/30\",\n          \"transition-all duration-200\",\n        ),\n\n        outline: cn(\n          \"bg-theme-background transition-colors duration-200\",\n          \"border border-border hover:border-accent/50 hover:bg-zinc-50/80 dark:bg-neutral-900/30 dark:hover:bg-neutral-900/80\",\n          \"focus-visible:ring-accent/30\",\n          \"hover:shadow-sm\",\n        ),\n        text: cn(\n          \"text-accent\",\n          \"hover:contrast-[1.10] active:contrast-125 hover:bg-accent/10\",\n          \"focus-visible:ring-accent/30\",\n          \"p-0 inline align-baseline\",\n        ),\n        ghost: cn(\n          \"px-2\",\n          \"hover:bg-material-ultra-thick\",\n          \"focus-visible:ring-accent/30\",\n          \"transition-all duration-200\",\n        ),\n      },\n    },\n\n    defaultVariants: {\n      variant: \"primary\",\n      size: \"default\",\n    },\n  },\n)\n"
  },
  {
    "path": "packages/internal/components/src/ui/card/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as React from \"react\"\n\nconst Card = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => (\n  <div\n    ref={ref}\n    className={cn(\"rounded-lg border bg-material-ultra-thin text-text\", className)}\n    {...props}\n  />\n)\nCard.displayName = \"Card\"\n\nconst CardHeader = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => (\n  <div ref={ref} className={cn(\"flex flex-col space-y-1.5 p-6\", className)} {...props} />\n)\nCardHeader.displayName = \"CardHeader\"\n\nconst CardTitle = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLHeadingElement> & {\n  ref?: React.Ref<HTMLParagraphElement | null>\n}) => (\n  <h3\n    ref={ref}\n    className={cn(\"text-2xl font-semibold leading-none tracking-tight\", className)}\n    {...props}\n  />\n)\nCardTitle.displayName = \"CardTitle\"\n\nconst CardDescription = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLParagraphElement> & {\n  ref?: React.Ref<HTMLParagraphElement | null>\n}) => <p ref={ref} className={cn(\"text-sm text-text-secondary\", className)} {...props} />\nCardDescription.displayName = \"CardDescription\"\n\nconst CardContent = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => (\n  <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n)\nCardContent.displayName = \"CardContent\"\n\nconst CardFooter = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => (\n  <div ref={ref} className={cn(\"flex items-center p-6 pt-0\", className)} {...props} />\n)\nCardFooter.displayName = \"CardFooter\"\n\nexport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }\n"
  },
  {
    "path": "packages/internal/components/src/ui/checkbox/index.tsx",
    "content": "\"use client\"\n\nimport { cn } from \"@follow/utils/utils\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\ntype CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> &\n  HTMLMotionProps<\"button\"> & {\n    indeterminate?: boolean\n    size?: \"sm\" | \"md\"\n  }\n\nfunction Checkbox({\n  className,\n  onCheckedChange,\n  indeterminate,\n  size = \"md\",\n  ...props\n}: CheckboxProps) {\n  const [isChecked, setIsChecked] = React.useState(props?.checked ?? props?.defaultChecked ?? false)\n\n  React.useEffect(() => {\n    if (props?.checked !== undefined) setIsChecked(props.checked)\n  }, [props?.checked])\n\n  const handleCheckedChange = React.useCallback(\n    (checked: boolean) => {\n      setIsChecked(checked)\n      onCheckedChange?.(checked)\n    },\n    [onCheckedChange],\n  )\n\n  return (\n    <CheckboxPrimitive.Root {...props} onCheckedChange={handleCheckedChange} asChild>\n      <m.button\n        data-slot=\"checkbox\"\n        className={cn(\n          \"peer flex shrink-0 cursor-checkbox items-center justify-center rounded-sm bg-fill transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent data-[state=checked]:text-white\",\n          size === \"sm\" ? \"size-4\" : \"size-5\",\n          indeterminate && \"bg-accent text-white\",\n          className,\n        )}\n        whileTap={{ scale: 0.95 }}\n        whileHover={{ scale: 1.05 }}\n        {...props}\n      >\n        <CheckboxPrimitive.Indicator forceMount asChild>\n          {indeterminate ? (\n            <m.svg\n              data-slot=\"checkbox-indicator\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              strokeWidth=\"3.5\"\n              stroke=\"currentColor\"\n              className={size === \"sm\" ? \"size-3\" : \"size-3.5\"}\n              initial=\"hidden\"\n              animate=\"visible\"\n            >\n              <m.path\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                d=\"M5 12h14\"\n                variants={{\n                  visible: {\n                    pathLength: 1,\n                    opacity: 1,\n                    transition: {\n                      duration: 0.2,\n                      delay: 0.1,\n                    },\n                  },\n                  hidden: {\n                    pathLength: 0,\n                    opacity: 0,\n                    transition: {\n                      duration: 0.2,\n                    },\n                  },\n                }}\n              />\n            </m.svg>\n          ) : (\n            <m.svg\n              data-slot=\"checkbox-indicator\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              strokeWidth=\"3.5\"\n              stroke=\"currentColor\"\n              className={size === \"sm\" ? \"size-3\" : \"size-3.5\"}\n              initial=\"unchecked\"\n              animate={isChecked ? \"checked\" : \"unchecked\"}\n            >\n              <m.path\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                d=\"M4.5 12.75l6 6 9-13.5\"\n                variants={{\n                  checked: {\n                    pathLength: 1,\n                    opacity: 1,\n                    transition: {\n                      duration: 0.2,\n                      delay: 0.1,\n                    },\n                  },\n                  unchecked: {\n                    pathLength: 0,\n                    opacity: 0,\n                    transition: {\n                      duration: 0.2,\n                    },\n                  },\n                }}\n              />\n            </m.svg>\n          )}\n        </CheckboxPrimitive.Indicator>\n      </m.button>\n    </CheckboxPrimitive.Root>\n  )\n}\n\nCheckbox.displayName = CheckboxPrimitive.Root.displayName\n\nexport { Checkbox, type CheckboxProps }\n"
  },
  {
    "path": "packages/internal/components/src/ui/collapse/Collapse.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { atom, useStore } from \"jotai\"\nimport type { Variants } from \"motion/react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { useEffect } from \"react\"\n\nimport type { CollapseContextValue } from \"./hooks\"\nimport { CollaspeContext } from \"./hooks\"\n\ninterface CollapseProps {\n  title: React.ReactNode\n  hideArrow?: boolean\n  defaultOpen?: boolean\n  collapseId?: string\n  onOpenChange?: (isOpened: boolean) => void\n  contentClassName?: string\n}\n\n/**\n * @deprecated Use CollapseCssGroup instead\n */\nexport const CollapseGroup: FC<\n  {\n    defaultOpenId?: string\n    onOpenChange?: (state: Record<string, boolean>) => void\n  } & React.PropsWithChildren\n> = ({ children, defaultOpenId, onOpenChange }) => {\n  const ctxValue = React.useMemo<CollapseContextValue>(\n    () => ({\n      currentOpenCollapseIdAtom: atom<string | null>(defaultOpenId ?? null),\n      collapseGroupItemStateAtom: atom<Record<string, boolean>>({}),\n    }),\n    [defaultOpenId],\n  )\n\n  const store = useStore()\n  useEffect(() => {\n    return store.sub(ctxValue.collapseGroupItemStateAtom, () => {\n      const state = store.get(ctxValue.collapseGroupItemStateAtom)\n\n      onOpenChange?.(state)\n    })\n  }, [defaultOpenId])\n  return <CollaspeContext value={ctxValue}>{children}</CollaspeContext>\n}\n\n/**\n * @deprecated Use CollapseCss instead\n */\n\nexport const CollapseControlled: Component<\n  {\n    isOpened: boolean\n    onOpenChange: (v: boolean) => void\n  } & CollapseProps\n> = (props) => (\n  <div\n    className={cn(\"flex flex-col\", props.className)}\n    data-state={props.isOpened ? \"open\" : \"hidden\"}\n  >\n    <div\n      className=\"relative flex w-full cursor-pointer items-center justify-between\"\n      onClick={() => props.onOpenChange(!props.isOpened)}\n    >\n      <span className=\"w-0 shrink grow truncate\">{props.title}</span>\n      {!props.hideArrow && (\n        <div className=\"inline-flex shrink-0 items-center text-gray-400\">\n          <i\n            className={cn(\"i-mingcute-down-line duration-200\", props.isOpened ? \"rotate-180\" : \"\")}\n          />\n        </div>\n      )}\n    </div>\n    <CollapseContent isOpened={props.isOpened} className={props.contentClassName}>\n      {props.children}\n    </CollapseContent>\n  </div>\n)\n\n/**\n * @deprecated Use CollapseCssContent instead\n */\nexport const CollapseContent: Component<{\n  isOpened: boolean\n  withBackground?: boolean\n}> = ({ isOpened, className, children }) => {\n  const variants = React.useMemo(() => {\n    const v = {\n      open: {\n        opacity: 1,\n        height: \"auto\",\n\n        transition: {\n          type: \"spring\",\n          mass: 0.2,\n        },\n      },\n      collapsed: {\n        opacity: 0,\n        height: 0,\n        overflow: \"hidden\",\n      },\n    } satisfies Variants\n\n    return v\n  }, [])\n  return (\n    <AnimatePresence initial={false}>\n      {isOpened && (\n        <m.div\n          key=\"content\"\n          initial=\"collapsed\"\n          animate=\"open\"\n          exit=\"collapsed\"\n          variants={variants}\n          className={className}\n        >\n          {children}\n        </m.div>\n      )}\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/collapse/CollapseCss.tsx",
    "content": "/**\n * @see https://www.zhangxinxu.com/wordpress/2024/06/css-transition-behavior/\n * @see https://www.zhangxinxu.com/wordpress/2024/11/css-calc-interpolate-size/\n */\nimport { cn } from \"@follow/utils/utils\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { createContext, use, useState } from \"react\"\n\ninterface CollapseContextValue {\n  openStates: Record<string, boolean>\n  setOpenState: (id: string, open: boolean) => void\n}\n\nconst CollapseContext = createContext<CollapseContextValue | null>(null)\n\nconst useCollapseContext = () => {\n  const ctx = use(CollapseContext)\n  if (!ctx) {\n    throw new Error(\"useCollapseContext must be used within CollapseGroup\")\n  }\n  return ctx\n}\n\ninterface CollapseGroupProps {\n  defaultOpenId?: string\n  onOpenChange?: (state: Record<string, boolean>) => void\n  children: React.ReactNode\n}\n\nexport const CollapseCssGroup: FC<CollapseGroupProps> = ({\n  children,\n  defaultOpenId,\n  onOpenChange,\n}) => {\n  const [openStates, setOpenStates] = useState<Record<string, boolean>>(() => {\n    return defaultOpenId ? { [defaultOpenId]: true } : {}\n  })\n\n  const setOpenState = React.useCallback(\n    (id: string, open: boolean) => {\n      setOpenStates((prev) => {\n        const newState = { ...prev, [id]: open }\n        onOpenChange?.(newState)\n        return newState\n      })\n    },\n    [onOpenChange],\n  )\n\n  const ctxValue = React.useMemo<CollapseContextValue>(\n    () => ({\n      openStates,\n      setOpenState,\n    }),\n    [openStates, setOpenState],\n  )\n\n  return <CollapseContext value={ctxValue}>{children}</CollapseContext>\n}\n\ninterface CollapseProps {\n  title: React.ReactNode\n  hideArrow?: boolean\n  defaultOpen?: boolean\n  isOpened?: boolean // For controlled usage\n  collapseId?: string\n  onOpenChange?: (isOpened: boolean) => void\n  contentClassName?: string\n  className?: string\n  children: React.ReactNode\n  innerClassName?: string\n  ref?: React.Ref<CollapseCssRef>\n}\n\nexport interface CollapseCssRef {\n  setIsOpened: (isOpened: boolean) => void\n}\nexport const CollapseCss: FC<CollapseProps> = ({\n  title,\n  hideArrow,\n  defaultOpen = false,\n  isOpened: controlledIsOpened,\n  collapseId,\n  onOpenChange,\n  contentClassName,\n  className,\n  innerClassName,\n  children,\n  ref,\n}) => {\n  const reactId = React.useId()\n  const id = collapseId ?? reactId\n  const { openStates, setOpenState } = useCollapseContext()\n\n  // Use controlled value if provided, otherwise use context state or defaultOpen\n  const isOpened = controlledIsOpened ?? openStates[id] ?? defaultOpen\n\n  const handleToggle = React.useCallback(() => {\n    const newOpened = !isOpened\n    // Only update context state if not controlled\n    if (controlledIsOpened === undefined) {\n      setOpenState(id, newOpened)\n    }\n    onOpenChange?.(newOpened)\n  }, [id, isOpened, controlledIsOpened, setOpenState, onOpenChange])\n\n  React.useImperativeHandle(ref, () => ({\n    setIsOpened: (isOpened: boolean) => {\n      setOpenState(id, isOpened)\n    },\n  }))\n  return (\n    <div className={cn(\"flex flex-col\", className)} data-state={isOpened ? \"open\" : \"hidden\"}>\n      <div\n        className=\"relative flex w-full cursor-pointer items-center justify-between\"\n        onClick={controlledIsOpened === undefined ? handleToggle : undefined}\n      >\n        <span className=\"w-0 shrink grow truncate\">{title}</span>\n        {!hideArrow && (\n          <div className=\"inline-flex shrink-0 items-center text-text-secondary\">\n            <i\n              className={cn(\n                \"i-mingcute-down-line transition-transform duration-300 ease-in-out\",\n                isOpened ? \"rotate-180\" : \"\",\n              )}\n            />\n          </div>\n        )}\n      </div>\n      <CollapseCssContent\n        isOpened={isOpened}\n        className={contentClassName}\n        innerClassName={innerClassName}\n      >\n        {children}\n      </CollapseCssContent>\n    </div>\n  )\n}\n\ninterface CollapseContentProps {\n  isOpened: boolean\n  className?: string\n  children: React.ReactNode\n  innerClassName?: string\n}\n\nconst CollapseCssContent: FC<CollapseContentProps> = ({\n  isOpened,\n  className,\n  children,\n  innerClassName,\n}) => {\n  const contentRef = React.useRef<HTMLDivElement>(null)\n\n  return (\n    <div\n      ref={contentRef}\n      className={cn(\n        \"overflow-hidden [interpolate-size:allow-keywords] [transition-behavior:allow-discrete]\",\n        \"transition-[height,opacity,display] duration-300 ease-in-out\",\n        \"[@starting-style]:h-0 [@starting-style]:opacity-0\",\n        className,\n        isOpened ? \"block h-[calc-size(auto)] opacity-100\" : \"hidden h-0 opacity-0\",\n      )}\n      data-state={isOpened ? \"open\" : \"closed\"}\n    >\n      <div\n        className={cn(\n          \"transition-transform duration-300 ease-in-out\",\n          \"[@starting-style]:translate-y-[-8px]\",\n          isOpened ? \"translate-y-0\" : \"translate-y-[-8px]\",\n          innerClassName,\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/collapse/hooks.tsx",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport { createContext, use } from \"react\"\n\nexport interface CollapseContextValue {\n  currentOpenCollapseIdAtom: PrimitiveAtom<string | null>\n  collapseGroupItemStateAtom: PrimitiveAtom<Record<string, boolean>>\n}\nexport const CollaspeContext = createContext<CollapseContextValue>(null!)\nexport const useCollapseContext = () => {\n  const ctx = use(CollaspeContext)\n  if (!ctx) {\n    throw new Error(\"CollapseContext not found\")\n  }\n  return ctx\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/collapse/index.ts",
    "content": "export * from \"./CollapseCss\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/context-menu/context-menu.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\nimport * as React from \"react\"\n\nimport { Divider } from \"../divider/Divider.js\"\nimport { RootPortal } from \"../portal/index.jsx\"\n\nconst styles = {\n  content: {\n    backgroundImage:\n      \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n    boxShadow:\n      \"0 6px 20px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px hsl(var(--fo-a) / 0.04), 0 1px 3px rgba(0, 0, 0, 0.03)\",\n  } as React.CSSProperties,\n  innerGlow: {\n    background:\n      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.01), transparent, hsl(var(--fo-a) / 0.01))\",\n  } as React.CSSProperties,\n}\n\nconst ContextMenu = ContextMenuPrimitive.Root\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger\nconst ContextMenuGroup = ContextMenuPrimitive.Group\nconst ContextMenuSub = ContextMenuPrimitive.Sub\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup\n\nconst ContextMenuSubTrigger = ({\n  ref,\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.SubTrigger> | null> }) => (\n  <ContextMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none data-[highlighted]:text-accent data-[state=open]:text-accent data-[highlighted]:bg-mix-background/accent-9/1 data-[state=open]:bg-mix-background/accent-9/1\",\n      \"h-[28px]\",\n      inset && \"pl-8\",\n      \"center gap-2\",\n      className,\n      props.disabled && \"cursor-not-allowed opacity-30\",\n    )}\n    {...props}\n  >\n    {children}\n    <i className=\"i-mingcute-right-line -mr-1 ml-auto size-3.5\" />\n  </ContextMenuPrimitive.SubTrigger>\n)\nContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName\n\nconst ContextMenuSubContent = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & {\n  ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.SubContent> | null>\n}) => (\n  <RootPortal>\n    <ContextMenuPrimitive.SubContent\n      ref={ref}\n      className={cn(\n        \"text-body text-text\",\n        \"min-w-32 overflow-hidden\",\n        \"rounded-[6px] p-1\",\n        \"backdrop-blur-2xl\",\n        \"z-[61]\",\n        \"relative\",\n        \"dark:border dark:border-border/50\",\n        className,\n      )}\n      style={styles.content}\n      {...props}\n    >\n      {/* Inner glow layer */}\n      <div\n        className=\"pointer-events-none absolute inset-0 rounded-[6px]\"\n        style={styles.innerGlow}\n      />\n      {/* Content wrapper */}\n      <div className=\"relative\">{props.children}</div>\n    </ContextMenuPrimitive.SubContent>\n  </RootPortal>\n)\nContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName\n\nconst ContextMenuContent = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Content> | null>\n}) => (\n  <RootPortal>\n    <ContextMenuPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"z-[60] min-w-32 overflow-hidden rounded-[6px] p-1 text-text\",\n        \"backdrop-blur-2xl\",\n        \"text-body motion-scale-in-75 motion-duration-150 lg:animate-none\",\n        \"relative\",\n        \"dark:border dark:border-border/50\",\n        className,\n      )}\n      style={styles.content}\n      {...props}\n    >\n      {/* Inner glow layer */}\n      <div\n        className=\"pointer-events-none absolute inset-0 rounded-[6px]\"\n        style={styles.innerGlow}\n      />\n      {/* Content wrapper */}\n      <div className=\"relative\">{props.children}</div>\n    </ContextMenuPrimitive.Content>\n  </RootPortal>\n)\nContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName\n\nconst ContextMenuItem = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Item> | null> }) => (\n  <ContextMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      \"focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background/accent-9/1\",\n      \"h-[28px]\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  />\n)\nContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName\n\nconst ContextMenuCheckboxItem = ({\n  ref,\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {\n  ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem> | null>\n}) => (\n  <ContextMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-checkbox select-none items-center rounded-[5px] px-8 py-1.5 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      \"focus-within:text-accent focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background/accent-9/1\",\n      \"h-[28px]\",\n      className,\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator asChild>\n        <i className=\"i-mgc-check-filled size-3\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.CheckboxItem>\n)\nContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName\n\nconst ContextMenuLabel = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Label> | null> }) => (\n  <ContextMenuPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 font-semibold text-text\", inset && \"pl-8\", className)}\n    {...props}\n  />\n)\nContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName\n\nconst ContextMenuSeparator = ({\n  ref,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> & {\n  ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Separator> | null>\n}) => (\n  <ContextMenuPrimitive.Separator\n    className=\"mx-2 my-1 h-px backdrop-blur-background\"\n    asChild\n    ref={ref}\n    {...props}\n  >\n    <Divider />\n  </ContextMenuPrimitive.Separator>\n)\nContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName\n\nexport {\n  ContextMenu,\n  ContextMenuCheckboxItem,\n  ContextMenuContent,\n  ContextMenuGroup,\n  ContextMenuItem,\n  ContextMenuLabel,\n  ContextMenuRadioGroup,\n  ContextMenuSeparator,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuTrigger,\n}\n\nexport { RootPortal as ContextMenuPortal } from \"@follow/components/ui/portal/index.jsx\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/context-menu/index.ts",
    "content": "export * from \"./context-menu\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/datetime/index.tsx",
    "content": "import { stopPropagation } from \"@follow/utils/dom\"\nimport dayjs from \"dayjs\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from \"../tooltip\"\nimport { getUpdateInterval } from \"./utils\"\n\nconst formatTemplateString = \"lll\"\n\nconst formatTime = (\n  date: string | Date,\n  relativeBeforeDay?: number,\n  template = formatTemplateString,\n) => {\n  if (relativeBeforeDay && Math.abs(dayjs(date).diff(new Date(), \"d\")) > relativeBeforeDay) {\n    return dayjs(date).format(template)\n  }\n  return dayjs.duration(dayjs(date).diff(dayjs(), \"minute\"), \"minute\").humanize()\n}\n\nexport const RelativeTime: FC<{\n  date: string | Date\n  displayAbsoluteTimeAfterDay?: number\n  dateFormatTemplate?: string\n  postfix?: string\n}> = (props) => {\n  const { displayAbsoluteTimeAfterDay = 29, dateFormatTemplate = formatTemplateString } = props\n  const nextDateFormatTemplate =\n    dateFormatTemplate === \"default\" ? formatTemplateString : dateFormatTemplate\n  const [relative, setRelative] = useState<string>(() =>\n    formatTime(props.date, displayAbsoluteTimeAfterDay, nextDateFormatTemplate),\n  )\n\n  const timerRef = useRef<any>(null)\n\n  const { i18n } = useTranslation(\"common\")\n\n  useEffect(() => {\n    const updateRelativeTime = () => {\n      setRelative(formatTime(props.date, displayAbsoluteTimeAfterDay, nextDateFormatTemplate))\n      const updateInterval = getUpdateInterval(props.date, displayAbsoluteTimeAfterDay)\n\n      if (updateInterval !== null) {\n        timerRef.current = setTimeout(updateRelativeTime, updateInterval)\n      }\n    }\n\n    updateRelativeTime()\n\n    return () => {\n      clearTimeout(timerRef.current)\n    }\n  }, [props.date, displayAbsoluteTimeAfterDay, nextDateFormatTemplate, i18n.language])\n  const formated = dayjs(props.date).format(nextDateFormatTemplate)\n\n  const { t } = useTranslation(\"common\")\n  if (formated === relative) {\n    return <>{relative}</>\n  }\n\n  const resolvedPostfix = props.postfix ?? t(\"space\") + t(\"words.ago\")\n  return (\n    <Tooltip>\n      {/* https://github.com/radix-ui/primitives/issues/2248#issuecomment-2147056904 */}\n      <TooltipTrigger tabIndex={-1} onFocusCapture={stopPropagation}>\n        {relative}\n        {resolvedPostfix}\n      </TooltipTrigger>\n\n      <TooltipPortal>\n        <TooltipContent>{formated}</TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/datetime/utils.tsx",
    "content": "import dayjs from \"dayjs\"\n\nexport const getUpdateInterval = (date: string | Date, relativeBeforeDay?: number) => {\n  if (!relativeBeforeDay) return null\n  const diffInSeconds = Math.abs(dayjs(date).diff(new Date(), \"second\"))\n  if (diffInSeconds <= 60) {\n    return 1000 // Update every second\n  }\n  const diffInMinutes = Math.abs(dayjs(date).diff(new Date(), \"minute\"))\n  if (diffInMinutes <= 60) {\n    return 60000 // Update every minute\n  }\n  const diffInHours = Math.abs(dayjs(date).diff(new Date(), \"hour\"))\n  if (diffInHours <= 24) {\n    return 3600000 // Update every hour\n  }\n  const diffInDays = Math.abs(dayjs(date).diff(new Date(), \"day\"))\n  if (diffInDays <= relativeBeforeDay) {\n    return 86400000 // Update every day\n  }\n  return null // No need to update\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/divider/Divider.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { DetailedHTMLProps, FC, HTMLAttributes } from \"react\"\nimport * as React from \"react\"\n\nexport const Divider: FC<DetailedHTMLProps<HTMLAttributes<HTMLHRElement>, HTMLHRElement>> = (\n  props,\n) => {\n  const { className, ...rest } = props\n  return (\n    <hr\n      className={cn(\n        \"my-4 h-[0.5px] border-0\",\n        \"bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)]\",\n        className,\n      )}\n      {...rest}\n    />\n  )\n}\n\nexport const DividerVertical: FC<\n  DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>\n> = (props) => {\n  const { className, ...rest } = props\n  return (\n    <span\n      className={cn(\n        \"mx-3 inline-block h-full w-[0.5px] select-none text-transparent\",\n        \"bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)]\",\n        className,\n      )}\n      {...rest}\n    >\n      w\n    </span>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/divider/PanelSplitter.tsx",
    "content": "import { useMeasure } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as React from \"react\"\n\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../tooltip\"\n\nexport const PanelSplitter = (\n  props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {\n    isDragging?: boolean\n    cursor?: string\n\n    tooltip?: React.ReactNode\n  },\n) => {\n  const { isDragging, cursor, tooltip, className, ...rest } = props\n\n  React.useEffect(() => {\n    if (!isDragging) return\n    const $css = document.createElement(\"style\")\n\n    $css.innerHTML = `\n      * {\n        cursor: ${cursor} !important;\n      }\n    `\n\n    document.head.append($css)\n    return () => {\n      $css.remove()\n    }\n  }, [cursor, isDragging])\n  const [ref, { height }] = useMeasure()\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <div ref={ref} className=\"relative h-full w-0 shrink-0\" data-hide-in-print>\n          <div\n            tabIndex={-1}\n            {...rest}\n            className={cn(\n              \"absolute inset-0 z-[3] w-[2px] -translate-x-1/2 cursor-ew-resize bg-transparent hover:bg-gray-400 active:!bg-accent hover:dark:bg-neutral-500\",\n              isDragging ? \"bg-accent\" : \"\",\n              className,\n            )}\n          />\n        </div>\n      </TooltipTrigger>\n      {tooltip && <TooltipContent sideOffset={-height / 2}>{tooltip}</TooltipContent>}\n    </Tooltip>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/divider/index.ts",
    "content": "export * from \"./Divider\"\nexport * from \"./PanelSplitter\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/drop-zone/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { DragEvent, ReactNode } from \"react\"\nimport { useCallback, useRef, useState } from \"react\"\n\n// Ported from https://github.com/react-dropzone/react-dropzone/issues/753#issuecomment-774782919\nconst useDragAndDrop = ({ callback }: { callback: (file: FileList) => void | Promise<void> }) => {\n  const [isDragging, setIsDragging] = useState(false)\n  const dragCounter = useRef(0)\n\n  const onDrop = useCallback(\n    async (event: DragEvent<HTMLLabelElement>) => {\n      event.preventDefault()\n      setIsDragging(false)\n      if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {\n        dragCounter.current = 0\n        await callback(event.dataTransfer.files)\n        event.dataTransfer.clearData()\n      }\n    },\n    [callback],\n  )\n\n  const onDragEnter = useCallback((event: DragEvent) => {\n    event.preventDefault()\n    dragCounter.current++\n    setIsDragging(true)\n  }, [])\n\n  const onDragOver = useCallback((event: DragEvent) => {\n    event.preventDefault()\n  }, [])\n\n  const onDragLeave = useCallback((event: DragEvent) => {\n    event.preventDefault()\n    dragCounter.current--\n    if (dragCounter.current > 0) return\n    setIsDragging(false)\n  }, [])\n\n  return {\n    isDragging,\n\n    dragHandlers: {\n      onDrop,\n      onDragOver,\n      onDragEnter,\n      onDragLeave,\n    },\n  }\n}\n\nexport interface DropZoneProps {\n  id?: string\n  accept?: string\n  children?: ReactNode\n  className?: string\n  onDrop: (files: FileList) => void | Promise<void>\n}\n\nexport const DropZone = ({ id, accept, children, className, onDrop }: DropZoneProps) => {\n  const { isDragging, dragHandlers } = useDragAndDrop({ callback: onDrop })\n\n  return (\n    <label\n      className={cn(\n        \"center flex h-[100px] w-full cursor-pointer rounded-md border border-dashed\",\n        isDragging ? \"border-accent bg-accent/10\" : \"\",\n        \"duration-200 hover:border-accent/50\",\n        className,\n      )}\n      htmlFor={id}\n      {...dragHandlers}\n    >\n      {children}\n      <input\n        id={id}\n        type=\"file\"\n        accept={accept}\n        onChange={(e) => e.target.files && onDrop(e.target.files)}\n        className=\"hidden\"\n      />\n    </label>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/effect/MagneticHoverEffect.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as React from \"react\"\nimport { useCallback, useImperativeHandle, useRef } from \"react\"\n\ntype MagneticHoverEffectProps<T extends React.ElementType> = {\n  as?: T\n  children: React.ReactNode\n  ref?: React.Ref<T>\n} & Omit<React.ComponentPropsWithoutRef<T>, \"as\" | \"children\">\n\nexport const MagneticHoverEffect = <T extends React.ElementType = \"div\">({\n  as,\n  children,\n  ref,\n  ...rest\n}: MagneticHoverEffectProps<T>) => {\n  const Component = as || \"div\"\n\n  const itemRef = useRef<HTMLElement>(null)\n\n  useImperativeHandle(ref, () => {\n    return itemRef.current as unknown as T\n  })\n\n  const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {\n    if (!itemRef.current) return\n    const rect = itemRef.current.getBoundingClientRect()\n\n    const x = ((e.clientX - rect.left) / rect.width) * 100\n    const y = ((e.clientY - rect.top) / rect.height) * 100\n\n    itemRef.current.style.transition = \"transform 0.2s cubic-bezier(0.33, 1, 0.68, 1)\"\n    itemRef.current.style.setProperty(\"--origin-x\", `${x}%`)\n    itemRef.current.style.setProperty(\"--origin-y\", `${y}%`)\n  }\n\n  const handleMouseLeave = () => {\n    if (!itemRef.current) return\n    itemRef.current.style.transform = \"translate(0px, 0px)\"\n    itemRef.current.style.transition = \"transform 0.4s cubic-bezier(0.33, 1, 0.68, 1)\"\n  }\n\n  const handleMouseMove = useCallback((e: React.MouseEvent<HTMLElement>) => {\n    if (!itemRef.current) return\n    const rect = itemRef.current.getBoundingClientRect()\n\n    const centerX = rect.left + rect.width / 2\n    const centerY = rect.top + rect.height / 2\n\n    const distanceX = (e.clientX - centerX) * 0.05\n    const distanceY = (e.clientY - centerY) * 0.05\n\n    itemRef.current.style.transform = `translate(${distanceX}px, ${distanceY}px)`\n  }, [])\n\n  return (\n    <Component\n      ref={itemRef as any}\n      {...rest}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onMouseMove={handleMouseMove}\n      className={cn(\n        \"relative\",\n        \"inline-block transition-transform duration-200 ease-out will-change-transform\",\n        \"hover:before:bg-fill-tertiary\",\n        \"before:absolute before:-inset-x-2 before:inset-y-0 before:z-[-1] before:scale-0 before:rounded-xl before:opacity-0 before:backdrop-blur-background before:transition-all before:duration-200 before:[transform-origin:var(--origin-x)_var(--origin-y)] hover:before:scale-100 hover:before:opacity-100\",\n        rest.className,\n      )}\n    >\n      {children}\n    </Component>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/form/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport * as React from \"react\"\nimport type { ControllerProps, FieldPath, FieldValues } from \"react-hook-form\"\nimport { Controller, FormProvider, useFormContext } from \"react-hook-form\"\n\nimport { Label } from \"../label\"\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n  name: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => (\n  <FormFieldContext value={React.useMemo(() => ({ name: props.name }), [props.name])}>\n    <Controller {...props} />\n  </FormFieldContext>\n)\n\nconst useFormField = () => {\n  const fieldContext = React.use(FormFieldContext)\n  const itemContext = React.use(FormItemContext)\n  const { getFieldState, formState } = useFormContext()\n\n  const fieldState = getFieldState(fieldContext.name, formState)\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\")\n  }\n\n  const { id } = itemContext\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  }\n}\n\ntype FormItemContextValue = {\n  id: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)\n\nconst FormItem = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const id = React.useId()\n\n  return (\n    <FormItemContext value={React.useMemo(() => ({ id }), [id])}>\n      <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n    </FormItemContext>\n  )\n}\nFormItem.displayName = \"FormItem\"\n\nconst FormLabel = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & {\n  ref?: React.Ref<React.ElementRef<typeof LabelPrimitive.Root> | null>\n}) => {\n  const { error, formItemId } = useFormField()\n\n  return (\n    <Label\n      ref={ref}\n      className={cn(error && \"text-red\", \"font-semibold\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  )\n}\nFormLabel.displayName = \"FormLabel\"\n\nconst FormControl = ({\n  ref,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof Slot> & {\n  ref?: React.Ref<React.ElementRef<typeof Slot> | null>\n}) => {\n  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}\n      aria-invalid={!!error}\n      {...props}\n    />\n  )\n}\nFormControl.displayName = \"FormControl\"\n\nconst FormDescription = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLParagraphElement> & {\n  ref?: React.Ref<HTMLParagraphElement | null>\n}) => {\n  const { formDescriptionId } = useFormField()\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn(\"text-xs text-text-secondary\", className)}\n      {...props}\n    />\n  )\n}\nFormDescription.displayName = \"FormDescription\"\n\nconst FormMessage = ({\n  ref,\n  className,\n  children,\n  ...props\n}: React.HTMLAttributes<HTMLParagraphElement> & {\n  ref?: React.Ref<HTMLParagraphElement | null>\n}) => {\n  const { error, formMessageId } = useFormField()\n  const body = error ? String(error?.message) : children\n\n  if (!body) {\n    return null\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn(\"text-sm font-medium text-red\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  )\n}\nFormMessage.displayName = \"FormMessage\"\n\nexport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n  useFormField,\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/hover-card/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\nimport * as React from \"react\"\n\nimport { RootPortal } from \"../portal\"\n\nconst HoverCard = HoverCardPrimitive.Root\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger\n\nconst HoverCardContent = ({\n  ref,\n  className,\n  align = \"center\",\n  sideOffset = 8,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof HoverCardPrimitive.Content> | null>\n}) => (\n  <RootPortal>\n    <HoverCardPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-[60] w-fit overflow-hidden rounded-md border border-border bg-material-medium text-text shadow-lg backdrop-blur-background\",\n        \"text-body motion-scale-in-95 motion-duration-200\",\n        \"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]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    />\n  </RootPortal>\n)\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName\n\nconst HoverCardArrow = HoverCardPrimitive.Arrow\n\nexport { HoverCard, HoverCardArrow, HoverCardContent, HoverCardTrigger }\n"
  },
  {
    "path": "packages/internal/components/src/ui/icon/SiteIcon.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { m, useAnimationControls } from \"motion/react\"\nimport { useRef } from \"react\"\n\nimport { getFeedIconSrc } from \"../../utils/icon\"\nimport { PlatformIcon } from \"../platform-icon\"\n\ninterface SiteIconProps {\n  siteUrl: string\n  className?: string\n  size?: number\n  fadeIn?: boolean\n}\nexport const SiteIcon = ({ siteUrl, className, size = 20, fadeIn = true }: SiteIconProps) => {\n  const [src] = getFeedIconSrc({\n    siteUrl,\n  })\n\n  const sizeStyle = {\n    width: size,\n    height: size,\n  }\n\n  const isIconLoaded = useRef(false)\n  const fadeInVariant = {\n    opacity: [0, 1],\n    transition: {\n      duration: 0.2,\n    },\n  }\n\n  const animateControl = useAnimationControls()\n  return (\n    <PlatformIcon url={siteUrl} style={sizeStyle} className={cn(\"center\", className)}>\n      <m.img\n        style={sizeStyle}\n        src={src}\n        animate={animateControl}\n        onLoad={() => {\n          if (isIconLoaded.current) {\n            return\n          }\n          if (!fadeIn) {\n            isIconLoaded.current = true\n            return\n          }\n          isIconLoaded.current = true\n          animateControl.start(fadeInVariant)\n        }}\n      />\n    </PlatformIcon>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/DateTimePicker.tsx",
    "content": "import { clsx, cn } from \"@follow/utils/utils\"\nimport { useDatePicker } from \"@rehookify/datepicker\"\nimport dayjs from \"dayjs\"\nimport { memo, useEffect, useMemo, useState } from \"react\"\nimport * as React from \"react\"\n\nimport { Button } from \"../button\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"../popover\"\nimport { TimeSelect } from \"./TimeSelect\"\n\nexport interface DateTimePickerProps {\n  /** DateTime value as ISO string */\n  value?: string\n  /** Called with new datetime ISO string */\n  onChange?: (value: string) => void\n  /** Minimum date as ISO string */\n  minDate?: string\n  /** Placeholder text */\n  placeholder?: string\n  className?: string\n  disabled?: boolean\n  /** Picker mode. Default is single */\n  mode?: \"single\" | \"range\"\n  /** Range value when mode is range */\n  rangeValue?: { start?: string; end?: string }\n  /** Called with new range when mode is range */\n  onRangeChange?: (value: { start?: string; end?: string }) => void\n  /** Placeholder for range mode */\n  rangePlaceholder?: string\n  /** Class name for the content */\n  contentClassName?: string\n  /** Custom trigger element. If provided, replaces the default button */\n  children?: React.ReactNode\n}\n\n/**\n * Custom datetime picker using @rehookify/datepicker for headless calendar logic\n * and custom time selection components.\n */\nexport const DateTimePicker = memo<DateTimePickerProps>(\n  ({\n    value,\n    onChange,\n    minDate,\n    placeholder = \"Select date & time\",\n    className,\n    disabled,\n    mode = \"single\",\n    rangeValue,\n    onRangeChange,\n    rangePlaceholder = \"Select date range\",\n    contentClassName,\n    children,\n  }) => {\n    const [isOpen, setIsOpen] = useState(false)\n    const [viewMode, setViewMode] = useState<\"days\" | \"months\" | \"years\">(\"days\")\n\n    const currentDateTime = useMemo(() => {\n      if (!value) return dayjs()\n      return dayjs(value)\n    }, [value])\n\n    const isRangeMode = mode === \"range\"\n\n    // Local state for range mode\n    const [rangeStart, setRangeStart] = useState<dayjs.Dayjs | null>(() =>\n      rangeValue?.start ? dayjs(rangeValue.start) : null,\n    )\n    const [rangeEnd, setRangeEnd] = useState<dayjs.Dayjs | null>(() =>\n      rangeValue?.end ? dayjs(rangeValue.end) : null,\n    )\n\n    useEffect(() => {\n      setRangeStart(rangeValue?.start ? dayjs(rangeValue.start) : null)\n      setRangeEnd(rangeValue?.end ? dayjs(rangeValue.end) : null)\n    }, [rangeValue?.start, rangeValue?.end])\n\n    const minDateObj = useMemo(() => {\n      return minDate ? dayjs(minDate) : dayjs()\n    }, [minDate])\n\n    const {\n      data: { weekDays, calendars, months, years },\n      propGetters: { dayButton, addOffset, subtractOffset, monthButton, yearButton },\n    } = useDatePicker({\n      selectedDates: isRangeMode\n        ? ([rangeStart?.toDate(), rangeEnd?.toDate()].filter(Boolean) as Date[])\n        : value\n          ? [new Date(value)]\n          : [],\n      onDatesChange: (dates) => {\n        if (isRangeMode) {\n          // Ignore internal range selection; we handle it per-click\n          return\n        }\n        if (dates.length > 0 && dates[0]) {\n          const selectedDate = dayjs(dates[0])\n          const newDateTime = selectedDate\n            .hour(currentDateTime.hour())\n            .minute(currentDateTime.minute())\n            .second(0)\n            .millisecond(0)\n\n          // Ensure the new date is not before min date\n          const finalDateTime = newDateTime.isBefore(minDateObj) ? minDateObj : newDateTime\n\n          onChange?.(finalDateTime.toISOString())\n          setIsOpen(false)\n        }\n      },\n      dates: {\n        minDate: minDate ? new Date(minDate) : new Date(),\n      },\n    })\n\n    const calendar = calendars[0]\n    if (!calendar) return null\n\n    const handleTimeChange = (time: string) => {\n      const [hours, minutes] = time.split(\":\")\n      const newDateTime = currentDateTime\n        .hour(Number(hours))\n        .minute(Number(minutes))\n        .second(0)\n        .millisecond(0)\n\n      onChange?.(newDateTime.toISOString())\n    }\n\n    const formatRangeButtonLabel = (): string => {\n      if (!rangeStart && !rangeEnd) return rangePlaceholder\n      if (rangeStart && !rangeEnd) return `${rangeStart.format(\"MMM DD, YYYY\")} – ...`\n      if (!rangeStart && rangeEnd) return `... – ${rangeEnd.format(\"MMM DD, YYYY\")}`\n      return `${rangeStart!.format(\"MMM DD, YYYY\")} – ${rangeEnd!.format(\"MMM DD, YYYY\")}`\n    }\n\n    const handleDayClickRange = (clickedDate: Date) => {\n      const clicked = dayjs(clickedDate).startOf(\"day\")\n      if (clicked.isBefore(minDateObj)) {\n        return\n      }\n\n      if (!rangeStart || (rangeStart && rangeEnd)) {\n        setRangeStart(clicked)\n        setRangeEnd(null)\n        return\n      }\n\n      if (rangeStart && !rangeEnd) {\n        if (clicked.isBefore(rangeStart)) {\n          setRangeEnd(rangeStart)\n          setRangeStart(clicked)\n          onRangeChange?.({ start: clicked.toISOString(), end: rangeStart.toISOString() })\n          setIsOpen(false)\n          return\n        }\n\n        setRangeEnd(clicked)\n        onRangeChange?.({ start: rangeStart.toISOString(), end: clicked.toISOString() })\n        setIsOpen(false)\n      }\n    }\n\n    return (\n      <Popover open={isOpen} onOpenChange={setIsOpen}>\n        <PopoverTrigger asChild>\n          {children || (\n            <Button\n              variant=\"outline\"\n              disabled={disabled}\n              buttonClassName={cn(\n                \"w-full justify-start text-left font-normal px-2.5\",\n                isRangeMode\n                  ? !rangeStart && !rangeEnd && \"text-text-tertiary\"\n                  : !value && \"text-text-tertiary\",\n                className,\n              )}\n            >\n              <i className=\"i-mgc-calendar-time-add-cute-re mr-2 size-4\" />\n              {isRangeMode\n                ? formatRangeButtonLabel()\n                : value\n                  ? currentDateTime.format(\"MMM DD, YYYY HH:mm\")\n                  : placeholder}\n            </Button>\n          )}\n        </PopoverTrigger>\n\n        <PopoverContent\n          className={clsx(\"w-auto min-w-[280px] rounded-[6px] border p-0\", contentClassName)}\n          align=\"start\"\n        >\n          <div className=\"p-2\">\n            {/* Calendar Header */}\n            <div className=\"mb-2 flex items-center justify-between\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                {...subtractOffset({ months: 1 })}\n                buttonClassName=\"size-7 p-0 hover:bg-accent/10\"\n                disabled={viewMode !== \"days\"}\n              >\n                <i className=\"i-mingcute-left-line size-3.5\" />\n              </Button>\n              <div className=\"flex items-center gap-1\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  buttonClassName=\"text-text text-sm font-medium hover:bg-mix-accent/background-1/4 px-2 py-1 rounded-[4px]\"\n                  onClick={() => setViewMode(viewMode === \"months\" ? \"days\" : \"months\")}\n                >\n                  {calendar.month}\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  buttonClassName=\"text-text text-sm font-medium hover:bg-mix-accent/background-1/4 px-2 py-1 rounded-[4px]\"\n                  onClick={() => setViewMode(viewMode === \"years\" ? \"days\" : \"years\")}\n                >\n                  {calendar.year}\n                </Button>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                {...addOffset({ months: 1 })}\n                buttonClassName=\"size-7 p-0 hover:bg-accent/10\"\n                disabled={viewMode !== \"days\"}\n              >\n                <i className=\"i-mingcute-right-line size-3.5\" />\n              </Button>\n            </div>\n\n            {/* Days View */}\n            {viewMode === \"days\" && (\n              <>\n                {/* Weekdays */}\n                <div className=\"mb-1 grid grid-cols-7 gap-0.5 text-center text-xs text-text-secondary\">\n                  {weekDays.map((day) => (\n                    <div key={day} className=\"flex h-6 items-center justify-center font-medium\">\n                      {day.slice(0, 2)}\n                    </div>\n                  ))}\n                </div>\n\n                {/* Calendar Days */}\n                <div className=\"mb-2 grid grid-cols-7 gap-0.5\">\n                  {calendar.days.map((day) => {\n                    const dayProps = dayButton(day)\n                    const isSelected = isRangeMode\n                      ? !!(\n                          (rangeStart && dayjs(day.$date).isSame(rangeStart, \"day\")) ||\n                          (rangeEnd && dayjs(day.$date).isSame(rangeEnd, \"day\"))\n                        )\n                      : day.selected\n                    const isToday = day.now\n                    const isOtherMonth = !day.inCurrentMonth\n                    const isDisabled = day.disabled\n\n                    return (\n                      <Button\n                        key={day.$date.toISOString()}\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        {...dayProps}\n                        onClick={(ev) => {\n                          if (isRangeMode) {\n                            ev.preventDefault()\n                            ev.stopPropagation()\n                            handleDayClickRange(day.$date)\n                          } else {\n                            dayProps.onClick?.(ev)\n                          }\n                        }}\n                        buttonClassName={cn(\n                          \"size-7 p-0 font-normal text-xs rounded-[4px]\",\n                          isSelected && \"bg-accent hover:bg-accent/90 text-white\",\n                          isToday && !isSelected && \"bg-accent/10 font-medium\",\n                          isOtherMonth && \"text-text-quaternary\",\n                          isDisabled && \"text-text-quaternary cursor-not-allowed opacity-50\",\n                          !isSelected && !isDisabled && \"hover:bg-mix-accent/background-1/4\",\n                        )}\n                        disabled={isDisabled}\n                      >\n                        {day.day}\n                      </Button>\n                    )\n                  })}\n                </div>\n              </>\n            )}\n\n            {/* Months View */}\n            {viewMode === \"months\" && (\n              <div className=\"mb-2 grid grid-cols-3 gap-1\">\n                {months.map((month) => {\n                  const monthProps = monthButton(month)\n                  const isSelected = month.selected\n                  const isToday = month.now\n                  const isDisabled = month.disabled\n\n                  return (\n                    <Button\n                      key={month.$date.toISOString()}\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      {...monthProps}\n                      onClick={(ev) => {\n                        monthProps.onClick?.(ev)\n                        setViewMode(\"days\")\n                      }}\n                      buttonClassName={cn(\n                        \"h-8 p-2 font-normal text-xs rounded-[4px]\",\n                        isSelected && \"bg-accent hover:bg-accent/90 text-white\",\n                        isToday && !isSelected && \"bg-accent/10 font-medium\",\n                        isDisabled && \"text-text-quaternary cursor-not-allowed opacity-50\",\n                        !isSelected && !isDisabled && \"hover:bg-mix-accent/background-1/4\",\n                      )}\n                      disabled={isDisabled}\n                    >\n                      {month.month}\n                    </Button>\n                  )\n                })}\n              </div>\n            )}\n\n            {/* Years View */}\n            {viewMode === \"years\" && (\n              <div className=\"mb-2 grid grid-cols-4 gap-1\">\n                {years.map((year) => {\n                  const yearProps = yearButton(year)\n                  const isSelected = year.selected\n                  const isToday = year.now\n                  const isDisabled = year.disabled\n\n                  return (\n                    <Button\n                      key={year.$date.toISOString()}\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      {...yearProps}\n                      onClick={(ev) => {\n                        yearProps.onClick?.(ev)\n                        setViewMode(\"days\")\n                      }}\n                      buttonClassName={cn(\n                        \"h-8 p-2 font-normal text-xs rounded-[4px]\",\n                        isSelected && \"bg-accent hover:bg-accent/90 text-white\",\n                        isToday && !isSelected && \"bg-accent/10 font-medium\",\n                        isDisabled && \"text-text-quaternary cursor-not-allowed opacity-50\",\n                        !isSelected && !isDisabled && \"hover:bg-mix-accent/background-1/4\",\n                      )}\n                      disabled={isDisabled}\n                    >\n                      {year.year}\n                    </Button>\n                  )\n                })}\n              </div>\n            )}\n\n            {/* Time Selection (only single mode) */}\n            {!isRangeMode && (\n              <div className=\"-mx-2 border-t border-border px-2 pt-2\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-xs font-medium text-text-secondary\">Time</span>\n                  <TimeSelect\n                    value={currentDateTime.format(\"HH:mm\")}\n                    onChange={handleTimeChange}\n                    className=\"gap-0.5\"\n                  />\n                </div>\n              </div>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n    )\n  },\n)\n\nDateTimePicker.displayName = \"DateTimePicker\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/Input.tsx",
    "content": "import { useInputComposition } from \"@follow/hooks\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { DetailedHTMLProps, InputHTMLAttributes } from \"react\"\nimport * as React from \"react\"\n// This composition handler is not perfect\n// @see https://foxact.skk.moe/use-composition-input\nexport const Input = ({\n  ref,\n  className,\n  ...props\n}: Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, \"ref\"> & {\n  ref?: React.Ref<HTMLInputElement | null>\n}) => {\n  const inputProps = useInputComposition(props)\n  return (\n    <input\n      onContextMenu={stopPropagation}\n      ref={ref}\n      className={cn(\n        \"min-w-0 flex-auto appearance-none rounded-lg text-sm\",\n        \"bg-theme-background px-3 py-[calc(theme(spacing.2)-1px)]\",\n        \"ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2\",\n        \"focus:!bg-accent/5\",\n        \"border border-border\",\n        \"placeholder:text-text-tertiary dark:bg-zinc-700/[0.15] dark:text-zinc-200\",\n        \"hover:border-accent/60\",\n        props.type === \"password\" && \"font-mono placeholder:font-sans\",\n        \"w-full\",\n        className,\n      )}\n      {...props}\n      {...inputProps}\n    />\n  )\n}\nInput.displayName = \"Input\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/InputV2.tsx",
    "content": "import { useInputComposition } from \"@follow/hooks\"\nimport { cn, stopPropagation } from \"@follow/utils\"\nimport type { DetailedHTMLProps, InputHTMLAttributes, ReactNode } from \"react\"\nimport { useState } from \"react\"\nimport * as React from \"react\"\n\nexport const InputV2 = ({\n  ref,\n  className,\n  icon,\n  canClear,\n  ...props\n}: Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, \"ref\"> & {\n  icon?: ReactNode\n  canClear?: boolean\n} & { ref?: React.RefObject<HTMLInputElement | null> }) => {\n  const [internalValue, setInternalValue] = useState(props.value || props.defaultValue || \"\")\n\n  const inputProps = useInputComposition(props)\n\n  const handleClear = () => {\n    setInternalValue(\"\")\n\n    if (props.onChange) {\n      const event = {\n        target: { value: \"\" },\n        currentTarget: { value: \"\" },\n      } as React.ChangeEvent<HTMLInputElement>\n      props.onChange(event)\n    }\n  }\n\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setInternalValue(e.target.value)\n    if (props.onChange) {\n      props.onChange(e)\n    }\n  }\n\n  const showClearButton = canClear && internalValue\n\n  return (\n    <div className=\"group relative\">\n      {icon && (\n        <div className=\"absolute left-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-text-tertiary\">\n          {icon}\n        </div>\n      )}\n      <input\n        onContextMenu={stopPropagation}\n        ref={ref}\n        className={cn(\n          \"min-w-0 flex-auto appearance-none rounded-lg text-sm\",\n          \"bg-theme-background py-[calc(theme(spacing.2)-1px)]\",\n          \"ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2\",\n          \"focus:!bg-accent/5\",\n          \"border border-border\",\n          \"placeholder:text-text-tertiary dark:bg-zinc-700/[0.15] dark:text-zinc-200\",\n          \"hover:border-accent/60\",\n          props.type === \"password\" && \"font-mono placeholder:font-sans\",\n          \"w-full\",\n          // Adjust padding based on icon and clear button\n          icon ? \"pl-9\" : \"pl-3\",\n          canClear ? \"pr-10\" : \"pr-3\",\n          className,\n        )}\n        {...props}\n        {...inputProps}\n        value={props.value !== undefined ? props.value : internalValue}\n        onChange={handleChange}\n      />\n      {showClearButton && (\n        <button\n          type=\"button\"\n          onClick={handleClear}\n          className=\"absolute right-3 top-1/2 hidden -translate-y-1/2 items-center text-text-tertiary transition-colors hover:text-text-secondary group-focus-within:flex\"\n          tabIndex={-1}\n        >\n          <i className=\"i-mingcute-close-circle-fill\" />\n        </button>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/OTP.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { OTPInput, OTPInputContext } from \"input-otp\"\nimport * as React from \"react\"\n\nconst InputOTP = ({\n  ref,\n  className,\n  containerClassName,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof OTPInput> & {\n  ref?: React.Ref<React.ElementRef<typeof OTPInput> | null>\n}) => (\n  <OTPInput\n    ref={ref}\n    containerClassName={cn(\n      \"flex items-center gap-2 has-[:disabled]:opacity-50\",\n      containerClassName,\n    )}\n    className={cn(\"disabled:cursor-not-allowed\", className)}\n    {...props}\n  />\n)\nInputOTP.displayName = \"InputOTP\"\n\nconst InputOTPGroup = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<\"div\"> & {\n  ref?: React.Ref<React.ElementRef<\"div\"> | null>\n}) => <div ref={ref} className={cn(\"flex items-center\", className)} {...props} />\nInputOTPGroup.displayName = \"InputOTPGroup\"\n\nconst InputOTPSlot = ({\n  ref,\n  index,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<\"div\"> & { index: number } & {\n  ref?: React.Ref<React.ElementRef<\"div\"> | null>\n}) => {\n  const inputOTPContext = React.use(OTPInputContext)\n  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"relative flex size-9 items-center justify-center border-y border-r border-border font-mono text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md\",\n        isActive && \"z-10 ring-1 ring-accent\",\n        className,\n      )}\n      {...props}\n    >\n      {char}\n      {hasFakeCaret && (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div className=\"bg-foreground h-4 w-px animate-caret-blink duration-1000\" />\n        </div>\n      )}\n    </div>\n  )\n}\nInputOTPSlot.displayName = \"InputOTPSlot\"\n\nconst InputOTPSeparator = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<\"div\"> & {\n  ref?: React.Ref<React.ElementRef<\"div\"> | null>\n}) => (\n  <div\n    ref={ref}\n    className={cn(\"flex w-10 items-center justify-center\", className)}\n    role=\"separator\"\n    {...props}\n  >\n    <div className=\"h-1 w-3 rounded-full bg-border\" />\n  </div>\n)\nInputOTPSeparator.displayName = \"InputOTPSeparator\"\n\nexport { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot }\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/TextArea.tsx",
    "content": "import { useInputComposition } from \"@follow/hooks\"\nimport { stopPropagation } from \"@follow/utils/dom\"\nimport { cn } from \"@follow/utils/utils\"\nimport type { DetailedHTMLProps, PropsWithChildren, TextareaHTMLAttributes } from \"react\"\nimport { useCallback, useState } from \"react\"\nimport * as React from \"react\"\n\nimport type { RoundedSize } from \"./TextAreaWrapper\"\nimport { roundedMap, TextAreaWrapper } from \"./TextAreaWrapper\"\n\nexport const TextArea = ({\n  ref,\n  ...props\n}: DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement> &\n  PropsWithChildren<{\n    wrapperClassName?: string\n    onCmdEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void\n    rounded?: RoundedSize\n    bordered?: boolean\n    autoHeight?: boolean\n  }> & { ref?: React.Ref<HTMLTextAreaElement | null> }) => {\n  const {\n    className,\n    wrapperClassName,\n    children,\n    rounded = \"lg\",\n    bordered = true,\n    onCmdEnter,\n    autoHeight,\n    ...rest\n  } = props\n\n  const syncHeight = useCallback(() => {\n    if (ref && \"current\" in ref && ref.current) {\n      const el = ref.current\n      el.style.height = \"auto\"\n      el.style.height = `${el.scrollHeight}px`\n    }\n  }, [ref])\n\n  const inputProps = useInputComposition<HTMLTextAreaElement>(props)\n  const [isFocus, setIsFocus] = useState(false)\n\n  return (\n    <TextAreaWrapper\n      wrapperClassName={wrapperClassName}\n      rounded={rounded}\n      bordered={bordered}\n      isFocused={isFocus}\n    >\n      <textarea\n        ref={ref}\n        className={cn(\n          \"size-full resize-none bg-transparent\",\n          \"overflow-auto px-3 py-4\",\n          \"!outline-none\",\n          \"text-text placeholder:text-text-tertiary\",\n          \"focus:!bg-accent/5\",\n          roundedMap[rounded],\n          className,\n        )}\n        {...rest}\n        onFocus={(e) => {\n          setIsFocus(true)\n          rest.onFocus?.(e)\n        }}\n        onBlur={(e) => {\n          setIsFocus(false)\n          rest.onBlur?.(e)\n        }}\n        onContextMenu={stopPropagation}\n        {...inputProps}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n            onCmdEnter?.(e)\n          }\n          rest.onKeyDown?.(e)\n          inputProps.onKeyDown?.(e)\n        }}\n        onInput={(e) => {\n          if (autoHeight) {\n            syncHeight()\n          }\n          rest.onInput?.(e)\n        }}\n      />\n\n      {children}\n    </TextAreaWrapper>\n  )\n}\nTextArea.displayName = \"TextArea\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/TextAreaWrapper.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport clsx from \"clsx\"\nimport type { PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { useCallback, useState } from \"react\"\n\nexport type RoundedSize = \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\" | \"3xl\" | \"default\"\n\nexport const roundedMap: Record<RoundedSize, string> = {\n  sm: \"rounded-sm\",\n  md: \"rounded-md\",\n  lg: \"rounded-lg\",\n  xl: \"rounded-xl\",\n  \"2xl\": \"rounded-2xl\",\n  \"3xl\": \"rounded-3xl\",\n  default: \"rounded\",\n}\n\nexport interface TextAreaWrapperProps extends PropsWithChildren {\n  /**\n   * Wrapper class name for the outer container\n   */\n  wrapperClassName?: string\n  /**\n   * Border radius style\n   */\n  rounded?: RoundedSize\n  /**\n   * Whether to show border\n   */\n  bordered?: boolean\n  /**\n   * Whether the textarea is focused\n   */\n  isFocused?: boolean\n  /**\n   * Callback when focus state changes\n   */\n  onFocusChange?: (isFocused: boolean) => void\n  /**\n   * Additional padding class name\n   */\n  paddingClassName?: string\n  /**\n   * Callback when pointer down event occurs\n   */\n  onPointerDown?: (e: React.PointerEvent) => void\n}\n\n/**\n * TextAreaWrapper - A reusable wrapper component for textarea-like inputs\n *\n * This component provides common UI/UX patterns:\n * - Focus state management with ring animation\n * - Border styles with hover effects\n * - Theme-aware background colors\n * - Mouse tracking for potential gradient effects\n * - Configurable border radius\n * - Optional border overlay\n *\n */\nexport const TextAreaWrapper = ({\n  children,\n  wrapperClassName,\n  rounded = \"lg\",\n  bordered = true,\n  isFocused: externalIsFocused,\n  onFocusChange,\n  paddingClassName,\n  onPointerDown,\n}: TextAreaWrapperProps) => {\n  const [internalIsFocused, setInternalIsFocused] = useState(false)\n  const isFocused = externalIsFocused ?? internalIsFocused\n\n  const handleFocus = useCallback(() => {\n    if (externalIsFocused === undefined) {\n      setInternalIsFocused(true)\n    }\n    onFocusChange?.(true)\n  }, [externalIsFocused, onFocusChange])\n\n  const handleBlur = useCallback(() => {\n    if (externalIsFocused === undefined) {\n      setInternalIsFocused(false)\n    }\n    onFocusChange?.(false)\n  }, [externalIsFocused, onFocusChange])\n\n  return (\n    <div\n      className={cn(\n        \"group relative flex h-full overflow-hidden border ring-0 ring-accent/20 duration-200\",\n        roundedMap[rounded],\n\n        // Border states\n        \"border-transparent hover:border-accent/60\",\n        isFocused && \"!border-accent/80 ring-2\",\n\n        // Theme colors\n        \"placeholder:text-text-tertiary dark:text-zinc-200\",\n        \"bg-theme-background dark:bg-zinc-700/[0.15]\",\n\n        paddingClassName,\n        wrapperClassName,\n      )}\n      onFocus={handleFocus}\n      onBlur={handleBlur}\n      onPointerDown={onPointerDown}\n    >\n      {/* Optional border overlay for better visual separation */}\n      {bordered && (\n        <div\n          className={clsx(\n            \"pointer-events-none absolute inset-0 z-0 border border-border\",\n            roundedMap[rounded],\n          )}\n          aria-hidden=\"true\"\n        />\n      )}\n      {children}\n    </div>\n  )\n}\n\nTextAreaWrapper.displayName = \"TextAreaWrapper\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/TimeSelect.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { memo, useMemo } from \"react\"\nimport * as React from \"react\"\n\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../select\"\n\nexport interface TimeSelectProps {\n  /** Time value in 24h format HH:mm */\n  value?: string\n  /** Called with new time string in HH:mm */\n  onChange?: (value: string) => void\n  /** Minute step granularity */\n  minuteStep?: number\n  className?: string\n  disabled?: boolean\n  /** Optional aria-label prefix (Hour / Minute will be appended) */\n  label?: string\n}\n\nconst FALLBACK_TIME = \"12:00\"\n/**\n * Custom time picker built from our Select primitives.\n * Provides hour + minute dropdowns (24h clock) with configurable minute step (default 1).\n */\nexport const TimeSelect = memo<TimeSelectProps>(\n  ({ value = FALLBACK_TIME, onChange, minuteStep = 1, className, disabled, label }) => {\n    // Normalize incoming value\n    const [hourValue, minuteValue] = useMemo(() => {\n      if (!/^\\d{2}:\\d{2}$/.test(value)) {\n        console.warn(\"Invalid TimeSelect value, expected HH:mm\", value)\n        return [\"12\", \"00\"]\n      }\n      const [h, m] = value.split(\":\")\n      return [h!.padStart(2, \"0\"), m!.padStart(2, \"0\")]\n    }, [value])\n\n    const hours = useMemo(\n      () =>\n        Array.from({ length: 24 }, (_, i) => {\n          const v = String(i).padStart(2, \"0\")\n          return { label: v, value: v }\n        }),\n      [],\n    )\n\n    const minutes = useMemo(() => {\n      const list: { label: string; value: string }[] = []\n      for (let i = 0; i < 60; i += minuteStep) {\n        const v = String(i).padStart(2, \"0\")\n        list.push({ label: v, value: v })\n      }\n      return list\n    }, [minuteStep])\n\n    const update = (h: string, m: string) => {\n      const hh = h.padStart(2, \"0\")\n      const mm = m.padStart(2, \"0\")\n      onChange?.(`${hh}:${mm}`)\n    }\n\n    return (\n      <div\n        className={cn(\n          \"flex items-center gap-1\", // layout\n          className,\n        )}\n      >\n        <Select disabled={disabled} value={hourValue} onValueChange={(h) => update(h, minuteValue)}>\n          <SelectTrigger\n            aria-label={label ? `${label} hour` : \"Hour\"}\n            className=\"h-6 w-12 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent/background-1/4\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent className=\"max-h-[50vh] w-16 p-1\">\n            {hours.map((h) => (\n              <SelectItem key={h.value} value={h.value} className=\"h-6 text-xs\">\n                {h.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <span className=\"mx-0.5 select-none text-xs text-text-secondary\">:</span>\n        <Select disabled={disabled} value={minuteValue} onValueChange={(m) => update(hourValue, m)}>\n          <SelectTrigger\n            aria-label={label ? `${label} minute` : \"Minute\"}\n            className=\"h-6 w-12 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent/background-1/4\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent className=\"max-h-[50vh] w-16 p-1\">\n            {minutes.map((m) => (\n              <SelectItem key={m.value} value={m.value} className=\"h-6 text-xs\">\n                {m.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n    )\n  },\n)\n\nTimeSelect.displayName = \"TimeSelect\"\n\nexport default TimeSelect\n"
  },
  {
    "path": "packages/internal/components/src/ui/input/index.ts",
    "content": "export * from \"./DateTimePicker\"\nexport * from \"./Input\"\nexport * from \"./InputV2\"\nexport * from \"./OTP\"\nexport * from \"./TextArea\"\nexport * from \"./TextAreaWrapper\"\nexport * from \"./TimeSelect\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/json-highlighter/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as React from \"react\"\n\nexport interface JsonHighlighterProps {\n  /** JSON string to highlight */\n  json: string\n  /** Additional CSS class name */\n  className?: string\n  /** Whether to show indentation */\n  showIndentation?: boolean\n  /** Whether to show line numbers */\n  showLineNumbers?: boolean\n  /** Maximum height before scrolling */\n  maxHeight?: string\n}\n\n/**\n * A lightweight JSON syntax highlighter component that uses regex matching\n * and TailwindCSS for styling without external highlighting libraries.\n */\nexport const JsonHighlighter = ({\n  ref,\n  json,\n  className,\n  showIndentation = true,\n  showLineNumbers = false,\n  maxHeight,\n  ...props\n}: JsonHighlighterProps & { ref?: React.RefObject<HTMLPreElement | null> }) => {\n  const highlightedJson = React.useMemo(() => {\n    try {\n      // Try to parse and format the JSON first\n      const parsed = JSON.parse(json)\n      const formatted = JSON.stringify(parsed, null, showIndentation ? 2 : 0)\n      return highlightJson(formatted)\n    } catch {\n      // If parsing fails, highlight the raw string\n      return highlightJson(json)\n    }\n  }, [json, showIndentation])\n\n  const lines = React.useMemo(() => {\n    return highlightedJson.split(\"\\n\")\n  }, [highlightedJson])\n\n  return (\n    <pre\n      ref={ref}\n      className={cn(\n        \"overflow-auto rounded-md border bg-material-ultra-thin p-4 text-sm text-text\",\n        \"font-mono leading-relaxed\",\n        className,\n      )}\n      style={{ maxHeight }}\n      {...props}\n    >\n      <code className=\"block\">\n        {showLineNumbers ? (\n          <div className=\"flex\">\n            <div className=\"mr-4 select-none border-r border-fill pr-4 text-text-tertiary\">\n              {lines.map((_, index) => (\n                <div key={index} className=\"text-right\">\n                  {index + 1}\n                </div>\n              ))}\n            </div>\n            <div className=\"flex-1\">\n              {lines.map((line, index) => (\n                <div key={index} dangerouslySetInnerHTML={{ __html: line }} />\n              ))}\n            </div>\n          </div>\n        ) : (\n          lines.map((line, index) => <div key={index} dangerouslySetInnerHTML={{ __html: line }} />)\n        )}\n      </code>\n    </pre>\n  )\n}\n\nJsonHighlighter.displayName = \"JsonHighlighter\"\n\n/**\n * Token types for JSON highlighting\n */\ninterface Token {\n  type: \"key\" | \"string\" | \"number\" | \"boolean\" | \"null\" | \"punctuation\" | \"whitespace\"\n  value: string\n  start: number\n  end: number\n}\n\n/**\n * Highlights JSON syntax using precise tokenization and UIKit colors\n */\nfunction highlightJson(jsonString: string): string {\n  // Escape HTML entities first\n  const escaped = jsonString\n    ?.replaceAll(\"&\", \"&amp;\")\n    .replaceAll(\"<\", \"&lt;\")\n    .replaceAll(\">\", \"&gt;\")\n\n  const tokens = tokenizeJson(escaped)\n  return renderTokens(tokens, escaped)\n}\n\n/**\n * Tokenizes JSON string into semantic tokens\n */\nfunction tokenizeJson(json: string): Token[] {\n  const tokens: Token[] = []\n  let i = 0\n\n  // Track context to distinguish keys from string values\n  const contextStack: (\"object\" | \"array\")[] = []\n  let expectingKey = false\n\n  while (i < json.length) {\n    const char = json[i]!\n\n    // Skip whitespace but track it for proper rendering\n    if (/\\s/.test(char)) {\n      const start = i\n      while (i < json.length && /\\s/.test(json[i]!)) {\n        i++\n      }\n      tokens.push({\n        type: \"whitespace\",\n        value: json.slice(start, i),\n        start,\n        end: i,\n      })\n      continue\n    }\n\n    // Handle strings (keys and values)\n    if (char === '\"') {\n      const start = i\n      i++ // Skip opening quote\n      let value = '\"'\n\n      // Parse string content, handling escapes\n      while (i < json.length) {\n        const current = json[i]\n        value += current\n\n        if (current === '\"') {\n          i++\n          break\n        }\n\n        // Handle escape sequences\n        if (current === \"\\\\\" && i + 1 < json.length) {\n          i++\n          value += json[i]\n        }\n        i++\n      }\n\n      // Determine if this is a key or string value\n      const isKey = expectingKey || (contextStack.at(-1) === \"object\" && isFollowedByColon(json, i))\n\n      tokens.push({\n        type: isKey ? \"key\" : \"string\",\n        value,\n        start,\n        end: i,\n      })\n\n      if (isKey) {\n        expectingKey = false\n      }\n      continue\n    }\n\n    // Handle numbers\n    if (/[-\\d]/.test(char)) {\n      const start = i\n      let value = \"\"\n\n      // Handle negative sign\n      if (char === \"-\") {\n        value += char\n        i++\n      }\n\n      // Parse integer part\n      if (i < json.length && /\\d/.test(json[i]!)) {\n        while (i < json.length && /\\d/.test(json[i]!)) {\n          value += json[i]!\n          i++\n        }\n\n        // Parse decimal part\n        if (i < json.length && json[i] === \".\") {\n          value += json[i]!\n          i++\n          while (i < json.length && /\\d/.test(json[i]!)) {\n            value += json[i]!\n            i++\n          }\n        }\n\n        // Parse exponent part\n        if (i < json.length && /e/i.test(json[i]!)) {\n          value += json[i]!\n          i++\n          if (i < json.length && /[+-]/.test(json[i]!)) {\n            value += json[i]!\n            i++\n          }\n          while (i < json.length && /\\d/.test(json[i]!)) {\n            value += json[i]!\n            i++\n          }\n        }\n\n        tokens.push({\n          type: \"number\",\n          value,\n          start,\n          end: i,\n        })\n        continue\n      } else {\n        // Not a valid number, treat as punctuation\n        tokens.push({\n          type: \"punctuation\",\n          value: char,\n          start,\n          end: i + 1,\n        })\n        i++\n        continue\n      }\n    }\n\n    // Handle boolean and null literals\n    if (/[tfn]/.test(char)) {\n      const start = i\n\n      // Check for 'true'\n      if (json.slice(i, i + 4) === \"true\") {\n        tokens.push({\n          type: \"boolean\",\n          value: \"true\",\n          start,\n          end: i + 4,\n        })\n        i += 4\n        continue\n      }\n\n      // Check for 'false'\n      if (json.slice(i, i + 5) === \"false\") {\n        tokens.push({\n          type: \"boolean\",\n          value: \"false\",\n          start,\n          end: i + 5,\n        })\n        i += 5\n        continue\n      }\n\n      // Check for 'null'\n      if (json.slice(i, i + 4) === \"null\") {\n        tokens.push({\n          type: \"null\",\n          value: \"null\",\n          start,\n          end: i + 4,\n        })\n        i += 4\n        continue\n      }\n    }\n\n    // Handle punctuation\n    if (/[{}[\\],:]/.test(char)) {\n      // Update context stack\n      switch (char) {\n        case \"{\": {\n          contextStack.push(\"object\")\n          expectingKey = true\n\n          break\n        }\n        case \"[\": {\n          contextStack.push(\"array\")\n\n          break\n        }\n        case \"}\":\n        case \"]\": {\n          contextStack.pop()\n          expectingKey = contextStack.at(-1) === \"object\"\n\n          break\n        }\n        case \",\": {\n          expectingKey = contextStack.at(-1) === \"object\"\n\n          break\n        }\n        case \":\": {\n          expectingKey = false\n\n          break\n        }\n        // No default\n      }\n\n      tokens.push({\n        type: \"punctuation\",\n        value: char,\n        start: i,\n        end: i + 1,\n      })\n      i++\n      continue\n    }\n\n    // Unknown character, skip\n    i++\n  }\n\n  return tokens\n}\n\n/**\n * Checks if a string token is followed by a colon (indicating it's a key)\n */\nfunction isFollowedByColon(json: string, startIndex: number): boolean {\n  let i = startIndex\n\n  // Skip whitespace\n  while (i < json.length && /\\s/.test(json[i]!)) {\n    i++\n  }\n\n  return i < json.length && json[i] === \":\"\n}\n\n/**\n * Renders tokens with appropriate UIKit colors\n */\nfunction renderTokens(tokens: Token[], originalJson: string): string {\n  let result = \"\"\n  let lastEnd = 0\n\n  for (const token of tokens) {\n    // Add any characters between tokens (shouldn't happen with proper tokenization)\n    if (token.start > lastEnd) {\n      result += originalJson.slice(lastEnd, token.start)\n    }\n\n    // Apply semantic coloring with enhanced Tailwind colors\n    switch (token.type) {\n      case \"key\": {\n        result += `<span class=\"text-sky-600 dark:text-sky-400 font-semibold\">${token.value}</span>`\n        break\n      }\n      case \"string\": {\n        result += `<span class=\"text-emerald-600 dark:text-emerald-400\">${token.value}</span>`\n        break\n      }\n      case \"number\": {\n        result += `<span class=\"text-amber-600 dark:text-amber-400\">${token.value}</span>`\n        break\n      }\n      case \"boolean\": {\n        result += `<span class=\"text-violet-600 dark:text-violet-400 font-medium\">${token.value}</span>`\n        break\n      }\n      case \"null\": {\n        result += `<span class=\"text-slate-500 dark:text-slate-400 italic\">${token.value}</span>`\n        break\n      }\n      case \"punctuation\": {\n        // Use different colors for different punctuation types\n        if (token.value === \":\") {\n          result += `<span class=\"text-slate-600 dark:text-slate-300\">${token.value}</span>`\n        } else if (/[{}[\\]]/.test(token.value)) {\n          result += `<span class=\"text-indigo-600 dark:text-indigo-400 font-semibold\">${token.value}</span>`\n        } else {\n          result += `<span class=\"text-slate-500 dark:text-slate-400\">${token.value}</span>`\n        }\n        break\n      }\n      case \"whitespace\": {\n        result += token.value\n        break\n      }\n      default: {\n        result += token.value\n        break\n      }\n    }\n\n    lastEnd = token.end\n  }\n\n  // Add any remaining characters\n  if (lastEnd < originalJson.length) {\n    result += originalJson.slice(lastEnd)\n  }\n\n  return result\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/katex/index.tsx",
    "content": "import katex from \"katex\"\nimport type { FC } from \"react\"\nimport { useMemo } from \"react\"\n\ntype KateXProps = {\n  children: string\n  mode?: \"display\" | \"inline\"\n}\n\nexport const KateX: FC<KateXProps> = (props) => {\n  const { children, mode } = props\n\n  const displayMode = mode === \"display\"\n\n  const throwOnError = false // render unsupported commands as text instead of throwing a `ParseError`\n\n  return (\n    <span\n      dangerouslySetInnerHTML={useMemo(\n        () => ({\n          __html: katex.renderToString(children, {\n            displayMode,\n            throwOnError,\n          }),\n        }),\n        [children, displayMode, throwOnError],\n      )}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/katex/lazy.tsx",
    "content": "import { lazy, Suspense } from \"react\"\n\nimport type { KateX } from \"./index\"\n\nconst LazyKateX_ = lazy(() => import(\"./index\").then((mod) => ({ default: mod.KateX })))\nexport const LazyKateX: typeof KateX = (props) => {\n  return (\n    <Suspense>\n      <LazyKateX_ {...props} />\n    </Suspense>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/kbd/Kbd.tsx",
    "content": "import { cn, getOS } from \"@follow/utils/utils\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\nimport { Fragment, memo } from \"react\"\nimport { isHotkeyPressed } from \"react-hotkeys-hook\"\n\nconst os = getOS()\n\nconst SharedKeys = {\n  backspace: \"⌫\",\n  space: \"␣\",\n  pageup: \"PageUp\",\n  pagedown: \"PageDown\",\n  tab: \"Tab\",\n  arrowup: \"↑\",\n  arrowdown: \"↓\",\n  arrowleft: \"←\",\n  arrowright: \"→\",\n\n  $mod: os === \"macOS\" ? \"⌘\" : \"Ctrl\",\n}\nconst SpecialKeys = {\n  Windows: {\n    meta: \"⊞\",\n    ctrl: \"Ctrl\",\n    control: \"Ctrl\",\n    alt: \"Alt\",\n    shift: \"Shift\",\n    escape: \"Esc\",\n  },\n  macOS: {\n    meta: \"⌘\",\n    ctrl: \"⌃\",\n    control: \"⌃\",\n    alt: \"⌥\",\n    shift: \"⇧\",\n    escape: \"⎋\",\n  },\n  Linux: {\n    meta: \"Super\",\n    ctrl: \"Ctrl\",\n    control: \"Ctrl\",\n    alt: \"Alt\",\n    shift: \"Shift\",\n    escape: \"Escape\",\n  },\n}\n// @ts-ignore\nSpecialKeys.iOS = SpecialKeys.macOS\n// @ts-ignore\nSpecialKeys.Android = SpecialKeys.Linux\n\nexport const KbdCombined: FC<{\n  children: string\n  className?: string\n  joint?: boolean\n  kbdProps?: Partial<React.ComponentProps<typeof Kbd>>\n  abbr?: string\n}> = ({ children, joint, className, kbdProps, abbr }) => {\n  const keys = children.split(\",\")\n  return (\n    <div className=\"flex items-center gap-1\">\n      {keys.map((k, i) => (\n        <Fragment key={k}>\n          {joint ? (\n            <Kbd className={className} {...kbdProps} abbr={abbr}>\n              {k}\n            </Kbd>\n          ) : (\n            <div className=\"flex items-center gap-1\">\n              {k.split(\"+\").map((key) => (\n                <Kbd key={key} className={className} {...kbdProps} abbr={abbr}>\n                  {key}\n                </Kbd>\n              ))}\n            </div>\n          )}\n          {i !== keys.length - 1 && (\n            <span>\n              <i className=\"i-mgc-line-cute-re size-[0.75em] shrink-0 origin-center translate-y-[0.15em] rotate-[-25deg] text-text-secondary\" />\n            </span>\n          )}\n        </Fragment>\n      ))}\n    </div>\n  )\n}\n\n// Key: `[` `1` `Meta+B`\nfunction simulateKeyPress(key: string) {\n  const keyCodes = {\n    \"0\": 48,\n    \"1\": 49,\n    \"2\": 50,\n    \"3\": 51,\n    \"4\": 52,\n    \"5\": 53,\n    \"6\": 54,\n    \"7\": 55,\n    \"8\": 56,\n    \"9\": 57,\n    a: 65,\n    b: 66,\n    c: 67,\n    d: 68,\n    e: 69,\n    f: 70,\n    g: 71,\n    h: 72,\n    i: 73,\n    j: 74,\n    k: 75,\n    l: 76,\n    m: 77,\n    n: 78,\n    o: 79,\n    p: 80,\n    q: 81,\n    r: 82,\n    s: 83,\n    t: 84,\n    u: 85,\n    v: 86,\n    w: 87,\n    x: 88,\n    y: 89,\n    z: 90,\n    \"[\": 219,\n    \"]\": 221,\n    \"\\\\\": 220,\n    \";\": 186,\n    \"'\": 222,\n    \",\": 188,\n    \".\": 190,\n    \"/\": 191,\n    \"`\": 192,\n    \"-\": 189,\n    \"=\": 187,\n    backspace: 8,\n    tab: 9,\n    enter: 13,\n    shift: 16,\n    ctrl: 17,\n    alt: 18,\n    meta: 91,\n    escape: 27,\n    space: 32,\n    pageup: 33,\n    pagedown: 34,\n    end: 35,\n    home: 36,\n    arrowleft: 37,\n    arrowup: 38,\n    arrowright: 39,\n    arrowdown: 40,\n  }\n\n  // Handle combination keys like \"Meta+B\"\n  if (key.includes(\"+\")) {\n    const keys = key.split(\"+\")\n    const modifiers = {\n      meta: false,\n      ctrl: false,\n      alt: false,\n      shift: false,\n    }\n\n    let mainKey = \"\"\n\n    // Process each part of the combination\n    keys.forEach((k) => {\n      const lowerK = k.toLowerCase().trim()\n      switch (lowerK) {\n        case \"meta\":\n        case \"command\":\n        case \"cmd\": {\n          modifiers.meta = true\n\n          break\n        }\n        case \"ctrl\":\n        case \"control\": {\n          modifiers.ctrl = true\n\n          break\n        }\n        case \"alt\":\n        case \"option\": {\n          modifiers.alt = true\n\n          break\n        }\n        case \"shift\": {\n          modifiers.shift = true\n\n          break\n        }\n        default: {\n          mainKey = k\n        }\n      }\n    })\n\n    if (mainKey) {\n      const code =\n        keyCodes[mainKey.toLowerCase() as keyof typeof keyCodes] || mainKey.codePointAt(0)\n      const event = new KeyboardEvent(\"keydown\", {\n        key: mainKey.length === 1 ? mainKey : mainKey.toLowerCase(),\n        code: mainKey.length === 1 ? `Key${mainKey.toUpperCase()}` : mainKey,\n        keyCode: code,\n        which: code,\n        metaKey: modifiers.meta,\n        ctrlKey: modifiers.ctrl,\n        altKey: modifiers.alt,\n        shiftKey: modifiers.shift,\n        bubbles: true,\n        cancelable: true,\n      })\n\n      document.dispatchEvent(event)\n    }\n    return\n  }\n\n  // Handle single keys\n  const lowerKey = key.toLowerCase().trim()\n  const code = keyCodes[lowerKey as keyof typeof keyCodes] || key.codePointAt(0)\n\n  const keyCode = code\n  let keyName = key\n  let codeStr = `Key${key.toUpperCase()}`\n\n  // Special handling for non-letter keys\n  if (key.length === 1 && !/[a-z]/i.test(key)) {\n    codeStr = key\n    switch (key) {\n      case \"[\": {\n        codeStr = \"BracketLeft\"\n        break\n      }\n      case \"]\": {\n        codeStr = \"BracketRight\"\n        break\n      }\n      case \"\\\\\": {\n        codeStr = \"Backslash\"\n        break\n      }\n      case \";\": {\n        codeStr = \"Semicolon\"\n        break\n      }\n      case \"'\": {\n        codeStr = \"Quote\"\n        break\n      }\n      case \",\": {\n        codeStr = \"Comma\"\n        break\n      }\n      case \".\": {\n        codeStr = \"Period\"\n        break\n      }\n      case \"/\": {\n        codeStr = \"Slash\"\n        break\n      }\n      case \"`\": {\n        codeStr = \"Backquote\"\n        break\n      }\n      case \"-\": {\n        codeStr = \"Minus\"\n        break\n      }\n      case \"=\": {\n        codeStr = \"Equal\"\n        break\n      }\n      case \"0\":\n      case \"1\":\n      case \"2\":\n      case \"3\":\n      case \"4\":\n      case \"5\":\n      case \"6\":\n      case \"7\":\n      case \"8\":\n      case \"9\": {\n        codeStr = `Digit${key}`\n        break\n      }\n    }\n  } else if (lowerKey === \"space\") {\n    keyName = \" \"\n    codeStr = \"Space\"\n  } else if (lowerKey.startsWith(\"arrow\")) {\n    codeStr = lowerKey.charAt(0).toUpperCase() + lowerKey.slice(1)\n  }\n\n  const event = new KeyboardEvent(\"keydown\", {\n    key: keyName,\n    code: codeStr,\n    keyCode,\n    which: keyCode,\n    bubbles: true,\n    cancelable: true,\n  })\n\n  document.dispatchEvent(event)\n}\nexport const Kbd: FC<{\n  children: string\n  className?: string\n  wrapButton?: boolean\n  abbr?: string\n}> = memo(({ children, className, wrapButton = true, abbr }) => {\n  let specialKeys = (SpecialKeys as any)[os] as Record<string, string>\n  specialKeys = { ...SharedKeys, ...specialKeys }\n\n  const [isKeyPressed, setIsKeyPressed] = React.useState(false)\n  React.useEffect(() => {\n    const handler = () => {\n      setIsKeyPressed(isHotkeyPressed(children.toLowerCase()))\n    }\n    document.addEventListener(\"keydown\", handler)\n    document.addEventListener(\"keyup\", handler)\n\n    return () => {\n      document.removeEventListener(\"keydown\", handler)\n      document.removeEventListener(\"keyup\", handler)\n    }\n  }, [children])\n\n  const handleClick = React.useCallback(() => {\n    setIsKeyPressed(true)\n    setTimeout(() => {\n      setIsKeyPressed(false)\n    }, 100)\n\n    simulateKeyPress(children.trim())\n  }, [children])\n\n  const KbdElement = (\n    <kbd\n      className={cn(\n        \"kbd box-border h-5 space-x-1 font-sans text-[0.7rem] tabular-nums text-text transition-all duration-200\",\n        wrapButton && isKeyPressed && \"scale-95 opacity-80\",\n        className,\n      )}\n      {...(abbr && { title: abbr })}\n    >\n      {children.split(\"+\").map((key_) => {\n        let key: string = key_.toLowerCase()\n        for (const [k, v] of Object.entries(specialKeys)) {\n          key = key.replace(k, v)\n        }\n\n        switch (key) {\n          case SharedKeys.space: {\n            return <MaterialSymbolsSpaceBarRounded key={key} />\n          }\n\n          case SharedKeys.backspace: {\n            return <IcOutlineBackspace key={key} />\n          }\n          case SpecialKeys.macOS.meta: {\n            return <MaterialSymbolsKeyboardCommandKey key={key} />\n          }\n          case SpecialKeys.macOS.alt: {\n            return <MaterialSymbolsKeyboardOptionKey key={key} />\n          }\n\n          case SpecialKeys.macOS.ctrl: {\n            return <MaterialSymbolsKeyboardControlKey key={key} />\n          }\n\n          case SpecialKeys.macOS.shift: {\n            return <MaterialSymbolsShiftOutlineRounded key={key} />\n          }\n\n          case SharedKeys.tab: {\n            return <MaterialSymbolsKeyboardTabRounded key={key} />\n          }\n          case SpecialKeys.Windows.meta: {\n            return <MaterialSymbolsWindowOutlineSharp key={key} />\n          }\n          default: {\n            return (\n              <span className=\"capitalize\" key={key}>\n                {key}\n              </span>\n            )\n          }\n        }\n      })}\n    </kbd>\n  )\n  return wrapButton ? (\n    <button type=\"button\" className=\"contents\" onClick={handleClick}>\n      {KbdElement}\n    </button>\n  ) : (\n    KbdElement\n  )\n})\n\nfunction MaterialSymbolsKeyboardCommandKey(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M6.5 21q-1.45 0-2.475-1.025T3 17.5t1.025-2.475T6.5 14H8v-4H6.5q-1.45 0-2.475-1.025T3 6.5t1.025-2.475T6.5 3t2.475 1.025T10 6.5V8h4V6.5q0-1.45 1.025-2.475T17.5 3t2.475 1.025T21 6.5t-1.025 2.475T17.5 10H16v4h1.5q1.45 0 2.475 1.025T21 17.5t-1.025 2.475T17.5 21t-2.475-1.025T14 17.5V16h-4v1.5q0 1.45-1.025 2.475T6.5 21m0-2q.625 0 1.063-.437T8 17.5V16H6.5q-.625 0-1.062.438T5 17.5t.438 1.063T6.5 19m11 0q.625 0 1.063-.437T19 17.5t-.437-1.062T17.5 16H16v1.5q0 .625.438 1.063T17.5 19M10 14h4v-4h-4zM6.5 8H8V6.5q0-.625-.437-1.062T6.5 5t-1.062.438T5 6.5t.438 1.063T6.5 8M16 8h1.5q.625 0 1.063-.437T19 6.5t-.437-1.062T17.5 5t-1.062.438T16 6.5z\"\n      />\n    </svg>\n  )\n}\n\nfunction MaterialSymbolsKeyboardOptionKey(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path fill=\"currentColor\" d=\"M14.775 19L7.85 7H3V5h6l6.925 12H21v2zM15 7V5h6v2z\" />\n    </svg>\n  )\n}\n\nfunction MaterialSymbolsKeyboardControlKey(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path fill=\"currentColor\" d=\"M6.4 13.4L5 12l7-7l7 7l-1.4 1.4L12 7.825z\" />\n    </svg>\n  )\n}\n\nfunction MaterialSymbolsShiftOutlineRounded(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M8 20v-7H5.1q-.65 0-.912-.562t.137-1.063l6.9-8.425q.3-.375.775-.375t.775.375l6.9 8.425q.4.5.138 1.063T18.9 13H16v7q0 .425-.288.713T15 21H9q-.425 0-.712-.288T8 20m2-1h4v-8h2.775L12 5.15L7.225 11H10zm2-8\"\n      />\n    </svg>\n  )\n}\n\nfunction MaterialSymbolsKeyboardTabRounded(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M21 18q-.425 0-.712-.288T20 17V7q0-.425.288-.712T21 6t.713.288T22 7v10q0 .425-.288.713T21 18m-6.825-5H3q-.425 0-.712-.288T2 12t.288-.712T3 11h11.175L11.3 8.1q-.275-.275-.288-.687T11.3 6.7q.275-.275.7-.275t.7.275l4.6 4.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-4.6 4.6q-.275.275-.687.275T11.3 17.3q-.3-.3-.3-.712t.3-.713z\"\n      />\n    </svg>\n  )\n}\n\nfunction MaterialSymbolsSpaceBarRounded(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M6 15q-.825 0-1.412-.587T4 13v-3q0-.425.288-.712T5 9t.713.288T6 10v3h12v-3q0-.425.288-.712T19 9t.713.288T20 10v3q0 .825-.587 1.413T18 15z\"\n      />\n    </svg>\n  )\n}\n\nexport function IcOutlineBackspace(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M22 3H7c-.69 0-1.23.35-1.59.88L0 12l5.41 8.11c.36.53.9.89 1.59.89h15c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H7.07L2.4 12l4.66-7H22zm-11.59-2L14 13.41L17.59 17L19 15.59L15.41 12L19 8.41L17.59 7L14 10.59L10.41 7L9 8.41L12.59 12L9 15.59z\"\n      />\n    </svg>\n  )\n}\n\nfunction MaterialSymbolsWindowOutlineSharp(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M21 21H3V3h18zm-8-8v6h6v-6zm0-2h6V5h-6zm-2 0V5H5v6zm0 2H5v6h6z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/key-value-editor/index.tsx",
    "content": "import { Button } from \"@follow/components/ui/button/index.js\"\nimport { Input } from \"@follow/components/ui/input/index.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\nexport interface KeyValuePair {\n  key: string\n  value: string\n}\n\nexport interface KeyValueEditorProps {\n  value?: Record<string, string>\n  onChange?: (data: Record<string, string>) => void\n  className?: string\n  keyPlaceholder?: string\n  valuePlaceholder?: string\n  addButtonText?: string\n  minRows?: number\n  disabled?: boolean\n}\n\nconst emptyObject = {} as Record<string, string>\n\nexport const KeyValueEditor = ({\n  value = emptyObject,\n  onChange,\n  className,\n  keyPlaceholder = \"Key\",\n  valuePlaceholder = \"Value\",\n  addButtonText = \"Add Row\",\n  minRows = 1,\n  disabled = false,\n}: KeyValueEditorProps) => {\n  // Internal state for key-value pairs array\n  const [pairs, setPairs] = useState<KeyValuePair[]>(() => {\n    const entries = Object.entries(value)\n    return entries.length > 0\n      ? entries.map(([key, val]) => ({ key, value: val }))\n      : Array.from({ length: minRows }, () => ({ key: \"\", value: \"\" }))\n  })\n\n  // Track if we're in the middle of an internal update to avoid sync conflicts\n  const isInternalUpdateRef = useRef(false)\n  const lastExternalValueRef = useRef(value)\n\n  // Sync external value changes to internal state\n  useEffect(() => {\n    // Skip sync if this change originated from internal update\n    if (isInternalUpdateRef.current) {\n      isInternalUpdateRef.current = false\n      return\n    }\n\n    // Skip if external value hasn't actually changed\n    if (JSON.stringify(value) === JSON.stringify(lastExternalValueRef.current)) {\n      return\n    }\n\n    lastExternalValueRef.current = value\n\n    const entries = Object.entries(value)\n    const newPairs =\n      entries.length > 0\n        ? entries.map(([key, val]) => ({ key, value: val }))\n        : Array.from({ length: minRows }, () => ({ key: \"\", value: \"\" }))\n\n    setPairs(newPairs)\n  }, [value, minRows])\n\n  // Convert pairs array to object and notify parent\n  const notifyChange = useCallback(\n    (newPairs: KeyValuePair[]) => {\n      if (!onChange) return\n\n      const dataObject = newPairs.reduce(\n        (acc, { key, value }) => {\n          if (key.trim() && value.trim()) {\n            acc[key.trim()] = value.trim()\n          }\n          return acc\n        },\n        {} as Record<string, string>,\n      )\n\n      // Mark this as an internal update to prevent sync conflicts\n      isInternalUpdateRef.current = true\n      lastExternalValueRef.current = dataObject\n      onChange(dataObject)\n    },\n    [onChange],\n  )\n\n  const addPair = useCallback(() => {\n    const newPairs = [...pairs, { key: \"\", value: \"\" }]\n    setPairs(newPairs)\n    notifyChange(newPairs)\n  }, [pairs, notifyChange])\n\n  const removePair = useCallback(\n    (index: number) => {\n      if (pairs.length <= minRows) return\n\n      const newPairs = pairs.filter((_, i) => i !== index)\n      setPairs(newPairs)\n      notifyChange(newPairs)\n    },\n    [pairs, minRows, notifyChange],\n  )\n\n  const updatePair = useCallback(\n    (index: number, field: \"key\" | \"value\", newValue: string) => {\n      const newPairs = pairs.map((pair, i) => (i === index ? { ...pair, [field]: newValue } : pair))\n      setPairs(newPairs)\n      notifyChange(newPairs)\n    },\n    [pairs, notifyChange],\n  )\n\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      {pairs.map((pair, index) => (\n        <div key={index} className=\"flex items-center gap-2\">\n          <Input\n            placeholder={keyPlaceholder}\n            value={pair.key}\n            onChange={(e) => updatePair(index, \"key\", e.target.value)}\n            className=\"flex-1\"\n            disabled={disabled}\n          />\n          <Input\n            placeholder={valuePlaceholder}\n            value={pair.value}\n            onChange={(e) => updatePair(index, \"value\", e.target.value)}\n            className=\"flex-1\"\n            disabled={disabled}\n          />\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => removePair(index)}\n            disabled={disabled || pairs.length <= minRows}\n            buttonClassName=\"size-8 shrink-0 p-0\"\n          >\n            <i className=\"i-mgc-close-cute-re\" />\n          </Button>\n        </div>\n      ))}\n\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"sm\"\n        onClick={addPair}\n        disabled={disabled}\n        buttonClassName=\"w-full h-8\"\n      >\n        <i className=\"i-mgc-add-cute-re mr-2\" />\n        {addButtonText}\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/label/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\nimport * as React from \"react\"\n\nconst labelVariants = cva(\n  \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n)\n\nexport const Label = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n  VariantProps<typeof labelVariants> & {\n    ref?: React.Ref<React.ElementRef<typeof LabelPrimitive.Root> | null>\n  }) => <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />\nLabel.displayName = LabelPrimitive.Root.displayName\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx",
    "content": "import { cn, stopPropagation } from \"@follow/utils\"\nimport { TRANSFORMERS } from \"@lexical/markdown\"\nimport { AutoFocusPlugin } from \"@lexical/react/LexicalAutoFocusPlugin\"\nimport { AutoLinkPlugin } from \"@lexical/react/LexicalAutoLinkPlugin\"\nimport type { InitialConfigType } from \"@lexical/react/LexicalComposer\"\nimport { LexicalComposer } from \"@lexical/react/LexicalComposer\"\nimport { ContentEditable } from \"@lexical/react/LexicalContentEditable\"\nimport { EditorRefPlugin } from \"@lexical/react/LexicalEditorRefPlugin\"\nimport { LexicalErrorBoundary } from \"@lexical/react/LexicalErrorBoundary\"\nimport { HistoryPlugin } from \"@lexical/react/LexicalHistoryPlugin\"\nimport { LinkPlugin } from \"@lexical/react/LexicalLinkPlugin\"\nimport { ListPlugin } from \"@lexical/react/LexicalListPlugin\"\nimport { MarkdownShortcutPlugin } from \"@lexical/react/LexicalMarkdownShortcutPlugin\"\nimport { OnChangePlugin } from \"@lexical/react/LexicalOnChangePlugin\"\nimport { RichTextPlugin } from \"@lexical/react/LexicalRichTextPlugin\"\nimport { TabIndentationPlugin } from \"@lexical/react/LexicalTabIndentationPlugin\"\nimport type { LexicalEditor } from \"lexical\"\nimport { $getRoot } from \"lexical\"\nimport { useImperativeHandle, useState } from \"react\"\n\nimport { LexicalRichEditorNodes } from \"./nodes\"\nimport {\n  CodeHighlightingPlugin,\n  ExitCodeBoundaryPlugin,\n  KeyboardPlugin,\n  TripleBacktickTogglePlugin,\n} from \"./plugins\"\nimport { StringLengthChangePlugin } from \"./plugins/string-length-change\"\nimport { defaultLexicalTheme } from \"./theme\"\nimport type { BuiltInPlugins, LexicalRichEditorProps, LexicalRichEditorRef } from \"./types\"\n\nfunction onError(error: Error) {\n  console.error(\"Lexical Editor Error:\", error)\n}\nconst defaultEnabledPlugins: BuiltInPlugins = {\n  history: true,\n  markdown: true,\n  list: true,\n  link: true,\n  autoFocus: true,\n  autoLink: true,\n  tabIndentation: true,\n}\n\nconst URL_MATCHER =\n  /((https?:\\/\\/(www\\.)?)|(www\\.))[-\\w@:%.+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-\\w()@:%+.~#?&/=]*)/\n\nconst MATCHERS = [\n  (text: string) => {\n    const match = URL_MATCHER.exec(text)\n    if (match === null) {\n      return null\n    }\n    const fullMatch = match[0]\n    return {\n      index: match.index,\n      length: fullMatch.length,\n      text: fullMatch,\n      url: fullMatch.startsWith(\"http\") ? fullMatch : `https://${fullMatch}`,\n      attributes: { rel: \"noreferrer\", target: \"_blank\" },\n    }\n  },\n]\nexport const LexicalRichEditor = function LexicalRichEditor({\n  ref,\n  placeholder = \"Enter your message...\",\n  className,\n  namespace = \"LexicalRichEditor\",\n  autoFocus = false,\n  theme = defaultLexicalTheme,\n  enabledPlugins = defaultEnabledPlugins,\n  initalEditorState,\n  plugins,\n  accessories,\n\n  onKeyDown,\n  onChange,\n  onLengthChange,\n}: LexicalRichEditorProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) {\n  const [editorRef, setEditorRef] = useState<LexicalEditor | null>(null)\n\n  // Collect nodes from plugins\n  const pluginNodes = plugins?.flatMap((plugin) => plugin.nodes || []) || []\n\n  // Merge base nodes with custom nodes and plugin nodes\n  const allNodes = [...LexicalRichEditorNodes, ...pluginNodes]\n\n  const initialConfig: InitialConfigType = {\n    namespace,\n    theme,\n    onError,\n    nodes: allNodes,\n    editorState: initalEditorState,\n  }\n\n  useImperativeHandle(ref, () => ({\n    getEditor: () => editorRef!,\n    focus: () => {\n      editorRef?.focus()\n    },\n    clear: () => {\n      editorRef?.update(() => {\n        const root = $getRoot()\n        root.clear()\n      })\n    },\n    isEmpty: () =>\n      editorRef?.getEditorState().read(() => $getRoot().getTextContent().trim() === \"\") || false,\n  }))\n\n  return (\n    <LexicalComposer initialConfig={initialConfig}>\n      <div className={cn(\"relative cursor-text\", className)}>\n        {accessories}\n        <RichTextPlugin\n          contentEditable={\n            <ContentEditable\n              onContextMenu={stopPropagation}\n              className={cn(\n                \"size-full cursor-text text-text scrollbar-none placeholder:text-text-secondary\",\n                \"size-full resize-none bg-transparent\",\n                \"text-sm !outline-none transition-all duration-200 focus:outline-none\",\n              )}\n              aria-placeholder={placeholder}\n              placeholder={\n                <div className=\"pointer-events-none absolute left-0 top-0 text-sm text-text-secondary\">\n                  {placeholder}\n                </div>\n              }\n            />\n          }\n          ErrorBoundary={LexicalErrorBoundary}\n        />\n\n        {onChange && <OnChangePlugin onChange={onChange} />}\n        {onLengthChange && <StringLengthChangePlugin onChange={onLengthChange} />}\n        <EditorRefPlugin editorRef={setEditorRef} />\n        {enabledPlugins.tabIndentation && <TabIndentationPlugin />}\n        {enabledPlugins.autoLink && <AutoLinkPlugin matchers={MATCHERS} />}\n        {enabledPlugins.history && <HistoryPlugin />}\n        {enabledPlugins.markdown && <MarkdownShortcutPlugin transformers={TRANSFORMERS} />}\n        {enabledPlugins.list && <ListPlugin />}\n        {enabledPlugins.link && <LinkPlugin />}\n\n        {plugins?.map((Plugin) => (\n          <Plugin key={Plugin.id} />\n        ))}\n\n        <ExitCodeBoundaryPlugin />\n        <CodeHighlightingPlugin />\n        <TripleBacktickTogglePlugin />\n        <KeyboardPlugin onKeyDown={onKeyDown} />\n        {autoFocus && enabledPlugins.autoFocus && <AutoFocusPlugin />}\n      </div>\n    </LexicalComposer>\n  )\n}\n\nLexicalRichEditor.displayName = \"LexicalRichEditor\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditorTextArea.tsx",
    "content": "import { cn, nextFrame } from \"@follow/utils\"\nimport type { EditorState, LexicalEditor } from \"lexical\"\nimport { $getRoot } from \"lexical\"\nimport { useCallback, useImperativeHandle, useMemo, useRef, useState } from \"react\"\n\nimport type { RoundedSize } from \"../input/TextAreaWrapper\"\nimport { roundedMap, TextAreaWrapper } from \"../input/TextAreaWrapper\"\nimport { ScrollArea } from \"../scroll-area\"\nimport { LexicalRichEditor } from \"./LexicalRichEditor\"\nimport type { LexicalRichEditorProps, LexicalRichEditorRef } from \"./types\"\nimport { getEditorStateJSONString } from \"./utils\"\n\ninterface LexicalRichEditorTextAreaProps extends Omit<LexicalRichEditorProps, \"initalEditorState\"> {\n  /**\n   * Initial value can be:\n   * - JSON string (serialized EditorState)\n   * - Plain text (will be converted to EditorState)\n   */\n  initialValue?: string\n  /**\n   * Callback when editor content changes\n   * @param serializedState - JSON string of the editor state\n   * @param textLength - Length of plain text content\n   */\n  onValueChange?: (serializedState: string, textLength: number) => void\n  /**\n   * Callback when editor is ready\n   */\n  onEditorReady?: (editor: LexicalEditor) => void\n  /**\n   * Wrapper class name for the outer container\n   */\n  wrapperClassName?: string\n  /**\n   * Border radius style\n   */\n  rounded?: RoundedSize\n  /**\n   * Whether to show border\n   */\n  bordered?: boolean\n}\n\nexport const LexicalRichEditorTextArea = ({\n  initialValue,\n  onChange,\n  onValueChange,\n  onEditorReady,\n  wrapperClassName,\n  rounded = \"lg\",\n  bordered = true,\n  className,\n  ref,\n  ...restProps\n}: LexicalRichEditorTextAreaProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) => {\n  const editorRef = useRef<LexicalRichEditorRef | null>(null)\n  const [isFocus, setIsFocus] = useState(false)\n\n  // Create initial editor state from saved value\n  const initialEditorState = useMemo(() => {\n    if (initialValue === undefined) return null\n\n    // Try to parse as JSON state first\n    try {\n      // If successful, it's already a JSON state\n      const json = JSON.parse(initialValue)\n      // Check if it has root and children\n      if (!(\"root\" in json) || !(\"children\" in json.root) || json.root.children.length === 0) {\n        return getEditorStateJSONString(\"\")\n      }\n      return initialValue\n    } catch {\n      // If parsing fails, it's plain text, convert it\n      return getEditorStateJSONString(initialValue)\n    }\n  }, [initialValue])\n\n  const handleEditorChange = useCallback(\n    (editorState: EditorState, editor: LexicalEditor) => {\n      // Call original onChange if provided\n      onChange?.(editorState, editor)\n\n      // Call onValueChange if provided\n      if (onValueChange) {\n        editorState.read(() => {\n          const root = $getRoot()\n          const textContent = root.getTextContent()\n          const { length } = textContent\n\n          // Notify parent with serialized state\n          const serializedState = JSON.stringify(editorState.toJSON())\n          onValueChange(serializedState, length)\n        })\n      }\n\n      // Notify parent when editor is ready\n      if (onEditorReady) {\n        onEditorReady(editor)\n      }\n    },\n    [onChange, onValueChange, onEditorReady],\n  )\n\n  useImperativeHandle(ref, () => editorRef.current!)\n\n  const handlePointerDown = useCallback(() => {\n    nextFrame(() => {\n      editorRef.current?.getEditor().focus()\n    })\n  }, [editorRef])\n\n  return (\n    <TextAreaWrapper\n      wrapperClassName={wrapperClassName}\n      rounded={rounded}\n      bordered={bordered}\n      isFocused={isFocus}\n      onFocusChange={setIsFocus}\n      paddingClassName=\"p-0\"\n      onPointerDown={handlePointerDown}\n    >\n      <ScrollArea.ScrollArea rootClassName=\"size-full\" viewportClassName=\"px-3 py-4\">\n        <LexicalRichEditor\n          ref={editorRef}\n          {...restProps}\n          className={cn(\n            \"size-full resize-none bg-transparent\",\n            \"!outline-none\",\n            \"text-text\",\n            \"focus:!bg-accent/5\",\n            roundedMap[rounded],\n            className,\n          )}\n          onChange={handleEditorChange}\n          initalEditorState={initialEditorState}\n        />\n      </ScrollArea.ScrollArea>\n    </TextAreaWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/editor.tsx",
    "content": "import type { CreateEditorArgs } from \"lexical\"\nimport { createEditor } from \"lexical\"\n\nimport { LexicalRichEditorNodes } from \"./nodes\"\nimport { defaultLexicalTheme } from \"./theme\"\nimport type { LexicalPluginFC } from \"./types\"\n\nexport const createLexicalEditor = (options: CreateEditorArgs) => {\n  const editor = createEditor({\n    theme: defaultLexicalTheme,\n    nodes: LexicalRichEditorNodes,\n    ...options,\n  })\n  return editor\n}\n\nexport const createDefaultLexicalEditor = (plugins?: LexicalPluginFC<unknown>[]) => {\n  const pluginNodes = plugins?.flatMap((plugin) => plugin.nodes || []) || []\n\n  const allNodes = [...LexicalRichEditorNodes, ...pluginNodes]\n  return createLexicalEditor({\n    namespace: \"LexicalRichEditor\",\n    theme: defaultLexicalTheme,\n    nodes: allNodes,\n  })\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/index.ts",
    "content": "export { createDefaultLexicalEditor, createLexicalEditor } from \"./editor\"\nexport { LexicalRichEditor } from \"./LexicalRichEditor\"\nexport { LexicalRichEditorTextArea } from \"./LexicalRichEditorTextArea\"\nexport { LexicalRichEditorNodes } from \"./nodes\"\nexport { KeyboardPlugin } from \"./plugins\"\nexport { defaultLexicalTheme } from \"./theme\"\nexport type * from \"./types\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/nodes.ts",
    "content": "import { CodeHighlightNode, CodeNode } from \"@lexical/code\"\nimport { AutoLinkNode, LinkNode } from \"@lexical/link\"\nimport { ListItemNode, ListNode } from \"@lexical/list\"\nimport { MarkNode } from \"@lexical/mark\"\nimport { HeadingNode, QuoteNode } from \"@lexical/rich-text\"\nimport { ParagraphNode, TextNode } from \"lexical\"\n\nexport const LexicalRichEditorNodes = [\n  ParagraphNode,\n  TextNode,\n  AutoLinkNode,\n  HeadingNode,\n  QuoteNode,\n  ListNode,\n  ListItemNode,\n  CodeNode,\n  LinkNode,\n  MarkNode,\n  CodeHighlightNode,\n]\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/plugins/code-highlighting/index.tsx",
    "content": "import { registerCodeHighlighting } from \"@lexical/code\"\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport { useEffect } from \"react\"\n\nexport function CodeHighlightingPlugin() {\n  const [editor] = useLexicalComposerContext()\n\n  useEffect(() => {\n    const unregister = registerCodeHighlighting(editor)\n    return () => unregister()\n  }, [editor])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/plugins/exit-code/index.tsx",
    "content": "import { $isCodeNode } from \"@lexical/code\"\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport {\n  $createParagraphNode,\n  $createTextNode,\n  $getSelection,\n  $isRangeSelection,\n  $isTextNode,\n  COMMAND_PRIORITY_NORMAL,\n  KEY_ARROW_RIGHT_COMMAND,\n  KEY_ENTER_COMMAND,\n} from \"lexical\"\nimport { useEffect } from \"react\"\n\n/**\n * Handles exiting from inline and block code contexts:\n * - Inline code: Enter or ArrowRight at the end inserts a space after the code span and moves caret there.\n * - Block code: ArrowRight at the end inserts an empty paragraph after the code block and moves caret there.\n */\nexport function ExitCodeBoundaryPlugin() {\n  const [editor] = useLexicalComposerContext()\n\n  useEffect(() => {\n    // Enter inside inline code at end → insert a trailing space outside code\n    const unregisterEnter = editor.registerCommand(\n      KEY_ENTER_COMMAND,\n      (event) => {\n        let handled = false\n        editor.update(() => {\n          const selection = $getSelection()\n          if (!$isRangeSelection(selection) || !selection.isCollapsed()) return\n\n          const focusNode = selection.focus.getNode()\n          if ($isTextNode(focusNode) && focusNode.hasFormat(\"code\")) {\n            const atEnd = selection.focus.offset === focusNode.getTextContentSize()\n            if (atEnd) {\n              const space = $createTextNode(\" \") // outside code format\n              // Insert after current code-formatted text node\n              focusNode.insertAfter(space)\n              // Place caret after inserted space\n              space.select(1, 1)\n              handled = true\n            }\n          }\n        })\n        if (handled) {\n          if (event) {\n            event.preventDefault()\n            event.stopPropagation()\n          }\n          return true\n        }\n        return false\n      },\n      COMMAND_PRIORITY_NORMAL,\n    )\n\n    // ArrowRight inside inline code at end → same as Enter behavior\n    const unregisterArrowRight = editor.registerCommand(\n      KEY_ARROW_RIGHT_COMMAND,\n      (event) => {\n        let handled = false\n        editor.update(() => {\n          const selection = $getSelection()\n          if (!$isRangeSelection(selection) || !selection.isCollapsed()) return\n\n          const focusNode = selection.focus.getNode()\n\n          // 1) Inline code span end → insert space outside code\n          if ($isTextNode(focusNode) && focusNode.hasFormat(\"code\")) {\n            const atEnd = selection.focus.offset === focusNode.getTextContentSize()\n            if (atEnd) {\n              const space = $createTextNode(\" \")\n              focusNode.insertAfter(space)\n              space.select(1, 1)\n              handled = true\n              return\n            }\n          }\n\n          // 2) Block code (CodeNode) end → insert empty paragraph after code block\n          const codeAncestor = focusNode.getParent()\n          if (codeAncestor && $isCodeNode(codeAncestor)) {\n            // Heuristic: only exit when the caret is at the very end of the last descendant\n            const lastDescendant = codeAncestor.getLastDescendant()\n            if ($isTextNode(lastDescendant)) {\n              const isAtEnd =\n                lastDescendant.getKey() === selection.focus.getNode().getKey() &&\n                selection.focus.offset === lastDescendant.getTextContentSize()\n              if (isAtEnd) {\n                const paragraph = $createParagraphNode()\n                const text = $createTextNode(\"\")\n                paragraph.append(text)\n                // Insert after the code block and move caret into the new empty line\n                codeAncestor.insertAfter(paragraph)\n                text.select(0, 0)\n                handled = true\n              }\n            }\n          }\n        })\n        if (handled) {\n          if (event) {\n            event.preventDefault()\n            event.stopPropagation()\n          }\n          return true\n        }\n        return false\n      },\n      COMMAND_PRIORITY_NORMAL,\n    )\n\n    return () => {\n      unregisterEnter()\n      unregisterArrowRight()\n    }\n  }, [editor])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts",
    "content": "export { CodeHighlightingPlugin } from \"./code-highlighting\"\nexport { ExitCodeBoundaryPlugin } from \"./exit-code\"\nexport { KeyboardPlugin } from \"./keyboard\"\nexport { StringLengthChangePlugin } from \"./string-length-change\"\nexport { TripleBacktickTogglePlugin } from \"./triple-backtick-toggle\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from \"lexical\"\nimport { useEffect } from \"react\"\n\ninterface KeyboardPluginProps {\n  onKeyDown?: (event: KeyboardEvent) => boolean\n}\n\nexport function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {\n  const [editor] = useLexicalComposerContext()\n\n  useEffect(() => {\n    if (!onKeyDown) return\n\n    // Register a low-priority command that will only execute if no higher-priority commands handle the event\n    const removeEnterCommand = editor.registerCommand(\n      KEY_ENTER_COMMAND,\n      (event) => {\n        // This will only be called if no higher-priority commands handled the Enter key\n        if (!event) return false\n        const handled = onKeyDown(event)\n        return handled\n      },\n      COMMAND_PRIORITY_LOW,\n    )\n\n    // For other keys, use DOM event listener\n    const handleKeyDown = (event: KeyboardEvent) => {\n      // Skip Enter key as it's handled by the command system\n      if (event.key === \"Enter\") return\n\n      const handled = onKeyDown(event)\n      if (handled) {\n        event.preventDefault()\n        event.stopPropagation()\n      }\n    }\n\n    const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {\n      if (prevRootElement !== null) {\n        prevRootElement.removeEventListener(\"keydown\", handleKeyDown)\n      }\n      if (rootElement !== null) {\n        rootElement.addEventListener(\"keydown\", handleKeyDown)\n      }\n    })\n\n    return () => {\n      removeEnterCommand()\n      removeRootListener()\n    }\n  }, [editor, onKeyDown])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/plugins/string-length-change/index.tsx",
    "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport type { LexicalEditor } from \"lexical\"\nimport { $getRoot } from \"lexical\"\nimport { useEffect, useRef } from \"react\"\n\ninterface StringLengthChangePluginProps {\n  /**\n   * Callback when the plain text length changes.\n   */\n  onChange?: (length: number, editor: LexicalEditor) => void\n}\n\nexport function StringLengthChangePlugin({ onChange }: StringLengthChangePluginProps) {\n  const [editor] = useLexicalComposerContext()\n  const previousLengthRef = useRef<number | null>(null)\n\n  useEffect(() => {\n    // Fire once on mount with the initial content length\n    editor.getEditorState().read(() => {\n      const textContent = $getRoot().getTextContent()\n      const currentLength = textContent.length\n      previousLengthRef.current = currentLength\n    })\n\n    // Subscribe to updates and emit only when the length actually changes\n    const unregister = editor.registerUpdateListener(({ editorState }) => {\n      editorState.read(() => {\n        const textContent = $getRoot().getTextContent()\n        const currentLength = textContent.length\n\n        if (previousLengthRef.current !== currentLength) {\n          previousLengthRef.current = currentLength\n        }\n      })\n    })\n\n    return () => unregister()\n  }, [editor, onChange])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/plugins/triple-backtick-toggle/index.tsx",
    "content": "import { $createCodeNode, $isCodeNode } from \"@lexical/code\"\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\"\nimport type { LexicalNode } from \"lexical\"\nimport {\n  $createParagraphNode,\n  $createTextNode,\n  $getSelection,\n  $isRangeSelection,\n  COMMAND_PRIORITY_HIGH,\n  KEY_ENTER_COMMAND,\n} from \"lexical\"\nimport { useEffect } from \"react\"\n\nfunction findAncestor(\n  node: LexicalNode | null,\n  predicate: (n: LexicalNode) => boolean,\n): LexicalNode | null {\n  let current: LexicalNode | null = node\n  while (current) {\n    if (predicate(current)) return current\n    current = current.getParent()\n  }\n  return null\n}\n\nexport function TripleBacktickTogglePlugin() {\n  const [editor] = useLexicalComposerContext()\n\n  useEffect(() => {\n    const unregister = editor.registerCommand(\n      KEY_ENTER_COMMAND,\n      (event) => {\n        if (!event) return false\n\n        // Phase 1 (read): decide intent without mutating editor\n        const intent = editor.getEditorState().read(() => {\n          const selection = $getSelection()\n          if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null\n          const focusNode = selection.focus.getNode()\n          const topLevel = focusNode.getTopLevelElementOrThrow()\n\n          // Toggle ON if paragraph is exactly ``` or ```lang\n          if (topLevel.getType() === \"paragraph\") {\n            const raw = topLevel.getTextContent()\n            const text = raw.trim()\n            const match = /^```([\\w+-]+)?$/.exec(text)\n            if (match) {\n              return { type: \"toggleOn\" as const, lang: match[1] as string | undefined }\n            }\n          }\n\n          // Shift+Enter inside a code block should exit the block\n          const codeAncestor = findAncestor(focusNode, (n) => $isCodeNode(n))\n          if (event.shiftKey && codeAncestor && $isCodeNode(codeAncestor)) {\n            return { type: \"exitCode\" as const }\n          }\n          return null\n        })\n\n        if (!intent) return false\n\n        // Phase 2 (write): perform mutation according to intent\n        editor.update(() => {\n          const selection = $getSelection()\n          if (!$isRangeSelection(selection) || !selection.isCollapsed()) return\n          const focusNode = selection.focus.getNode()\n\n          if (intent.type === \"toggleOn\") {\n            const topLevel = focusNode.getTopLevelElementOrThrow()\n            const codeNode = $createCodeNode(intent.lang)\n            topLevel.replace(codeNode)\n            codeNode.selectEnd()\n          } else if (intent.type === \"exitCode\") {\n            const codeAncestor = findAncestor(focusNode, (n) => $isCodeNode(n))\n            if (codeAncestor && $isCodeNode(codeAncestor)) {\n              const paragraph = $createParagraphNode()\n              const text = $createTextNode(\"\")\n              paragraph.append(text)\n              codeAncestor.insertAfter(paragraph)\n              text.select()\n            }\n          }\n        })\n\n        event.preventDefault()\n        event.stopPropagation()\n        event.stopImmediatePropagation()\n        return true\n      },\n      COMMAND_PRIORITY_HIGH,\n    )\n\n    return () => unregister()\n  }, [editor])\n\n  return null\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/theme.ts",
    "content": "/**\n * Default Lexical theme configuration for consistent styling\n * across editable and read-only rich text components.\n *\n * Uses Follow's UIKit color system with Tailwind classes for\n * automatic light/dark mode adaptation.\n */\nexport const defaultLexicalTheme = {\n  paragraph: \"mb-1 last:mb-0\",\n  text: {\n    bold: \"font-semibold\",\n    italic: \"italic\",\n    strikethrough: \"line-through\",\n    underline: \"underline\",\n    code: \"bg-fill px-1 py-0.5 rounded text-sm font-mono\",\n  },\n  heading: {\n    h1: \"text-2xl font-bold mb-2\",\n    h2: \"text-xl font-bold mb-2\",\n    h3: \"text-lg font-bold mb-1\",\n    h4: \"text-base font-bold mb-1\",\n    h5: \"text-sm font-bold mb-1\",\n    h6: \"text-xs font-bold mb-1\",\n  },\n  list: {\n    nested: {\n      listitem: \"list-none\",\n    },\n    ol: \"list-decimal list-inside mb-2\",\n    ul: \"list-disc list-inside mb-2\",\n    listitem: \"mb-1\",\n  },\n  quote: \"border-l-4 border-accent pl-4 italic mb-2\",\n  code: \"bg-fill px-3 py-2 rounded font-mono text-sm mb-2 block overflow-x-auto\",\n  codeHighlight: {\n    atrule: \"text-purple-400\",\n    attr: \"text-blue-400\",\n    boolean: \"text-orange-400\",\n    builtin: \"text-purple-400\",\n    cdata: \"text-gray-400\",\n    char: \"text-green-400\",\n    class: \"text-blue-400\",\n    \"class-name\": \"text-blue-400\",\n    comment: \"text-gray-400\",\n    constant: \"text-orange-400\",\n    deleted: \"text-red-400\",\n    doctype: \"text-gray-400\",\n    entity: \"text-orange-400\",\n    function: \"text-yellow-400\",\n    important: \"text-red-400\",\n    inserted: \"text-green-400\",\n    keyword: \"text-purple-400\",\n    namespace: \"text-blue-400\",\n    number: \"text-orange-400\",\n    operator: \"text-pink-400\",\n    prolog: \"text-gray-400\",\n    property: \"text-blue-400\",\n    punctuation: \"text-gray-300\",\n    regex: \"text-green-400\",\n    selector: \"text-green-400\",\n    string: \"text-green-400\",\n    symbol: \"text-orange-400\",\n    tag: \"text-red-400\",\n    url: \"text-blue-400\",\n    variable: \"text-orange-400\",\n  },\n  link: \"text-accent underline hover:text-accent/80\",\n  mark: \"bg-yellow-200 px-1 py-0.5 rounded\",\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/types.ts",
    "content": "import type { InitialEditorStateType } from \"@lexical/react/LexicalComposer\"\nimport type { EditorState, Klass, LexicalEditor, LexicalNode } from \"lexical\"\n\nexport interface LexicalRichEditorRef {\n  getEditor: () => LexicalEditor\n  focus: () => void\n  clear: () => void\n  isEmpty: () => boolean\n}\n\nexport interface BuiltInPlugins {\n  history?: boolean\n  markdown?: boolean\n  list?: boolean\n  link?: boolean\n  autoFocus?: boolean\n  autoLink?: boolean\n  tabIndentation?: boolean\n}\nexport interface LexicalRichEditorProps {\n  placeholder?: string\n  className?: string\n  autoFocus?: boolean\n  namespace?: string\n  theme?: any\n  enabledPlugins?: BuiltInPlugins\n  initalEditorState?: InitialEditorStateType\n  plugins?: LexicalPluginFC[]\n  accessories?: React.ReactNode[]\n  onLengthChange?: (length: number, editor: LexicalEditor) => void\n  onChange?: (editorState: EditorState, editor: LexicalEditor) => void\n  onKeyDown?: (event: KeyboardEvent) => boolean\n}\nexport type LexicalPluginFC<T = unknown> = React.FC<T> & {\n  id: string\n  nodes?: ReadonlyArray<Klass<LexicalNode>>\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/lexical-rich-editor/utils.ts",
    "content": "import { $convertToMarkdownString, TRANSFORMERS } from \"@lexical/markdown\"\nimport type { LexicalEditor } from \"lexical\"\n\n/**\n * Convert Lexical editor state to markdown string for AI communication\n */\nexport function convertLexicalToMarkdown(editor: LexicalEditor): string {\n  let markdown = \"\"\n\n  editor.getEditorState().read(() => {\n    markdown = $convertToMarkdownString(TRANSFORMERS)\n  })\n\n  return markdown\n}\n\nexport function getEditorStateJSONString(plainText: string): string {\n  return JSON.stringify({\n    root: {\n      children: [\n        {\n          children: [\n            {\n              detail: 0,\n              format: 0,\n              mode: \"normal\",\n              style: \"\",\n              text: plainText,\n              type: \"text\",\n              version: 1,\n            },\n          ],\n          direction: \"ltr\",\n          format: \"\",\n          indent: 0,\n          type: \"paragraph\",\n          version: 1,\n        },\n      ],\n      direction: \"ltr\",\n      format: \"\",\n      indent: 0,\n      type: \"root\",\n      version: 1,\n    },\n  })\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/link/LinkWithTooltip.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from \"../tooltip/index.jsx\"\n\nexport interface LinkProps {\n  href: string\n  title: string\n  children: React.ReactNode\n  target: string\n}\n\nexport const LinkWithTooltip = (props: LinkProps) => (\n  <Tooltip delayDuration={0}>\n    <TooltipTrigger asChild>\n      <a href={props.href} title={props.title} target={props.target}>\n        {props.children}\n      </a>\n    </TooltipTrigger>\n    <TooltipPortal>\n      <TooltipContent align=\"start\" className=\"break-all\" side=\"bottom\">\n        {props.href}\n      </TooltipContent>\n    </TooltipPortal>\n  </Tooltip>\n)\n"
  },
  {
    "path": "packages/internal/components/src/ui/link/index.ts",
    "content": "export * from \"./LinkWithTooltip\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/loading/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { m, useAnimation } from \"motion/react\"\nimport * as React from \"react\"\nimport { cloneElement, useEffect } from \"react\"\n\ninterface LoadingCircleProps {\n  size: \"small\" | \"medium\" | \"large\"\n}\n\nconst sizeMap = {\n  small: 16,\n  medium: 24,\n  large: 30,\n}\nexport const LoadingCircle: Component<LoadingCircleProps> = ({ className, size }) => (\n  <div\n    className={className}\n    style={{\n      fontSize: sizeMap[size],\n    }}\n  >\n    <i className=\"i-mgc-loading-3-cute-re animate-spin\" />\n  </div>\n)\n\nconst sizeMap2 = {\n  small: 30,\n  medium: 40,\n  large: 50,\n}\n\nconst smallIconSizeMap = {\n  small: 16,\n  medium: 18,\n  large: 24,\n}\nexport const LoadingWithIcon: Component<\n  LoadingCircleProps & {\n    icon: React.JSX.Element\n    order?: \"loading-first\" | \"icon-first\"\n  }\n> = ({ order = \"loading-first\", size, className, icon, children }) => {\n  const rootStyle = { width: sizeMap2[size], height: sizeMap2[size] }\n\n  const smallIconStyle = {\n    height: smallIconSizeMap[size],\n    width: smallIconSizeMap[size],\n  }\n\n  const Children = children && <div className=\"center flex\">{children}</div>\n  if (order === \"icon-first\") {\n    return (\n      <div className={className}>\n        <div style={rootStyle} className=\"relative inline-block\">\n          <span className=\"block size-full\">\n            {cloneElement(icon, {\n              style: {\n                ...icon.props.style,\n                fontSize: sizeMap2[size],\n              },\n              className: cn(icon.props.className),\n            })}\n          </span>\n          <span\n            className=\"absolute bottom-1\"\n            style={{\n              ...smallIconStyle,\n              right: -smallIconSizeMap[size] + 4,\n            }}\n          >\n            <i\n              className=\"i-mgc-loading-3-cute-re animate-spin\"\n              style={{ fontSize: smallIconSizeMap[size] }}\n            />\n          </span>\n        </div>\n\n        {Children}\n      </div>\n    )\n  } else {\n    return (\n      <div className={className}>\n        <div style={rootStyle} className=\"relative inline-block\">\n          <span\n            className=\"block size-full\"\n            style={{\n              clipPath: `polygon(0% 0%, 0% 100%, calc(100% - ${smallIconSizeMap[size]}px) 100%, ${smallIconSizeMap[size]}px ${smallIconSizeMap[size]}px, 100% calc(100% - ${smallIconSizeMap[size]}px), 100% 100%, 100% 100%, 100% 0%)`,\n            }}\n          >\n            <i\n              className=\"i-mgc-loading-3-cute-li animate-spin\"\n              style={{ fontSize: sizeMap2[size] }}\n            />\n          </span>\n          <span\n            className={cn(\"absolute bottom-0 right-0\", \"animate-pulse duration-700\")}\n            style={smallIconStyle}\n          >\n            {cloneElement(icon, {\n              style: {\n                ...icon.props.style,\n                fontSize: smallIconSizeMap[size],\n                width: smallIconSizeMap[size],\n                height: smallIconSizeMap[size],\n              },\n              className: icon.props.className,\n            })}\n          </span>\n        </div>\n        {Children}\n      </div>\n    )\n  }\n}\n\nexport const RotatingRefreshIcon: React.FC<{\n  isRefreshing: boolean\n  className?: string\n}> = ({ isRefreshing, className }) => {\n  const controls = useAnimation()\n\n  useEffect(() => {\n    if (isRefreshing) {\n      controls.set({ transform: `rotate(0deg)` })\n      controls.start({\n        transform: `rotate(360deg)`,\n        transition: { duration: 1, repeat: Infinity, ease: \"linear\" },\n      })\n    } else {\n      controls.start({\n        transform: `rotate(0deg)`,\n        transition: { type: \"spring\" },\n      })\n    }\n  }, [isRefreshing, controls])\n\n  return <m.i className={cn(\"i-mgc-refresh-2-cute-re\", className)} animate={controls} />\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/markdown/html.tsx",
    "content": "import { parseHtml } from \"@follow/utils/html\"\nimport type { Components } from \"hast-util-to-jsx-runtime\"\nimport { useInsertionEffect } from \"react\"\n\nexport type ParseHtmlOptions = {\n  renderInlineStyle?: boolean\n  noMedia?: boolean\n  components?: Components\n  scrollEnabled?: boolean\n}\n\nexport type HtmlProps = { content: string } & ParseHtmlOptions\n\nexport function Html({ content, scrollEnabled = true, ...options }: HtmlProps) {\n  const res = parseHtml(content, options)\n\n  useInsertionEffect(() => {\n    const originalOverflow = document.body.style.overflow\n\n    if (!scrollEnabled) {\n      document.body.style.overflow = \"hidden\"\n      document.documentElement.style.overflow = \"hidden\"\n    }\n    return () => {\n      document.body.style.overflow = originalOverflow\n      document.documentElement.style.overflow = originalOverflow\n    }\n  }, [scrollEnabled])\n\n  return (\n    <article className=\"prose !max-w-full px-2 dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold\">\n      {res.toContent()}\n    </article>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/marquee/index.tsx",
    "content": "import type { PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { useCallback, useRef, useState } from \"react\"\nimport type { MarqueeProps } from \"react-fast-marquee\"\nimport Marquee from \"react-fast-marquee\"\n\nexport const TitleMarquee = ({\n  children,\n  speed = 30,\n  play,\n  className,\n  ...rest\n}: PropsWithChildren &\n  MarqueeProps & {\n    className?: string\n  }) => {\n  const [hovered, setHovered] = useState(false)\n  const [pause, setPause] = useState(false)\n  const ref = useRef<HTMLDivElement>(null)\n\n  const $wrapper = useRef<HTMLDivElement>(null)\n  return (\n    <div\n      className={className}\n      ref={$wrapper}\n      // className=\"[&_*]:scrollbar-none\"\n      onMouseEnter={useCallback(() => {\n        const $container = ref.current\n\n        if (!$container) return\n\n        const $child = $container.querySelector(\".rfm-child\")\n        if (!$child) return\n\n        const canScroll = $child.clientWidth > $container.clientWidth\n\n        if (!canScroll) return\n\n        setHovered(true)\n      }, [])}\n      onMouseLeave={useCallback(() => {\n        setHovered(false)\n\n        const $container = ref.current\n        if (!$container) return\n        const marqueeContainer = $container\n        const marqueeChildren = Array.from(marqueeContainer.children) as HTMLElement[]\n\n        // Force a reflow on marquee child nodes to reset animation\n        marqueeChildren.forEach((marquee: HTMLElement) => {\n          marquee.style.animation = \"none\"\n          void marquee.offsetHeight\n          marquee.style.animation = \"\"\n        })\n      }, [])}\n    >\n      <Marquee\n        className=\"overflow-hidden\"\n        play={hovered && !pause}\n        ref={ref}\n        speed={speed}\n        gradient={false}\n        {...rest}\n        onCycleComplete={useCallback(() => {\n          setPause(true)\n          setTimeout(() => setPause(false), 1000)\n        }, [])}\n      >\n        {children}\n        {\"\\u00A0\\u00A0\\u00A0\\u00A0\"}\n      </Marquee>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/masonry/contexts.tsx",
    "content": "import { noop } from \"foxact/noop\"\nimport type { Dispatch, SetStateAction } from \"react\"\nimport { createContext, use, useCallback } from \"react\"\nimport { createContext as createContextSelector, useContextSelector } from \"use-context-selector\"\n\nexport const MasonryItemWidthContext = createContext(0)\n\nexport const MasonryForceRerenderContext = createContext(0)\n\nexport const useMasonryForceRerender = () => use(MasonryForceRerenderContext)\n\nexport const useMasonryItemWidth = () => use(MasonryItemWidthContext)\n\nexport const MasonryItemsAspectRatioContext = createContextSelector({} as Record<string, number>)\n\nexport const MasonryIntersectionContext = createContext<IntersectionObserver>(null!)\n\nexport const useMasonryItemRatio = (url: string) =>\n  useContextSelector(MasonryItemsAspectRatioContext, (ctx) => ctx[url])\n\nexport const MasonryItemsAspectRatioSetterContext =\n  createContext<Dispatch<SetStateAction<Record<string, number>>>>(noop)\n\nexport const useSetStableMasonryItemRatio = () => {\n  const ctx = use(MasonryItemsAspectRatioSetterContext)\n  return useCallback(\n    (url: string, ratio: number) => {\n      ctx((prev: Record<string, number>) => {\n        // Skip if the ratio is already set, make it stable\n        if (prev[url]) return prev\n\n        return { ...prev, [url]: ratio }\n      })\n    },\n    [ctx],\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/masonry/hooks.ts",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport { throttle } from \"es-toolkit/compat\"\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\"\n\nimport { getCurrentColumn } from \"./utils\"\n\nconst calItemWidth = (clientWidth: number, gutter: number, column: number) =>\n  Math.trunc(clientWidth - gutter * (column - 1)) / column\nexport const useMasonryColumn = (gutter: number, onReady?: (column: number) => any) => {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const [currentColumn, setCurrentColumn] = useState(1)\n  const [currentItemWidth, setCurrentItemWidth] = useState(0)\n\n  useLayoutEffect(() => {\n    let readyCallOnce = false\n    const $warpper = containerRef.current\n    if (!$warpper) return\n\n    const handler = () => {\n      // Skip if element doesn't have proper dimensions yet\n      if ($warpper.clientWidth === 0) return\n\n      const column = getCurrentColumn($warpper.clientWidth)\n\n      setCurrentItemWidth(calItemWidth($warpper.clientWidth, gutter, column))\n\n      setCurrentColumn(column)\n\n      nextFrame(() => {\n        if (readyCallOnce) return\n        readyCallOnce = true\n        onReady?.(column)\n      })\n    }\n    const recal = throttle(handler, 1000 / 12)\n\n    let previousWidth = $warpper.offsetWidth\n    const resizeObserver = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const newWidth = entry.contentRect.width\n\n        if (newWidth !== previousWidth && newWidth > 0) {\n          previousWidth = newWidth\n\n          recal()\n        }\n      }\n    })\n\n    // Use nextFrame to ensure DOM is ready before initial calculation\n    nextFrame(() => {\n      recal()\n    })\n\n    resizeObserver.observe($warpper)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  return {\n    containerRef,\n    currentColumn,\n    currentItemWidth,\n    calcItemWidth: useCallback(\n      (column: number) => {\n        const $warpper = containerRef.current\n        if (!$warpper) return 0\n        return calItemWidth($warpper.clientWidth, gutter, column)\n      },\n      [gutter],\n    ),\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/masonry/index.tsx",
    "content": "// @copy internal masonic hooks\nimport { clearRequestTimeout, requestTimeout } from \"@essentials/request-timeout\"\nimport { useWindowSize } from \"@react-hook/window-size\"\nimport { isEqual, throttle } from \"es-toolkit/compat\"\nimport type { ContainerPosition, MasonryProps, MasonryScrollerProps, Positioner } from \"masonic\"\nimport { createResizeObserver, useMasonry, usePositioner, useScrollToIndex } from \"masonic\"\nimport { useForceUpdate } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { useScrollViewElement } from \"../scroll-area/hooks.js\"\nimport { useMasonryForceRerender } from \"./contexts.jsx\"\n/**\n * A \"batteries included\" masonry grid which includes all of the implementation details below. This component is the\n * easiest way to get off and running in your app, before switching to more advanced implementations, if necessary.\n * It will change its column count to fit its container's width and will decide how many rows to render based upon\n * the height of the browser `window`.\n *\n * @param props\n */\nexport const Masonry = <Item,>(props: MasonryProps<Item>) => {\n  const [scrollTop, setScrollTop] = React.useState(0)\n  const [isScrolling, setIsScrolling] = React.useState(false)\n  const scrollElement = useScrollViewElement()\n  const forceRerender = useMasonryForceRerender()\n\n  const fps = props.scrollFps || 12\n  React.useEffect(() => {\n    if (!scrollElement) return\n\n    const scrollTimer: number | null = null\n    const handleScroll = throttle(() => {\n      setIsScrolling(true)\n      setScrollTop(scrollElement.scrollTop)\n    }, 1000 / fps)\n\n    scrollElement.addEventListener(\"scroll\", handleScroll)\n\n    return () => {\n      scrollElement.removeEventListener(\"scroll\", handleScroll)\n      if (scrollTimer) {\n        clearTimeout(scrollTimer)\n      }\n    }\n  }, [fps, scrollElement])\n  const didMount = React.useRef(0)\n  React.useEffect(() => {\n    if (didMount.current === 1) setIsScrolling(true)\n    let didUnsubscribe = false\n    const to = requestTimeout(\n      () => {\n        if (didUnsubscribe) return\n        // This is here to prevent premature bail outs while maintaining high resolution\n        // unsets. Without it there will always bee a lot of unnecessary DOM writes to style.\n        setIsScrolling(false)\n      },\n      40 + 1000 / fps,\n    )\n    didMount.current = 1\n    return () => {\n      didUnsubscribe = true\n      clearRequestTimeout(to)\n    }\n  }, [fps, scrollTop])\n\n  const containerRef = React.useRef<null | HTMLElement>(null)\n  const windowSize = useWindowSize({\n    initialWidth: props.ssrWidth,\n    initialHeight: props.ssrHeight,\n  })\n  const containerPos = useContainerPosition(containerRef, windowSize)\n\n  const nextProps = Object.assign(\n    {\n      offset: containerPos.offset,\n      width: containerPos.width || windowSize[0],\n      height: containerPos.height || windowSize[1],\n      containerRef,\n    },\n    props,\n  ) as any\n\n  // Workaround for https://github.com/jaredLunde/masonic/issues/12\n  const itemCounter = React.useRef<number>(props.items.length)\n\n  let shrunk = false\n\n  if (props.items.length !== itemCounter.current) {\n    if (props.items.length < itemCounter.current) shrunk = true\n\n    itemCounter.current = props.items.length\n  }\n\n  nextProps.positioner = usePositioner(nextProps, [shrunk && Math.random(), forceRerender])\n\n  nextProps.resizeObserver = useResizeObserver(nextProps.positioner)\n  nextProps.scrollTop = scrollTop\n  nextProps.isScrolling = isScrolling\n  nextProps.height = window.innerHeight\n\n  const scrollToIndex = useScrollToIndex(nextProps.positioner, {\n    height: nextProps.height,\n    offset: containerPos.offset,\n    align: typeof props.scrollToIndex === \"object\" ? props.scrollToIndex.align : void 0,\n  })\n  const index =\n    props.scrollToIndex &&\n    (typeof props.scrollToIndex === \"number\" ? props.scrollToIndex : props.scrollToIndex.index)\n\n  React.useEffect(() => {\n    if (index !== void 0) scrollToIndex(index)\n  }, [index, scrollToIndex])\n\n  return <MasonryScroller {...nextProps} />\n}\n\nfunction MasonryScroller<Item>(\n  props: MasonryScrollerProps<Item> & {\n    scrollTop: number\n    isScrolling: boolean\n  },\n) {\n  // We put this in its own layer because it's the thing that will trigger the most updates\n  // and we don't want to slower ourselves by cycling through all the functions, objects, and effects\n  // of other hooks\n  // const { scrollTop, isScrolling } = useScroller(props.offset, props.scrollFps)\n  // This is an update-heavy phase and while we could just Object.assign here,\n  // it is way faster to inline and there's a relatively low hit to he bundle\n  // size.\n\n  return useMasonry<Item>({\n    scrollTop: props.scrollTop,\n    isScrolling: props.isScrolling,\n    positioner: props.positioner,\n    resizeObserver: props.resizeObserver,\n    items: props.items,\n    onRender: props.onRender,\n    as: props.as,\n    id: props.id,\n    className: props.className,\n    style: props.style,\n    role: props.role,\n    tabIndex: props.tabIndex,\n    containerRef: props.containerRef,\n    itemAs: props.itemAs,\n    itemStyle: props.itemStyle,\n    itemHeightEstimate: props.itemHeightEstimate,\n    itemKey: props.itemKey,\n    overscanBy: props.overscanBy,\n    height: props.height,\n    render: props.render,\n  })\n}\n\nfunction useContainerPosition(\n  elementRef: React.MutableRefObject<HTMLElement | null>,\n  deps: React.DependencyList = [],\n): ContainerPosition & {\n  height: number\n} {\n  const [containerPosition, setContainerPosition] = React.useState<\n    ContainerPosition & {\n      height: number\n    }\n  >({\n    offset: 0,\n    width: 0,\n    height: 0,\n  })\n\n  React.useLayoutEffect(() => {\n    const { current } = elementRef\n    if (current !== null) {\n      let offset = 0\n      let el = current\n\n      do {\n        offset += el.offsetTop || 0\n        el = el.offsetParent as HTMLElement\n      } while (el)\n\n      if (offset !== containerPosition.offset || current.offsetWidth !== containerPosition.width) {\n        setContainerPosition({\n          offset,\n          width: current.offsetWidth,\n          height: current.offsetHeight,\n        })\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, deps)\n\n  React.useEffect(() => {\n    const resizeObserver = new ResizeObserver(() => {\n      setContainerPosition((prev) => {\n        const next = {\n          ...prev,\n          width: elementRef.current?.offsetWidth || 0,\n        }\n        if (isEqual(next, prev)) return prev\n        return next\n      })\n    })\n    resizeObserver.observe(elementRef.current as HTMLElement)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [containerPosition, elementRef])\n\n  return containerPosition\n}\n\nfunction useResizeObserver(positioner: Positioner) {\n  const [forceUpdate] = useForceUpdate()\n  const resizeObserver = createResizeObserver(positioner, throttle(forceUpdate, 1000 / 12))\n  // Cleans up the resize observers when they change or the\n  // component unmounts\n  React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver])\n  return resizeObserver\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/masonry/utils.ts",
    "content": "const breakpoints = {\n  0: 2,\n  // 32rem => 32 * 16= 512\n  512: 3,\n  // 48rem => 48 * 16= 768\n  768: 4,\n  // 72rem => 72 * 16= 1152\n  1024: 5,\n  // 80rem => 80 * 16= 1280\n  1280: 6,\n  1536: 7,\n  1792: 8,\n  2048: 9,\n}\n\nexport const getCurrentColumn = (w: number) => {\n  // Initialize column count with the minimum number of columns\n  let columns = 1\n\n  // Iterate through each breakpoint and determine the column count\n  for (const [breakpoint, cols] of Object.entries(breakpoints)) {\n    if (w >= Number.parseInt(breakpoint)) {\n      columns = cols\n    } else {\n      break\n    }\n  }\n\n  return columns\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/navigation-menu/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as NavigationMenuPrimitive from \"@radix-ui/react-navigation-menu\"\nimport * as React from \"react\"\n\nimport { navigationMenuTriggerStyle } from \"./style\"\n\nconst NavigationMenu = ({\n  ref,\n  className,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> & {\n  ref?: React.Ref<React.ElementRef<typeof NavigationMenuPrimitive.Root> | null>\n}) => (\n  <NavigationMenuPrimitive.Root\n    ref={ref}\n    className={cn(\"relative z-10 flex max-w-max flex-1 items-center justify-center\", className)}\n    {...props}\n  >\n    {children}\n    <NavigationMenuViewport />\n  </NavigationMenuPrimitive.Root>\n)\nNavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName\n\nconst NavigationMenuList = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> & {\n  ref?: React.Ref<React.ElementRef<typeof NavigationMenuPrimitive.List> | null>\n}) => (\n  <NavigationMenuPrimitive.List\n    ref={ref}\n    className={cn(\"group flex flex-1 list-none items-center justify-center space-x-1\", className)}\n    {...props}\n  />\n)\nNavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName\n\nconst NavigationMenuItem = NavigationMenuPrimitive.Item\n\nconst NavigationMenuTrigger = ({\n  ref,\n  className,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> & {\n  ref?: React.Ref<React.ElementRef<typeof NavigationMenuPrimitive.Trigger> | null>\n}) => (\n  <NavigationMenuPrimitive.Trigger\n    ref={ref}\n    className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n    {...props}\n  >\n    {children}{\" \"}\n    <i className=\"i-mingcute-down-line ml-1 size-3 opacity-50 duration-200 group-data-[state=open]:rotate-180\" />\n  </NavigationMenuPrimitive.Trigger>\n)\nNavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName\n\nconst NavigationMenuContent = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof NavigationMenuPrimitive.Content> | null>\n}) => (\n  <NavigationMenuPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto\",\n      className,\n    )}\n    {...props}\n  />\n)\nNavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName\n\nconst NavigationMenuLink = NavigationMenuPrimitive.Link\n\nconst NavigationMenuViewport = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> & {\n  ref?: React.Ref<React.ElementRef<typeof NavigationMenuPrimitive.Viewport> | null>\n}) => (\n  <div className={cn(\"absolute left-0 top-full flex justify-center\")}>\n    <NavigationMenuPrimitive.Viewport\n      className={cn(\n        \"relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-material-medium text-text shadow-lg backdrop-blur-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]\",\n        className,\n      )}\n      ref={ref}\n      {...props}\n    />\n  </div>\n)\nNavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName\n\nconst NavigationMenuIndicator = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> & {\n  ref?: React.Ref<React.ElementRef<typeof NavigationMenuPrimitive.Indicator> | null>\n}) => (\n  <NavigationMenuPrimitive.Indicator\n    ref={ref}\n    className={cn(\n      \"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in\",\n      className,\n    )}\n    {...props}\n  >\n    <div className=\"relative top-[60%] size-2 rotate-45 rounded-tl-sm bg-border shadow-md\" />\n  </NavigationMenuPrimitive.Indicator>\n)\nNavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName\n\nexport {\n  NavigationMenu,\n  NavigationMenuContent,\n  NavigationMenuIndicator,\n  NavigationMenuItem,\n  NavigationMenuLink,\n  NavigationMenuList,\n  NavigationMenuTrigger,\n  NavigationMenuViewport,\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/navigation-menu/style.ts",
    "content": "import { cva } from \"class-variance-authority\"\n\nexport const navigationMenuTriggerStyle = cva(\n  \"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50\",\n)\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/cubox.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsCubox({ className, ...props }: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M10.782.26c-5.083 0-7.625 0-9.203 1.579C0 3.417 0 5.959 0 11.04v2.439c0 5.083 0 7.624 1.58 9.203.488.488 1.069.825 1.788 1.058a14 14 0 0 1-.171-2.306c0-6.494 2.95-11.312 8.66-11.312s10.338 5.262 10.338 11.756c-.006.375-.027.724-.056 1.06q.146-.12.283-.256c1.579-1.58 1.578-4.12 1.578-9.203v-2.438c0-5.082.002-7.623-1.578-9.202-1.579-1.58-4.12-1.58-9.203-1.58ZM7.437 15.358a1.835 2.064 0 0 0-1.834 2.063 1.835 2.064 0 0 0 1.834 2.063 1.835 2.064 0 0 0 1.836-2.063 1.835 2.064 0 0 0-1.836-2.063m4.394 0a1.897 2.064 0 0 0-1.898 2.063 1.897 2.064 0 0 0 1.898 2.063 1.897 2.064 0 0 0 1.896-2.063 1.897 2.064 0 0 0-1.896-2.063m-4.845 1.068a.84.985 0 0 1 .841.986.84.985 0 0 1-.84.984.84.985 0 0 1-.84-.985.84.985 0 0 1 .84-.985m4.33 0a.84.985 0 0 1 .839.986.84.985 0 0 1-.839.984.84.985 0 0 1-.84-.985.84.985 0 0 1 .84-.985\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/eagle.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsEagle(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <title>Eagle</title>\n      <path\n        fill=\"currentColor\"\n        d=\"M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm.04 3.858c1.32.019 2.634.335 3.78.989.549.31.957.642 1.238.895a6.912 6.912 0 0 0-2.25 3.04c-.06.165-.123.354-.183.546a6.856 6.856 0 0 0-.252 1.605c-.003.162.002.374.003.578.012.242.05.519.08.789a7.013 7.013 0 0 0 1.753 3.586 6.889 6.889 0 0 0 1.87 1.42 7.792 7.792 0 0 1-2.629 2.166 7.717 7.717 0 0 1-3.846.808 9.16 9.16 0 0 1-.22-.013 7.695 7.695 0 0 1-1.504-.247 8.201 8.201 0 0 1-2.83-1.354 7.056 7.056 0 0 1-1.894-2.1c-.22-.38-1.49-2.644-.769-5.452A7.261 7.261 0 0 1 5.93 8.18a5.513 5.513 0 0 0-2.105 1.082C4.12 8.573 5.306 6 8.217 4.66a8.944 8.944 0 0 1 3.823-.8zm5.702 2.508c.202.126.464.309.736.572.108.103.478.468.82 1.054.413.703.549 1.327.62 1.65a5.52 5.52 0 0 1 .013 2.302 7.133 7.133 0 0 0-2.044-1.688 7.243 7.243 0 0 0-1.551.3 6.834 6.834 0 0 0-1.05.422 6.058 6.058 0 0 1 .267-1.563 5.923 5.923 0 0 1 .806-1.643 6.255 6.255 0 0 1 1.383-1.406Z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/instapaper.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsInstapaper(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <title>Instapaper</title>\n      <path\n        fill=\"currentColor\"\n        d=\"M14.766 20.259c0 1.819.271 2.089 2.934 2.292V24H6.301v-1.449c2.666-.203 2.934-.473 2.934-2.292V3.708c0-1.784-.27-2.089-2.934-2.292V0h11.398v1.416c-2.662.203-2.934.506-2.934 2.292v16.551z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/obsidian.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsObsidian(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <title>Obsidian</title>\n      <path\n        fill=\"currentColor\"\n        d=\"M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/outline.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsOutline(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <title>Outline</title>\n      <path\n        fill=\"currentColor\"\n        d=\"M 15.081 21.857 L 15.081 22.459 C 15.081 23.636 13.812 24.378 12.785 23.8 L 3.543 18.602 C 3.058 18.329 2.758 17.816 2.758 17.26 L 2.758 6.742 C 2.758 6.185 3.058 5.672 3.543 5.399 L 12.785 0.201 C 13.812 -0.378 15.082 0.365 15.081 1.544 L 15.081 2.145 L 16.178 1.814 C 17.167 1.517 18.163 2.258 18.162 3.29 L 18.162 3.915 L 19.511 3.746 C 20.431 3.632 21.243 4.348 21.242 5.275 L 21.242 18.726 C 21.243 19.652 20.431 20.37 19.511 20.254 L 18.162 20.085 L 18.162 20.71 C 18.163 21.743 17.167 22.484 16.178 22.186 L 15.081 21.857 Z M 15.081 20.249 L 16.621 20.71 L 16.621 3.29 L 15.081 3.753 L 15.081 20.249 Z M 18.162 5.467 L 18.162 18.534 L 19.702 18.726 L 19.702 5.275 L 18.162 5.467 Z M 2.758 16.801 L 2.758 7.2 L 2.758 16.801 Z M 4.298 6.742 L 4.298 17.26 L 13.54 22.459 L 13.54 1.544 L 4.298 6.742 Z M 5.838 7.765 L 7.379 6.995 L 7.379 17.005 L 5.838 16.235 L 5.838 7.765 Z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/readeck.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsReadeck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M8.51 0 1.906 3.644v19.569h6.606v-4.245L19.304 24l-.647-3.946 3.438-2.04-5.933-2.767 5.315-2.478V5.481Zm0 6.724 5.152 2.402-5.151 2.401V7.146Z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/readwise.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsReadwise(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"currentColor\"\n        d=\"M15.518 2.571 8.286 10.56c1.769-1.47 7.12-1.494 7.12-1.494a.53.53 0 0 0 .42-.544s-.875-4.91-.308-5.95m5.311 17.896c.867 1.442 1.573 1.92 3.018 2.092V24H16.72l-6.677-11.144H8.375v7.304c0 1.956.484 2.226 2.539 2.399V24H.153v-1.441c2.054-.173 2.538-.447 2.538-2.4V3.84c0-1.987-.42-2.226-2.538-2.399V0h11.074c6.677 0 10.165 1.405 10.165 6.342 0 3.736-1.817 5.52-5.475 6.135z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/rss3.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function TokenBrandedRss3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fill=\"#0b70ff\"\n        d=\"M7 10.661c-.005.885-.66 1.6-1.565 1.59l1.47 2.25H7.5v.5h-2v-.5h.65l-1.39-2.25H3.5v2.25H4v.5H2.5v-.5H3v-5h-.5V9h2.816c.95 0 1.675.695 1.685 1.66M5.28 9.501H3.5v2.25h1.9a1.14 1.14 0 0 0 1.1-1.085c.024-.616-.6-1.165-1.22-1.165m5.17 2.085c.615.155 1.43.395 1.8.91q.153.216.2.48c.05.164.06.34.04.515q-.048.3-.2.554a1.7 1.7 0 0 1-.37.44 2.32 2.32 0 0 1-1.55.517h-.04a3.1 3.1 0 0 1-1.83-.676V15H8v-1.999h.5v.49c.405.65 1.32 1.01 2.03 1.01.66-.01 1.61-.565 1.47-1.33-.14-.64-1.124-.935-1.67-1.065l-.29-.08-.055-.015c-.81-.21-1.765-.456-1.735-1.5-.02-1.056.936-1.51 1.836-1.51.6.015.934.205 1.414.555V9h.5v2h-.5v-.65c-.43-.6-.836-.84-1.415-.85-.56 0-1.284.356-1.335 1.011.04.624.67.79 1.224.93.176.05.341.09.476.144m10.15.2c.564.195.9.71.9 1.463 0 1.077-.936 1.752-2.055 1.752a2.72 2.72 0 0 1-1.944-.835l.374-.475.035.04a2.16 2.16 0 0 0 1.54.77c.82 0 1.52-.535 1.555-1.29 0-.676-.62-1.21-1.37-1.21H19v-.5h.595c.6 0 1.155-.35 1.155-.95 0-.636-.56-1.05-1.345-1.05-.636 0-.97.336-1.195.65l-.45-.276a1.81 1.81 0 0 1 1.65-.875c1.08 0 1.84.466 1.84 1.5 0 .475-.16 1.001-.65 1.28zm-3.35.71c-.37-.516-1.186-.75-1.8-.91a4 4 0 0 0-.475-.145c-.55-.14-1.185-.306-1.224-.93.049-.65.774-1.01 1.334-1.01.58.01.984.25 1.415.85V11h.5V9h-.5v.556c-.48-.35-.816-.54-1.416-.556-.9 0-1.848.45-1.834 1.511-.03 1.044.924 1.29 1.736 1.5l.054.015.29.08c.55.13 1.53.425 1.67 1.064.14.766-.81 1.32-1.47 1.331-.71 0-1.624-.365-2.03-1.01V13H13v2h.5v-.675c.52.416 1.164.654 1.83.674h.04c.325 0 1.02-.04 1.55-.514q.228-.186.37-.44c.1-.17.17-.36.2-.555a1.3 1.3 0 0 0-.04-.516 1.2 1.2 0 0 0-.2-.48M0 11.5h1.5v1H0Zm22.5 0H24v1h-1.5z\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/rsshub.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function RSSHubLogo(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <rect width=\"24\" height=\"24\" fill=\"#ffd6a6\" rx=\"7.636\" />\n      <path\n        fill=\"#ff8549\"\n        d=\"M.142 6.16Q.002 6.875 0 7.635v8.728A7.636 7.636 0 0 0 7.636 24h8.728a7.7 7.7 0 0 0 1.477-.142 13.26 13.26 0 0 0 1.25-5.64c0-7.35-5.959-13.309-13.31-13.309a13.25 13.25 0 0 0-5.64 1.25\"\n      />\n      <circle cx=\"5.727\" cy=\"18.273\" r=\"4.964\" fill=\"#ff2900\" />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/collections/zotero.tsx",
    "content": "import type { SVGProps } from \"react\"\n\nexport function SimpleIconsZotero(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        d=\"M21.231 2.462 7.18 20.923h14.564V24H2.256v-2.462L16.308 3.076H2.975V0h18.256v2.462z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/icons.ts",
    "content": "export * from \"./collections/cubox\"\nexport * from \"./collections/eagle\"\nexport * from \"./collections/instapaper\"\nexport * from \"./collections/obsidian\"\nexport * from \"./collections/outline\"\nexport * from \"./collections/readeck\"\nexport * from \"./collections/readwise\"\nexport * from \"./collections/rss3\"\nexport * from \"./collections/rsshub\"\nexport * from \"./collections/zotero\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/index.tsx",
    "content": "import * as LinkParsers from \"@follow/utils/link-parser\"\nimport { cn } from \"@follow/utils/utils\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport type { FC } from \"react\"\nimport * as React from \"react\"\n\nimport { getSupportedPlatformIconName } from \"./utils\"\n\nconst IconMap = Object.values(LinkParsers).reduce(\n  (acc, parser) => {\n    acc[parser.parserName] = parser.icon\n    return acc\n  },\n  {} as Record<string, string>,\n)\n\nconst shouldAddWhiteBgUrls = [\"1x.com\"]\nexport const PlatformIcon: FC<{\n  url?: string\n  children: React.JSX.Element\n  className?: string\n  style?: React.CSSProperties\n}> = ({ className, url, children, style, ...rest }) => {\n  const iconName = url && getSupportedPlatformIconName(url)\n\n  if (!iconName || !IconMap[iconName] || !url) {\n    return (\n      <Slot\n        className={`${url && shouldAddWhiteBgUrls.some((_) => url.includes(_)) ? \"bg-white\" : \"\"} ${className}`}\n        style={style}\n        {...rest}\n      >\n        {children}\n      </Slot>\n    )\n  }\n\n  return (\n    <i\n      className={cn(`${IconMap[iconName]} ${className}`)}\n      style={{\n        ...style,\n        maskImage: \"var(--svg)\",\n        display: \"block\",\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/platform-icon/utils.tsx",
    "content": "import * as validator from \"@follow/utils/link-parser\"\n\nexport const getSupportedPlatformIconName = (url: string) => {\n  const safeUrl = parseSafeUrl(url)\n\n  if (!safeUrl) {\n    return false\n  }\n\n  return (\n    Object.values(validator).find((parser) => {\n      const res = parser(safeUrl)\n\n      if (res.validate) {\n        return true\n      }\n      return false\n    })?.parserName || false\n  )\n}\n\nexport const parseSafeUrl = (url: string) => {\n  try {\n    return new URL(url)\n  } catch {\n    return null\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/popover/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\nimport * as React from \"react\"\n\nimport { RootPortal } from \"../portal\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverClose = PopoverPrimitive.Close\n\nconst PopoverArrow = PopoverPrimitive.Arrow\n\nconst PopoverContent = ({\n  ref,\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof PopoverPrimitive.Content> | null>\n}) => (\n  <RootPortal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-[60] overflow-hidden rounded-[6px] border bg-background/90 p-4 text-text backdrop-blur-background\",\n        \"shadow-context-menu\",\n        \"text-body motion-scale-in-75 motion-duration-150 lg:animate-none\",\n        \"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]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    />\n  </RootPortal>\n)\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverArrow, PopoverClose, PopoverContent, PopoverTrigger }\n"
  },
  {
    "path": "packages/internal/components/src/ui/portal/index.tsx",
    "content": "import type { FC, PropsWithChildren } from \"react\"\nimport { createPortal } from \"react-dom\"\n\nimport { useRootPortal } from \"./provider\"\n\nexport const RootPortal: FC<\n  {\n    to?: HTMLElement | null\n  } & PropsWithChildren\n> = (props) => {\n  const to = useRootPortal()\n\n  if (props.to === null) return props.children\n\n  return createPortal(props.children, props.to || to || document.body)\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/portal/provider.tsx",
    "content": "import { createContext, use } from \"react\"\n\nexport const useRootPortal = () => {\n  const ctx = use(RootPortalContext)\n\n  return ctx || document.body\n}\n\nexport const RootPortalContext = createContext<HTMLElement | undefined>(undefined)\n"
  },
  {
    "path": "packages/internal/components/src/ui/progress/index.tsx",
    "content": "\"use client\"\n\nimport { cn } from \"@follow/utils/utils\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\nimport * as React from \"react\"\n\nconst Progress = ({\n  ref,\n  className,\n  value,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {\n  ref?: React.RefObject<React.ElementRef<typeof ProgressPrimitive.Root> | null>\n}) => (\n  <ProgressPrimitive.Root\n    ref={ref}\n    className={cn(\"relative h-2 w-full overflow-hidden rounded-full bg-accent/20\", className)}\n    {...props}\n  >\n    <ProgressPrimitive.Indicator\n      className=\"size-full flex-1 bg-accent transition-all\"\n      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n    />\n  </ProgressPrimitive.Root>\n)\nProgress.displayName = ProgressPrimitive.Root.displayName\n\nexport { Progress }\n"
  },
  {
    "path": "packages/internal/components/src/ui/progressive-blur/index.tsx",
    "content": "import * as React from \"react\"\n\ninterface LinearBlurProps extends React.HTMLAttributes<HTMLDivElement> {\n  strength?: number\n  steps?: number\n  falloffPercentage?: number\n  tint?: string\n  side?: \"left\" | \"right\" | \"top\" | \"bottom\"\n}\n\nconst oppositeSide = {\n  left: \"right\",\n  right: \"left\",\n  top: \"bottom\",\n  bottom: \"top\",\n}\n\nexport function LinearBlur({\n  strength = 64,\n  steps = 8,\n  falloffPercentage = 100,\n  tint = \"transparent\",\n  side = \"top\",\n  ...props\n}: LinearBlurProps) {\n  const actualSteps = Math.max(1, steps)\n  const step = falloffPercentage / actualSteps\n\n  const factor = 0.5\n\n  const base = Math.pow(strength / factor, 1 / (actualSteps - 1))\n\n  const mainPercentage = 100 - falloffPercentage\n\n  const getBackdropFilter = (i: number) => `blur(${factor * base ** (actualSteps - i - 1)}px)`\n\n  return (\n    <div\n      {...props}\n      style={{\n        // This has to be set on the top level element to prevent pointer events\n        pointerEvents: \"none\",\n        transformOrigin: side,\n        ...props.style,\n      }}\n    >\n      <div className=\"absolute z-0 size-full\">\n        {/* Full blur at 100-falloffPercentage% */}\n        {actualSteps > 1 && (\n          <div\n            className=\"absolute inset-0 z-[2]\"\n            style={{\n              mask: `linear-gradient(to ${oppositeSide[side]}, rgba(0, 0, 0, 1) ${mainPercentage}%, rgba(0, 0, 0, 1) ${mainPercentage + step}%, rgba(0, 0, 0, 0) ${mainPercentage + step * 2}%)`,\n              backdropFilter: getBackdropFilter(1),\n              WebkitBackdropFilter: getBackdropFilter(1),\n            }}\n          />\n        )}\n        {actualSteps > 2 &&\n          Array.from({ length: actualSteps - 2 }).map((_, i) => (\n            <div\n              key={i}\n              className=\"absolute inset-0\"\n              style={{\n                zIndex: i + 2,\n\n                mask: `linear-gradient(to ${oppositeSide[side]},rgba(0, 0, 0, 0) ${mainPercentage + i * step}%,rgba(0, 0, 0, 1) ${mainPercentage + (i + 1) * step}%,rgba(0, 0, 0, 1) ${mainPercentage + (i + 2) * step}%,rgba(0, 0, 0, 0) ${mainPercentage + (i + 3) * step}%)`,\n                backdropFilter: getBackdropFilter(i + 2),\n                WebkitBackdropFilter: getBackdropFilter(i + 2),\n              }}\n            />\n          ))}\n        <div\n          className=\"absolute -top-full left-0 size-full\"\n          style={{ boxShadow: `0 0 60px ${tint}, 0 0 100px ${tint}` }}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/radio-group/RadioCard.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { FC, ReactNode } from \"react\"\nimport { useId } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useRadioContext, useRadioGroupValue } from \"./context\"\n\nexport const RadioCard: FC<\n  React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {\n    label: ReactNode\n    wrapperClassName?: string\n  }\n> = (props) => {\n  const { id, label, className, wrapperClassName, value, onChange, ...rest } = props\n  const { onChange: ctxOnChange } = useRadioContext() || {}\n  const fallbackId = useId()\n\n  const ctxValue = useRadioGroupValue()\n\n  const handleChange: React.ChangeEventHandler<HTMLInputElement> = useEventCallback((e) => {\n    ctxOnChange?.(e.target.value)\n    onChange?.(e)\n  })\n\n  const selected = value === ctxValue\n\n  return (\n    <label\n      htmlFor={id ?? fallbackId}\n      data-state={selected ? \"selected\" : \"unselected\"}\n      className={cn(\n        \"flex cursor-pointer items-center rounded-md p-2\",\n        \"border\",\n\n        \"ring-0 ring-accent/20 duration-200\",\n\n        selected && \"border-accent bg-accent/5 font-medium outline-none ring-2\",\n        wrapperClassName,\n      )}\n      tabIndex={1}\n    >\n      <input\n        id={id ?? fallbackId}\n        type=\"radio\"\n        className={cn(\"hidden size-0\", className)}\n        value={value}\n        checked={ctxValue === value}\n        onChange={handleChange}\n        {...rest}\n      />\n      <span>{label}</span>\n    </label>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/radio-group/RadioGroup.tsx",
    "content": "import { useMemo, useRef, useState } from \"react\"\n\nimport { RadioGroupContextProvider, RadioGroupValueProvider } from \"./context\"\n\nexport const RadioGroup: Component<{\n  value?: string\n  onValueChange?: (value: string) => void\n}> = (props) => {\n  const { onValueChange, value } = props\n\n  const stableOnValueChange = useRef(onValueChange).current\n\n  const [currentValue, setCurrentValue] = useState(value)\n  return (\n    <RadioGroupContextProvider\n      value={useMemo(\n        () => ({\n          onChange(value) {\n            setCurrentValue(value)\n            stableOnValueChange?.(value)\n          },\n        }),\n        [stableOnValueChange],\n      )}\n    >\n      <RadioGroupValueProvider value={currentValue}>{props.children}</RadioGroupValueProvider>\n    </RadioGroupContextProvider>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/radio-group/context.ts",
    "content": "import { createContext, use } from \"react\"\n\ninterface RadioContext {\n  onChange: (value: string) => void\n}\nconst RadioContext = createContext<RadioContext>(null!)\n\nexport const useRadioContext = () => use(RadioContext)\n\nexport const RadioGroupContextProvider = RadioContext\ntype RadioGroupValue = string | undefined\n\nconst RadioGroupValueContext = createContext<RadioGroupValue>(\"\")\nexport const RadioGroupValueProvider = RadioGroupValueContext\nexport const useRadioGroupValue = () => use(RadioGroupValueContext)\n"
  },
  {
    "path": "packages/internal/components/src/ui/radio-group/index.ts",
    "content": "export * from \"./context\"\nexport * as MotionRadio from \"./motion\"\nexport * from \"./RadioCard\"\nexport * from \"./RadioGroup\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/radio-group/motion.tsx",
    "content": "\"use client\"\n\nimport { cn } from \"@follow/utils/utils\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport type { HTMLMotionProps, Transition } from \"motion/react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { Spring } from \"../../constants/spring\"\n\ntype RadioGroupProps = React.ComponentProps<typeof RadioGroupPrimitive.Root> & {\n  transition?: Transition\n}\n\nfunction RadioGroup({ className, ...props }: RadioGroupProps) {\n  return (\n    <RadioGroupPrimitive.Root\n      data-slot=\"radio-group\"\n      className={cn(\"grid gap-2.5\", className)}\n      {...props}\n    />\n  )\n}\n\ntype RadioGroupIndicatorProps = React.ComponentProps<typeof RadioGroupPrimitive.Indicator> & {\n  transition: Transition\n}\n\nfunction RadioGroupIndicator({ className, transition, ...props }: RadioGroupIndicatorProps) {\n  return (\n    <RadioGroupPrimitive.Indicator\n      data-slot=\"radio-group-indicator\"\n      asChild\n      className={cn(\"absolute inset-0 flex items-center justify-center\", className)}\n      {...props}\n    >\n      <AnimatePresence>\n        <m.div\n          key=\"radio-group-indicator-circle\"\n          data-slot=\"radio-group-indicator-circle\"\n          initial={{ opacity: 0, scale: 0 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={{ opacity: 0, scale: 0 }}\n          transition={transition}\n        >\n          <svg\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"size-3 fill-current text-current\"\n          >\n            <circle cx=\"12\" cy=\"12\" r=\"10\" />\n          </svg>\n        </m.div>\n      </AnimatePresence>\n    </RadioGroupPrimitive.Indicator>\n  )\n}\n\ntype RadioGroupItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item> &\n  HTMLMotionProps<\"button\"> & {\n    transition?: Transition\n    label?: string\n    labelClassName?: string\n  }\n\nfunction RadioGroupItem({\n  className,\n  transition = Spring.presets.smooth,\n  label,\n  labelClassName,\n  ...props\n}: RadioGroupItemProps) {\n  const id = React.useId()\n  return (\n    <RadioGroupPrimitive.Item asChild {...props}>\n      <div className=\"flex items-center gap-2\">\n        <m.button\n          type=\"button\"\n          data-slot=\"radio-group-item\"\n          className={cn(\n            \"flex aspect-square size-5 items-center justify-center rounded-full border border-border text-accent ring-material-opaque focus:outline-none focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\",\n            className,\n          )}\n          whileHover={{ scale: 1.05 }}\n          whileTap={{ scale: 0.95 }}\n          {...props}\n        >\n          <RadioGroupIndicator data-slot=\"radio-group-item-indicator\" transition={transition} />\n        </m.button>\n        {label && (\n          <label htmlFor={id} className={labelClassName}>\n            {label}\n          </label>\n        )}\n      </div>\n    </RadioGroupPrimitive.Item>\n  )\n}\n\nexport { RadioGroup, RadioGroupItem, type RadioGroupItemProps, type RadioGroupProps }\n"
  },
  {
    "path": "packages/internal/components/src/ui/scroll-area/ScrollArea.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as ScrollAreaBase from \"@radix-ui/react-scroll-area\"\nimport * as React from \"react\"\n\nimport { ScrollElementContext, ScrollElementEventsContext } from \"./ctx\"\nimport styles from \"./index.module.css\"\n\nconst Corner = ({\n  ref: forwardedRef,\n  className,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Corner> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Corner> | null>\n}) => <ScrollAreaBase.Corner {...rest} ref={forwardedRef} className={cn(\"bg-accent\", className)} />\n\nCorner.displayName = \"ScrollArea.Corner\"\n\nconst Thumb = ({\n  ref: forwardedRef,\n  className,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Thumb> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Thumb> | null>\n}) => (\n  <ScrollAreaBase.Thumb\n    {...rest}\n    onClick={(e) => {\n      e.stopPropagation()\n      rest.onClick?.(e)\n    }}\n    ref={forwardedRef}\n    className={cn(\n      \"relative w-full flex-1 rounded-xl transition-colors duration-150\",\n      \"bg-fill-secondary hover:bg-fill\",\n      \"active:bg-fill-vibrant\",\n      \"before:absolute before:-left-1/2 before:-top-1/2 before:h-full before:min-h-[44]\",\n      'before:w-full before:min-w-[44] before:-translate-x-full before:-translate-y-full before:content-[\"\"]',\n\n      className,\n    )}\n  />\n)\nThumb.displayName = \"ScrollArea.Thumb\"\n\nconst Scrollbar = ({\n  ref: forwardedRef,\n  className,\n  children,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Scrollbar> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Scrollbar> | null>\n}) => {\n  const { orientation = \"vertical\" } = rest\n  return (\n    <ScrollAreaBase.Scrollbar\n      {...rest}\n      ref={forwardedRef}\n      className={cn(\n        \"flex w-2.5 touch-none select-none p-0.5\",\n        orientation === \"horizontal\" ? `h-2.5 w-full flex-col` : `w-2.5 flex-row`,\n        \"animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in\",\n        className,\n      )}\n    >\n      {children}\n      <Thumb />\n    </ScrollAreaBase.Scrollbar>\n  )\n}\nScrollbar.displayName = \"ScrollArea.Scrollbar\"\n\nconst Viewport = ({\n  ref: forwardedRef,\n  className,\n  mask = false,\n  focusable = true,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & {\n  mask?: boolean\n  focusable?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Viewport> | null> }) => {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [shouldAddMask, setShouldAddMask] = React.useState(false)\n  React.useLayoutEffect(() => {\n    if (!mask) {\n      setShouldAddMask(false)\n      return\n    }\n    const $el = ref.current\n    const $child = $el?.firstElementChild\n    if (!$el) return\n    if (!$child) return\n    const handler = () => {\n      setShouldAddMask($el.scrollHeight > $el.clientHeight + 48 * 2)\n    }\n    const observer = new ResizeObserver(handler)\n    handler()\n    observer.observe($child)\n\n    return () => observer.disconnect()\n  }, [mask])\n\n  React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement)\n  return (\n    <ScrollAreaBase.Viewport\n      {...rest}\n      ref={ref}\n      tabIndex={focusable ? -1 : void 0}\n      className={cn(\"block size-full\", shouldAddMask && styles[\"mask-scroller\"], className)}\n    />\n  )\n}\nViewport.displayName = \"ScrollArea.Viewport\"\n\nconst Root = ({\n  ref: forwardedRef,\n  className,\n  children,\n  flex,\n  ...rest\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Root> & {\n  ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Root> | null>\n  flex?: boolean\n}) => (\n  <ScrollAreaBase.Root\n    {...rest}\n    scrollHideDelay={0}\n    ref={forwardedRef}\n    className={cn(\n      \"overflow-hidden\",\n      flex && \"min-h-0\", // Add explicit min-height for flex contexts\n      className,\n    )}\n  >\n    {children}\n    <Corner />\n  </ScrollAreaBase.Root>\n)\n\nRoot.displayName = \"ScrollArea.Root\"\nexport const ScrollArea = ({\n  ref,\n  flex,\n  children,\n  rootClassName,\n  viewportClassName,\n  scrollbarClassName,\n  mask = false,\n  onScroll,\n  orientation = \"vertical\",\n  asChild = false,\n  onUpdateMaxScroll,\n  focusable = true,\n  scrollbarProps,\n  viewportProps,\n}: React.PropsWithChildren & {\n  rootClassName?: string\n  viewportClassName?: string\n  scrollbarClassName?: string\n  scrollbarProps?: React.ComponentProps<typeof ScrollAreaBase.Scrollbar>\n  flex?: boolean\n  mask?: boolean\n  onScroll?: (e: React.UIEvent<HTMLDivElement>) => void\n  onUpdateMaxScroll?: () => void\n  orientation?: \"vertical\" | \"horizontal\"\n  asChild?: boolean\n  focusable?: boolean\n  viewportProps?: React.ComponentProps<typeof ScrollAreaBase.Viewport>\n} & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(null)\n  React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)\n\n  const events = React.useMemo(() => ({ onUpdateMaxScroll }), [onUpdateMaxScroll])\n\n  return (\n    <ScrollElementContext value={viewportRef}>\n      <ScrollElementEventsContext value={events}>\n        <Root className={rootClassName} flex={flex}>\n          <Viewport\n            ref={setViewportRef}\n            className={cn(\n              flex && \"[&>div]:!flex [&>div]:!min-h-0 [&>div]:!flex-col\", // Add min-h-0 to flex children\n              viewportClassName,\n            )}\n            mask={mask}\n            asChild={asChild}\n            onScroll={onScroll}\n            focusable={focusable}\n            {...viewportProps}\n          >\n            {children}\n          </Viewport>\n          <Scrollbar orientation={orientation} className={scrollbarClassName} {...scrollbarProps} />\n        </Root>\n      </ScrollElementEventsContext>\n    </ScrollElementContext>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/scroll-area/ctx.ts",
    "content": "import { createContext } from \"react\"\n\nexport const ScrollElementContext = createContext<HTMLElement | null>(document.documentElement)\n\nexport const ScrollElementEventsContext = createContext<{\n  onUpdateMaxScroll?: () => void\n}>({\n  onUpdateMaxScroll: undefined,\n})\n"
  },
  {
    "path": "packages/internal/components/src/ui/scroll-area/hooks.ts",
    "content": "import clsx from \"clsx\"\nimport { useIsomorphicLayoutEffect } from \"foxact/use-isomorphic-layout-effect\"\nimport { use, useCallback, useRef, useState } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { ScrollElementContext, ScrollElementEventsContext } from \"./ctx\"\n\nconst THRESHOLD = 0\nexport const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({\n  ref,\n  size = \"base\",\n  element,\n  selector,\n}: {\n  ref?: React.RefObject<HTMLElement | null>\n  element?: HTMLElement\n  size?: \"base\" | \"lg\"\n  selector?: string\n} = {}) => {\n  const containerRef = useRef<T>(null)\n  const [isScrollToBottom, setIsScrollToBottom] = useState(false)\n  const [isScrollToTop, setIsScrollToTop] = useState(false)\n  const [canScroll, setCanScroll] = useState(false)\n\n  const getDomRef = useCallback(() => {\n    let $ = containerRef.current || ref?.current || element\n\n    if (!$) return\n\n    if (selector) {\n      $ = $.querySelector(selector) as HTMLElement\n    }\n    return $\n  }, [ref, selector, element])\n  const eventHandler = useEventCallback(() => {\n    const $ = getDomRef()\n\n    if (!$) return\n\n    // if $ can not scroll\n    if ($.scrollHeight <= $.clientHeight + 2) {\n      setCanScroll(false)\n      setIsScrollToBottom(false)\n      setIsScrollToTop(false)\n      return\n    }\n\n    setCanScroll(true)\n\n    // if $ can scroll\n    const isScrollToBottom = $.scrollTop + $.clientHeight >= $.scrollHeight - THRESHOLD\n    const isScrollToTop = $.scrollTop <= THRESHOLD\n    setIsScrollToBottom(isScrollToBottom)\n    setIsScrollToTop(isScrollToTop)\n  })\n  useIsomorphicLayoutEffect(() => {\n    const $ = getDomRef()\n    if (!$) return\n\n    $.addEventListener(\"scroll\", eventHandler)\n\n    return () => {\n      $.removeEventListener(\"scroll\", eventHandler)\n    }\n  }, [eventHandler, getDomRef, element])\n\n  useIsomorphicLayoutEffect(() => {\n    if (!ref?.current) {\n      return\n    }\n    const $ = ref.current\n    const resizeObserver = new ResizeObserver(() => {\n      eventHandler()\n    })\n    eventHandler()\n    resizeObserver.observe($)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [eventHandler, element])\n\n  const postfixSize = {\n    base: \"\",\n    lg: \"-lg\",\n  }[size]\n\n  return [\n    containerRef,\n    canScroll\n      ? clsx(\n          isScrollToBottom && \"mask-t\",\n          isScrollToTop && \"mask-b\",\n          !isScrollToBottom && !isScrollToTop && \"mask-both\",\n        ) + postfixSize\n      : \"\",\n  ] as const\n}\n\n/**\n * Get the scroll area element when in radix scroll area\n * @returns\n */\nexport const useScrollViewElement = () => use(ScrollElementContext)\n\nexport const useScrollElementUpdate = () => use(ScrollElementEventsContext)\n"
  },
  {
    "path": "packages/internal/components/src/ui/scroll-area/index.module.css",
    "content": ".mask-scroller {\n  mask:\n    linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat,\n    linear-gradient(white, white) 50% 50% / 100% 100% no-repeat,\n    linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat;\n  mask-composite: exclude;\n  mask-size:\n    100% calc((var(--scroll-progress-top) / 100) * 30px),\n    100% 100%,\n    100% calc((100 - (100 * (var(--scroll-progress-bottom) / 100))) * 1px);\n}\n\n@supports (animation-timeline: scroll()) {\n  .mask-scroller {\n    mask:\n      linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat,\n      linear-gradient(white, white) 50% 50% / 100% 100% no-repeat,\n      linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat;\n    mask-composite: exclude;\n    animation:\n      mask-up both linear,\n      mask-down both linear;\n    animation-timeline: scroll(self);\n    animation-range:\n      0 50px,\n      calc(100% - 50px) 100%;\n  }\n}\n@keyframes mask-up {\n  100% {\n    mask-size:\n      100% 30px,\n      100% 100%,\n      100% 30px;\n  }\n}\n@keyframes mask-down {\n  100% {\n    mask-size:\n      100% 30px,\n      100% 100%,\n      100% 0;\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/scroll-area/index.ts",
    "content": "export * as ScrollArea from \"./ScrollArea\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/segment/ctx.tsx",
    "content": "import { createContext } from \"use-context-selector\"\n\nexport interface SegmentGroupContextValue {\n  value: string\n  setValue: (value: string) => void\n  componentId: string\n}\nexport const SegmentGroupContext = createContext<SegmentGroupContextValue>(null!)\n"
  },
  {
    "path": "packages/internal/components/src/ui/segment/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { m } from \"motion/react\"\nimport type { ReactNode } from \"react\"\nimport { useId, useMemo, useState } from \"react\"\nimport { useContextSelector } from \"use-context-selector\"\n\nimport { Spring } from \"../../constants/spring\"\nimport { SegmentGroupContext } from \"./ctx\"\n\ninterface SegmentGroupProps {\n  value?: string\n  onValueChanged?: (value: string) => void\n}\nexport const SegmentGroup = (props: ComponentType<SegmentGroupProps>) => {\n  const { onValueChanged, value, className } = props\n\n  const [currentValue, setCurrentValue] = useState(value || \"\")\n  const componentId = useId()\n\n  return (\n    // eslint-disable-next-line @eslint-react/no-context-provider\n    <SegmentGroupContext.Provider\n      value={useMemo(\n        () => ({\n          value: currentValue,\n          setValue: (value) => {\n            setCurrentValue(value)\n            onValueChanged?.(value)\n          },\n          componentId,\n        }),\n        [componentId, currentValue, onValueChanged],\n      )}\n    >\n      <div\n        role=\"tablist\"\n        className={cn(\n          \"inline-flex h-9 items-center justify-center rounded-lg bg-fill-tertiary p-1 text-text-secondary outline-none\",\n          className,\n        )}\n        tabIndex={0}\n        data-orientation=\"horizontal\"\n      >\n        {props.children}\n      </div>\n    </SegmentGroupContext.Provider>\n  )\n}\n\nexport const SegmentItem: Component<{\n  value: string\n  label: ReactNode\n}> = ({ label, value, className }) => {\n  const isActive = useContextSelector(SegmentGroupContext, (v) => v.value === value)\n  const setValue = useContextSelector(SegmentGroupContext, (v) => v.setValue)\n  const layoutId = useContextSelector(SegmentGroupContext, (v) => v.componentId)\n  return (\n    <button\n      type=\"button\"\n      role=\"tab\"\n      className={cn(\n        \"relative inline-flex items-center justify-center whitespace-nowrap px-3 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-text\",\n        \"h-full rounded-md focus-visible:ring-accent/30\",\n        className,\n      )}\n      tabIndex={-1}\n      data-orientation=\"horizontal\"\n      onClick={() => {\n        setValue(value)\n      }}\n      data-state={isActive ? \"active\" : \"inactive\"}\n    >\n      <span className=\"z-[1]\">{label}</span>\n\n      {isActive && (\n        <m.span\n          layout\n          transition={Spring.presets.smooth}\n          layoutId={layoutId}\n          className=\"absolute inset-0 z-0 rounded-md bg-background shadow\"\n        />\n      )}\n    </button>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/select/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport * as React from \"react\"\n\nimport { Divider } from \"../divider/Divider\"\nimport { RootPortal } from \"../portal\"\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = ({\n  ref,\n  size = \"default\",\n  className,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {\n  size?: \"default\" | \"sm\"\n} & { ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Trigger> | null> }) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex w-full items-center justify-between whitespace-nowrap rounded-lg bg-transparent\",\n      \"outline-none transition-all duration-200 focus-within:outline-transparent focus-within:ring-2 focus-within:ring-material-medium\",\n      \"border border-border hover:border-fill\",\n      size === \"sm\" ? \"h-8 px-3 text-sm\" : \"h-9 px-3.5 py-2 text-sm\",\n      \"placeholder:text-text-secondary\",\n      \"disabled:cursor-not-allowed disabled:opacity-50\",\n      \"[&>span]:line-clamp-1\",\n      \"shadow-sm shadow-material-thin hover:shadow\",\n      className,\n      props.disabled && \"cursor-not-allowed opacity-30\",\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <i className=\"i-mingcute-down-line -mr-1 ml-2 size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[state=open]:rotate-180\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n)\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.ScrollUpButton> | null>\n}) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\"flex cursor-menu items-center justify-center py-1\", className)}\n    {...props}\n  >\n    <i className=\"i-mingcute-up-line size-3.5\" />\n  </SelectPrimitive.ScrollUpButton>\n)\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.ScrollDownButton> | null>\n}) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\"flex cursor-menu items-center justify-center py-1\", className)}\n    {...props}\n  >\n    <i className=\"i-mingcute-down-line size-3.5\" />\n  </SelectPrimitive.ScrollDownButton>\n)\nSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = ({\n  ref,\n  className,\n  children,\n  position = \"popper\",\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Content> | null>\n}) => (\n  <RootPortal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"z-[60] max-h-96 min-w-32 overflow-hidden rounded-[6px] border bg-material-medium p-1 text-text backdrop-blur-background\",\n        \"shadow-context-menu\",\n        \"text-body motion-scale-in-75 motion-duration-150 lg:animate-none\",\n        className,\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          \"p-0\",\n          position === \"popper\" &&\n            \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\",\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </RootPortal>\n)\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = ({\n  ref,\n  className,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> & {\n  inset?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Label> | null> }) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 font-semibold text-text\", inset && \"pl-8\", className)}\n    {...props}\n  />\n)\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = ({\n  ref,\n  className,\n  children,\n  inset,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {\n  inset?: boolean\n} & { ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Item> | null> }) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      \"focus-within:outline-transparent data-[highlighted]:bg-theme-selection-hover\",\n      \"h-[28px] w-full\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <i className=\"i-mgc-check-filled size-3\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n)\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = ({\n  ref,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> & {\n  ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Separator> | null>\n}) => (\n  <SelectPrimitive.Separator\n    className=\"mx-2 my-1 h-px backdrop-blur-background\"\n    asChild\n    ref={ref}\n    {...props}\n  >\n    <Divider />\n  </SelectPrimitive.Separator>\n)\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/select/responsive.tsx",
    "content": "import { useControlled } from \"@follow/hooks\"\nimport { cn } from \"@follow/utils/utils\"\nimport { useMemo, useState } from \"react\"\n\nimport { useMobile } from \"../../hooks/useMobile\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"./index\"\n\nexport type ResponsiveSelectItem = {\n  label: string\n  value: string\n}\nexport interface ResponsiveSelectProps {\n  defaultValue?: string\n  value?: string\n  onValueChange?: (value: string) => void\n  placeholder?: string\n  items: ResponsiveSelectItem[]\n  renderValue?: (item: ResponsiveSelectItem) => React.ReactNode\n  renderItem?: (item: ResponsiveSelectItem) => React.ReactNode\n  size?: \"sm\" | \"default\"\n\n  disabled?: boolean\n  triggerClassName?: string\n  contentClassName?: string\n  triggerTestId?: string\n  nativeSelectTestId?: string\n}\nexport const ResponsiveSelect = ({\n  defaultValue,\n  value,\n  onValueChange,\n  items,\n  renderValue,\n  renderItem,\n  disabled,\n  size = \"default\",\n  triggerClassName,\n  contentClassName,\n  placeholder,\n  triggerTestId,\n  nativeSelectTestId,\n}: ResponsiveSelectProps) => {\n  const [valueInner] = useControlled(value, defaultValue ?? \"\", onValueChange)\n\n  const isMobile = useMobile()\n\n  const valueToLabelMap = useMemo(\n    () =>\n      items.reduce(\n        (acc, item) => {\n          acc[item.value] = item.label\n          return acc\n        },\n        {} as Record<string, string>,\n      ),\n    [items],\n  )\n\n  const [realSelectRef, setRealSelectRef] = useState<HTMLSelectElement | null>(null)\n  if (isMobile) {\n    return (\n      <button\n        type=\"button\"\n        data-testid={triggerTestId}\n        onClick={() => realSelectRef?.click()}\n        className={cn(\n          \"flex w-full items-center justify-between whitespace-nowrap rounded-md bg-transparent placeholder:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1\",\n          \"ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2\",\n          \"border border-border\",\n          size === \"sm\" ? \"h-7 p-2 text-sm\" : \"h-9 px-3 py-2 text-sm\",\n          \"hover:border-accent/80\",\n          \"relative\",\n          triggerClassName,\n        )}\n      >\n        <span className=\"flex\">\n          {(renderValue?.(items.find((item) => item.value === valueInner)!) ??\n            valueToLabelMap[valueInner]) || (\n            <span className=\"text-text-tertiary\">{placeholder}</span>\n          )}\n        </span>\n        <i className=\"i-mingcute-down-line ml-2 size-4 shrink-0 opacity-50\" />\n        <select\n          ref={setRealSelectRef}\n          data-testid={nativeSelectTestId}\n          className=\"absolute inset-0 opacity-0\"\n          value={valueInner}\n          onChange={(e) => onValueChange?.(e.target.value)}\n        >\n          {items.map((item) => (\n            <option key={item.value} value={item.value}>\n              {renderItem?.(item) ?? item.label}\n            </option>\n          ))}\n        </select>\n      </button>\n    )\n  }\n\n  return (\n    <Select\n      disabled={disabled}\n      defaultValue={defaultValue}\n      value={valueInner}\n      onValueChange={onValueChange}\n    >\n      <SelectTrigger size={size} className={triggerClassName} data-testid={triggerTestId}>\n        <SelectValue />\n      </SelectTrigger>\n      <SelectContent className={contentClassName} position=\"item-aligned\">\n        {items.map((item) => (\n          <SelectItem key={item.value} value={item.value}>\n            {renderItem?.(item) ?? item.label}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/sheet/Sheet.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport { useStore } from \"jotai\"\nimport type { FC, PropsWithChildren, ReactNode, RefObject } from \"react\"\nimport * as React from \"react\"\nimport { useEffect, useImperativeHandle, useMemo, useState } from \"react\"\nimport { Drawer } from \"vaul\"\n\nimport { RootPortalContext } from \"../portal/provider\"\nimport { SheetContext, sheetStackAtom } from \"./context\"\n\nexport interface PresentSheetProps {\n  content: ReactNode | FC\n  open?: boolean\n  onOpenChange?: (value: boolean) => void\n  title?: ReactNode\n  hideHeader?: boolean\n  zIndex?: number\n  dismissible?: boolean\n  defaultOpen?: boolean\n\n  triggerAsChild?: boolean\n  contentRef?: RefObject<HTMLDivElement | null>\n  dismissableClassName?: string\n  modalClassName?: string\n  contentClassName?: string\n}\n\nexport type SheetRef = {\n  dismiss: () => Promise<void>\n}\nconst MODAL_STACK_Z_INDEX = 1001\nexport const PresentSheet = ({\n  ref,\n  ...props\n}: PropsWithChildren<PresentSheetProps> & { ref?: React.Ref<SheetRef | null> }) => {\n  const {\n    content,\n    children,\n    zIndex = MODAL_STACK_Z_INDEX,\n    title,\n    hideHeader,\n    dismissible = true,\n    defaultOpen,\n    triggerAsChild,\n    contentRef,\n    dismissableClassName,\n    modalClassName,\n    contentClassName,\n  } = props\n\n  const [isOpen, setIsOpen] = useState(props.open ?? defaultOpen)\n\n  useImperativeHandle(ref, () => ({\n    dismiss: () => {\n      return new Promise<void>((resolve) => {\n        setIsOpen(false)\n        setTimeout(() => {\n          resolve()\n        }, 500)\n      })\n    },\n  }))\n\n  const nextRootProps = useMemo(() => {\n    const nextProps = {\n      onOpenChange: setIsOpen,\n    } as any\n    if (isOpen !== undefined) {\n      nextProps.open = isOpen\n    }\n\n    if (props.onOpenChange !== undefined) {\n      nextProps.onOpenChange = (v: boolean) => {\n        setIsOpen(v)\n        props.onOpenChange?.(v)\n      }\n    }\n\n    return nextProps\n  }, [props, isOpen, setIsOpen])\n\n  useEffect(() => {\n    if (props.open !== undefined) {\n      setIsOpen(props.open)\n    }\n  }, [props.open])\n  const [holderRef, setHolderRef] = useState<HTMLDivElement | null>()\n  const store = useStore()\n\n  useEffect(() => {\n    const holder = holderRef\n    if (!holder) return\n    store.set(sheetStackAtom, (p) => p.concat(holder))\n\n    return () => {\n      store.set(sheetStackAtom, (p) => p.filter((item) => item !== holder))\n    }\n  }, [holderRef, store])\n\n  const overlayZIndex = zIndex - 1\n  const contentZIndex = zIndex\n\n  const contentInnerRef = React.useRef<HTMLDivElement>(null)\n  useImperativeHandle(contentRef, () => contentInnerRef.current!)\n\n  return (\n    <Drawer.Root dismissible={dismissible} repositionInputs={false} {...nextRootProps}>\n      {!!children && <Drawer.Trigger asChild={triggerAsChild}>{children}</Drawer.Trigger>}\n      <Drawer.Portal>\n        <Drawer.Content\n          ref={contentInnerRef}\n          style={{\n            zIndex: contentZIndex,\n          }}\n          aria-describedby={undefined}\n          className={cn(\n            \"fixed inset-x-0 bottom-0 flex max-h-[calc(100svh-3rem)] flex-col rounded-t-[10px] border-t bg-theme-background pt-4\",\n            modalClassName,\n          )}\n        >\n          {dismissible && (\n            <div\n              className={cn(\n                \"mx-auto mb-8 h-1.5 w-12 shrink-0 rounded-full bg-zinc-300 dark:bg-neutral-800\",\n                dismissableClassName,\n              )}\n            />\n          )}\n\n          {title ? (\n            <Drawer.Title\n              className={cn(\n                \"-mt-4 mb-4 flex justify-center px-4 text-lg font-medium\",\n                hideHeader && \"sr-only\",\n              )}\n            >\n              {title}\n            </Drawer.Title>\n          ) : (\n            <Drawer.Title />\n          )}\n\n          <SheetContext\n            value={useMemo(\n              () => ({\n                dismiss() {\n                  setIsOpen(false)\n                },\n              }),\n              [setIsOpen],\n            )}\n          >\n            <RootPortalContext value={contentInnerRef.current!}>\n              <div\n                className={cn(\n                  \"flex grow flex-col overflow-auto px-4 pb-safe-offset-4\",\n                  contentClassName,\n                )}\n              >\n                {typeof content === \"function\" ? React.createElement(content) : content}\n              </div>\n            </RootPortalContext>\n          </SheetContext>\n          <div ref={setHolderRef} />\n        </Drawer.Content>\n        <Drawer.Overlay\n          className=\"fixed inset-0 bg-neutral-800/40\"\n          style={{\n            zIndex: overlayZIndex,\n          }}\n        />\n      </Drawer.Portal>\n    </Drawer.Root>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/sheet/context.tsx",
    "content": "import { atom } from \"jotai\"\nimport { createContext, use } from \"react\"\n\ninterface SheetContextValue {\n  dismiss: () => void\n}\nexport const SheetContext = createContext<SheetContextValue | null>(null)\n\nexport const useSheetContext = () => use(SheetContext)\nexport const sheetStackAtom = atom([] as HTMLDivElement[])\n"
  },
  {
    "path": "packages/internal/components/src/ui/sheet/index.ts",
    "content": "export * from \"./Sheet\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/shiny-text/ShinyText.tsx",
    "content": "import { cn } from \"@follow/utils\"\nimport type { ComponentPropsWithoutRef, CSSProperties, FC } from \"react\"\n\nimport styles from \"./index.module.css\"\n\nexport interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<\"span\"> {\n  shimmerWidth?: number\n}\n\nexport const ShinyText: FC<AnimatedShinyTextProps> = ({\n  children,\n  className,\n  shimmerWidth = 100,\n  ...props\n}) => {\n  return (\n    <span\n      style={\n        {\n          \"--shiny-width\": `${shimmerWidth}px`,\n        } as CSSProperties\n      }\n      className={cn(\n        \"mx-auto max-w-md text-text-secondary\",\n\n        // Shine effect\n        \"bg-clip-text bg-repeat-x\",\n\n        styles[\"shiny-text\"],\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </span>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/shiny-text/index.module.css",
    "content": ".shiny-text {\n  background-image: none, linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.85), transparent);\n\n  background-size:\n    cover,\n    200% 100%;\n  background-repeat: no-repeat, repeat-x;\n  background-position:\n    center center,\n    0% 0;\n\n  -webkit-background-clip: text;\n  color: transparent;\n\n  animation: shiny-text 2s linear infinite;\n}\n\n[data-theme=\"dark\"] .shiny-text {\n  background-image:\n    none, linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.85), transparent);\n}\n\n@keyframes shiny-text {\n  from {\n    background-position:\n      center center,\n      0% 0;\n  }\n\n  to {\n    background-position:\n      center center,\n      -200% 0;\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/shrinking-focus-border/index.tsx",
    "content": "import type { FC } from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\n\nimport { RootPortal } from \"../portal\"\n\nexport interface ShrinkingFocusBorderProps {\n  isVisible: boolean\n  containerRef: React.RefObject<HTMLElement | null>\n  persistBorder?: boolean\n  radius?: number\n}\n\nexport const ShrinkingFocusBorder: FC<ShrinkingFocusBorderProps> = ({\n  isVisible,\n  containerRef,\n  persistBorder = false,\n  radius = 6,\n}) => {\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const animationFrameRef = useRef<number>(undefined)\n  const startTimeRef = useRef<number>(undefined)\n  const resizeObserverRef = useRef<ResizeObserver>(undefined)\n  const [currentRect, setCurrentRect] = useState<DOMRect | null>(null)\n  const [isAnimating, setIsAnimating] = useState(false)\n  const transitionStartRef = useRef<number>(undefined)\n  const previousRectRef = useRef<DOMRect | null>(null)\n\n  // Reset animation state when visibility changes\n  useEffect(() => {\n    if (isVisible) {\n      setIsAnimating(true)\n      previousRectRef.current = null\n    } else {\n      setIsAnimating(false)\n      setCurrentRect(null)\n    }\n  }, [isVisible])\n\n  // Setup resize observer for persistent border\n  useEffect(() => {\n    if (!persistBorder || !containerRef.current || !canvasRef.current) {\n      return\n    }\n\n    const observer = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const newRect = entry.target.getBoundingClientRect()\n        if (\n          currentRect &&\n          (Math.abs(newRect.width - currentRect.width) > 1 ||\n            Math.abs(newRect.height - currentRect.height) > 1 ||\n            Math.abs(newRect.left - currentRect.left) > 1 ||\n            Math.abs(newRect.top - currentRect.top) > 1)\n        ) {\n          previousRectRef.current = currentRect\n          setCurrentRect(newRect)\n          transitionStartRef.current = Date.now()\n        }\n      }\n    })\n\n    observer.observe(containerRef.current)\n    resizeObserverRef.current = observer\n\n    return () => {\n      observer.disconnect()\n    }\n  }, [persistBorder, containerRef, currentRect])\n\n  useEffect(() => {\n    if (!isVisible || !containerRef.current || !canvasRef.current) {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      return\n    }\n\n    // Delay animation start to ensure proper positioning\n    const animationTimeout = setTimeout(() => {\n      const canvas = canvasRef.current\n      const container = containerRef.current\n\n      if (!canvas || !container) return\n\n      const ctx = canvas.getContext(\"2d\")\n      if (!ctx) return\n\n      // Get fresh rect after DOM has settled\n      const rect = container.getBoundingClientRect()\n      setCurrentRect(rect)\n\n      // Canvas positioned fixed to viewport\n      canvas.width = rect.width + 100\n      canvas.height = rect.height + 100\n\n      // Position canvas relative to viewport\n      canvas.style.left = `${rect.left - 50}px`\n      canvas.style.top = `${rect.top - 50}px`\n\n      startTimeRef.current = Date.now()\n\n      const drawBorder = () => {\n        if (!ctx || !canvas) return\n\n        const now = Date.now()\n        const elapsed = (now - (startTimeRef.current || 0)) / 1000\n        const duration = 0.4 // Animation duration in seconds\n\n        // Clear canvas\n        ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n        let borderWidth = rect.width\n        let borderHeight = rect.height\n        let borderX = canvas.width / 2 - borderWidth / 2\n        let borderY = canvas.height / 2 - borderHeight / 2\n\n        if (isAnimating) {\n          if (elapsed >= duration) {\n            // Animation complete\n            setIsAnimating(false)\n            if (!persistBorder) {\n              // Stop animation completely for non-persistent border\n              return\n            }\n            // For persistent border, continue with final dimensions\n            borderWidth = rect.width\n            borderHeight = rect.height\n            borderX = canvas.width / 2 - borderWidth / 2\n            borderY = canvas.height / 2 - borderHeight / 2\n          } else {\n            // Initial shrinking animation in progress\n            const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)\n            const progress = Math.min(elapsed / duration, 1)\n            const easedProgress = easeOutCubic(progress)\n\n            const startWidth = rect.width + 80\n            const startHeight = rect.height + 80\n\n            borderWidth = startWidth - (startWidth - rect.width) * easedProgress\n            borderHeight = startHeight - (startHeight - rect.height) * easedProgress\n            borderX = canvas.width / 2 - borderWidth / 2\n            borderY = canvas.height / 2 - borderHeight / 2\n          }\n        } else if (persistBorder && previousRectRef.current && transitionStartRef.current) {\n          // Resize transition animation\n          const transitionElapsed = (now - transitionStartRef.current) / 1000\n          const transitionDuration = 0.3\n\n          if (transitionElapsed <= transitionDuration) {\n            const progress = Math.min(transitionElapsed / transitionDuration, 1)\n            const easeInOutCubic = (t: number) =>\n              t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2\n            const easedProgress = easeInOutCubic(progress)\n\n            const prevRect = previousRectRef.current\n            borderWidth = prevRect.width + (rect.width - prevRect.width) * easedProgress\n            borderHeight = prevRect.height + (rect.height - prevRect.height) * easedProgress\n\n            // Update canvas position during transition\n            const currentLeft = prevRect.left + (rect.left - prevRect.left) * easedProgress\n            const currentTop = prevRect.top + (rect.top - prevRect.top) * easedProgress\n\n            canvas.style.left = `${currentLeft - 50}px`\n            canvas.style.top = `${currentTop - 50}px`\n            canvas.width = borderWidth + 100\n            canvas.height = borderHeight + 100\n\n            borderX = canvas.width / 2 - borderWidth / 2\n            borderY = canvas.height / 2 - borderHeight / 2\n\n            if (progress >= 1) {\n              previousRectRef.current = null\n              transitionStartRef.current = undefined\n            }\n          }\n        } else if (persistBorder) {\n          // Update canvas position for current rect\n          canvas.style.left = `${rect.left - 50}px`\n          canvas.style.top = `${rect.top - 50}px`\n          canvas.width = rect.width + 100\n          canvas.height = rect.height + 100\n          borderX = canvas.width / 2 - borderWidth / 2\n          borderY = canvas.height / 2 - borderHeight / 2\n        }\n\n        // Draw border only if animating or persistBorder is true\n        if (isAnimating || persistBorder) {\n          // Get dynamic color from CSS variable\n          const computedStyle = getComputedStyle(document.documentElement)\n          const foColor = computedStyle.getPropertyValue(\"--fo-a\").trim()\n\n          // Parse HSL string (e.g., \"21.6 100% 50%\") and convert to usable format\n          const hslMatch = foColor.match(/^(\\d+(?:\\.\\d+)?)\\s+(\\d+)%\\s+(\\d+)%$/)\n          let strokeColor = \"rgba(59, 130, 246, 0.8)\" // fallback\n          let shadowColor = \"rgba(59, 130, 246, 0.5)\" // fallback\n\n          if (hslMatch) {\n            const [, h, s, l] = hslMatch\n            strokeColor = `hsla(${h}, ${s}%, ${l}%, 0.8)`\n            shadowColor = `hsla(${h}, ${s}%, ${l}%, 0.5)`\n          }\n\n          // Border style\n          ctx.strokeStyle = strokeColor\n          ctx.lineWidth = 2\n          ctx.shadowColor = shadowColor\n          ctx.shadowBlur = 6\n\n          // Draw rounded rectangle border\n          ctx.beginPath()\n          ctx.roundRect(borderX, borderY, borderWidth, borderHeight, radius)\n          ctx.stroke()\n        }\n\n        // Continue animation if needed\n        if (\n          isAnimating ||\n          (persistBorder && previousRectRef.current && transitionStartRef.current) ||\n          (persistBorder && elapsed > duration)\n        ) {\n          animationFrameRef.current = requestAnimationFrame(drawBorder)\n        }\n      }\n\n      drawBorder()\n    }, 16) // One frame delay to ensure DOM positioning\n\n    return () => {\n      clearTimeout(animationTimeout)\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n    }\n  }, [isVisible, containerRef, isAnimating, persistBorder, radius])\n\n  // Cleanup\n  useEffect(() => {\n    return () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      if (resizeObserverRef.current) {\n        resizeObserverRef.current.disconnect()\n      }\n    }\n  }, [])\n\n  if (!isVisible) return null\n\n  // If not persisting border and animation is complete, don't render\n  if (!persistBorder && !isAnimating) return null\n\n  return (\n    <RootPortal>\n      <canvas\n        ref={canvasRef}\n        className=\"pointer-events-none fixed z-50\"\n        style={{ borderRadius: `${radius}px` }}\n      />\n    </RootPortal>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/skeleton/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\n\nexport function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn(\"animate-pulse rounded-md bg-fill-tertiary\", className)} {...props} />\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/slider/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport * as SliderPrimitive from \"@radix-ui/react-slider\"\nimport * as React from \"react\"\n\nexport const Slider = ({\n  ref,\n  className,\n  variant = \"primary\",\n  ...props\n}: React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {\n  variant?: \"primary\" | \"secondary\"\n} & {\n  ref?: React.Ref<\n    | (React.ElementRef<typeof SliderPrimitive.Root> & {\n        variant?: \"primary\" | \"secondary\"\n      })\n    | null\n  >\n}) => (\n  <SliderPrimitive.Root\n    ref={ref}\n    className={cn(\"relative flex w-full touch-none select-none items-center\", className)}\n    {...props}\n  >\n    <SliderPrimitive.Track\n      className={cn(\n        \"relative h-1.5 w-full grow overflow-hidden rounded-full\",\n        variant === \"primary\" ? \"bg-accent/20\" : \"bg-zinc-200 dark:bg-zinc-700\",\n      )}\n    >\n      <SliderPrimitive.Range\n        className={cn(\n          \"absolute h-full\",\n          variant === \"primary\" ? \"bg-accent\" : \"bg-zinc-400 dark:bg-zinc-500\",\n        )}\n      />\n    </SliderPrimitive.Track>\n    <SliderPrimitive.Thumb\n      className={cn(\n        \"block size-4 rounded-full border bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50\",\n        variant === \"primary\"\n          ? \"border-accent/50 focus-visible:ring-accent\"\n          : \"border-zinc-400 focus-visible:ring-zinc-400 dark:border-zinc-500 dark:focus-visible:ring-zinc-500\",\n      )}\n    />\n  </SliderPrimitive.Root>\n)\nSlider.displayName = SliderPrimitive.Root.displayName\n"
  },
  {
    "path": "packages/internal/components/src/ui/switch/index.tsx",
    "content": "\"use client\"\n\nimport { cn } from \"@follow/utils/utils\"\nimport type { SwitchProps as SwitchPrimitiveProps } from \"@headlessui/react\"\nimport { Switch as SwitchPrimitive } from \"@headlessui/react\"\nimport type { HTMLMotionProps } from \"motion/react\"\nimport { m as motion } from \"motion/react\"\nimport * as React from \"react\"\nimport { useMemo } from \"react\"\n\nimport { Spring } from \"../../constants/spring\"\n\ntype SwitchProps<TTag extends React.ElementType = typeof motion.button> =\n  SwitchPrimitiveProps<TTag> &\n    Omit<HTMLMotionProps<\"button\">, \"children\"> & {\n      leftIcon?: React.ReactNode\n      rightIcon?: React.ReactNode\n      thumbIcon?: React.ReactNode\n      onCheckedChange?: (checked: boolean) => void\n      as?: TTag\n      size?: \"sm\" | \"md\"\n    }\n\nconst THUMB_PADDING = 3\nconst THUMB_SIZE = 18\nconst SWITCH_WIDTH = 40\nconst THUMB_PADDING_SM = 2\nconst THUMB_SIZE_SM = 14\nconst SWITCH_WIDTH_SM = 32\nfunction Switch({\n  className,\n  leftIcon,\n  rightIcon,\n  thumbIcon,\n  onChange,\n  onCheckedChange,\n  as = motion.button,\n  size = \"md\",\n  ...props\n}: SwitchProps) {\n  const [isChecked, setIsChecked] = React.useState(props.checked ?? props.defaultChecked ?? false)\n  const [isTapped, setIsTapped] = React.useState(false)\n\n  React.useEffect(() => {\n    setIsChecked(props.checked ?? props.defaultChecked ?? false)\n  }, [props.checked, props.defaultChecked])\n\n  const handleChange = React.useCallback(\n    (checked: boolean) => {\n      setIsChecked(checked)\n      onCheckedChange?.(checked)\n      onChange?.(checked)\n    },\n    [onCheckedChange, onChange],\n  )\n\n  const thumbPadding = size === \"sm\" ? THUMB_PADDING_SM : THUMB_PADDING\n  const thumbSize = size === \"sm\" ? THUMB_SIZE_SM : THUMB_SIZE\n  const switchWidth = size === \"sm\" ? SWITCH_WIDTH_SM : SWITCH_WIDTH\n\n  const currentAnimation = useMemo(() => {\n    return !props.checked\n      ? { left: thumbPadding }\n      : { left: switchWidth - thumbPadding - thumbSize }\n  }, [props.checked, thumbPadding, thumbSize, switchWidth])\n\n  return (\n    <SwitchPrimitive\n      data-slot=\"switch\"\n      checked={isChecked}\n      onChange={handleChange}\n      style={{ width: switchWidth, padding: thumbPadding }}\n      className={cn(\n        \"relative flex shrink-0 cursor-switch items-center justify-start rounded-full bg-fill transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-accent\",\n        size === \"sm\" ? \"h-5\" : \"h-6\",\n        className,\n      )}\n      as={as}\n      whileTap=\"tap\"\n      initial={false}\n      onTapStart={() => {\n        setIsTapped(true)\n      }}\n      onTapCancel={() => setIsTapped(false)}\n      onTap={() => setIsTapped(false)}\n      {...props}\n    >\n      {leftIcon && (\n        <motion.div\n          data-slot=\"switch-left-icon\"\n          animate={isChecked ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}\n          transition={{ type: \"spring\", bounce: 0 }}\n          className={cn(\n            \"absolute left-1 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500\",\n            size === \"sm\" ? \"[&_svg]:size-2.5\" : \"[&_svg]:size-3\",\n          )}\n        >\n          {typeof leftIcon !== \"string\" ? leftIcon : null}\n        </motion.div>\n      )}\n\n      {rightIcon && (\n        <motion.div\n          data-slot=\"switch-right-icon\"\n          animate={isChecked ? { scale: 0, opacity: 0 } : { scale: 1, opacity: 1 }}\n          transition={{ type: \"spring\", bounce: 0 }}\n          className={cn(\n            \"absolute right-1 top-1/2 -translate-y-1/2 text-neutral-500 dark:text-neutral-400\",\n            size === \"sm\" ? \"[&_svg]:size-2.5\" : \"[&_svg]:size-3\",\n          )}\n        >\n          {typeof rightIcon !== \"string\" ? rightIcon : null}\n        </motion.div>\n      )}\n\n      <motion.span\n        data-slot=\"switch-thumb\"\n        whileTap=\"tab\"\n        className={cn(\n          \"z-[1] flex items-center justify-center rounded-full bg-background text-neutral-500 shadow-lg ring-0 dark:text-neutral-400\",\n          size === \"sm\" ? \"[&_svg]:size-2.5\" : \"[&_svg]:size-3\",\n          \"absolute\",\n        )}\n        transition={Spring.presets.smooth}\n        style={{\n          width: thumbSize,\n          height: thumbSize,\n        }}\n        initial={currentAnimation}\n        animate={Object.assign(\n          isTapped\n            ? { width: size === \"sm\" ? 17 : 21, transition: Spring.presets.snappy }\n            : { width: thumbSize },\n          currentAnimation,\n        )}\n      >\n        {thumbIcon && typeof thumbIcon !== \"string\" ? thumbIcon : null}\n      </motion.span>\n    </SwitchPrimitive>\n  )\n}\n\nexport { Switch, type SwitchProps }\n"
  },
  {
    "path": "packages/internal/components/src/ui/table/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { VariantProps } from \"class-variance-authority\"\nimport * as React from \"react\"\n\nimport { tableCellVariants, tableHeadVariants } from \"./variants\"\n\nconst Table = ({\n  ref,\n  className,\n  containerClassName,\n  ...props\n}: React.HTMLAttributes<HTMLTableElement> & { containerClassName?: string } & {\n  ref?: React.Ref<HTMLTableElement | null>\n}) => (\n  <div className={cn(\"relative w-full\", containerClassName)}>\n    <table ref={ref} className={cn(\"w-full caption-bottom text-sm\", className)} {...props} />\n  </div>\n)\nTable.displayName = \"Table\"\n\nconst TableHeader = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLTableSectionElement> & {\n  ref?: React.Ref<HTMLTableSectionElement | null>\n}) => <thead ref={ref} className={cn(className)} {...props} />\nTableHeader.displayName = \"TableHeader\"\n\nconst TableBody = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLTableSectionElement> & {\n  ref?: React.Ref<HTMLTableSectionElement | null>\n}) => <tbody ref={ref} className={cn(\"[&_tr:last-child]:border-0\", className)} {...props} />\nTableBody.displayName = \"TableBody\"\n\nconst TableFooter = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLTableSectionElement> & {\n  ref?: React.Ref<HTMLTableSectionElement | null>\n}) => (\n  <tfoot\n    ref={ref}\n    className={cn(\"border-t bg-material-thin font-medium [&>tr]:last:border-b-0\", className)}\n    {...props}\n  />\n)\nTableFooter.displayName = \"TableFooter\"\n\nconst TableRow = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLTableRowElement> & {\n  ref?: React.Ref<HTMLTableRowElement | null>\n}) => (\n  <tr\n    ref={ref}\n    className={cn(\"transition-colors data-[state=selected]:bg-material-medium\", className)}\n    {...props}\n  />\n)\nTableRow.displayName = \"TableRow\"\n\nexport interface TableHeadProps\n  extends React.ThHTMLAttributes<HTMLTableCellElement>, VariantProps<typeof tableHeadVariants> {}\n\nconst TableHead = ({\n  ref,\n  className,\n  size,\n  ...props\n}: TableHeadProps & { ref?: React.Ref<HTMLTableCellElement | null> }) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"text-left align-middle font-medium text-text-secondary [&:has([role=checkbox])]:pr-0\",\n      tableHeadVariants({ size, className }),\n    )}\n    {...props}\n  />\n)\nTableHead.displayName = \"TableHead\"\n\nexport interface TableCellProps\n  extends React.TdHTMLAttributes<HTMLTableCellElement>, VariantProps<typeof tableHeadVariants> {}\n\nconst TableCell = ({\n  ref,\n  className,\n  size,\n  ...props\n}: TableCellProps & { ref?: React.Ref<HTMLTableCellElement | null> }) => (\n  <td\n    ref={ref}\n    className={cn(\n      \"align-middle [&:has([role=checkbox])]:pr-0\",\n      tableCellVariants({ size, className }),\n    )}\n    {...props}\n  />\n)\nTableCell.displayName = \"TableCell\"\n\nconst TableCaption = ({\n  ref,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLTableCaptionElement> & {\n  ref?: React.Ref<HTMLTableCaptionElement | null>\n}) => <caption ref={ref} className={cn(\"mt-4 text-sm text-text-secondary\", className)} {...props} />\nTableCaption.displayName = \"TableCaption\"\n\nexport { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }\n"
  },
  {
    "path": "packages/internal/components/src/ui/table/variants.tsx",
    "content": "import { cva } from \"class-variance-authority\"\n\nexport const tableHeadVariants = cva(\"\", {\n  variants: {\n    size: {\n      default: \"h-12 px-4\",\n      sm: \"h-6 px-3 font-normal text-zinc-800 dark:text-zinc-500\",\n    },\n  },\n  defaultVariants: {\n    size: \"default\",\n  },\n})\n\nexport const tableCellVariants = cva(\"\", {\n  variants: {\n    size: {\n      default: \"px-4 py-2\",\n      sm: \"py-1 pr-2 [&:last-child]:pr-0\",\n    },\n  },\n  defaultVariants: {\n    size: \"default\",\n  },\n})\n"
  },
  {
    "path": "packages/internal/components/src/ui/tabs/index.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { TabsProps } from \"@radix-ui/react-tabs\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\nimport { cva } from \"class-variance-authority\"\nimport * as React from \"react\"\n\nimport { ScrollArea } from \"../scroll-area\"\n\nconst TabsIdContext = React.createContext<string | null>(null)\nconst SetTabIndicatorContext = React.createContext<\n  React.Dispatch<\n    React.SetStateAction<{\n      w: number | undefined\n      x: number | undefined\n    }>\n  >\n>(() => {})\n\nconst TabVariantContext = React.createContext<\"default\" | \"rounded\" | undefined>(undefined)\n\nconst TabIndicatorContext = React.createContext<{\n  w: number | undefined\n  x: number | undefined\n} | null>(null)\n\nconst Tabs: ComponentWithRef<\n  TabsProps &\n    React.RefAttributes<HTMLDivElement> & {\n      variant?: \"default\" | \"rounded\"\n    },\n  HTMLDivElement\n> = ({ ref, ...props }) => {\n  const { children, variant, ...rest } = props\n  const [indicator, setIndicator] = React.useState<{\n    w: number | undefined\n    x: number | undefined\n  }>({\n    w: undefined,\n    x: undefined,\n  })\n  const id = React.useId()\n\n  return (\n    <TabsIdContext value={id}>\n      <SetTabIndicatorContext value={setIndicator}>\n        <TabsPrimitive.Root {...rest} ref={ref}>\n          <TabIndicatorContext value={indicator}>\n            <TabVariantContext value={variant}>{children}</TabVariantContext>\n          </TabIndicatorContext>\n        </TabsPrimitive.Root>\n      </SetTabIndicatorContext>\n    </TabsIdContext>\n  )\n}\n\nexport interface TabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> {}\nconst TabsList = ({\n  ref,\n  className,\n  ...props\n}: TabsListProps & {\n  ref?: React.Ref<React.ElementRef<typeof TabsPrimitive.List> | null>\n}) => {\n  const indicator = React.use(TabIndicatorContext)\n  const variant = React.use(TabVariantContext)\n\n  return (\n    <TabsPrimitive.List\n      ref={ref}\n      className={cn(\n        \"relative inline-flex items-center justify-center text-text-secondary\",\n        className,\n      )}\n    >\n      {props.children}\n      {indicator?.x !== undefined && (\n        <span\n          className={cn(\n            \"absolute left-0 duration-200 will-change-[transform,width]\",\n            variant === \"rounded\"\n              ? \"inset-0 z-0 h-full rounded-lg bg-material-medium group-hover:bg-theme-item-hover\"\n              : \"bottom-0 h-0.5 rounded bg-accent\",\n          )}\n          style={{\n            width: indicator.w,\n            transform: indicator.x ? `translate3d(${indicator.x}px, 0, 0)` : undefined,\n          }}\n        />\n      )}\n    </TabsPrimitive.List>\n  )\n}\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst tabsTriggerVariants = cva(\"\", {\n  variants: {\n    variant: {\n      default: \"py-1.5 border-b-2 border-transparent data-[state=active]:text-accent\",\n      rounded: \"!py-1 !px-3 bg-transparent\",\n    },\n  },\n  defaultVariants: {\n    variant: \"default\",\n  },\n})\n\nexport interface TabsTriggerProps extends React.ComponentPropsWithoutRef<\n  typeof TabsPrimitive.Trigger\n> {}\nconst TabsTrigger = ({\n  ref,\n  className,\n  children,\n  ...props\n}: TabsTriggerProps & { ref?: React.Ref<HTMLDivElement | null> }) => {\n  const variant = React.use(TabVariantContext)\n  const triggerRef = React.useRef<HTMLDivElement>(null)\n  React.useImperativeHandle(ref, () => triggerRef.current!, [])\n\n  const setIndicator = React.use(SetTabIndicatorContext)\n\n  React.useLayoutEffect(() => {\n    if (!triggerRef.current) return\n\n    const handler = () => {\n      const trigger = triggerRef.current as HTMLElement\n      const isSelect = trigger.dataset.state === \"active\"\n      if (isSelect) {\n        setIndicator({\n          w: trigger.clientWidth,\n          x: trigger.offsetLeft,\n        })\n      }\n    }\n\n    handler()\n    const trigger = triggerRef.current as HTMLElement\n    const ob = new MutationObserver(handler)\n    ob.observe(trigger, {\n      attributes: true,\n      attributeFilter: [\"data-state\"],\n    })\n\n    return () => {\n      ob.disconnect()\n    }\n  }, [setIndicator])\n\n  return (\n    <TabsPrimitive.Trigger\n      ref={triggerRef as any}\n      className={cn(\n        \"inline-flex items-center justify-center whitespace-nowrap px-3 text-sm font-medium ring-offset-background transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-text\",\n        \"group relative z-[1]\",\n        tabsTriggerVariants({ variant }),\n      )}\n      {...props}\n    >\n      {children}\n    </TabsPrimitive.Trigger>\n  )\n}\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = ({\n  ref,\n  className,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof TabsPrimitive.Content> | null>\n}) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\"mt-2 ring-offset-background focus-visible:outline-none\", className)}\n    {...props}\n  />\n)\n\nexport const TabsScrollAreaContent = ({\n  ref,\n  className,\n  scrollareaRootClassName,\n  viewportClassName,\n  children,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof TabsPrimitive.Content> | null>\n  scrollareaRootClassName?: string\n  viewportClassName?: string\n}) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\"mt-2 ring-offset-background focus-visible:outline-none\", className)}\n    {...props}\n  >\n    <ScrollArea.ScrollArea\n      rootClassName={cn(\"h-full\", scrollareaRootClassName)}\n      viewportClassName={cn(\"h-full\", viewportClassName)}\n    >\n      {children}\n    </ScrollArea.ScrollArea>\n  </TabsPrimitive.Content>\n)\n\nexport { Tabs, TabsContent, TabsList, TabsTrigger }\n"
  },
  {
    "path": "packages/internal/components/src/ui/toast/index.tsx",
    "content": "import { useIsDark } from \"@follow/hooks\"\nimport * as React from \"react\"\nimport { Toaster as Sonner } from \"sonner\"\n\nimport { ZIndexProvider } from \"../z-index\"\nimport { toastStyles } from \"./styles\"\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>\nconst TOAST_Z_INDEX = 999999999\n\nexport const Toaster = ({ ...props }: ToasterProps) => {\n  const isDark = useIsDark()\n\n  return (\n    <ZIndexProvider zIndex={TOAST_Z_INDEX}>\n      <Sonner\n        theme={isDark ? \"dark\" : \"light\"}\n        gap={12}\n        toastOptions={{\n          unstyled: true,\n          classNames: toastStyles,\n        }}\n        icons={{\n          success: <i className=\"i-mgc-check-circle-cute-re\" />,\n          error: <i className=\"i-mgc-close-cute-re\" />,\n          warning: <i className=\"i-mgc-warning-cute-re\" />,\n          info: <i className=\"i-mgc-information-cute-re\" />,\n          loading: <i className=\"i-mgc-loading-3-cute-re animate-spin\" />,\n        }}\n        {...props}\n      />\n    </ZIndexProvider>\n  )\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/toast/styles.ts",
    "content": "export const toastStyles = {\n  toast: tw`\n    group relative flex w-full items-center justify-between gap-3 rounded-2xl p-4\n    backdrop-blur-2xl duration-300 ease-out\n    max-w-md min-w-[320px]\n    z-[9999999999]\n    pointer-events-auto\n    font-theme\n    [&]:border [&]:border-solid\n    [&]:data-[type=default]:border-[rgba(255,92,0,0.2)]\n    [&]:data-[type=success]:border-[rgba(40,205,65,0.2)]\n    [&]:data-[type=error]:border-[rgba(255,69,58,0.2)]\n    [&]:data-[type=warning]:border-[rgba(255,149,0,0.2)]\n    [&]:data-[type=info]:border-[rgba(0,122,255,0.2)]\n    [&]:data-[type=loading]:border-[rgba(142,142,147,0.2)]\n    [&]:shadow-[0_8px_32px_rgba(255,92,0,0.08),0_4px_16px_rgba(255,92,0,0.06),0_2px_8px_rgba(0,0,0,0.1)]\n    [&]:data-[type=success]:shadow-[0_8px_32px_rgba(40,205,65,0.08),0_4px_16px_rgba(40,205,65,0.06),0_2px_8px_rgba(0,0,0,0.1)]\n    [&]:data-[type=error]:shadow-[0_8px_32px_rgba(255,69,58,0.08),0_4px_16px_rgba(255,69,58,0.06),0_2px_8px_rgba(0,0,0,0.1)]\n    [&]:data-[type=warning]:shadow-[0_8px_32px_rgba(255,149,0,0.08),0_4px_16px_rgba(255,149,0,0.06),0_2px_8px_rgba(0,0,0,0.1)]\n    [&]:data-[type=info]:shadow-[0_8px_32px_rgba(0,122,255,0.08),0_4px_16px_rgba(0,122,255,0.06),0_2px_8px_rgba(0,0,0,0.1)]\n    [&]:data-[type=loading]:shadow-[0_8px_32px_rgba(142,142,147,0.08),0_4px_16px_rgba(142,142,147,0.06),0_2px_8px_rgba(0,0,0,0.1)]\n    [&_.sonner-loader]:relative\n  `,\n  title: tw`\n    text-sm font-medium text-text\n    leading-tight\n  `,\n  description: tw`\n    text-xs text-text-secondary\n    leading-relaxed mt-1\n  `,\n  content: tw`\n    flex-1 min-w-0\n  `,\n  icon: tw`\n    flex-shrink-0 mt-0.5 size-5\n    [li[data-type=\"success\"]_&]:text-green\n    [li[data-type=\"error\"]_&]:text-red\n    [li[data-type=\"warning\"]_&]:text-orange\n    [li[data-type=\"info\"]_&]:text-blue\n    [li[data-type=\"loading\"]_&]:text-gray\n  `,\n  actionButton: tw`\n    shrink-0\n    h-6\n    px-2.5 text-xs font-medium rounded-md\n    transition-all duration-200\n    focus:outline-none focus:shadow-lg bg-accent\n    group-data-[type=success]:bg-green group-data-[type=success]:text-white group-data-[type=success]:hover:bg-green/90 group-data-[type=success]:focus:shadow-green/50\n    group-data-[type=error]:bg-red group-data-[type=error]:text-white group-data-[type=error]:hover:bg-red/90 group-data-[type=error]:focus:shadow-red/50\n    group-data-[type=warning]:bg-orange group-data-[type=warning]:text-white group-data-[type=warning]:hover:bg-orange/90 group-data-[type=warning]:focus:shadow-orange/50\n    group-data-[type=info]:bg-blue group-data-[type=info]:text-white group-data-[type=info]:hover:bg-blue/90 group-data-[type=info]:focus:shadow-blue/50\n    group-data-[type=loading]:bg-gray group-data-[type=loading]:text-white group-data-[type=loading]:hover:bg-gray/90 group-data-[type=loading]:focus:shadow-gray/50\n    hover:shadow-md active:scale-95\n  `,\n  cancelButton: tw`\n    h-6\n    px-2.5 text-xs font-medium rounded-md\n    bg-fill-secondary text-text-secondary\n    hover:bg-fill-tertiary hover:text-text\n    transition-colors duration-200\n    focus:outline-none focus:ring-2 focus:ring-fill/50 focus:ring-offset-1\n  `,\n  closeButton: tw`\n    absolute -top-2 -right-2 w-6 h-6 rounded-full\n    flex items-center justify-center\n    text-text\n    border border-border\n    backdrop-blur-background\n    bg-material-ultra-thick\n    transition-all duration-200\n    opacity-0 group-hover:opacity-100\n    focus:outline-none focus:ring-2\n    group-data-[type=default]:focus:ring-[rgba(255,92,0,0.5)]\n    group-data-[type=success]:focus:ring-[rgba(40,205,65,0.5)]\n    group-data-[type=error]:focus:ring-[rgba(255,69,58,0.5)]\n    group-data-[type=warning]:focus:ring-[rgba(255,149,0,0.5)]\n    group-data-[type=info]:focus:ring-[rgba(0,122,255,0.5)]\n    group-data-[type=loading]:focus:ring-[rgba(142,142,147,0.5)]\n    focus:opacity-100\n  `,\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/tooltip/index.tsx",
    "content": "import { Spring } from \"@follow/components/constants/spring.js\"\nimport { cn } from \"@follow/utils/utils\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\nimport { m } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { tooltipStyle, tooltipStyles } from \"./styles\"\n\nconst TooltipProvider = TooltipPrimitive.Provider\nconst TooltipRoot = TooltipPrimitive.Root\n\nconst Tooltip: typeof TooltipProvider = ({ children, ...props }) => (\n  <TooltipProvider delayDuration={200} skipDelayDuration={1000} {...props}>\n    <TooltipPrimitive.Tooltip>{children}</TooltipPrimitive.Tooltip>\n  </TooltipProvider>\n)\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst TooltipContent = ({\n  ref,\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {\n  ref?: React.Ref<React.ElementRef<typeof TooltipPrimitive.Content> | null>\n}) => (\n  <TooltipPrimitive.Content\n    ref={ref}\n    asChild\n    sideOffset={sideOffset}\n    className={cn(tooltipStyle.content, className)}\n    {...props}\n  >\n    <m.div\n      initial={{ opacity: 0.82, scale: 0.95 }}\n      animate={{ opacity: 1, scale: 1 }}\n      transition={Spring.snappy(0.1)}\n      style={tooltipStyles.container}\n    >\n      {/* Inner glow layer */}\n      <div\n        className=\"pointer-events-none absolute inset-0 rounded-lg\"\n        style={tooltipStyles.innerGlow}\n      />\n      {/* https://github.com/radix-ui/primitives/discussions/868 */}\n      <TooltipPrimitive.Arrow\n        className=\"z-50 [clip-path:inset(0_-10px_-10px_-10px)]\"\n        style={tooltipStyles.arrow}\n      />\n      <div className=\"relative\">{props.children}</div>\n    </m.div>\n  </TooltipPrimitive.Content>\n)\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nexport { Tooltip, TooltipContent, TooltipRoot, TooltipTrigger }\n\nexport { RootPortal as TooltipPortal } from \"../portal\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/tooltip/styles.ts",
    "content": "import type { CSSProperties } from \"react\"\n\nexport const tooltipStyle = {\n  content: [\n    \"relative z-[101] px-2 py-1 text-text\",\n    \"data-[state=closed]:animate-out data-[state=closed]:fade-out-0\",\n    \"rounded-lg text-sm backdrop-blur-2xl\",\n    \"max-w-[75ch] select-text\",\n    \"border border-solid\",\n  ],\n}\n\nexport const tooltipStyles: {\n  container: CSSProperties\n  innerGlow: CSSProperties\n  arrow: CSSProperties\n} = {\n  container: {\n    backgroundImage:\n      \"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))\",\n    borderColor: \"hsl(var(--fo-a) / 0.2)\",\n    boxShadow:\n      \"0 4px 16px hsl(var(--fo-a) / 0.08), 0 2px 8px hsl(var(--fo-a) / 0.06), 0 1px 4px rgba(0, 0, 0, 0.1)\",\n  },\n  innerGlow: {\n    background:\n      \"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.01), transparent, hsl(var(--fo-a) / 0.01))\",\n  },\n  arrow: {\n    fill: \"hsl(var(--fo-a) / 0.2)\",\n  },\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/typography/EllipsisWithTooltip.tsx",
    "content": "import { cn } from \"@follow/utils/utils\"\nimport type { PropsWithChildren } from \"react\"\nimport * as React from \"react\"\nimport { useEffect, useState } from \"react\"\n\nimport { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from \"../tooltip\"\n\nconst isTextOverflowed = (element: HTMLElement, dir: \"h\" | \"v\") => {\n  if (dir === \"h\") {\n    return element.offsetWidth < element.scrollWidth\n  } else {\n    return element.offsetHeight < element.scrollHeight\n  }\n}\ntype EllipsisProps = PropsWithChildren<{\n  width?: string\n  className?: string\n  disabled?: boolean\n  dir?: \"h\" | \"v\"\n}>\n\nexport const EllipsisTextWithTooltip = (props: EllipsisProps) => {\n  const { children, className, width, disabled, dir = \"v\" } = props\n\n  const [textElRef, setTextElRef] = useState<HTMLSpanElement | null>()\n  const [isOverflowed, setIsOverflowed] = useState(false)\n\n  const judgment = () => {\n    if (!textElRef) return\n\n    setIsOverflowed(isTextOverflowed(textElRef, dir))\n  }\n  useEffect(() => {\n    judgment()\n  }, [textElRef, children])\n\n  useEffect(() => {\n    if (!textElRef) return\n    const resizeObserver = new ResizeObserver(() => {\n      judgment()\n    })\n    resizeObserver.observe(textElRef)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [textElRef])\n\n  const Content = (\n    <span\n      className={className}\n      ref={setTextElRef}\n      style={\n        width\n          ? {\n              maxWidth: width,\n            }\n          : undefined\n      }\n    >\n      {children}\n    </span>\n  )\n\n  if (!isOverflowed || disabled) return Content\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{Content}</TooltipTrigger>\n\n      <TooltipPortal>\n        <TooltipContent>\n          <span className=\"whitespace-pre-line break-all\" onClick={(e) => e.stopPropagation()}>\n            {children}\n          </span>\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  )\n}\n\n/**\n * Show ellipses when horizontal text overflows and full text on hover.\n */\nexport const EllipsisHorizontalTextWithTooltip = (props: EllipsisProps) => {\n  const { className, ...rest } = props\n  return <EllipsisTextWithTooltip className={cn(\"block truncate\", className)} {...rest} dir=\"h\" />\n}\n"
  },
  {
    "path": "packages/internal/components/src/ui/typography/index.ts",
    "content": "export * from \"./EllipsisWithTooltip\"\n"
  },
  {
    "path": "packages/internal/components/src/ui/z-index/ctx.tsx",
    "content": "import { createContext, use } from \"react\"\n\nexport const ZIndexContext = createContext(0)\n\nexport const useCorrectZIndex = (zIndex: number) => use(ZIndexContext) + zIndex\n"
  },
  {
    "path": "packages/internal/components/src/ui/z-index/index.tsx",
    "content": "import * as React from \"react\"\n\nimport { ZIndexContext } from \"./ctx\"\n\nexport const ZIndexProvider: Component<{\n  zIndex: number\n}> = (props) => {\n  return <ZIndexContext value={props.zIndex}>{props.children}</ZIndexContext>\n}\n"
  },
  {
    "path": "packages/internal/components/src/utils/dayjs.ts",
    "content": "import dayjs from \"dayjs\"\nimport customParseFormat from \"dayjs/plugin/customParseFormat\"\nimport duration from \"dayjs/plugin/duration\"\nimport localizedFormat from \"dayjs/plugin/localizedFormat\"\nimport relativeTime from \"dayjs/plugin/relativeTime\"\n// Initialize dayjs\nexport const initializeDayjs = () => {\n  dayjs.extend(duration)\n  dayjs.extend(relativeTime)\n  dayjs.extend(localizedFormat)\n  dayjs.extend(customParseFormat)\n}\n"
  },
  {
    "path": "packages/internal/components/src/utils/icon.ts",
    "content": "import { getImageProxyUrl } from \"@follow/utils/img-proxy\"\nimport { getUrlIcon } from \"@follow/utils/utils\"\n\nexport const getFeedIconSrc = ({\n  src,\n  siteUrl,\n  fallback,\n  proxy,\n}: {\n  src?: string\n  siteUrl?: string\n  fallback?: boolean\n  proxy?: { height: number; width: number }\n} = {}) => {\n  if (src) {\n    if (proxy) {\n      return [\n        getImageProxyUrl({\n          url: src,\n          width: proxy.width,\n          height: proxy.height,\n        }),\n        \"\",\n      ]\n    }\n\n    return [src, \"\"]\n  }\n  if (!siteUrl) return [\"\", \"\"]\n  const ret = getUrlIcon(siteUrl, fallback)\n\n  return [ret.src, ret.fallbackUrl]\n}\n"
  },
  {
    "path": "packages/internal/components/src/utils/parse-markdown.tsx",
    "content": "import \"@microflash/remark-callout-directives/themes/github/index.css\"\nimport \"remark-gh-alerts/styles/github-colors-light.css\"\nimport \"remark-gh-alerts/styles/github-colors-dark-media.css\"\nimport \"remark-gh-alerts/styles/github-base.css\"\n\n// @ts-ignore\nimport remarkCalloutDirectives from \"@microflash/remark-callout-directives\"\nimport type { Components } from \"hast-util-to-jsx-runtime\"\nimport { toJsxRuntime } from \"hast-util-to-jsx-runtime\"\nimport { Fragment, jsx, jsxs } from \"react/jsx-runtime\"\nimport rehypeStringify from \"rehype-stringify\"\nimport remarkDirective from \"remark-directive\"\nimport remarkGfm from \"remark-gfm\"\nimport remarkGithubAlerts from \"remark-gh-alerts\"\nimport remarkParse from \"remark-parse\"\nimport remarkRehype from \"remark-rehype\"\nimport type { PluggableList, Processor } from \"unified\"\nimport { unified } from \"unified\"\n\nexport interface RemarkOptions {\n  components: Partial<Components>\n  applyMiddleware?: <T extends Processor<any, any, any, any, any>>(pipeline: T) => T\n  rehypePlugins?: PluggableList\n}\n\nexport const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {\n  const { components, applyMiddleware, rehypePlugins } = options || {}\n\n  let pipeline: Processor<any, any, any, any, any> = unified()\n    .use(remarkDirective)\n    .use(remarkParse)\n\n    .use(remarkGfm)\n    .use(remarkGithubAlerts)\n\n    .use(remarkCalloutDirectives, {\n      aliases: {\n        danger: \"deter\",\n        tip: \"note\",\n        warning: \"warn\",\n      },\n      callouts: {\n        note: {\n          title: \"Note\",\n          hint: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12 8h.01M12 12v4\"/><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>`,\n        },\n        commend: {\n          title: \"Success\",\n          hint: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"m8 12 2.7 2.7L16 9.3\"/><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>`,\n        },\n        warn: {\n          title: \"Warning\",\n          hint: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12 9v4m0 4h.01M8.681 4.082C9.351 2.797 10.621 2 12 2s2.649.797 3.319 2.082l6.203 11.904a4.28 4.28 0 0 1-.046 4.019C20.793 21.241 19.549 22 18.203 22H5.797c-1.346 0-2.59-.759-3.273-1.995a4.28 4.28 0 0 1-.046-4.019L8.681 4.082Z\"/></svg>`,\n        },\n        deter: {\n          title: \"Danger\",\n          hint: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12 12s-5.6 4.6-3.6 8c1.6 2.6 5.7 2.7 7.2 0 2-3.7-3.6-8-3.6-8Z\"/><path d=\"M13.004 2 8.5 9 6.001 6s-4.268 7.206-1.629 11.8c3.016 5.5 11.964 5.7 15.08 0C23.876 10 13.004 2 13.004 2Z\"/></svg>`,\n        },\n        assert: {\n          title: \"Info\",\n          hint: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12.5 7.5h.01m-.01 4v4m-7.926.685L2 21l6.136-1.949c1.307.606 2.791.949 4.364.949 5.243 0 9.5-3.809 9.5-8.5S17.743 3 12.5 3 3 6.809 3 11.5c0 1.731.579 3.341 1.574 4.685\"/></svg>`,\n        },\n      },\n    })\n\n  // Apply custom middleware if provided\n  if (applyMiddleware) {\n    pipeline = applyMiddleware(pipeline)\n  }\n\n  pipeline = pipeline.use(remarkRehype, { allowDangerousHtml: true })\n\n  if (rehypePlugins) {\n    pipeline = pipeline.use(rehypePlugins)\n  }\n\n  pipeline = pipeline.use(rehypeStringify, { allowDangerousHtml: true })\n\n  const tree = pipeline.parse(content)\n\n  const hastTree = pipeline.runSync(tree, content)\n\n  return {\n    content: toJsxRuntime(hastTree, {\n      Fragment,\n      ignoreInvalidStyle: true,\n      jsx: (type, props, key) => jsx(type as any, props, key),\n      jsxs: (type, props, key) => jsxs(type as any, props, key),\n      passNode: true,\n      components,\n    }),\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/src/utils/selector.tsx",
    "content": "import { useMobile } from \"@follow/components/hooks/useMobile.js\"\nimport { useViewport } from \"@follow/components/hooks/useViewport.js\"\nimport type { ComponentType, ReactNode, RefAttributes } from \"react\"\nimport { lazy, Suspense } from \"react\"\n\nexport function withResponsiveComponent<P extends object>(\n  desktopImport: () => Promise<{ default: ComponentType<P> }>,\n  mobileImport: () => Promise<{ default: ComponentType<P> }>,\n  fallbackElement?: ReactNode,\n): ComponentType<P>\nexport function withResponsiveComponent<P extends object>(\n  desktopImport: () => Promise<{ default: ComponentType<P> }>,\n  mobileImport: () => Promise<{ default: ComponentType<P> }>,\n  breakpointFn: (w: number) => boolean,\n  fallbackElement?: ReactNode,\n): ComponentType<P>\nexport function withResponsiveComponent<P extends object>(\n  desktopImport: () => Promise<{ default: ComponentType<P> }>,\n  mobileImport: () => Promise<{ default: ComponentType<P> }>,\n  fallbackElementOrBreakpointFn?: ReactNode | ((w: number) => boolean),\n  fallbackElement?: ReactNode,\n) {\n  const LazyDesktopLayout = lazy(desktopImport) as unknown as ComponentType<P>\n  const LazyMobileLayout = lazy(mobileImport) as unknown as ComponentType<P>\n\n  // Check if the third parameter is a function (breakpoint function) or ReactNode (fallback)\n  const isBreakpointFn = typeof fallbackElementOrBreakpointFn === \"function\"\n  const breakpointFn = isBreakpointFn ? fallbackElementOrBreakpointFn : undefined\n  const fallback = isBreakpointFn ? fallbackElement : fallbackElementOrBreakpointFn\n\n  return function ResponsiveLayout(props: P) {\n    const isMobile = useViewport(({ w: viewport }) =>\n      breakpointFn ? breakpointFn(viewport) : viewport < 1024 && viewport !== 0,\n    )\n\n    return (\n      <Suspense fallback={fallback}>\n        {isMobile ? <LazyMobileLayout {...props} /> : <LazyDesktopLayout {...props} />}\n      </Suspense>\n    )\n  }\n}\n\nexport function withResponsiveSyncComponent<P extends object, R = any>(\n  DesktopComponent: ComponentType<P & RefAttributes<R>>,\n  MobileComponent: ComponentType<P & RefAttributes<R>>,\n) {\n  return function ResponsiveLayout({\n    ref,\n    ...props\n  }: P & { ref?: React.Ref<R | null> | ((node: R | null) => void) }) {\n    const isMobile = useMobile()\n    const componentProps = { ...props } as P & RefAttributes<R>\n\n    return isMobile ? (\n      <MobileComponent {...componentProps} ref={ref} />\n    ) : (\n      <DesktopComponent {...componentProps} ref={ref} />\n    )\n  }\n}\n"
  },
  {
    "path": "packages/internal/components/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"jsx\": \"preserve\",\n    \"declaration\": true,\n    \"types\": [\"@follow/types/react\", \"@follow/types/global\", \"vite/client\"],\n    \"paths\": {\n      \"@follow/components/*\": [\"./src/*\"],\n      \"@pkg\": [\"../../package.json\"]\n    }\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/internal/constants/package.json",
    "content": "{\n  \"name\": \"@follow/constants\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"main\": \"./src/index.ts\",\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/constants/src/app.ts",
    "content": "export const APPLE_APP_STORE_ID = \"6739802604\"\nexport const GOOGLE_PLAY_PACKAGE_ID = \"is.follow\"\n\nexport const APP_STORE_URLS = {\n  iOS: `https://apps.apple.com/us/app/folo-follow-everything/id${APPLE_APP_STORE_ID}`,\n  Android: `https://play.google.com/store/apps/details?id=${GOOGLE_PLAY_PACKAGE_ID}`,\n} as const\n"
  },
  {
    "path": "packages/internal/constants/src/auth-providers.ts",
    "content": "export const authProvidersConfig = {\n  google: {\n    buttonClassName:\n      \"bg-blue-500 hover:!bg-blue-500/90 focus:!border-blue-500/80 focus:!ring-blue-500/80\",\n    iconClassName: \"i-mgc-google-cute-fi\",\n  },\n  github: {\n    buttonClassName: \"bg-black hover:!bg-black/90 focus:!border-black/80 focus:!ring-black/80\",\n    iconClassName: \"i-mgc-github-cute-fi\",\n  },\n  apple: {\n    buttonClassName:\n      \"bg-gray-800 hover:!bg-gray-800/90 focus:!border-gray-800/80 focus:!ring-gray-800/80\",\n    iconClassName: \"i-mgc-apple-cute-fi\",\n  },\n} as Record<\n  string,\n  {\n    buttonClassName: string\n    iconClassName: string\n  }\n>\n"
  },
  {
    "path": "packages/internal/constants/src/enums.ts",
    "content": "export { FeedViewType } from \"@follow-app/client-sdk\"\nexport enum Routes {\n  Timeline = \"/timeline\",\n  Discover = \"/discover\",\n}\n\nexport enum UserRole {\n  Admin = \"admin\",\n  Free = \"free\",\n  /**\n   * @deprecated\n   * @see UserRole.Free\n   */\n  // TODO: remove this\n  Trial = \"trial\",\n  Pro = \"pro\",\n  Plus = \"plus\",\n  Basic = \"basic\",\n}\n\nexport const UserRoleName: Record<UserRole, string> = {\n  [UserRole.Admin]: \"Admin\",\n  [UserRole.Free]: \"Free\",\n  /**\n   * @deprecated\n   * @see UserRole.Free\n   */\n  [UserRole.Trial]: \"Free\",\n  [UserRole.Pro]: \"Pro\",\n  [UserRole.Plus]: \"Plus\",\n  [UserRole.Basic]: \"Basic\",\n} as const\n\nexport const UserRolePriority: Record<UserRole, number> = {\n  [UserRole.Admin]: 4,\n  [UserRole.Pro]: 3,\n  [UserRole.Plus]: 2,\n  [UserRole.Basic]: 1,\n  [UserRole.Free]: 0,\n  [UserRole.Trial]: 0,\n} as const\n\nexport const isFreeRole = (role?: UserRole | null) => {\n  return role ? role === UserRole.Free || role === UserRole.Trial : true\n}\n"
  },
  {
    "path": "packages/internal/constants/src/index.ts",
    "content": "export * from \"./app\"\nexport * from \"./auth-providers\"\nexport * from \"./enums\"\nexport * from \"./rsshub\"\nexport * from \"./social\"\nexport * from \"./tabs\"\n"
  },
  {
    "path": "packages/internal/constants/src/rsshub.ts",
    "content": "export const RSSHubCategories = [\n  \"all\",\n  \"social-media\",\n  \"new-media\",\n  \"traditional-media\",\n  \"bbs\",\n  \"blog\",\n  \"programming\",\n  \"design\",\n  \"live\",\n  \"multimedia\",\n  \"picture\",\n  \"anime\",\n  \"program-update\",\n  // \"university\",\n  // \"forecast\",\n  // \"travel\",\n  \"shopping\",\n  \"game\",\n  \"reading\",\n  // \"government\",\n  // \"study\",\n  \"journal\",\n  \"finance\",\n] as const\n\nexport type RSSHubCategory = (typeof RSSHubCategories)[number]\n\nexport const CategoryMap: Record<RSSHubCategory, { color: string; emoji: string }> = {\n  all: { color: \"#FFCA28\", emoji: \"⭐️\" },\n  \"social-media\": { color: \"#42A5F5\", emoji: \"💬\" },\n  \"new-media\": { color: \"#FF7043\", emoji: \"📣\" },\n  \"traditional-media\": { color: \"#BDBDBD\", emoji: \"📰\" },\n  bbs: { color: \"#FFB74D\", emoji: \"💭\" },\n  blog: { color: \"#AB47BC\", emoji: \"✍️\" },\n  programming: { color: \"#66BB6A\", emoji: \"💻\" },\n  design: { color: \"#EC407A\", emoji: \"🎨\" },\n  live: { color: \"#EF5350\", emoji: \"📺\" },\n  multimedia: { color: \"#FF8A65\", emoji: \"🎞️\" },\n  picture: { color: \"#26A69A\", emoji: \"🖼️\" },\n  anime: { color: \"#F06292\", emoji: \"🌸\" },\n  \"program-update\": { color: \"#78909C\", emoji: \"🔄\" },\n  // university: { color: \"#5C6BC0\", emoji: \"🎓\" },\n  // forecast: { color: \"#4FC3F7\", emoji: \"⛅️\" },\n  // travel: { color: \"#4DD0E1\", emoji: \"✈️\" },\n  shopping: { color: \"#EEAE24\", emoji: \"🛍️\" },\n  game: { color: \"#7E57C2\", emoji: \"🎮\" },\n  reading: { color: \"#3F8BB7\", emoji: \"📖\" },\n  // government: { color: \"#90A4AE\", emoji: \"🏛️\" },\n  // study: { color: \"#FFD54F\", emoji: \"📚\" },\n  journal: { color: \"#A1887F\", emoji: \"📓\" },\n  finance: { color: \"#81C784\", emoji: \"💰\" },\n}\n"
  },
  {
    "path": "packages/internal/constants/src/social.ts",
    "content": "export const SocialMediaLinks = [\n  {\n    iconClassName: \"i-mgc-github-cute-fi text-[#000000] dark:text-[#ffffff]\",\n    label: \"GitHub\",\n    url: \"https://github.com/RSSNext/Folo\",\n  },\n  {\n    iconClassName: \"i-mgc-discord-cute-fi text-[#5865F2] dark:brightness-125\",\n    label: \"Discord\",\n    url: \"https://discord.gg/AwWcAQ7euc\",\n  },\n  {\n    iconClassName: \"i-mgc-social-x-cute-re text-[#000000] dark:text-[#ffffff]\",\n    label: \"X\",\n    url: \"https://x.com/intent/follow?screen_name=folo_is\",\n  },\n]\n"
  },
  {
    "path": "packages/internal/constants/src/tabs.tsx",
    "content": "import * as React from \"react\"\n\nimport { FeedViewType } from \"./enums\"\n\nexport interface ViewDefinition {\n  name:\n    | \"feed_view_type.all\"\n    | \"feed_view_type.articles\"\n    | \"feed_view_type.audios\"\n    | \"feed_view_type.notifications\"\n    | \"feed_view_type.pictures\"\n    | \"feed_view_type.social_media\"\n    | \"feed_view_type.videos\"\n  icon: React.JSX.Element\n  className: string\n  peerClassName: string\n  mentionClassName: string\n  backgroundClassName: string\n  translation: string\n  view: FeedViewType\n  wideMode?: boolean\n  gridMode?: boolean\n  activeColor: string\n  /** if it's switchable from other views to this view by user */\n  switchable: boolean\n}\n\nconst viewAll: ViewDefinition = {\n  name: \"feed_view_type.all\",\n  icon: <i className=\"i-mgc-bubble-cute-fi\" />,\n  className: \"text-folo\",\n  peerClassName: \"peer-checked:text-folo dark:peer-checked:text-folo\",\n  mentionClassName: \"bg-folo/10 text-folo border-folo/20 hover:bg-folo/20 hover:border-folo/30\",\n  backgroundClassName: \"bg-folo\",\n  translation: \"title,description,content\",\n  view: FeedViewType.All,\n  activeColor: \"#FF5C00\",\n  switchable: false,\n}\n\n/**\n * Subscription views only\n */\nconst views: ViewDefinition[] = [\n  {\n    name: \"feed_view_type.articles\",\n    icon: <i className=\"i-mgc-paper-cute-fi\" />,\n    className: \"text-lime-600 dark:text-lime-500\",\n    peerClassName: \"peer-checked:text-lime-600 dark:peer-checked:text-lime-500\",\n    mentionClassName:\n      \"bg-lime-600/10 text-lime-600 border-lime-600/20 hover:bg-lime-600/20 hover:border-lime-600/30\",\n    backgroundClassName: \"bg-lime-600\",\n    translation: \"title,description\",\n    view: FeedViewType.Articles,\n    activeColor: \"#FF5C00\",\n    switchable: true,\n  },\n  {\n    name: \"feed_view_type.social_media\",\n    icon: <i className=\"i-mgc-thought-cute-fi\" />,\n    className: \"text-sky-600 dark:text-sky-500\",\n    peerClassName: \"peer-checked:text-sky-600 peer-checked:dark:text-sky-500\",\n    mentionClassName:\n      \"bg-sky-600/10 text-sky-600 border-sky-600/20 hover:bg-sky-600/20 hover:border-sky-600/30\",\n    backgroundClassName: \"bg-sky-600\",\n    wideMode: true,\n    translation: \"content\",\n    view: FeedViewType.SocialMedia,\n    // sky-500\n    activeColor: \"#0ea5e9\",\n    switchable: true,\n  },\n  {\n    name: \"feed_view_type.pictures\",\n    icon: <i className=\"i-mgc-pic-cute-fi\" />,\n    className: \"text-green-600 dark:text-green-500\",\n    peerClassName: \"peer-checked:text-green-600 peer-checked:dark:text-green-500\",\n    mentionClassName:\n      \"bg-green-600/10 text-green-600 border-green-600/20 hover:bg-green-600/20 hover:border-green-600/30\",\n    backgroundClassName: \"bg-green-600\",\n    gridMode: true,\n    wideMode: true,\n    translation: \"title\",\n    view: FeedViewType.Pictures,\n    // green-500\n    activeColor: \"#22c55e\",\n    switchable: true,\n  },\n  {\n    name: \"feed_view_type.videos\",\n    icon: <i className=\"i-mgc-video-cute-fi\" />,\n    className: \"text-red-600 dark:text-red-500\",\n    peerClassName: \"peer-checked:text-red-600 peer-checked:dark:text-red-500\",\n    mentionClassName:\n      \"bg-red-600/10 text-red-600 border-red-600/20 hover:bg-red-600/20 hover:border-red-600/30\",\n    backgroundClassName: \"bg-red-600\",\n    gridMode: true,\n    wideMode: true,\n    translation: \"title\",\n    view: FeedViewType.Videos,\n    // red-500\n    activeColor: \"#ef4444\",\n    switchable: true,\n  },\n  {\n    name: \"feed_view_type.audios\",\n    icon: <i className=\"i-mgc-mic-cute-fi\" />,\n    className: \"text-purple-600 dark:text-purple-500\",\n    peerClassName: \"peer-checked:text-purple-600 peer-checked:dark:text-purple-500\",\n    mentionClassName:\n      \"bg-purple-600/10 text-purple-600 border-purple-600/20 hover:bg-purple-600/20 hover:border-purple-600/30\",\n    backgroundClassName: \"bg-purple-600\",\n    translation: \"title\",\n    view: FeedViewType.Audios,\n    // purple-500\n    activeColor: \"#a855f7\",\n    switchable: true,\n  },\n  {\n    name: \"feed_view_type.notifications\",\n    icon: <i className=\"i-mgc-announcement-cute-fi\" />,\n    className: \"text-yellow-600 dark:text-yellow-500\",\n    peerClassName: \"peer-checked:text-yellow-600 peer-checked:dark:text-yellow-500\",\n    mentionClassName:\n      \"bg-yellow-600/10 text-yellow-600 border-yellow-600/20 hover:bg-yellow-600/20 hover:border-yellow-600/30\",\n    backgroundClassName: \"bg-yellow-600\",\n    translation: \"title\",\n    view: FeedViewType.Notifications,\n    // yellow-500\n    activeColor: \"#eab308\",\n    switchable: true,\n  },\n]\n\nconst allViews = [viewAll, ...views]\n\nexport function getView(id: FeedViewType): ViewDefinition\nexport function getView(id: number): ViewDefinition | undefined\nexport function getView(id: FeedViewType | number): ViewDefinition | undefined {\n  return allViews.find((view) => view.view === id)\n}\n\nexport function getViewList(options: { includeAll?: boolean } = {}): ViewDefinition[] {\n  const { includeAll = false } = options\n  return includeAll ? allViews : views\n}\n"
  },
  {
    "path": "packages/internal/constants/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"declaration\": true,\n    \"types\": [\"@follow/types/react\", \"@follow/types/global\", \"vite/client\"],\n    \"paths\": {\n      \"@follow/constants/*\": [\"./src/*\"],\n      \"@pkg\": [\"../../package.json\"]\n    }\n  },\n  \"include\": [\"src/**/*\", \"../../../../locales/**/*.json\"]\n}\n"
  },
  {
    "path": "packages/internal/database/drizzle.config.ts",
    "content": "import { defineConfig } from \"drizzle-kit\"\n\nexport default defineConfig({\n  dialect: \"sqlite\",\n  driver: \"expo\",\n  schema: \"./src/schemas/index.ts\",\n  out: \"./src/drizzle\",\n})\n"
  },
  {
    "path": "packages/internal/database/package.json",
    "content": "{\n  \"name\": \"@follow/database\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"author\": \"Folo Team\",\n  \"license\": \"AGPL-3.0-only\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/follow\",\n    \"type\": \"git\"\n  },\n  \"sideEffects\": false,\n  \"exports\": {\n    \"./*\": {\n      \"types\": \"./src/*.ts\",\n      \"require\": \"./src/*.ts\",\n      \"import\": \"./src/*.ts\"\n    },\n    \"./schemas/*\": {\n      \"types\": \"./src/schemas/*.ts\",\n      \"require\": \"./src/schemas/*.ts\",\n      \"import\": \"./src/schemas/*.ts\"\n    },\n    \"./services/*\": {\n      \"types\": \"./src/services/*.ts\",\n      \"require\": \"./src/services/*.ts\",\n      \"import\": \"./src/services/*.ts\"\n    }\n  },\n  \"scripts\": {\n    \"generate\": \"drizzle-kit generate\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"ai\": \"6.0.85\",\n    \"drizzle-orm\": \"0.45.1\",\n    \"expo-sqlite\": \"16.0.10\",\n    \"sqlocal\": \"npm:@hyoban/sqlocal@0.14.1-fork.4\",\n    \"wa-sqlite\": \"git+https://github.com/rhashimoto/wa-sqlite.git#v1.0.8\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\",\n    \"drizzle-kit\": \"0.31.9\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/DatabaseSource.js",
    "content": "// https://github.com/rhashimoto/wa-sqlite/blob/4f2b7e8f87acef4e8d9902e6a131f48f656d023e/demo/file/service-worker.js#L48-L121\n\nimport * as VFS from \"wa-sqlite/src/VFS.js\"\n\n// This is a stateful source object for a ReadableStream.\nexport class DatabaseSource {\n  isDone\n\n  #vfs\n  #path\n  #fileId = Math.floor(Math.random() * 0x100000000)\n  #iOffset = 0\n  #bytesRemaining = 0\n\n  #onDone = []\n  #resolve\n  #reject\n\n  constructor(vfs, path) {\n    this.#vfs = vfs\n    this.#path = path\n    this.isDone = new Promise((resolve, reject) => {\n      this.#resolve = resolve\n      this.#reject = reject\n    }).finally(async () => {\n      while (this.#onDone.length > 0) {\n        await this.#onDone.pop()()\n      }\n    })\n  }\n\n  async start(controller) {\n    try {\n      // Open the file for reading.\n      const flags = VFS.SQLITE_OPEN_MAIN_DB | VFS.SQLITE_OPEN_READONLY\n      await check(this.#vfs.jOpen(this.#path, this.#fileId, flags, { setInt32() {} }))\n      this.#onDone.push(() => this.#vfs.jClose(this.#fileId))\n      await check(this.#vfs.jLock(this.#fileId, VFS.SQLITE_LOCK_SHARED))\n      this.#onDone.push(() => this.#vfs.jUnlock(this.#fileId, VFS.SQLITE_LOCK_NONE))\n\n      // Get the file size.\n      const fileSize = new DataView(new ArrayBuffer(8))\n      await check(this.#vfs.jFileSize(this.#fileId, fileSize))\n      this.#bytesRemaining = Number(fileSize.getBigUint64(0, true))\n    } catch (e) {\n      controller.error(e)\n      this.#reject(e)\n    }\n  }\n\n  async pull(controller) {\n    try {\n      const buffer = new Uint8Array(Math.min(this.#bytesRemaining, 65536))\n      await check(this.#vfs.jRead(this.#fileId, buffer, this.#iOffset))\n\n      // The stream may have been cancelled between the async read and now\n      if (controller.desiredSize === null) {\n        this.#reject(new Error(\"Stream cancelled during pull\"))\n        return\n      }\n\n      controller.enqueue(buffer)\n\n      this.#iOffset += buffer.byteLength\n      this.#bytesRemaining -= buffer.byteLength\n      if (this.#bytesRemaining === 0) {\n        controller.close()\n        this.#resolve()\n      }\n    } catch (e) {\n      try {\n        controller.error(e)\n      } catch {\n        // Controller may already be in error/closed state\n      }\n      this.#reject(e)\n    }\n  }\n\n  cancel(reason) {\n    this.#reject(new Error(reason))\n  }\n}\n\nasync function check(code) {\n  if ((await code) !== VFS.SQLITE_OK) {\n    throw new Error(`Error code: ${await code}`)\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/ResourceLock.ts",
    "content": "class ResourceLock {\n  private locked = false\n  private queue: Array<() => void> = []\n\n  acquire(): Promise<() => void> {\n    return new Promise((resolve) => {\n      const tryAcquire = () => {\n        if (!this.locked) {\n          this.locked = true\n          resolve(this.release.bind(this))\n        } else {\n          this.queue.push(tryAcquire)\n        }\n      }\n      tryAcquire()\n    })\n  }\n\n  private release() {\n    this.locked = false\n    const next = this.queue.shift()\n    if (next) {\n      next()\n    }\n  }\n}\n\nexport const resourceLock = new ResourceLock()\n"
  },
  {
    "path": "packages/internal/database/src/constant.ts",
    "content": "export const SQLITE_DB_NAME = \"follow.db\"\n"
  },
  {
    "path": "packages/internal/database/src/db.desktop.ts",
    "content": "import type { SqliteRemoteDatabase } from \"drizzle-orm/sqlite-proxy\"\nimport { drizzle } from \"drizzle-orm/sqlite-proxy\"\n// @ts-expect-error\nimport SQLiteESMFactory from \"wa-sqlite/dist/wa-sqlite-async.mjs\"\n// @ts-expect-error\nimport { IDBMirrorVFS as MyVFS } from \"wa-sqlite/src/examples/IDBMirrorVFS.js\"\n// @ts-expect-error\nimport * as SQLite from \"wa-sqlite/src/sqlite-api.js\"\n\nimport { SQLITE_DB_NAME } from \"./constant\"\nimport { DatabaseSource } from \"./DatabaseSource\"\nimport migrations from \"./drizzle/migrations\"\nimport { migrate } from \"./migrator\"\nimport { resourceLock } from \"./ResourceLock\"\nimport * as schema from \"./schemas\"\n\nlet db: SqliteRemoteDatabase<typeof schema>\n\nconst IDB_NAME = \"WA_SQLITE\"\n\nexport async function initializeDB() {\n  const module = await SQLiteESMFactory()\n  const sqlite3 = SQLite.Factory(module)\n  const vfs = await MyVFS.create(IDB_NAME, module)\n  sqlite3.vfs_register(vfs, true)\n  const dbSqlite3 = await sqlite3.open_v2(SQLITE_DB_NAME)\n\n  db = drizzle(\n    async (sql, params, method) => {\n      let releaseLock: (() => void) | undefined\n      const query = async function (db: any, sql: any) {\n        releaseLock = await resourceLock.acquire()\n        const rows: any[] = []\n\n        for await (const stmt of sqlite3.statements(db, sql)) {\n          if (Array.isArray(params) && params.length > 0) {\n            sqlite3.bind_collection(stmt, params)\n          }\n\n          while ((await sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {\n            const row = sqlite3.row(stmt)\n            rows.push(row)\n          }\n        }\n\n        return rows\n      }\n\n      try {\n        const rows = await query(dbSqlite3, sql)\n        if (method === \"get\") {\n          if (rows.length > 0) {\n            return { rows: rows[0] }\n          }\n\n          return { rows: undefined }\n        }\n\n        return { rows }\n      } catch (error) {\n        console.error(`Error executing SQL: ${sql} with params:${params}`, error)\n        return { rows: [] }\n      } finally {\n        releaseLock && releaseLock()\n      }\n    },\n    {\n      schema,\n      logger: false,\n    },\n  )\n}\nexport { db }\n\nexport async function migrateDB() {\n  try {\n    await migrate(db, migrations)\n  } catch (error) {\n    console.error(\"Failed to migrate database:\", error)\n\n    await deleteDB()\n    await migrate(db, migrations)\n  }\n}\nexport async function getDBFile() {\n  const module = await SQLiteESMFactory()\n  const vfs = await MyVFS.create(IDB_NAME, module)\n  const source = new DatabaseSource(vfs, SQLITE_DB_NAME)\n  source.isDone.finally(() => {\n    vfs.close()\n  })\n  const response = new Response(new ReadableStream(source), {\n    headers: {\n      \"Content-Type\": \"application/vnd.sqlite3\",\n      \"Content-Disposition\": `attachment; filename=\"${SQLITE_DB_NAME}\"`,\n    },\n  })\n  const databaseFile = await response.blob()\n  return databaseFile\n}\n\nexport async function exportDB() {\n  const databaseFile = await getDBFile()\n  const fileUrl = URL.createObjectURL(databaseFile)\n\n  const a = document.createElement(\"a\")\n  a.href = fileUrl\n  a.download = SQLITE_DB_NAME\n  a.click()\n  a.remove()\n\n  URL.revokeObjectURL(fileUrl)\n}\n\nexport async function deleteDB() {\n  indexedDB.deleteDatabase(IDB_NAME)\n}\n"
  },
  {
    "path": "packages/internal/database/src/db.rn.ts",
    "content": "import type { ExpoSQLiteDatabase } from \"drizzle-orm/expo-sqlite\"\nimport { drizzle } from \"drizzle-orm/expo-sqlite\"\nimport * as SQLite from \"expo-sqlite\"\n\nimport { SQLITE_DB_NAME } from \"./constant\"\nimport migrations from \"./drizzle/migrations\"\nimport { migrateExpoSQLite } from \"./migrator\"\nimport * as schema from \"./schemas\"\n\nexport let sqlite = SQLite.openDatabaseSync(SQLITE_DB_NAME)\n\nlet db: ExpoSQLiteDatabase<typeof schema> & {\n  $client: SQLite.SQLiteDatabase\n}\n\nexport function initializeDB() {\n  db = drizzle(sqlite, {\n    schema,\n    logger: false,\n  })\n}\nexport { db }\n\nexport async function migrateDB(): Promise<void> {\n  try {\n    await migrateExpoSQLite(sqlite, migrations)\n  } catch (error) {\n    console.error(\"Failed to migrate database:\", error)\n    await deleteDB()\n    sqlite = SQLite.openDatabaseSync(SQLITE_DB_NAME)\n    initializeDB()\n    await migrateExpoSQLite(sqlite, migrations)\n  }\n}\n\nexport async function getDBFile() {}\nexport async function exportDB() {}\nexport async function deleteDB() {\n  try {\n    sqlite.closeSync()\n  } catch {\n    /* empty */\n  }\n  SQLite.deleteDatabaseSync(SQLITE_DB_NAME)\n}\n"
  },
  {
    "path": "packages/internal/database/src/db.ts",
    "content": "import type { BaseSQLiteDatabase } from \"drizzle-orm/sqlite-core\"\n\nimport type * as schema from \"./schemas\"\nimport type { DB } from \"./types\"\n\nexport declare const sqlite: unknown\nexport declare const db: DB\nexport declare function initializeDB(): Promise<void>\nexport declare function migrateDB(): Promise<void>\nexport declare function getDBFile(): Promise<Blob>\nexport declare function exportDB(): Promise<void>\n/**\n * Deletes the database file, normally you should reload the app after calling this function.\n */\nexport declare function deleteDB(): Promise<void>\n\nexport type AsyncDb = BaseSQLiteDatabase<\"async\", any, typeof schema>\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0000_harsh_shiva.sql",
    "content": "CREATE TABLE `feeds` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`title` text,\n\t`url` text NOT NULL,\n\t`description` text,\n\t`image` text,\n\t`error_at` text,\n\t`site_url` text,\n\t`owner_user_id` text,\n\t`error_message` text\n);\n--> statement-breakpoint\nCREATE TABLE `inboxes` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`user_id` text NOT NULL,\n\t`title` text\n);\n--> statement-breakpoint\nCREATE TABLE `lists` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`user_id` text NOT NULL,\n\t`title` text NOT NULL,\n\t`feed_ids` text,\n\t`description` text,\n\t`view` integer NOT NULL,\n\t`image` text,\n\t`fee` integer,\n\t`owner_user_id` text\n);\n--> statement-breakpoint\nCREATE TABLE `subscriptions` (\n\t`feed_id` text,\n\t`list_id` text,\n\t`inbox_id` text,\n\t`user_id` text NOT NULL,\n\t`view` integer NOT NULL,\n\t`is_private` integer NOT NULL,\n\t`title` text,\n\t`category` text,\n\t`created_at` text,\n\t`type` text NOT NULL,\n\t`id` text PRIMARY KEY NOT NULL\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0001_bored_hobgoblin.sql",
    "content": "ALTER TABLE `inboxes` DROP COLUMN `user_id`;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0002_smart_power_man.sql",
    "content": "CREATE TABLE `unread` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`subscription_id` text NOT NULL,\n\t`count` integer NOT NULL\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0003_known_roland_deschain.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_unread` (\n\t`subscription_id` text PRIMARY KEY NOT NULL,\n\t`count` integer NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_unread`(\"subscription_id\", \"count\") SELECT \"subscription_id\", \"count\" FROM `unread`;--> statement-breakpoint\nDROP TABLE `unread`;--> statement-breakpoint\nALTER TABLE `__new_unread` RENAME TO `unread`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0004_majestic_thunderbolt_ross.sql",
    "content": "CREATE TABLE `users` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`email` text NOT NULL,\n\t`handle` text,\n\t`name` text,\n\t`image` text,\n\t`is_me` integer NOT NULL\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0005_tense_sleepwalker.sql",
    "content": "CREATE TABLE `entries` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`title` text,\n\t`url` text,\n\t`content` text,\n\t`description` text,\n\t`guid` text NOT NULL,\n\t`author` text,\n\t`author_url` text,\n\t`author_avatar` text,\n\t`inserted_at` integer NOT NULL,\n\t`published_at` integer NOT NULL,\n\t`media` text,\n\t`categories` text,\n\t`attachments` text,\n\t`extra` text,\n\t`language` text\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0006_exotic_kid_colt.sql",
    "content": "ALTER TABLE `entries` ADD `feed_id` text;--> statement-breakpoint\nALTER TABLE `entries` ADD `inbox_handle` text;--> statement-breakpoint\nALTER TABLE `entries` ADD `read` integer;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0007_curvy_tarantula.sql",
    "content": "ALTER TABLE `lists` ADD `entry_ids` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0008_last_the_santerians.sql",
    "content": "CREATE TABLE `collections` (\n\t`feed_id` text,\n\t`entry_id` text PRIMARY KEY NOT NULL,\n\t`created_at` text,\n\t`view` integer NOT NULL\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0009_lucky_power_man.sql",
    "content": "CREATE TABLE `summaries` (\n\t`entry_id` text PRIMARY KEY NOT NULL,\n\t`summary` text,\n\t`created_at` text,\n\t`language` text\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0010_legal_ben_grimm.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_summaries` (\n\t`entry_id` text PRIMARY KEY NOT NULL,\n\t`summary` text NOT NULL,\n\t`created_at` text,\n\t`language` text NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_summaries`(\"entry_id\", \"summary\", \"created_at\", \"language\") SELECT \"entry_id\", \"summary\", \"created_at\", \"language\" FROM `summaries`;--> statement-breakpoint\nDROP TABLE `summaries`;--> statement-breakpoint\nALTER TABLE `__new_summaries` RENAME TO `summaries`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `unq` ON `summaries` (`entry_id`,`language`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0011_mysterious_stark_industries.sql",
    "content": "CREATE TABLE `images` (\n\t`url` text PRIMARY KEY NOT NULL,\n\t`colors` text NOT NULL,\n\t`created_at` integer DEFAULT (CURRENT_TIMESTAMP)\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0012_magenta_thing.sql",
    "content": "ALTER TABLE `entries` ADD `sources` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0013_chunky_stephen_strange.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_users` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`email` text,\n\t`handle` text,\n\t`name` text,\n\t`image` text,\n\t`is_me` integer NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_users`(\"id\", \"email\", \"handle\", \"name\", \"image\", \"is_me\") SELECT \"id\", \"email\", \"handle\", \"name\", \"image\", \"is_me\" FROM `users`;--> statement-breakpoint\nDROP TABLE `users`;--> statement-breakpoint\nALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0014_chemical_shocker.sql",
    "content": "ALTER TABLE `lists` DROP COLUMN `entry_ids`;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0015_colorful_warbird.sql",
    "content": "ALTER TABLE `entries` ADD `source_content` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0016_curious_carnage.sql",
    "content": "ALTER TABLE `entries` ADD `settings` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0017_talented_captain_cross.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_summaries` (\n\t`entry_id` text PRIMARY KEY NOT NULL,\n\t`summary` text NOT NULL,\n\t`created_at` text,\n\t`language` text\n);\n--> statement-breakpoint\nINSERT INTO `__new_summaries`(\"entry_id\", \"summary\", \"created_at\", \"language\") SELECT \"entry_id\", \"summary\", \"created_at\", \"language\" FROM `summaries`;--> statement-breakpoint\nDROP TABLE `summaries`;--> statement-breakpoint\nALTER TABLE `__new_summaries` RENAME TO `summaries`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `unq` ON `summaries` (`entry_id`,`language`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0018_dashing_the_fury.sql",
    "content": "CREATE TABLE `translations` (\n\t`entry_id` text PRIMARY KEY NOT NULL,\n\t`language` text NOT NULL,\n\t`title` text NOT NULL,\n\t`description` text NOT NULL,\n\t`content` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `translation-unique-index` ON `translations` (`entry_id`,`language`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0019_wonderful_shape.sql",
    "content": "ALTER TABLE `users` ADD `email_verified` integer;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0020_little_marauders.sql",
    "content": "ALTER TABLE `summaries` ADD `readability_summary` text;--> statement-breakpoint\nALTER TABLE `translations` ADD `readability_content` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0021_wakeful_onslaught.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_translations` (\n\t`entry_id` text PRIMARY KEY NOT NULL,\n\t`language` text NOT NULL,\n\t`title` text,\n\t`description` text,\n\t`content` text,\n\t`readability_content` text,\n\t`created_at` text NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_translations`(\"entry_id\", \"language\", \"title\", \"description\", \"content\", \"readability_content\", \"created_at\") SELECT \"entry_id\", \"language\", \"title\", \"description\", \"content\", \"readability_content\", \"created_at\" FROM `translations`;--> statement-breakpoint\nDROP TABLE `translations`;--> statement-breakpoint\nALTER TABLE `__new_translations` RENAME TO `translations`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `translation-unique-index` ON `translations` (`entry_id`,`language`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0022_tiny_northstar.sql",
    "content": "ALTER TABLE `lists` ADD `subscription_count` integer;--> statement-breakpoint\nALTER TABLE `lists` ADD `purchase_amount` text;\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0023_pink_namor.sql",
    "content": "ALTER TABLE `feeds` ADD `subscription_count` integer;--> statement-breakpoint\nALTER TABLE `feeds` ADD `updates_per_week` integer;--> statement-breakpoint\nALTER TABLE `feeds` ADD `latest_entry_published_at` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0024_spooky_alex_power.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_lists` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`user_id` text,\n\t`title` text NOT NULL,\n\t`feed_ids` text,\n\t`description` text,\n\t`view` integer NOT NULL,\n\t`image` text,\n\t`fee` integer,\n\t`owner_user_id` text,\n\t`subscription_count` integer,\n\t`purchase_amount` text\n);\n--> statement-breakpoint\nINSERT INTO `__new_lists`(\"id\", \"user_id\", \"title\", \"feed_ids\", \"description\", \"view\", \"image\", \"fee\", \"owner_user_id\", \"subscription_count\", \"purchase_amount\") SELECT \"id\", \"user_id\", \"title\", \"feed_ids\", \"description\", \"view\", \"image\", \"fee\", \"owner_user_id\", \"subscription_count\", \"purchase_amount\" FROM `lists`;--> statement-breakpoint\nDROP TABLE `lists`;--> statement-breakpoint\nALTER TABLE `__new_lists` RENAME TO `lists`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE TABLE `__new_users` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`email` text,\n\t`handle` text,\n\t`name` text,\n\t`image` text,\n\t`is_me` integer,\n\t`email_verified` integer\n);\n--> statement-breakpoint\nINSERT INTO `__new_users`(\"id\", \"email\", \"handle\", \"name\", \"image\", \"is_me\", \"email_verified\") SELECT \"id\", \"email\", \"handle\", \"name\", \"image\", \"is_me\", \"email_verified\" FROM `users`;--> statement-breakpoint\nDROP TABLE `users`;--> statement-breakpoint\nALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint\nALTER TABLE `feeds` ADD `tip_users` text;--> statement-breakpoint\nALTER TABLE `feeds` ADD `published_at` integer;--> statement-breakpoint\nALTER TABLE `inboxes` ADD `secret` text DEFAULT '' NOT NULL;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0025_colorful_valkyrie.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_inboxes` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`title` text,\n\t`secret` text NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_inboxes`(\"id\", \"title\", \"secret\") SELECT \"id\", \"title\", \"secret\" FROM `inboxes`;--> statement-breakpoint\nDROP TABLE `inboxes`;--> statement-breakpoint\nALTER TABLE `__new_inboxes` RENAME TO `inboxes`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0026_numerous_slyde.sql",
    "content": "ALTER TABLE `users` ADD `bio` text;--> statement-breakpoint\nALTER TABLE `users` ADD `website` text;--> statement-breakpoint\nALTER TABLE `users` ADD `social_links` text;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0027_nostalgic_human_torch.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_images` (\n\t`url` text PRIMARY KEY NOT NULL,\n\t`colors` text NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_images`(\"url\", \"colors\", \"created_at\") SELECT \"url\", \"colors\", \"created_at\" FROM `images`;--> statement-breakpoint\nDROP TABLE `images`;--> statement-breakpoint\nALTER TABLE `__new_images` RENAME TO `images`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0028_chief_cyclops.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_summaries` (\n\t`entry_id` text NOT NULL,\n\t`summary` text NOT NULL,\n\t`readability_summary` text,\n\t`created_at` text,\n\t`language` text\n);\n--> statement-breakpoint\nINSERT INTO `__new_summaries`(\"entry_id\", \"summary\", \"readability_summary\", \"created_at\", \"language\") SELECT \"entry_id\", \"summary\", \"readability_summary\", \"created_at\", \"language\" FROM `summaries`;--> statement-breakpoint\nDROP TABLE `summaries`;--> statement-breakpoint\nALTER TABLE `__new_summaries` RENAME TO `summaries`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `unq` ON `summaries` (`entry_id`,`language`);--> statement-breakpoint\nCREATE TABLE `__new_translations` (\n\t`entry_id` text NOT NULL,\n\t`language` text NOT NULL,\n\t`title` text,\n\t`description` text,\n\t`content` text,\n\t`readability_content` text,\n\t`created_at` text NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_translations`(\"entry_id\", \"language\", \"title\", \"description\", \"content\", \"readability_content\", \"created_at\") SELECT \"entry_id\", \"language\", \"title\", \"description\", \"content\", \"readability_content\", \"created_at\" FROM `translations`;--> statement-breakpoint\nDROP TABLE `translations`;--> statement-breakpoint\nALTER TABLE `__new_translations` RENAME TO `translations`;--> statement-breakpoint\nCREATE UNIQUE INDEX `translation-unique-index` ON `translations` (`entry_id`,`language`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0029_flaky_gorgon.sql",
    "content": "CREATE TABLE `ai_chat_messages` (\n\t`room_id` text NOT NULL,\n\t`id` text PRIMARY KEY NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`message` blob,\n\tFOREIGN KEY (`room_id`) REFERENCES `ai_chat`(`room_id`) ON UPDATE no action ON DELETE no action\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `ai_chat_messages_unq` ON `ai_chat_messages` (`room_id`,`id`);--> statement-breakpoint\nCREATE TABLE `ai_chat` (\n\t`room_id` text PRIMARY KEY NOT NULL,\n\t`title` text,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL\n);\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0030_common_gabe_jones.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_ai_chat_messages` (\n\t`room_id` text NOT NULL,\n\t`id` text PRIMARY KEY NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`message` text NOT NULL,\n\tFOREIGN KEY (`room_id`) REFERENCES `ai_chat`(`room_id`) ON UPDATE no action ON DELETE no action\n);\n--> statement-breakpoint\nINSERT INTO `__new_ai_chat_messages`(\"room_id\", \"id\", \"created_at\", \"message\") SELECT \"room_id\", \"id\", \"created_at\", \"message\" FROM `ai_chat_messages`;--> statement-breakpoint\nDROP TABLE `ai_chat_messages`;--> statement-breakpoint\nALTER TABLE `__new_ai_chat_messages` RENAME TO `ai_chat_messages`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `ai_chat_messages_unq` ON `ai_chat_messages` (`room_id`,`id`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0031_kind_ikaris.sql",
    "content": "ALTER TABLE `entries` ADD `readability_updated_at` integer;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0032_orange_prima.sql",
    "content": "ALTER TABLE `subscriptions` ADD `hide_from_timeline` integer;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0033_shiny_sebastian_shaw.sql",
    "content": "ALTER TABLE `ai_chat` RENAME TO `ai_chat_sessions`;--> statement-breakpoint\nALTER TABLE `ai_chat_sessions` RENAME COLUMN \"room_id\" TO \"id\";--> statement-breakpoint\nALTER TABLE `ai_chat_sessions` ADD `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL;--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_sessions_updated_at` ON `ai_chat_sessions` (`updated_at`);--> statement-breakpoint\nPRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_ai_chat_messages` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`chat_id` text NOT NULL,\n\t`role` text NOT NULL,\n\t`rich_text_schema` text,\n\t`created_at` integer,\n\t`metadata` text,\n\t`status` text DEFAULT 'completed',\n\t`finished_at` integer,\n\t`message_parts` text,\n\tFOREIGN KEY (`chat_id`) REFERENCES `ai_chat_sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nINSERT INTO `__new_ai_chat_messages`(\"id\", \"chat_id\", \"role\", \"rich_text_schema\", \"created_at\", \"metadata\", \"status\", \"finished_at\", \"message_parts\") SELECT \"id\", \"chat_id\", \"role\", \"rich_text_schema\", \"created_at\", \"metadata\", \"status\", \"finished_at\", \"message_parts\" FROM `ai_chat_messages`;--> statement-breakpoint\nDROP TABLE `ai_chat_messages`;--> statement-breakpoint\nALTER TABLE `__new_ai_chat_messages` RENAME TO `ai_chat_messages`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_messages_chat_id_created_at` ON `ai_chat_messages` (`chat_id`,`created_at`);--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_messages_status` ON `ai_chat_messages` (`status`);--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_messages_chat_id_role` ON `ai_chat_messages` (`chat_id`,`role`);"
  },
  {
    "path": "packages/internal/database/src/drizzle/0034_curly_darkstar.sql",
    "content": "ALTER TABLE `ai_chat_messages` DROP COLUMN `rich_text_schema`;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0035_last_valeria_richards.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_ai_chat_messages` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`chat_id` text NOT NULL,\n\t`role` text NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`metadata` text,\n\t`status` text DEFAULT 'completed',\n\t`finished_at` integer,\n\t`message_parts` text,\n\tFOREIGN KEY (`chat_id`) REFERENCES `ai_chat_sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nINSERT INTO `__new_ai_chat_messages`(\"id\", \"chat_id\", \"role\", \"created_at\", \"metadata\", \"status\", \"finished_at\", \"message_parts\") SELECT \"id\", \"chat_id\", \"role\", \"created_at\", \"metadata\", \"status\", \"finished_at\", \"message_parts\" FROM `ai_chat_messages`;--> statement-breakpoint\nDROP TABLE `ai_chat_messages`;--> statement-breakpoint\nALTER TABLE `__new_ai_chat_messages` RENAME TO `ai_chat_messages`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_messages_chat_id_created_at` ON `ai_chat_messages` (`chat_id`,`created_at`);--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_messages_status` ON `ai_chat_messages` (`status`);--> statement-breakpoint\nCREATE INDEX `idx_ai_chat_messages_chat_id_role` ON `ai_chat_messages` (`chat_id`,`role`);--> statement-breakpoint\nALTER TABLE `ai_chat_sessions` ADD `is_local` integer DEFAULT false NOT NULL;"
  },
  {
    "path": "packages/internal/database/src/drizzle/0036_entry_tag_summary.sql",
    "content": "ALTER TABLE `entries` ADD `tags` text;--> statement-breakpoint\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/0037_bored_the_leader.sql",
    "content": "ALTER TABLE `entries` DROP COLUMN `tags`;"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0000_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"f277136a-3ff5-4d70-8509-c1f40f0aa4ca\",\n  \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n  \"tables\": {\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0001_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"03bb7af3-104a-4220-aa4d-46abfac83303\",\n  \"prevId\": \"f277136a-3ff5-4d70-8509-c1f40f0aa4ca\",\n  \"tables\": {\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0002_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"38eb9a81-9c8d-4f2f-8c98-8f065decf7ed\",\n  \"prevId\": \"03bb7af3-104a-4220-aa4d-46abfac83303\",\n  \"tables\": {\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0003_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"b74f8057-78c5-4675-94dd-e41d94674ef7\",\n  \"prevId\": \"38eb9a81-9c8d-4f2f-8c98-8f065decf7ed\",\n  \"tables\": {\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0004_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"fc790c48-4ade-4648-a1b4-0a3e9faf65c5\",\n  \"prevId\": \"b74f8057-78c5-4675-94dd-e41d94674ef7\",\n  \"tables\": {\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0005_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"babbc945-5d80-431e-b106-da8cfac9f77d\",\n  \"prevId\": \"fc790c48-4ade-4648-a1b4-0a3e9faf65c5\",\n  \"tables\": {\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0006_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"9f943ca5-ddaf-4916-98cd-d3e3f7ead201\",\n  \"prevId\": \"babbc945-5d80-431e-b106-da8cfac9f77d\",\n  \"tables\": {\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0007_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"0d5c6f89-f31c-405c-8c2d-7b59ab03a275\",\n  \"prevId\": \"9f943ca5-ddaf-4916-98cd-d3e3f7ead201\",\n  \"tables\": {\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0008_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"25bbf165-d945-4ec7-9571-b7bd36a7b9b5\",\n  \"prevId\": \"0d5c6f89-f31c-405c-8c2d-7b59ab03a275\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0009_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"a6ae006a-3112-4456-aac4-3704ae53a349\",\n  \"prevId\": \"25bbf165-d945-4ec7-9571-b7bd36a7b9b5\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0010_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"b19d005b-f43c-474b-99bc-f9aa04dd28dd\",\n  \"prevId\": \"a6ae006a-3112-4456-aac4-3704ae53a349\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0011_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"6268a018-0d72-4aa1-9e39-6caab324682e\",\n  \"prevId\": \"b19d005b-f43c-474b-99bc-f9aa04dd28dd\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0012_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"79459566-8faa-4e7c-bbb0-666e4a8bae92\",\n  \"prevId\": \"6268a018-0d72-4aa1-9e39-6caab324682e\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0013_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"9859ecf0-4bcb-4d5a-90f0-66ceb1c133a5\",\n  \"prevId\": \"79459566-8faa-4e7c-bbb0-666e4a8bae92\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_ids\": {\n          \"name\": \"entry_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0014_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"95c0e828-629d-42da-8708-93156bde1199\",\n  \"prevId\": \"9859ecf0-4bcb-4d5a-90f0-66ceb1c133a5\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0015_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"1c7389f2-ca2c-47be-bcd1-7b7dece9f457\",\n  \"prevId\": \"95c0e828-629d-42da-8708-93156bde1199\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0016_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"44da86ee-5ce1-49e9-8f77-992f3fc23a16\",\n  \"prevId\": \"1c7389f2-ca2c-47be-bcd1-7b7dece9f457\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0017_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"bfd1be54-8339-4937-8c75-52fe36a25d84\",\n  \"prevId\": \"44da86ee-5ce1-49e9-8f77-992f3fc23a16\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0018_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"e32f739c-b3ca-43e0-afb2-767cd6daf30c\",\n  \"prevId\": \"bfd1be54-8339-4937-8c75-52fe36a25d84\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0019_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"0295d33b-ce34-41aa-a428-1dc63c6bb447\",\n  \"prevId\": \"e32f739c-b3ca-43e0-afb2-767cd6daf30c\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0020_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"493dc0df-ddca-4c5c-96bc-f74802251bc6\",\n  \"prevId\": \"0295d33b-ce34-41aa-a428-1dc63c6bb447\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0021_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"7fd74515-4875-4df8-89d0-42e06279c402\",\n  \"prevId\": \"493dc0df-ddca-4c5c-96bc-f74802251bc6\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0022_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"52d21c45-d498-48b7-8c8c-3775d72d16a4\",\n  \"prevId\": \"7fd74515-4875-4df8-89d0-42e06279c402\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0023_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"61f98b38-f03a-4f57-beb4-8c219ac1331c\",\n  \"prevId\": \"52d21c45-d498-48b7-8c8c-3775d72d16a4\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0024_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"04ab0964-78ae-4c9b-a5a8-b4b05d239d75\",\n  \"prevId\": \"61f98b38-f03a-4f57-beb4-8c219ac1331c\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"''\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0025_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"f4599e18-03d8-442b-80b4-eb73fb18b9d3\",\n  \"prevId\": \"04ab0964-78ae-4c9b-a5a8-b4b05d239d75\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0026_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"3788abcf-7e98-4d02-ba93-7d3407c4eea5\",\n  \"prevId\": \"f4599e18-03d8-442b-80b4-eb73fb18b9d3\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"(CURRENT_TIMESTAMP)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0027_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"e26241b1-007f-4624-b072-4c7e8f436f3f\",\n  \"prevId\": \"3788abcf-7e98-4d02-ba93-7d3407c4eea5\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0028_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"0b3abda0-88d8-44a7-ac77-0e16056b5845\",\n  \"prevId\": \"e26241b1-007f-4624-b072-4c7e8f436f3f\",\n  \"tables\": {\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0029_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"11dab821-4739-44db-818a-ef81702fd2b5\",\n  \"prevId\": \"0b3abda0-88d8-44a7-ac77-0e16056b5845\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"ai_chat_messages_unq\": {\n          \"name\": \"ai_chat_messages_unq\",\n          \"columns\": [\"room_id\", \"id\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_room_id_ai_chat_room_id_fk\": {\n          \"name\": \"ai_chat_messages_room_id_ai_chat_room_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat\",\n          \"columnsFrom\": [\"room_id\"],\n          \"columnsTo\": [\"room_id\"],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat\": {\n      \"name\": \"ai_chat\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0030_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"31cec72c-8c06-40ab-8e7f-c2e1be252870\",\n  \"prevId\": \"11dab821-4739-44db-818a-ef81702fd2b5\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"ai_chat_messages_unq\": {\n          \"name\": \"ai_chat_messages_unq\",\n          \"columns\": [\"room_id\", \"id\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_room_id_ai_chat_room_id_fk\": {\n          \"name\": \"ai_chat_messages_room_id_ai_chat_room_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat\",\n          \"columnsFrom\": [\"room_id\"],\n          \"columnsTo\": [\"room_id\"],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat\": {\n      \"name\": \"ai_chat\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0031_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"022f2968-0d86-43b1-a257-1c81d8fd42d4\",\n  \"prevId\": \"31cec72c-8c06-40ab-8e7f-c2e1be252870\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"ai_chat_messages_unq\": {\n          \"name\": \"ai_chat_messages_unq\",\n          \"columns\": [\"room_id\", \"id\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_room_id_ai_chat_room_id_fk\": {\n          \"name\": \"ai_chat_messages_room_id_ai_chat_room_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat\",\n          \"columnsFrom\": [\"room_id\"],\n          \"columnsTo\": [\"room_id\"],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat\": {\n      \"name\": \"ai_chat\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0032_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"611bbe39-b8f0-4c81-a067-315ac9b0f8a3\",\n  \"prevId\": \"022f2968-0d86-43b1-a257-1c81d8fd42d4\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"ai_chat_messages_unq\": {\n          \"name\": \"ai_chat_messages_unq\",\n          \"columns\": [\"room_id\", \"id\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_room_id_ai_chat_room_id_fk\": {\n          \"name\": \"ai_chat_messages_room_id_ai_chat_room_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat\",\n          \"columnsFrom\": [\"room_id\"],\n          \"columnsTo\": [\"room_id\"],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat\": {\n      \"name\": \"ai_chat\",\n      \"columns\": {\n        \"room_id\": {\n          \"name\": \"room_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hide_from_timeline\": {\n          \"name\": \"hide_from_timeline\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0033_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"339611d0-cf1e-4ec9-9bf6-275608b815f5\",\n  \"prevId\": \"611bbe39-b8f0-4c81-a067-315ac9b0f8a3\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"chat_id\": {\n          \"name\": \"chat_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"role\": {\n          \"name\": \"role\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"rich_text_schema\": {\n          \"name\": \"rich_text_schema\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"'completed'\"\n        },\n        \"finished_at\": {\n          \"name\": \"finished_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"message_parts\": {\n          \"name\": \"message_parts\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_messages_chat_id_created_at\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_created_at\",\n          \"columns\": [\"chat_id\", \"created_at\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_status\": {\n          \"name\": \"idx_ai_chat_messages_status\",\n          \"columns\": [\"status\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_chat_id_role\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_role\",\n          \"columns\": [\"chat_id\", \"role\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\": {\n          \"name\": \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat_sessions\",\n          \"columnsFrom\": [\"chat_id\"],\n          \"columnsTo\": [\"id\"],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat_sessions\": {\n      \"name\": \"ai_chat_sessions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_sessions_updated_at\": {\n          \"name\": \"idx_ai_chat_sessions_updated_at\",\n          \"columns\": [\"updated_at\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hide_from_timeline\": {\n          \"name\": \"hide_from_timeline\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {\n      \"\\\"ai_chat\\\"\": \"\\\"ai_chat_sessions\\\"\"\n    },\n    \"columns\": {\n      \"\\\"ai_chat_messages\\\".\\\"room_id\\\"\": \"\\\"ai_chat_messages\\\".\\\"chat_id\\\"\",\n      \"\\\"ai_chat_sessions\\\".\\\"room_id\\\"\": \"\\\"ai_chat_sessions\\\".\\\"id\\\"\"\n    }\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0034_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"6cfe24ba-739c-4f3f-a894-dd70ecb47d02\",\n  \"prevId\": \"339611d0-cf1e-4ec9-9bf6-275608b815f5\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"chat_id\": {\n          \"name\": \"chat_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"role\": {\n          \"name\": \"role\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"'completed'\"\n        },\n        \"finished_at\": {\n          \"name\": \"finished_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"message_parts\": {\n          \"name\": \"message_parts\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_messages_chat_id_created_at\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_created_at\",\n          \"columns\": [\"chat_id\", \"created_at\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_status\": {\n          \"name\": \"idx_ai_chat_messages_status\",\n          \"columns\": [\"status\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_chat_id_role\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_role\",\n          \"columns\": [\"chat_id\", \"role\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\": {\n          \"name\": \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat_sessions\",\n          \"columnsFrom\": [\"chat_id\"],\n          \"columnsTo\": [\"id\"],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat_sessions\": {\n      \"name\": \"ai_chat_sessions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_sessions_updated_at\": {\n          \"name\": \"idx_ai_chat_sessions_updated_at\",\n          \"columns\": [\"updated_at\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hide_from_timeline\": {\n          \"name\": \"hide_from_timeline\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0035_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"1ba7c166-20b5-473e-aea4-5fa6e99dff23\",\n  \"prevId\": \"6cfe24ba-739c-4f3f-a894-dd70ecb47d02\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"chat_id\": {\n          \"name\": \"chat_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"role\": {\n          \"name\": \"role\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"'completed'\"\n        },\n        \"finished_at\": {\n          \"name\": \"finished_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"message_parts\": {\n          \"name\": \"message_parts\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_messages_chat_id_created_at\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_created_at\",\n          \"columns\": [\"chat_id\", \"created_at\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_status\": {\n          \"name\": \"idx_ai_chat_messages_status\",\n          \"columns\": [\"status\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_chat_id_role\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_role\",\n          \"columns\": [\"chat_id\", \"role\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\": {\n          \"name\": \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat_sessions\",\n          \"columnsFrom\": [\"chat_id\"],\n          \"columnsTo\": [\"id\"],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat_sessions\": {\n      \"name\": \"ai_chat_sessions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"is_local\": {\n          \"name\": \"is_local\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_sessions_updated_at\": {\n          \"name\": \"idx_ai_chat_sessions_updated_at\",\n          \"columns\": [\"updated_at\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hide_from_timeline\": {\n          \"name\": \"hide_from_timeline\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0036_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"31afa146-88d1-49ab-a139-0d34d54f8526\",\n  \"prevId\": \"1ba7c166-20b5-473e-aea4-5fa6e99dff23\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"chat_id\": {\n          \"name\": \"chat_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"role\": {\n          \"name\": \"role\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"'completed'\"\n        },\n        \"finished_at\": {\n          \"name\": \"finished_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"message_parts\": {\n          \"name\": \"message_parts\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_messages_chat_id_created_at\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_created_at\",\n          \"columns\": [\"chat_id\", \"created_at\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_status\": {\n          \"name\": \"idx_ai_chat_messages_status\",\n          \"columns\": [\"status\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_chat_id_role\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_role\",\n          \"columns\": [\"chat_id\", \"role\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\": {\n          \"name\": \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat_sessions\",\n          \"columnsFrom\": [\"chat_id\"],\n          \"columnsTo\": [\"id\"],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat_sessions\": {\n      \"name\": \"ai_chat_sessions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"is_local\": {\n          \"name\": \"is_local\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_sessions_updated_at\": {\n          \"name\": \"idx_ai_chat_sessions_updated_at\",\n          \"columns\": [\"updated_at\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hide_from_timeline\": {\n          \"name\": \"hide_from_timeline\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/0037_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"f6cb39bb-84e8-47fd-90d8-373f18bd6472\",\n  \"prevId\": \"31afa146-88d1-49ab-a139-0d34d54f8526\",\n  \"tables\": {\n    \"ai_chat_messages\": {\n      \"name\": \"ai_chat_messages\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"chat_id\": {\n          \"name\": \"chat_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"role\": {\n          \"name\": \"role\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": \"'completed'\"\n        },\n        \"finished_at\": {\n          \"name\": \"finished_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"message_parts\": {\n          \"name\": \"message_parts\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_messages_chat_id_created_at\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_created_at\",\n          \"columns\": [\"chat_id\", \"created_at\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_status\": {\n          \"name\": \"idx_ai_chat_messages_status\",\n          \"columns\": [\"status\"],\n          \"isUnique\": false\n        },\n        \"idx_ai_chat_messages_chat_id_role\": {\n          \"name\": \"idx_ai_chat_messages_chat_id_role\",\n          \"columns\": [\"chat_id\", \"role\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {\n        \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\": {\n          \"name\": \"ai_chat_messages_chat_id_ai_chat_sessions_id_fk\",\n          \"tableFrom\": \"ai_chat_messages\",\n          \"tableTo\": \"ai_chat_sessions\",\n          \"columnsFrom\": [\"chat_id\"],\n          \"columnsTo\": [\"id\"],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"ai_chat_sessions\": {\n      \"name\": \"ai_chat_sessions\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"updated_at\": {\n          \"name\": \"updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        },\n        \"is_local\": {\n          \"name\": \"is_local\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        }\n      },\n      \"indexes\": {\n        \"idx_ai_chat_sessions_updated_at\": {\n          \"name\": \"idx_ai_chat_sessions_updated_at\",\n          \"columns\": [\"updated_at\"],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"collections\": {\n      \"name\": \"collections\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"entries\": {\n      \"name\": \"entries\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"source_content\": {\n          \"name\": \"source_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_updated_at\": {\n          \"name\": \"readability_updated_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"guid\": {\n          \"name\": \"guid\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"author\": {\n          \"name\": \"author\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_url\": {\n          \"name\": \"author_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"author_avatar\": {\n          \"name\": \"author_avatar\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inserted_at\": {\n          \"name\": \"inserted_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"media\": {\n          \"name\": \"media\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"categories\": {\n          \"name\": \"categories\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"attachments\": {\n          \"name\": \"attachments\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"extra\": {\n          \"name\": \"extra\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_handle\": {\n          \"name\": \"inbox_handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"read\": {\n          \"name\": \"read\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sources\": {\n          \"name\": \"sources\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"feeds\": {\n      \"name\": \"feeds\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_at\": {\n          \"name\": \"error_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"site_url\": {\n          \"name\": \"site_url\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error_message\": {\n          \"name\": \"error_message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"updates_per_week\": {\n          \"name\": \"updates_per_week\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"latest_entry_published_at\": {\n          \"name\": \"latest_entry_published_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"tip_users\": {\n          \"name\": \"tip_users\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"published_at\": {\n          \"name\": \"published_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"images\": {\n      \"name\": \"images\",\n      \"columns\": {\n        \"url\": {\n          \"name\": \"url\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"colors\": {\n          \"name\": \"colors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"(unixepoch() * 1000)\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"inboxes\": {\n      \"name\": \"inboxes\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"secret\": {\n          \"name\": \"secret\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"lists\": {\n      \"name\": \"lists\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"feed_ids\": {\n          \"name\": \"feed_ids\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fee\": {\n          \"name\": \"fee\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"owner_user_id\": {\n          \"name\": \"owner_user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"subscription_count\": {\n          \"name\": \"subscription_count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"purchase_amount\": {\n          \"name\": \"purchase_amount\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"subscriptions\": {\n      \"name\": \"subscriptions\",\n      \"columns\": {\n        \"feed_id\": {\n          \"name\": \"feed_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"list_id\": {\n          \"name\": \"list_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"inbox_id\": {\n          \"name\": \"inbox_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"user_id\": {\n          \"name\": \"user_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"view\": {\n          \"name\": \"view\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"is_private\": {\n          \"name\": \"is_private\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hide_from_timeline\": {\n          \"name\": \"hide_from_timeline\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"category\": {\n          \"name\": \"category\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"summaries\": {\n      \"name\": \"summaries\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"summary\": {\n          \"name\": \"summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"readability_summary\": {\n          \"name\": \"readability_summary\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"unq\": {\n          \"name\": \"unq\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"translations\": {\n      \"name\": \"translations\",\n      \"columns\": {\n        \"entry_id\": {\n          \"name\": \"entry_id\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"language\": {\n          \"name\": \"language\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"content\": {\n          \"name\": \"content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"readability_content\": {\n          \"name\": \"readability_content\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"created_at\": {\n          \"name\": \"created_at\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"translation-unique-index\": {\n          \"name\": \"translation-unique-index\",\n          \"columns\": [\"entry_id\", \"language\"],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"unread\": {\n      \"name\": \"unread\",\n      \"columns\": {\n        \"subscription_id\": {\n          \"name\": \"subscription_id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"count\": {\n          \"name\": \"count\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"users\": {\n      \"name\": \"users\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"handle\": {\n          \"name\": \"handle\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"image\": {\n          \"name\": \"image\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"is_me\": {\n          \"name\": \"is_me\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"email_verified\": {\n          \"name\": \"email_verified\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"bio\": {\n          \"name\": \"bio\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"social_links\": {\n          \"name\": \"social_links\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/meta/_journal.json",
    "content": "{\n  \"version\": \"7\",\n  \"dialect\": \"sqlite\",\n  \"entries\": [\n    {\n      \"idx\": 0,\n      \"version\": \"6\",\n      \"when\": 1734622443265,\n      \"tag\": \"0000_harsh_shiva\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 1,\n      \"version\": \"6\",\n      \"when\": 1734622716320,\n      \"tag\": \"0001_bored_hobgoblin\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 2,\n      \"version\": \"6\",\n      \"when\": 1734962295258,\n      \"tag\": \"0002_smart_power_man\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 3,\n      \"version\": \"6\",\n      \"when\": 1734963906590,\n      \"tag\": \"0003_known_roland_deschain\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 4,\n      \"version\": \"6\",\n      \"when\": 1735137187415,\n      \"tag\": \"0004_majestic_thunderbolt_ross\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 5,\n      \"version\": \"6\",\n      \"when\": 1737080611316,\n      \"tag\": \"0005_tense_sleepwalker\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 6,\n      \"version\": \"6\",\n      \"when\": 1737080887634,\n      \"tag\": \"0006_exotic_kid_colt\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 7,\n      \"version\": \"6\",\n      \"when\": 1737108493439,\n      \"tag\": \"0007_curvy_tarantula\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 8,\n      \"version\": \"6\",\n      \"when\": 1739265018895,\n      \"tag\": \"0008_last_the_santerians\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 9,\n      \"version\": \"6\",\n      \"when\": 1739350142864,\n      \"tag\": \"0009_lucky_power_man\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 10,\n      \"version\": \"6\",\n      \"when\": 1739350709266,\n      \"tag\": \"0010_legal_ben_grimm\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 11,\n      \"version\": \"6\",\n      \"when\": 1740623123535,\n      \"tag\": \"0011_mysterious_stark_industries\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 12,\n      \"version\": \"6\",\n      \"when\": 1740629979168,\n      \"tag\": \"0012_magenta_thing\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 13,\n      \"version\": \"6\",\n      \"when\": 1741017801271,\n      \"tag\": \"0013_chunky_stephen_strange\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 14,\n      \"version\": \"6\",\n      \"when\": 1741168250902,\n      \"tag\": \"0014_chemical_shocker\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 15,\n      \"version\": \"6\",\n      \"when\": 1741226954359,\n      \"tag\": \"0015_colorful_warbird\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 16,\n      \"version\": \"6\",\n      \"when\": 1741572308405,\n      \"tag\": \"0016_curious_carnage\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 17,\n      \"version\": \"6\",\n      \"when\": 1741700557982,\n      \"tag\": \"0017_talented_captain_cross\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 18,\n      \"version\": \"6\",\n      \"when\": 1743043720748,\n      \"tag\": \"0018_dashing_the_fury\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 19,\n      \"version\": \"6\",\n      \"when\": 1743153830369,\n      \"tag\": \"0019_wonderful_shape\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 20,\n      \"version\": \"6\",\n      \"when\": 1744793226628,\n      \"tag\": \"0020_little_marauders\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 21,\n      \"version\": \"6\",\n      \"when\": 1745569856356,\n      \"tag\": \"0021_wakeful_onslaught\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 22,\n      \"version\": \"6\",\n      \"when\": 1748331571730,\n      \"tag\": \"0022_tiny_northstar\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 23,\n      \"version\": \"6\",\n      \"when\": 1749121539199,\n      \"tag\": \"0023_pink_namor\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 24,\n      \"version\": \"6\",\n      \"when\": 1749628868500,\n      \"tag\": \"0024_spooky_alex_power\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 25,\n      \"version\": \"6\",\n      \"when\": 1749633611180,\n      \"tag\": \"0025_colorful_valkyrie\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 26,\n      \"version\": \"6\",\n      \"when\": 1749640123453,\n      \"tag\": \"0026_numerous_slyde\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 27,\n      \"version\": \"6\",\n      \"when\": 1749790511829,\n      \"tag\": \"0027_nostalgic_human_torch\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 28,\n      \"version\": \"6\",\n      \"when\": 1751278105927,\n      \"tag\": \"0028_chief_cyclops\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 29,\n      \"version\": \"6\",\n      \"when\": 1751637550260,\n      \"tag\": \"0029_flaky_gorgon\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 30,\n      \"version\": \"6\",\n      \"when\": 1751639397791,\n      \"tag\": \"0030_common_gabe_jones\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 31,\n      \"version\": \"6\",\n      \"when\": 1751640829832,\n      \"tag\": \"0031_kind_ikaris\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 32,\n      \"version\": \"6\",\n      \"when\": 1753240775348,\n      \"tag\": \"0032_orange_prima\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 33,\n      \"version\": \"6\",\n      \"when\": 1753890574250,\n      \"tag\": \"0033_shiny_sebastian_shaw\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 34,\n      \"version\": \"6\",\n      \"when\": 1753937164043,\n      \"tag\": \"0034_curly_darkstar\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 35,\n      \"version\": \"6\",\n      \"when\": 1761930812297,\n      \"tag\": \"0035_last_valeria_richards\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 36,\n      \"version\": \"6\",\n      \"when\": 1761930812300,\n      \"tag\": \"0036_entry_tag_summary\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 37,\n      \"version\": \"6\",\n      \"when\": 1768727702017,\n      \"tag\": \"0037_bored_the_leader\",\n      \"breakpoints\": true\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/internal/database/src/drizzle/migrations.js",
    "content": "// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo\n\nimport m0000 from \"./0000_harsh_shiva.sql\"\nimport m0001 from \"./0001_bored_hobgoblin.sql\"\nimport m0002 from \"./0002_smart_power_man.sql\"\nimport m0003 from \"./0003_known_roland_deschain.sql\"\nimport m0004 from \"./0004_majestic_thunderbolt_ross.sql\"\nimport m0005 from \"./0005_tense_sleepwalker.sql\"\nimport m0006 from \"./0006_exotic_kid_colt.sql\"\nimport m0007 from \"./0007_curvy_tarantula.sql\"\nimport m0008 from \"./0008_last_the_santerians.sql\"\nimport m0009 from \"./0009_lucky_power_man.sql\"\nimport m0010 from \"./0010_legal_ben_grimm.sql\"\nimport m0011 from \"./0011_mysterious_stark_industries.sql\"\nimport m0012 from \"./0012_magenta_thing.sql\"\nimport m0013 from \"./0013_chunky_stephen_strange.sql\"\nimport m0014 from \"./0014_chemical_shocker.sql\"\nimport m0015 from \"./0015_colorful_warbird.sql\"\nimport m0016 from \"./0016_curious_carnage.sql\"\nimport m0017 from \"./0017_talented_captain_cross.sql\"\nimport m0018 from \"./0018_dashing_the_fury.sql\"\nimport m0019 from \"./0019_wonderful_shape.sql\"\nimport m0020 from \"./0020_little_marauders.sql\"\nimport m0021 from \"./0021_wakeful_onslaught.sql\"\nimport m0022 from \"./0022_tiny_northstar.sql\"\nimport m0023 from \"./0023_pink_namor.sql\"\nimport m0024 from \"./0024_spooky_alex_power.sql\"\nimport m0025 from \"./0025_colorful_valkyrie.sql\"\nimport m0026 from \"./0026_numerous_slyde.sql\"\nimport m0027 from \"./0027_nostalgic_human_torch.sql\"\nimport m0028 from \"./0028_chief_cyclops.sql\"\nimport m0029 from \"./0029_flaky_gorgon.sql\"\nimport m0030 from \"./0030_common_gabe_jones.sql\"\nimport m0031 from \"./0031_kind_ikaris.sql\"\nimport m0032 from \"./0032_orange_prima.sql\"\nimport m0033 from \"./0033_shiny_sebastian_shaw.sql\"\nimport m0034 from \"./0034_curly_darkstar.sql\"\nimport m0035 from \"./0035_last_valeria_richards.sql\"\nimport m0036 from \"./0036_entry_tag_summary.sql\"\nimport m0037 from \"./0037_bored_the_leader.sql\"\nimport journal from \"./meta/_journal.json\"\n\nexport default {\n  journal,\n  migrations: {\n    m0000,\n    m0001,\n    m0002,\n    m0003,\n    m0004,\n    m0005,\n    m0006,\n    m0007,\n    m0008,\n    m0009,\n    m0010,\n    m0011,\n    m0012,\n    m0013,\n    m0014,\n    m0015,\n    m0016,\n    m0017,\n    m0018,\n    m0019,\n    m0020,\n    m0021,\n    m0022,\n    m0023,\n    m0024,\n    m0025,\n    m0026,\n    m0027,\n    m0028,\n    m0029,\n    m0030,\n    m0031,\n    m0032,\n    m0033,\n    m0034,\n    m0035,\n    m0036,\n    m0037,\n  },\n}\n"
  },
  {
    "path": "packages/internal/database/src/migrator.ts",
    "content": "import type { SQL } from \"drizzle-orm\"\nimport { sql } from \"drizzle-orm\"\nimport type { SQLiteDatabase } from \"expo-sqlite\"\n\ninterface MigrationConfig {\n  journal: MigrationJournal\n  migrations: Record<string, string>\n  migrationsTable?: string\n}\n\ninterface MigrationJournal {\n  version: string\n  dialect: string\n  entries: {\n    idx: number\n    version: string\n    when: number\n    tag: string\n    breakpoints: boolean\n  }[]\n}\n\ninterface MigrationMeta {\n  sql: string[]\n  folderMillis: number\n  hash: string\n  bps: boolean\n}\n\ntype MaybePromise<T> = T | Promise<T>\ntype SQLiteMigrationDatabase = Pick<SQLiteDatabase, \"execSync\" | \"getAllSync\">\n\ninterface SQLiteColumnInfo {\n  name: string\n}\n\ninterface SQLiteMigrationRow {\n  id: number\n  hash: string\n  created_at: number | string\n}\n\nconst ADD_COLUMN_RE = /^ALTER TABLE\\s+[`\"]?(\\w+)[`\"]?\\s+ADD\\s+[`\"]?(\\w+)[`\"]?\\s+/i\nconst DROP_COLUMN_RE = /^ALTER TABLE\\s+[`\"]?(\\w+)[`\"]?\\s+DROP COLUMN\\s+[`\"]?(\\w+)[`\"]?\\s*;?$/i\n\n// https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/expo-sqlite/migrator.ts\nasync function readMigrationFiles({\n  journal,\n  migrations,\n}: MigrationConfig): Promise<MigrationMeta[]> {\n  const migrationQueries: MigrationMeta[] = []\n\n  for await (const journalEntry of journal.entries) {\n    const query = migrations[`m${journalEntry.idx.toString().padStart(4, \"0\")}`]\n\n    if (!query) {\n      throw new Error(`Missing migration: ${journalEntry.tag}`)\n    }\n\n    try {\n      const result = query.split(\"--> statement-breakpoint\").map((it) => {\n        return it\n      })\n\n      migrationQueries.push({\n        sql: result,\n        bps: journalEntry.breakpoints,\n        folderMillis: journalEntry.when,\n        hash: \"\",\n      })\n    } catch {\n      throw new Error(`Failed to parse migration: ${journalEntry.tag}`)\n    }\n  }\n\n  return migrationQueries\n}\n\n// https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/sqlite-proxy/migrator.ts\nexport async function migrate<_TSchema extends Record<string, unknown>>(\n  db: {\n    run: (query: SQL) => MaybePromise<unknown>\n    values: <TResult extends unknown[]>(query: SQL) => MaybePromise<TResult[]>\n  },\n  config: MigrationConfig,\n) {\n  const migrations = await readMigrationFiles(config)\n\n  const migrationTableCreate = sql`\n\t\tCREATE TABLE IF NOT EXISTS \"__drizzle_migrations\" (\n\t\t\tid SERIAL PRIMARY KEY,\n\t\t\thash text NOT NULL,\n\t\t\tcreated_at numeric\n\t\t)\n\t`\n\n  await db.run(migrationTableCreate)\n\n  const dbMigrations = await db.values<[number, string, string]>(\n    sql`SELECT id, hash, created_at FROM \"__drizzle_migrations\" ORDER BY created_at DESC LIMIT 1`,\n  )\n\n  const lastDbMigration = dbMigrations[0] ?? undefined\n\n  const queriesToRun: string[] = []\n  for (const migration of migrations) {\n    if (!lastDbMigration || Number(lastDbMigration[2])! < migration.folderMillis) {\n      queriesToRun.push(\n        ...migration.sql,\n        `INSERT INTO \"__drizzle_migrations\" (\"hash\", \"created_at\") VALUES('${migration.hash}', '${migration.folderMillis}')`,\n      )\n    }\n  }\n\n  for (const query of queriesToRun) {\n    await db.run(sql.raw(query))\n  }\n}\n\nfunction getTableColumns(db: SQLiteMigrationDatabase, tableName: string): Set<string> {\n  const escapedTableName = tableName.replaceAll(\"`\", \"``\")\n  const columns = db.getAllSync<SQLiteColumnInfo>(`PRAGMA table_info(\\`${escapedTableName}\\`)`)\n  return new Set(columns.map((column) => column.name))\n}\n\nfunction shouldSkipMigrationQuery(db: SQLiteMigrationDatabase, query: string): boolean {\n  const addColumnMatch = query.match(ADD_COLUMN_RE)\n  if (addColumnMatch) {\n    const tableName = addColumnMatch[1]\n    const columnName = addColumnMatch[2]\n    if (!tableName || !columnName) {\n      return false\n    }\n    const columns = getTableColumns(db, tableName)\n    return columns.has(columnName)\n  }\n\n  const dropColumnMatch = query.match(DROP_COLUMN_RE)\n  if (dropColumnMatch) {\n    const tableName = dropColumnMatch[1]\n    const columnName = dropColumnMatch[2]\n    if (!tableName || !columnName) {\n      return false\n    }\n    const columns = getTableColumns(db, tableName)\n    return !columns.has(columnName)\n  }\n\n  return false\n}\n\nexport async function migrateExpoSQLite(db: SQLiteMigrationDatabase, config: MigrationConfig) {\n  const migrations = await readMigrationFiles(config)\n\n  db.execSync(`\n    CREATE TABLE IF NOT EXISTS \"__drizzle_migrations\" (\n      id SERIAL PRIMARY KEY,\n      hash text NOT NULL,\n      created_at numeric\n    );\n  `)\n\n  const dbMigrations = db.getAllSync<SQLiteMigrationRow>(\n    `SELECT id, hash, created_at FROM \"__drizzle_migrations\" ORDER BY created_at DESC LIMIT 1`,\n  )\n  const lastDbMigration = dbMigrations[0] ?? undefined\n\n  for (const migration of migrations) {\n    if (lastDbMigration && Number(lastDbMigration.created_at) >= migration.folderMillis) {\n      continue\n    }\n\n    for (const rawQuery of migration.sql) {\n      const query = rawQuery.trim()\n      if (!query) {\n        continue\n      }\n      if (shouldSkipMigrationQuery(db, query)) {\n        continue\n      }\n      db.execSync(query)\n    }\n\n    const escapedHash = migration.hash.replaceAll(\"'\", \"''\")\n    db.execSync(\n      `INSERT INTO \"__drizzle_migrations\" (\"hash\", \"created_at\") VALUES('${escapedHash}', '${migration.folderMillis}')`,\n    )\n  }\n}\n"
  },
  {
    "path": "packages/internal/database/src/schemas/index.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { SupportedActionLanguage } from \"@follow/shared/language\"\nimport type { EntrySettings } from \"@follow-app/client-sdk\"\nimport { sql } from \"drizzle-orm\"\nimport { index, integer, sqliteTable, text, uniqueIndex } from \"drizzle-orm/sqlite-core\"\n\nimport type { AttachmentsModel, ExtraModel, ImageColorsResult, MediaModel } from \"./types\"\n\nexport const feedsTable = sqliteTable(\"feeds\", {\n  id: text(\"id\").primaryKey(),\n  title: text(\"title\"),\n  url: text(\"url\").notNull(),\n  description: text(\"description\"),\n  image: text(\"image\"),\n  errorAt: text(\"error_at\"),\n  siteUrl: text(\"site_url\"),\n  ownerUserId: text(\"owner_user_id\"),\n  errorMessage: text(\"error_message\"),\n  subscriptionCount: integer(\"subscription_count\"),\n  updatesPerWeek: integer(\"updates_per_week\"),\n  latestEntryPublishedAt: text(\"latest_entry_published_at\"),\n  tipUserIds: text(\"tip_users\", { mode: \"json\" }).$type<string[]>(),\n  updatedAt: integer(\"published_at\", { mode: \"timestamp_ms\" }),\n})\n\nexport const subscriptionsTable = sqliteTable(\"subscriptions\", {\n  feedId: text(\"feed_id\"),\n  listId: text(\"list_id\"),\n  inboxId: text(\"inbox_id\"),\n  userId: text(\"user_id\").notNull(),\n  view: integer(\"view\").notNull().$type<FeedViewType>(),\n  isPrivate: integer(\"is_private\", { mode: \"boolean\" }).notNull(),\n  hideFromTimeline: integer(\"hide_from_timeline\", { mode: \"boolean\" }),\n  title: text(\"title\"),\n  category: text(\"category\"),\n  createdAt: text(\"created_at\"),\n  type: text(\"type\").notNull().$type<\"feed\" | \"list\" | \"inbox\">(),\n  id: text(\"id\").primaryKey(),\n})\n\nexport const inboxesTable = sqliteTable(\"inboxes\", {\n  id: text(\"id\").primaryKey(),\n  title: text(\"title\"),\n  secret: text(\"secret\").notNull(),\n})\n\nexport const listsTable = sqliteTable(\"lists\", {\n  id: text(\"id\").primaryKey(),\n  userId: text(\"user_id\"),\n  title: text(\"title\").notNull(),\n  feedIds: text(\"feed_ids\", { mode: \"json\" }).$type<string>(),\n  description: text(\"description\"),\n  view: integer(\"view\").notNull().$type<FeedViewType>(),\n  image: text(\"image\"),\n  fee: integer(\"fee\"),\n  ownerUserId: text(\"owner_user_id\"),\n  subscriptionCount: integer(\"subscription_count\"),\n  purchaseAmount: text(\"purchase_amount\"),\n})\n\nexport const unreadTable = sqliteTable(\"unread\", {\n  id: text(\"subscription_id\").notNull().primaryKey(),\n  count: integer(\"count\").notNull(),\n})\n\nexport const usersTable = sqliteTable(\"users\", {\n  id: text(\"id\").primaryKey(),\n  email: text(\"email\"),\n  handle: text(\"handle\"),\n  name: text(\"name\"),\n  image: text(\"image\"),\n  isMe: integer(\"is_me\", { mode: \"boolean\" }),\n  emailVerified: integer(\"email_verified\", { mode: \"boolean\" }),\n  bio: text(\"bio\"),\n  website: text(\"website\"),\n  socialLinks: text(\"social_links\", { mode: \"json\" }).$type<{\n    twitter?: string\n    github?: string\n    instagram?: string\n    facebook?: string\n    youtube?: string\n    discord?: string\n  }>(),\n})\nexport const entriesTable = sqliteTable(\"entries\", {\n  id: text(\"id\").primaryKey(),\n  title: text(\"title\"),\n  url: text(\"url\"),\n  content: text(\"content\"),\n  readabilityContent: text(\"source_content\"),\n  readabilityUpdatedAt: integer(\"readability_updated_at\", { mode: \"timestamp_ms\" }),\n  description: text(\"description\"),\n  guid: text(\"guid\").notNull(),\n  author: text(\"author\"),\n  authorUrl: text(\"author_url\"),\n  authorAvatar: text(\"author_avatar\"),\n  insertedAt: integer(\"inserted_at\", { mode: \"timestamp_ms\" }).notNull(),\n  publishedAt: integer(\"published_at\", { mode: \"timestamp_ms\" }).notNull(),\n  media: text(\"media\", { mode: \"json\" }).$type<MediaModel[]>(),\n  categories: text(\"categories\", { mode: \"json\" }).$type<string[]>(),\n  attachments: text(\"attachments\", { mode: \"json\" }).$type<AttachmentsModel[]>(),\n  extra: text(\"extra\", { mode: \"json\" }).$type<ExtraModel>(),\n  language: text(\"language\"),\n\n  feedId: text(\"feed_id\"),\n\n  inboxHandle: text(\"inbox_handle\"),\n  read: integer(\"read\", { mode: \"boolean\" }),\n  sources: text(\"sources\", { mode: \"json\" }).$type<string[]>(),\n  settings: text(\"settings\", { mode: \"json\" }).$type<EntrySettings>(),\n})\n\nexport const collectionsTable = sqliteTable(\"collections\", {\n  feedId: text(\"feed_id\"),\n  entryId: text(\"entry_id\").notNull().primaryKey(),\n  createdAt: text(\"created_at\"),\n  view: integer(\"view\").notNull().$type<FeedViewType>(),\n})\n\nexport const summariesTable = sqliteTable(\n  \"summaries\",\n  {\n    entryId: text(\"entry_id\").notNull(),\n    summary: text(\"summary\").notNull(),\n    readabilitySummary: text(\"readability_summary\"),\n    createdAt: text(\"created_at\").$defaultFn(() => new Date().toISOString()),\n    language: text(\"language\").$type<SupportedActionLanguage>(),\n  },\n  (t) => [uniqueIndex(\"unq\").on(t.entryId, t.language)],\n)\n\nexport const translationsTable = sqliteTable(\n  \"translations\",\n  (t) => ({\n    entryId: t.text(\"entry_id\").notNull(),\n    language: t.text(\"language\").$type<SupportedActionLanguage>().notNull(),\n    title: t.text(\"title\"),\n    description: t.text(\"description\"),\n    content: t.text(\"content\"),\n    readabilityContent: t.text(\"readability_content\"),\n    createdAt: t\n      .text(\"created_at\")\n      .notNull()\n      .$defaultFn(() => new Date().toISOString()),\n  }),\n  (t) => [uniqueIndex(\"translation-unique-index\").on(t.entryId, t.language)],\n)\n\nexport const imagesTable = sqliteTable(\"images\", (t) => ({\n  url: t.text(\"url\").notNull().primaryKey(),\n  colors: t.text(\"colors\", { mode: \"json\" }).$type<ImageColorsResult>().notNull(),\n  createdAt: t\n    .integer(\"created_at\", { mode: \"timestamp_ms\" })\n    .notNull()\n    .default(sql`(unixepoch() * 1000)`),\n}))\n\n// AI Chat Sessions Table\nexport const aiChatTable = sqliteTable(\n  \"ai_chat_sessions\",\n  (t) => ({\n    chatId: t.text(\"id\").notNull().primaryKey(),\n    title: t.text(\"title\"),\n    createdAt: t\n      .integer(\"created_at\", { mode: \"timestamp_ms\" })\n      .notNull()\n      .default(sql`(unixepoch() * 1000)`),\n    updatedAt: t\n      .integer(\"updated_at\", { mode: \"timestamp_ms\" })\n      .notNull()\n      .default(sql`(unixepoch() * 1000)`),\n    isLocal: t.integer(\"is_local\", { mode: \"boolean\" }).notNull().default(false),\n  }),\n  (table) => [index(\"idx_ai_chat_sessions_updated_at\").on(table.updatedAt)],\n)\n\n// AI Chat Messages Table - Rich text support\nexport const aiChatMessagesTable = sqliteTable(\n  \"ai_chat_messages\",\n  (t) => ({\n    id: t.text(\"id\").notNull().primaryKey(),\n    chatId: t\n      .text(\"chat_id\")\n      .notNull()\n      .references(() => aiChatTable.chatId, { onDelete: \"cascade\" }),\n\n    role: t.text(\"role\").notNull().$type<\"user\" | \"assistant\" | \"system\">(),\n\n    createdAt: t\n      .integer(\"created_at\", { mode: \"timestamp_ms\" })\n      .notNull()\n      .default(sql`(unixepoch() * 1000)`),\n    metadata: t.text(\"metadata\", { mode: \"json\" }).$type<any>(),\n\n    status: t\n      .text(\"status\")\n      .$type<\"pending\" | \"streaming\" | \"completed\" | \"error\">()\n      .default(\"completed\"),\n    finishedAt: t.integer(\"finished_at\", { mode: \"timestamp_ms\" }),\n\n    // Store UIMessage parts for complex assistant responses (tools, reasoning, etc)\n    messageParts: t.text(\"message_parts\", { mode: \"json\" }).$type<unknown[]>(),\n  }),\n  (table) => [\n    index(\"idx_ai_chat_messages_chat_id_created_at\").on(table.chatId, table.createdAt),\n    index(\"idx_ai_chat_messages_status\").on(table.status),\n    index(\"idx_ai_chat_messages_chat_id_role\").on(table.chatId, table.role),\n  ],\n)\n\nexport type AiChatMessagesModel = typeof aiChatMessagesTable.$inferSelect\n"
  },
  {
    "path": "packages/internal/database/src/schemas/types.ts",
    "content": "import type {\n  collectionsTable,\n  entriesTable,\n  feedsTable,\n  imagesTable,\n  inboxesTable,\n  listsTable,\n  subscriptionsTable,\n  summariesTable,\n  translationsTable,\n  unreadTable,\n  usersTable,\n} from \".\"\n\nexport type SubscriptionSchema = typeof subscriptionsTable.$inferInsert\n\nexport type FeedSchema = typeof feedsTable.$inferInsert\n\nexport type InboxSchema = typeof inboxesTable.$inferInsert\n\nexport type ListSchema = typeof listsTable.$inferInsert\n\nexport type UnreadSchema = typeof unreadTable.$inferInsert\n\nexport type UserSchema = typeof usersTable.$inferInsert\n\nexport type EntrySchema = typeof entriesTable.$inferInsert\n\nexport type CollectionSchema = typeof collectionsTable.$inferInsert\n\nexport type SummarySchema = typeof summariesTable.$inferInsert\n\nexport type TranslationSchema = typeof translationsTable.$inferInsert\n\nexport type ImageSchema = typeof imagesTable.$inferInsert\n\nexport type MediaModel = {\n  url: string\n  type: \"photo\" | \"video\"\n  preview_image_url?: string\n  width?: number\n  height?: number\n  blurhash?: string\n}\n\nexport type AttachmentsModel = {\n  url: string\n  duration_in_seconds?: number | string\n  mime_type?: string\n  size_in_bytes?: number\n  title?: string\n}\n\nexport type ExtraModel = {\n  links?: {\n    url: string\n    type: string\n    content_html?: string\n  }[]\n  title_keyword?: string\n}\n\n// export { ImageColorsResult } from \"react-native-image-colors\"\n\ninterface AndroidImageColors {\n  dominant: string\n  average: string\n  vibrant: string\n  darkVibrant: string\n  lightVibrant: string\n  darkMuted: string\n  lightMuted: string\n  muted: string\n  platform: \"android\"\n}\n\ninterface WebImageColors {\n  dominant: string\n  vibrant: string\n  darkVibrant: string\n  lightVibrant: string\n  darkMuted: string\n  lightMuted: string\n  muted: string\n  platform: \"web\"\n}\n\ninterface IOSImageColors {\n  background: string\n  primary: string\n  secondary: string\n  detail: string\n  platform: \"ios\"\n}\n\nexport type ImageColorsResult = AndroidImageColors | IOSImageColors | WebImageColors\n"
  },
  {
    "path": "packages/internal/database/src/services/collection.ts",
    "content": "import { eq, inArray } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { collectionsTable } from \"../schemas\"\nimport type { CollectionSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\nclass CollectionServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(collectionsTable).execute()\n  }\n\n  async upsertMany(collections: CollectionSchema[], options?: { reset?: boolean }) {\n    if (collections.length === 0) return\n\n    if (options?.reset) {\n      await db.delete(collectionsTable).execute()\n    }\n\n    await db\n      .insert(collectionsTable)\n      .values(collections)\n      .onConflictDoUpdate({\n        target: [collectionsTable.entryId],\n        set: conflictUpdateAllExcept(collectionsTable, [\"entryId\"]),\n      })\n  }\n\n  async delete(entryId: string) {\n    await db.delete(collectionsTable).where(eq(collectionsTable.entryId, entryId))\n  }\n\n  async deleteMany(entryId: string[]) {\n    if (entryId.length === 0) return\n    await db.delete(collectionsTable).where(inArray(collectionsTable.entryId, entryId))\n  }\n\n  getCollectionMany(entryId: string[]) {\n    return db.query.collectionsTable.findMany({ where: inArray(collectionsTable.entryId, entryId) })\n  }\n\n  getCollectionAll() {\n    return db.query.collectionsTable.findMany()\n  }\n}\n\nexport const CollectionService = new CollectionServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/entry.ts",
    "content": "import { and, between, eq, inArray, lt, or } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { entriesTable } from \"../schemas\"\nimport type { EntrySchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\ninterface PublishAtTimeRangeFilter {\n  startTime: number\n  endTime: number\n}\n\ninterface InsertedBeforeTimeRangeFilter {\n  insertedBefore: number\n}\n\nclass EntryServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(entriesTable).execute()\n  }\n\n  async upsertMany(entries: EntrySchema[]) {\n    if (entries.length === 0) return\n    await db\n      .insert(entriesTable)\n      .values(entries)\n      .onConflictDoUpdate({\n        target: [entriesTable.id],\n        set: conflictUpdateAllExcept(entriesTable, [\"id\"]),\n      })\n  }\n\n  async patch(entry: Partial<EntrySchema> & { id: string }) {\n    await db.update(entriesTable).set(entry).where(eq(entriesTable.id, entry.id))\n  }\n\n  async patchMany({\n    entry,\n    entryIds,\n    feedIds,\n    time,\n  }: {\n    entry: Partial<EntrySchema>\n    entryIds?: string[]\n    feedIds?: string[]\n    time?: PublishAtTimeRangeFilter | InsertedBeforeTimeRangeFilter\n  }) {\n    if (!entryIds && !feedIds) return\n    await db\n      .update(entriesTable)\n      .set(entry)\n      .where(\n        and(\n          or(inArray(entriesTable.id, entryIds ?? []), inArray(entriesTable.feedId, feedIds ?? [])),\n          time && \"startTime\" in time\n            ? between(entriesTable.publishedAt, new Date(time.startTime), new Date(time.endTime))\n            : undefined,\n          time && \"insertedBefore\" in time\n            ? lt(entriesTable.insertedAt, new Date(time.insertedBefore))\n            : undefined,\n        ),\n      )\n  }\n\n  getEntryMany(entryId: string[]) {\n    return db.query.entriesTable.findMany({ where: inArray(entriesTable.id, entryId) })\n  }\n\n  getEntryAll() {\n    return db.query.entriesTable.findMany()\n  }\n\n  async getEntriesToHydrate() {\n    const [entries, subscriptions] = await Promise.all([\n      db.query.entriesTable.findMany({\n        orderBy: (t, { desc }) => desc(t.publishedAt),\n      }),\n      db.query.subscriptionsTable.findMany(),\n    ])\n\n    const entryIdCountMap = new Map<string, number>(\n      subscriptions.map((s) => [s.listId || s.inboxId || s.feedId || \"\", 0] as const),\n    )\n\n    const result: typeof entries = []\n    const idsToClear = new Set<string>()\n\n    for (const entry of entries) {\n      const possibleIdList = [\n        ...(entry.sources?.filter((s) => s !== \"feed\") ?? []),\n        entry.inboxHandle,\n        entry.feedId,\n      ].filter(Boolean) as string[]\n\n      if (possibleIdList.length === 0) continue\n\n      for (const id of possibleIdList) {\n        const count = entryIdCountMap.get(id)\n        if (count === undefined) continue\n\n        if (count >= 20) {\n          idsToClear.add(entry.id)\n        } else {\n          result.push(entry)\n          entryIdCountMap.set(id, count + 1)\n        }\n      }\n    }\n\n    await db\n      .delete(entriesTable)\n      .where(inArray(entriesTable.id, Array.from(idsToClear)))\n      .execute()\n\n    return result\n  }\n\n  async deleteMany(entryIds: string[]) {\n    if (entryIds.length === 0) return\n    await db.delete(entriesTable).where(inArray(entriesTable.id, entryIds))\n  }\n}\n\nexport const EntryService = new EntryServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/feed.ts",
    "content": "import { eq } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { feedsTable } from \"../schemas\"\nimport type { FeedSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\nexport const FEED_EXTRA_DATA_KEYS = [\n  \"subscriptionCount\",\n  \"updatesPerWeek\",\n  \"latestEntryPublishedAt\",\n] as const\n\nclass FeedServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(feedsTable).execute()\n  }\n  async upsertMany(feed: FeedSchema[]) {\n    if (feed.length === 0) return\n    await db\n      .insert(feedsTable)\n      .values(feed)\n      .onConflictDoUpdate({\n        target: [feedsTable.id],\n        set: conflictUpdateAllExcept(feedsTable, [\"id\", ...FEED_EXTRA_DATA_KEYS]),\n      })\n  }\n\n  getFeedAll() {\n    return db.query.feedsTable.findMany()\n  }\n\n  async patch(feedId: string, patch: Partial<FeedSchema>) {\n    await db.update(feedsTable).set(patch).where(eq(feedsTable.id, feedId)).execute()\n  }\n}\n\nexport const FeedService = new FeedServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/image.ts",
    "content": "import { db } from \"../db\"\nimport { imagesTable } from \"../schemas\"\nimport type { ImageSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\nclass ImageServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(imagesTable).execute()\n  }\n\n  async upsertMany(imageColors: ImageSchema[]) {\n    if (imageColors.length === 0) return\n    await db\n      .insert(imagesTable)\n      .values(imageColors)\n      .onConflictDoUpdate({\n        target: [imagesTable.url],\n        set: conflictUpdateAllExcept(imagesTable, [\"url\"]),\n      })\n  }\n\n  async getImageAll() {\n    return db.query.imagesTable.findMany()\n  }\n}\n\nexport const ImagesService = new ImageServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/inbox.ts",
    "content": "import { eq } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { inboxesTable } from \"../schemas\"\nimport type { InboxSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\nclass InboxServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(inboxesTable).execute()\n  }\n\n  async deleteById(id: string) {\n    await db.delete(inboxesTable).where(eq(inboxesTable.id, id)).execute()\n  }\n\n  getInboxAll() {\n    return db.query.inboxesTable.findMany()\n  }\n\n  async upsertMany(inboxes: InboxSchema[]) {\n    if (inboxes.length === 0) return\n    await db\n      .insert(inboxesTable)\n      .values(inboxes)\n      .onConflictDoUpdate({\n        target: [inboxesTable.id],\n        set: conflictUpdateAllExcept(inboxesTable, [\"id\"]),\n      })\n  }\n}\n\nexport const InboxService = new InboxServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/internal/base.ts",
    "content": "export interface Resetable {\n  reset: () => Promise<void>\n}\n"
  },
  {
    "path": "packages/internal/database/src/services/internal/utils.ts",
    "content": "import { sql } from \"drizzle-orm\"\nimport type { SQL } from \"drizzle-orm/sql\"\nimport type { SQLiteTable } from \"drizzle-orm/sqlite-core\"\nimport { getTableColumns } from \"drizzle-orm/utils\"\n\nexport function conflictUpdateAllExcept<\n  T extends SQLiteTable,\n  E extends (keyof T[\"$inferInsert\"])[],\n>(table: T, except: E) {\n  const columns = getTableColumns(table)\n  const updateColumns = Object.entries(columns).filter(\n    ([col]) => !except.includes(col as keyof typeof table.$inferInsert),\n  )\n\n  return Object.fromEntries(\n    updateColumns.map(([colName, table]) => [colName, sql.raw(`excluded.${table.name}`)]),\n  ) as Omit<Record<keyof typeof table.$inferInsert, SQL>, E[number]>\n}\n"
  },
  {
    "path": "packages/internal/database/src/services/list.ts",
    "content": "import { eq } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { listsTable } from \"../schemas\"\nimport type { ListSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\nclass ListServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(listsTable).execute()\n  }\n\n  async upsertMany(lists: ListSchema[]) {\n    if (lists.length === 0) return\n    await db\n      .insert(listsTable)\n      .values(lists)\n      .onConflictDoUpdate({\n        target: [listsTable.id],\n        set: conflictUpdateAllExcept(listsTable, [\"id\"]),\n      })\n  }\n\n  async deleteList(listId: string) {\n    await db.delete(listsTable).where(eq(listsTable.id, listId))\n  }\n\n  getListAll() {\n    return db.query.listsTable.findMany()\n  }\n}\n\nexport const ListService = new ListServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/subscription.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { and, eq, inArray, notInArray, sql } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { feedsTable, inboxesTable, listsTable, subscriptionsTable } from \"../schemas\"\nimport type { SubscriptionSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\n\nclass SubscriptionServiceStatic implements Resetable {\n  getSubscriptionAll() {\n    return db.query.subscriptionsTable.findMany()\n  }\n\n  async reset() {\n    await db.delete(subscriptionsTable).execute()\n  }\n  async upsertMany(subscriptions: SubscriptionSchema[]) {\n    if (subscriptions.length === 0) return\n    await db\n      .insert(subscriptionsTable)\n      .values(subscriptions)\n      .onConflictDoUpdate({\n        target: [subscriptionsTable.id],\n        set: {\n          category: sql`excluded.category`,\n          createdAt: sql`excluded.created_at`,\n          feedId: sql`excluded.feed_id`,\n          isPrivate: sql`excluded.is_private`,\n          title: sql`excluded.title`,\n          userId: sql`excluded.user_id`,\n          view: sql`excluded.view`,\n        },\n      })\n  }\n\n  async patch(subscription: Partial<SubscriptionSchema> & { id: string }) {\n    await db\n      .update(subscriptionsTable)\n      .set(subscription)\n      .where(eq(subscriptionsTable.id, subscription.id))\n  }\n\n  async patchMany({ feedIds, data }: { feedIds: string[]; data: Partial<SubscriptionSchema> }) {\n    await db.update(subscriptionsTable).set(data).where(inArray(subscriptionsTable.feedId, feedIds))\n  }\n\n  async deleteNotExists(existsIds: string[], view?: FeedViewType) {\n    const notExistsIds = await db.query.subscriptionsTable.findMany({\n      where: and(\n        notInArray(subscriptionsTable.id, existsIds),\n        typeof view === \"number\" ? eq(subscriptionsTable.view, view) : undefined,\n      ),\n      columns: {\n        id: true,\n      },\n    })\n    if (notExistsIds.length === 0) return\n\n    this.delete(notExistsIds.map((s) => s.id))\n  }\n\n  async delete(id: string | string[]) {\n    const ids = Array.isArray(id) ? id : [id]\n\n    const results = await db.query.subscriptionsTable.findMany({\n      where: inArray(subscriptionsTable.id, ids),\n      columns: {\n        feedId: true,\n        listId: true,\n        type: true,\n        inboxId: true,\n      },\n    })\n\n    await db.delete(subscriptionsTable).where(inArray(subscriptionsTable.id, ids)).execute()\n\n    if (!results || results.length === 0) return\n\n    // Cleanup\n    for (const result of results) {\n      const { type, feedId, listId, inboxId } = result\n      switch (type) {\n        case \"feed\": {\n          if (!feedId) break\n          await db.delete(feedsTable).where(eq(feedsTable.id, feedId)).execute()\n          break\n        }\n        case \"list\": {\n          if (!listId) break\n          await db.delete(listsTable).where(eq(listsTable.id, listId)).execute()\n          break\n        }\n        case \"inbox\": {\n          if (!inboxId) break\n          await db.delete(inboxesTable).where(eq(inboxesTable.id, inboxId)).execute()\n          break\n        }\n      }\n    }\n  }\n}\nexport const SubscriptionService = new SubscriptionServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/summary.ts",
    "content": "import { eq } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { summariesTable } from \"../schemas\"\nimport type { SummarySchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\n\nclass SummaryServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(summariesTable)\n  }\n\n  async insertSummary(data: Omit<SummarySchema, \"createdAt\">) {\n    const updateExceptEmpty = Object.fromEntries(\n      Object.entries({\n        summary: data.summary,\n        readabilitySummary: data.readabilitySummary,\n      }).filter(([_, value]) => !!value),\n    )\n\n    await db\n      .insert(summariesTable)\n      .values({\n        ...data,\n        createdAt: new Date().toISOString(),\n      })\n      .onConflictDoUpdate({\n        target: [summariesTable.entryId, summariesTable.language],\n        set: updateExceptEmpty,\n      })\n  }\n\n  async getSummary(entryId: string) {\n    const summary = await db.query.summariesTable.findFirst({\n      where: eq(summariesTable.entryId, entryId),\n    })\n\n    return summary\n  }\n\n  async getAllSummaries() {\n    const summaries = await db.query.summariesTable.findMany()\n    return summaries\n  }\n\n  async deleteSummary(entryId: string) {\n    await db.delete(summariesTable).where(eq(summariesTable.entryId, entryId))\n  }\n}\n\nexport const summaryService = new SummaryServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/translation.ts",
    "content": "import { eq, inArray } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { translationsTable } from \"../schemas\"\nimport type { TranslationSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\n\nclass TranslationServiceStatic implements Resetable {\n  getTranslationAll() {\n    return db.query.translationsTable.findMany()\n  }\n\n  async getTranslationToHydrate() {\n    const translations = await db.query.translationsTable.findMany()\n    // Remove translations created before the last 7 days\n    const translationsToClean = translations.filter(\n      (translation) =>\n        new Date(translation.createdAt) < new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),\n    )\n    await db.delete(translationsTable).where(\n      inArray(\n        translationsTable.entryId,\n        translationsToClean.map((t) => t.entryId),\n      ),\n    )\n    return translations\n  }\n\n  async reset() {\n    await db.delete(translationsTable).execute()\n  }\n\n  async insertTranslation(data: Omit<TranslationSchema, \"createdAt\">) {\n    const updateExceptEmpty = Object.fromEntries(\n      Object.entries({\n        title: data.title,\n        description: data.description,\n        content: data.content,\n        readabilityContent: data.readabilityContent,\n      }).filter(([_, value]) => !!value),\n    )\n\n    await db\n      .insert(translationsTable)\n      .values({\n        ...data,\n        createdAt: new Date().toISOString(),\n      })\n      .onConflictDoUpdate({\n        target: [translationsTable.entryId, translationsTable.language],\n        set: updateExceptEmpty,\n      })\n  }\n\n  async deleteTranslation(entryId: string) {\n    await db.delete(translationsTable).where(eq(translationsTable.entryId, entryId))\n  }\n}\n\nexport const TranslationService = new TranslationServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/unread.ts",
    "content": "import { db } from \"../db\"\nimport { unreadTable } from \"../schemas\"\nimport type { UnreadSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\ninterface UnreadUpdateOptions {\n  reset?: boolean\n}\n\nclass UnreadServiceStatic implements Resetable {\n  async reset() {\n    await db.delete(unreadTable).execute()\n  }\n\n  async getUnreadAll() {\n    return db.query.unreadTable.findMany()\n  }\n\n  async upsertMany(unreads: UnreadSchema[], options?: UnreadUpdateOptions) {\n    if (unreads.length === 0) return\n    if (options?.reset) {\n      await db.delete(unreadTable).execute()\n    }\n    await db\n      .insert(unreadTable)\n      .values(unreads)\n      .onConflictDoUpdate({\n        target: [unreadTable.id],\n        set: conflictUpdateAllExcept(unreadTable, [\"id\"]),\n      })\n  }\n}\n\nexport const UnreadService = new UnreadServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/services/user.ts",
    "content": "import { eq } from \"drizzle-orm\"\n\nimport { db } from \"../db\"\nimport { usersTable } from \"../schemas\"\nimport type { UserSchema } from \"../schemas/types\"\nimport type { Resetable } from \"./internal/base\"\nimport { conflictUpdateAllExcept } from \"./internal/utils\"\n\nclass UserServiceStatic implements Resetable {\n  getUserAll() {\n    return db.query.usersTable.findMany()\n  }\n\n  async upsertMany(users: UserSchema[]) {\n    if (users.length === 0) return\n    await db\n      .insert(usersTable)\n      .values(users)\n      .onConflictDoUpdate({\n        target: [usersTable.id],\n        set: conflictUpdateAllExcept(usersTable, [\"id\"]),\n      })\n  }\n\n  async removeCurrentUser() {\n    await db.update(usersTable).set({ isMe: false }).where(eq(usersTable.isMe, true))\n  }\n\n  async reset() {\n    await db.delete(usersTable).execute()\n  }\n}\n\nexport const UserService = new UserServiceStatic()\n"
  },
  {
    "path": "packages/internal/database/src/types.ts",
    "content": "import type { BaseSQLiteDatabase } from \"drizzle-orm/sqlite-core/db\"\n\nimport type * as schema from \"./schemas\"\n\nexport type DB =\n  | BaseSQLiteDatabase<\"async\", any, typeof schema>\n  | BaseSQLiteDatabase<\"sync\", any, typeof schema>\n"
  },
  {
    "path": "packages/internal/database/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"types\": [\"vite/client\"]\n  },\n  \"include\": [\"src/**/*\", \"drizzle.config.ts\"]\n}\n"
  },
  {
    "path": "packages/internal/hooks/package.json",
    "content": "{\n  \"name\": \"@follow/hooks\",\n  \"private\": true,\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": {\n      \"import\": \"./src/index.ts\",\n      \"require\": \"./src/index.js\"\n    }\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"foxact\": \"0.2.52\",\n    \"jotai\": \"2.17.1\",\n    \"usehooks-ts\": \"3.1.1\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/factory/createHTMLMediaHook.ts",
    "content": "/**\n * @see https://github.com/streamich/react-use/blob/master/src/factory/createHTMLMediaHook.ts\n */\nimport { noop } from \"foxact/noop\"\nimport * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\nimport { useSetState } from \"../useSetState\"\n\nfunction parseTimeRanges(ranges: TimeRanges) {\n  const result: { start: number; end: number }[] = []\n\n  for (let i = 0; i < ranges.length; i++) {\n    result.push({\n      start: ranges.start(i),\n      end: ranges.end(i),\n    })\n  }\n\n  return result\n}\nexport interface HTMLMediaProps\n  extends React.AudioHTMLAttributes<any>, React.VideoHTMLAttributes<any> {\n  src: string\n}\n\nexport interface HTMLMediaState {\n  buffered: any[]\n  duration: number\n  paused: boolean\n  muted: boolean\n  time: number\n  volume: number\n  playing: boolean\n\n  hasAudio: boolean\n}\n\nexport interface HTMLMediaControls {\n  play: () => Promise<void> | void\n  pause: () => void\n  mute: () => void\n  unmute: () => void\n  volume: (volume: number) => void\n  seek: (time: number) => void\n}\n\ntype MediaPropsWithRef<T> = HTMLMediaProps & {\n  ref?: React.MutableRefObject<T | null>\n}\n\nexport default function createHTMLMediaHook<T extends HTMLAudioElement | HTMLVideoElement>(\n  tag: \"audio\" | \"video\",\n) {\n  return (elOrProps: HTMLMediaProps | React.ReactElement<HTMLMediaProps>) => {\n    let element: React.ReactElement<MediaPropsWithRef<T>> | undefined\n    let props: MediaPropsWithRef<T>\n\n    if (React.isValidElement(elOrProps)) {\n      element = elOrProps\n      props = element.props\n    } else {\n      props = elOrProps\n    }\n\n    const [state, setState] = useSetState<HTMLMediaState>({\n      buffered: [],\n      time: 0,\n      duration: 0,\n      paused: true,\n      muted: false,\n      volume: 1,\n      playing: false,\n      hasAudio: false,\n    })\n    const getState = useEventCallback(() => state)\n    const ref = useRef<T | null>(null)\n\n    const wrapEvent = (userEvent: any, proxyEvent?: any) => (event: any) => {\n      try {\n        proxyEvent && proxyEvent(event)\n      } finally {\n        userEvent && userEvent(event)\n      }\n    }\n\n    const onPlay = () => setState({ paused: false })\n    const onPlaying = () => setState({ playing: true })\n    const onWaiting = () => setState({ playing: false })\n    const onPause = () => setState({ paused: true, playing: false })\n    const onVolumeChange = () => {\n      const el = ref.current\n      if (!el) {\n        return\n      }\n      setState({\n        muted: el.muted,\n        volume: el.volume,\n      })\n    }\n    const onDurationChange = () => {\n      const el = ref.current\n      if (!el) {\n        return\n      }\n      const { duration, buffered } = el\n      setState({\n        duration,\n        buffered: parseTimeRanges(buffered),\n      })\n    }\n    const onTimeUpdate = () => {\n      const el = ref.current\n      if (!el) {\n        return\n      }\n      setState({ time: el.currentTime })\n    }\n    const onProgress = () => {\n      const el = ref.current\n      if (!el) {\n        return\n      }\n      setState({ buffered: parseTimeRanges(el.buffered) })\n    }\n    const onCanPlay = (e: React.SyntheticEvent<HTMLVideoElement>) => {\n      const target = e.currentTarget\n\n      const hasAudio =\n        target.srcObject instanceof MediaStream\n          ? target.srcObject.getAudioTracks().length > 0\n          : target.webkitAudioDecodedByteCount === undefined\n            ? true\n            : target.webkitAudioDecodedByteCount > 0\n\n      setState({ hasAudio })\n    }\n\n    if (element) {\n      element = React.cloneElement(element, {\n        controls: false,\n        ...props,\n        ref,\n        onPlay: wrapEvent(props.onPlay, onPlay),\n        onPlaying: wrapEvent(props.onPlaying, onPlaying),\n        onWaiting: wrapEvent(props.onWaiting, onWaiting),\n        onPause: wrapEvent(props.onPause, onPause),\n        onVolumeChange: wrapEvent(props.onVolumeChange, onVolumeChange),\n        onDurationChange: wrapEvent(props.onDurationChange, onDurationChange),\n        onTimeUpdate: wrapEvent(props.onTimeUpdate, onTimeUpdate),\n        onProgress: wrapEvent(props.onProgress, onProgress),\n        onCanPlay: wrapEvent(props.onCanPlay, onCanPlay),\n      })\n    } else {\n      element = React.createElement(tag, {\n        controls: false,\n        ...props,\n        ref,\n        onPlay: wrapEvent(props.onPlay, onPlay),\n        onPlaying: wrapEvent(props.onPlaying, onPlaying),\n        onWaiting: wrapEvent(props.onWaiting, onWaiting),\n        onPause: wrapEvent(props.onPause, onPause),\n        onVolumeChange: wrapEvent(props.onVolumeChange, onVolumeChange),\n        onDurationChange: wrapEvent(props.onDurationChange, onDurationChange),\n        onTimeUpdate: wrapEvent(props.onTimeUpdate, onTimeUpdate),\n        onProgress: wrapEvent(props.onProgress, onProgress),\n        onCanPlay: wrapEvent(props.onCanPlay, onCanPlay),\n      } as any) // TODO: fix this typing.\n    }\n\n    // Some browsers return `Promise` on `.play()` and may throw errors\n    // if one tries to execute another `.play()` or `.pause()` while that\n    // promise is resolving. So we prevent that with this lock.\n    // See: https://bugs.chromium.org/p/chromium/issues/detail?id=593273\n    let lockPlay = false\n\n    const controls = React.useState(() => ({\n      play: () => {\n        const el = ref.current\n        if (!el) {\n          return\n        }\n\n        if (!lockPlay) {\n          const promise = el.play().catch(noop)\n          const isPromise = typeof promise === \"object\"\n\n          if (isPromise) {\n            lockPlay = true\n            const resetLock = () => {\n              lockPlay = false\n            }\n            promise.then(resetLock, resetLock)\n          }\n\n          return promise\n        }\n        return\n      },\n      pause: () => {\n        const el = ref.current\n        if (el && !lockPlay) {\n          return el.pause()\n        }\n      },\n      seek: (time: number) => {\n        const state = getState()\n        const el = ref.current\n        if (!el || state.duration === undefined) {\n          return\n        }\n        time = Math.min(state.duration, Math.max(0, time))\n        el.currentTime = time\n      },\n      volume: (volume: number) => {\n        const el = ref.current\n        if (!el) {\n          return\n        }\n        volume = Math.min(1, Math.max(0, volume))\n        el.volume = volume\n        setState({ volume })\n      },\n      mute: () => {\n        const el = ref.current\n        if (!el) {\n          return\n        }\n        el.muted = true\n      },\n      unmute: () => {\n        const el = ref.current\n        if (!el) {\n          return\n        }\n        el.muted = false\n      },\n    }))[0]\n\n    useEffect(() => {\n      const el = ref.current!\n\n      if (!el) {\n        if (import.meta.env.DEV) {\n          if (tag === \"audio\") {\n            console.error(\n              \"useAudio() ref to <audio> element is empty at mount. \" +\n                \"It seem you have not rendered the audio element, which it \" +\n                \"returns as the first argument const [audio] = useAudio(...).\",\n            )\n          } else if (tag === \"video\") {\n            console.error(\n              \"useVideo() ref to <video> element is empty at mount. \" +\n                \"It seem you have not rendered the video element, which it \" +\n                \"returns as the first argument const [video] = useVideo(...).\",\n            )\n          }\n        }\n        return\n      }\n\n      setState({\n        volume: el.volume,\n        muted: el.muted,\n        paused: el.paused,\n      })\n\n      // Start media, if autoPlay requested.\n      if (props.autoPlay && el.paused) {\n        controls.play()\n      }\n    }, [props.src])\n\n    return [element, state, controls, ref] as const\n  }\n}\n\ndeclare global {\n  interface HTMLVideoElement {\n    /*\n     * @see https://newbedev.com/html5-video-how-to-detect-when-there-is-no-audio-track\n     */\n    webkitAudioDecodedByteCount?: number\n  }\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/index.ts",
    "content": "export type { HTMLMediaState } from \"./factory/createHTMLMediaHook\"\nexport * from \"./optimistic\"\nexport * from \"./useAnyPointDown\"\nexport * from \"./useControlled\"\nexport * from \"./useCountDown\"\nexport * from \"./useDark\"\nexport * from \"./useElementWidth\"\nexport * from \"./useInputComposition\"\nexport * from \"./useInterval\"\nexport * from \"./useIsOnline\"\nexport * from \"./useLongPress\"\nexport * from \"./useMeasure\"\nexport * from \"./useOnce\"\nexport * from \"./usePageVisibility\"\nexport * from \"./usePrevious\"\nexport * from \"./useRefValue\"\nexport * from \"./useSetState\"\nexport * from \"./useSmoothScroll\"\nexport * from \"./useSyncTheme\"\nexport * from \"./useTitle\"\nexport * from \"./useTriangleMenu\"\nexport * from \"./useTypescriptHappyCallback\"\nexport * from \"./useVideo\"\n"
  },
  {
    "path": "packages/internal/hooks/src/internal/for-theme.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\nimport { atom } from \"jotai\"\nimport { atomWithStorage } from \"jotai/utils\"\nimport { useMediaQuery } from \"usehooks-ts\"\n\nexport const useDarkQuery = () => useMediaQuery(\"(prefers-color-scheme: dark)\")\nexport type ColorMode = \"light\" | \"dark\" | \"system\"\n\ndeclare const window: any\nexport const themeAtom = !window.electron\n  ? atomWithStorage(getStorageNS(\"color-mode\"), \"system\" as ColorMode, undefined, {\n      getOnInit: true,\n    })\n  : atom(\"system\" as ColorMode)\n"
  },
  {
    "path": "packages/internal/hooks/src/optimistic/config.ts",
    "content": "import type { MutationFunction, QueryKey } from \"@tanstack/react-query\"\n\nimport { optimisticStrategies } from \"./strategies\"\nimport type { OptimisticItem, OptimisticKey, OptimisticMutationConfig } from \"./types\"\n\n/**\n * Configuration builders for common optimistic update patterns\n * These provide type-safe, pre-configured optimistic mutation setups\n */\nexport const createOptimisticConfig = {\n  /**\n   * Configuration for create operations\n   * Adds new items to the beginning of the list\n   */\n  forCreate: <TData extends OptimisticItem, TVariables, TResponse = any>(config: {\n    mutationFn: MutationFunction<TResponse, TVariables>\n    queryKey: QueryKey\n    generateOptimistic: (variables: TVariables) => Omit<TData, \"id\" | OptimisticKey | \"updatedAt\">\n    onSuccess?: (result: TResponse, variables: TVariables) => void | Promise<void>\n    errorMessage?: string\n    retryable?: boolean\n    maxRetries?: number\n  }): OptimisticMutationConfig<TData, TVariables, TResponse> => ({\n    mutationFn: config.mutationFn,\n    queryKey: config.queryKey,\n    onSuccess: config.onSuccess,\n    ...optimisticStrategies.create<TData, TResponse>(\n      (variables: TVariables) =>\n        ({\n          ...config.generateOptimistic(variables),\n          createdAt: new Date().toISOString(),\n        }) as Omit<TData, \"id\" | OptimisticKey>,\n    ),\n    errorConfig: {\n      showToast: true,\n      customMessage: config.errorMessage || \"Failed to create item\",\n      retryable: config.retryable ?? false,\n      maxRetries: config.maxRetries ?? 0,\n    },\n  }),\n\n  /**\n   * Configuration for update operations\n   * Updates existing items in place\n   */\n  forUpdate: <\n    TData extends OptimisticItem,\n    TVariables extends Record<string, any>,\n    TResponse = any,\n  >(config: {\n    mutationFn: MutationFunction<TResponse, TVariables>\n    queryKey: QueryKey\n    getId: (variables: TVariables) => string\n    onSuccess?: (result: TResponse, variables: TVariables) => void | Promise<void>\n    errorMessage?: string\n    retryable?: boolean\n    maxRetries?: number\n  }): OptimisticMutationConfig<TData, TVariables, TResponse> => ({\n    mutationFn: config.mutationFn,\n    queryKey: config.queryKey,\n    onSuccess: config.onSuccess,\n    ...optimisticStrategies.update<TData, TResponse>(config.getId),\n    errorConfig: {\n      showToast: true,\n      customMessage: config.errorMessage || \"Failed to update item\",\n      retryable: config.retryable ?? false,\n      maxRetries: config.maxRetries ?? 0,\n    },\n  }),\n\n  /**\n   * Configuration for delete operations\n   * Removes items from the list\n   */\n  forDelete: <TData extends OptimisticItem, TVariables, TResponse = any>(config: {\n    mutationFn: MutationFunction<TResponse, TVariables>\n    queryKey: QueryKey\n    getId: (variables: TVariables) => string\n    onSuccess?: (result: TResponse, variables: TVariables) => void | Promise<void>\n    errorMessage?: string\n    retryable?: boolean\n  }): OptimisticMutationConfig<TData, TVariables, TResponse> => ({\n    mutationFn: config.mutationFn,\n    queryKey: config.queryKey,\n    onSuccess: config.onSuccess,\n    ...optimisticStrategies.delete<TData, TResponse>(config.getId),\n    errorConfig: {\n      showToast: true,\n      customMessage: config.errorMessage || \"Failed to delete item\",\n      retryable: config.retryable ?? false,\n      maxRetries: 0, // Usually don't retry deletes\n    },\n  }),\n\n  /**\n   * Configuration for toggle operations\n   * Updates specific properties while preserving others\n   */\n  forToggle: <TData extends OptimisticItem, TVariables, TResponse = any>(config: {\n    mutationFn: MutationFunction<TResponse, TVariables>\n    queryKey: QueryKey\n    getId: (variables: TVariables) => string\n    getToggleData: (variables: TVariables) => Partial<TData>\n    onSuccess?: (result: TResponse, variables: TVariables) => void | Promise<void>\n    errorMessage?: string\n    retryable?: boolean\n  }): OptimisticMutationConfig<TData, TVariables, TResponse> => ({\n    mutationFn: config.mutationFn,\n    queryKey: config.queryKey,\n    onSuccess: config.onSuccess,\n    ...optimisticStrategies.toggle<TData, TResponse>(config.getId, config.getToggleData),\n    errorConfig: {\n      showToast: true,\n      customMessage: config.errorMessage || \"Failed to toggle item\",\n      retryable: config.retryable ?? true,\n      maxRetries: 0,\n    },\n  }),\n\n  /**\n   * Configuration for custom operations\n   * Provides full control over optimistic update behavior\n   */\n  custom: <TData extends OptimisticItem, TVariables, TResponse = any, TContext = unknown>(\n    config: OptimisticMutationConfig<TData, TVariables, TResponse, TContext>,\n  ): OptimisticMutationConfig<TData, TVariables, TResponse, TContext> => ({\n    errorConfig: {\n      showToast: true,\n      retryable: false,\n      maxRetries: 0,\n    },\n    ...config,\n  }),\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/optimistic/index.ts",
    "content": "export { createOptimisticConfig } from \"./config\"\nexport { optimisticStrategies } from \"./strategies\"\nexport type {\n  OptimisticContext,\n  OptimisticItem,\n  OptimisticMutationConfig,\n  StrategyConfig,\n  WithOptimistic,\n} from \"./types\"\nexport { useOptimisticMutation } from \"./useOptimisticMutation\"\n"
  },
  {
    "path": "packages/internal/hooks/src/optimistic/strategies.ts",
    "content": "import type { OptimisticItem, OptimisticKey, StrategyConfig } from \"./types\"\nimport { optimisticKey, optimisticStatusKey } from \"./types\"\n\n/**\n * Predefined optimistic update strategies for common operations\n */\nexport const optimisticStrategies = {\n  /**\n   * Strategy for creating new items\n   * Adds the new item to the beginning of the list with a temporary ID\n   */\n  create: <T extends OptimisticItem, TResponse = T>(\n    generateOptimisticItem: (variables: any) => Omit<T, \"id\" | OptimisticKey>,\n  ): StrategyConfig<T, TResponse> => ({\n    optimisticUpdater: (variables: any, previousData: T[]) => {\n      const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`\n      const optimisticItem = {\n        ...generateOptimisticItem(variables),\n        id: tempId,\n        [optimisticKey]: true,\n        [optimisticStatusKey]: \"pending\",\n        updatedAt: new Date().toISOString(),\n      } as T\n\n      return {\n        newData: [optimisticItem, ...previousData],\n        rollbackData: previousData,\n        tempId,\n      }\n    },\n\n    successUpdater: (result: TResponse, variables: any, previousData: T[], context: any) => {\n      return previousData.map((item) =>\n        item.id === context?.tempId\n          ? { ...item, ...(result as any), [optimisticKey]: false }\n          : item,\n      )\n    },\n  }),\n\n  /**\n   * Strategy for updating existing items\n   * Updates the item in place and marks it as optimistic\n   */\n  update: <T extends OptimisticItem, TResponse = T>(\n    getTargetId: string | ((variables: any) => string),\n  ): StrategyConfig<T, TResponse> => ({\n    optimisticUpdater: (variables: any, previousData: T[]) => {\n      const targetId = typeof getTargetId === \"function\" ? getTargetId(variables) : getTargetId\n\n      const newData = previousData.map((item) =>\n        item.id === targetId\n          ? {\n              ...item,\n              ...variables,\n              [optimisticKey]: true,\n              [optimisticStatusKey]: \"updating\",\n              updatedAt: new Date().toISOString(),\n            }\n          : item,\n      )\n\n      return {\n        newData,\n        rollbackData: previousData,\n        targetId,\n      }\n    },\n\n    successUpdater: (result: TResponse, variables: any, previousData: T[]) => {\n      const targetId = typeof getTargetId === \"function\" ? getTargetId(variables) : getTargetId\n      return previousData.map((item) =>\n        item.id === targetId ? { ...item, ...(result as any), [optimisticKey]: false } : item,\n      )\n    },\n  }),\n\n  /**\n   * Strategy for deleting items\n   * Removes the item from the list immediately\n   */\n  delete: <T extends OptimisticItem, TResponse = any>(\n    getId: (variables: any) => string,\n  ): StrategyConfig<T, TResponse> => ({\n    optimisticUpdater: (variables: any, previousData: T[]) => {\n      const targetId = getId(variables)\n      const newData = previousData.filter((item) => item.id !== targetId)\n\n      return {\n        newData,\n        rollbackData: previousData,\n        targetId,\n      }\n    },\n  }),\n\n  /**\n   * Strategy for toggling item properties\n   * Updates specific properties while keeping the rest unchanged\n   */\n  toggle: <T extends OptimisticItem, TResponse = any>(\n    getId: (variables: any) => string,\n    getToggleData: (variables: any) => Partial<T>,\n  ): StrategyConfig<T, TResponse> => ({\n    optimisticUpdater: (variables: any, previousData: T[]) => {\n      const targetId = getId(variables)\n      const toggleData = getToggleData(variables)\n\n      const newData = previousData.map((item) =>\n        item.id === targetId\n          ? {\n              ...item,\n              ...toggleData,\n              [optimisticKey]: true,\n              [optimisticStatusKey]: \"updating\",\n\n              updatedAt: new Date().toISOString(),\n            }\n          : item,\n      )\n\n      return {\n        newData,\n        rollbackData: previousData,\n        targetId,\n      }\n    },\n\n    successUpdater: (result: TResponse, variables: any, previousData: T[]) => {\n      const targetId = getId(variables)\n      return previousData.map((item) =>\n        item.id === targetId ? { ...item, ...(result as any), [optimisticKey]: false } : item,\n      )\n    },\n  }),\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/optimistic/types.ts",
    "content": "import type { MutationFunction, QueryKey } from \"@tanstack/react-query\"\n\nexport const optimisticKey = Symbol(\"optimistic\")\nexport const optimisticStatusKey = Symbol(\"optimisticStatus\")\n\nexport type OptimisticKey = typeof optimisticKey\nexport type OptimisticStatusKey = typeof optimisticStatusKey\n// Base interface for items that support optimistic updates\nexport interface OptimisticItem {\n  id: string\n  // Use prefixed properties to minimize conflicts\n  [optimisticKey]?: boolean\n  [optimisticStatusKey]?: \"pending\" | \"updating\" | \"error\" | \"connected\"\n  updatedAt?: string\n}\n\n// Generic optimistic wrapper type\nexport type WithOptimistic<T extends { id: string }> = T & {\n  [optimisticKey]?: boolean\n  [optimisticStatusKey]?: \"pending\" | \"updating\" | \"error\" | \"connected\"\n}\n\n// Core optimistic update configuration\nexport interface OptimisticMutationConfig<\n  TData extends OptimisticItem,\n  TVariables,\n  TResponse = any,\n  TContext = unknown,\n> {\n  // Basic configuration - mutationFn can return any response type\n  mutationFn: MutationFunction<TResponse, TVariables>\n  queryKey: QueryKey\n\n  // Optimistic update strategy\n  optimisticUpdater: (\n    variables: TVariables,\n    previousData: TData[],\n  ) => {\n    newData: TData[]\n    rollbackData: TData[]\n    tempId?: string\n  }\n\n  // Success data mapping - handles any response type\n  successUpdater?: (\n    result: TResponse,\n    variables: TVariables,\n    previousData: TData[],\n    context?: TContext,\n  ) => TData[]\n\n  // Error handling configuration\n  errorConfig?: {\n    showToast?: boolean\n    customMessage?: string\n    retryable?: boolean\n    maxRetries?: number\n  }\n\n  // Success callback - receives the actual API response\n  onSuccess?: (result: TResponse, variables: TVariables) => void | Promise<void>\n}\n\n// Optimistic update context returned from onMutate\nexport interface OptimisticContext {\n  rollbackData: any[]\n  tempId?: string\n  previousData: any[]\n  targetId?: string\n}\n\n// Strategy configuration for different operation types\nexport interface StrategyConfig<T extends OptimisticItem, TResponse = any> {\n  optimisticUpdater: OptimisticMutationConfig<T, any, TResponse>[\"optimisticUpdater\"]\n  successUpdater?: OptimisticMutationConfig<T, any, TResponse>[\"successUpdater\"]\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/optimistic/useOptimisticMutation.ts",
    "content": "import { useMutation, useQueryClient } from \"@tanstack/react-query\"\nimport { toast } from \"sonner\"\n\nimport type { OptimisticContext, OptimisticItem, OptimisticMutationConfig } from \"./types\"\n\n/**\n * Generic optimistic update mutation hook\n * Provides automatic optimistic updates with error rollback for any mutation\n */\nexport function useOptimisticMutation<\n  TData extends OptimisticItem,\n  TVariables,\n  TResponse = any,\n  TContext = OptimisticContext,\n>(config: OptimisticMutationConfig<TData, TVariables, TResponse, TContext>) {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: config.mutationFn,\n\n    onMutate: async (variables: TVariables) => {\n      // Cancel any outgoing queries to prevent race conditions\n      await queryClient.cancelQueries({ queryKey: config.queryKey })\n\n      // Get previous data for rollback\n      const previousData = queryClient.getQueryData<TData[]>(config.queryKey) || []\n\n      // Execute optimistic update\n      const { newData, rollbackData, tempId } = config.optimisticUpdater(variables, previousData)\n\n      // Update query cache with optimistic data\n      queryClient.setQueryData(config.queryKey, newData)\n\n      return {\n        rollbackData,\n        tempId,\n        previousData,\n        variables,\n      } as TContext\n    },\n\n    onSuccess: async (result, variables, context) => {\n      // Apply success updater if provided, otherwise invalidate queries\n      if (config.successUpdater) {\n        const currentData = queryClient.getQueryData<TData[]>(config.queryKey) || []\n        const updatedData = config.successUpdater(result, variables, currentData, context)\n        queryClient.setQueryData(config.queryKey, updatedData)\n      }\n\n      // Execute custom success callback\n      await config.onSuccess?.(result, variables)\n    },\n\n    onError: (error, _variables, context: TContext | undefined) => {\n      // Rollback to previous state\n      const rollbackData = (context as any)?.rollbackData\n      if (rollbackData) {\n        queryClient.setQueryData(config.queryKey, rollbackData)\n      }\n\n      // Show error toast if configured\n      if (config.errorConfig?.showToast !== false) {\n        const message = config.errorConfig?.customMessage || \"Operation failed\"\n        toast.error(message)\n      }\n\n      console.error(\"Optimistic mutation failed:\", error)\n    },\n\n    onSettled: () => {\n      // Ensure eventual consistency by invalidating queries\n      queryClient.invalidateQueries({ queryKey: config.queryKey })\n    },\n\n    // Configure retry behavior\n    retry: config.errorConfig?.retryable ? (config.errorConfig.maxRetries ?? 2) : false,\n\n    retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 5000),\n  })\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useAnyPointDown.ts",
    "content": "import { useEventListener } from \"usehooks-ts\"\n\nexport const useAnyPointDown = (handler: (event: PointerEvent) => void) => {\n  useEventListener(\"pointerdown\", (event) => {\n    handler(event)\n  })\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useControlled.ts",
    "content": "import { useCallback, useState } from \"react\"\n\nimport { useRefValue } from \"./useRefValue\"\n\nexport const useControlled = <T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (v: T, ...args: any[]) => void,\n): [T, (value: T) => void] => {\n  const [stateValue, setStateValue] = useState(value !== undefined ? value : defaultValue)\n  const isControlled = value !== undefined\n  const onChangeRef = useRefValue(onChange)\n\n  const setValue = useCallback(\n    (newValue: T) => {\n      if (!isControlled) {\n        setStateValue(newValue)\n      }\n      onChangeRef.current?.(newValue)\n    },\n    [isControlled, onChangeRef],\n  )\n\n  return [isControlled ? value : stateValue, setValue]\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useCountDown.ts",
    "content": "// credits: https://usehooks-ts.com/react-hook/use-countdown\n\nimport { useCallback } from \"react\"\nimport { useBoolean, useCounter, useInterval } from \"usehooks-ts\"\n\ntype CountdownOptions = {\n  countStart: number\n\n  intervalMs?: number\n  isIncrement?: boolean\n  autoStart?: boolean\n\n  countStop?: number\n}\n\ntype CountdownControllers = {\n  startCountdown: () => void\n  stopCountdown: () => void\n  resetCountdown: () => void\n}\n\nexport function useCountdown({\n  countStart,\n  countStop = 0,\n  intervalMs = 1000,\n  isIncrement = false,\n  autoStart = true,\n}: CountdownOptions): [number, CountdownControllers] {\n  const { count, increment, decrement, reset: resetCounter } = useCounter(countStart)\n\n  /*\n   * Note: used to control the useInterval\n   * running: If true, the interval is running\n   * start: Should set running true to trigger interval\n   * stop: Should set running false to remove interval.\n   */\n  const {\n    value: isCountdownRunning,\n    setTrue: startCountdown,\n    setFalse: stopCountdown,\n  } = useBoolean(autoStart)\n\n  // Will set running false and reset the seconds to initial value.\n  const resetCountdown = useCallback(() => {\n    stopCountdown()\n    resetCounter()\n  }, [stopCountdown, resetCounter])\n\n  const countdownCallback = useCallback(() => {\n    if (count === countStop) {\n      stopCountdown()\n      return\n    }\n\n    if (isIncrement) {\n      increment()\n    } else {\n      decrement()\n    }\n  }, [count, countStop, decrement, increment, isIncrement, stopCountdown])\n\n  useInterval(countdownCallback, isCountdownRunning ? intervalMs : null)\n\n  return [count, { startCountdown, stopCountdown, resetCountdown }]\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useDark.ts",
    "content": "import { useAtomValue } from \"jotai\"\n\nimport { themeAtom, useDarkQuery } from \"./internal/for-theme\"\n\nfunction useDarkWebApp() {\n  const systemIsDark = useDarkQuery()\n  const mode = useAtomValue(themeAtom)\n  return mode === \"dark\" || (mode === \"system\" && systemIsDark)\n}\n\nexport const useIsDark = useDarkWebApp\n\nexport const useThemeAtomValue = () => useAtomValue(themeAtom)\n\nexport type { ColorMode } from \"./internal/for-theme\"\nexport { useDarkQuery } from \"./internal/for-theme\"\n"
  },
  {
    "path": "packages/internal/hooks/src/useElementWidth.ts",
    "content": "import type { RefObject } from \"react\"\nimport { startTransition, useLayoutEffect, useState } from \"react\"\n\n/**\n * Hook to track the width of an element using ResizeObserver\n * @param ref - RefObject pointing to the element to observe\n * @returns The current width of the element (0 if element is not available)\n */\nexport function useElementWidth<T extends HTMLElement>(ref: RefObject<T | null>): number {\n  const [width, setWidth] = useState<number>(0)\n\n  useLayoutEffect(() => {\n    if (!ref.current) return\n\n    const updateWidth = (newWidth: number) => {\n      startTransition(() => {\n        setWidth(newWidth)\n      })\n    }\n\n    // Set initial width\n    updateWidth(ref.current.clientWidth)\n\n    // Create ResizeObserver to track width changes\n    const resizeObserver = new ResizeObserver(() => {\n      if (ref.current) {\n        updateWidth(ref.current.clientWidth)\n      }\n    })\n\n    resizeObserver.observe(ref.current)\n\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [ref])\n\n  return width\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useInputComposition.ts",
    "content": "import type { CompositionEventHandler } from \"react\"\nimport { useCallback, useEffect, useRef } from \"react\"\n\ntype InputElementAttributes = React.DetailedHTMLProps<\n  React.InputHTMLAttributes<HTMLInputElement>,\n  HTMLInputElement\n>\ntype TextareaElementAttributes = React.DetailedHTMLProps<\n  React.TextareaHTMLAttributes<HTMLTextAreaElement>,\n  HTMLTextAreaElement\n>\nexport const useInputComposition = <E = HTMLInputElement>(\n  props: Pick<\n    E extends HTMLInputElement\n      ? InputElementAttributes\n      : E extends HTMLTextAreaElement\n        ? TextareaElementAttributes\n        : never,\n    \"onKeyDown\" | \"onCompositionEnd\" | \"onCompositionStart\" | \"onKeyDownCapture\"\n  >,\n) => {\n  const { onKeyDown, onCompositionStart, onCompositionEnd } = props\n\n  const isCompositionRef = useRef(false)\n\n  const currentInputTargetRef = useRef<E | null>(null)\n\n  const handleCompositionStart: CompositionEventHandler<E> = useCallback(\n    (e) => {\n      currentInputTargetRef.current = e.target as E\n\n      isCompositionRef.current = true\n      onCompositionStart?.(e as any)\n    },\n    [onCompositionStart],\n  )\n\n  const handleCompositionEnd: CompositionEventHandler<E> = useCallback(\n    (e) => {\n      currentInputTargetRef.current = null\n      isCompositionRef.current = false\n      onCompositionEnd?.(e as any)\n    },\n    [onCompositionEnd],\n  )\n\n  const handleKeyDown: React.KeyboardEventHandler<E> = useCallback(\n    (e: any) => {\n      // The keydown event stop emit when the composition is being entered\n      if (isCompositionRef.current) {\n        e.stopPropagation()\n        return\n      }\n      onKeyDown?.(e)\n\n      if (e.key === \"Escape\") {\n        e.preventDefault()\n        e.stopPropagation()\n\n        if (!isCompositionRef.current) {\n          e.currentTarget.blur()\n        }\n      }\n    },\n    [onKeyDown],\n  )\n\n  // Register a global capture keydown listener to prevent the radix `useEscapeKeydown` from working\n  useEffect(() => {\n    const handleGlobalKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && currentInputTargetRef.current) {\n        e.stopPropagation()\n        e.preventDefault()\n      }\n    }\n\n    document.addEventListener(\"keydown\", handleGlobalKeyDown, { capture: true })\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleGlobalKeyDown, { capture: true })\n    }\n  }, [])\n\n  const ret = {\n    onCompositionEnd: handleCompositionEnd,\n    onCompositionStart: handleCompositionStart,\n    onKeyDown: handleKeyDown,\n  }\n  Object.defineProperty(ret, \"isCompositionRef\", {\n    value: isCompositionRef,\n    enumerable: false,\n  })\n  return ret as typeof ret & {\n    isCompositionRef: typeof isCompositionRef\n  }\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useInterval.ts",
    "content": "import { useEffect, useRef } from \"react\"\nimport { useIsomorphicLayoutEffect } from \"usehooks-ts\"\n\nexport function useAccurateInterval(\n  callback: () => void,\n  options: {\n    delay: number\n    enable?: boolean\n    immediately?: boolean\n  },\n) {\n  const { delay, enable = true, immediately = true } = options\n  const savedCallback = useRef(callback)\n  const nextTick = useRef(Date.now() + (delay || 0))\n\n  const triggerCountRef = useRef(0)\n\n  const timerRef = useRef<any | null | undefined>(null)\n\n  // Remember the latest callback if it changes.\n  useIsomorphicLayoutEffect(() => {\n    savedCallback.current = callback\n  }, [callback])\n\n  // Set up the interval.\n  useEffect(() => {\n    if (!enable) return\n    // Don't schedule if no delay is specified.\n    // Note: 0 is a valid value for delay.\n    if (!delay && delay !== 0) {\n      return\n    }\n\n    function tick() {\n      if (immediately || triggerCountRef.current > 0) {\n        savedCallback.current()\n      }\n      triggerCountRef.current++\n\n      const now = Date.now()\n      const expectedNextTick = nextTick.current\n      const actualDelay = Math.max(0, expectedNextTick - now)\n\n      // Compensate for the time taken by the task\n      nextTick.current = now + delay + actualDelay\n      timerRef.current = setTimeout(tick, nextTick.current - now)\n    }\n\n    tick()\n\n    return () => {\n      nextTick.current = Date.now() // Reset for the next run\n      timerRef.current = clearTimeout(timerRef.current)\n    }\n  }, [delay, enable, immediately])\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useIsOnline.ts",
    "content": "import { useEffect, useState } from \"react\"\n\nexport const useIsOnline = () => {\n  const [isOnline, setIsOnline] = useState(navigator.onLine)\n\n  useEffect(() => {\n    const handleOnline = () => setIsOnline(true)\n    const handleOffline = () => setIsOnline(false)\n\n    window.addEventListener(\"online\", handleOnline)\n    window.addEventListener(\"offline\", handleOffline)\n\n    return () => {\n      window.removeEventListener(\"online\", handleOnline)\n      window.removeEventListener(\"offline\", handleOffline)\n    }\n  }, [])\n\n  return isOnline\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useLongPress.ts",
    "content": "import { useRef } from \"react\"\nimport { useEventCallback } from \"usehooks-ts\"\n\ninterface UseLongPressOptions {\n  onLongPress: (e: { pageX: number; pageY: number; target: EventTarget }) => void\n  onClick?: (e: React.TouchEvent) => void\n  onTouchMove?: (e: React.TouchEvent) => void\n  onTouchEnd?: (e: React.TouchEvent) => void\n  onTouchStart?: (e: React.TouchEvent) => void\n  threshold?: number\n}\n\n/**\n * Only for mobile touch event\n */\nexport function useLongPress({\n  onLongPress,\n  onClick,\n  threshold = 500,\n  ...events\n}: UseLongPressOptions) {\n  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)\n  const isLongPress = useRef(false)\n  const startPosition = useRef<{ x: number; y: number }>(undefined)\n\n  const onTouchStart = useEventCallback((e: React.TouchEvent) => {\n    events.onTouchStart?.(e)\n    e.preventDefault()\n    isLongPress.current = false\n    const touch = e.touches[0]!\n\n    clearTimeout(timerRef.current)\n    startPosition.current = {\n      x: touch.clientX + window.scrollX,\n      y: touch.clientY + window.scrollY,\n    }\n\n    timerRef.current = setTimeout(() => {\n      isLongPress.current = true\n      if (!startPosition.current) return\n\n      const compatEvent = {\n        pageX: startPosition.current.x,\n        pageY: startPosition.current.y,\n        target: e.target,\n        preventDefault: () => {},\n        stopPropagation: () => {},\n        clientX: touch.clientX,\n        clientY: touch.clientY,\n      }\n      const compatProperties = new Set([\n        \"pageX\",\n        \"pageY\",\n        \"target\",\n        \"preventDefault\",\n        \"stopPropagation\",\n        \"clientX\",\n        \"clientY\",\n      ])\n\n      const compatEventProxy = new Proxy(compatEvent, {\n        get: (target, prop: string) => {\n          if (compatProperties.has(prop)) {\n            return target[prop as keyof typeof target]\n          }\n          throw new Error(`Property ${prop} not implemented on compatEvent`)\n        },\n      })\n\n      onLongPress(compatEventProxy)\n    }, threshold)\n  })\n\n  const onTouchMove = useEventCallback((e: React.TouchEvent) => {\n    events.onTouchMove?.(e)\n    if (!startPosition.current) return\n\n    const touch = e.touches[0]!\n    const currentX = touch.clientX + window.scrollX\n    const currentY = touch.clientY + window.scrollY\n\n    const moveOffset = Math.sqrt(\n      Math.pow(currentX - startPosition.current.x, 2) +\n        Math.pow(currentY - startPosition.current.y, 2),\n    )\n\n    if (moveOffset > 10) {\n      clearTimeout(timerRef.current)\n      startPosition.current = undefined\n    }\n  })\n\n  const onTouchEnd = useEventCallback((e: React.TouchEvent) => {\n    events.onTouchEnd?.(e)\n    clearTimeout(timerRef.current)\n    if (!isLongPress.current && onClick) {\n      onClick(e)\n    }\n    startPosition.current = undefined\n  })\n\n  return {\n    onTouchStart,\n    onTouchMove,\n    onTouchEnd,\n  }\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useMeasure.ts",
    "content": "// @copy https://github.com/pmndrs/react-use-measure/blob/master/src/web/index.ts\n\nimport { debounce } from \"es-toolkit/compat\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\n\nconst createDebounce = debounce\ndeclare type ResizeObserverCallback = (entries: any[], observer: ResizeObserver) => void\ndeclare class ResizeObserver {\n  constructor(callback: ResizeObserverCallback)\n  observe(target: Element, options?: any): void\n  unobserve(target: Element): void\n  disconnect(): void\n  static toString(): string\n}\n\nexport interface RectReadOnly {\n  readonly x: number\n  readonly y: number\n  readonly width: number\n  readonly height: number\n  readonly top: number\n  readonly right: number\n  readonly bottom: number\n  readonly left: number\n  [key: string]: number\n}\n\ntype HTMLOrSVGElement = HTMLElement | SVGElement\n\ntype Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void]\n\ntype State = {\n  element: HTMLOrSVGElement | null\n  scrollContainers: HTMLOrSVGElement[] | null\n  resizeObserver: ResizeObserver | null\n  lastBounds: RectReadOnly\n}\n\nexport type Options = {\n  debounce?: number | { scroll: number; resize: number }\n  scroll?: boolean\n  offsetSize?: boolean\n}\n\nconst defaultOptions: Options = {\n  debounce: 0,\n  scroll: false,\n  offsetSize: false,\n}\nexport function useMeasure({ debounce, scroll, offsetSize }: Options = defaultOptions): Result {\n  const [bounds, set] = useState<RectReadOnly>({\n    left: 0,\n    top: 0,\n    width: 0,\n    height: 0,\n    bottom: 0,\n    right: 0,\n    x: 0,\n    y: 0,\n  })\n\n  // keep all state in a ref\n  const state = useRef<State>({\n    element: null,\n    scrollContainers: null,\n    resizeObserver: null,\n    lastBounds: bounds,\n  })\n\n  // set actual debounce values early, so effects know if they should react accordingly\n  const scrollDebounce = debounce\n    ? typeof debounce === \"number\"\n      ? debounce\n      : debounce.scroll\n    : null\n  const resizeDebounce = debounce\n    ? typeof debounce === \"number\"\n      ? debounce\n      : debounce.resize\n    : null\n\n  // make sure to update state only as long as the component is truly mounted\n  const mounted = useRef(false)\n  useEffect(() => {\n    mounted.current = true\n    return () => void (mounted.current = false)\n  })\n\n  // memoize handlers, so event-listeners know when they should update\n  const [forceRefresh, resizeChange, scrollChange] = useMemo(() => {\n    const callback = () => {\n      if (!state.current.element) return\n      const { left, top, width, height, bottom, right, x, y } =\n        state.current.element.getBoundingClientRect() as unknown as RectReadOnly\n\n      const size = {\n        left,\n        top,\n        width,\n        height,\n        bottom,\n        right,\n        x,\n        y,\n      }\n\n      if (state.current.element instanceof HTMLElement && offsetSize) {\n        size.height = state.current.element.offsetHeight\n        size.width = state.current.element.offsetWidth\n      }\n\n      Object.freeze(size)\n      if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {\n        set((state.current.lastBounds = size))\n      }\n    }\n    return [\n      callback,\n      resizeDebounce ? createDebounce(callback, resizeDebounce) : callback,\n      scrollDebounce ? createDebounce(callback, scrollDebounce) : callback,\n    ]\n  }, [set, offsetSize, scrollDebounce, resizeDebounce])\n\n  // cleanup current scroll-listeners / observers\n  function removeListeners() {\n    if (state.current.scrollContainers) {\n      state.current.scrollContainers.forEach((element) =>\n        element.removeEventListener(\"scroll\", scrollChange, true),\n      )\n      state.current.scrollContainers = null\n    }\n\n    if (state.current.resizeObserver) {\n      state.current.resizeObserver.disconnect()\n      state.current.resizeObserver = null\n    }\n  }\n\n  // add scroll-listeners / observers\n  function addListeners() {\n    if (!state.current.element) return\n    state.current.resizeObserver = new ResizeObserver(scrollChange)\n    state.current.resizeObserver!.observe(state.current.element)\n    if (scroll && state.current.scrollContainers) {\n      state.current.scrollContainers.forEach((scrollContainer) =>\n        scrollContainer.addEventListener(\"scroll\", scrollChange, {\n          capture: true,\n          passive: true,\n        }),\n      )\n    }\n  }\n\n  // the ref we expose to the user\n  const ref = (node: HTMLOrSVGElement | null) => {\n    if (!node || node === state.current.element) return\n    removeListeners()\n    state.current.element = node\n    state.current.scrollContainers = findScrollContainers(node)\n    addListeners()\n  }\n\n  // add general event listeners\n  useOnWindowScroll(scrollChange, Boolean(scroll))\n  useOnWindowResize(resizeChange)\n\n  // respond to changes that are relevant for the listeners\n  useEffect(() => {\n    removeListeners()\n    addListeners()\n  }, [scroll, scrollChange, resizeChange])\n\n  // remove all listeners when the components unmounts\n  useEffect(() => removeListeners, [])\n  return [ref, bounds, forceRefresh]\n}\n\n// Adds native resize listener to window\nfunction useOnWindowResize(onWindowResize: (event: Event) => void) {\n  useEffect(() => {\n    const cb = onWindowResize\n    window.addEventListener(\"resize\", cb)\n    return () => void window.removeEventListener(\"resize\", cb)\n  }, [onWindowResize])\n}\nfunction useOnWindowScroll(onScroll: () => void, enabled: boolean) {\n  useEffect(() => {\n    if (enabled) {\n      const cb = onScroll\n      window.addEventListener(\"scroll\", cb, { capture: true, passive: true })\n      return () => void window.removeEventListener(\"scroll\", cb, true)\n    }\n  }, [onScroll, enabled])\n}\n\n// Returns a list of scroll offsets\nfunction findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] {\n  const result: HTMLOrSVGElement[] = []\n  if (!element || element === document.body) return result\n  const { overflow, overflowX, overflowY } = window.getComputedStyle(element)\n  if ([overflow, overflowX, overflowY].some((prop) => prop === \"auto\" || prop === \"scroll\")) {\n    result.push(element)\n  }\n  return [...result, ...findScrollContainers(element.parentElement)]\n}\n\n// Checks if element boundaries are equal\nconst keys: (keyof RectReadOnly)[] = [\"x\", \"y\", \"top\", \"bottom\", \"left\", \"right\", \"width\", \"height\"]\nconst areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean =>\n  keys.every((key) => a[key] === b[key])\n"
  },
  {
    "path": "packages/internal/hooks/src/useOnce.ts",
    "content": "import { useEffect, useRef } from \"react\"\n\nexport const useOnce = (fn: () => any) => {\n  const isDone = useRef(false)\n  useEffect(() => {\n    if (isDone.current) return\n    fn()\n    isDone.current = true\n  }, [])\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/usePageVisibility.ts",
    "content": "import { useLayoutEffect, useState } from \"react\"\n\nexport function usePageVisibility() {\n  const [isPageVisible, setIsPageVisible] = useState(!document.hidden)\n\n  useLayoutEffect(() => {\n    const handleVisibility = () => {\n      setIsPageVisible(!document.hidden)\n    }\n    document.addEventListener(\"visibilitychange\", handleVisibility)\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibility)\n    }\n  }, [])\n\n  return isPageVisible\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/usePrevious.ts",
    "content": "import { useEffect, useRef } from \"react\"\n\nexport const usePrevious = <T>(value: T): T | undefined => {\n  const ref = useRef<T>(undefined)\n  useEffect(() => {\n    ref.current = value\n  })\n  return ref.current\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useRefValue.ts",
    "content": "import { useLayoutEffect, useRef } from \"react\"\n\nexport const useRefValue = <S>(\n  value: S,\n): Readonly<{\n  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n  current: S extends Function ? S : Readonly<S>\n}> => {\n  const ref = useRef<S>(value)\n\n  useLayoutEffect(() => {\n    ref.current = value\n  }, [value])\n  return ref as any\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useSetState.ts",
    "content": "/**\n * @see https://github.com/streamich/react-use/blob/master/src/useSetState.ts\n */\nimport { useCallback, useState } from \"react\"\n\nexport const useSetState = <T extends object>(\n  initialState: T = {} as T,\n): [T, (patch: Partial<T> | ((prevState: T) => Partial<T>)) => void] => {\n  const [state, set] = useState<T>(initialState)\n  const setState = useCallback((patch: any) => {\n    set((prevState) =>\n      Object.assign({}, prevState, typeof patch === \"function\" ? patch(prevState) : patch),\n    )\n  }, [])\n\n  return [state, setState]\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useSmoothScroll.ts",
    "content": "import { useCallback, useRef } from \"react\"\n\n/**\n *\n * Smooth scroll implementation similar to Vimium\n */\nexport const useSmoothScroll = () => {\n  const scrollAnimationRef = useRef<{ stop: () => void } | null>(null)\n\n  return useCallback(\n    (targetScrollTop: number, element: HTMLDivElement) => {\n      // Stop any existing animation\n      if (scrollAnimationRef.current) {\n        scrollAnimationRef.current.stop()\n      }\n\n      const startScrollTop = element.scrollTop\n      const distance = targetScrollTop - startScrollTop\n\n      // If distance is very small, just set it directly\n      if (Math.abs(distance) < 1) {\n        element.scrollTop = targetScrollTop\n        scrollAnimationRef.current = null\n        return\n      }\n\n      // Adaptive duration based on distance - shorter for small distances, longer for large ones\n      const baseDuration = 150\n      const maxDuration = 300\n      const duration = Math.min(maxDuration, baseDuration + Math.abs(distance) * 0.5)\n      const startTime = performance.now()\n\n      // Easing function similar to Vimium's smooth scrolling - ease out cubic for natural feel\n      const easeOutCubic = (t: number): number => {\n        return 1 - Math.pow(1 - t, 3)\n      }\n\n      let animationId: number\n\n      const animateScroll = (currentTime: number) => {\n        const elapsed = currentTime - startTime\n        const progress = Math.min(elapsed / duration, 1)\n\n        const easedProgress = easeOutCubic(progress)\n        const currentScrollTop = startScrollTop + distance * easedProgress\n\n        element.scrollTop = currentScrollTop\n\n        if (progress < 1) {\n          animationId = requestAnimationFrame(animateScroll)\n          scrollAnimationRef.current = {\n            stop: () => {\n              cancelAnimationFrame(animationId)\n              scrollAnimationRef.current = null\n            },\n          } as any\n        } else {\n          scrollAnimationRef.current = null\n        }\n      }\n\n      animationId = requestAnimationFrame(animateScroll)\n    },\n    [scrollAnimationRef],\n  )\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useSyncTheme.ts",
    "content": "import { nextFrame } from \"@follow/utils/dom\"\nimport { jotaiStore } from \"@follow/utils/jotai\"\nimport { useAtomValue } from \"jotai\"\nimport { useCallback, useLayoutEffect } from \"react\"\n\nimport type { ColorMode } from \"./internal/for-theme\"\nimport { themeAtom, useDarkQuery } from \"./internal/for-theme\"\n\nexport const useSyncThemeWebApp = () => {\n  const colorMode = useAtomValue(themeAtom)\n  const systemIsDark = useDarkQuery()\n  useLayoutEffect(() => {\n    const realColorMode: Exclude<ColorMode, \"system\"> =\n      colorMode === \"system\" ? (systemIsDark ? \"dark\" : \"light\") : colorMode\n    document.documentElement.dataset.theme = realColorMode\n    disableTransition([\"[role=switch]>*\"])()\n  }, [colorMode, systemIsDark])\n}\n\nexport const internal_useSetTheme = () =>\n  // eslint-disable-next-line react-hooks/rules-of-hooks\n  useCallback((theme: ColorMode) => jotaiStore.set(themeAtom, theme), [])\n\nexport function disableTransition(disableTransitionExclude: string[] = []) {\n  const css = document.createElement(\"style\")\n  css.append(\n    document.createTextNode(\n      `\n*${disableTransitionExclude.map((s) => `:not(${s})`).join(\"\")} {\n  -webkit-transition: none !important;\n  -moz-transition: none !important;\n  -o-transition: none !important;\n  -ms-transition: none !important;\n  transition: none !important;\n}\n      `,\n    ),\n  )\n  document.head.append(css)\n\n  return () => {\n    // Force restyle\n    ;(() => window.getComputedStyle(document.body))()\n\n    // Wait for next tick before removing\n    nextFrame(() => {\n      css.remove()\n    })\n  }\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useTitle.ts",
    "content": "import { IN_ELECTRON } from \"@follow/shared/constants\"\nimport { useEffect, useRef } from \"react\"\n\ndeclare const APP_NAME: string\nconst titleTemplate = IN_ELECTRON ? `%s` : `%s | ${APP_NAME}`\n\nexport const useTitle = (title?: string | null) => {\n  const currentTitleRef = useRef(document.title)\n  useEffect(() => {\n    if (!title) return\n\n    document.title = titleTemplate.replace(\"%s\", title)\n    return () => {\n      document.title = currentTitleRef.current\n    }\n  }, [title])\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useTriangleMenu.ts",
    "content": "import { useEffect, useState } from \"react\"\n\nexport function useTriangleMenu(\n  triggerRef: React.RefObject<HTMLElement>,\n  panelRef: HTMLElement,\n  openDelay = 100,\n) {\n  const [open, setOpen] = useState(false)\n\n  useEffect(() => {\n    const $trigger = triggerRef.current\n    const $panel = panelRef\n    if (!$trigger || !$panel) return\n    let timer: ReturnType<typeof setTimeout> | null = null\n    let inStartTrack = false\n    const mousePath = [] as { x: number; y: number }[]\n    function handleMouseMove(event: MouseEvent) {\n      const { clientX, clientY } = event\n\n      if (!inStartTrack) return\n\n      mousePath.push({ x: clientX, y: clientY })\n      if (mousePath.length > 5) {\n        mousePath.shift()\n      }\n\n      const triggerRect = $trigger?.getBoundingClientRect()\n\n      if (!triggerRect) return\n      const panelRect = $panel.getBoundingClientRect()\n\n      const lastPoint = mousePath[0]\n\n      if (!lastPoint) return\n\n      const inTriangle = isPointInTriangle(\n        { x: clientX, y: clientY },\n        { x: lastPoint.x, y: lastPoint.y },\n        { x: panelRect.left, y: panelRect.top },\n        { x: panelRect.left, y: panelRect.bottom },\n      )\n\n      if (inTriangle) {\n        if (timer) clearTimeout(timer)\n        timer = setTimeout(() => setOpen(true), openDelay)\n      } else {\n        const inRect =\n          isPointInRect(\n            { x: clientX, y: clientY },\n            {\n              x: triggerRect.left,\n              y: triggerRect.top,\n              width: triggerRect.width,\n              height: triggerRect.height,\n            },\n          ) ||\n          isPointInRect(\n            { x: clientX, y: clientY },\n            {\n              x: panelRect.left,\n              y: panelRect.top,\n              width: panelRect.width,\n              height: panelRect.height,\n            },\n          )\n\n        if (inRect) {\n          if (timer) clearTimeout(timer)\n          timer = setTimeout(() => setOpen(true), openDelay)\n        } else {\n          if (timer) clearTimeout(timer)\n          setOpen(false)\n          inStartTrack = false\n        }\n      }\n    }\n\n    function handleMouseEnter() {\n      if (timer) clearTimeout(timer)\n      timer = setTimeout(() => {\n        inStartTrack = true\n        setOpen(true)\n      }, openDelay)\n    }\n\n    function handleMouseLeave() {\n      if (timer) clearTimeout(timer)\n      setOpen(false)\n    }\n\n    document.addEventListener(\"mousemove\", handleMouseMove)\n    $trigger.addEventListener(\"mouseenter\", handleMouseEnter)\n    $panel.addEventListener(\"mouseleave\", handleMouseLeave)\n\n    return () => {\n      document.removeEventListener(\"mousemove\", handleMouseMove)\n      $trigger.removeEventListener(\"mouseenter\", handleMouseEnter)\n      $panel.removeEventListener(\"mouseleave\", handleMouseLeave)\n      if (timer) {\n        clearTimeout(timer)\n      }\n    }\n  }, [triggerRef, panelRef, openDelay])\n\n  return open\n}\n\nfunction isPointInTriangle(\n  P: { x: number; y: number },\n  A: { x: number; y: number },\n  B: { x: number; y: number },\n  C: { x: number; y: number },\n) {\n  function sign(p1: any, p2: any, p3: any) {\n    return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)\n  }\n\n  const d1 = sign(P, A, B)\n  const d2 = sign(P, B, C)\n  const d3 = sign(P, C, A)\n\n  const hasNeg = d1 < 0 || d2 < 0 || d3 < 0\n  const hasPos = d1 > 0 || d2 > 0 || d3 > 0\n\n  return !(hasNeg && hasPos)\n}\n\nfunction isPointInRect(\n  point: { x: number; y: number },\n  rect: { x: number; y: number; width: number; height: number },\n) {\n  return (\n    point.x >= rect.x &&\n    point.x <= rect.x + rect.width &&\n    point.y >= rect.y &&\n    point.y <= rect.y + rect.height\n  )\n}\n"
  },
  {
    "path": "packages/internal/hooks/src/useTypescriptHappyCallback.ts",
    "content": "import { useCallback } from \"react\"\n\nexport const useTypeScriptHappyCallback: <Args extends unknown[], R>(\n  fn: (...args: Args) => R,\n  deps: React.DependencyList,\n) => (...args: Args) => R = useCallback\n"
  },
  {
    "path": "packages/internal/hooks/src/useVideo.ts",
    "content": "import createHTMLMediaHook from \"./factory/createHTMLMediaHook\"\n\nexport const useVideo = createHTMLMediaHook<HTMLVideoElement>(\"video\")\n"
  },
  {
    "path": "packages/internal/hooks/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"baseUrl\": \".\",\n    \"jsx\": \"preserve\",\n    \"declaration\": true,\n    \"types\": [\"@follow/types/react\", \"@follow/types/global\", \"vite/client\"],\n    \"paths\": {\n      \"@follow/hooks/*\": [\"./src/*\"],\n      \"@pkg\": [\"../../package.json\"]\n    }\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/internal/logger/electron.ts",
    "content": "export { initialize, log } from \"electron-log\"\n"
  },
  {
    "path": "packages/internal/logger/package.json",
    "content": "{\n  \"name\": \"@follow/logger\",\n  \"private\": true,\n  \"exports\": {\n    \".\": {\n      \"types\": \"./electron.ts\",\n      \"web\": \"./web.ts\",\n      \"default\": \"./electron.ts\"\n    }\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"electron-log\": \"5.4.3\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/logger/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"baseUrl\": \".\",\n    \"jsx\": \"preserve\",\n    \"declaration\": true,\n    \"paths\": {\n      \"@pkg\": [\"../../package.json\"]\n    }\n  },\n  \"include\": [\"**/*\"]\n}\n"
  },
  {
    "path": "packages/internal/logger/web.ts",
    "content": "export const log = (...args: any[]) => {\n  // eslint-disable-next-line no-console\n  console.log(...args)\n}\n\nexport const initialize = () => {}\n"
  },
  {
    "path": "packages/internal/models/package.json",
    "content": "{\n  \"name\": \"@follow/models\",\n  \"private\": true,\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": {\n      \"import\": \"./src/index.ts\",\n      \"require\": \"./src/index.ts\"\n    },\n    \"./rsshub\": {\n      \"import\": \"./src/rsshub.ts\",\n      \"types\": \"./src/rsshub.ts\"\n    },\n    \"./types\": {\n      \"import\": \"./src/types.ts\",\n      \"require\": \"./src/types.ts\"\n    }\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/models/src/index.ts",
    "content": "export {}\n"
  },
  {
    "path": "packages/internal/models/src/rsshub.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { FeedDiscoveryResult } from \"@follow-app/client-sdk\"\n\nexport type RSSHubRouteType = Record<string, RSSHubRouteDeclaration>\nexport interface RSSHubRouteDeclaration {\n  routes: Routes\n  name: string\n  url: string\n}\ntype Routes = Record<string, RSSHubRoute>\nexport type RSSHubParameterObject = {\n  description: string\n  default: string | null\n  options?: {\n    label: string\n    value: string\n  }[]\n}\n\nexport type RSSHubParameter = string | RSSHubParameterObject\nexport type RSSHubRoute = {\n  path: string\n  categories: string[]\n  example: string\n  parameters: Record<string, RSSHubParameter>\n  name: string\n  maintainers: string[]\n  location: string\n  description: string\n  view?: FeedViewType\n  heat?: number\n  topFeeds?: FeedDiscoveryResult[]\n}\n"
  },
  {
    "path": "packages/internal/models/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"jsx\": \"preserve\",\n    \"declaration\": true,\n    \"types\": [\"@follow/types/react\", \"@follow/types/global\", \"vite/client\"],\n    \"paths\": {\n      \"@follow/models/*\": [\"./src/*\"],\n      \"@pkg\": [\"../../package.json\"]\n    }\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/internal/shared/package.json",
    "content": "{\n  \"name\": \"@follow/shared\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"author\": \"Folo Team\",\n  \"license\": \"AGPL-3.0-only\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/follow\",\n    \"type\": \"git\"\n  },\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": {\n      \"types\": \"./src/index.ts\",\n      \"require\": \"./src/index.ts\",\n      \"import\": \"./src/index.ts\"\n    },\n    \"./*\": {\n      \"types\": \"./src/*.ts\",\n      \"require\": \"./src/*.ts\",\n      \"import\": \"./src/*.ts\"\n    },\n    \"./interface/*\": {\n      \"types\": \"./src/interface/*.ts\",\n      \"require\": \"./src/interface/*.ts\",\n      \"import\": \"./src/interface/*.ts\"\n    }\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@better-auth/stripe\": \"1.5.5\",\n    \"@electron-toolkit/preload\": \"3.0.2\",\n    \"@electron-toolkit/tsconfig\": \"2.0.0\",\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@folo-services/drizzle\": \"0.1.44\",\n    \"@t3-oss/env-core\": \"0.13.10\",\n    \"ai\": \"6.0.85\",\n    \"better-auth\": \"1.5.5\",\n    \"drizzle-orm\": \"0.45.1\",\n    \"sonner\": \"2.0.7\",\n    \"stripe\": \"20.3.1\",\n    \"zod\": \"3.25.76\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/shared/src/auth.ts",
    "content": "import { stripeClient } from \"@better-auth/stripe/client\"\nimport { IN_ELECTRON } from \"@follow/shared\"\nimport type { AuthPlugins } from \"@follow-app/client-sdk/auth\"\nimport type { BetterAuthClientPlugin, BetterFetchOption } from \"better-auth/client\"\nimport { createAuthClient } from \"better-auth/client\"\nimport {\n  inferAdditionalFields,\n  lastLoginMethodClient,\n  magicLinkClient,\n  twoFactorClient,\n} from \"better-auth/client/plugins\"\n\ntype AuthPlugin = AuthPlugins[number]\n\nexport const baseAuthPlugins = [\n  {\n    id: \"customGetProviders\",\n    $InferServerPlugin: {} as Extract<AuthPlugin, { id: \"customGetProviders\" }>,\n  },\n  {\n    id: \"getAccountInfo\",\n    $InferServerPlugin: {} as Extract<AuthPlugin, { id: \"getAccountInfo\" }>,\n  },\n  {\n    id: \"deleteUserCustom\",\n    $InferServerPlugin: {} as Extract<AuthPlugin, { id: \"deleteUserCustom\" }>,\n  },\n  {\n    id: \"oneTimeToken\",\n    $InferServerPlugin: {} as Extract<AuthPlugin, { id: \"oneTimeToken\" }>,\n  },\n\n  inferAdditionalFields({\n    user: {\n      handle: {\n        type: \"string\",\n        required: false,\n      },\n      bio: {\n        type: \"string\",\n        required: false,\n      },\n      website: {\n        type: \"string\",\n        required: false,\n      },\n      socialLinks: {\n        type: \"json\",\n        required: false,\n      },\n    },\n  }),\n  twoFactorClient(),\n  stripeClient({ subscription: true }),\n  lastLoginMethodClient(),\n  magicLinkClient(),\n]\n\nexport type AuthClient<ExtraPlugins extends BetterAuthClientPlugin[] = []> = ReturnType<\n  typeof createAuthClient<{\n    plugins: [...typeof baseAuthPlugins, ...ExtraPlugins]\n  }>\n>\n\nexport type LoginRuntime = \"browser\" | \"app\"\n\nexport class Auth {\n  authClient: AuthClient\n\n  constructor(\n    private readonly options: {\n      apiURL: string\n      webURL: string\n      fetchOptions?: BetterFetchOption\n    },\n  ) {\n    this.authClient = createAuthClient({\n      baseURL: `${this.options.apiURL}/better-auth`,\n      plugins: baseAuthPlugins,\n      fetchOptions: {\n        ...this.options.fetchOptions,\n        credentials: \"include\",\n        cache: \"no-store\",\n        onRequest: (context) => {\n          const referralCode = localStorage.getItem(getStorageNS(\"referral-code\"))\n          if (referralCode) {\n            context.headers.set(\"folo-referral-code\", referralCode)\n          }\n\n          this.options.fetchOptions?.onRequest?.(context)\n\n          return context\n        },\n      },\n    })\n  }\n\n  loginHandler = async (\n    provider: string,\n    runtime?: LoginRuntime,\n    args?: {\n      email?: string\n      password?: string\n      headers?: Record<string, string>\n    },\n  ) => {\n    const { email, password, headers } = args ?? {}\n    const callbackURL = runtime === \"app\" ? `${this.options.webURL}/login` : this.options.webURL\n    if (IN_ELECTRON && provider !== \"credential\" && provider !== \"magicLink\") {\n      window.open(`${this.options.webURL}/login?provider=${provider}`)\n    } else {\n      if (provider === \"credential\") {\n        if (!email || !password) {\n          window.location.href = \"/login\"\n          return\n        }\n        return this.authClient.signIn.email({ email, password }, { headers })\n      }\n\n      if (provider === \"magicLink\") {\n        if (!email) {\n          window.location.href = \"/login\"\n          return\n        }\n        return this.authClient.signIn.magicLink(\n          {\n            email,\n            name: email.split(\"@\")[0]!,\n            callbackURL,\n          },\n          { headers },\n        )\n      }\n\n      this.authClient.signIn.social({\n        provider: provider as \"google\" | \"github\" | \"apple\",\n        callbackURL,\n      })\n    }\n  }\n}\n\n// copy from packages/internal/utils/src/ns.ts\nconst ns = \"follow\"\nconst getStorageNS = (key: string) => `${ns}:${key}`\n"
  },
  {
    "path": "packages/internal/shared/src/bridge.ts",
    "content": "import type { StoreDistribution } from \"@follow-app/client-sdk\"\nimport type { BrowserWindow } from \"electron\"\nimport { useEffect, useLayoutEffect, useRef } from \"react\"\nimport type { toast } from \"sonner\"\n\nimport type { GeneralSettings, UISettings } from \"./settings/interface\"\n\nconst PREFIX = \"__follow\"\n\n// eslint-disable-next-line unused-imports/no-unused-vars\ndeclare const dialog: {\n  ask: (options: {\n    title: string\n    message: string\n    onConfirm?: () => void\n    onCancel?: () => void\n    confirmText?: string\n    cancelText?: string\n  }) => Promise<boolean>\n}\n\nexport enum WindowState {\n  MINIMIZED = \"minimized\",\n  MAXIMIZED = \"maximized\",\n  NORMAL = \"normal\",\n}\nexport interface DistributionUpdateNotice {\n  distribution: StoreDistribution\n  storeUrl: string\n  storeVersion: string | null\n  currentVersion: string | null\n}\n\ninterface RenderGlobalContext {\n  /// Access Settings\n  showSetting: (path?: string) => void\n  getGeneralSettings: () => GeneralSettings\n  getUISettings: () => UISettings\n\n  /**\n   * @description only work in electron app\n   */\n  onWindowClose: () => void\n  /**\n   * @description only work in electron app\n   */\n  onWindowShow: () => void\n\n  /// Actions\n  follow: (options?: { isList: boolean; id?: string; url?: string }) => void\n  profile: (id: string, variant?: \"drawer\" | \"dialog\") => void\n  quickAdd: () => void\n  rsshubRoute: (route: string) => void\n  // Navigate\n  goToDiscover: () => void\n  goToFeed: ({ id, view }: { id: string; view?: number }) => void\n  goToList: ({ id, view }: { id: string; view?: number }) => void\n\n  // user data\n  clearIfLoginOtherAccount: (newUserId: string) => void\n  applyOneTimeToken: (token: string) => void\n\n  /// Utils\n  toast: typeof toast\n\n  /// Electron State\n  setWindowState: (state: WindowState) => void\n\n  readyToUpdate: () => void\n  dialog: typeof dialog\n  // URL\n  getWebUrl: () => string\n  getApiUrl: () => string\n  distributionUpdateAvailable: (payload: DistributionUpdateNotice) => void\n\n  // Utils\n  invalidateQuery: (queryKey: string | string[]) => void\n  navigateEntry: (options: { feedId: string; entryId: string; view: number }) => void\n  updateDownloaded: () => void\n\n  refreshSession: () => void\n}\n\nexport const registerGlobalContext = (context: Partial<RenderGlobalContext>) => {\n  // @ts-ignore\n  globalThis[PREFIX] = {\n    // @ts-ignore\n    ...globalThis[PREFIX],\n    ...context,\n  }\n}\n\nexport const useRegisterGlobalContext = <K extends keyof RenderGlobalContext>(\n  key: K,\n  fn: RenderGlobalContext[K],\n) => {\n  const eventCallbackRef = useRef(fn)\n  useLayoutEffect(() => {\n    eventCallbackRef.current = fn\n  }, [fn])\n\n  useEffect(() => {\n    registerGlobalContext({ [key]: eventCallbackRef.current })\n  }, [key])\n}\n\nfunction createProxy<T extends RenderGlobalContext>(\n  evalCode: (code: string) => void,\n  path: string[] = [],\n): T {\n  return new Proxy((() => {}) as any, {\n    get(_, prop: string) {\n      const newPath = [...path, prop]\n\n      return createProxy(evalCode, newPath)\n    },\n    async apply(_, __, args: any[]) {\n      const methodPath = path.join(\".\")\n\n      try {\n        return await evalCode(\n          `globalThis.${PREFIX}?.${methodPath}?.(${args\n            .map((arg) => {\n              if (arg === undefined) return \"undefined\"\n              return JSON.stringify(arg)\n            })\n            .join(\",\")})`,\n        )\n      } catch (err) {\n        console.error(`Failed to executeJavaScript: ${methodPath}`, err)\n      }\n    },\n  })\n}\ntype AddPromise<T> = T extends (...args: infer A) => Promise<infer R>\n  ? (...args: A) => Promise<R>\n  : T extends (...args: infer A) => infer R\n    ? (...args: A) => Promise<Awaited<R>>\n    : unknown\n\ntype Fn<T> = {\n  [K in keyof T]: AddPromise<T[K]> &\n    (T[K] extends object ? { [P in keyof T[K]]: AddPromise<T[K][P]> } : never)\n}\nexport function callWindowExpose<T extends RenderGlobalContext>(window: BrowserWindow) {\n  return createProxy((code) => window.webContents.executeJavaScript(code)) as Fn<T>\n}\n\nexport function callWebviewExpose<T extends RenderGlobalContext>(\n  injectCode: (code: string) => void,\n) {\n  return createProxy(injectCode) as Fn<T>\n}\n\nexport function callWindowExposeRenderer() {\n  // @ts-ignore\n  return globalThis[PREFIX] as Fn<RenderGlobalContext>\n}\n"
  },
  {
    "path": "packages/internal/shared/src/constants.ts",
    "content": "import type { ElectronAPI } from \"@electron-toolkit/preload\"\n\ndeclare const globalThis: {\n  window: Window & {\n    electron?: ElectronAPI\n    api?: { canWindowBlur: boolean }\n  }\n  electron?: ElectronAPI\n}\n\nexport enum ModeEnum {\n  development = \"development\",\n  staging = \"staging\",\n  production = \"production\",\n}\n\nexport const MODE = import.meta.env?.MODE as ModeEnum\n\nexport const { PROD } = import.meta.env ?? {}\n\nexport const DEV =\n  \"process\" in globalThis ? process.env.NODE_ENV === \"development\" : import.meta.env.DEV\n\nexport const LEGACY_APP_PROTOCOL = DEV ? \"follow-dev\" : \"follow\"\nexport const APP_PROTOCOL = DEV ? \"folo-dev\" : \"folo\"\nexport const DEEPLINK_SCHEME = `${APP_PROTOCOL}://` as const\n\nexport const SYSTEM_CAN_UNDER_BLUR_WINDOW = globalThis?.window?.electron\n  ? globalThis?.window.api?.canWindowBlur\n  : false\n\ndeclare const ELECTRON: boolean\n/**\n * Current build type for electron\n */\nexport const ELECTRON_BUILD = !!ELECTRON\nexport const WEB_BUILD = !ELECTRON\n\nexport const IN_ELECTRON = !!globalThis[\"electron\"] || ELECTRON_BUILD\n\nexport const MICROSOFT_STORE_BUILD =\n  typeof process !== \"undefined\"\n    ? process.platform === \"win32\" &&\n      (process.windowsStore || process.execPath.startsWith(\"C:\\\\Program Files\\\\WindowsApps\"))\n    : false\n"
  },
  {
    "path": "packages/internal/shared/src/electron.ts",
    "content": "import { DEEPLINK_SCHEME } from \"./constants\"\n\nconst ELECTRON_QUERY_KEY = \"__electron\"\nexport interface OpenElectronWindowOptions {\n  resizable?: boolean\n  height?: number\n  width?: number\n}\n\n/**\n * Work electron and browser,\n * if in electron, open a new window, otherwise open a new tab\n */\nexport const openElectronWindow = (url: string, options: OpenElectronWindowOptions = {}) => {\n  if (\"electron\" in window) {\n    const urlObject = new URL(url)\n    const { searchParams } = urlObject\n\n    searchParams.set(ELECTRON_QUERY_KEY, encodeURIComponent(JSON.stringify(options)))\n\n    window.open(urlObject.toString())\n  } else {\n    // eslint-disable-next-line no-restricted-globals\n    window.open(url.replace(DEEPLINK_SCHEME, `${location.origin}/`))\n  }\n}\n\nexport const extractElectronWindowOptions = (url: string): OpenElectronWindowOptions => {\n  try {\n    const urlObject = new URL(url)\n    const { searchParams } = urlObject\n    const options = searchParams.get(ELECTRON_QUERY_KEY)\n    if (options) {\n      return JSON.parse(decodeURIComponent(options))\n    }\n  } catch (e) {\n    console.error(e)\n  }\n  return {}\n}\n"
  },
  {
    "path": "packages/internal/shared/src/env.common.ts",
    "content": "const FIREBASE_CONFIG_DEFAULT = JSON.stringify({\n  apiKey: \"AIzaSyBpGB2C2Vz-9ktivqVkW7uTtVopNh3ELvo\",\n  authDomain: \"diygod-folo.firebaseapp.com\",\n  projectId: \"diygod-folo\",\n  storageBucket: \"diygod-folo.firebasestorage.app\",\n  messagingSenderId: \"992336953943\",\n  appId: \"1:992336953943:web:998aae576c8bc77dc11912\",\n  measurementId: \"G-HS4SF4GHWG\",\n})\n\nexport const DEFAULT_VALUES = {\n  PROD: {\n    API_URL: \"https://api.folo.is\",\n    WEB_URL: \"https://app.folo.is\",\n    INBOXES_EMAIL: \"@follow.re\",\n    FIREBASE_CONFIG: FIREBASE_CONFIG_DEFAULT,\n    RECAPTCHA_V3_SITE_KEY: \"6LeGa3csAAAAALi_WqhlWoaGaqd_kke4HRGvNE0C\",\n\n    POSTHOG_KEY: \"phc_EZGEvBt830JgBHTiwpHqJAEbWnbv63m5UpreojwEWNL\",\n    POSTHOG_HOST: \"https://us.posthog.com\",\n  },\n  DEV: {\n    API_URL: \"https://api.dev.follow.is\",\n    WEB_URL: \"https://dev.follow.is\",\n    INBOXES_EMAIL: \"__dev@follow.re\",\n  },\n  STAGING: {\n    API_URL: \"https://api.folo.is\",\n    WEB_URL: \"https://staging.follow.is\",\n    INBOXES_EMAIL: \"@follow.re\",\n    POSTHOG_KEY: \"phc_EZGEvBt830JgBHTiwpHqJAEbWnbv63m5UpreojwEWNL\",\n    POSTHOG_HOST: \"https://us.posthog.com\",\n  },\n  LOCAL: {\n    API_URL: \"http://localhost:3000\",\n    WEB_URL: \"http://localhost:2233\",\n    INBOXES_EMAIL: \"@follow.re\",\n  },\n}\n"
  },
  {
    "path": "packages/internal/shared/src/env.desktop.ts",
    "content": "/**\n * This env for apps/desktop\n */\nimport { createEnv } from \"@t3-oss/env-core\"\nimport { z } from \"zod\"\n\nimport { DEFAULT_VALUES } from \"./env.common\"\n\nexport const env = createEnv({\n  clientPrefix: \"VITE_\",\n  client: {\n    VITE_WEB_URL: z.string().url().default(DEFAULT_VALUES.PROD.WEB_URL),\n    VITE_API_URL: z.string().default(DEFAULT_VALUES.PROD.API_URL),\n    VITE_DEV_PROXY: z.string().optional(),\n    VITE_SENTRY_DSN: z.string().optional(),\n    VITE_INBOXES_EMAIL: z.string().default(DEFAULT_VALUES.PROD.INBOXES_EMAIL),\n    VITE_FIREBASE_CONFIG: z.string().default(DEFAULT_VALUES.PROD.FIREBASE_CONFIG),\n    VITE_POSTHOG_KEY: z.string().optional().default(DEFAULT_VALUES.PROD.POSTHOG_KEY),\n    VITE_POSTHOG_HOST: z.string().url().optional().default(DEFAULT_VALUES.PROD.POSTHOG_HOST),\n\n    VITE_RECAPTCHA_V3_SITE_KEY: z.string().default(DEFAULT_VALUES.PROD.RECAPTCHA_V3_SITE_KEY),\n  },\n\n  emptyStringAsUndefined: true,\n  runtimeEnv: getRuntimeEnv() as any,\n\n  skipValidation: \"process\" in globalThis ? process.env.VITEST === \"true\" : false,\n})\n\nfunction metaEnvIsEmpty() {\n  try {\n    return Object.keys(import.meta.env || {}).length === 0\n  } catch {\n    return true\n  }\n}\n\nfunction getRuntimeEnv() {\n  try {\n    if (metaEnvIsEmpty()) {\n      return process.env\n    }\n    return injectExternalEnv(import.meta.env)\n  } catch {\n    return process.env\n  }\n}\n\ndeclare const globalThis: any\nfunction injectExternalEnv<T>(originEnv: T): T {\n  if (!(\"document\" in globalThis)) {\n    return originEnv\n  }\n  const prefix = \"__followEnv\"\n  const env = globalThis[prefix]\n  if (!env) {\n    return originEnv\n  }\n\n  for (const key in env) {\n    originEnv[key as keyof T] = env[key]\n  }\n  return originEnv\n}\n"
  },
  {
    "path": "packages/internal/shared/src/env.rn.ts",
    "content": "/**\n * This env for apps/mobile\n */\nimport { DEFAULT_VALUES } from \"./env.common\"\n\nconst profile = \"prod\"\n\nconst envProfileMap = {\n  prod: {\n    API_URL: DEFAULT_VALUES.PROD.API_URL,\n    WEB_URL: DEFAULT_VALUES.PROD.WEB_URL,\n    INBOXES_EMAIL: DEFAULT_VALUES.PROD.INBOXES_EMAIL,\n    POSTHOG_KEY: DEFAULT_VALUES.PROD.POSTHOG_KEY,\n    POSTHOG_HOST: DEFAULT_VALUES.PROD.POSTHOG_HOST,\n  },\n  dev: {\n    API_URL: DEFAULT_VALUES.DEV.API_URL,\n    WEB_URL: DEFAULT_VALUES.DEV.WEB_URL,\n    INBOXES_EMAIL: DEFAULT_VALUES.DEV.INBOXES_EMAIL,\n  },\n  staging: {\n    API_URL: DEFAULT_VALUES.STAGING.API_URL,\n    WEB_URL: DEFAULT_VALUES.STAGING.WEB_URL,\n    INBOXES_EMAIL: DEFAULT_VALUES.STAGING.INBOXES_EMAIL,\n    POSTHOG_KEY: DEFAULT_VALUES.STAGING.POSTHOG_KEY,\n    POSTHOG_HOST: DEFAULT_VALUES.STAGING.POSTHOG_HOST,\n  },\n  local: {\n    API_URL: DEFAULT_VALUES.LOCAL.API_URL,\n    WEB_URL: DEFAULT_VALUES.LOCAL.WEB_URL,\n    INBOXES_EMAIL: DEFAULT_VALUES.LOCAL.INBOXES_EMAIL,\n  },\n}\nexport const getEnvProfiles__dangerously = () => envProfileMap\nexport type { envProfileMap }\n/**\n * @deprecated\n * @description this env always use prod env, please use `proxyEnv` to access dynamic env\n */\nexport const env = {\n  WEB_URL: envProfileMap[profile].WEB_URL,\n  API_URL: envProfileMap[profile].API_URL,\n  APP_CHECK_DEBUG_TOKEN: process.env.EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN,\n  POSTHOG_KEY: envProfileMap[profile].POSTHOG_KEY,\n  POSTHOG_HOST: envProfileMap[profile].POSTHOG_HOST,\n}\n"
  },
  {
    "path": "packages/internal/shared/src/env.ssr.ts",
    "content": "/**\n * This env for apps/ssr\n */\nimport { createEnv } from \"@t3-oss/env-core\"\nimport { z } from \"zod\"\n\nimport { DEFAULT_VALUES } from \"./env.common\"\n\nexport const env = createEnv({\n  clientPrefix: \"VITE_\",\n  client: {\n    VITE_WEB_URL: z.string().url().default(DEFAULT_VALUES.PROD.WEB_URL),\n    VITE_API_URL: z.string().default(DEFAULT_VALUES.PROD.API_URL),\n    VITE_DEV_PROXY: z.string().optional(),\n    VITE_SENTRY_DSN: z.string().optional(),\n    VITE_INBOXES_EMAIL: z.string().default(DEFAULT_VALUES.PROD.INBOXES_EMAIL),\n    VITE_FIREBASE_CONFIG: z.string().default(DEFAULT_VALUES.PROD.FIREBASE_CONFIG),\n    VITE_POSTHOG_KEY: z.string().optional().default(DEFAULT_VALUES.PROD.POSTHOG_KEY),\n    VITE_POSTHOG_HOST: z.string().url().optional().default(DEFAULT_VALUES.PROD.POSTHOG_HOST),\n\n    VITE_RECAPTCHA_V3_SITE_KEY: z.string().default(DEFAULT_VALUES.PROD.RECAPTCHA_V3_SITE_KEY),\n  },\n\n  emptyStringAsUndefined: true,\n  runtimeEnv: getRuntimeEnv() as any,\n\n  skipValidation: \"process\" in globalThis ? process.env.VITEST === \"true\" : false,\n})\n\nfunction metaEnvIsEmpty() {\n  try {\n    return Object.keys(import.meta.env || {}).length === 0\n  } catch {\n    return true\n  }\n}\n\nfunction getRuntimeEnv() {\n  try {\n    if (metaEnvIsEmpty()) {\n      return process.env\n    }\n    return injectExternalEnv(import.meta.env)\n  } catch {\n    return process.env\n  }\n}\n\ndeclare const globalThis: any\nfunction injectExternalEnv<T>(originEnv: T): T {\n  if (!(\"document\" in globalThis)) {\n    return originEnv\n  }\n  const prefix = \"__followEnv\"\n  const env = globalThis[prefix]\n  if (!env) {\n    return originEnv\n  }\n\n  for (const key in env) {\n    originEnv[key as keyof T] = env[key]\n  }\n  return originEnv\n}\n"
  },
  {
    "path": "packages/internal/shared/src/event.ts",
    "content": "import type { BrowserWindow } from \"electron\"\nimport { useEffect, useRef } from \"react\"\n\nexport const EventsMap = {\n  QuickAdd: \"quick-add\",\n  Discover: \"discover\",\n  OpenSearch: \"open-search\",\n}\n\nexport const dispatchEventOnWindow = (\n  window: BrowserWindow,\n  event: keyof typeof EventsMap,\n  ...args: any[]\n) => {\n  window.webContents.executeJavaScript(\n    iife(`\n   ${function Call(event: string, ...args: any[]) {\n     globalThis.window.dispatchEvent(new CustomEvent(event, { detail: args }))\n   }}\n    Call('${EventsMap[event]}', ${args.map((arg) => JSON.stringify(arg)).join(\",\")});\n  `),\n  )\n}\n\nconst iife = (code: string) => `!(() => {${code}})()`\nconst subscribeEvent = (event: keyof typeof EventsMap, callback: (args: any) => void) => {\n  const handler = (e) => {\n    callback(e.detail)\n  }\n  window.addEventListener(EventsMap[event], handler)\n\n  return () => {\n    window.removeEventListener(EventsMap[event], handler)\n  }\n}\n\nexport const useSubscribeElectronEvent = (\n  event: keyof typeof EventsMap,\n  callback: (args: any) => void,\n) => {\n  const eventCallbackRef = useRef(callback)\n  eventCallbackRef.current = callback\n\n  useEffect(() => {\n    const unsubscribe = subscribeEvent(event, eventCallbackRef.current)\n    return unsubscribe\n  }, [event])\n}\n"
  },
  {
    "path": "packages/internal/shared/src/global.d.ts",
    "content": "import type { ElectronAPI } from \"@electron-toolkit/preload\"\n\ndeclare global {\n  interface Window {\n    electron?: ElectronAPI\n    api?: { canWindowBlur: boolean }\n  }\n\n  export const ELECTRON: boolean\n}\n\nexport {}\n"
  },
  {
    "path": "packages/internal/shared/src/index.ts",
    "content": "export * from \"./constants\"\nexport * from \"./language\"\n"
  },
  {
    "path": "packages/internal/shared/src/language.ts",
    "content": "export type SupportedActionLanguage = \"en\" | \"ja\" | \"zh-CN\" | \"zh-TW\" | \"fr-FR\"\nexport const ACTION_LANGUAGE_MAP: Record<\n  SupportedActionLanguage,\n  {\n    label: string\n    value: string\n    code?: string\n  }\n> = {\n  // keep-sorted\n  en: {\n    label: \"English\",\n    value: \"en\",\n    code: \"eng\",\n  },\n  \"zh-CN\": {\n    code: \"cmn\",\n    label: \"Simplified Chinese\",\n    value: \"zh-CN\",\n  },\n  \"zh-TW\": {\n    label: \"Traditional Chinese (Taiwan)\",\n    value: \"zh-TW\",\n  },\n  ja: {\n    label: \"Japanese\",\n    value: \"ja\",\n    code: \"jpn\",\n  },\n  \"fr-FR\": {\n    label: \"Français (France)\",\n    value: \"fr-FR\",\n    code: \"fra\",\n  },\n}\nexport const ACTION_LANGUAGE_KEYS = Object.keys(ACTION_LANGUAGE_MAP) as SupportedActionLanguage[]\n\nexport type ApiSupportedActionLanguage = Exclude<SupportedActionLanguage, \"fr-FR\">\n\nexport const toApiSupportedActionLanguage = (\n  language: SupportedActionLanguage,\n): ApiSupportedActionLanguage => {\n  if (language === \"fr-FR\") {\n    // Server-side AI language enum does not include French yet.\n    return \"en\"\n  }\n\n  return language\n}\n"
  },
  {
    "path": "packages/internal/shared/src/queue.ts",
    "content": "export class AsyncQueue {\n  private maxConcurrent: number\n  private queue: (() => Promise<any>)[]\n  private activeCount: number\n\n  constructor(maxConcurrent: number) {\n    this.maxConcurrent = maxConcurrent\n    this.queue = []\n    this.activeCount = 0\n  }\n\n  private async runNext() {\n    if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {\n      return\n    }\n\n    this.activeCount++\n    const request = this.queue.shift()!\n\n    try {\n      return await request()\n    } catch (error) {\n      console.error(\"Request failed\", error)\n    } finally {\n      this.activeCount--\n      this.runNext() // Start the next request after this one finishes\n    }\n  }\n\n  add(request: () => Promise<any>) {\n    this.queue.push(request)\n    return this.runNext()\n  }\n\n  addMultiple(requests: (() => Promise<any>)[]) {\n    this.queue.push(...requests)\n    this.runNext()\n  }\n}\n"
  },
  {
    "path": "packages/internal/shared/src/review-prompt.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport {\n  createReviewPromptState,\n  getReviewPromptEligibility,\n  getReviewPromptScore,\n  recordReviewPromptActiveDay,\n  recordReviewPromptEntryOpen,\n  recordReviewPromptOutcome,\n  recordReviewPromptSubscriptionAdded,\n  syncReviewPromptSubscriptionCount,\n} from \"./review-prompt\"\n\ndescribe(\"review prompt scoring\", () => {\n  it(\"allows auto prompt with 2 active days, 3 entry opens, and 1 subscription add\", () => {\n    const now = new Date(\"2026-03-11T00:00:00.000Z\")\n    let state = createReviewPromptState()\n\n    state = recordReviewPromptActiveDay(state, new Date(\"2026-03-10T00:00:00.000Z\"), \"2026-03-10\")\n    state = recordReviewPromptActiveDay(state, now, \"2026-03-11\")\n    state = recordReviewPromptEntryOpen(state)\n    state = recordReviewPromptEntryOpen(state)\n    state = recordReviewPromptEntryOpen(state)\n    state = recordReviewPromptSubscriptionAdded(state, 1)\n\n    expect(getReviewPromptScore(state, false)).toBe(3)\n    expect(\n      getReviewPromptEligibility({\n        appVersion: \"1.0.0\",\n        isLoggedIn: true,\n        isInQuietWindow: true,\n        isPaidUser: false,\n        isPlatformSupported: true,\n        now,\n        state,\n      }).allowed,\n    ).toBe(true)\n  })\n\n  it(\"adds an extra point when entry open count reaches 5\", () => {\n    let state = createReviewPromptState()\n\n    for (let index = 0; index < 5; index += 1) {\n      state = recordReviewPromptEntryOpen(state)\n    }\n\n    expect(getReviewPromptScore(state, false)).toBe(2)\n  })\n\n  it(\"does not allow auto prompt with only 2 active days and 3 entry opens\", () => {\n    const now = new Date(\"2026-03-11T00:00:00.000Z\")\n    let state = createReviewPromptState()\n\n    state = recordReviewPromptActiveDay(state, new Date(\"2026-03-10T00:00:00.000Z\"), \"2026-03-10\")\n    state = recordReviewPromptActiveDay(state, now, \"2026-03-11\")\n    state = recordReviewPromptEntryOpen(state)\n    state = recordReviewPromptEntryOpen(state)\n    state = recordReviewPromptEntryOpen(state)\n\n    const result = getReviewPromptEligibility({\n      appVersion: \"1.0.0\",\n      isLoggedIn: true,\n      isInQuietWindow: true,\n      isPaidUser: false,\n      isPlatformSupported: true,\n      now,\n      state,\n    })\n\n    expect(result.allowed).toBe(false)\n    expect(result.blockedBy).toBe(\"score_too_low\")\n  })\n\n  it(\"allows paid users even when other signals are low\", () => {\n    const result = getReviewPromptEligibility({\n      appVersion: \"1.0.0\",\n      isLoggedIn: true,\n      isInQuietWindow: true,\n      isPaidUser: true,\n      isPlatformSupported: true,\n      now: new Date(\"2026-03-11T00:00:00.000Z\"),\n      state: createReviewPromptState(),\n    })\n\n    expect(result.allowed).toBe(true)\n    expect(result.score).toBe(3)\n  })\n})\n\ndescribe(\"review prompt cooldowns\", () => {\n  it(\"blocks the same version after dismissal\", () => {\n    const now = new Date(\"2026-03-11T00:00:00.000Z\")\n    const state = recordReviewPromptOutcome(createReviewPromptState(), \"dismissed\", now, \"1.0.0\")\n\n    const result = getReviewPromptEligibility({\n      appVersion: \"1.0.0\",\n      isLoggedIn: true,\n      isInQuietWindow: true,\n      isPaidUser: true,\n      isPlatformSupported: true,\n      now,\n      state,\n    })\n\n    expect(result.allowed).toBe(false)\n    expect(result.blockedBy).toBe(\"already_prompted_in_version\")\n  })\n\n  it(\"keeps negative feedback in cooldown on later versions\", () => {\n    const state = recordReviewPromptOutcome(\n      createReviewPromptState(),\n      \"negative_feedback\",\n      new Date(\"2026-03-01T00:00:00.000Z\"),\n      \"1.0.0\",\n    )\n\n    const result = getReviewPromptEligibility({\n      appVersion: \"1.0.1\",\n      isLoggedIn: true,\n      isInQuietWindow: true,\n      isPaidUser: true,\n      isPlatformSupported: true,\n      now: new Date(\"2026-03-11T00:00:00.000Z\"),\n      state,\n    })\n\n    expect(result.allowed).toBe(false)\n    expect(result.blockedBy).toBe(\"cooldown_active\")\n  })\n\n  it(\"permanently disables auto prompt after native request\", () => {\n    const state = recordReviewPromptOutcome(\n      createReviewPromptState(),\n      \"native_request\",\n      new Date(\"2026-03-01T00:00:00.000Z\"),\n      \"1.0.0\",\n    )\n\n    const result = getReviewPromptEligibility({\n      appVersion: \"1.0.1\",\n      isLoggedIn: true,\n      isInQuietWindow: true,\n      isPaidUser: true,\n      isPlatformSupported: true,\n      now: new Date(\"2026-03-11T00:00:00.000Z\"),\n      state,\n    })\n\n    expect(result.allowed).toBe(false)\n    expect(result.blockedBy).toBe(\"auto_prompt_disabled\")\n  })\n\n  it(\"syncs subscription count without affecting other signals\", () => {\n    const state = syncReviewPromptSubscriptionCount(createReviewPromptState(), 5)\n\n    expect(state.lastKnownSubscriptionCount).toBe(5)\n    expect(state.subscriptionAddCount).toBe(0)\n  })\n})\n"
  },
  {
    "path": "packages/internal/shared/src/review-prompt.ts",
    "content": "const DAY_IN_MS = 24 * 60 * 60 * 1000\n\nexport type ReviewPromptOutcome =\n  | \"dismissed\"\n  | \"negative_feedback\"\n  | \"positive_store_redirect\"\n  | \"native_request\"\n\nexport interface ReviewPromptState {\n  firstSeenAt: string | null\n  lastActiveDate: string | null\n  activeDaysCount: number\n  entryOpenCount: number\n  subscriptionAddCount: number\n  lastKnownSubscriptionCount: number\n  paidConversionAt: string | null\n  lastPromptAt: string | null\n  lastPromptVersion: string | null\n  lastOutcome: ReviewPromptOutcome | null\n  autoPromptDisabled: boolean\n}\n\nexport interface ReviewPromptEligibilityInput {\n  appVersion: string\n  isLoggedIn: boolean\n  isInQuietWindow: boolean\n  isPaidUser: boolean\n  isPlatformSupported: boolean\n  now: Date\n  state: ReviewPromptState\n}\n\nexport interface ReviewPromptEligibilityResult {\n  allowed: boolean\n  blockedBy: string | null\n  cooldownUntil: string | null\n  score: number\n}\n\nexport const getReviewPromptDayKey = (date: Date) => {\n  const year = date.getFullYear()\n  const month = `${date.getMonth() + 1}`.padStart(2, \"0\")\n  const day = `${date.getDate()}`.padStart(2, \"0\")\n  return `${year}-${month}-${day}`\n}\n\nexport const createReviewPromptState = (): ReviewPromptState => ({\n  firstSeenAt: null,\n  lastActiveDate: null,\n  activeDaysCount: 0,\n  entryOpenCount: 0,\n  subscriptionAddCount: 0,\n  lastKnownSubscriptionCount: 0,\n  paidConversionAt: null,\n  lastPromptAt: null,\n  lastPromptVersion: null,\n  lastOutcome: null,\n  autoPromptDisabled: false,\n})\n\nexport const normalizeReviewPromptState = (\n  value: Partial<ReviewPromptState> | null | undefined,\n): ReviewPromptState => ({\n  ...createReviewPromptState(),\n  ...value,\n})\n\nexport const recordReviewPromptActiveDay = (\n  state: ReviewPromptState,\n  now: Date,\n  dayKey = getReviewPromptDayKey(now),\n): ReviewPromptState => {\n  const nextState = normalizeReviewPromptState(state)\n\n  if (!nextState.firstSeenAt) {\n    nextState.firstSeenAt = now.toISOString()\n  }\n\n  if (nextState.lastActiveDate !== dayKey) {\n    nextState.activeDaysCount += 1\n    nextState.lastActiveDate = dayKey\n  }\n\n  return nextState\n}\n\nexport const recordReviewPromptEntryOpen = (state: ReviewPromptState): ReviewPromptState => ({\n  ...normalizeReviewPromptState(state),\n  entryOpenCount: normalizeReviewPromptState(state).entryOpenCount + 1,\n})\n\nexport const recordReviewPromptSubscriptionAdded = (\n  state: ReviewPromptState,\n  lastKnownSubscriptionCount?: number,\n): ReviewPromptState => ({\n  ...normalizeReviewPromptState(state),\n  subscriptionAddCount: normalizeReviewPromptState(state).subscriptionAddCount + 1,\n  lastKnownSubscriptionCount:\n    typeof lastKnownSubscriptionCount === \"number\"\n      ? lastKnownSubscriptionCount\n      : normalizeReviewPromptState(state).lastKnownSubscriptionCount,\n})\n\nexport const syncReviewPromptSubscriptionCount = (\n  state: ReviewPromptState,\n  lastKnownSubscriptionCount: number,\n): ReviewPromptState => ({\n  ...normalizeReviewPromptState(state),\n  lastKnownSubscriptionCount,\n})\n\nexport const recordReviewPromptPaidConversion = (\n  state: ReviewPromptState,\n  now: Date,\n): ReviewPromptState => {\n  const nextState = normalizeReviewPromptState(state)\n\n  if (!nextState.paidConversionAt) {\n    nextState.paidConversionAt = now.toISOString()\n  }\n\n  return nextState\n}\n\nexport const recordReviewPromptOutcome = (\n  state: ReviewPromptState,\n  outcome: ReviewPromptOutcome,\n  now: Date,\n  appVersion: string,\n): ReviewPromptState => ({\n  ...normalizeReviewPromptState(state),\n  autoPromptDisabled:\n    outcome === \"native_request\" || outcome === \"positive_store_redirect\"\n      ? true\n      : normalizeReviewPromptState(state).autoPromptDisabled,\n  lastOutcome: outcome,\n  lastPromptAt: now.toISOString(),\n  lastPromptVersion: appVersion,\n})\n\nexport const getReviewPromptScore = (state: ReviewPromptState, isPaidUser: boolean) => {\n  const nextState = normalizeReviewPromptState(state)\n\n  let score = 0\n\n  if (nextState.activeDaysCount >= 2) {\n    score += 1\n  }\n\n  if (nextState.entryOpenCount >= 3) {\n    score += 1\n  }\n\n  if (nextState.entryOpenCount >= 5) {\n    score += 1\n  }\n\n  if (nextState.subscriptionAddCount >= 1) {\n    score += 1\n  }\n\n  if (nextState.lastKnownSubscriptionCount >= 3) {\n    score += 1\n  }\n\n  if (nextState.lastKnownSubscriptionCount >= 5) {\n    score += 1\n  }\n\n  if (isPaidUser) {\n    score += 3\n  }\n\n  return score\n}\n\nconst getCooldownDays = (outcome: ReviewPromptOutcome | null) => {\n  switch (outcome) {\n    case \"dismissed\": {\n      return 120\n    }\n    case \"negative_feedback\": {\n      return 180\n    }\n    default: {\n      return null\n    }\n  }\n}\n\nexport const getReviewPromptCooldownUntil = (state: ReviewPromptState) => {\n  const nextState = normalizeReviewPromptState(state)\n  const cooldownDays = getCooldownDays(nextState.lastOutcome)\n\n  if (!cooldownDays || !nextState.lastPromptAt) {\n    return null\n  }\n\n  const lastPromptAt = new Date(nextState.lastPromptAt)\n  if (Number.isNaN(lastPromptAt.getTime())) {\n    return null\n  }\n\n  return new Date(lastPromptAt.getTime() + cooldownDays * DAY_IN_MS)\n}\n\nexport const getReviewPromptEligibility = (\n  input: ReviewPromptEligibilityInput,\n): ReviewPromptEligibilityResult => {\n  const { appVersion, isLoggedIn, isInQuietWindow, isPaidUser, isPlatformSupported, now } = input\n  const state = normalizeReviewPromptState(input.state)\n  const score = getReviewPromptScore(state, isPaidUser)\n\n  if (!isLoggedIn) {\n    return { allowed: false, blockedBy: \"logged_out\", cooldownUntil: null, score }\n  }\n\n  if (!isPlatformSupported) {\n    return { allowed: false, blockedBy: \"unsupported_platform\", cooldownUntil: null, score }\n  }\n\n  if (!isInQuietWindow) {\n    return { allowed: false, blockedBy: \"not_quiet\", cooldownUntil: null, score }\n  }\n\n  if (state.autoPromptDisabled) {\n    return { allowed: false, blockedBy: \"auto_prompt_disabled\", cooldownUntil: null, score }\n  }\n\n  if (state.lastPromptVersion === appVersion) {\n    return { allowed: false, blockedBy: \"already_prompted_in_version\", cooldownUntil: null, score }\n  }\n\n  const cooldownUntil = getReviewPromptCooldownUntil(state)\n  if (cooldownUntil && cooldownUntil.getTime() > now.getTime()) {\n    return {\n      allowed: false,\n      blockedBy: \"cooldown_active\",\n      cooldownUntil: cooldownUntil.toISOString(),\n      score,\n    }\n  }\n\n  if (score < 3) {\n    return { allowed: false, blockedBy: \"score_too_low\", cooldownUntil: null, score }\n  }\n\n  return { allowed: true, blockedBy: null, cooldownUntil: null, score }\n}\n"
  },
  {
    "path": "packages/internal/shared/src/settings/constants.ts",
    "content": "import type {\n  AccentColor,\n  AISettings,\n  GeneralSettings,\n  IntegrationSettings,\n  UISettings,\n} from \"./interface\"\n\nexport enum SettingPaidLevels {\n  Free,\n  FreeLimited,\n  Basic,\n}\n\ntype PartialRecord<K extends PropertyKey, V> = Partial<Record<K, V>>\n\nexport const PAID_SETTINGS = {\n  general: {\n    summary: SettingPaidLevels.FreeLimited,\n    translation: SettingPaidLevels.Basic,\n    translationMode: SettingPaidLevels.Basic,\n    hidePrivateSubscriptionsInTimeline: SettingPaidLevels.Basic,\n  },\n  ui: {\n    hideExtraBadge: SettingPaidLevels.Basic,\n    hideRecentReader: SettingPaidLevels.Basic,\n  },\n  integration: {\n    enableCubox: SettingPaidLevels.Basic,\n    enableObsidian: SettingPaidLevels.Basic,\n    enableOutline: SettingPaidLevels.Basic,\n    enableReadwise: SettingPaidLevels.Basic,\n    enableZotero: SettingPaidLevels.Basic,\n    enableInstapaper: SettingPaidLevels.Basic,\n    enableReadeck: SettingPaidLevels.Basic,\n    enableEagle: SettingPaidLevels.Basic,\n    enableQBittorrent: SettingPaidLevels.Basic,\n    enableCustomIntegration: SettingPaidLevels.Basic,\n  },\n  ai: {},\n} as const satisfies {\n  general: PartialRecord<keyof GeneralSettings, SettingPaidLevels>\n  ui: PartialRecord<keyof UISettings, SettingPaidLevels>\n  integration: PartialRecord<keyof IntegrationSettings, SettingPaidLevels>\n  ai: PartialRecord<keyof AISettings, SettingPaidLevels>\n}\n\nexport type SettingNamespace = keyof typeof PAID_SETTINGS\n\nexport const getSettingPaidLevel = (namespace: string, key: string) => {\n  const group = PAID_SETTINGS[namespace as keyof typeof PAID_SETTINGS]\n  if (!group) return\n  return group[key as keyof typeof group]\n}\n\nconst ACCENT_COLOR_MAP = {\n  orange: {\n    light: \"#FF6B35\",\n    dark: \"#FF5C00\",\n  },\n  blue: {\n    light: \"#5CA9F2\",\n    dark: \"#2F78E8\",\n  },\n  green: {\n    light: \"#4CD7A5\",\n    dark: \"#1FA97A\",\n  },\n  purple: {\n    light: \"#B07BEF\",\n    dark: \"#8A3DCC\",\n  },\n  pink: {\n    light: \"#F266A8\",\n    dark: \"#C63C82\",\n  },\n  red: {\n    light: \"#E84A3C\",\n    dark: \"#C22E28\",\n  },\n  yellow: {\n    light: \"#F7B500\",\n    dark: \"#D99800\",\n  },\n  gray: {\n    light: \"#8A96A3\",\n    dark: \"#5C6673\",\n  },\n} satisfies Record<string, { light: string; dark: string }>\n\nexport const getAccentColorValue = (color: AccentColor) => {\n  // If it's a custom color (hex code), return it for both light and dark\n  if (color.startsWith(\"#\")) {\n    return { light: color, dark: color }\n  }\n  const preset = ACCENT_COLOR_MAP[color as keyof typeof ACCENT_COLOR_MAP]\n  return preset || ACCENT_COLOR_MAP.orange\n}\n"
  },
  {
    "path": "packages/internal/shared/src/settings/defaults.ts",
    "content": "import type { AISettings, GeneralSettings, IntegrationSettings, UISettings } from \"./interface\"\n\nexport const DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID = \"default-summarize-timeline\"\nexport const DEFAULT_RECOMMEND_FEEDS_SHORTCUT_ID = \"default-recommend-feeds\"\n\nexport const defaultGeneralSettings: GeneralSettings = {\n  // App\n  appLaunchOnStartup: false,\n  language: \"en\",\n  translation: false,\n  translationMode: \"bilingual\",\n  summary: true,\n  actionLanguage: \"default\",\n\n  sendAnonymousData: true,\n  showQuickTimeline: true,\n\n  // subscription\n  autoGroup: true,\n  hideAllReadSubscriptions: false,\n  hidePrivateSubscriptionsInTimeline: false,\n\n  // view\n  unreadOnly: false,\n  // mark unread\n  scrollMarkUnread: true,\n  hoverMarkUnread: false,\n  renderMarkUnread: false,\n  // timeline\n  groupByDate: false,\n  autoExpandLongSocialMedia: false,\n  dimRead: false,\n\n  // Secure\n  jumpOutLinkWarn: true,\n  // TTS\n  voice: \"en-US-AndrewMultilingualNeural\",\n\n  // Pro feature\n  enhancedSettings: false,\n\n  // @mobile\n  openLinksInExternalApp: false,\n}\n\nexport const defaultUISettings: UISettings = {\n  accentColor: \"orange\",\n\n  // Sidebar\n  entryColWidth: 450,\n  aiColWidth: 384,\n  feedColWidth: 256,\n  hideExtraBadge: false,\n\n  opaqueSidebar: false,\n  sidebarShowUnreadCount: true,\n  thumbnailRatio: \"square\",\n\n  // Global UI\n  uiTextSize: 16,\n  // System\n  showDockBadge: true,\n  // Misc\n  modalOverlay: true,\n  modalDraggable: true,\n\n  reduceMotion: false,\n  usePointerCursor: false,\n\n  // Font\n  uiFontFamily: \"system-ui\",\n  readerFontFamily: \"inherit\",\n  contentFontSize: 16,\n  dateFormat: \"default\",\n  contentLineHeight: 1.75,\n  // Content\n  readerRenderInlineStyle: true,\n  codeHighlightThemeLight: \"github-light\",\n  codeHighlightThemeDark: \"github-dark\",\n  guessCodeLanguage: true,\n  hideRecentReader: false,\n  customCSS: \"\",\n\n  // View\n  pictureViewMasonry: true,\n  pictureViewImageOnly: false,\n  wideMode: false,\n\n  // Action Order\n  toolbarOrder: {\n    main: [],\n    more: [],\n  },\n\n  showUnreadCountViewAndSubscriptionMobile: false,\n  showUnreadCountBadgeMobile: false,\n\n  // Discover\n  discoverLanguage: \"all\",\n\n  // Timeline tabs preset (excluding the first fixed tab)\n  timelineTabs: {\n    visible: [],\n    hidden: [],\n  },\n}\n\nexport const defaultIntegrationSettings: IntegrationSettings = {\n  // eagle\n  enableEagle: false,\n\n  // readwise\n  enableReadwise: false,\n  readwiseToken: \"\",\n\n  // instapaper\n  enableInstapaper: false,\n  instapaperUsername: \"\",\n  instapaperPassword: \"\",\n\n  // obsidian\n  enableObsidian: false,\n  obsidianVaultPath: \"\",\n\n  // outline\n  enableOutline: false,\n  outlineEndpoint: \"\",\n  outlineToken: \"\",\n  outlineCollection: \"\",\n\n  // readeck\n  enableReadeck: false,\n  readeckEndpoint: \"\",\n  readeckToken: \"\",\n\n  // cubox\n  enableCubox: false,\n  cuboxToken: \"\",\n  enableCuboxAutoMemo: false,\n\n  // zotero\n  enableZotero: false,\n  zoteroUserID: \"\",\n  zoteroToken: \"\",\n\n  // qbittorrent\n  enableQBittorrent: false,\n  qbittorrentHost: \"\",\n  qbittorrentUsername: \"\",\n  qbittorrentPassword: \"\",\n\n  saveSummaryAsDescription: false,\n\n  // custom actions\n  enableCustomIntegration: false,\n  customIntegration: [],\n\n  // fetch preferences (Electron only)\n  useBrowserFetch: false,\n}\n\nexport const defaultAISettings: AISettings = {\n  personalizePrompt: \"\",\n  aiTimelinePrompt: \"\",\n  shortcuts: [],\n\n  // MCP Services\n  mcpEnabled: false,\n  mcpServices: [],\n\n  // Features\n  autoScrollWhenStreaming: true,\n\n  // BYOK (Bring Your Own Key)\n  byok: {\n    enabled: false,\n    providers: [],\n  },\n}\n\nexport const defaultSettings = {\n  general: defaultGeneralSettings,\n  ui: defaultUISettings,\n  integration: defaultIntegrationSettings,\n  ai: defaultAISettings,\n}\n"
  },
  {
    "path": "packages/internal/shared/src/settings/hook.ts",
    "content": "import { useCallback, useMemo } from \"react\"\n\n// Generic enhanced settings hook composer shared across platforms\nexport function hookEnhancedSettings<\n  TUseKey extends (key: any) => any,\n  TUseSelector extends (selector: (s: any) => any) => any,\n  TUseKeys extends (keys: any) => any,\n  TGetSettings extends () => any,\n  TUseValue extends () => any,\n>(\n  useSettingKey: TUseKey,\n  useSettingSelector: TUseSelector,\n  useSettingKeys: TUseKeys,\n  getSettings: TGetSettings,\n  useSettingValue: TUseValue,\n\n  enhancedSettingKeys: Set<string>,\n  defaultSettings: Record<string, any>,\n  options: {\n    // Provide whether enhanced settings are enabled (reactive version)\n    useEnhancedEnabled: () => boolean\n    // Provide whether enhanced settings are enabled (sync read version)\n    getEnhancedEnabled: () => boolean\n  },\n) {\n  const { useEnhancedEnabled, getEnhancedEnabled } = options\n\n  const useNextSettingKey = (key: string) => {\n    const enableEnhancedSettings = useEnhancedEnabled()\n    const settingValue = useSettingKey(key)\n    const shouldBackToDefault = enhancedSettingKeys.has(key) && !enableEnhancedSettings\n    if (!shouldBackToDefault) {\n      return settingValue\n    }\n\n    return defaultSettings[key] === undefined ? settingValue : defaultSettings[key]\n  }\n\n  const useNextSettingSelector = (selector: (s: any) => any) => {\n    const enableEnhancedSettings = useEnhancedEnabled()\n    return useSettingSelector(\n      useCallback(\n        (settings) => {\n          if (enableEnhancedSettings) {\n            return selector(settings)\n          }\n\n          const enhancedSettings = { ...settings }\n          for (const key of enhancedSettingKeys) {\n            if (defaultSettings[key] !== undefined) {\n              enhancedSettings[key] = defaultSettings[key]\n            }\n          }\n\n          return selector(enhancedSettings)\n        },\n        [enableEnhancedSettings, selector],\n      ),\n    )\n  }\n\n  const useNextSettingKeys = (keys: string[]) => {\n    const enableEnhancedSettings = useEnhancedEnabled()\n    const rawSettingValues: string[] = useSettingKeys(keys)\n\n    return useMemo(() => {\n      if (enableEnhancedSettings) {\n        return rawSettingValues\n      }\n\n      const result: string[] = []\n\n      for (const [i, key] of keys.entries()) {\n        if (enhancedSettingKeys.has(key) && defaultSettings[key] !== undefined) {\n          result.push(defaultSettings[key])\n        } else if (rawSettingValues[i] !== undefined) {\n          result.push(rawSettingValues[i])\n        }\n      }\n\n      return result\n    }, [enableEnhancedSettings, keys, rawSettingValues])\n  }\n\n  const getNextSettings = () => {\n    const settings = getSettings()\n    const enableEnhancedSettings = getEnhancedEnabled()\n\n    if (enableEnhancedSettings) {\n      return settings\n    }\n\n    const enhancedSettings = { ...settings }\n    for (const key of enhancedSettingKeys) {\n      if (defaultSettings[key] !== undefined) {\n        enhancedSettings[key] = defaultSettings[key]\n      }\n    }\n\n    return enhancedSettings\n  }\n\n  const useNextSettingValue = () => {\n    const settingValues = useSettingValue()\n    const enableEnhancedSettings = useEnhancedEnabled()\n\n    return useMemo(() => {\n      if (enableEnhancedSettings) {\n        return settingValues\n      }\n\n      const result = { ...settingValues }\n      for (const key of enhancedSettingKeys) {\n        if (defaultSettings[key] !== undefined) {\n          result[key] = defaultSettings[key]\n        }\n      }\n\n      return result\n    }, [enableEnhancedSettings, settingValues])\n  }\n\n  return [\n    useNextSettingKey as TUseKey,\n    useNextSettingSelector as TUseSelector,\n    useNextSettingKeys as TUseKeys,\n    getNextSettings as TGetSettings,\n    useNextSettingValue as TUseValue,\n  ] as [TUseKey, TUseSelector, TUseKeys, TGetSettings, TUseValue]\n}\n"
  },
  {
    "path": "packages/internal/shared/src/settings/interface.ts",
    "content": "export interface GeneralSettings {\n  appLaunchOnStartup: boolean\n  language: string\n  translation: boolean\n  translationMode: \"bilingual\" | \"translation-only\"\n  summary: boolean\n  actionLanguage: string\n  sendAnonymousData: boolean\n  unreadOnly: boolean\n  scrollMarkUnread: boolean\n  hoverMarkUnread: boolean\n  renderMarkUnread: boolean\n  groupByDate: boolean\n  jumpOutLinkWarn: boolean\n  dimRead: boolean\n  // TTS\n  voice: string\n\n  // subscription\n  autoGroup: boolean\n  hideAllReadSubscriptions: boolean\n  hidePrivateSubscriptionsInTimeline: boolean\n\n  /**\n   * Top timeline for mobile\n   */\n  showQuickTimeline: boolean\n  /**\n   * Auto expand long social media\n   */\n  autoExpandLongSocialMedia: boolean\n\n  // Pro feature\n  enhancedSettings: boolean\n\n  // @mobile\n  openLinksInExternalApp: boolean\n}\n\nexport type AccentColor =\n  | \"orange\"\n  | \"blue\"\n  | \"green\"\n  | \"purple\"\n  | \"pink\"\n  | \"red\"\n  | \"yellow\"\n  | \"gray\"\n  | string // Allow custom hex colors\nexport interface UISettings {\n  accentColor: AccentColor\n  customAccentColor?: string // Store custom color value\n  entryColWidth: number\n  aiColWidth: number\n  /**\n   * Dedicated AI panel width for `FeedViewType.All`.\n   * If not set, the runtime default falls back to half of the window width.\n   */\n  feedColWidth: number\n  opaqueSidebar: boolean\n  sidebarShowUnreadCount: boolean\n  hideExtraBadge: boolean\n  thumbnailRatio: \"square\" | \"original\"\n  uiTextSize: number\n  showDockBadge: boolean\n  modalOverlay: boolean\n  modalDraggable: boolean\n  reduceMotion: boolean\n  usePointerCursor: boolean | null\n  uiFontFamily: string\n  readerFontFamily: string\n  // Content\n  readerRenderInlineStyle: boolean\n  codeHighlightThemeLight: string\n  codeHighlightThemeDark: string\n  guessCodeLanguage: boolean\n  hideRecentReader: boolean\n  customCSS: string\n\n  // view\n  pictureViewMasonry: boolean\n  pictureViewImageOnly: boolean\n  wideMode: boolean\n  contentFontSize: number\n  dateFormat: string\n  contentLineHeight: number\n\n  // Action Order\n  toolbarOrder: {\n    main: (string | number)[]\n    more: (string | number)[]\n  }\n\n  // @mobile\n  showUnreadCountViewAndSubscriptionMobile: boolean\n  showUnreadCountBadgeMobile: boolean\n\n  // Discover\n  discoverLanguage: \"all\" | \"eng\" | \"cmn\" | \"fra\"\n\n  // Desktop: Timeline tabs preset (excluding the first fixed tab)\n  timelineTabs: {\n    visible: string[]\n    hidden: string[]\n  }\n}\n\nexport interface IntegrationSettings {\n  // eagle\n  enableEagle: boolean\n\n  // readwise\n  enableReadwise: boolean\n  readwiseToken: string\n\n  // instapaper\n  enableInstapaper: boolean\n  instapaperUsername: string\n  instapaperPassword: string\n\n  // obsidian\n  enableObsidian: boolean\n  obsidianVaultPath: string\n\n  // outline\n  enableOutline: boolean\n  outlineEndpoint: string\n  outlineToken: string\n  outlineCollection: string\n\n  // readeck\n  enableReadeck: boolean\n  readeckEndpoint: string\n  readeckToken: string\n\n  // cubox\n  enableCubox: boolean\n  cuboxToken: string\n  enableCuboxAutoMemo: boolean\n\n  //zotero\n  enableZotero: boolean\n  zoteroUserID: string\n  zoteroToken: string\n\n  // qbittorrent\n  enableQBittorrent: boolean\n  qbittorrentHost: string\n  qbittorrentUsername: string\n  qbittorrentPassword: string\n\n  saveSummaryAsDescription: boolean\n\n  // custom actions\n  enableCustomIntegration: boolean\n  customIntegration: CustomIntegration[]\n\n  // fetch preferences (Electron only)\n  useBrowserFetch: boolean\n}\n\nexport interface FetchTemplate {\n  method: \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\"\n  url: string\n  headers: Record<string, string>\n  body?: string\n}\n\nexport interface URLSchemeTemplate {\n  scheme: string // e.g., \"obsidian://\", \"bear://\", \"drafts://action\"\n  // URL schemes use query parameters or path segments for data\n}\n\nexport interface CustomIntegration {\n  id: string\n  name: string\n  icon: string\n  type?: \"http\" | \"url-scheme\" // Optional for backward compatibility\n  fetchTemplate?: FetchTemplate // Keep optional for url-scheme integrations\n  urlSchemeTemplate?: URLSchemeTemplate\n  enabled: boolean\n}\n\nexport type AIShortcutTarget = \"list\" | \"entry\"\n\nexport const DEFAULT_SHORTCUT_TARGETS: readonly AIShortcutTarget[] = [\"list\", \"entry\"]\n\nexport interface AIShortcut {\n  id: string\n  name: string\n  prompt: string\n  defaultPrompt?: string\n  enabled: boolean\n  icon?: string\n  hotkey?: string\n  displayTargets?: AIShortcutTarget[]\n}\n\nexport type MCPTransportType = \"streamable-http\" | \"sse\"\n\nexport interface MCPService {\n  id: string\n  name: string\n  transportType: MCPTransportType\n  url?: string\n  headers?: Record<string, string>\n  isConnected: boolean\n  enabled: boolean\n  lastError?: string\n  toolCount: number\n  resourceCount: number\n  promptCount: number\n  createdAt: string\n  lastUsed: string | null\n}\n\nexport interface AISettings {\n  personalizePrompt: string\n  aiTimelinePrompt: string\n  shortcuts: AIShortcut[]\n\n  // MCP Services (stored locally, actual connections managed via server API)\n  mcpEnabled: boolean\n  mcpServices: MCPService[]\n\n  // Features\n  autoScrollWhenStreaming: boolean\n\n  byok: UserByokSettings\n}\n\nexport type ByokProviderName = \"openai\" | \"google\" | \"vercel-ai-gateway\" | \"openrouter\"\n\nexport type UserByokProviderConfig = {\n  provider: ByokProviderName\n  baseURL?: string | null\n  apiKey?: string | null\n  headers?: Record<string, string>\n}\n\nexport type UserByokSettings = {\n  enabled: boolean\n  providers: UserByokProviderConfig[]\n}\n"
  },
  {
    "path": "packages/internal/shared/tsconfig.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.node.json\",\n  \"compilerOptions\": {\n    \"composite\": false,\n    \"types\": [\"electron-vite/node\"],\n    \"moduleResolution\": \"Bundler\",\n    \"noImplicitReturns\": false,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@shared/*\": [\"./src/*\"],\n      \"@pkg\": [\"../../package.json\"],\n      \"@locales/*\": [\"../../../../locales/*\"],\n      \"~/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "packages/internal/store/package.json",
    "content": "{\n  \"name\": \"@follow/store\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"author\": \"Folo Team\",\n  \"license\": \"AGPL-3.0-only\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/follow\",\n    \"type\": \"git\"\n  },\n  \"sideEffects\": false,\n  \"exports\": {\n    \"./action/*\": {\n      \"types\": \"./src/modules/action/*.ts\",\n      \"require\": \"./src/modules/action/*.ts\",\n      \"import\": \"./src/modules/action/*.ts\"\n    },\n    \"./collection/*\": {\n      \"types\": \"./src/modules/collection/*.ts\",\n      \"require\": \"./src/modules/collection/*.ts\",\n      \"import\": \"./src/modules/collection/*.ts\"\n    },\n    \"./constants/*\": {\n      \"types\": \"./src/constants/*.ts\",\n      \"require\": \"./src/constants/*.ts\",\n      \"import\": \"./src/constants/*.ts\"\n    },\n    \"./context\": {\n      \"types\": \"./src/context.ts\",\n      \"require\": \"./src/context.ts\",\n      \"import\": \"./src/context.ts\"\n    },\n    \"./entry/*\": {\n      \"types\": \"./src/modules/entry/*.ts\",\n      \"require\": \"./src/modules/entry/*.ts\",\n      \"import\": \"./src/modules/entry/*.ts\"\n    },\n    \"./feed/*\": {\n      \"types\": \"./src/modules/feed/*.ts\",\n      \"require\": \"./src/modules/feed/*.ts\",\n      \"import\": \"./src/modules/feed/*.ts\"\n    },\n    \"./hydrate\": {\n      \"types\": \"./src/hydrate.ts\",\n      \"require\": \"./src/hydrate.ts\",\n      \"import\": \"./src/hydrate.ts\"\n    },\n    \"./image/*\": {\n      \"types\": \"./src/modules/image/*.ts\",\n      \"require\": \"./src/modules/image/*.ts\",\n      \"import\": \"./src/modules/image/*.ts\"\n    },\n    \"./inbox/*\": {\n      \"types\": \"./src/modules/inbox/*.ts\",\n      \"require\": \"./src/modules/inbox/*.ts\",\n      \"import\": \"./src/modules/inbox/*.ts\"\n    },\n    \"./list/*\": {\n      \"types\": \"./src/modules/list/*.ts\",\n      \"require\": \"./src/modules/list/*.ts\",\n      \"import\": \"./src/modules/list/*.ts\"\n    },\n    \"./morph\": {\n      \"types\": \"./src/morph/*.ts\",\n      \"require\": \"./src/morph/*.ts\",\n      \"import\": \"./src/morph/*.ts\"\n    },\n    \"./reset\": {\n      \"types\": \"./src/reset.ts\",\n      \"require\": \"./src/reset.ts\",\n      \"import\": \"./src/reset.ts\"\n    },\n    \"./subscription/*\": {\n      \"types\": \"./src/modules/subscription/*.ts\",\n      \"require\": \"./src/modules/subscription/*.ts\",\n      \"import\": \"./src/modules/subscription/*.ts\"\n    },\n    \"./summary/*\": {\n      \"types\": \"./src/modules/summary/*.ts\",\n      \"require\": \"./src/modules/summary/*.ts\",\n      \"import\": \"./src/modules/summary/*.ts\"\n    },\n    \"./translation/*\": {\n      \"types\": \"./src/modules/translation/*.ts\",\n      \"require\": \"./src/modules/translation/*.ts\",\n      \"import\": \"./src/modules/translation/*.ts\"\n    },\n    \"./unread/*\": {\n      \"types\": \"./src/modules/unread/*.ts\",\n      \"require\": \"./src/modules/unread/*.ts\",\n      \"import\": \"./src/modules/unread/*.ts\"\n    },\n    \"./user/*\": {\n      \"types\": \"./src/modules/user/*.ts\",\n      \"require\": \"./src/modules/user/*.ts\",\n      \"import\": \"./src/modules/user/*.ts\"\n    }\n  },\n  \"scripts\": {\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"19.2.0\"\n  },\n  \"dependencies\": {\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@follow/constants\": \"workspace:*\",\n    \"@follow/database\": \"workspace:*\",\n    \"@follow/models\": \"workspace:*\",\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/tracker\": \"workspace:*\",\n    \"@follow/utils\": \"workspace:*\",\n    \"@tanstack/react-query\": \"5.90.21\",\n    \"es-toolkit\": \"1.44.0\",\n    \"immer\": \"11.1.4\",\n    \"zustand\": \"5.0.11\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/store/src/@types/default-resource.ts",
    "content": "import settings_en from \"../../../../../locales/settings/en.json\"\n\nexport const defaultResources = {\n  en: {\n    settings: settings_en,\n  },\n}\n"
  },
  {
    "path": "packages/internal/store/src/@types/i18next.d.ts",
    "content": "import type { defaultResources as resources } from \"./default-resource\"\n\ndeclare module \"i18next\" {\n  interface CustomTypeOptions {\n    ns: [\"settings\"]\n    resources: (typeof resources)[\"en\"]\n    defaultNS: \"settings\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/store/src/constants/app.ts",
    "content": "import { getStorageNS } from \"@follow/utils/ns\"\n\n/// Feed\nexport const FEED_COLLECTION_LIST = \"collections\"\n/// Local storage keys\nexport const QUERY_PERSIST_KEY = getStorageNS(\"REACT_QUERY_OFFLINE_CACHE\")\nexport const I18N_LOCALE_KEY = getStorageNS(\"I18N_LOCALE\")\n\n/// Route Keys\nexport const ROUTE_FEED_PENDING = \"all\"\nexport const ROUTE_ENTRY_PENDING = \"pending\"\nexport const ROUTE_FEED_IN_FOLDER = \"folder-\"\nexport const ROUTE_FEED_IN_LIST = \"list-\"\nexport const ROUTE_FEED_IN_INBOX = \"inbox-\"\nexport const ROUTE_TIMELINE_OF_VIEW = \"view-\"\n"
  },
  {
    "path": "packages/internal/store/src/constants/onboarding.ts",
    "content": "import { getEntry } from \"../modules/entry/getter\"\n\nconst ONBOARDING_ENTRY_URL_PREFIX = \"folo://onboarding\"\n\nexport const isOnboardingEntryUrl = (url?: string | null) => {\n  return typeof url === \"string\" && url.startsWith(ONBOARDING_ENTRY_URL_PREFIX)\n}\n\nexport const isOnboardingEntry = (entryId: string) => {\n  return isOnboardingEntryUrl(getEntry(entryId)?.url)\n}\n\nexport const isOnboardingFeedUrl = (url?: string | null) => {\n  return typeof url === \"string\" && url.startsWith(ONBOARDING_ENTRY_URL_PREFIX)\n}\n"
  },
  {
    "path": "packages/internal/store/src/context.ts",
    "content": "import type { AuthClient } from \"@follow/shared/auth\"\nimport type { QueryClient } from \"@tanstack/react-query\"\n\nimport type { FollowAPI } from \"./types\"\n\nconst NO_VALUE_DEFAULT = Symbol(\"NO_VALUE_DEFAULT\")\ntype ContextValue<T> = T | typeof NO_VALUE_DEFAULT\n\nfunction createJSContext<T>() {\n  let contextValue: ContextValue<T> = NO_VALUE_DEFAULT\n\n  const provide = (value: T) => {\n    contextValue = value\n  }\n\n  const consumer = (): T => {\n    if (contextValue === NO_VALUE_DEFAULT) {\n      throw new TypeError(\"You should only use this context value inside a provider.\")\n    }\n    return contextValue\n  }\n\n  return {\n    provide,\n    consumer,\n  }\n}\n\nexport const apiContext = createJSContext<FollowAPI>()\nexport const authClientContext = createJSContext<AuthClient>()\nexport const queryClientContext = createJSContext<QueryClient>()\n\nexport const api = apiContext.consumer\nexport const authClient = authClientContext.consumer\nexport const queryClient = queryClientContext.consumer\n"
  },
  {
    "path": "packages/internal/store/src/hydrate.ts",
    "content": "import { initializeDB, migrateDB } from \"@follow/database/db\"\n\nimport type { Hydratable } from \"./lib/base\"\nimport { collectionActions } from \"./modules/collection/store\"\nimport { entryActions } from \"./modules/entry/store\"\nimport { feedActions } from \"./modules/feed/store\"\nimport { imageActions } from \"./modules/image/store\"\nimport { inboxActions } from \"./modules/inbox/store\"\nimport { listActions } from \"./modules/list/store\"\nimport { subscriptionActions } from \"./modules/subscription/store\"\nimport { summaryActions } from \"./modules/summary/store\"\nimport { translationActions } from \"./modules/translation/store\"\nimport { unreadActions } from \"./modules/unread/store\"\nimport { userActions } from \"./modules/user/store\"\n\nconst hydrates: Hydratable[] = [\n  feedActions,\n  subscriptionActions,\n  inboxActions,\n  listActions,\n  unreadActions,\n  userActions,\n  entryActions,\n  collectionActions,\n  summaryActions,\n  translationActions,\n  imageActions,\n]\n\nexport const hydrateDatabaseToStore = async (options?: { migrateDatabase?: boolean }) => {\n  if (options?.migrateDatabase) {\n    await initializeDB()\n    await migrateDB()\n  }\n  await Promise.all(hydrates.map((h) => h.hydrate()))\n}\n"
  },
  {
    "path": "packages/internal/store/src/lib/base.ts",
    "content": "export interface Hydratable {\n  hydrate: () => Promise<void>\n}\n\nexport interface Resetable {\n  reset: () => Promise<void>\n}\n"
  },
  {
    "path": "packages/internal/store/src/lib/helper.ts",
    "content": "/**\n * @copy from renderer/src/store/utils/helper.ts\n */\nimport { enableMapSet, isDraft, original, produce } from \"immer\"\nimport type { StateCreator, StoreApi, UseBoundStore } from \"zustand\"\nimport { shallow } from \"zustand/shallow\"\nimport type { UseBoundStoreWithEqualityFn } from \"zustand/traditional\"\nimport { createWithEqualityFn } from \"zustand/traditional\"\n\nconst storeMap = {} as Record<string, UseBoundStoreWithEqualityFn<any>>\n\nenableMapSet()\ndeclare const globalThis: any\nexport const createZustandStore =\n  <S, T extends StateCreator<S, [], []> = StateCreator<S, [], []>>(name: string) =>\n  (store: T) => {\n    const newStore = createWithEqualityFn(store, shallow)\n\n    storeMap[name] = newStore\n\n    globalThis.store =\n      globalThis.store ||\n      new Proxy(\n        {},\n        {\n          get(_, prop) {\n            if (prop in storeMap) {\n              return new Proxy(() => {}, {\n                get() {\n                  return storeMap[prop as string].getState()\n                },\n                apply(target, thisArg, argumentsList) {\n                  return storeMap[prop as string].setState(\n                    produce(storeMap[prop as string].getState(), ...argumentsList),\n                  )\n                },\n              })\n            }\n            return\n          },\n        },\n      )\n\n    globalThis[`store_${name}`] = newStore\n\n    return newStore\n  }\ntype FunctionKeys<T> = {\n  [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never\n}[keyof T]\n\ntype FunctionProps<T> = Pick<T, FunctionKeys<T>>\nexport const getStoreActions = <T extends { getState: () => any }>(\n  store: T,\n): FunctionProps<ReturnType<T[\"getState\"]>> => {\n  const actions = {} as any\n  const state = store.getState()\n  for (const key in state) {\n    if (typeof state[key] === \"function\") {\n      actions[key] = state[key]\n    }\n  }\n\n  return actions as any\n}\n\nexport function createImmerSetter<T>(useStore: UseBoundStore<StoreApi<T>>) {\n  return (updater: (state: T) => void) =>\n    useStore.setState((state) =>\n      produce(state, (draft) => {\n        try {\n          return updater(draft as T)\n        } catch (error) {\n          console.error(error)\n          throw error\n        }\n      }),\n    )\n}\n\ntype MayBeDraft<T> = T\nexport const toRaw = <T>(draft: MayBeDraft<T>): T => {\n  return isDraft(draft) ? original(draft)! : draft\n}\ntype SyncOrAsync<T> = T | Promise<T>\ntype ExecutorFn<S, Ctx, Result = void> = (snapshot: S, ctx: Ctx) => SyncOrAsync<Result>\ntype PersisterFn<S, Ctx, Result = void> = (\n  snapshot: S,\n  ctx: Ctx,\n  result?: Result,\n) => SyncOrAsync<void>\n\nclass Transaction<S, Ctx, Result = void> {\n  private _snapshot: S\n  private _ctx: Ctx\n  private _result?: Result\n  private onRollback?: ExecutorFn<S, Ctx>\n  private executorFn?: ExecutorFn<S, Ctx, Result>\n  private optimisticExecutor?: ExecutorFn<S, Ctx>\n  private onPersist?: PersisterFn<S, Ctx, Result>\n\n  constructor(snapshot?: S, ctx?: Ctx) {\n    this._snapshot = snapshot || ({} as S)\n    this._ctx = ctx || ({} as Ctx)\n  }\n\n  rollback(fn: ExecutorFn<S, Ctx>): this {\n    this.onRollback = fn\n    return this\n  }\n\n  request(executor: ExecutorFn<S, Ctx, Result>): this {\n    this.executorFn = executor\n    return this\n  }\n\n  store(executor: ExecutorFn<S, Ctx>): this {\n    this.optimisticExecutor = executor\n    return this\n  }\n\n  persist(fn: PersisterFn<S, Ctx, Result>): this {\n    this.onPersist = fn\n    return this\n  }\n\n  async run(): Promise<void> {\n    let isOptimisticFailed = false\n\n    if (this.optimisticExecutor) {\n      try {\n        await Promise.resolve(this.optimisticExecutor(this._snapshot, this._ctx))\n      } catch (error) {\n        isOptimisticFailed = true\n        console.error(error)\n      }\n    }\n\n    if (this.executorFn) {\n      try {\n        this._result = await Promise.resolve(this.executorFn(this._snapshot, this._ctx))\n      } catch (err) {\n        if (this.onRollback && !isOptimisticFailed) {\n          await Promise.resolve(this.onRollback(this._snapshot, this._ctx))\n        }\n        throw err\n      }\n    }\n\n    if (this.onPersist) {\n      await Promise.resolve(this.onPersist!(this._snapshot, this._ctx, this._result)).catch(\n        (err) => {\n          console.error(err)\n          throw err\n        },\n      )\n    }\n  }\n}\n\nexport const createTransaction = <S, Ctx, Result = void>(\n  snapshot?: S,\n  ctx?: Ctx,\n): Transaction<S, Ctx, Result> => {\n  return new Transaction(snapshot, ctx)\n}\n\nexport const createSelectorHelper = <TState>() => {\n  return function defineSelector<TSelected>(selector: (state: TState) => TSelected) {\n    return selector\n  }\n}\n\n// Utility functions for creating static getters from store selectors\nexport const createStaticGetter =\n  <TState, TArgs extends unknown[], TResult>(\n    getState: () => TState,\n    selectorFn: (state: TState) => (...args: TArgs) => TResult,\n  ) =>\n  (...args: TArgs): TResult =>\n    selectorFn(getState())(...args)\n\nexport const createSingleArgGetter =\n  <TState, TArg, TResult>(\n    getState: () => TState,\n    selectorFn: (state: TState) => (arg: TArg) => TResult,\n  ) =>\n  (arg: TArg): TResult =>\n    selectorFn(getState())(arg)\n"
  },
  {
    "path": "packages/internal/store/src/lib/stream.ts",
    "content": "type LineHandler<T> = (data: T) => void | Promise<void>\n\nconst processNdjsonText = async <T = unknown>(text: string, onLine: LineHandler<T>) => {\n  const lines = text.split(\"\\n\")\n\n  for (const line of lines) {\n    const trimmed = line.trim()\n    if (!trimmed) continue\n\n    try {\n      const json = JSON.parse(trimmed) as T\n      await onLine(json)\n    } catch (error) {\n      console.error(\"Failed to parse NDJSON line:\", error)\n    }\n  }\n}\n\n/**\n * Read a Response body as a newline-delimited JSON stream.\n * Each complete line will be parsed and passed to onLine.\n */\nexport async function readNdjsonStream<T = unknown>(response: Response, onLine: LineHandler<T>) {\n  const reader = response.body?.getReader()\n  if (!reader) {\n    await processNdjsonText<T>(await response.text(), onLine)\n    return\n  }\n\n  const decoder = new TextDecoder()\n  let buffer = \"\"\n\n  try {\n    while (true) {\n      const { done, value } = await reader.read()\n      if (done) break\n      buffer += decoder.decode(value, { stream: true })\n      const lines = buffer.split(\"\\n\")\n      for (let i = 0; i < lines.length - 1; i++) {\n        await processNdjsonText<T>(lines[i]!, onLine)\n      }\n      buffer = lines.at(-1) || \"\"\n    }\n\n    if (buffer.trim()) {\n      await processNdjsonText<T>(buffer, onLine)\n    }\n  } finally {\n    reader.releaseLock()\n  }\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/action/constant.ts",
    "content": "import type {\n  ActionFeedField,\n  ActionId,\n  ActionOperation,\n  SupportedLanguages,\n} from \"@follow-app/client-sdk\"\nimport type { ParseKeys } from \"i18next\"\nimport type { SFSymbol } from \"sf-symbols-typescript\"\n\nimport { actionActions } from \"./store\"\n\nconst filterFieldOptionsMap: {\n  [K in ActionFeedField]: {\n    label: Extract<ParseKeys<\"settings\">, `actions.action_card.feed_options.${string}`>\n    value: K\n    type?: \"text\" | \"number\" | \"view\" | \"status\"\n  }\n} = {\n  status: {\n    label: \"actions.action_card.feed_options.status\",\n    value: \"status\",\n    type: \"status\",\n  },\n  view: {\n    label: \"actions.action_card.feed_options.subscription_view\",\n    value: \"view\",\n    type: \"view\",\n  },\n  title: {\n    label: \"actions.action_card.feed_options.feed_title\",\n    value: \"title\",\n  },\n  category: {\n    label: \"actions.action_card.feed_options.feed_category\",\n    value: \"category\",\n  },\n  site_url: {\n    label: \"actions.action_card.feed_options.site_url\",\n    value: \"site_url\",\n  },\n  feed_url: {\n    label: \"actions.action_card.feed_options.feed_url\",\n    value: \"feed_url\",\n  },\n  entry_title: {\n    label: \"actions.action_card.feed_options.entry_title\",\n    value: \"entry_title\",\n  },\n  entry_content: {\n    label: \"actions.action_card.feed_options.entry_content\",\n    value: \"entry_content\",\n  },\n  entry_url: {\n    label: \"actions.action_card.feed_options.entry_url\",\n    value: \"entry_url\",\n  },\n  entry_author: {\n    label: \"actions.action_card.feed_options.entry_author\",\n    value: \"entry_author\",\n  },\n  entry_media_length: {\n    label: \"actions.action_card.feed_options.entry_media_length\",\n    value: \"entry_media_length\",\n    type: \"number\",\n  },\n  entry_attachments_duration: {\n    label: \"actions.action_card.feed_options.entry_attachments_duration\",\n    value: \"entry_attachments_duration\",\n    type: \"number\",\n  },\n}\n\nexport const filterFieldOptions = Object.values(filterFieldOptionsMap)\n\nconst filterOperatorOptionsMap: {\n  [K in ActionOperation]: {\n    label: Extract<ParseKeys<\"settings\">, `actions.action_card.operation_options.${string}`>\n    value: K\n    types: Array<\"text\" | \"number\" | \"view\" | \"status\">\n  }\n} = {\n  contains: {\n    label: \"actions.action_card.operation_options.contains\",\n    value: \"contains\",\n    types: [\"text\"],\n  },\n  not_contains: {\n    label: \"actions.action_card.operation_options.does_not_contain\",\n    value: \"not_contains\",\n    types: [\"text\"],\n  },\n  eq: {\n    label: \"actions.action_card.operation_options.is_equal_to\",\n    value: \"eq\",\n    types: [\"number\", \"text\", \"view\", \"status\"],\n  },\n  not_eq: {\n    label: \"actions.action_card.operation_options.is_not_equal_to\",\n    value: \"not_eq\",\n    types: [\"number\", \"text\", \"view\"],\n  },\n  gt: {\n    label: \"actions.action_card.operation_options.is_greater_than\",\n    value: \"gt\",\n    types: [\"number\"],\n  },\n  lt: {\n    label: \"actions.action_card.operation_options.is_less_than\",\n    value: \"lt\",\n    types: [\"number\"],\n  },\n  regex: {\n    label: \"actions.action_card.operation_options.matches_regex\",\n    value: \"regex\",\n    types: [\"text\"],\n  },\n}\n\nexport const filterOperatorOptions = Object.values(filterOperatorOptionsMap)\n\nexport type ActionAction = {\n  value: ActionId\n  label: Extract<ParseKeys<\"settings\">, `actions.action_card.${string}`>\n  onEnable?: (index: number) => void\n  icon: SFSymbol\n  iconClassname: string\n  settingsPath?: string\n\n  prefixElement?: React.ReactNode\n}\n\nexport const availableActionMap: Record<ActionId, ActionAction> = {\n  summary: {\n    value: \"summary\",\n    label: \"actions.action_card.generate_summary\",\n    icon: \"sparkles\",\n    iconClassname: \"i-mgc-ai-cute-re\",\n  },\n  translation: {\n    value: \"translation\",\n    label: \"actions.action_card.translate_into\",\n    icon: \"translate\",\n    iconClassname: \"i-mgc-translate-2-ai-cute-re\",\n  },\n  readability: {\n    value: \"readability\",\n    label: \"actions.action_card.enable_readability\",\n    icon: \"text.document\",\n    iconClassname: \"i-mgc-docment-cute-re\",\n  },\n  sourceContent: {\n    value: \"sourceContent\",\n    label: \"actions.action_card.source_content\",\n    icon: \"macwindow\",\n    iconClassname: \"i-mgc-web-cute-re\",\n  },\n  newEntryNotification: {\n    value: \"newEntryNotification\",\n    label: \"actions.action_card.new_entry_notification\",\n    icon: \"bell.and.waves.left.and.right\",\n    iconClassname: \"i-mgc-notification-cute-re\",\n    settingsPath: \"notifications\",\n  },\n  silence: {\n    value: \"silence\",\n    label: \"actions.action_card.silence\",\n    icon: \"speaker.slash\",\n    iconClassname: \"i-mgc-volume-mute-cute-re\",\n  },\n  block: {\n    value: \"block\",\n    label: \"actions.action_card.block\",\n    icon: \"xmark.circle\",\n    iconClassname: \"i-mgc-delete-2-cute-re\",\n  },\n  star: {\n    value: \"star\",\n    label: \"actions.action_card.star\",\n    icon: \"star\",\n    iconClassname: \"i-mgc-star-cute-re\",\n  },\n  rewriteRules: {\n    value: \"rewriteRules\",\n    label: \"actions.action_card.rewrite_rules\",\n    icon: \"pencil.and.outline\",\n    iconClassname: \"i-mgc-quill-pen-cute-re\",\n    onEnable: (index: number) => {\n      actionActions.addRewriteRule(index)\n    },\n  },\n  webhooks: {\n    value: \"webhooks\",\n    label: \"actions.action_card.webhooks\",\n    icon: \"arrow.up.right.square\",\n    iconClassname: \"i-mgc-webhook-cute-re\",\n    onEnable: (index) => {\n      actionActions.addWebhook(index)\n    },\n  },\n}\n\nexport const translationOptions: {\n  label: string\n  value: SupportedLanguages\n}[] = [\n  {\n    label: \"English\",\n    value: \"en\",\n  },\n  {\n    label: \"日本語\",\n    value: \"ja\",\n  },\n  {\n    label: \"简体中文\",\n    value: \"zh-CN\",\n  },\n  {\n    label: \"繁體中文\",\n    value: \"zh-TW\",\n  },\n]\n"
  },
  {
    "path": "packages/internal/store/src/modules/action/hooks.ts",
    "content": "import type { ActionConditionIndex } from \"@follow-app/client-sdk\"\nimport { useMutation, useQuery } from \"@tanstack/react-query\"\nimport { useCallback } from \"react\"\n\nimport type { GeneralMutationOptions } from \"../../types\"\nimport type { ActionItem } from \"./store\"\nimport { actionActions, actionSyncService, useActionStore } from \"./store\"\n\nexport const usePrefetchActions = () => {\n  return useQuery({\n    queryKey: [\"action\", \"rules\"],\n    queryFn: () => actionSyncService.fetchRules(),\n  })\n}\n\nexport const useUpdateActionsMutation = (options?: GeneralMutationOptions) => {\n  return useMutation({\n    ...options,\n    mutationFn: () => actionSyncService.saveRules(),\n  })\n}\n\nexport function useActionRules(): ActionItem[]\nexport function useActionRules<T>(selector: (rules: ActionItem[]) => T): T\nexport function useActionRules<T>(selector?: (rules: ActionItem[]) => T) {\n  return useActionStore((state) => {\n    const { rules } = state\n    return selector ? selector(rules) : rules\n  })\n}\n\nexport function useActionRule(index: number): ActionItem | undefined\nexport function useActionRule<T>(index: number, selector: (rule: ActionItem) => T): T\nexport function useActionRule<T>(index: number, selector?: (rule: ActionItem) => T) {\n  return useActionStore((state) => {\n    const rule = state.rules[index]\n    if (!rule) return\n    return selector ? selector(rule) : rule\n  })\n}\n\nexport function useActionRuleCondition({\n  ruleIndex,\n  groupIndex,\n  conditionIndex,\n}: ActionConditionIndex) {\n  return useActionStore(\n    useCallback(\n      (state) => state.rules[ruleIndex]?.condition[groupIndex]?.[conditionIndex],\n      [ruleIndex, groupIndex, conditionIndex],\n    ),\n  )\n}\n\nexport const useIsActionDataDirty = () => {\n  return useActionStore((state) => state.isDirty)\n}\n\nexport const useHasNotificationActions = () => {\n  return useActionStore((state) => {\n    return state.rules.some((rule) => !!rule.result.newEntryNotification && !rule.result.disabled)\n  })\n}\n\nexport const useActionImportExport = () => {\n  return {\n    exportRules: () => actionActions.exportRules(),\n    importRules: (jsonData: string) => actionActions.importRules(jsonData),\n  }\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/action/store.ts",
    "content": "import type {\n  ActionConditionIndex,\n  ActionFilterItem,\n  ActionId,\n  ActionItem as ActionItemRes,\n} from \"@follow-app/client-sdk\"\nimport { merge } from \"es-toolkit/compat\"\n\nimport { api } from \"../../context\"\nimport { createImmerSetter, createZustandStore } from \"../../lib/helper\"\n\nexport type ActionItem = Omit<ActionItemRes, \"condition\"> & {\n  condition: ActionFilterItem[][]\n  index: number\n}\n\ntype ActionStore = {\n  rules: ActionItem[]\n  isDirty: boolean\n}\n\ntype ActionRules = ActionItem[]\nexport type ActionModel = ActionItem\n\nexport const useActionStore = createZustandStore<ActionStore>(\"action\")(() => ({\n  rules: [],\n  isDirty: false,\n}))\n\nconst immerSet = createImmerSetter(useActionStore)\n\nclass ActionSyncService {\n  async fetchRules() {\n    const res = await api().actions.get()\n    if (res.data) {\n      actionActions.updateRules(\n        (res.data.rules ?? []).map((rule, index) => {\n          const { condition } = rule\n          // fix old data\n          const finalCondition =\n            condition.length === 0 || Array.isArray(condition[0]) ? condition : [condition]\n\n          return {\n            ...rule,\n            condition: finalCondition as ActionFilterItem[][],\n            index,\n          }\n        }),\n      )\n\n      actionActions.setDirty(false)\n    }\n    return res\n  }\n\n  async saveRules() {\n    const { rules, isDirty } = useActionStore.getState()\n    if (!isDirty) {\n      return null\n    }\n\n    const res = await api().actions.put({ rules: rules as any })\n    actionActions.setDirty(false)\n    return res\n  }\n}\n\nclass ActionActions {\n  updateRules(rules: ActionRules) {\n    immerSet((state) => {\n      state.rules = rules\n      state.isDirty = true\n    })\n  }\n\n  patchRule(index: number, rule: Partial<ActionModel>) {\n    immerSet((state) => {\n      if (state.rules[index]) {\n        state.rules[index] = merge(state.rules[index], rule)\n        state.isDirty = true\n      }\n    })\n  }\n\n  addRule(getName: (index: number) => string) {\n    immerSet((state) => {\n      state.rules.push({\n        name: getName(state.rules.length + 1),\n        condition: [],\n        index: state.rules.length,\n        result: {},\n      })\n      state.isDirty = true\n    })\n  }\n\n  pathCondition(index: ActionConditionIndex, condition: Partial<ActionFilterItem>) {\n    immerSet((state) => {\n      const rule = state.rules[index.ruleIndex]\n      if (!rule) return\n      const group = rule.condition[index.groupIndex]\n      if (!group) return\n      group[index.conditionIndex] = merge(group[index.conditionIndex], condition)\n      state.isDirty = true\n    })\n  }\n\n  addConditionItem(index: Omit<ActionConditionIndex, \"conditionIndex\">) {\n    immerSet((state) => {\n      const rule = state.rules[index.ruleIndex]\n      if (!rule) return\n      const group = rule.condition[index.groupIndex]\n      if (!group) return\n      group.push({})\n      state.isDirty = true\n    })\n  }\n  deleteConditionItem(index: ActionConditionIndex) {\n    immerSet((state) => {\n      const rule = state.rules[index.ruleIndex]\n      if (!rule) return\n      const group = rule.condition[index.groupIndex]\n      if (!group) return\n      group.splice(index.conditionIndex, 1)\n      if (group.length === 0) {\n        rule.condition.splice(index.groupIndex, 1)\n      }\n      state.isDirty = true\n    })\n  }\n\n  addConditionGroup(index: Omit<ActionConditionIndex, \"conditionIndex\" | \"groupIndex\">) {\n    immerSet((state) => {\n      const rule = state.rules[index.ruleIndex]\n      if (!rule) return\n      rule.condition.push([{}])\n      state.isDirty = true\n    })\n  }\n\n  toggleRuleFilter(index: number) {\n    immerSet((state) => {\n      if (state.rules[index]) {\n        const hasCustomFilters = state.rules[index].condition.length > 0\n        state.rules[index].condition = hasCustomFilters ? [] : [[{}]]\n        state.isDirty = true\n      }\n    })\n  }\n\n  deleteRuleAction(index: number, actionId: ActionId) {\n    immerSet((state) => {\n      if (state.rules[index]) {\n        delete state.rules[index].result[actionId]\n        state.isDirty = true\n      }\n    })\n  }\n\n  deleteRule(index: number) {\n    immerSet((state) => {\n      state.rules.splice(index, 1)\n      state.isDirty = true\n    })\n  }\n\n  setDirty(isDirty: boolean) {\n    immerSet((state) => {\n      state.isDirty = isDirty\n    })\n  }\n\n  addWebhook(index: number) {\n    immerSet((state) => {\n      const rule = state.rules[index]\n      if (!rule) return\n      const { webhooks } = rule.result\n      if (!webhooks) {\n        rule.result.webhooks = [\"\"]\n      } else {\n        webhooks.push(\"\")\n      }\n      state.isDirty = true\n    })\n  }\n\n  deleteWebhook(index: number, webhookIndex: number) {\n    immerSet((state) => {\n      const rule = state.rules[index]\n      if (!rule) return\n      const { webhooks } = rule.result\n      if (!webhooks) return\n      if (webhooks.length === 1) {\n        delete rule.result.webhooks\n      } else {\n        webhooks.splice(webhookIndex, 1)\n      }\n      state.isDirty = true\n    })\n  }\n\n  updateWebhook({\n    index,\n    webhookIndex,\n    value,\n  }: {\n    index: number\n    webhookIndex: number\n    value: string\n  }) {\n    immerSet((state) => {\n      const rule = state.rules[index]\n      if (!rule) return\n      const { webhooks } = rule.result\n      if (!webhooks) return\n      webhooks[webhookIndex] = value\n      state.isDirty = true\n    })\n  }\n\n  addRewriteRule(index: number) {\n    immerSet((state) => {\n      const rule = state.rules[index]\n      if (!rule) return\n      const { rewriteRules } = rule.result\n      if (!rewriteRules) {\n        rule.result.rewriteRules = [\n          {\n            from: \"\",\n            to: \"\",\n          },\n        ]\n      } else {\n        rewriteRules.push({ from: \"\", to: \"\" })\n      }\n      state.isDirty = true\n    })\n  }\n\n  deleteRewriteRule(index: number, rewriteIdx: number) {\n    immerSet((state) => {\n      const rule = state.rules[index]\n      if (!rule) return\n      const { rewriteRules } = rule.result\n      if (!rewriteRules) return\n      if (rewriteRules.length === 1) {\n        delete rule.result.rewriteRules\n      } else {\n        rewriteRules.splice(rewriteIdx, 1)\n      }\n      state.isDirty = true\n    })\n  }\n\n  updateRewriteRule({\n    index,\n    rewriteRuleIndex,\n    key,\n    value,\n  }: {\n    index: number\n    rewriteRuleIndex: number\n    key: \"from\" | \"to\"\n    value: string\n  }) {\n    immerSet((state) => {\n      const rule = state.rules[index]\n      if (!rule) return\n      const { rewriteRules } = rule.result\n      if (!rewriteRules) return\n      const rewriteRule = rewriteRules[rewriteRuleIndex]\n      if (!rewriteRule) return\n      rewriteRule[key] = value\n      state.isDirty = true\n    })\n  }\n\n  exportRules(): string {\n    const { rules } = useActionStore.getState()\n    const exportData = {\n      version: \"1.0\",\n      exportDate: new Date().toISOString(),\n      rules: rules.map((rule) => ({\n        name: rule.name,\n        condition: rule.condition,\n        result: rule.result,\n      })),\n    }\n    return JSON.stringify(exportData)\n  }\n\n  importRules(jsonData: string): { success: boolean; message: string; importedCount?: number } {\n    try {\n      const parsedData = JSON.parse(jsonData)\n\n      // Validate the structure\n      if (!parsedData.rules || !Array.isArray(parsedData.rules)) {\n        return { success: false, message: \"Invalid JSON structure: missing or invalid rules array\" }\n      }\n\n      // Validate each rule structure\n      for (const rule of parsedData.rules) {\n        if (!rule.name || typeof rule.name !== \"string\") {\n          return { success: false, message: \"Invalid rule: missing or invalid name field\" }\n        }\n        if (!rule.condition || !Array.isArray(rule.condition)) {\n          return { success: false, message: \"Invalid rule: missing or invalid condition field\" }\n        }\n        if (!rule.result || typeof rule.result !== \"object\") {\n          return { success: false, message: \"Invalid rule: missing or invalid result field\" }\n        }\n      }\n\n      // Import the rules\n      const importedRules: ActionRules = parsedData.rules.map((rule: any, index: number) => ({\n        name: rule.name,\n        condition: rule.condition,\n        result: rule.result,\n        index,\n      }))\n\n      immerSet((state) => {\n        state.rules = importedRules\n        state.isDirty = true\n      })\n\n      return {\n        success: true,\n        message: `Successfully imported ${importedRules.length} action rule(s)`,\n        importedCount: importedRules.length,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to parse JSON: ${error instanceof Error ? error.message : \"Unknown error\"}`,\n      }\n    }\n  }\n}\n\nexport const actionSyncService = new ActionSyncService()\nexport const actionActions = new ActionActions()\n"
  },
  {
    "path": "packages/internal/store/src/modules/collection/getter.ts",
    "content": "import { useCollectionStore } from \"./store\"\n\nexport const isEntryStarred = (entryId: string): boolean => {\n  return !!useCollectionStore.getState().collections[entryId]\n}\n\nexport const getEntryCollections = (entryId: string) => {\n  return useCollectionStore.getState().collections[entryId]\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/collection/hooks.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useCallback } from \"react\"\n\nimport { useCollectionStore } from \"./store\"\n\nexport const useCollectionEntry = (entryId: string) => {\n  return useCollectionStore(\n    useCallback(\n      (state) => {\n        return state.collections[entryId]\n      },\n      [entryId],\n    ),\n  )\n}\n\nexport const useIsEntryStarred = (entryId: string) => {\n  return useCollectionStore(\n    useCallback(\n      (state) => {\n        return !!state.collections[entryId]\n      },\n      [entryId],\n    ),\n  )\n}\n\nexport const useCollectionEntryList = (view: FeedViewType) => {\n  return useCollectionStore(\n    useCallback(\n      (state) => {\n        return Object.values(state.collections)\n          .filter((collection) => collection.view === view)\n          .sort((a, b) => (new Date(a.createdAt ?? 0) > new Date(b.createdAt ?? 0) ? -1 : 1))\n          .map((i) => i.entryId)\n      },\n      [view],\n    ),\n  )\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/collection/store.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { CollectionSchema } from \"@follow/database/schemas/types\"\nimport { CollectionService } from \"@follow/database/services/collection\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { getEntry } from \"../entry/getter\"\nimport { invalidateEntriesQuery } from \"../entry/hooks\"\n\ninterface CollectionState {\n  collections: Record<string, CollectionSchema>\n}\n\nconst defaultState = {\n  collections: {},\n}\n\nexport const useCollectionStore = createZustandStore<CollectionState>(\"collection\")(\n  () => defaultState,\n)\n\nconst get = useCollectionStore.getState\nconst set = useCollectionStore.setState\n\nclass CollectionSyncService {\n  async starEntry({\n    entryId,\n    view,\n    invalidate,\n  }: {\n    entryId: string\n    view: FeedViewType\n    invalidate?: boolean\n  }) {\n    const entry = getEntry(entryId)\n    if (!entry) {\n      return\n    }\n    const tx = createTransaction()\n    tx.store(async () => {\n      await collectionActions.upsertMany([\n        {\n          createdAt: new Date().toISOString(),\n          entryId,\n          feedId: entry.feedId,\n          view,\n        },\n      ])\n    })\n    tx.request(async () => {\n      await api().collections.post({\n        entryId,\n        view,\n      })\n    })\n    tx.rollback(() => {\n      collectionActions.delete(entryId)\n    })\n\n    await tx.run()\n\n    if (invalidate) {\n      invalidateEntriesQuery({ collection: true })\n    }\n  }\n\n  async unstarEntry({ entryId, invalidate = true }: { entryId: string; invalidate?: boolean }) {\n    const tx = createTransaction()\n\n    const snapshot = useCollectionStore.getState().collections[entryId]\n    tx.store(() => {\n      collectionActions.delete(entryId)\n    })\n    tx.request(async () => {\n      await api().collections.delete({\n        entryId,\n      })\n    })\n\n    tx.rollback(() => {\n      if (!snapshot) return\n      collectionActions.upsertMany([snapshot])\n    })\n\n    await tx.run()\n\n    if (invalidate) invalidateEntriesQuery({ collection: true })\n  }\n}\n\nclass CollectionActions implements Hydratable, Resetable {\n  async hydrate() {\n    const collections = await CollectionService.getCollectionAll()\n    collectionActions.upsertManyInSession(collections)\n  }\n\n  upsertManyInSession(collections: CollectionSchema[], options?: { reset?: boolean }) {\n    const state = get()\n    const nextCollections: CollectionState[\"collections\"] = options?.reset\n      ? {}\n      : {\n          ...state.collections,\n        }\n    collections.forEach((collection) => {\n      if (!collection.entryId) return\n      nextCollections[collection.entryId] = collection\n    })\n    set({\n      ...state,\n      collections: nextCollections,\n    })\n  }\n\n  async upsertMany(collections: CollectionSchema[], options?: { reset?: boolean }) {\n    const tx = createTransaction()\n    tx.store(() => {\n      this.upsertManyInSession(collections, options)\n    })\n    tx.persist(() => {\n      return CollectionService.upsertMany(collections, options)\n    })\n    await tx.run()\n  }\n\n  deleteInSession(entryId: string | string[]) {\n    const normalizedEntryId = Array.isArray(entryId) ? entryId : [entryId]\n\n    const state = useCollectionStore.getState()\n    const nextCollections: CollectionState[\"collections\"] = {\n      ...state.collections,\n    }\n\n    normalizedEntryId.forEach((id) => {\n      delete nextCollections[id]\n    })\n    set({\n      ...state,\n      collections: nextCollections,\n    })\n  }\n\n  async delete(entryId: string | string[]) {\n    const entryIdsInCollection = new Set(Object.keys(get().collections))\n    const normalizedEntryId = (Array.isArray(entryId) ? entryId : [entryId]).filter((id) =>\n      entryIdsInCollection.has(id),\n    )\n\n    if (normalizedEntryId.length === 0) return\n\n    const tx = createTransaction()\n    tx.store(() => {\n      this.deleteInSession(entryId)\n    })\n    tx.persist(() => {\n      return CollectionService.deleteMany(normalizedEntryId)\n    })\n    tx.run()\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(defaultState)\n    })\n\n    tx.persist(() => {\n      return CollectionService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nexport const collectionActions = new CollectionActions()\nexport const collectionSyncService = new CollectionSyncService()\n"
  },
  {
    "path": "packages/internal/store/src/modules/collection/types.ts",
    "content": "import type { CollectionSchema } from \"@follow/database/schemas/types\"\n\nexport type CollectionModel = CollectionSchema\n"
  },
  {
    "path": "packages/internal/store/src/modules/entry/getter.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\n\nimport { createSingleArgGetter, createStaticGetter } from \"../../lib/helper\"\nimport { getSubscriptionByEntryId } from \"../subscription/getter\"\nimport { useEntryStore } from \"./store\"\n\nexport const getEntry = (id: string) => {\n  return useEntryStore.getState().data[id]\n}\n\nfunction sortEntryIdsByPublishDate(a: string, b: string) {\n  const entryA = getEntry(a)\n  const entryB = getEntry(b)\n  if (!entryA || !entryB) return 0\n  return entryB.publishedAt.getTime() - entryA.publishedAt.getTime()\n}\n\n// Utility functions for creating getters\ntype StateType = ReturnType<typeof useEntryStore.getState>\nconst getState = () => useEntryStore.getState()\n\n// Store selector functions (for React hooks)\nexport const getHasEntrySelector = (state: StateType) => (id: string) => {\n  return !!state.data[id]\n}\n\nexport const getEntryIdsByViewSelector =\n  (state: StateType) => (view: FeedViewType, excludePrivate: boolean | undefined) => {\n    const ids = state.entryIdByView[view]\n    if (!ids) return null\n    return Array.from(ids)\n      .filter((id) => {\n        const subscription = getSubscriptionByEntryId(id)\n        if ((excludePrivate && subscription?.isPrivate) || subscription?.hideFromTimeline) {\n          return false\n        }\n        return true\n      })\n      .sort((a, b) => sortEntryIdsByPublishDate(a, b))\n  }\n\nexport const getEntryIdsByFeedIdSelector =\n  (state: StateType) => (feedId: string | undefined | null) => {\n    if (!feedId) return null\n    const ids = state.entryIdByFeed[feedId]\n    if (!ids) return null\n    return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b))\n  }\n\nexport const getEntryIdsByFeedIdsSelector =\n  (state: StateType) => (feedIds: string[] | undefined) => {\n    const ids = feedIds?.flatMap((feedId) => Array.from(state.entryIdByFeed[feedId] || []))\n    if (!ids) return null\n    return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b))\n  }\n\nexport const getEntryIdsByInboxIdSelector = (state: StateType) => (inboxId: string | undefined) => {\n  if (!inboxId) return null\n  const ids = state.entryIdByInbox[inboxId]\n  if (!ids) return null\n  return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b))\n}\n\nexport const getEntryIdsByCategorySelector = (state: StateType) => (category: string) => {\n  const ids = state.entryIdByCategory[category]\n  if (!ids) return null\n  return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b))\n}\n\nexport const getEntryIdsByListIdSelector = (state: StateType) => (listId: string | undefined) => {\n  if (!listId) return null\n  const ids = state.entryIdByList[listId]\n  if (!ids) return null\n  return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b))\n}\n\nexport const getEntryIsInboxSelector = (state: StateType) => (entryId: string) => {\n  const entry = state.data[entryId]\n  if (!entry) return false\n  return !!entry.inboxHandle\n}\n\n// Static getters for use outside React components\nexport const hasEntry = createSingleArgGetter(getState, getHasEntrySelector)\nexport const getEntryIdsByView = createStaticGetter(getState, getEntryIdsByViewSelector)\nexport const getEntryIdsByFeedId = createSingleArgGetter(getState, getEntryIdsByFeedIdSelector)\nexport const getEntryIdsByFeedIds = createSingleArgGetter(getState, getEntryIdsByFeedIdsSelector)\nexport const getEntryIdsByInboxId = createSingleArgGetter(getState, getEntryIdsByInboxIdSelector)\nexport const getEntryIdsByCategory = createSingleArgGetter(getState, getEntryIdsByCategorySelector)\nexport const getEntryIdsByListId = createSingleArgGetter(getState, getEntryIdsByListIdSelector)\nexport const isEntryInbox = createSingleArgGetter(getState, getEntryIsInboxSelector)\n"
  },
  {
    "path": "packages/internal/store/src/modules/entry/hooks.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useInfiniteQuery, useQuery } from \"@tanstack/react-query\"\nimport { useCallback, useMemo } from \"react\"\n\nimport { FEED_COLLECTION_LIST } from \"../../constants/app\"\nimport { queryClient } from \"../../context\"\nimport { useFeedUnreadIsDirty } from \"../feed/hooks\"\nimport { useSyncUnreadWhenUnMatch } from \"../unread/hooks\"\nimport {\n  getEntryIdsByCategorySelector,\n  getEntryIdsByFeedIdSelector,\n  getEntryIdsByFeedIdsSelector,\n  getEntryIdsByInboxIdSelector,\n  getEntryIdsByListIdSelector,\n  getEntryIdsByViewSelector,\n  getEntryIsInboxSelector,\n  getHasEntrySelector,\n} from \"./getter\"\nimport { entrySyncServices, useEntryStore } from \"./store\"\nimport type { EntryModel, FetchEntriesProps, FetchEntriesPropsSettings } from \"./types\"\n\nexport const invalidateEntriesQuery = ({\n  views,\n  collection,\n}: {\n  views?: FeedViewType[]\n  collection?: true\n}) => {\n  return queryClient().invalidateQueries({\n    predicate: (query) => {\n      const { queryKey } = query\n      if (Array.isArray(queryKey) && queryKey[0] === \"entries\") {\n        const feedId = queryKey[1]\n        const view = queryKey[4]\n\n        const isCollection = queryKey[7]\n        if (views) {\n          return views.includes(view as FeedViewType)\n        }\n\n        if (collection) {\n          return isCollection === true || feedId === FEED_COLLECTION_LIST\n        }\n      }\n      return false\n    },\n  })\n}\n\nconst defaultStaleTime = 10 * (60 * 1000) // 10 minutes\n\nexport const useEntriesQuery = (\n  props?: Omit<FetchEntriesProps, \"pageParam\" | \"read\" | \"excludePrivate\"> &\n    FetchEntriesPropsSettings,\n) => {\n  const {\n    feedId,\n    inboxId,\n    listId,\n    view,\n    limit,\n    feedIdList,\n    isCollection,\n    unreadOnly,\n    hidePrivateSubscriptionsInTimeline,\n    aiSort,\n  } = props || {}\n\n  const fetchUnread = unreadOnly\n  const feedUnreadDirty = useFeedUnreadIsDirty((feedId as string) || \"\")\n\n  const isPop =\n    \"history\" in globalThis && \"isPop\" in globalThis.history && !!globalThis.history.isPop\n  const queryKey = useMemo(\n    () => [\n      \"entries\",\n      feedId,\n      inboxId,\n      listId,\n      view,\n      limit,\n      feedIdList,\n      isCollection,\n      unreadOnly,\n      hidePrivateSubscriptionsInTimeline,\n      aiSort,\n    ],\n    [\n      feedId,\n      inboxId,\n      listId,\n      view,\n      limit,\n      feedIdList,\n      isCollection,\n      unreadOnly,\n      hidePrivateSubscriptionsInTimeline,\n      aiSort,\n    ],\n  )\n\n  const query = useInfiniteQuery({\n    queryKey,\n    queryFn: ({ pageParam }) =>\n      entrySyncServices.fetchEntries({\n        ...props,\n        limit: aiSort ? 100 : limit,\n        pageParam,\n        read: unreadOnly ? false : undefined,\n        excludePrivate: hidePrivateSubscriptionsInTimeline,\n      }),\n\n    getNextPageParam: (lastPage) => (aiSort ? null : lastPage.data?.at(-1)?.entries.publishedAt),\n    initialPageParam: undefined as undefined | string,\n    refetchOnWindowFocus: false,\n    refetchOnReconnect: false,\n    // DON'T refetch when the router is pop to previous page\n    refetchOnMount: fetchUnread && feedUnreadDirty && !isPop ? \"always\" : false,\n\n    staleTime:\n      // Force refetch unread entries when feed is dirty\n      // HACK: disable refetch when the router is pop to previous page\n      isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime,\n    enabled: !!props,\n  })\n\n  const entriesIds = useMemo(() => {\n    if (!query.data || query.isLoading || query.isError) {\n      return []\n    }\n    return (\n      query.data?.pages\n        ?.flatMap((page) => page.data?.map((entry) => entry.entries.id))\n        .filter((id) => typeof id === \"string\") || []\n    )\n  }, [query.data, query.isLoading, query.isError])\n\n  useSyncUnreadWhenUnMatch(entriesIds)\n\n  return useMemo(() => {\n    return {\n      ...query,\n      entriesIds,\n      queryKey,\n    }\n  }, [entriesIds, query, queryKey])\n}\n\nexport const usePrefetchEntryDetail = (entryId: string | undefined, isInbox?: boolean) => {\n  return useQuery({\n    queryKey: [\"entry\", entryId],\n    queryFn: () => entrySyncServices.fetchEntryDetail(entryId, isInbox),\n  })\n}\n\nconst defaultSelector = (state: EntryModel) => state\n\nexport function useEntry<T>(\n  id: string | undefined,\n  selector: (state: EntryModel) => T,\n): T | undefined {\n  return useEntryStore((state) => {\n    if (!id) return\n    const entry = state.data[id]\n    if (!entry) return\n    return selector(entry)\n  })\n}\nexport const useHasEntry = (id: string) => {\n  return useEntryStore(useCallback((state) => getHasEntrySelector(state)(id), [id]))\n}\nexport function useEntryList(ids: string[]): Array<EntryModel | null>\nexport function useEntryList<T>(ids: string[], selector: (state: EntryModel) => T): T[] | undefined\nexport function useEntryList(\n  ids: string[],\n  selector: (state: EntryModel) => EntryModel = defaultSelector,\n) {\n  return useEntryStore((state) => {\n    return ids.map((id) => {\n      const entry = state.data[id]\n      if (!entry) return null\n      return selector(entry)\n    })\n  })\n}\n\nexport const useEntryIdsByView = (view: FeedViewType, excludePrivate: boolean | undefined) => {\n  return useEntryStore(\n    useCallback(\n      (state) => getEntryIdsByViewSelector(state)(view, excludePrivate),\n      [excludePrivate, view],\n    ),\n  )\n}\n\nexport const useEntryIdsByFeedId = (feedId: string | undefined | null) => {\n  return useEntryStore(useCallback((state) => getEntryIdsByFeedIdSelector(state)(feedId), [feedId]))\n}\n\nexport const useEntryIdsByFeedIds = (feedIds: string[] | undefined) => {\n  return useEntryStore(\n    useCallback((state) => getEntryIdsByFeedIdsSelector(state)(feedIds), [feedIds?.toString()]),\n  )\n}\n\nexport const useEntryIdsByInboxId = (inboxId: string | undefined) => {\n  return useEntryStore(\n    useCallback((state) => getEntryIdsByInboxIdSelector(state)(inboxId), [inboxId]),\n  )\n}\n\nexport const useEntryIdsByCategory = (category: string) => {\n  return useEntryStore(\n    useCallback((state) => getEntryIdsByCategorySelector(state)(category), [category]),\n  )\n}\n\nexport const useEntryIdsByListId = (listId: string | undefined) => {\n  return useEntryStore(useCallback((state) => getEntryIdsByListIdSelector(state)(listId), [listId]))\n}\n\nexport const useEntryIsInbox = (entryId: string) => {\n  return useEntryStore(useCallback((state) => getEntryIsInboxSelector(state)(entryId), [entryId]))\n}\n\nexport const useEntryReadHistory = (entryId: string, size = 20, enabled = true) => {\n  const isInboxEntry = useEntryIsInbox(entryId)\n  const { data } = useQuery({\n    queryKey: [\"entry-read-history\", entryId],\n    queryFn: () => {\n      return entrySyncServices.fetchEntryReadHistory(entryId, size)\n    },\n    staleTime: 1000 * 60 * 5,\n    enabled: enabled && !isInboxEntry,\n  })\n\n  return data\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/entry/store.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { EntryService } from \"@follow/database/services/entry\"\nimport { isBizId } from \"@follow/utils\"\nimport { cloneDeep } from \"es-toolkit\"\nimport { debounce } from \"es-toolkit/compat\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { readNdjsonStream } from \"../../lib/stream\"\nimport { apiMorph } from \"../../morph/api\"\nimport { dbStoreMorph } from \"../../morph/db-store\"\nimport { storeDbMorph } from \"../../morph/store-db\"\nimport { collectionActions } from \"../collection/store\"\nimport { clearAllFeedUnreadDirty, clearFeedUnreadDirty } from \"../feed/hooks\"\nimport { feedActions } from \"../feed/store\"\nimport { getSubscriptionById } from \"../subscription/getter\"\nimport { getDefaultCategory } from \"../subscription/utils\"\nimport type {\n  FeedIdOrInboxHandle,\n  InsertedBeforeTimeRangeFilter,\n  PublishAtTimeRangeFilter,\n} from \"../unread/types\"\nimport { userActions } from \"../user/store\"\nimport { getEntry } from \"./getter\"\nimport type { EntryModel, FetchEntriesProps, FetchEntriesPropsSettings } from \"./types\"\nimport { getEntriesParams } from \"./utils\"\n\ntype EntryId = string\ntype FeedId = string\ntype InboxId = string\ntype Category = string\ntype ListId = string\n\ninterface EntryState {\n  data: Record<EntryId, EntryModel>\n  entryIdByView: Record<FeedViewType, Set<EntryId>>\n  entryIdByCategory: Record<Category, Set<EntryId>>\n  entryIdByFeed: Record<FeedId, Set<EntryId>>\n  entryIdByInbox: Record<InboxId, Set<EntryId>>\n  entryIdByList: Record<ListId, Set<EntryId>>\n  entryIdSet: Set<EntryId>\n}\n\nconst defaultState: EntryState = {\n  data: {},\n  entryIdByView: {\n    [FeedViewType.All]: new Set(),\n    [FeedViewType.Articles]: new Set(),\n    [FeedViewType.Audios]: new Set(),\n    [FeedViewType.Notifications]: new Set(),\n    [FeedViewType.Pictures]: new Set(),\n    [FeedViewType.SocialMedia]: new Set(),\n    [FeedViewType.Videos]: new Set(),\n  },\n  entryIdByCategory: {},\n  entryIdByFeed: {},\n  entryIdByInbox: {},\n  entryIdByList: {},\n  entryIdSet: new Set(),\n}\n\nexport const useEntryStore = createZustandStore<EntryState>(\"entry\")(() => defaultState)\n\nconst get = useEntryStore.getState\nconst immerSet = createImmerSetter(useEntryStore)\n\nclass EntryActions implements Hydratable, Resetable {\n  async hydrate() {\n    const entries = await EntryService.getEntriesToHydrate()\n    entryActions.upsertManyInSession(entries.map((e) => dbStoreMorph.toEntryModel(e)))\n  }\n\n  getFlattenMapEntries() {\n    const state = get()\n    return state.data\n  }\n\n  private addEntryIdToView({\n    draft,\n    feedId,\n    entryId,\n    sources,\n    hidePrivateSubscriptionsInTimeline,\n  }: {\n    draft: EntryState\n    feedId?: FeedId | null\n    entryId: EntryId\n    sources?: string[] | null\n    hidePrivateSubscriptionsInTimeline?: boolean\n  }) {\n    if (!feedId) return\n\n    const subscription = getSubscriptionById(feedId)\n    const ignore =\n      (hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) ||\n      subscription?.hideFromTimeline\n\n    if (!ignore) {\n      if (typeof subscription?.view === \"number\") {\n        draft.entryIdByView[subscription.view].add(entryId)\n      }\n      draft.entryIdByView[FeedViewType.All].add(entryId)\n    }\n\n    // lists\n    for (const s of sources ?? []) {\n      const subscription = getSubscriptionById(s)\n      const ignore =\n        (hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) ||\n        subscription?.hideFromTimeline\n\n      if (!ignore) {\n        if (typeof subscription?.view === \"number\") {\n          draft.entryIdByView[subscription.view].add(entryId)\n        }\n        draft.entryIdByView[FeedViewType.All].add(entryId)\n      }\n    }\n  }\n\n  private addEntryIdToCategory({\n    draft,\n    feedId,\n    entryId,\n  }: {\n    draft: EntryState\n    feedId?: FeedId | null\n    entryId: EntryId\n  }) {\n    if (!feedId) return\n    const subscription = getSubscriptionById(feedId)\n    const category = subscription?.category || getDefaultCategory(subscription)\n    if (!category) return\n    const entryIdSetByCategory = draft.entryIdByCategory[category]\n    if (!entryIdSetByCategory) {\n      draft.entryIdByCategory[category] = new Set([entryId])\n    } else {\n      entryIdSetByCategory.add(entryId)\n    }\n  }\n\n  private addEntryIdToFeed({\n    draft,\n    feedId,\n    entryId,\n  }: {\n    draft: EntryState\n    feedId?: FeedId | null\n    entryId: EntryId\n  }) {\n    if (!feedId) return\n    const entryIdSetByFeed = draft.entryIdByFeed[feedId]\n    if (!entryIdSetByFeed) {\n      draft.entryIdByFeed[feedId] = new Set([entryId])\n    } else {\n      entryIdSetByFeed.add(entryId)\n    }\n  }\n\n  private addEntryIdToInbox({\n    draft,\n    inboxHandle,\n    entryId,\n  }: {\n    draft: EntryState\n    inboxHandle?: InboxId | null\n    entryId: EntryId\n  }) {\n    if (!inboxHandle) return\n    const entryIdSetByInbox = draft.entryIdByInbox[inboxHandle]\n    if (!entryIdSetByInbox) {\n      draft.entryIdByInbox[inboxHandle] = new Set([entryId])\n    } else {\n      entryIdSetByInbox.add(entryId)\n    }\n  }\n\n  private addEntryIdToList({\n    draft,\n    listId,\n    entryId,\n  }: {\n    draft: EntryState\n    listId?: ListId | null\n    entryId: EntryId\n  }) {\n    if (!listId) return\n    const entryIdSetByList = draft.entryIdByList[listId]\n    if (!entryIdSetByList) {\n      draft.entryIdByList[listId] = new Set([entryId])\n    } else {\n      entryIdSetByList.add(entryId)\n    }\n  }\n\n  upsertManyInSession(entries: EntryModel[], options?: FetchEntriesPropsSettings) {\n    if (entries.length === 0) return\n    const { unreadOnly, hidePrivateSubscriptionsInTimeline } = options || {}\n\n    immerSet((draft) => {\n      for (const entry of entries) {\n        draft.entryIdSet.add(entry.id)\n        draft.data[entry.id] = entry\n\n        const { feedId, inboxHandle, read, sources } = entry\n        if (unreadOnly && read) continue\n\n        if (inboxHandle) {\n          this.addEntryIdToInbox({\n            draft,\n            inboxHandle,\n            entryId: entry.id,\n          })\n        } else {\n          this.addEntryIdToFeed({\n            draft,\n            feedId,\n            entryId: entry.id,\n          })\n        }\n\n        this.addEntryIdToView({\n          draft,\n          feedId,\n          entryId: entry.id,\n          sources,\n          hidePrivateSubscriptionsInTimeline,\n        })\n\n        this.addEntryIdToCategory({\n          draft,\n          feedId,\n          entryId: entry.id,\n        })\n\n        entry.sources\n          ?.filter((s) => !!s && s !== \"feed\")\n          .forEach((s) => {\n            this.addEntryIdToList({\n              draft,\n              listId: s,\n              entryId: entry.id,\n            })\n          })\n      }\n    })\n  }\n\n  async upsertMany(entries: EntryModel[]) {\n    const tx = createTransaction()\n    tx.store(() => {\n      this.upsertManyInSession(entries)\n    })\n\n    tx.persist(() => {\n      return EntryService.upsertMany(entries.map((e) => storeDbMorph.toEntrySchema(e)))\n    })\n\n    await tx.run()\n  }\n\n  updateEntryContentInSession({\n    entryId,\n    content,\n    readabilityContent,\n    readabilityUpdatedAt,\n  }: {\n    entryId: EntryId\n    content?: string\n    readabilityContent?: string\n    readabilityUpdatedAt?: Date\n  }) {\n    immerSet((draft) => {\n      const entry = draft.data[entryId]\n      if (!entry) return\n      if (content) {\n        entry.content = content\n      }\n      if (readabilityContent) {\n        entry.readabilityContent = readabilityContent\n        entry.readabilityUpdatedAt = readabilityUpdatedAt\n      }\n    })\n  }\n\n  async updateEntryContent({\n    entryId,\n    content,\n    readabilityContent,\n    readabilityUpdatedAt = new Date(),\n  }: {\n    entryId: EntryId\n    content?: string\n    readabilityContent?: string\n    readabilityUpdatedAt?: Date\n  }) {\n    const tx = createTransaction()\n    tx.store(() => {\n      this.updateEntryContentInSession({\n        entryId,\n        content,\n        readabilityContent,\n        readabilityUpdatedAt,\n      })\n    })\n\n    tx.persist(() => {\n      if (content) {\n        EntryService.patch({ id: entryId, content })\n      }\n\n      if (readabilityContent) {\n        EntryService.patch({ id: entryId, readabilityContent, readabilityUpdatedAt })\n      }\n    })\n\n    await tx.run()\n  }\n\n  markEntryReadStatusInSession({\n    entryIds,\n    ids,\n    read,\n    time,\n  }: {\n    entryIds?: EntryId[]\n    ids?: FeedIdOrInboxHandle[]\n    read: boolean\n    time?: PublishAtTimeRangeFilter | InsertedBeforeTimeRangeFilter\n  }) {\n    const affectedEntryIds = new Set<EntryId>()\n\n    immerSet((draft) => {\n      if (entryIds) {\n        for (const entryId of entryIds) {\n          const entry = draft.data[entryId]\n          if (!entry) {\n            continue\n          }\n\n          if (\n            time &&\n            \"startTime\" in time &&\n            (+new Date(entry.publishedAt) < time.startTime ||\n              +new Date(entry.publishedAt) > time.endTime)\n          ) {\n            continue\n          }\n          if (\n            time &&\n            \"insertedBefore\" in time &&\n            +new Date(entry.insertedAt) >= time.insertedBefore\n          ) {\n            continue\n          }\n\n          if (entry.read !== read) {\n            entry.read = read\n            affectedEntryIds.add(entryId)\n          }\n        }\n      }\n\n      if (ids) {\n        const entries = Array.from(draft.entryIdSet)\n          .map((id) => draft.data[id])\n          .filter((entry): entry is EntryModel => {\n            if (!entry) return false\n            const id = entry.inboxHandle || entry.feedId || \"\"\n            if (!id) return false\n            return ids.includes(id)\n          })\n\n        for (const entry of entries) {\n          if (\n            time &&\n            \"startTime\" in time &&\n            (+new Date(entry.publishedAt) < time.startTime ||\n              +new Date(entry.publishedAt) > time.endTime)\n          ) {\n            continue\n          }\n          if (\n            time &&\n            \"insertedBefore\" in time &&\n            +new Date(entry.insertedAt) >= time.insertedBefore\n          ) {\n            continue\n          }\n\n          if (entry.read !== read) {\n            entry.read = read\n            affectedEntryIds.add(entry.id)\n          }\n        }\n      }\n    })\n\n    return Array.from(affectedEntryIds)\n  }\n\n  resetByView({ view, entries }: { view?: FeedViewType; entries: EntryModel[] }) {\n    if (view === undefined) return\n    immerSet((draft) => {\n      draft.entryIdByView[view] = new Set(entries.map((e) => e.id))\n    })\n  }\n\n  resetByCategory({ category, entries }: { category?: Category; entries: EntryModel[] }) {\n    if (!category) return\n    immerSet((draft) => {\n      draft.entryIdByCategory[category] = new Set(entries.map((e) => e.id))\n    })\n  }\n\n  resetByFeed({ feedId, entries }: { feedId?: FeedId; entries: EntryModel[] }) {\n    if (!feedId) return\n    immerSet((draft) => {\n      draft.entryIdByFeed[feedId] = new Set(entries.map((e) => e.id))\n    })\n  }\n\n  resetByInbox({ inboxId, entries }: { inboxId?: InboxId; entries: EntryModel[] }) {\n    if (!inboxId) return\n    immerSet((draft) => {\n      draft.entryIdByInbox[inboxId] = new Set(entries.map((e) => e.id))\n    })\n  }\n\n  resetByList({ listId, entries }: { listId?: ListId; entries: EntryModel[] }) {\n    if (!listId) return\n    immerSet((draft) => {\n      draft.entryIdByList[listId] = new Set(entries.map((e) => e.id))\n    })\n  }\n\n  deleteInboxEntryById(entryId: EntryId) {\n    const entry = get().data[entryId]\n    if (!entry || !entry.inboxHandle) return\n\n    immerSet((draft) => {\n      delete draft.data[entryId]\n      draft.entryIdSet.delete(entryId)\n      draft.entryIdByInbox[entry.inboxHandle!]?.delete(entryId)\n      draft.entryIdByView[FeedViewType.All].delete(entryId)\n    })\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      immerSet(() => defaultState)\n    })\n\n    tx.persist(() => {\n      return EntryService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nclass EntrySyncServices {\n  async fetchEntries(props: FetchEntriesProps) {\n    const {\n      feedId,\n      inboxId,\n      listId,\n      view,\n      read,\n      limit,\n      pageParam,\n      isCollection,\n      feedIdList,\n      excludePrivate,\n      aiSort,\n    } = props\n    const params = getEntriesParams({\n      feedId,\n      inboxId,\n      listId,\n      view,\n      feedIdList,\n    })\n\n    const res = params.inboxId\n      ? await api().entries.inbox.list({\n          publishedAfter: pageParam,\n          read,\n          limit,\n          isCollection,\n          inboxId: params.inboxId,\n          ...(aiSort && { aiSort }),\n          ...params,\n        })\n      : await api().entries.list(\n          {\n            publishedAfter: pageParam,\n            read,\n            limit,\n            isCollection,\n            excludePrivate,\n            ...(aiSort && { aiSort }),\n            ...params,\n          },\n          aiSort\n            ? {\n                timeout: 3 * 60 * 1000,\n              }\n            : undefined,\n        )\n\n    // Mark feed unread dirty, so re-fetch the unread data when view feed unread entires in the next time\n    if (read === false) {\n      if (typeof params.view === \"number\" && !params.feedId) {\n        clearAllFeedUnreadDirty()\n      }\n      if (params.feedId) {\n        clearFeedUnreadDirty(params.feedId as string)\n      }\n      if (params.feedIdList) {\n        params.feedIdList.forEach((feedId) => {\n          clearFeedUnreadDirty(feedId)\n        })\n      }\n    }\n\n    const entries = apiMorph.toEntryList(res.data)\n    const entriesInDB = await EntryService.getEntryMany(entries.map((e) => e.id))\n    for (const entry of entries) {\n      const entryInDB = entriesInDB.find((e) => e.id === entry.id)\n      if (entryInDB) {\n        entry.content = entryInDB.content\n        entry.readabilityContent = entryInDB.readabilityContent\n        entry.readabilityUpdatedAt = entryInDB.readabilityUpdatedAt\n      }\n    }\n\n    await entryActions.upsertMany(entries)\n\n    if (typeof view === \"number\") {\n      const { collections, entryIdsNotInCollections } = apiMorph.toCollections(res.data, view)\n      await collectionActions.upsertMany(collections, {\n        reset: params.isCollection && !pageParam,\n      })\n      await collectionActions.delete(entryIdsNotInCollections)\n    }\n\n    const dataFeeds = res.data?.map((e) => e.feeds).filter((f) => f.type === \"feed\")\n    const feeds = dataFeeds?.map((f) => apiMorph.toFeed(f)) ?? []\n    feedActions.upsertMany(feeds)\n\n    return res\n  }\n\n  async fetchEntryDetail(entryId: EntryId | undefined, isInbox?: boolean) {\n    if (!isBizId(entryId)) return null\n\n    const currentEntry = getEntry(entryId)\n    const res =\n      currentEntry?.inboxHandle || isInbox\n        ? await api().entries.inbox.get({ id: entryId })\n        : await api().entries.get({ id: entryId })\n    const entry = apiMorph.toEntry(res.data)\n    if (!currentEntry && entry) {\n      await entryActions.upsertMany([entry])\n    } else {\n      if (entry?.content && currentEntry?.content !== entry.content) {\n        await entryActions.updateEntryContent({ entryId, content: entry.content })\n      }\n      if (\n        entry?.readabilityContent &&\n        currentEntry?.readabilityContent !== entry.readabilityContent\n      ) {\n        await entryActions.updateEntryContent({\n          entryId,\n          readabilityContent: entry.readabilityContent,\n        })\n      }\n    }\n    return entry\n  }\n\n  async fetchEntryReadabilityContent(\n    entryId: EntryId,\n    fallBack?: () => Promise<string | null | undefined>,\n  ) {\n    const entry = getEntry(entryId)\n    if (!entry?.url) return entry\n    if (\n      entry.readabilityContent &&\n      entry.readabilityUpdatedAt &&\n      entry.readabilityUpdatedAt.getTime() > Date.now() - 1000 * 60 * 60 * 24 * 3\n    ) {\n      return entry\n    }\n\n    let readabilityContent: string | null | undefined\n\n    try {\n      const { data: contentByFetch } = await api().entries.readability({\n        id: entryId,\n      })\n      readabilityContent = contentByFetch?.content || null\n    } catch (error) {\n      if (fallBack) {\n        readabilityContent = await fallBack()\n      } else {\n        throw error\n      }\n    }\n    if (readabilityContent) {\n      await entryActions.updateEntryContent({\n        entryId,\n        readabilityContent,\n      })\n    }\n    return entry\n  }\n\n  async fetchEntryContentByStream(remoteEntryIds?: string[]) {\n    if (!remoteEntryIds || remoteEntryIds.length === 0) return\n\n    const onlyNoStored = true\n\n    const nextIds = [] as string[]\n    if (onlyNoStored) {\n      for (const id of remoteEntryIds) {\n        const entry = getEntry(id)!\n        if (entry.content) {\n          continue\n        }\n\n        nextIds.push(id)\n      }\n    }\n\n    if (nextIds.length === 0) return\n\n    const readStream = async () => {\n      const response = await api().entries.stream({\n        ids: nextIds.slice(0, 30),\n      })\n\n      if (!response.ok) {\n        console.error(\"Failed to fetch stream:\", response.statusText, await response.text())\n        return\n      }\n\n      await readNdjsonStream<{ id: string; content: string }>(response, async (json) => {\n        await entryActions.updateEntryContent({ entryId: json.id, content: json.content })\n      })\n    }\n\n    readStream()\n  }\n\n  async fetchEntryReadHistory(entryId: EntryId, size: number) {\n    const res = await api().entries.readHistories({\n      id: entryId,\n      size,\n    })\n\n    await userActions.upsertMany(Object.values(res.data.users))\n\n    return res.data\n  }\n\n  async deleteInboxEntry(entryId: string) {\n    const entry = get().data[entryId]\n    if (!entry || !entry.inboxHandle) return\n    const tx = createTransaction()\n    const currentEntry = cloneDeep(entry)\n\n    tx.store(() => {\n      entryActions.deleteInboxEntryById(entryId)\n    })\n    tx.request(async () => {\n      await api().entries.inbox.delete({ entryId })\n    })\n    tx.rollback(() => {\n      entryActions.upsertManyInSession([currentEntry])\n    })\n    tx.persist(() => {\n      return EntryService.deleteMany([entryId])\n    })\n    await tx.run()\n  }\n}\n\nexport const entrySyncServices = new EntrySyncServices()\nexport const entryActions = new EntryActions()\nexport const debouncedFetchEntryContentByStream = debounce(\n  entrySyncServices.fetchEntryContentByStream,\n  1000,\n)\n"
  },
  {
    "path": "packages/internal/store/src/modules/entry/types.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { EntrySchema } from \"@follow/database/schemas/types\"\n\nexport type EntryModel = EntrySchema\nexport type FetchEntriesProps = {\n  feedId?: string\n  feedIdList?: string[]\n  inboxId?: string\n  listId?: string\n  view?: number\n  read?: boolean\n  limit?: number\n  pageParam?: string\n  isCollection?: boolean\n  excludePrivate?: boolean\n  aiSort?: boolean\n}\n\nexport type FetchEntriesPropsSettings = {\n  hidePrivateSubscriptionsInTimeline?: boolean\n  unreadOnly?: boolean\n}\n\nexport type UseEntriesProps = {\n  viewId?: FeedViewType\n  active?: boolean\n}\n\nexport type UseEntriesReturn = {\n  entriesIds: string[]\n  hasNext: boolean\n  refetch: () => Promise<void>\n  fetchNextPage: () => Promise<void> | void\n  isLoading: boolean\n  isRefetching: boolean\n  isReady: boolean\n  isFetching: boolean\n  isFetchingNextPage: boolean\n  hasNextPage: boolean\n  error: Error | null\n  fetchedTime?: number\n  queryKey?: (string | number | boolean | string[] | undefined)[]\n}\n\nexport type UseEntriesControl = Pick<\n  UseEntriesReturn,\n  \"fetchNextPage\" | \"isFetching\" | \"refetch\" | \"isRefetching\" | \"hasNextPage\"\n>\n"
  },
  {
    "path": "packages/internal/store/src/modules/entry/utils.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\n\nimport { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from \"../../constants/app\"\nimport type { UseEntriesReturn } from \"./types\"\n\nexport function getEntriesParams({\n  feedId,\n  inboxId,\n  listId,\n  view,\n  feedIdList,\n}: {\n  feedId?: number | string\n  inboxId?: number | string\n  listId?: number | string\n  view?: number\n  feedIdList?: string[]\n}) {\n  const params: {\n    feedId?: string\n    feedIdList?: string[]\n    isCollection?: boolean\n    withContent?: boolean\n    inboxId?: string\n    listId?: string\n  } = {}\n  if (inboxId) {\n    params.inboxId = `${inboxId}`\n  } else if (listId) {\n    params.listId = `${listId}`\n  } else if (feedIdList) {\n    params.feedIdList = feedIdList\n  } else if (feedId) {\n    if (feedId === FEED_COLLECTION_LIST) {\n      params.isCollection = true\n    } else if (feedId !== ROUTE_FEED_PENDING) {\n      if (feedId.toString().includes(\",\")) {\n        params.feedIdList = `${feedId}`.split(\",\")\n      } else {\n        params.feedId = `${feedId}`\n      }\n    }\n  }\n  if (view === FeedViewType.SocialMedia) {\n    params.withContent = true\n  }\n  return {\n    view,\n    ...params,\n  }\n}\n\nexport function getInboxFrom(entry?: { inboxHandle?: string | null; authorUrl?: string | null }) {\n  if (isInboxEntry(entry)) {\n    return entry?.authorUrl?.replace(\"mailto:\", \"\")\n  }\n}\n\nexport function isInboxEntry(entry?: { inboxHandle?: string | null }) {\n  return !!entry?.inboxHandle\n}\n\nexport const fallbackReturn: UseEntriesReturn = {\n  entriesIds: [],\n  hasNext: false,\n  refetch: async () => {},\n\n  fetchNextPage: async () => {},\n\n  isLoading: true,\n  isReady: false,\n  isFetching: false,\n  isRefetching: false,\n  isFetchingNextPage: false,\n  hasNextPage: false,\n  error: null,\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/feed/getter.ts",
    "content": "import { useFeedStore } from \"./store\"\n\nexport const getFeedById = (id: string | undefined | null) => {\n  if (!id) return\n  return useFeedStore.getState().feeds[id]\n}\n\nexport const getFeedByUrl = (url: string) => {\n  const { feeds } = useFeedStore.getState()\n  return Object.values(feeds).find((feed) => feed.url === url)\n}\n\nexport const getFeedByIdOrUrl = ({ id, url }: { id?: string; url?: string }) => {\n  if (id) {\n    return getFeedById(id)\n  }\n  if (url) {\n    return getFeedByUrl(url)\n  }\n  return\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/feed/hooks.ts",
    "content": "import { jotaiStore } from \"@follow/utils/jotai\"\nimport { isBizId } from \"@follow/utils/utils\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { atom, useAtomValue } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { useCallback, useMemo } from \"react\"\n\nimport { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from \"../../constants/app\"\nimport type { GeneralQueryOptions } from \"../../types\"\nimport { feedSyncServices, useFeedStore } from \"./store\"\nimport type { FeedModel } from \"./types\"\n\nconst defaultSelector = (feed: FeedModel) => feed\nexport function useFeedById(id: string | undefined | null): FeedModel | undefined\nexport function useFeedById<T>(\n  id: string | undefined | null,\n  selector: (feed: FeedModel) => T,\n): T | undefined\nexport function useFeedById<T>(\n  id: string | undefined | null,\n  // @ts-expect-error\n  selector: (feed: FeedModel) => T = defaultSelector,\n): T | undefined {\n  return useFeedStore(\n    useCallback(\n      (state) => {\n        if (!id) return\n        const feed = state.feeds[id]\n        if (!feed) return\n        return selector(feed)\n      },\n      [id],\n    ),\n  )\n}\n\nexport function useFeedsByIds(ids: string[] | undefined | null): FeedModel[]\nexport function useFeedsByIds<T>(\n  ids: string[] | undefined | null,\n  selector: (feed: FeedModel) => T,\n): T[]\nexport function useFeedsByIds<T>(\n  ids: string[] | undefined | null,\n  // @ts-expect-error\n  selector: (feed: FeedModel) => T = defaultSelector,\n): T[] {\n  return useFeedStore(\n    useCallback(\n      (state) => {\n        if (!ids || ids.length === 0) return []\n        const feeds: T[] = []\n        for (const id of ids) {\n          const feed = state.feeds[id]\n          if (feed) {\n            feeds.push(selector(feed))\n          }\n        }\n        return feeds\n      },\n      [ids?.toString()],\n    ),\n  )\n}\n\nexport function useFeedByUrl(url: string | undefined | null): FeedModel | undefined {\n  return useFeedStore(\n    useCallback(\n      (state) => {\n        if (!url) return\n        const feed = Object.values(state.feeds).find((feed) => feed.url === url)\n        if (!feed) return\n        return feed\n      },\n      [url],\n    ),\n  )\n}\n\nexport function useFeedByIdOrUrl(params: { id?: string; url?: string }): FeedModel | undefined {\n  const { id, url } = params\n  const feedById = useFeedById(id)\n  const feedByUrl = useFeedByUrl(url)\n  return feedById || feedByUrl\n}\n\nexport const usePrefetchFeed = (id: string | undefined, options?: GeneralQueryOptions) => {\n  return useQuery({\n    ...options,\n    queryKey: [\"feed\", id],\n    queryFn: () => feedSyncServices.fetchFeedById({ id }),\n  })\n}\n\nexport const usePrefetchFeedByUrl = (url: string, options?: GeneralQueryOptions) => {\n  return useQuery({\n    ...options,\n    queryKey: [\"feed\", url],\n    queryFn: () => feedSyncServices.fetchFeedByUrl({ url }),\n  })\n}\n\nexport const usePrefetchFeedAnalytics = (id: string | string[], options?: GeneralQueryOptions) => {\n  return useQuery({\n    ...options,\n    queryKey: [\"feed\", \"analytics\", id],\n    queryFn: () => feedSyncServices.fetchAnalytics(id),\n  })\n}\n\nconst feedUnreadDirtySetAtom = atom(new Set<string>())\n\n// 1. feedId may be feedId, or `inbox-id` or `feedId, feedId,` or `list-id`, or `all`, or `collections`\nexport const useFeedUnreadIsDirty = (feedId: string) => {\n  return useAtomValue(\n    useMemo(\n      () =>\n        selectAtom(feedUnreadDirtySetAtom, (set) => {\n          const isRealFeedId = isBizId(feedId)\n\n          if (isRealFeedId) return set.has(feedId)\n\n          if (feedId === ROUTE_FEED_PENDING) {\n            return set.size > 0\n          }\n\n          if (feedId === FEED_COLLECTION_LIST) {\n            // Entry in collections has not unread status\n            return false\n          }\n\n          const splitted = feedId.split(\",\")\n          let isDirty = false\n          for (const feedId of splitted) {\n            if (isBizId(feedId)) {\n              isDirty = isDirty || set.has(feedId)\n\n              if (isDirty) break\n            }\n          }\n          return isDirty\n        }),\n      [feedId],\n    ),\n  )\n}\n\nexport const setFeedUnreadDirty = (feedId: string) => {\n  jotaiStore.set(feedUnreadDirtySetAtom, (prev) => {\n    const newSet = new Set(prev)\n    newSet.add(feedId)\n    return newSet\n  })\n}\n\nexport const clearFeedUnreadDirty = (feedId: string) => {\n  jotaiStore.set(feedUnreadDirtySetAtom, (prev) => {\n    const newSet = new Set(prev)\n    newSet.delete(feedId)\n    return newSet\n  })\n}\n\nexport const clearAllFeedUnreadDirty = () => {\n  jotaiStore.set(feedUnreadDirtySetAtom, new Set())\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/feed/selectors.ts",
    "content": "import type { FeedModel } from \"./types\"\n\nexport const feedIconSelector = (feed: FeedModel) => {\n  return {\n    type: feed.type,\n    ownerUserId: feed.ownerUserId,\n    id: feed.id,\n    title: feed.title,\n    url: (feed as any).url || \"\",\n    image: feed.image,\n    siteUrl: feed.siteUrl,\n  }\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/feed/store.ts",
    "content": "import type { FeedSchema } from \"@follow/database/schemas/types\"\nimport { FEED_EXTRA_DATA_KEYS, FeedService } from \"@follow/database/services/feed\"\nimport { getDateISOString, isBizId } from \"@follow/utils\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { whoami } from \"../user/getters\"\nimport type { FeedModel } from \"./types\"\n\ninterface FeedState {\n  feeds: Record<string, FeedModel>\n}\n\nconst initialFeedStore: FeedState = {\n  feeds: {},\n}\n\nexport const useFeedStore = createZustandStore<FeedState>(\"feed\")(() => initialFeedStore)\n\nconst get = useFeedStore.getState\nconst set = useFeedStore.setState\nconst immerSet = createImmerSetter(useFeedStore)\n// const get = useFeedStore.getState\n// const distanceTime = 1000 * 60 * 60 * 9\nclass FeedActions implements Hydratable, Resetable {\n  async hydrate() {\n    const feeds = await FeedService.getFeedAll()\n    feedActions.upsertManyInSession(feeds)\n  }\n\n  upsertManyInSession(feeds: FeedSchema[]) {\n    immerSet((draft) => {\n      for (const feed of feeds) {\n        const data = Object.fromEntries(\n          FEED_EXTRA_DATA_KEYS.filter((key) => (draft.feeds[feed.id] || {})[key]).map((key) => [\n            key,\n            draft.feeds[feed.id]?.[key],\n          ]),\n        )\n\n        draft.feeds[feed.id] = {\n          ...feed,\n          ...data,\n          type: \"feed\",\n        }\n      }\n    })\n  }\n\n  async upsertMany(feeds: FeedSchema[]) {\n    if (feeds.length === 0) return\n\n    const tx = createTransaction()\n    tx.store(() => {\n      this.upsertManyInSession(feeds)\n    })\n\n    tx.persist(async () => {\n      await FeedService.upsertMany(feeds.filter((feed) => !(\"nonce\" in feed)))\n    })\n\n    await tx.run()\n  }\n\n  patchInSession(feedId: string, patch: Partial<FeedSchema>) {\n    immerSet((state) => {\n      const feed = state.feeds[feedId]\n      if (!feed) return\n      Object.assign(feed, patch)\n    })\n  }\n\n  async patch(feedId: string, patch: Partial<FeedSchema>) {\n    const tx = createTransaction()\n    tx.store(() => {\n      this.patchInSession(feedId, patch)\n    })\n    tx.persist(() => {\n      return FeedService.patch(feedId, patch)\n    })\n    await tx.run()\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(initialFeedStore)\n    })\n\n    tx.persist(() => {\n      return FeedService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\ntype FeedQueryParams = {\n  id?: string\n  url?: string\n}\n\nclass FeedSyncServices {\n  async fetchFeedById({ id, url }: FeedQueryParams) {\n    const isFeedId = isBizId(id)\n    if (!url && !isFeedId) {\n      return null\n    }\n\n    const res = await api().feeds.get({\n      id,\n      url,\n    })\n\n    const nonce = Math.random().toString(36).slice(2, 15)\n\n    const finalData = {\n      ...res.data.feed,\n      updatesPerWeek: res.data.analytics?.updatesPerWeek,\n      subscriptionCount: res.data.analytics?.subscriptionCount,\n      latestEntryPublishedAt: res.data.analytics?.latestEntryPublishedAt,\n    } as FeedModel\n    if (!finalData.id) {\n      finalData[\"nonce\"] = nonce\n    }\n    feedActions.upsertMany([finalData])\n\n    const feed = !finalData.id ? { ...finalData, id: nonce } : finalData\n    return {\n      ...res.data,\n      ...feed,\n    }\n  }\n\n  async fetchFeedByUrl({ url }: FeedQueryParams) {\n    const res = await api().feeds.get({\n      url,\n    })\n\n    const nonce = Math.random().toString(36).slice(2, 15)\n\n    const finalData = {\n      ...res.data.feed,\n      updatesPerWeek: res.data.analytics?.updatesPerWeek,\n      subscriptionCount: res.data.analytics?.subscriptionCount,\n      latestEntryPublishedAt: res.data.analytics?.latestEntryPublishedAt,\n    } as FeedModel\n    if (!finalData.id) {\n      finalData[\"nonce\"] = nonce\n      finalData[\"id\"] = nonce\n    }\n    feedActions.upsertMany([finalData])\n\n    return {\n      responseData: res.data,\n      feed: finalData,\n    }\n  }\n\n  async claimFeed(feedId: string) {\n    const curFeed = get().feeds[feedId]\n    if (!curFeed) return\n\n    const tx = createTransaction()\n    tx.store(() => {\n      feedActions.patchInSession(feedId, {\n        ownerUserId: whoami()?.id || null,\n      })\n    })\n\n    tx.request(async () => {\n      await api().feeds.claim.challenge({\n        feedId,\n      })\n    })\n\n    tx.persist(() => {\n      const newFeed = get().feeds[feedId]\n      if (!newFeed) return\n      return FeedService.upsertMany([newFeed])\n    })\n\n    tx.rollback(() => {\n      feedActions.patchInSession(feedId, {\n        ownerUserId: curFeed.ownerUserId,\n      })\n    })\n\n    await tx.run()\n  }\n\n  async fetchAnalytics(feedId: string | string[]) {\n    const feedIds = Array.isArray(feedId) ? feedId : [feedId]\n    const res = await api().feeds.analytics({\n      id: feedIds,\n    })\n\n    const { analytics } = res.data\n\n    for (const id of feedIds) {\n      const feedAnalytics = analytics[id]\n      if (feedAnalytics) {\n        await feedActions.patch(id, {\n          subscriptionCount: feedAnalytics.subscriptionCount,\n          updatesPerWeek: feedAnalytics.updatesPerWeek,\n          latestEntryPublishedAt: getDateISOString(feedAnalytics.latestEntryPublishedAt),\n        })\n      }\n    }\n\n    return analytics\n  }\n}\nexport const feedSyncServices = new FeedSyncServices()\nexport const feedActions = new FeedActions()\n"
  },
  {
    "path": "packages/internal/store/src/modules/feed/types.ts",
    "content": "import type { FeedSchema } from \"@follow/database/schemas/types\"\n\nexport type FeedModel = FeedSchema & {\n  type: \"feed\"\n  nonce?: string\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/image/getters.ts",
    "content": "import { useImagesStore } from \"./store\"\n\nconst get = useImagesStore.getState\n\nexport const getImageInfo = (url: string) => {\n  return get().images[url]\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/image/hooks.ts",
    "content": "import { useCallback } from \"react\"\n\nimport { useImagesStore } from \"./store\"\n\nexport const useImageColors = (url?: string | null) => {\n  return useImagesStore(\n    useCallback(\n      (state) => {\n        if (!url) {\n          return\n        }\n        return state.images[url]?.colors\n      },\n      [url],\n    ),\n  )\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/image/store.ts",
    "content": "import type { ImageSchema } from \"@follow/database/schemas/types\"\nimport { ImagesService } from \"@follow/database/services/image\"\n\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\n\nexport type ImageModel = ImageSchema\ntype ImageStore = {\n  images: Record<string, ImageModel>\n}\n\nconst defaultState: ImageStore = {\n  images: {},\n}\n\nexport const useImagesStore = createZustandStore<ImageStore>(\"images\")(() => defaultState)\n\nconst set = useImagesStore.setState\nconst immerSet = createImmerSetter(useImagesStore)\n\nclass ImageActions implements Hydratable, Resetable {\n  async hydrate() {\n    const images = await ImagesService.getImageAll()\n    imageActions.upsertManyInSession(images)\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(defaultState)\n    })\n    tx.persist(() => ImagesService.reset())\n    await tx.run()\n  }\n\n  upsertManyInSession(images: ImageModel[]) {\n    immerSet((state) => {\n      for (const image of images) {\n        state.images[image.url] = image\n      }\n    })\n  }\n\n  async upsertMany(images: ImageModel[]) {\n    const tx = createTransaction()\n    tx.store(() => this.upsertManyInSession(images))\n    tx.persist(() => ImagesService.upsertMany(images))\n    await tx.run()\n  }\n}\n\nexport const imageActions = new ImageActions()\n"
  },
  {
    "path": "packages/internal/store/src/modules/inbox/getters.ts",
    "content": "import { useInboxStore } from \"./store\"\n\nexport function getInboxList() {\n  return Object.values(useInboxStore.getState().inboxes)\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/inbox/hooks.ts",
    "content": "import { useInboxStore } from \"./store\"\nimport type { InboxModel } from \"./types\"\n\nexport const useIsInbox = (inboxId: string | null | undefined) => {\n  return useInboxStore((state) => {\n    if (!inboxId) return false\n    return !!state.inboxes[inboxId]\n  })\n}\n\nexport const useInboxById = (inboxId: string | null | undefined) => {\n  return useInboxStore((state) => {\n    if (!inboxId) return\n    return state.inboxes[inboxId]\n  })\n}\n\nexport function useInboxList(): InboxModel[]\nexport function useInboxList<T>(selector: (inboxes: InboxModel[]) => T): T\nexport function useInboxList<T>(selector?: (inboxes: InboxModel[]) => T) {\n  return useInboxStore((state) => {\n    const inboxes = Object.values(state.inboxes)\n    return selector ? selector(inboxes) : inboxes\n  })\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/inbox/store.ts",
    "content": "import type { InboxSchema } from \"@follow/database/schemas/types\"\nimport { InboxService } from \"@follow/database/services/inbox\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport type { InboxModel } from \"./types\"\n\ninterface InboxState {\n  inboxes: Record<string, InboxModel>\n}\n\nconst defaultState = {\n  inboxes: {},\n}\n\nexport const useInboxStore = createZustandStore<InboxState>(\"inbox\")(() => defaultState)\n\nconst get = useInboxStore.getState\nconst set = useInboxStore.setState\nconst immerSet = createImmerSetter(useInboxStore)\n\nclass InboxActions implements Hydratable, Resetable {\n  async hydrate() {\n    const inboxes = await InboxService.getInboxAll()\n    inboxActions.upsertManyInSession(inboxes)\n  }\n  async upsertManyInSession(inboxes: InboxSchema[]) {\n    const state = useInboxStore.getState()\n    const nextInboxes: InboxState[\"inboxes\"] = {\n      ...state.inboxes,\n    }\n    inboxes.forEach((inbox) => {\n      nextInboxes[inbox.id] = {\n        type: \"inbox\",\n        ...inbox,\n      }\n    })\n    set({\n      ...state,\n      inboxes: nextInboxes,\n    })\n  }\n  async upsertMany(inboxes: InboxSchema[]) {\n    const tx = createTransaction()\n    tx.store(() => {\n      this.upsertManyInSession(inboxes)\n    })\n    tx.persist(() => {\n      return InboxService.upsertMany(inboxes)\n    })\n    tx.run()\n  }\n\n  deleteById(id: string) {\n    immerSet((state) => {\n      delete state.inboxes[id]\n    })\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(defaultState)\n    })\n\n    tx.persist(() => {\n      return InboxService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nclass InboxSyncService {\n  async createInbox({ handle, title }: { handle: string; title: string }) {\n    const newInbox = {\n      id: handle,\n      title,\n      secret: \"\",\n    }\n    const tx = createTransaction()\n    tx.store(async () => {\n      await inboxActions.upsertManyInSession([newInbox])\n    })\n    tx.request(async () => {\n      await api().inboxes.post({\n        handle,\n        title,\n      })\n    })\n\n    tx.persist(() => InboxService.upsertMany([newInbox]))\n    tx.rollback(() => inboxActions.deleteById(handle))\n    await tx.run()\n  }\n\n  async updateInbox({ handle, title }: { handle: string; title: string }) {\n    const existingInbox = get().inboxes[handle]\n    if (!existingInbox) return\n\n    const newInbox = {\n      ...existingInbox,\n      title,\n    }\n    const tx = createTransaction()\n    tx.store(async () => {\n      await inboxActions.upsertManyInSession([newInbox])\n    })\n    tx.request(async () => {\n      await api().inboxes.put({\n        handle,\n        title,\n      })\n    })\n\n    tx.persist(() => InboxService.upsertMany([newInbox]))\n    tx.rollback(() => inboxActions.upsertMany([existingInbox]))\n    await tx.run()\n  }\n\n  async deleteInbox(inboxId: string) {\n    const inbox = get().inboxes[inboxId]\n    if (!inbox) return\n\n    const tx = createTransaction(inbox)\n    tx.store(async () => inboxActions.deleteById(inboxId))\n    tx.request(async () => {\n      await api().inboxes.delete({\n        handle: inboxId,\n      })\n    })\n\n    tx.persist(() => InboxService.deleteById(inboxId))\n    tx.rollback(async (inbox) => inboxActions.upsertMany([inbox]))\n    await tx.run()\n  }\n}\n\nexport const inboxActions = new InboxActions()\nexport const inboxSyncService = new InboxSyncService()\n"
  },
  {
    "path": "packages/internal/store/src/modules/inbox/types.ts",
    "content": "import type { InboxSchema } from \"@follow/database/schemas/types\"\n\nexport type InboxModel = InboxSchema & {\n  type: \"inbox\"\n  // for easier type checking, do not exist actually\n  ownerUserId?: string\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/list/getters.ts",
    "content": "import { useListStore } from \"./store\"\n\nexport const getListById = (id: string) => {\n  const get = () => useListStore.getState()\n  return get().lists[id]\n}\n\nexport const getListFeedIds = (id: string) => {\n  const get = () => useListStore.getState()\n  return get().lists[id]?.feedIds\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/list/hooks.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useCallback, useMemo } from \"react\"\n\nimport type { GeneralQueryOptions } from \"../../types\"\nimport { whoami } from \"../user/getters\"\nimport { useWhoami } from \"../user/hooks\"\nimport { listSyncServices, useListStore } from \"./store\"\nimport type { ListModel } from \"./types\"\n\nexport function useListById(id: string | undefined): ListModel | undefined\nexport function useListById<T>(\n  id: string | undefined,\n  selector: (list: ListModel) => T,\n): T | undefined\nexport function useListById<T = ListModel>(\n  id: string | undefined,\n  selector?: (list: ListModel) => T,\n) {\n  return useListStore((state) => {\n    if (!id) return\n    const list = state.lists[id]\n    if (!list) return\n    return selector ? selector(list) : list\n  })\n}\n\nexport const useListByView = (view: FeedViewType) => {\n  return useListStore(\n    useCallback((state) => Object.values(state.lists).filter((list) => list.view === view), [view]),\n  )\n}\n\nexport const useOwnedListByView = (view: FeedViewType) => {\n  const whoami = useWhoami()\n  const viewLists = useListByView(view)\n  return useMemo(\n    () => viewLists.filter((list) => list.ownerUserId === whoami?.id),\n    [viewLists, whoami],\n  )\n}\n\nexport const useListFeedIds = (id: string) => {\n  return useListStore((state) => {\n    return state.lists[id]?.feedIds\n  })\n}\nexport const useListsFeedIds = (ids: string[]) => {\n  return useListStore((state) => {\n    return ids.flatMap((id) => state.lists[id]?.feedIds || [])\n  })\n}\n\nexport const useIsOwnList = (id: string) => {\n  return useListStore((state) => {\n    return state.lists[id]?.userId === whoami()?.id\n  })\n}\n\nexport const useOwnedLists = () => {\n  return useListStore(\n    useCallback((state) => {\n      return Object.values(state.lists).filter((list) => list.userId === whoami()?.id)\n    }, []),\n  )\n}\n\nexport const usePrefetchLists = () => {\n  return useQuery({\n    queryKey: [\"owned\", \"lists\"],\n    queryFn: () => listSyncServices.fetchOwnedLists(),\n  })\n}\n\nexport const usePrefetchListById = (id: string | undefined, options?: GeneralQueryOptions) => {\n  return useQuery({\n    ...options,\n    queryKey: [\"list\", id],\n    queryFn: () => listSyncServices.fetchListById({ id }),\n  })\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/list/store.ts",
    "content": "import { ListService } from \"@follow/database/services/list\"\nimport { clone } from \"es-toolkit\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { apiMorph } from \"../../morph/api\"\nimport { storeDbMorph } from \"../../morph/store-db\"\nimport { feedActions } from \"../feed/store\"\nimport { subscriptionActions, subscriptionSyncService } from \"../subscription/store\"\nimport type { CreateListModel, ListModel } from \"./types\"\n\ntype ListId = string\ninterface ListState {\n  lists: Record<ListId, ListModel>\n  listIds: ListId[]\n}\n\nconst defaultState: ListState = {\n  lists: {},\n  listIds: [],\n}\n\nexport const useListStore = createZustandStore<ListState>(\"list\")(() => defaultState)\n\nconst get = useListStore.getState\nconst set = useListStore.setState\nconst immerSet = createImmerSetter(useListStore)\nclass ListActions implements Hydratable, Resetable {\n  async hydrate() {\n    const lists = await ListService.getListAll()\n    listActions.upsertManyInSession(\n      lists.map((list) => ({\n        ...list,\n        feedIds: JSON.parse(list.feedIds || \"[]\") as string[],\n        type: \"list\" as const,\n      })),\n    )\n  }\n\n  upsertManyInSession(lists: ListModel[]) {\n    const state = get()\n\n    set({\n      ...state,\n      lists: { ...state.lists, ...Object.fromEntries(lists.map((list) => [list.id, list])) },\n      listIds: [...state.listIds, ...lists.map((list) => list.id)],\n    })\n  }\n\n  async upsertMany(lists: ListModel[]) {\n    const tx = createTransaction()\n    tx.store(() => {\n      this.upsertManyInSession(lists)\n    })\n\n    tx.persist(() => {\n      return ListService.upsertMany(lists.map((list) => storeDbMorph.toListSchema(list)))\n    })\n    await tx.run()\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(defaultState)\n    })\n\n    tx.persist(() => {\n      return ListService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nexport const listActions = new ListActions()\n\nclass ListSyncServices {\n  async fetchListById(params: { id: string | undefined }) {\n    if (!params.id) return null\n    const list = await api().lists.get({ listId: params.id })\n\n    await listActions.upsertMany([apiMorph.toList(list.data.list)])\n\n    return list.data\n  }\n\n  async fetchOwnedLists() {\n    const res = await api().lists.list({})\n    await listActions.upsertMany(res.data.map((list) => apiMorph.toList(list)))\n\n    return res.data.map((list) => apiMorph.toList(list))\n  }\n\n  async createList(params: { list: CreateListModel }) {\n    const res = await api().lists.create({\n      title: params.list.title,\n      description: params.list.description,\n      image: params.list.image,\n      view: params.list.view,\n      fee: 0,\n    })\n    await listActions.upsertMany([apiMorph.toList(res.data)])\n    await subscriptionActions.upsertMany([\n      {\n        isPrivate: false,\n        listId: res.data.id,\n        type: \"list\",\n        userId: res.data.ownerUserId || \"\",\n        view: res.data.view,\n        createdAt: new Date().toISOString(),\n      },\n    ])\n\n    return res.data\n  }\n\n  async updateList(params: { listId: string; list: CreateListModel }) {\n    const tx = createTransaction()\n    const snapshot = get().lists[params.listId]\n    if (!snapshot) return\n\n    const nextModel = {\n      title: params.list.title,\n      description: params.list.description,\n      image: params.list.image,\n      view: params.list.view,\n      listId: params.listId,\n    }\n\n    tx.store(async () => {\n      await listActions.upsertMany([\n        {\n          ...snapshot,\n          ...nextModel,\n        },\n      ])\n    })\n\n    tx.request(async () => {\n      await api().lists.update(nextModel)\n    })\n\n    tx.persist(async () => {\n      if (params.list.view === snapshot.view) return\n      await subscriptionSyncService.changeListView({\n        listId: params.listId,\n        view: params.list.view,\n      })\n    })\n\n    tx.rollback(async () => {\n      await listActions.upsertMany([snapshot])\n    })\n\n    await tx.run()\n  }\n\n  async deleteList(listId: string) {\n    const list = get().lists[listId]\n    if (!list) return\n    const listToDelete = clone(list)\n\n    const tx = createTransaction()\n    tx.store(() => {\n      immerSet((draft) => {\n        delete draft.lists[listId]\n        draft.listIds = draft.listIds.filter((id) => id !== listId)\n      })\n    })\n\n    tx.request(async () => {\n      await subscriptionSyncService.unsubscribe([listId])\n      await api().lists.delete({ listId })\n    })\n\n    tx.rollback(() => {\n      immerSet((draft) => {\n        draft.lists[listId] = listToDelete\n        draft.listIds.push(listId)\n      })\n    })\n\n    tx.persist(() => {\n      return ListService.deleteList(listId)\n    })\n\n    await tx.run()\n  }\n\n  async addFeedsToFeedList(\n    params: { listId: string; feedIds: string[] } | { listId: string; feedId: string },\n  ) {\n    const feeds = await api().lists.addFeeds(params)\n    const list = get().lists[params.listId]\n    if (!list) return\n\n    feeds.data.forEach((feed) => {\n      feedActions.upsertMany([apiMorph.toFeedFromAddFeeds(feed)])\n    })\n    await listActions.upsertMany([\n      { ...list, feedIds: [...list.feedIds, ...feeds.data.map((feed) => feed.id)] },\n    ])\n  }\n\n  async removeFeedFromFeedList(params: { listId: string; feedId: string }) {\n    await api().lists.removeFeed(params)\n    const list = get().lists[params.listId]\n    if (!list) return\n\n    const feedIds = list.feedIds.filter((id) => id !== params.feedId)\n    await listActions.upsertMany([{ ...list, feedIds }])\n  }\n}\n\nexport const listSyncServices = new ListSyncServices()\n"
  },
  {
    "path": "packages/internal/store/src/modules/list/types.ts",
    "content": "import type { ListSchema } from \"@follow/database/schemas/types\"\n\nexport type CreateListModel = Pick<ListModel, \"description\" | \"image\" | \"view\"> & {\n  title: string\n}\n\nexport type ListModel = Omit<ListSchema, \"feedIds\"> & {\n  feedIds: string[]\n  type: \"list\"\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/subscription/getter.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { sortByAlphabet } from \"@follow/utils/utils\"\n\nimport { createSingleArgGetter, createStaticGetter } from \"../../lib/helper\"\nimport { getEntry } from \"../entry/getter\"\nimport { getFeedById } from \"../feed/getter\"\nimport { getInboxList } from \"../inbox/getters\"\nimport { getListById, getListFeedIds } from \"../list/getters\"\nimport { getUnreadById, getUnreadByListId } from \"../unread/getters\"\nimport { folderFeedsByFeedIdSelector } from \"./selectors\"\nimport { useSubscriptionStore } from \"./store\"\nimport { getDefaultCategory } from \"./utils\"\n\nexport const getSubscriptionById = (id: string | undefined) => {\n  if (!id) return\n  return useSubscriptionStore.getState().data[id]\n}\nexport const getSubscriptionByFeedId = (feedId: string | undefined) => getSubscriptionById(feedId)\n\nexport const getSubscriptionByEntryId = (entryId: string | undefined) => {\n  if (!entryId) return\n  const entry = getEntry(entryId)\n  if (!entry) return\n  const { feedId, sources } = entry\n  const possibleSource = (sources?.concat(feedId || \"\") ?? []).filter((s) => !!s && s !== \"feed\")\n  if (!possibleSource || possibleSource.length === 0) return\n  return possibleSource.map((id) => getSubscriptionByFeedId(id)).find((s) => !!s)\n}\n\nexport const getSubscribedFeedIdAndInboxHandlesByView = ({\n  view,\n  excludePrivate,\n  excludeHidden,\n}: {\n  view: FeedViewType | undefined\n  excludePrivate: boolean\n  excludeHidden: boolean\n}): string[] => {\n  if (typeof view !== \"number\") return []\n  const state = useSubscriptionStore.getState()\n\n  const feedIds = Array.from(state.feedIdByView[view])\n    .filter((i) => !excludePrivate || !state.data[i]?.isPrivate)\n    .filter((i) => !excludeHidden || !state.data[i]?.hideFromTimeline)\n\n  const inboxIds = view === FeedViewType.Articles ? getInboxList().map((i) => i.id) : []\n\n  const listFeedIds = Array.from(state.listIdByView[view])\n    .filter((i) => !excludePrivate || !state.data[i]?.isPrivate)\n    .filter((i) => !excludeHidden || !state.data[i]?.hideFromTimeline)\n    .flatMap((id) => getListFeedIds(id) ?? [])\n\n  // Use Set to remove duplicates when feeds exist in both subscriptions and lists\n  return Array.from(new Set([...feedIds, ...inboxIds, ...listFeedIds]))\n}\n\nexport const getSubscribedFeedIdsByView = (view: FeedViewType): string[] => {\n  const state = useSubscriptionStore.getState()\n  return Array.from(state.feedIdByView[view])\n}\n\nexport const getSubscriptionByCategory = ({\n  category,\n  view,\n}: {\n  category: string\n  view: FeedViewType\n}): string[] => {\n  const state = useSubscriptionStore.getState()\n\n  const ids = [] as string[]\n  for (const id of Object.keys(state.data)) {\n    const subscriptionCategory = state.data[id]\n      ? state.data[id].category || getDefaultCategory(state.data[id])\n      : null\n    if (subscriptionCategory === category && state.data[id]!.view === view) {\n      ids.push(id)\n    }\n  }\n  return ids\n}\n\nexport const getCategoryFeedIds = (feedIdOrCategory: string | undefined, view: FeedViewType) =>\n  folderFeedsByFeedIdSelector({ feedIdOrCategory, view })(useSubscriptionStore.getState())\n\n// Utility functions for creating getters\ntype StateType = ReturnType<typeof useSubscriptionStore.getState>\nconst getState = () => useSubscriptionStore.getState()\n\n// Helper functions for sorting\nconst sortUngroupedSubscriptionByAlphabet = (\n  leftSubscriptionId: string,\n  rightSubscriptionId: string,\n) => {\n  const leftSubscription = getSubscriptionById(leftSubscriptionId)\n  const rightSubscription = getSubscriptionById(rightSubscriptionId)\n\n  if (!leftSubscription || !rightSubscription) return 0\n\n  if (!leftSubscription.feedId || !rightSubscription.feedId) return 0\n  const leftFeed = getFeedById(leftSubscription.feedId)\n  const rightFeed = getFeedById(rightSubscription.feedId)\n\n  if (!leftFeed || !rightFeed) return 0\n\n  const comparedLeftTitle = leftSubscription.title ?? leftFeed.title ?? \"\"\n  const comparedRightTitle = rightSubscription.title ?? rightFeed.title ?? \"\"\n\n  return sortByAlphabet(comparedLeftTitle, comparedRightTitle)\n}\n\nconst sortByUnread = (leftSubscriptionId: string, rightSubscriptionId: string) => {\n  const leftSubscription = getSubscriptionById(leftSubscriptionId)\n  const rightSubscription = getSubscriptionById(rightSubscriptionId)\n\n  const nextLeftSubscriptionId = leftSubscription?.feedId || leftSubscription?.listId\n  const nextRightSubscriptionId = rightSubscription?.feedId || rightSubscription?.listId\n\n  if (!nextLeftSubscriptionId || !nextRightSubscriptionId) return 0\n  return getUnreadById(nextRightSubscriptionId) - getUnreadById(nextLeftSubscriptionId)\n}\n\nconst sortGroupedSubscriptionByUnread = (\n  leftCategory: string,\n  rightCategory: string,\n  view: FeedViewType,\n) => {\n  const leftFeedIds = getSubscriptionByCategory({ category: leftCategory, view })\n  const rightFeedIds = getSubscriptionByCategory({ category: rightCategory, view })\n\n  const leftUnreadCount = leftFeedIds.reduce((acc, feedId) => {\n    return acc + getUnreadById(feedId)\n  }, 0)\n  const rightUnreadCount = rightFeedIds.reduce((acc, feedId) => {\n    return acc + getUnreadById(feedId)\n  }, 0)\n  return -(rightUnreadCount - leftUnreadCount)\n}\n\n// Store selector functions (for React hooks)\nexport const getSubscriptionIdsByViewSelector = (state: StateType) => (view: FeedViewType) => {\n  const feedIds = Array.from(state.feedIdByView[view])\n  const inboxIds = view === FeedViewType.Articles ? getInboxList().map((i) => i.id) : []\n  const listFeedIds = Array.from(state.listIdByView[view]).flatMap((id) => getListFeedIds(id) ?? [])\n\n  // Use Set to remove duplicates when feeds exist in both subscriptions and lists\n  return Array.from(new Set([...feedIds, ...inboxIds, ...listFeedIds]))\n}\n\nexport const getFeedSubscriptionIdsByViewSelector =\n  (state: StateType) => (view: FeedViewType | undefined) => {\n    return typeof view === \"number\" ? Array.from(state.feedIdByView[view]) : []\n  }\n\nexport const getFeedSubscriptionByViewSelector = (state: StateType) => (view: FeedViewType) => {\n  return Array.from(state.feedIdByView[view])\n    .map((feedId) => state.data[feedId])\n    .filter((feed) => !!feed)\n}\n\nexport const getListSubscriptionByViewSelector = (state: StateType) => (view: FeedViewType) => {\n  return Array.from(state.listIdByView[view])\n    .map((listId) => state.data[listId])\n    .filter((list) => !!list)\n}\n\nexport const getGroupedSubscriptionSelector =\n  (state: StateType) =>\n  ({ view, autoGroup }: { view: FeedViewType; autoGroup: boolean }) => {\n    const feedIds = state.feedIdByView[view]\n\n    const grouped = {} as Record<string, string[]>\n    const unGrouped = [] as string[]\n\n    const autoGrouped = {} as Record<string, string[]>\n\n    for (const feedId of feedIds) {\n      const subscription = state.data[feedId]\n      if (!subscription) continue\n      const { category } = subscription\n      if (!category) {\n        const defaultCategory = getDefaultCategory(subscription)\n        if (defaultCategory && autoGroup) {\n          if (!autoGrouped[defaultCategory]) {\n            autoGrouped[defaultCategory] = []\n          }\n          autoGrouped[defaultCategory].push(feedId)\n        } else {\n          unGrouped.push(feedId)\n        }\n        continue\n      }\n      if (!grouped[category]) {\n        grouped[category] = []\n      }\n      grouped[category].push(feedId)\n    }\n\n    if (autoGroup) {\n      for (const category of Object.keys(autoGrouped)) {\n        if (autoGrouped[category] && autoGrouped[category].length > 1) {\n          grouped[category] = autoGrouped[category]\n        } else {\n          unGrouped.push(...autoGrouped[category]!)\n        }\n      }\n    }\n\n    return {\n      grouped,\n      unGrouped,\n    }\n  }\n\nexport const getSortedGroupedSubscriptionSelector =\n  (_state: StateType) =>\n  ({\n    view,\n    grouped,\n    sortBy,\n    sortOrder,\n    hideAllReadSubscriptions,\n  }: {\n    view: FeedViewType\n    grouped: Record<string, string[]>\n    sortBy: \"alphabet\" | \"count\"\n    sortOrder: \"asc\" | \"desc\"\n    hideAllReadSubscriptions: boolean\n  }) => {\n    const categories = Object.keys(grouped)\n    const sortedCategories = categories.sort((a, b) => {\n      const sortMethod = sortBy === \"alphabet\" ? sortByAlphabet : sortGroupedSubscriptionByUnread\n      const result = sortMethod(a, b, view)\n      return sortOrder === \"asc\" ? result : -result\n    })\n    const sortedList = [] as { category: string; subscriptionIds: string[] }[]\n    for (const category of sortedCategories) {\n      if (!hideAllReadSubscriptions || grouped[category]?.some((id) => getUnreadById(id) > 0)) {\n        sortedList.push({ category, subscriptionIds: grouped[category]! })\n      }\n    }\n    return sortedList\n  }\n\nexport const getSortedUngroupedSubscriptionSelector =\n  (_state: StateType) =>\n  ({\n    ids,\n    sortBy,\n    sortOrder,\n    hideAllReadSubscriptions,\n  }: {\n    ids: string[]\n    sortBy: \"alphabet\" | \"count\"\n    sortOrder: \"asc\" | \"desc\"\n    hideAllReadSubscriptions: boolean\n  }) => {\n    return ids\n      .filter((id) => {\n        return !hideAllReadSubscriptions || getUnreadById(id) > 0\n      })\n      .sort((a, b) => {\n        const sortMethod =\n          sortBy === \"alphabet\" ? sortUngroupedSubscriptionByAlphabet : sortByUnread\n        const result = sortMethod(a, b)\n        return sortOrder === \"asc\" ? result : -result\n      })\n  }\n\nexport const getSortedFeedSubscriptionByAlphabetSelector =\n  (_state: StateType) => (ids: string[]) => {\n    return ids.sort((a, b) => {\n      const leftFeed = getFeedById(a)\n      const rightFeed = getFeedById(b)\n      if (!leftFeed || !rightFeed) return 0\n      return sortByAlphabet(leftFeed.title ?? \"\", rightFeed.title ?? \"\")\n    })\n  }\n\nexport const getSubscriptionByIdSelector =\n  (state: StateType) => (id: string | undefined | null) => {\n    return id ? state.data[id] : undefined\n  }\n\nexport const getSubscriptionsByIdsSelector = (state: StateType) => (ids: string[]) => {\n  return ids.map((id) => state.data[id])\n}\n\nexport const getAllListSubscriptionSelector = (state: StateType) => () => {\n  return Object.values(state.listIdByView).flatMap((list) => Array.from(list))\n}\n\nexport const getListSubscriptionSelector = (state: StateType) => (view: FeedViewType) => {\n  return Array.from(state.listIdByView[view]).map((listId) => state.data[listId])\n}\n\nexport const getListSubscriptionIdsSelector = (state: StateType) => (view: FeedViewType) => {\n  return Array.from(state.listIdByView[view])\n}\n\nexport const getFeedSubscriptionSelector = (state: StateType) => (view: FeedViewType) => {\n  return Array.from(state.feedIdByView[view]).map((feedId) => state.data[feedId])\n}\n\nexport const getFeedSubscriptionIdsSelector = (state: StateType) => (view: FeedViewType) => {\n  return Array.from(state.feedIdByView[view])\n}\n\nexport const getAllFeedSubscriptionSelector = (state: StateType) => () => {\n  return Array.from(\n    new Set(Object.values(state.feedIdByView).flatMap((feedId) => Array.from(feedId))),\n  )\n    .map((id) => state.data[id])\n    .filter((feed) => !!feed)\n}\n\nexport const getAllFeedSubscriptionIdsSelector = (state: StateType) => () => {\n  return Array.from(\n    new Set(Object.values(state.feedIdByView).flatMap((feedId) => Array.from(feedId))),\n  )\n}\n\nexport const getAllSubscriptionSelector = (state: StateType) => () => {\n  return Object.values(state.data).filter((subscription) => !!subscription)\n}\n\nexport const getSortedListSubscriptionSelector =\n  (_state: StateType) =>\n  ({\n    ids,\n    sortBy,\n    hideAllReadSubscriptions,\n  }: {\n    ids: string[]\n    sortBy: \"alphabet\" | \"unread\"\n    hideAllReadSubscriptions: boolean\n  }) => {\n    return ids\n      .concat()\n      .filter((id) => !hideAllReadSubscriptions || getUnreadByListId(id) > 0)\n      .sort((a, b) => {\n        const leftList = getListById(a)\n        const rightList = getListById(b)\n        if (!leftList || !rightList) return 0\n        if (sortBy === \"alphabet\") {\n          return sortByAlphabet(leftList.title || \"\", rightList.title || \"\")\n        }\n        return sortByUnread(a, b)\n      })\n  }\n\nexport const getCategoriesSelector = (state: StateType) => (view?: FeedViewType) => {\n  return view === undefined\n    ? Array.from(\n        new Set(Object.values(state.categories).flatMap((category) => Array.from(category))),\n      )\n    : Array.from(state.categories[view])\n}\n\nexport const getSubscriptionCategoryExistSelector =\n  (state: StateType) => (categoryId: string | undefined | null) => {\n    if (!categoryId) return false\n    return Object.values(state.categories).some((category) => category.has(categoryId))\n  }\n\nexport const getCategoriesByViewSelector = (state: StateType) => (view: FeedViewType) => {\n  return state.categories[view]\n}\n\nexport const getListSubscriptionCountSelector = (state: StateType) => () => {\n  return Array.from(state.subscriptionIdSet).filter((id) => id.startsWith(\"list/\")).length\n}\n\nexport const getFeedSubscriptionCountSelector = (state: StateType) => () => {\n  return Array.from(state.subscriptionIdSet).filter((id) => id.startsWith(\"feed/\")).length\n}\n\nexport const getIsSubscribedSelector = (state: StateType) => (id: string | undefined) => {\n  if (!id) return false\n  return (\n    state.subscriptionIdSet.has(id) ||\n    state.subscriptionIdSet.has(`feed/${id}`) ||\n    state.subscriptionIdSet.has(`list/${id}`) ||\n    state.subscriptionIdSet.has(`inbox/${id}`)\n  )\n}\n\nexport const getIsListSubscriptionSelector = (state: StateType) => (id: string | undefined) => {\n  if (!id) return false\n  return state.subscriptionIdSet.has(`list/${id}`)\n}\n\nexport const getNonPrivateSubscriptionIdsSelector = (state: StateType) => (ids: string[]) => {\n  return ids\n    .map((id) => state.data[id])\n    .filter((s) => !s?.isPrivate)\n    .map((s) => s?.listId || s?.feedId)\n    .filter((id) => typeof id === \"string\")\n}\n\nexport const getCategoryOpenStateByViewSelector = (state: StateType) => (view: FeedViewType) => {\n  return state.categoryOpenStateByView[view]\n}\n\n// Static getters for use outside React components\nexport const getSubscriptionIdsByView = createSingleArgGetter(\n  getState,\n  getSubscriptionIdsByViewSelector,\n)\nexport const getFeedSubscriptionIdsByView = createSingleArgGetter(\n  getState,\n  getFeedSubscriptionIdsByViewSelector,\n)\nexport const getFeedSubscriptionByView = createSingleArgGetter(\n  getState,\n  getFeedSubscriptionByViewSelector,\n)\nexport const getListSubscriptionByView = createSingleArgGetter(\n  getState,\n  getListSubscriptionByViewSelector,\n)\nexport const getGroupedSubscription = createSingleArgGetter(\n  getState,\n  getGroupedSubscriptionSelector,\n)\nexport const getSortedGroupedSubscription = createSingleArgGetter(\n  getState,\n  getSortedGroupedSubscriptionSelector,\n)\nexport const getSortedUngroupedSubscription = createSingleArgGetter(\n  getState,\n  getSortedUngroupedSubscriptionSelector,\n)\nexport const getSortedFeedSubscriptionByAlphabet = createSingleArgGetter(\n  getState,\n  getSortedFeedSubscriptionByAlphabetSelector,\n)\nexport const getSubscriptionByIdStatic = createSingleArgGetter(\n  getState,\n  getSubscriptionByIdSelector,\n)\nexport const getSubscriptionsByIds = createSingleArgGetter(getState, getSubscriptionsByIdsSelector)\nexport const getAllListSubscription = createStaticGetter(getState, getAllListSubscriptionSelector)\nexport const getListSubscription = createSingleArgGetter(getState, getListSubscriptionSelector)\nexport const getListSubscriptionIds = createSingleArgGetter(\n  getState,\n  getListSubscriptionIdsSelector,\n)\nexport const getFeedSubscription = createSingleArgGetter(getState, getFeedSubscriptionSelector)\nexport const getFeedSubscriptionIds = createSingleArgGetter(\n  getState,\n  getFeedSubscriptionIdsSelector,\n)\nexport const getAllFeedSubscription = createStaticGetter(getState, getAllFeedSubscriptionSelector)\nexport const getAllFeedSubscriptionIds = createStaticGetter(\n  getState,\n  getAllFeedSubscriptionIdsSelector,\n)\nexport const getAllSubscription = createStaticGetter(getState, getAllSubscriptionSelector)\nexport const getSortedListSubscription = createSingleArgGetter(\n  getState,\n  getSortedListSubscriptionSelector,\n)\nexport const getCategories = createSingleArgGetter(getState, getCategoriesSelector)\nexport const getSubscriptionCategoryExist = createSingleArgGetter(\n  getState,\n  getSubscriptionCategoryExistSelector,\n)\nexport const getCategoriesByView = createSingleArgGetter(getState, getCategoriesByViewSelector)\nexport const getListSubscriptionCount = createStaticGetter(\n  getState,\n  getListSubscriptionCountSelector,\n)\nexport const getFeedSubscriptionCount = createStaticGetter(\n  getState,\n  getFeedSubscriptionCountSelector,\n)\nexport const getIsSubscribed = createSingleArgGetter(getState, getIsSubscribedSelector)\nexport const getIsListSubscription = createSingleArgGetter(getState, getIsListSubscriptionSelector)\nexport const getNonPrivateSubscriptionIds = createSingleArgGetter(\n  getState,\n  getNonPrivateSubscriptionIdsSelector,\n)\nexport const getCategoryOpenStateByView = createSingleArgGetter(\n  getState,\n  getCategoryOpenStateByViewSelector,\n)\n"
  },
  {
    "path": "packages/internal/store/src/modules/subscription/hooks.ts",
    "content": "import { FeedViewType, getViewList } from \"@follow/constants\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useCallback, useMemo, useRef } from \"react\"\n\nimport {\n  getAllFeedSubscriptionIdsSelector,\n  getAllFeedSubscriptionSelector,\n  getAllListSubscriptionSelector,\n  getAllSubscriptionSelector,\n  getCategoriesByViewSelector,\n  getCategoriesSelector,\n  getCategoryOpenStateByViewSelector,\n  getFeedSubscriptionByViewSelector,\n  getFeedSubscriptionCountSelector,\n  getFeedSubscriptionIdsByViewSelector,\n  getFeedSubscriptionIdsSelector,\n  getFeedSubscriptionSelector,\n  getGroupedSubscriptionSelector,\n  getIsListSubscriptionSelector,\n  getIsSubscribedSelector,\n  getListSubscriptionByViewSelector,\n  getListSubscriptionCountSelector,\n  getListSubscriptionIdsSelector,\n  getListSubscriptionSelector,\n  getNonPrivateSubscriptionIdsSelector,\n  getSortedFeedSubscriptionByAlphabetSelector,\n  getSortedGroupedSubscriptionSelector,\n  getSortedListSubscriptionSelector,\n  getSortedUngroupedSubscriptionSelector,\n  getSubscriptionByIdSelector,\n  getSubscriptionCategoryExistSelector,\n  getSubscriptionIdsByViewSelector,\n  getSubscriptionsByIdsSelector,\n} from \"./getter\"\nimport { folderFeedsByFeedIdSelector } from \"./selectors\"\nimport type { SubscriptionState } from \"./store\"\nimport { subscriptionSyncService, useSubscriptionStore } from \"./store\"\nimport { getDefaultCategory } from \"./utils\"\n\nexport const usePrefetchSubscription = (view?: FeedViewType) => {\n  return useQuery({\n    queryKey: [\"subscription\", view],\n    queryFn: () => subscriptionSyncService.fetch(view),\n    staleTime: 30 * 1000 * 60, // 30 minutes\n  })\n}\n\nexport const useSubscriptionIdsByView = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getSubscriptionIdsByViewSelector(state)(view), [view]),\n  )\n}\n\nexport const useFeedSubscriptionIdsByView = (view: FeedViewType | undefined) => {\n  return useSubscriptionStore(\n    useCallback((state) => getFeedSubscriptionIdsByViewSelector(state)(view), [view]),\n  )\n}\n\nexport const useFeedSubscriptionByView = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getFeedSubscriptionByViewSelector(state)(view), [view]),\n  )\n}\n\nexport const useListSubscriptionByView = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getListSubscriptionByViewSelector(state)(view), [view]),\n  )\n}\n\nexport const useGroupedSubscription = ({\n  view,\n  autoGroup,\n}: {\n  view: FeedViewType\n  autoGroup: boolean\n}) => {\n  return useSubscriptionStore(\n    useCallback(\n      (state) => getGroupedSubscriptionSelector(state)({ view, autoGroup }),\n      [autoGroup, view],\n    ),\n  )\n}\n\nexport const useSortedGroupedSubscription = ({\n  view,\n  grouped,\n  sortBy,\n  sortOrder,\n  hideAllReadSubscriptions,\n}: {\n  view: FeedViewType\n  grouped: Record<string, string[]>\n  sortBy: \"alphabet\" | \"count\"\n  sortOrder: \"asc\" | \"desc\"\n  hideAllReadSubscriptions: boolean\n}) => {\n  return useSubscriptionStore(\n    useCallback(\n      (state) => {\n        return getSortedGroupedSubscriptionSelector(state)({\n          view,\n          grouped,\n          sortBy,\n          sortOrder,\n          hideAllReadSubscriptions,\n        })\n      },\n      [grouped, sortBy, sortOrder, view, hideAllReadSubscriptions],\n    ),\n  )\n}\n\nexport const useSortedUngroupedSubscription = ({\n  ids,\n  sortBy,\n  sortOrder,\n  hideAllReadSubscriptions,\n}: {\n  ids: string[]\n  sortBy: \"alphabet\" | \"count\"\n  sortOrder: \"asc\" | \"desc\"\n  hideAllReadSubscriptions: boolean\n}) => {\n  return useSubscriptionStore(\n    useCallback(\n      (state) => {\n        return getSortedUngroupedSubscriptionSelector(state)({\n          ids,\n          sortBy,\n          sortOrder,\n          hideAllReadSubscriptions,\n        })\n      },\n      [ids, sortBy, sortOrder, hideAllReadSubscriptions],\n    ),\n  )\n}\n\nexport const useSortedFeedSubscriptionByAlphabet = (ids: string[]) => {\n  return useSubscriptionStore(\n    useCallback(\n      (state) => {\n        return getSortedFeedSubscriptionByAlphabetSelector(state)(ids)\n      },\n      [ids],\n    ),\n  )\n}\n\nexport const useSubscriptionById = (id: string | undefined | null) => {\n  return useSubscriptionStore(useCallback((state) => getSubscriptionByIdSelector(state)(id), [id]))\n}\nexport const useSubscriptionsByIds = (ids: string[]) => {\n  const idsString = ids.toString()\n  return useSubscriptionStore(\n    useCallback(\n      (state) => getSubscriptionsByIdsSelector(state)(ids),\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      [idsString],\n    ),\n  )\n}\n\nexport const useSubscriptionByFeedId = (feedId: string | undefined | null) =>\n  useSubscriptionById(feedId)\nexport const useSubscriptionsByFeedIds = (feedIds: string[]) => useSubscriptionsByIds(feedIds)\nexport const useSubscriptionByListId = (listId: string | undefined | null) =>\n  useSubscriptionById(listId)\n\nexport const useAllListSubscription = () => {\n  return useSubscriptionStore((state) => getAllListSubscriptionSelector(state)())\n}\n\nexport const useListSubscription = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getListSubscriptionSelector(state)(view), [view]),\n  )\n}\n\nexport const useListSubscriptionIds = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getListSubscriptionIdsSelector(state)(view), [view]),\n  )\n}\n\nexport const useFeedSubscription = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getFeedSubscriptionSelector(state)(view), [view]),\n  )\n}\n\nexport const useFeedSubscriptionIds = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getFeedSubscriptionIdsSelector(state)(view), [view]),\n  )\n}\n\nexport const useAllFeedSubscription = () => {\n  const stableSelector = useRef((state: SubscriptionState) =>\n    getAllFeedSubscriptionSelector(state)(),\n  ).current\n  return useSubscriptionStore(stableSelector)\n}\n\nexport const useAllFeedSubscriptionIds = () => {\n  const stableSelector = useRef((state: SubscriptionState) =>\n    getAllFeedSubscriptionIdsSelector(state)(),\n  ).current\n  return useSubscriptionStore(stableSelector)\n}\n\nexport const useAllSubscription = () => {\n  const stableSelector = useRef((state: SubscriptionState) =>\n    getAllSubscriptionSelector(state)(),\n  ).current\n  return useSubscriptionStore(stableSelector)\n}\n\nexport const useSortedListSubscription = ({\n  ids,\n  sortBy,\n  hideAllReadSubscriptions,\n}: {\n  ids: string[]\n  sortBy: \"alphabet\" | \"unread\"\n  hideAllReadSubscriptions: boolean\n}) => {\n  return useSubscriptionStore(\n    useCallback(\n      (state) => {\n        return getSortedListSubscriptionSelector(state)({\n          ids,\n          sortBy,\n          hideAllReadSubscriptions,\n        })\n      },\n      [ids, sortBy, hideAllReadSubscriptions],\n    ),\n  )\n}\n\nexport const useCategories = (view?: FeedViewType) => {\n  return useSubscriptionStore(useCallback((state) => getCategoriesSelector(state)(view), [view]))\n}\n\nexport const useSubscriptionCategoryExist = (categoryId: string | undefined | null) => {\n  return useSubscriptionStore(\n    useCallback((state) => getSubscriptionCategoryExistSelector(state)(categoryId), [categoryId]),\n  )\n}\n\nexport const getSubscriptionCategory = (view?: FeedViewType) => {\n  const state = useSubscriptionStore.getState()\n  return view === undefined ? [] : Array.from(state.categories[view])\n}\n\nexport const useViewWithSubscription = () =>\n  useSubscriptionStore((state) => {\n    return getViewList()\n      .filter((view) => {\n        if (\n          view.view === FeedViewType.Articles ||\n          view.view === FeedViewType.SocialMedia ||\n          view.view === FeedViewType.Pictures ||\n          view.view === FeedViewType.Videos\n        ) {\n          return true\n        } else {\n          return state.feedIdByView[view.view].size > 0\n        }\n      })\n      .map((v) => v.view)\n  })\n\nexport const useCategoriesByView = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getCategoriesByViewSelector(state)(view), [view]),\n  )\n}\n\nexport const useListSubscriptionCount = () => {\n  const stableSelector = useRef((state: SubscriptionState) =>\n    getListSubscriptionCountSelector(state)(),\n  ).current\n  return useSubscriptionStore(stableSelector)\n}\n\nexport const useFeedSubscriptionCount = () => {\n  const stableSelector = useRef((state: SubscriptionState) =>\n    getFeedSubscriptionCountSelector(state)(),\n  ).current\n  return useSubscriptionStore(stableSelector)\n}\n\nexport const useIsSubscribed = (id: string | undefined) => {\n  return useSubscriptionStore(useCallback((state) => getIsSubscribedSelector(state)(id), [id]))\n}\n\nexport const useIsListSubscription = (id: string | undefined) => {\n  return useSubscriptionStore(\n    useCallback((state) => getIsListSubscriptionSelector(state)(id), [id]),\n  )\n}\n\nexport const useFolderFeedsByFeedId = ({\n  feedId,\n  view,\n}: {\n  feedId: string | undefined\n  view: FeedViewType\n}) => {\n  return useSubscriptionStore(\n    useCallback(\n      (state) => {\n        return folderFeedsByFeedIdSelector({ feedIdOrCategory: feedId, view })(state)\n      },\n      [feedId, view],\n    ),\n  )\n}\n\nexport const useFeedsGroupedData = (view: FeedViewType, autoGroup: boolean) => {\n  const data = useFeedSubscriptionByView(view)\n\n  return useMemo(() => {\n    if (!data || data.length === 0) return {}\n\n    const groupFolder = {} as Record<string, string[]>\n\n    for (const subscription of data.filter((s) => !!s)) {\n      const category =\n        subscription.category ||\n        (autoGroup ? getDefaultCategory(subscription) : subscription.feedId)\n\n      if (category) {\n        if (!groupFolder[category]) {\n          groupFolder[category] = []\n        }\n        if (subscription.feedId) {\n          groupFolder[category].push(subscription.feedId)\n        }\n      }\n    }\n\n    return groupFolder\n  }, [autoGroup, data])\n}\n\nexport const useSubscriptionListIds = (view: FeedViewType) => {\n  const data = useListSubscriptionByView(view)\n\n  return useMemo(() => {\n    if (!data || data.length === 0) return []\n    const ids: string[] = []\n    for (const subscription of data) {\n      if (!subscription) continue\n      if (\"listId\" in subscription) {\n        ids.push(subscription.listId!)\n      }\n    }\n    return ids\n  }, [data])\n}\n\nexport const useCategoryOpenStateByView = (view: FeedViewType) => {\n  return useSubscriptionStore(\n    useCallback((state) => getCategoryOpenStateByViewSelector(state)(view), [view]),\n  )\n}\n\nexport const useNonPrivateSubscriptionIds = (ids: string[]) => {\n  const idsString = ids.toString()\n  const nonPrivateSubscriptions = useSubscriptionStore(\n    useCallback(\n      (state) => getNonPrivateSubscriptionIdsSelector(state)(ids),\n\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      [idsString],\n    ),\n  )\n\n  return nonPrivateSubscriptions\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/subscription/selectors.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\n\nimport { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from \"../../constants/app\"\nimport type { SubscriptionState } from \"./store\"\nimport { getDefaultCategory } from \"./utils\"\n\nexport const folderFeedsByFeedIdSelector =\n  ({ feedIdOrCategory, view }: { feedIdOrCategory: string | undefined; view: FeedViewType }) =>\n  (state: SubscriptionState): string[] => {\n    if (typeof feedIdOrCategory !== \"string\") return []\n    if (feedIdOrCategory === FEED_COLLECTION_LIST) {\n      return [feedIdOrCategory]\n    }\n\n    const folderName = feedIdOrCategory.startsWith(ROUTE_FEED_IN_FOLDER)\n      ? feedIdOrCategory.slice(ROUTE_FEED_IN_FOLDER.length)\n      : feedIdOrCategory\n\n    const feedIds: string[] = []\n    for (const feedId in state.data) {\n      const subscription = state.data[feedId]\n      if (!subscription) continue\n      if (\n        (subscription.view === view || view === FeedViewType.All) &&\n        (subscription.category\n          ? subscription.category === folderName\n          : getDefaultCategory(subscription) === folderName)\n      ) {\n        feedIds.push(feedId)\n      }\n    }\n    return feedIds\n  }\n"
  },
  {
    "path": "packages/internal/store/src/modules/subscription/store.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport { SubscriptionService } from \"@follow/database/services/subscription\"\nimport { tracker } from \"@follow/tracker\"\nimport { omit } from \"es-toolkit\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { apiMorph } from \"../../morph/api\"\nimport { dbStoreMorph } from \"../../morph/db-store\"\nimport { buildSubscriptionDbId, storeDbMorph } from \"../../morph/store-db\"\nimport { invalidateEntriesQuery } from \"../entry/hooks\"\nimport { getFeedById } from \"../feed/getter\"\nimport { feedActions } from \"../feed/store\"\nimport { inboxActions } from \"../inbox/store\"\nimport { getListById } from \"../list/getters\"\nimport { listActions } from \"../list/store\"\nimport { unreadActions } from \"../unread/store\"\nimport { whoami } from \"../user/getters\"\nimport { getCategoryFeedIds } from \"./getter\"\nimport type { SubscriptionForm, SubscriptionModel } from \"./types\"\nimport { getDefaultCategory, getSubscriptionDBId, getSubscriptionStoreId } from \"./utils\"\n\ntype FeedId = string\ntype ListId = string\n\nexport interface SubscriptionState {\n  /**\n   * Key: FeedId, ListId, `inbox/${inboxId}`\n   * Value: SubscriptionPlainModel\n   */\n  data: Record<string, SubscriptionModel>\n\n  feedIdByView: Record<FeedViewType, Set<FeedId>>\n\n  listIdByView: Record<FeedViewType, Set<ListId>>\n\n  /**\n   * All named categories names set\n   */\n  categories: Record<FeedViewType, Set<string>>\n  /**\n   * All subscription ids set\n   */\n  subscriptionIdSet: Set<string>\n\n  categoryOpenStateByView: Record<FeedViewType, Record<string, boolean>>\n}\n\nconst emptyDataSetByView: Record<FeedViewType, Set<FeedId>> = {\n  [FeedViewType.All]: new Set(),\n  [FeedViewType.Articles]: new Set(),\n  [FeedViewType.Audios]: new Set(),\n  [FeedViewType.Notifications]: new Set(),\n  [FeedViewType.Pictures]: new Set(),\n  [FeedViewType.SocialMedia]: new Set(),\n  [FeedViewType.Videos]: new Set(),\n}\nconst emptyCategoryOpenStateByView: Record<FeedViewType, Record<string, boolean>> = {\n  [FeedViewType.All]: {},\n  [FeedViewType.Articles]: {},\n  [FeedViewType.Audios]: {},\n  [FeedViewType.Notifications]: {},\n  [FeedViewType.Pictures]: {},\n  [FeedViewType.SocialMedia]: {},\n  [FeedViewType.Videos]: {},\n}\n\nconst defaultState: SubscriptionState = {\n  data: {},\n  feedIdByView: { ...emptyDataSetByView },\n  listIdByView: { ...emptyDataSetByView },\n  categories: { ...emptyDataSetByView },\n  subscriptionIdSet: new Set(),\n  categoryOpenStateByView: { ...emptyCategoryOpenStateByView },\n}\n\nconst invalidateViews = (...views: (FeedViewType | undefined)[]) => {\n  const viewSet = new Set<FeedViewType>()\n\n  for (const view of views) {\n    if (view === undefined) continue\n    viewSet.add(view)\n  }\n\n  if (viewSet.size === 0) return\n\n  viewSet.add(FeedViewType.All)\n\n  invalidateEntriesQuery({\n    views: Array.from(viewSet),\n  })\n}\nexport const useSubscriptionStore = createZustandStore<SubscriptionState>(\"subscription\")(\n  () => defaultState,\n)\n\nconst get = useSubscriptionStore.getState\n\nconst immerSet = createImmerSetter(useSubscriptionStore)\nclass SubscriptionActions implements Hydratable, Resetable {\n  async hydrate() {\n    const subscriptions = await SubscriptionService.getSubscriptionAll()\n    subscriptionActions.upsertManyInSession(\n      subscriptions.map((s) => dbStoreMorph.toSubscriptionModel(s)),\n    )\n  }\n  async upsertManyInSession(subscriptions: SubscriptionModel[]) {\n    immerSet((draft) => {\n      for (const subscription of subscriptions) {\n        const subscriptionSetId = getSubscriptionDBId(subscription)\n        const subscriptionStoreId = getSubscriptionStoreId(subscription)\n\n        draft.data[subscriptionStoreId] = subscription\n        draft.subscriptionIdSet.add(subscriptionSetId)\n\n        if (subscription.feedId && subscription.type === \"feed\") {\n          draft.feedIdByView[subscription.view].add(subscription.feedId)\n          draft.feedIdByView[FeedViewType.All].add(subscription.feedId)\n          if (subscription.category) {\n            draft.categories[subscription.view].add(subscription.category)\n          }\n        }\n        if (subscription.listId && subscription.type === \"list\") {\n          draft.listIdByView[subscription.view].add(subscription.listId)\n          draft.listIdByView[FeedViewType.All].add(subscription.listId)\n        }\n      }\n    })\n  }\n  async upsertMany(\n    subscriptions: SubscriptionModel[],\n    options: { resetBeforeUpsert?: boolean | FeedViewType } = {},\n  ) {\n    const tx = createTransaction()\n    tx.store(() => {\n      if (options.resetBeforeUpsert !== undefined) {\n        if (typeof options.resetBeforeUpsert === \"boolean\") {\n          this.reset()\n        } else {\n          this.resetByView(options.resetBeforeUpsert)\n        }\n      }\n      this.upsertManyInSession(subscriptions)\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.upsertMany(\n        subscriptions.map((s) => storeDbMorph.toSubscriptionSchema(s)),\n      )\n    })\n\n    await tx.run()\n  }\n\n  resetByView(view: FeedViewType) {\n    immerSet((draft) => {\n      draft.feedIdByView[view] = new Set()\n      draft.listIdByView[view] = new Set()\n      draft.categories[view] = new Set()\n      draft.subscriptionIdSet = new Set()\n    })\n  }\n\n  toggleCategoryOpenState(view: FeedViewType, category: string) {\n    immerSet((state) => {\n      state.categoryOpenStateByView[view][category] = !state.categoryOpenStateByView[view][category]\n    })\n  }\n\n  changeCategoryOpenState(view: FeedViewType, category: string, status: boolean) {\n    immerSet((state) => {\n      state.categoryOpenStateByView[view][category] = status\n    })\n  }\n\n  expandCategoryOpenStateByView(view: FeedViewType, isOpen: boolean) {\n    immerSet((state) => {\n      for (const category in state.categoryOpenStateByView[view]) {\n        state.categoryOpenStateByView[view][category] = isOpen\n      }\n    })\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      // set(defaultState)\n      immerSet((draft) => {\n        Object.assign(draft, omit(defaultState, [\"categoryOpenStateByView\"]))\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nclass SubscriptionSyncService {\n  async fetch(view?: FeedViewType) {\n    const { data } = await api().subscriptions.get({\n      view: view !== undefined ? view : undefined,\n    })\n\n    const { subscriptions, collections } = apiMorph.toSubscription(data)\n\n    feedActions.upsertMany(collections.feeds)\n    subscriptionActions.upsertMany(subscriptions, {\n      resetBeforeUpsert: typeof view === \"number\" ? view : true,\n    })\n    listActions.upsertMany(collections.lists)\n\n    inboxActions.upsertMany(collections.inboxes)\n\n    return {\n      subscriptions,\n      feeds: collections.feeds,\n    }\n  }\n\n  async edit(subscription: SubscriptionModel) {\n    const subscriptionId = getSubscriptionStoreId(subscription)\n    const current = get().data[subscriptionId]\n    if (!current) {\n      return\n    }\n    const tx = createTransaction(current)\n\n    let addNewCategory = false\n    tx.store(() => {\n      immerSet((draft) => {\n        if (\n          subscription.category &&\n          !draft.categories[subscription.view].has(subscription.category)\n        ) {\n          addNewCategory = true\n          draft.categories[subscription.view].add(subscription.category)\n        }\n\n        if (subscription.type === \"feed\") {\n          draft.feedIdByView[current.view].delete(current.feedId!)\n          draft.feedIdByView[subscription.view].add(subscription.feedId!)\n        }\n\n        draft.data[subscriptionId] = subscription\n      })\n    })\n    tx.rollback((current) => {\n      immerSet((draft) => {\n        if (addNewCategory && subscription.category) {\n          draft.categories[subscription.view].delete(subscription.category)\n        }\n\n        if (subscription.type === \"feed\") {\n          draft.feedIdByView[subscription.view].delete(subscription.feedId!)\n          draft.feedIdByView[current.view].add(current.feedId!)\n        }\n\n        draft.data[subscriptionId] = current\n      })\n    })\n    tx.request(async () => {\n      await api().subscriptions.update({\n        ...subscription,\n        feedId: subscription.feedId ?? undefined,\n        listId: subscription.listId ?? undefined,\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.patch(storeDbMorph.toSubscriptionSchema(subscription))\n    })\n\n    await tx.run()\n\n    invalidateViews(subscription.view)\n  }\n\n  async subscribe(subscription: SubscriptionForm) {\n    const data = await api().subscriptions.create(subscription)\n\n    if (data.feed) {\n      feedActions.upsertMany([data.feed])\n      tracker.subscribe({ feedId: data.feed.id, view: subscription.view })\n    }\n\n    if (data.list) {\n      listActions.upsertMany([\n        {\n          ...data.list,\n          userId: data.list.ownerUserId,\n          type: \"list\",\n          subscriptionCount: null,\n          purchaseAmount: null,\n        },\n      ])\n      tracker.subscribe({ listId: data.list.id, view: subscription.view })\n    }\n\n    if (data.unread) {\n      unreadActions.upsertMany(data.unread)\n    }\n\n    // Insert to subscription\n    await subscriptionActions.upsertMany([\n      {\n        ...subscription,\n        title: subscription.title ?? null,\n        category: subscription.category ?? null,\n\n        type: data.list ? \"list\" : \"feed\",\n        createdAt: new Date().toISOString(),\n        feedId: data.feed?.id ?? null,\n        listId: data.list?.id ?? null,\n        inboxId: null,\n        userId: whoami()?.id ?? \"\",\n      },\n    ])\n\n    invalidateViews(subscription.view)\n  }\n\n  async unsubscribe(id: string | undefined | null | (string | undefined | null)[]) {\n    const normalizedIds = (Array.isArray(id) ? id : [id]).filter((i) => typeof i === \"string\")\n    const subscriptionList = normalizedIds.map((id) => get().data[id]).filter((i) => !!i)\n    const feedsAndLists = normalizedIds\n      .map((id) => getFeedById(id) ?? getListById(id))\n      .filter((i) => !!i)\n    if (subscriptionList.length === 0) return feedsAndLists\n\n    const feedSubscriptions = subscriptionList.filter((i) => i.type === \"feed\")\n    const listSubscriptions = subscriptionList.filter((i) => i.type === \"list\")\n\n    const tx = createTransaction(subscriptionList)\n\n    tx.store(() => {\n      immerSet((draft) => {\n        for (const id of normalizedIds) {\n          const subscription = draft.data[id]\n          if (!subscription) continue\n          draft.subscriptionIdSet.delete(getSubscriptionDBId(subscription))\n          if (subscription.feedId) {\n            draft.feedIdByView[subscription.view].delete(subscription.feedId)\n            draft.feedIdByView[FeedViewType.All].delete(subscription.feedId)\n          }\n          if (subscription.listId) {\n            draft.listIdByView[subscription.view].delete(subscription.listId)\n            draft.listIdByView[FeedViewType.All].delete(subscription.listId)\n          }\n          if (subscription.category) {\n            draft.categories[subscription.view].delete(subscription.category)\n            draft.categories[FeedViewType.All].delete(subscription.category)\n          }\n          delete draft.data[id]\n        }\n      })\n    })\n\n    tx.request(async () => {\n      const feedIdList = feedSubscriptions.map((s) => s.feedId).filter((i) => typeof i === \"string\")\n      await api().subscriptions.delete({\n        feedIdList: feedIdList.length > 0 ? feedIdList : undefined,\n        listId: listSubscriptions.at(0)?.listId || undefined,\n      })\n    })\n\n    tx.rollback((current) => {\n      immerSet((draft) => {\n        for (const [index, id] of normalizedIds.entries()) {\n          const subscription = current[index]\n          if (!subscription) continue\n\n          draft.data[id] = subscription\n\n          draft.subscriptionIdSet.add(getSubscriptionDBId(subscription))\n          if (subscription.feedId) {\n            draft.feedIdByView[subscription.view].add(subscription.feedId)\n            draft.feedIdByView[FeedViewType.All].add(subscription.feedId)\n          }\n          if (subscription.listId) {\n            draft.listIdByView[subscription.view].add(subscription.listId)\n            draft.listIdByView[FeedViewType.All].add(subscription.listId)\n          }\n          if (subscription.category) {\n            draft.categories[subscription.view].add(subscription.category)\n            draft.categories[FeedViewType.All].add(subscription.category)\n          }\n        }\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.delete(subscriptionList.map((i) => buildSubscriptionDbId(i)))\n    })\n\n    await tx.run()\n    const affectedViews = Array.from(\n      new Set([...feedSubscriptions, ...listSubscriptions].map((i) => i.view)),\n    )\n    invalidateViews(...affectedViews)\n\n    feedSubscriptions.forEach((i) => {\n      unreadActions.updateById(i.feedId, 0)\n    })\n    return feedsAndLists\n  }\n\n  async batchUpdateSubscription({\n    feedIds,\n    category: newCategory,\n    view: newView,\n  }: {\n    feedIds: string[]\n    category?: string | null\n    view: FeedViewType\n  }) {\n    const current = feedIds\n      .map((id) => get().data[id])\n      .map((i) =>\n        i\n          ? {\n              view: i.view,\n              category: i.category,\n            }\n          : null,\n      )\n\n    const tx = createTransaction()\n    tx.store(() => {\n      immerSet((draft) => {\n        for (const feedId of feedIds) {\n          const subscription = draft.data[feedId]\n          if (!subscription) continue\n\n          const currentView = subscription.view\n          draft.feedIdByView[currentView].delete(feedId)\n          draft.feedIdByView[newView].add(feedId)\n          subscription.view = newView\n\n          if (newCategory) {\n            draft.categories[newView].add(newCategory)\n            subscription.category = newCategory\n          }\n        }\n      })\n    })\n\n    tx.request(async () => {\n      await api().subscriptions.batchUpdate({\n        feedIds,\n        category: newCategory,\n        view: newView,\n      })\n    })\n\n    tx.rollback(() => {\n      immerSet((draft) => {\n        for (const [index, feedId] of feedIds.entries()) {\n          const subscription = draft.data[feedId]\n          if (!subscription) continue\n          if (!current[index]) continue\n\n          subscription.view = current[index].view\n          draft.feedIdByView[newView].delete(feedId)\n          draft.feedIdByView[current[index].view].add(feedId)\n\n          if (newCategory) {\n            const currentCategory = current[index].category\n            subscription.category = currentCategory\n          }\n        }\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.patchMany({\n        feedIds,\n        data: {\n          view: newView,\n          category: newCategory,\n        },\n      })\n    })\n\n    await tx.run()\n  }\n\n  async changeListView({ listId, view }: { listId: string; view: FeedViewType }) {\n    const current = get().data[listId]\n    if (!current) {\n      return\n    }\n\n    const currentView = current.view\n    const newView = view\n\n    const tx = createTransaction(current)\n    tx.store(() => {\n      immerSet((draft) => {\n        if (!draft.data[listId]) {\n          return\n        }\n\n        draft.data[listId].view = newView\n        draft.listIdByView[currentView].delete(listId)\n        draft.listIdByView[newView].add(listId)\n      })\n    })\n\n    tx.request(async () => {\n      await api().subscriptions.update({\n        view,\n        listId,\n      })\n    })\n\n    tx.rollback((current) => {\n      immerSet((draft) => {\n        if (!draft.data[listId]) {\n          return\n        }\n\n        draft.data[listId].view = current.view\n        draft.listIdByView[newView].delete(listId)\n        draft.listIdByView[currentView].add(listId)\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.patch(\n        storeDbMorph.toSubscriptionSchema({\n          ...current,\n          view,\n        }),\n      )\n    })\n\n    await tx.run()\n  }\n\n  async deleteCategory({ category, view }: { category: string; view: FeedViewType }) {\n    const feedIds = getCategoryFeedIds(category, view)\n\n    const tx = createTransaction()\n    tx.store(() => {\n      immerSet((draft) => {\n        for (const feedId of feedIds) {\n          const subscription = draft.data[feedId]\n          if (!subscription) continue\n          subscription.category = null\n        }\n        draft.categories[view].delete(category)\n      })\n    })\n\n    tx.request(async () => {\n      await api().categories.delete({\n        feedIdList: feedIds,\n        deleteSubscriptions: false,\n      })\n    })\n\n    tx.rollback(() => {\n      immerSet((draft) => {\n        for (const feedId of feedIds) {\n          const subscription = draft.data[feedId]\n          if (!subscription) continue\n          subscription.category = category\n        }\n\n        draft.categories[view].add(category)\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.patchMany({\n        feedIds,\n        data: {\n          category: null,\n        },\n      })\n    })\n\n    await tx.run()\n  }\n\n  async changeCategoryView({\n    category,\n    currentView,\n    newView,\n  }: {\n    category: string\n    currentView: FeedViewType\n    newView: FeedViewType\n  }) {\n    const folderFeedIds = getCategoryFeedIds(category, currentView)\n\n    await this.batchUpdateSubscription({\n      feedIds: folderFeedIds,\n      view: newView,\n    })\n\n    invalidateViews(currentView, newView)\n  }\n\n  async renameCategory({\n    lastCategory,\n    newCategory,\n    view,\n  }: {\n    lastCategory: string\n    newCategory: string\n    view: FeedViewType\n  }) {\n    const feedIds = getCategoryFeedIds(lastCategory, view)\n\n    const tx = createTransaction()\n    tx.store(() => {\n      immerSet((draft) => {\n        for (const id of feedIds) {\n          const subscription = draft.data[id]\n          if (!subscription) continue\n          subscription.category = newCategory\n        }\n        draft.categories[view].add(newCategory)\n        draft.categories[view].delete(lastCategory)\n\n        const lastCategoryOpenState = draft.categoryOpenStateByView[view][lastCategory]\n        if (typeof lastCategoryOpenState === \"boolean\") {\n          draft.categoryOpenStateByView[view][newCategory] = lastCategoryOpenState\n          delete draft.categoryOpenStateByView[view][lastCategory]\n        }\n      })\n    })\n\n    tx.request(async () => {\n      await api().categories.update({\n        feedIdList: feedIds,\n        category: newCategory,\n      })\n    })\n\n    tx.rollback(() => {\n      immerSet((draft) => {\n        for (const id of feedIds) {\n          const subscription = draft.data[id]\n          if (!subscription) continue\n          const defaultCategory = getDefaultCategory(subscription)\n          subscription.category = lastCategory !== defaultCategory ? lastCategory : null\n        }\n        draft.categories[view].delete(newCategory)\n        draft.categories[view].add(lastCategory)\n\n        const lastCategoryOpenState = draft.categoryOpenStateByView[view][newCategory]\n        if (typeof lastCategoryOpenState === \"boolean\") {\n          draft.categoryOpenStateByView[view][lastCategory] = lastCategoryOpenState\n          delete draft.categoryOpenStateByView[view][newCategory]\n        }\n      })\n    })\n\n    tx.persist(() => {\n      return SubscriptionService.patchMany({\n        feedIds,\n        data: {\n          category: newCategory,\n        },\n      })\n    })\n\n    await tx.run()\n  }\n}\n\nexport const subscriptionActions = new SubscriptionActions()\nexport const subscriptionSyncService = new SubscriptionSyncService()\n"
  },
  {
    "path": "packages/internal/store/src/modules/subscription/types.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport type { SubscriptionSchema } from \"@follow/database/schemas/types\"\n\ntype Nullable<T> = T | null | undefined\n\nexport interface SubscriptionForm {\n  url: string | undefined\n  view: FeedViewType\n  category: Nullable<string>\n  isPrivate: boolean\n  hideFromTimeline: Nullable<boolean>\n  title: Nullable<string>\n  feedId: Nullable<string>\n  listId: string | undefined\n}\n\nexport type SubscriptionModel = Omit<SubscriptionSchema, \"id\">\n"
  },
  {
    "path": "packages/internal/store/src/modules/subscription/utils.ts",
    "content": "import { isOnboardingFeedUrl } from \"@follow/store/constants/onboarding\"\nimport { capitalizeFirstLetter, parseUrl } from \"@follow/utils/utils\"\n\nimport { getFeedById } from \"../feed/getter\"\nimport type { SubscriptionModel } from \"./types\"\n\nexport const getInboxStoreId = (inboxId: string) => `inbox/${inboxId}`\n\nexport const getSubscriptionStoreId = (subscription: SubscriptionModel) => {\n  if (subscription.feedId) return subscription.feedId\n  if (subscription.listId) return subscription.listId\n  if (subscription.inboxId) return getInboxStoreId(subscription.inboxId)\n  throw new Error(\"Invalid subscription\")\n}\n\nexport const getSubscriptionDBId = (subscription: SubscriptionModel) => {\n  if (subscription.feedId && subscription.type === \"feed\") {\n    return `${subscription.type}/${subscription.feedId}`\n  }\n  if (subscription.listId && subscription.type === \"list\") {\n    return `${subscription.type}/${subscription.listId}`\n  }\n  if (subscription.inboxId && subscription.type === \"inbox\") {\n    return `${subscription.type}/${subscription.inboxId}`\n  }\n  throw new Error(\"Invalid subscription\")\n}\n\nexport const getDefaultCategory = (subscription?: SubscriptionModel) => {\n  if (!subscription) return null\n  const { feedId } = subscription\n  if (!feedId) return null\n\n  const feed = getFeedById(feedId)\n  if (!feed) return null\n  const isOnboardingFeed = isOnboardingFeedUrl(feed.url)\n  if (isOnboardingFeed) return \"Onboarding Feeds\"\n  const siteUrl = getFeedById(feedId)?.siteUrl\n  if (!siteUrl) return null\n  const parsed = parseUrl(siteUrl)\n  return parsed?.domain ? capitalizeFirstLetter(parsed.domain) : siteUrl\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/summary/enum.ts",
    "content": "export enum SummaryGeneratingStatus {\n  Pending = \"pending\",\n  Success = \"success\",\n  Error = \"error\",\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/summary/getters.ts",
    "content": "import type { SupportedActionLanguage } from \"@follow/shared/language\"\n\nimport { useSummaryStore } from \"./store\"\n\nexport const getSummary = (entryId: string, language: SupportedActionLanguage) => {\n  return useSummaryStore.getState().data[entryId]?.[language]\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/summary/hooks.ts",
    "content": "import type { SupportedActionLanguage } from \"@follow/shared\"\nimport { useQuery } from \"@tanstack/react-query\"\n\nimport type { GeneralQueryOptions } from \"../../types\"\nimport { summarySyncService, useSummaryStore } from \"./store\"\nimport { getGenerateSummaryStatusId } from \"./utils\"\n\nexport const useSummary = (entryId: string, language: SupportedActionLanguage) => {\n  const summary = useSummaryStore((state) => state.data[entryId]?.[language])\n  return summary\n}\n\nexport const useSummaryStatus = ({\n  entryId,\n  actionLanguage,\n  target,\n}: {\n  entryId: string\n  actionLanguage: SupportedActionLanguage\n  target: \"content\" | \"readabilityContent\"\n}) => {\n  const status = useSummaryStore(\n    (state) => state.generatingStatus[getGenerateSummaryStatusId(entryId, actionLanguage, target)],\n  )\n  return status\n}\n\nexport function usePrefetchSummary({\n  entryId,\n  target,\n  actionLanguage,\n  ...options\n}: {\n  entryId: string\n  target: \"content\" | \"readabilityContent\"\n  actionLanguage: SupportedActionLanguage\n} & GeneralQueryOptions) {\n  return useQuery({\n    queryKey: [\"summary\", entryId, target, actionLanguage],\n    queryFn: () => {\n      return summarySyncService.generateSummary({ entryId, target, actionLanguage })\n    },\n    enabled: options?.enabled,\n    staleTime: 1000 * 60 * 60 * 24,\n  })\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/summary/store.ts",
    "content": "import type { SummarySchema } from \"@follow/database/schemas/types\"\nimport { summaryService } from \"@follow/database/services/summary\"\nimport type { SupportedActionLanguage } from \"@follow/shared\"\nimport { toApiSupportedActionLanguage } from \"@follow/shared\"\nimport { FollowAPIError } from \"@follow-app/client-sdk\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { getEntry } from \"../entry/getter\"\nimport { SummaryGeneratingStatus } from \"./enum\"\nimport type { StatusID } from \"./utils\"\nimport { getGenerateSummaryStatusId } from \"./utils\"\n\ntype SummaryModel = Omit<SummarySchema, \"createdAt\">\n\ninterface SummaryData {\n  summary: string\n  readabilitySummary: string | null\n  lastAccessed: number\n}\n\ninterface SummaryState {\n  /**\n   * Key: entryId\n   * Value: language -> SummaryData\n   */\n  data: Record<string, Partial<Record<SupportedActionLanguage, SummaryData>>>\n\n  generatingStatus: Record<StatusID, SummaryGeneratingStatus>\n}\nconst emptyDataSet: Record<string, Partial<Record<SupportedActionLanguage, SummaryData>>> = {}\n\nexport const useSummaryStore = createZustandStore<SummaryState>(\"summary\")(() => ({\n  data: emptyDataSet,\n  generatingStatus: {},\n}))\n\nconst get = useSummaryStore.getState\nconst set = useSummaryStore.setState\nconst immerSet = createImmerSetter(useSummaryStore)\nclass SummaryActions implements Resetable, Hydratable {\n  async hydrate() {\n    const summaries = await summaryService.getAllSummaries()\n    this.upsertManyInSession(summaries)\n  }\n\n  upsertManyInSession(summaries: SummaryModel[]) {\n    const now = Date.now()\n    immerSet((state) => {\n      summaries.forEach((summary) => {\n        if (!summary.language) return\n\n        if (!state.data[summary.entryId]) {\n          state.data[summary.entryId] = {}\n        }\n        if (!state.data[summary.entryId]![summary.language]) {\n          state.data[summary.entryId]![summary.language] = {\n            summary: \"\",\n            readabilitySummary: null,\n            lastAccessed: now,\n          }\n        }\n\n        state.data[summary.entryId]![summary.language] = {\n          summary: summary.summary || state.data[summary.entryId]![summary.language]!.summary || \"\",\n          readabilitySummary:\n            summary.readabilitySummary ||\n            state.data[summary.entryId]![summary.language]!.readabilitySummary ||\n            null,\n          lastAccessed: now,\n        }\n      })\n    })\n\n    this.batchClean()\n  }\n\n  async upsertMany(summaries: SummaryModel[]) {\n    this.upsertManyInSession(summaries)\n\n    for (const summary of summaries) {\n      summaryService.insertSummary(summary)\n    }\n  }\n\n  getSummary(entryId: string, language: SupportedActionLanguage) {\n    const state = get()\n    const summary = state.data[entryId]?.[language]\n\n    if (summary) {\n      immerSet((state) => {\n        if (state.data[entryId]) {\n          state.data[entryId]![language]!.lastAccessed = Date.now()\n        }\n      })\n    }\n\n    return summary\n  }\n\n  private batchClean() {\n    const state = get()\n    const entries = Object.entries(state.data)\n      .map(([, data]) => data)\n      .flatMap((data) => Object.entries(data))\n\n    if (entries.length <= 10) return\n\n    const sortedEntries = entries.sort(\n      ([, a], [, b]) => (a?.lastAccessed || 0) - (b?.lastAccessed || 0),\n    )\n\n    const entriesToRemove = sortedEntries.slice(0, entries.length - 10)\n\n    immerSet((state) => {\n      entriesToRemove.forEach(([entryId]) => {\n        delete state.data[entryId]\n      })\n    })\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set({\n        data: emptyDataSet,\n        generatingStatus: {},\n      })\n    })\n    tx.persist(() => {\n      summaryService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nexport const summaryActions = new SummaryActions()\n\nclass SummarySyncService {\n  private pendingPromises: Record<StatusID, Promise<string>> = {}\n\n  async generateSummary({\n    entryId,\n    target,\n    actionLanguage,\n  }: {\n    entryId: string\n    target: \"content\" | \"readabilityContent\"\n    actionLanguage: SupportedActionLanguage\n  }): Promise<string | null> {\n    const entry = getEntry(entryId)\n    if (!entry) return null\n\n    const state = get()\n    const existing =\n      state.data[entryId]?.[actionLanguage]?.[\n        target === \"content\" ? \"summary\" : \"readabilitySummary\"\n      ]\n    if (existing) {\n      return existing\n    }\n\n    const statusID = getGenerateSummaryStatusId(entryId, actionLanguage, target)\n    if (state.generatingStatus[statusID] === SummaryGeneratingStatus.Pending)\n      return this.pendingPromises[statusID] || null\n\n    immerSet((state) => {\n      state.generatingStatus[statusID] = SummaryGeneratingStatus.Pending\n    })\n\n    // Use Our AI to generate summary\n    const pendingPromise = api()\n      .ai.summary({\n        id: entryId,\n        language: toApiSupportedActionLanguage(actionLanguage),\n        target,\n      })\n      .then((summary) => {\n        if (!summary.data) {\n          throw new FollowAPIError(\"AI summary limit exceeded. Please try again later.\", 402)\n        }\n\n        immerSet((state) => {\n          if (!state.data[entryId]) {\n            state.data[entryId] = {}\n          }\n\n          state.data[entryId][actionLanguage] = {\n            summary:\n              target === \"content\"\n                ? summary.data || \"\"\n                : state.data[entryId]?.[actionLanguage]?.summary || \"\",\n            readabilitySummary:\n              target === \"readabilityContent\"\n                ? summary.data || \"\"\n                : state.data[entryId]?.[actionLanguage]?.readabilitySummary || null,\n            lastAccessed: Date.now(),\n          }\n          state.generatingStatus[statusID] = SummaryGeneratingStatus.Success\n        })\n\n        return summary.data || \"\"\n      })\n      .catch((error) => {\n        immerSet((state) => {\n          state.generatingStatus[statusID] = SummaryGeneratingStatus.Error\n        })\n\n        throw error\n      })\n      .finally(() => {\n        delete this.pendingPromises[statusID]\n      })\n\n    this.pendingPromises[statusID] = pendingPromise\n    const summary = await pendingPromise\n\n    if (summary) {\n      summaryActions.upsertMany([\n        {\n          entryId,\n          summary: target === \"content\" ? summary : \"\",\n          language: actionLanguage ?? null,\n          readabilitySummary: target === \"readabilityContent\" ? summary : null,\n        },\n      ])\n    }\n\n    return summary\n  }\n}\n\nexport const summarySyncService = new SummarySyncService()\n"
  },
  {
    "path": "packages/internal/store/src/modules/summary/utils.ts",
    "content": "import type { SupportedActionLanguage } from \"@follow/shared/language\"\n\nexport function getGenerateSummaryStatusId(\n  entryId: string,\n  actionLanguage: SupportedActionLanguage,\n  target: \"content\" | \"readabilityContent\",\n): StatusID {\n  return `${entryId}-${actionLanguage}-${target}` as StatusID\n}\n\nexport type StatusID = `${string}-${string}-${string}`\n"
  },
  {
    "path": "packages/internal/store/src/modules/translation/hooks.ts",
    "content": "import type { SupportedActionLanguage } from \"@follow/shared\"\nimport type { SupportedLanguages } from \"@follow-app/client-sdk\"\nimport { useQueries, useQueryClient } from \"@tanstack/react-query\"\nimport { useCallback, useEffect } from \"react\"\n\nimport { useEntry, useEntryList } from \"../entry/hooks\"\nimport type { EntryModel } from \"../entry/types\"\nimport { useIsLoggedIn } from \"../user/hooks\"\nimport { translationSyncService, useTranslationStore } from \"./store\"\nimport type { TranslationMode } from \"./types\"\n\nlet lastTranslationMode: TranslationMode | null = null\n\nexport const usePrefetchEntryTranslation = ({\n  entryIds,\n  withContent,\n  target = \"content\",\n  enabled,\n  language,\n  mode,\n}: {\n  entryIds: string[]\n  withContent?: boolean\n  target?: \"content\" | \"readabilityContent\"\n  enabled: boolean\n  language: SupportedActionLanguage\n  mode?: TranslationMode\n}) => {\n  const translationMode = mode ?? \"bilingual\"\n  const queryClient = useQueryClient()\n  const entryList = (useEntryList(entryIds)?.filter(\n    (entry) => entry !== null && (enabled || !!entry?.settings?.translation),\n  ) || []) as EntryModel[]\n\n  useEffect(() => {\n    if (lastTranslationMode === null) {\n      lastTranslationMode = translationMode\n      return\n    }\n\n    if (lastTranslationMode === translationMode) return\n\n    lastTranslationMode = translationMode\n    void queryClient.invalidateQueries({\n      predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === \"translation\",\n    })\n  }, [queryClient, translationMode])\n\n  const isLoggedIn = useIsLoggedIn()\n\n  return useQueries({\n    queries: isLoggedIn\n      ? entryList.map((entry) => {\n          const entryId = entry.id\n          const targetContent =\n            target === \"readabilityContent\" ? entry.readabilityContent : entry.content\n          const finalWithContent = withContent && !!targetContent\n\n          return {\n            queryKey: [\"translation\", entryId, language, finalWithContent, target, translationMode],\n            queryFn: () =>\n              translationSyncService.generateTranslation({\n                entryId,\n                language,\n                withContent: finalWithContent,\n                target,\n                mode: translationMode,\n              }),\n          }\n        })\n      : [],\n  })\n}\n\nexport const useEntryTranslation = ({\n  entryId,\n  language,\n  enabled,\n}: {\n  entryId: string\n  language: SupportedLanguages\n  enabled: boolean\n}) => {\n  const actionSetting = useEntry(entryId, (state) => state.settings?.translation)\n\n  return useTranslationStore(\n    useCallback(\n      (state) => {\n        if (!enabled && !actionSetting) return\n        return state.data[entryId]?.[language]\n      },\n      [actionSetting, entryId, language, enabled],\n    ),\n  )\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/translation/store.ts",
    "content": "import { UserRole } from \"@follow/constants\"\nimport type { TranslationSchema } from \"@follow/database/schemas/types\"\nimport { TranslationService } from \"@follow/database/services/translation\"\nimport type { SupportedActionLanguage } from \"@follow/shared\"\nimport { toApiSupportedActionLanguage } from \"@follow/shared\"\nimport { checkLanguage } from \"@follow/utils/language\"\nimport { create, indexedResolver, windowScheduler } from \"@yornaath/batshit\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { readNdjsonStream } from \"../../lib/stream\"\nimport { getEntry } from \"../entry/getter\"\nimport { useUserStore } from \"../user/store\"\nimport type { EntryTranslation, TranslationFieldArray, TranslationMode } from \"./types\"\nimport { translationFields } from \"./types\"\n\ntype TranslationModel = Omit<TranslationSchema, \"createdAt\">\ntype TranslationBatchRequest = Parameters<ReturnType<typeof api>[\"ai\"][\"translationBatch\"]>[0]\n\ninterface TranslationState {\n  data: Record<string, Partial<Record<SupportedActionLanguage, EntryTranslation>>>\n}\nconst defaultState: TranslationState = {\n  data: {},\n}\n\nexport const useTranslationStore = createZustandStore<TranslationState>(\"translation\")(\n  () => defaultState,\n)\n\nconst get = useTranslationStore.getState\nconst set = useTranslationStore.setState\nconst immerSet = createImmerSetter(useTranslationStore)\n\nclass TranslationActions implements Hydratable, Resetable {\n  async hydrate() {\n    const translations = await TranslationService.getTranslationToHydrate()\n    translationActions.upsertManyInSession(translations)\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(defaultState)\n    })\n    tx.persist(() => TranslationService.reset())\n\n    await tx.run()\n  }\n\n  upsertManyInSession(translations: TranslationModel[]) {\n    immerSet((state) => {\n      translations.forEach((translation) => {\n        if (!state.data[translation.entryId]) {\n          state.data[translation.entryId] = {}\n        }\n\n        if (!state.data[translation.entryId]![translation.language]) {\n          state.data[translation.entryId]![translation.language] = {\n            title: null,\n            description: null,\n            content: null,\n            readabilityContent: null,\n          }\n        }\n\n        translationFields.forEach((field) => {\n          if (translation[field]) {\n            state.data[translation.entryId]![translation.language]![field] = translation[field]\n          }\n        })\n      })\n    })\n  }\n\n  async upsertMany(translations: TranslationModel[]) {\n    this.upsertManyInSession(translations)\n\n    await Promise.all(\n      translations.map((translation) => TranslationService.insertTranslation(translation)),\n    )\n  }\n\n  getTranslation(entryId: string, language: SupportedActionLanguage) {\n    return get().data[entryId]?.[language]\n  }\n}\n\nexport const translationActions = new TranslationActions()\n\nclass TranslationSyncService {\n  private currentMode?: TranslationMode\n\n  private async ensureMode(mode: TranslationMode) {\n    if (!this.currentMode) {\n      this.currentMode = mode\n      return\n    }\n\n    if (this.currentMode === mode) return\n\n    this.currentMode = mode\n    await translationActions.reset()\n  }\n\n  private translationBatcher = create({\n    fetcher: async (keys: string[]) => {\n      // key format: `${entryId}|${language}|${target}|${fields}|${mode}`\n      type KeyParts = {\n        entryId: string\n        language: SupportedActionLanguage\n        target: \"content\" | \"readabilityContent\"\n        fields: string\n        mode: TranslationMode\n      }\n\n      const parseKey = (key: string): KeyParts => {\n        const [entryId, language, target, fields, mode] = key.split(\"|\") as [\n          string,\n          SupportedActionLanguage,\n          \"content\" | \"readabilityContent\",\n          string,\n          TranslationMode | undefined,\n        ]\n        return { entryId, language, target, fields, mode: mode ?? \"bilingual\" }\n      }\n\n      const requests = keys.map(parseKey)\n\n      // Group by language + fields + mode to minimize stream calls\n      const groupKey = (r: KeyParts) => `${r.language}#${r.fields}#${r.mode}`\n      const grouped = new Map<\n        string,\n        {\n          language: SupportedActionLanguage\n          fields: string\n          mode: TranslationMode\n          ids: string[]\n          keyById: Record<string, string>\n        }\n      >()\n\n      for (const r of requests) {\n        const gk = groupKey(r)\n        if (!grouped.has(gk)) {\n          grouped.set(gk, {\n            language: r.language,\n            fields: r.fields,\n            mode: r.mode,\n            ids: [],\n            keyById: {},\n          })\n        }\n        const g = grouped.get(gk)!\n        g.ids.push(r.entryId)\n        g.keyById[r.entryId] = `${r.entryId}|${r.language}|${r.target}|${r.fields}|${r.mode}`\n      }\n\n      const results: Record<string, TranslationModel | null> = {}\n\n      // Execute each group sequentially to keep memory small; groups are already windowed by scheduler\n      for (const [, group] of grouped) {\n        if (this.currentMode && this.currentMode !== group.mode) {\n          for (const id of group.ids) {\n            if (!group.keyById[id]) continue\n            results[group.keyById[id]] = null\n          }\n          continue\n        }\n\n        try {\n          const request: TranslationBatchRequest & { mode?: TranslationMode } = {\n            ids: group.ids,\n            language: toApiSupportedActionLanguage(group.language),\n            fields: group.fields,\n            mode: group.mode,\n          }\n          const response = await api().ai.translationBatch(request)\n\n          await readNdjsonStream<{\n            id: string\n            data: Partial<Record<keyof TranslationModel, string>>\n          }>(response, async (json) => {\n            const key = group.keyById[json.id]\n            if (!key) return\n\n            if (this.currentMode && this.currentMode !== group.mode) return\n\n            const translation: TranslationModel = {\n              entryId: json.id,\n              language: group.language,\n              title: null,\n              description: null,\n              content: null,\n              readabilityContent: null,\n            }\n\n            const { title, description, content, readabilityContent } = json.data || {}\n            if (typeof title === \"string\") translation.title = title\n            if (typeof description === \"string\") translation.description = description\n            if (typeof content === \"string\") translation.content = content\n            if (typeof readabilityContent === \"string\")\n              translation.readabilityContent = readabilityContent\n\n            results[key] = translation\n            await translationActions.upsertMany([translation])\n          })\n        } catch (e) {\n          console.error(\"Translation stream request failed:\", e)\n        }\n      }\n\n      return results\n    },\n    resolver: indexedResolver(),\n    scheduler: windowScheduler(1000),\n  })\n\n  async generateTranslation({\n    entryId,\n    language,\n    withContent,\n    target,\n    mode,\n  }: {\n    entryId: string\n    language: SupportedActionLanguage\n    withContent?: boolean\n    target: \"content\" | \"readabilityContent\"\n    mode?: TranslationMode\n  }) {\n    const userRole = useUserStore.getState().role\n\n    if (userRole === UserRole.Free) return null\n    const translationMode = mode ?? \"bilingual\"\n    await this.ensureMode(translationMode)\n\n    const entry = getEntry(entryId)\n\n    if (!entry) return\n    const translationSession = translationActions.getTranslation(entryId, language)\n\n    const fields = (\n      [\"title\", \"description\", ...(withContent ? [target] : [])] as TranslationFieldArray\n    ).filter((field) => {\n      const content = entry[field]\n      if (!content) return false\n\n      if (translationSession?.[field]) return false\n\n      return !checkLanguage({\n        content,\n        language,\n      })\n    })\n\n    if (fields.length === 0) return null\n\n    const key = `${entryId}|${language}|${target}|${fields.join(\",\")}|${translationMode}`\n    const result = await this.translationBatcher.fetch(key)\n    return result || null\n  }\n}\n\nexport const translationSyncService = new TranslationSyncService()\n"
  },
  {
    "path": "packages/internal/store/src/modules/translation/types.ts",
    "content": "import type { GeneralSettings } from \"@follow/shared/settings/interface\"\n\nexport const translationFields = [\"title\", \"description\", \"content\", \"readabilityContent\"] as const\nexport type TranslationField = (typeof translationFields)[number]\nexport type TranslationFieldArray = Array<TranslationField>\nexport type EntryTranslation = Record<TranslationField, string | null>\nexport type TranslationMode = GeneralSettings[\"translationMode\"]\n"
  },
  {
    "path": "packages/internal/store/src/modules/unread/getters.ts",
    "content": "import { getListFeedIds } from \"../list/getters\"\nimport { unreadCountAllSelector, unreadCountIdSelector, unreadCountIdsSelector } from \"./selectors\"\nimport { useUnreadStore } from \"./store\"\nimport type { FeedIdOrInboxHandle } from \"./types\"\n\nexport const getUnreadById = (id: FeedIdOrInboxHandle) => {\n  const state = useUnreadStore.getState()\n  return unreadCountIdSelector(id)(state)\n}\n\nexport const getUnreadByListId = (listId: string) => {\n  const state = useUnreadStore.getState()\n  const feedIds = getListFeedIds(listId)\n  if (!feedIds) return 0\n  return unreadCountIdsSelector(feedIds)(state)\n}\n\nexport const getUnreadAll = () => {\n  const state = useUnreadStore.getState()\n  return unreadCountAllSelector(state)\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/unread/hooks.ts",
    "content": "import type { FeedViewType } from \"@follow/constants\"\nimport { useMutation, useQuery } from \"@tanstack/react-query\"\nimport { useCallback, useEffect } from \"react\"\n\nimport { getEntry } from \"../entry/getter\"\nimport { useListFeedIds } from \"../list/hooks\"\nimport { useSubscriptionIdsByView } from \"../subscription/hooks\"\nimport { useIsLoggedIn } from \"../user/hooks\"\nimport { unreadCountAllSelector, unreadCountIdSelector, unreadCountIdsSelector } from \"./selectors\"\nimport { unreadSyncService, useUnreadStore } from \"./store\"\n\nexport const usePrefetchUnread = () => {\n  const isLoggedIn = useIsLoggedIn()\n  return useQuery({\n    queryKey: [\"unread\"],\n    queryFn: () => unreadSyncService.resetFromRemote(),\n    staleTime: 5 * 1000 * 60, // 5 minutes\n    enabled: isLoggedIn,\n  })\n}\n\nexport const useSyncUnreadWhenUnMatch = (entryIds: string[]) => {\n  useEffect(() => {\n    const entries = entryIds.map((id) => getEntry(id))\n    const unreadCountMap = entries.reduce(\n      (acc, entry) => {\n        if (entry && entry.feedId && !entry?.read) {\n          acc[entry.feedId] = (acc[entry.feedId] || 0) + 1\n        }\n        return acc\n      },\n      {} as Record<string, number>,\n    )\n\n    const unread = useUnreadStore.getState().data\n\n    const hasUnreadMismatch = Object.keys(unreadCountMap).some(\n      (feedId) =>\n        !unread[feedId] || (unreadCountMap[feedId] && unreadCountMap[feedId] > unread[feedId]),\n    )\n\n    if (hasUnreadMismatch) {\n      unreadSyncService.resetFromRemote()\n    }\n  }, [entryIds.toString()])\n}\n\nexport const useAutoMarkAsRead = (entryId: string, enabled: boolean) => {\n  const { mutate } = useMutation({\n    mutationFn: (entryId: string) => unreadSyncService.markEntryAsRead(entryId),\n  })\n  useEffect(() => {\n    if (enabled) {\n      mutate(entryId)\n    }\n  }, [enabled, entryId, mutate])\n}\n\nexport const useUnreadById = (id: string) => {\n  return useUnreadStore(\n    useCallback(\n      (state) => {\n        return unreadCountIdSelector(id)(state)\n      },\n      [id],\n    ),\n  )\n}\n\nexport const useUnreadByIds = (ids: string[]): number => {\n  return useUnreadStore(\n    useCallback(\n      (state) => {\n        return unreadCountIdsSelector(ids)(state)\n      },\n      [ids?.toString()],\n    ),\n  )\n}\n\nexport const useUnreadAll = (): number => {\n  return useUnreadStore(unreadCountAllSelector)\n}\n\nexport const useUnreadByListId = (listId: string) => {\n  const feedIds = useListFeedIds(listId)\n  return useUnreadByIds(feedIds ?? [])\n}\n\nexport const useUnreadByView = (view: FeedViewType) => {\n  const subscriptionIds = useSubscriptionIdsByView(view)\n  return useUnreadByIds(subscriptionIds)\n}\n\nexport const useSortedIdsByUnread = (ids: string[], isDesc?: boolean) => {\n  return useUnreadStore(\n    useCallback(\n      (state) =>\n        ids.sort((a, b) => {\n          const unreadCompare = (state.data[b] || 0) - (state.data[a] || 0)\n          if (unreadCompare !== 0) {\n            return isDesc ? unreadCompare : -unreadCompare\n          }\n          return a.localeCompare(b)\n        }),\n      [ids.toString(), isDesc],\n    ),\n  )\n}\n\n/**\n * @param categories key: category name, value: array of ids\n * @returns array of tuples [category, ids]\n */\nexport const useSortedCategoriesByUnread = (\n  categories: Record<string, string[]>,\n  isDesc?: boolean,\n) => {\n  return useUnreadStore(\n    useCallback(\n      (state) => {\n        const sortedList = [] as [string, string[]][]\n\n        const folderUnread = {} as Record<string, number>\n        // Calc total unread count for each folder\n        for (const category in categories) {\n          folderUnread[category] = categories[category]!.reduce(\n            (acc, cur) => (state.data[cur] || 0) + acc,\n            0,\n          )\n        }\n\n        // Sort by unread count\n        Object.keys(folderUnread)\n          .sort((a, b) => folderUnread[b]! - folderUnread[a]!)\n          .forEach((key) => {\n            sortedList.push([key, categories[key]!.concat()])\n          })\n\n        if (!isDesc) {\n          sortedList.reverse()\n        }\n        return sortedList\n      },\n      [categories, isDesc],\n    ),\n  )\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/unread/selectors.ts",
    "content": "import type { UnreadState } from \"./types\"\n\nexport const unreadCountIdSelector = (id: string) => {\n  return (state: UnreadState) => state.data[id] ?? 0\n}\n\nexport const unreadCountIdsSelector = (ids: string[]) => {\n  return (state: UnreadState) => {\n    if (!ids || ids.length === 0) return 0\n\n    let count = 0\n    for (const id of ids) {\n      count += state.data[id] ?? 0\n    }\n    return count\n  }\n}\n\nexport const unreadCountAllSelector = (state: UnreadState) => {\n  return Object.values(state.data).reduce((acc, unread) => acc + unread, 0)\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/unread/store.ts",
    "content": "import { FeedViewType } from \"@follow/constants\"\nimport type { UnreadSchema } from \"@follow/database/schemas/types\"\nimport { EntryService } from \"@follow/database/services/entry\"\nimport { UnreadService } from \"@follow/database/services/unread\"\nimport type { MarkAllAsReadRequest } from \"@follow-app/client-sdk\"\nimport { isEqual } from \"es-toolkit\"\n\nimport { api } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { getEntry } from \"../entry/getter\"\nimport { entryActions } from \"../entry/store\"\nimport { setFeedUnreadDirty } from \"../feed/hooks\"\nimport { getListFeedIds } from \"../list/getters\"\nimport { getSubscribedFeedIdAndInboxHandlesByView } from \"../subscription/getter\"\nimport type {\n  FeedIdOrInboxHandle,\n  InsertedBeforeTimeRangeFilter,\n  PublishAtTimeRangeFilter,\n  UnreadState,\n  UnreadStoreModel,\n  UnreadUpdateOptions,\n} from \"./types\"\n\nconst initialUnreadStore: UnreadState = {\n  data: {},\n}\n\nexport const useUnreadStore = createZustandStore<UnreadState>(\"unread\")(() => initialUnreadStore)\nconst get = useUnreadStore.getState\nconst set = useUnreadStore.setState\n\nclass UnreadSyncService {\n  async resetFromRemote() {\n    const res = await api().reads.get({})\n\n    if (isEqual(res.data, get().data)) {\n      return res.data\n    }\n\n    await unreadActions.upsertMany(res.data, { reset: true })\n    return res.data\n  }\n\n  private async updateUnreadStatus({\n    ids,\n    time,\n    request,\n  }: {\n    ids: FeedIdOrInboxHandle[]\n    time?: PublishAtTimeRangeFilter | InsertedBeforeTimeRangeFilter\n    request: () => Promise<UnreadStoreModel>\n  }) {\n    if (!ids || ids.length === 0) return\n\n    const currentUnreadList = ids.map((id) => ({ id, count: get().data[id] || 0 }))\n    const newUnreadListWhenNoTimeFilter = ids.map((id) => ({ id, count: 0 }))\n\n    let affectedEntryIds: string[] = []\n\n    const tx = createTransaction<unknown, unknown, UnreadStoreModel>()\n\n    tx.store(() => {\n      affectedEntryIds = entryActions.markEntryReadStatusInSession({\n        ids,\n        read: true,\n        time,\n      })\n\n      if (!time) {\n        unreadActions.upsertManyInSession(newUnreadListWhenNoTimeFilter)\n      }\n    })\n\n    tx.request(request)\n\n    tx.rollback(async () => {\n      entryActions.markEntryReadStatusInSession({\n        entryIds: affectedEntryIds,\n        read: false,\n      })\n\n      unreadActions.upsertManyInSession(currentUnreadList)\n    })\n\n    tx.persist(async (_s, _c, res) => {\n      if (!time) {\n        await UnreadService.upsertMany(newUnreadListWhenNoTimeFilter)\n      } else {\n        if (res) {\n          await unreadActions.changeBatch(res, \"decrement\")\n        }\n      }\n\n      await EntryService.patchMany({\n        feedIds: ids,\n        entry: { read: true },\n        time,\n      })\n    })\n\n    ids.forEach((id) => {\n      if (id) {\n        setFeedUnreadDirty(id)\n      }\n    })\n\n    await tx.run()\n  }\n\n  async markBatchAsRead({\n    view,\n    filter,\n    time,\n    excludePrivate,\n  }: {\n    view: FeedViewType | undefined\n    filter?: {\n      feedId?: string\n      listId?: string\n      feedIdList?: string[]\n      inboxId?: string\n      insertedBefore?: number\n    } | null\n    time?: PublishAtTimeRangeFilter | InsertedBeforeTimeRangeFilter\n    excludePrivate: boolean\n  }) {\n    const request = async () => {\n      const args: MarkAllAsReadRequest = {\n        view: view === FeedViewType.All ? undefined : view,\n        excludePrivate,\n        ...filter,\n        ...time,\n      }\n      if (view === FeedViewType.All) {\n        delete args.view\n      }\n      const res = await api().reads.markAllAsRead(args)\n\n      return res.data.read\n    }\n\n    if (filter?.feedIdList) {\n      await this.updateUnreadStatus({ ids: filter.feedIdList, time, request })\n    } else if (filter?.feedId) {\n      await this.updateUnreadStatus({ ids: [filter.feedId], time, request })\n    } else if (filter?.listId) {\n      const feedIds = getListFeedIds(filter.listId)\n      if (feedIds && feedIds.length > 0) {\n        await this.updateUnreadStatus({ ids: feedIds, time, request })\n      }\n    } else if (filter?.inboxId) {\n      await this.updateUnreadStatus({ ids: [filter.inboxId], time, request })\n    } else {\n      const feedIdAndInboxHandles = getSubscribedFeedIdAndInboxHandlesByView({\n        view,\n        excludePrivate,\n        excludeHidden: true,\n      })\n      await this.updateUnreadStatus({ ids: feedIdAndInboxHandles, time, request })\n    }\n  }\n\n  async markViewAsRead(view: FeedViewType, excludePrivate: boolean) {\n    await this.markBatchAsRead({\n      view: view === FeedViewType.All ? undefined : view,\n      excludePrivate,\n    })\n  }\n\n  async markFeedAsRead(feedId: string | string[], time?: PublishAtTimeRangeFilter) {\n    const feedIds = Array.isArray(feedId) ? feedId : [feedId]\n\n    await this.markBatchAsRead({\n      view: undefined,\n      excludePrivate: false,\n      filter: {\n        feedIdList: feedIds,\n      },\n      time,\n    })\n  }\n\n  async markListAsRead(listId: string, time?: PublishAtTimeRangeFilter) {\n    await this.markBatchAsRead({\n      view: undefined,\n      excludePrivate: false,\n      filter: {\n        listId,\n      },\n      time,\n    })\n  }\n\n  private async markEntryReadStatus({ entryId, read }: { entryId: string; read: boolean }) {\n    const entry = getEntry(entryId)\n    if (!entry || entry.read === read || (!entry.feedId && !entry.inboxHandle)) return\n\n    const id: FeedIdOrInboxHandle = entry.inboxHandle || entry.feedId || \"\"\n    const isInbox = !!entry.inboxHandle\n\n    const tx = createTransaction()\n    tx.store(() => {\n      entryActions.markEntryReadStatusInSession({ entryIds: [entryId], read })\n      if (read) {\n        unreadActions.removeUnread(id)\n      } else {\n        unreadActions.addUnread(id)\n      }\n    })\n\n    tx.request(async () => {\n      if (read) {\n        await api().reads.markAsRead({ entryIds: [entryId], isInbox })\n      } else {\n        await api().reads.markAsUnread({ entryId, isInbox })\n      }\n    })\n\n    tx.rollback(() => {\n      entryActions.markEntryReadStatusInSession({ entryIds: [entryId], read: !read })\n      if (read) {\n        unreadActions.addUnread(id)\n      } else {\n        unreadActions.removeUnread(id)\n      }\n    })\n\n    tx.persist(() => {\n      return EntryService.patchMany({\n        entry: { read },\n        entryIds: [entryId],\n      })\n    })\n\n    if (entry.feedId) {\n      setFeedUnreadDirty(entry.feedId)\n    }\n    await tx.run()\n  }\n\n  async markEntryAsRead(entryId: string) {\n    return this.markEntryReadStatus({ entryId, read: true })\n  }\n\n  async markEntryAsUnread(entryId: string) {\n    return this.markEntryReadStatus({ entryId, read: false })\n  }\n}\n\nclass UnreadActions implements Hydratable, Resetable {\n  async hydrate() {\n    const unreads = await UnreadService.getUnreadAll()\n    this.upsertManyInSession(unreads)\n  }\n\n  upsertManyInSession(unreads: UnreadSchema[], options?: UnreadUpdateOptions) {\n    const state = useUnreadStore.getState()\n    const nextData = options?.reset ? {} : { ...state.data }\n    for (const unread of unreads) {\n      nextData[unread.id] = unread.count\n    }\n    set({\n      data: nextData,\n    })\n  }\n\n  async upsertMany(unreads: UnreadSchema[] | UnreadStoreModel, options?: UnreadUpdateOptions) {\n    const normalizedUnreads = Array.isArray(unreads)\n      ? unreads\n      : Object.entries(unreads).map(([id, count]) => ({ id, count }))\n\n    const tx = createTransaction()\n    tx.store(() => this.upsertManyInSession(normalizedUnreads, options))\n    tx.persist(() => UnreadService.upsertMany(normalizedUnreads, options))\n    await tx.run()\n  }\n\n  async changeBatch(updates: UnreadStoreModel, type: \"decrement\" | \"increment\") {\n    const state = useUnreadStore.getState()\n    const dataToUpsert = Object.entries(updates).map(([id, count]) => {\n      const currentCount = state.data[id] || 0\n      return {\n        id,\n        count: type === \"increment\" ? currentCount + count : Math.max(0, currentCount - count),\n      }\n    })\n    await this.upsertMany(dataToUpsert)\n  }\n\n  addUnread(id: FeedIdOrInboxHandle, count = 1) {\n    const state = useUnreadStore.getState()\n    const cur = state.data[id] ?? 0\n    if (count <= 0) return cur\n    this.upsertMany([{ id, count: cur + count }])\n    return cur\n  }\n\n  removeUnread(id: FeedIdOrInboxHandle, count = 1) {\n    const state = useUnreadStore.getState()\n    const cur = state.data[id] ?? 0\n    if (count <= 0) return cur\n    this.upsertMany([{ id, count: Math.max(0, cur - count) }])\n    return cur\n  }\n\n  incrementById(id: FeedIdOrInboxHandle, count: number) {\n    return count > 0 ? this.addUnread(id, count) : this.removeUnread(id, -count)\n  }\n\n  async updateById(id: FeedIdOrInboxHandle | undefined | null, count: number) {\n    if (!id) return\n    const state = useUnreadStore.getState()\n    const cur = state.data[id] ?? 0\n    if (cur === count) return\n    await this.upsertMany([{ id, count }])\n  }\n\n  subscribeUnreadCount(fn: (count: number) => void, immediately?: boolean) {\n    const handler = (state: UnreadState): void => {\n      let unread = 0\n      for (const key in state.data) {\n        unread += state.data[key] ?? 0\n      }\n\n      fn(unread)\n    }\n    if (immediately) {\n      handler(get())\n    }\n    return useUnreadStore.subscribe(handler)\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(initialUnreadStore)\n    })\n\n    tx.persist(() => {\n      return UnreadService.reset()\n    })\n\n    await tx.run()\n  }\n}\n\nexport const unreadActions = new UnreadActions()\nexport const unreadSyncService = new UnreadSyncService()\n"
  },
  {
    "path": "packages/internal/store/src/modules/unread/types.ts",
    "content": "export interface PublishAtTimeRangeFilter {\n  startTime: number\n  endTime: number\n}\n\nexport interface InsertedBeforeTimeRangeFilter {\n  insertedBefore: number\n}\n\nexport interface UnreadUpdateOptions {\n  reset?: boolean\n}\n\nexport type FeedIdOrInboxHandle = string\nexport type UnreadStoreModel = Record<FeedIdOrInboxHandle, number>\nexport interface UnreadState {\n  data: UnreadStoreModel\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/unread/utils.ts",
    "content": "// Inbox subscription's feedId is `inbox-${inboxId}`, we need to convert it between unread and entry store.\nexport const INBOX_PREFIX_ID = \"inbox-\"\nexport const getInboxHandleOrFeedIdFromFeedId = (id: string) =>\n  id.startsWith(INBOX_PREFIX_ID) ? id.slice(INBOX_PREFIX_ID.length) : id\nexport const getInboxFeedIdWithPrefix = (id: string) =>\n  id.startsWith(INBOX_PREFIX_ID) ? id : INBOX_PREFIX_ID + id\n"
  },
  {
    "path": "packages/internal/store/src/modules/user/constants.ts",
    "content": "export const isNewUserQueryKey = [\"user\", \"isNewUser\"]\nexport const isOnboardingFinishedStorageKey = \"isOnboardingFinished\"\n"
  },
  {
    "path": "packages/internal/store/src/modules/user/getters.ts",
    "content": "import { useUserStore } from \"./store\"\n\nexport const whoami = () => {\n  return useUserStore.getState().whoami\n}\n\nexport const role = () => {\n  return useUserStore.getState().role\n}\n\nexport const getUserList = (userIds: string[]) => {\n  return userIds.map((id) => useUserStore.getState().users[id]).filter((i) => !!i)\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/user/hooks.ts",
    "content": "import { tracker } from \"@follow/tracker\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { useEffect } from \"react\"\n\nimport { api, queryClient } from \"../../context\"\nimport type { GeneralQueryOptions } from \"../../types\"\nimport { isNewUserQueryKey } from \"./constants\"\nimport type { UserStore } from \"./store\"\nimport { userSyncService, useUserStore } from \"./store\"\n\nexport const whoamiQueryKey = [\"user\", \"whoami\"]\n\nexport const invalidateUserSession = () => {\n  queryClient().invalidateQueries({\n    queryKey: whoamiQueryKey,\n  })\n}\n\nexport const usePrefetchSessionUser = () => {\n  const query = useQuery({\n    queryKey: whoamiQueryKey,\n    queryFn: () => userSyncService.whoami(),\n  })\n\n  useEffect(() => {\n    if (query.data) {\n      const { user } = query.data\n      user && tracker.identify(user)\n    }\n  }, [query.data])\n  return query\n}\n\nexport const usePrefetchUser = (userId: string | undefined) => {\n  const query = useQuery({\n    queryKey: [\"user\", userId],\n    queryFn: () => userSyncService.fetchUser(userId),\n    enabled: !!userId,\n    staleTime: 1000 * 60 * 5,\n  })\n  return query\n}\n\nconst whoamiSelector = (state: UserStore) => state.whoami\nexport const useWhoami = () => {\n  return useUserStore(whoamiSelector)\n}\n\nconst loggedInSelector = (state: UserStore) => !!state.whoami\nconst roleSelector = (state: UserStore) => state.role\nexport const useIsLoggedIn = () => {\n  return useUserStore(loggedInSelector)\n}\nexport const useUserRole = () => {\n  return useUserStore(roleSelector)\n}\n\nconst roleEndAtSelector = (state: UserStore) => state.roleEndAt\nexport const useRoleEndAt = () => {\n  return useUserStore(roleEndAtSelector)\n}\n\nexport const useUserSubscriptionLimit = () => {\n  const rsshubLimit = useUserStore((state) => state.rsshubSubscriptionLimit)\n  const feedLimit = useUserStore((state) => state.feedSubscriptionLimit)\n  return {\n    rsshubLimit,\n    feedLimit,\n  }\n}\n\nexport const useUserById = (userId: string | undefined) => {\n  return useUserStore((state) => (userId ? state.users[userId] : undefined))\n}\n\nexport const useUserList = (userIds: string[]) => {\n  return useUserStore((state) => {\n    return userIds.map((id) => state.users[id]).filter((i) => !!i)\n  })\n}\n\nexport function useIsNewUser(options?: GeneralQueryOptions) {\n  const { data } = useQuery({\n    enabled: options?.enabled,\n    queryKey: isNewUserQueryKey,\n    queryFn: async () => {\n      const subscriptions = await api().subscriptions.get({})\n      return subscriptions.data.length < 5\n    },\n  })\n  return !!data\n}\n"
  },
  {
    "path": "packages/internal/store/src/modules/user/store.ts",
    "content": "import { UserRole } from \"@follow/constants\"\nimport type { UserSchema } from \"@follow/database/schemas/types\"\nimport { UserService } from \"@follow/database/services/user\"\nimport type { AuthUser } from \"@follow-app/client-sdk\"\nimport { create, indexedResolver, windowScheduler } from \"@yornaath/batshit\"\n\nimport { api, authClient } from \"../../context\"\nimport type { Hydratable, Resetable } from \"../../lib/base\"\nimport { createImmerSetter, createTransaction, createZustandStore } from \"../../lib/helper\"\nimport { apiMorph } from \"../../morph/api\"\nimport type { UserProfileEditable } from \"./types\"\n\nexport type UserModel = UserSchema\n\nexport type MeModel = AuthUser & {\n  emailVerified?: boolean\n  twoFactorEnabled?: boolean | null\n}\nexport type UserStore = {\n  users: Record<string, UserModel>\n  whoami: MeModel | null\n  role: UserRole | null\n  roleEndAt: Date | null\n  rsshubSubscriptionLimit?: number | null\n  feedSubscriptionLimit?: number | null\n}\n\nconst defaultState: UserStore = {\n  users: {},\n  whoami: null,\n  role: null,\n  roleEndAt: null,\n}\n\nexport const useUserStore = createZustandStore<UserStore>(\"user\")(() => defaultState)\n\nconst get = useUserStore.getState\nconst set = useUserStore.setState\nconst immerSet = createImmerSetter(useUserStore)\n\nclass UserSyncService {\n  private userBatcher = create({\n    fetcher: async (userIds: string[]) => {\n      const res = await api().profiles.getBatch({ ids: userIds })\n\n      if (res.code === 0) {\n        const { whoami } = get()\n        const usersObject = res.data\n        const usersArray = Object.values(usersObject)\n\n        immerSet((state) => {\n          for (const user of usersArray) {\n            state.users[user.id] = {\n              email: null,\n              isMe: whoami?.id === user.id,\n              ...user,\n            }\n          }\n        })\n        return usersObject\n      }\n      return {}\n    },\n    resolver: indexedResolver(),\n    scheduler: windowScheduler(100),\n  })\n\n  async whoami() {\n    const res = await api()\n      .auth.getSession()\n      .catch((err) => {\n        if (err?.message.includes(\"Failed to fetch\")) {\n          throw err\n        }\n        return null\n      })\n\n    if (!res) {\n      immerSet((state) => {\n        state.whoami = null\n        state.role = null\n        state.roleEndAt = null\n        state.rsshubSubscriptionLimit = null\n        state.feedSubscriptionLimit = null\n      })\n      return null\n    }\n\n    if (!res.user) return res\n    const user = apiMorph.toWhoami(res.user)\n    immerSet((state) => {\n      state.whoami = { ...user, emailVerified: res.user?.emailVerified ?? false }\n      state.role = res.user?.role as UserRole | null\n      if (res.user?.roleEndAt) {\n        state.roleEndAt = new Date(res.user?.roleEndAt)\n      }\n      state.rsshubSubscriptionLimit = res.rsshubSubscriptionLimit ?? null\n      state.feedSubscriptionLimit = res.feedSubscriptionLimit ?? null\n    })\n    userActions.upsertMany([user])\n\n    return res\n  }\n\n  async updateProfile(data: Partial<UserProfileEditable>) {\n    const me = get().whoami\n    if (!me) return\n    const tx = createTransaction(me)\n\n    tx.store(() => {\n      immerSet((state) => {\n        if (!state.whoami) return\n        state.whoami = { ...state.whoami, ...data } as MeModel\n      })\n    })\n\n    tx.request(async () => {\n      await authClient().updateUser({\n        ...data,\n        socialLinks: (data.socialLinks || null) as any,\n      })\n    })\n    tx.persist(async () => {\n      const { whoami } = get()\n      if (!whoami) return\n      const nextUser = {\n        ...whoami,\n        ...data,\n      }\n      userActions.upsertMany([nextUser])\n    })\n    tx.rollback(() => {\n      immerSet((state) => {\n        if (!state.whoami) return\n        state.whoami = me\n      })\n    })\n    await tx.run()\n  }\n\n  async sendVerificationEmail() {\n    const me = get().whoami\n    if (!me?.email) return\n    await authClient().sendVerificationEmail({ email: me.email! })\n  }\n\n  async updateTwoFactor(enabled: boolean, password: string) {\n    const me = get().whoami\n\n    if (!me) throw new Error(\"user not login\")\n\n    const res = enabled\n      ? await authClient().twoFactor.enable({ password })\n      : await authClient().twoFactor.disable({ password })\n\n    if (!res.error) {\n      immerSet((state) => {\n        if (!state.whoami) return\n\n        // If set enable 2FA, we can't check the 2FA status immediately, must to bind the 2FA app and verify code first\n        if (!enabled) state.whoami.twoFactorEnabled = false\n      })\n    }\n\n    return res\n  }\n\n  async updateEmail(email: string) {\n    const oldEmail = get().whoami?.email\n    if (!oldEmail) return\n    const tx = createTransaction(oldEmail)\n    tx.store(() => {\n      immerSet((state) => {\n        if (!state.whoami) return\n        state.whoami = { ...state.whoami, email }\n      })\n    })\n    tx.request(async () => {\n      const { whoami } = get()\n      if (!whoami) return\n      await authClient().changeEmail({ newEmail: email })\n    })\n    tx.rollback(() => {\n      immerSet((state) => {\n        if (!state.whoami) return\n        state.whoami.email = oldEmail\n      })\n    })\n    tx.persist(async () => {\n      const { whoami } = get()\n      if (!whoami) return\n      userActions.upsertMany([{ ...whoami, email }])\n    })\n    await tx.run()\n  }\n\n  async applyInvitationCode(code: string) {\n    const res = await api().invitations.use({ code })\n    if (res.code === 0) {\n      immerSet((state) => {\n        state.role = UserRole.Pro\n      })\n    }\n\n    return res\n  }\n\n  async fetchUser(userId: string | undefined) {\n    if (!userId) return null\n\n    const user = await this.userBatcher.fetch(userId)\n\n    return user || null\n  }\n\n  async fetchUsers(userIds: string[]) {\n    const validUserIds = userIds.filter(Boolean)\n    if (validUserIds.length === 0) return []\n\n    const users = await Promise.all(validUserIds.map((id) => this.userBatcher.fetch(id)))\n    return users.filter(Boolean)\n  }\n}\n\nclass UserActions implements Hydratable, Resetable {\n  async hydrate() {\n    const users = await UserService.getUserAll()\n    userActions.upsertManyInSession(users)\n  }\n\n  async reset() {\n    const tx = createTransaction()\n    tx.store(() => {\n      set(defaultState)\n    })\n    tx.persist(() => UserService.reset())\n    await tx.run()\n  }\n\n  upsertManyInSession(users: UserModel[]) {\n    immerSet((state) => {\n      for (const user of users) {\n        state.users[user.id] = user\n        if (user.isMe) {\n          state.whoami = { ...user, emailVerified: user.emailVerified ?? false } as MeModel\n        }\n      }\n    })\n  }\n\n  updateWhoami(data: Partial<MeModel>) {\n    immerSet((state) => {\n      if (!state.whoami) return\n      state.whoami = { ...state.whoami, ...data }\n    })\n  }\n\n  async upsertMany(users: UserModel[]) {\n    const tx = createTransaction()\n    tx.store(() => this.upsertManyInSession(users))\n    const { whoami } = useUserStore.getState()\n    tx.persist(() =>\n      UserService.upsertMany(users.map((user) => ({ ...user, isMe: whoami?.id === user.id }))),\n    )\n    await tx.run()\n  }\n\n  async removeCurrentUser() {\n    const tx = createTransaction()\n    tx.store(() => {\n      immerSet((state) => {\n        state.whoami = null\n        state.role = null\n        state.roleEndAt = null\n      })\n    })\n    tx.persist(() => UserService.removeCurrentUser())\n    await tx.run()\n  }\n}\n\nexport const userSyncService = new UserSyncService()\nexport const userActions = new UserActions()\n"
  },
  {
    "path": "packages/internal/store/src/modules/user/types.ts",
    "content": "import type { UserSchema } from \"@follow/database/schemas/types\"\n\nexport interface UserProfileEditable {\n  email: string\n  name: string\n  handle: string\n  image: string\n  bio?: string\n  website?: string\n  socialLinks?: UserSchema[\"socialLinks\"]\n}\n"
  },
  {
    "path": "packages/internal/store/src/morph/api.ts",
    "content": "import type { FeedSchema, InboxSchema } from \"@follow/database/schemas/types\"\nimport { getDateISOString } from \"@follow/utils/utils\"\nimport type {\n  AddFeedsResponse,\n  AuthUser,\n  EntryGetByIdResponse,\n  EntryListResponse,\n  EntryWithFeed,\n  ExtractResponseData,\n  FeedViewType,\n  InboxEntryGetResponse,\n  InboxListEntry,\n  InboxListEntryResponse,\n  InboxSubscriptionResponse,\n  ListSchema,\n  ListSubscriptionResponse,\n  SubscriptionWithFeed,\n} from \"@follow-app/client-sdk\"\n\nimport type { CollectionModel } from \"../modules/collection/types\"\nimport type { EntryModel } from \"../modules/entry/types\"\nimport type { FeedModel } from \"../modules/feed/types\"\nimport type { ListModel } from \"../modules/list/types\"\nimport type { SubscriptionModel } from \"../modules/subscription/types\"\nimport type { MeModel } from \"../modules/user/store\"\n\nclass APIMorph {\n  toList(data: ListSchema): ListModel {\n    return {\n      id: data.id,\n      title: data.title!,\n      userId: (\"ownerUserId\" in data && data.ownerUserId ? data.ownerUserId : data.owner?.id)!,\n      description: data.description!,\n      view: data.view,\n      image: data.image!,\n      ownerUserId: (\"ownerUserId\" in data && data.ownerUserId ? data.ownerUserId : data.owner?.id)!,\n      feedIds: (data.feedIds ?? []) as string[],\n      fee: (data.fee ?? 0) as number,\n      subscriptionCount:\n        \"subscriptionCount\" in data ? (data.subscriptionCount as number | null) : null,\n      purchaseAmount:\n        \"purchaseAmount\" in data && data.purchaseAmount != null\n          ? String(data.purchaseAmount)\n          : null,\n      type: \"list\",\n    }\n  }\n\n  toEntry(data?: InboxEntryGetResponse[\"data\"] | EntryGetByIdResponse[\"data\"]): EntryModel | null {\n    if (!data) return null\n\n    return {\n      id: data.entries.id,\n      title: data.entries.title,\n      url: data.entries.url,\n      content: data.entries.content,\n      readabilityContent: null,\n      description: data.entries.description,\n      guid: data.entries.guid,\n      author: data.entries.author,\n      authorUrl: data.entries.authorUrl,\n      authorAvatar: data.entries.authorAvatar,\n      insertedAt: new Date(data.entries.insertedAt),\n      publishedAt: new Date(data.entries.publishedAt),\n      media: data.entries.media ?? null,\n      categories: data.entries.categories ?? null,\n      attachments: data.entries.attachments ?? null,\n      extra: data.entries.extra\n        ? {\n            links: data.entries.extra.links ?? undefined,\n            title_keyword: data.entries.extra.title_keyword ?? undefined,\n          }\n        : null,\n      language: data.entries.language,\n      feedId: data.feeds.id,\n      inboxHandle: \"feeds\" in data ? (data.feeds.type === \"inbox\" ? data.feeds.id : null) : null,\n      read: false,\n      sources: null,\n      settings: \"settings\" in data ? data.settings || null : null,\n    }\n  }\n  toSubscription(\n    data: (SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse)[],\n  ) {\n    const subscriptions: SubscriptionModel[] = []\n\n    const collections = {\n      feeds: [],\n      inboxes: [],\n      lists: [],\n    } as {\n      feeds: FeedSchema[]\n      inboxes: InboxSchema[]\n      lists: ListModel[]\n    }\n\n    for (const item of data) {\n      const baseSubscription = {\n        category: item.category!,\n\n        userId: item.userId,\n        view: item.view,\n        isPrivate: item.isPrivate,\n        hideFromTimeline: item.hideFromTimeline,\n        title: item.title,\n        createdAt: item.createdAt,\n      } as SubscriptionModel\n\n      if (\"feeds\" in item) {\n        baseSubscription.feedId = item.feedId\n        baseSubscription.type = \"feed\"\n        const feed = item.feeds\n        collections.feeds.push({\n          description: feed.description!,\n          id: feed.id,\n          errorAt: feed.errorAt!,\n          errorMessage: feed.errorMessage!,\n          image: feed.image!,\n          ownerUserId: feed.ownerUserId!,\n          siteUrl: feed.siteUrl!,\n          title: feed.title!,\n          url: feed.url,\n        })\n      }\n\n      if (\"inboxes\" in item) {\n        baseSubscription.inboxId = item.inboxId\n        baseSubscription.type = \"inbox\"\n        const inbox = item.inboxes\n\n        collections.inboxes.push({\n          id: inbox.id,\n          title: inbox.title,\n          secret: inbox.secret,\n        })\n      }\n\n      if (\"lists\" in item) {\n        baseSubscription.listId = item.listId\n        baseSubscription.type = \"list\"\n        const list = item.lists\n        if (list.owner)\n          collections.lists.push({\n            id: list.id,\n            title: list.title!,\n            userId: list.owner!.id,\n            description: list.description!,\n            view: list.view,\n            image: list.image!,\n            ownerUserId: list.owner.id,\n            feedIds: list.feedIds!,\n            fee: list.fee ?? 0,\n            subscriptionCount: null,\n            purchaseAmount: null,\n            type: \"list\",\n          })\n      }\n\n      subscriptions.push(baseSubscription)\n    }\n    return { subscriptions, collections }\n  }\n\n  toCollections(\n    data: ExtractResponseData<InboxListEntryResponse | EntryListResponse>,\n    view: FeedViewType,\n  ): {\n    collections: CollectionModel[]\n    entryIdsNotInCollections: string[]\n  } {\n    if (!data) return { collections: [], entryIdsNotInCollections: [] }\n\n    const collections: CollectionModel[] = []\n    const entryIdsNotInCollections: string[] = []\n    for (const item of data) {\n      if (!(\"collections\" in item)) {\n        entryIdsNotInCollections.push((item as EntryWithFeed).entries.id)\n        continue\n      }\n      if (item.collections)\n        collections.push({\n          createdAt: getDateISOString(item.collections.createdAt),\n          entryId: item.entries.id,\n          feedId: item.feeds.id,\n          view,\n        })\n    }\n\n    return {\n      collections,\n      entryIdsNotInCollections,\n    }\n  }\n\n  toEntryList(data?: InboxListEntry[] | EntryWithFeed[]): EntryModel[] {\n    const entries: EntryModel[] = []\n    for (const item of data ?? []) {\n      entries.push({\n        id: item.entries.id,\n        title: item.entries.title,\n        url: item.entries.url,\n        content: null,\n        readabilityContent: null,\n        description: item.entries.description,\n        guid: item.entries.guid,\n        author: item.entries.author,\n        authorUrl: item.entries.authorUrl,\n        authorAvatar: item.entries.authorAvatar,\n        insertedAt: new Date(item.entries.insertedAt),\n        publishedAt: new Date(item.entries.publishedAt),\n        media: item.entries.media ?? null,\n        categories: item.entries.categories ?? null,\n        attachments: item.entries.attachments ?? null,\n        extra: item.entries.extra\n          ? {\n              links: item.entries.extra.links ?? undefined,\n              title_keyword: item.entries.extra.title_keyword ?? undefined,\n            }\n          : null,\n        language: item.entries.language,\n        feedId: item.feeds.id,\n        inboxHandle: item.feeds.type === \"inbox\" ? item.feeds.id : null,\n        read: item.read,\n        sources: \"from\" in item ? (item.from ?? null) : null,\n        settings: item.settings ?? null,\n      })\n    }\n    return entries\n  }\n\n  toFeed(data: EntryWithFeed[\"feeds\"]): FeedModel {\n    return {\n      type: \"feed\",\n      id: data.id,\n      title: data.title,\n      url: data.url,\n      image: data.image,\n      description: data.description,\n      ownerUserId: data.ownerUserId,\n      errorAt: data.errorAt,\n      errorMessage: data.errorMessage,\n      siteUrl: data.siteUrl,\n    }\n  }\n\n  toFeedFromAddFeeds(data: AddFeedsResponse[\"data\"][number]): FeedModel {\n    return {\n      type: \"feed\",\n      id: data.id,\n      title: data.title,\n      url: data.url,\n      image: data.image,\n      description: data.description,\n      ownerUserId: data.ownerUserId,\n      errorAt: data.errorAt,\n      errorMessage: data.errorMessage,\n      siteUrl: data.siteUrl,\n    }\n  }\n\n  toWhoami(data: AuthUser): MeModel {\n    return {\n      id: data.id,\n      name: data.name,\n      email: data.email,\n      handle: data.handle,\n      image: data.image,\n      emailVerified: data.emailVerified ?? false,\n      twoFactorEnabled: (data.twoFactorEnabled ?? null) as boolean | null,\n      bio: data.bio,\n      website: data.website,\n      socialLinks: data.socialLinks,\n      createdAt: data.createdAt,\n      updatedAt: data.updatedAt,\n      isAnonymous: data.isAnonymous,\n      suspended: data.suspended,\n      role: data.role,\n      roleEndAt: data.roleEndAt,\n      deleted: data.deleted,\n      stripeCustomerId: data.stripeCustomerId,\n      inactive: data.inactive,\n      lastLoginMethod: data.lastLoginMethod,\n      appleAppAccountToken: data.appleAppAccountToken,\n    }\n  }\n}\nexport const apiMorph = new APIMorph()\n"
  },
  {
    "path": "packages/internal/store/src/morph/db-store.ts",
    "content": "import type { EntrySchema, SubscriptionSchema } from \"@follow/database/schemas/types\"\n\nimport type { EntryModel } from \"../modules/entry/types\"\nimport type { SubscriptionModel } from \"../modules/subscription/types\"\n\nclass DbStoreMorph {\n  toSubscriptionModel(subscription: SubscriptionSchema): SubscriptionModel {\n    return subscription\n  }\n\n  toEntryModel(entry: EntrySchema): EntryModel {\n    return {\n      ...entry,\n    }\n  }\n}\n\nexport const dbStoreMorph = new DbStoreMorph()\n"
  },
  {
    "path": "packages/internal/store/src/morph/store-db.ts",
    "content": "import type { EntrySchema, ListSchema, SubscriptionSchema } from \"@follow/database/schemas/types\"\n\nimport type { EntryModel } from \"../modules/entry/types\"\nimport type { ListModel } from \"../modules/list/types\"\nimport type { SubscriptionModel } from \"../modules/subscription/types\"\n\nclass StoreDbMorph {\n  toListSchema(list: ListModel): ListSchema {\n    return {\n      ...list,\n      feedIds: JSON.stringify(list.feedIds),\n    }\n  }\n  toSubscriptionSchema(subscription: SubscriptionModel): SubscriptionSchema {\n    return {\n      ...subscription,\n      id: buildSubscriptionDbId(subscription),\n    }\n  }\n  toEntrySchema(entry: EntryModel): EntrySchema {\n    return {\n      ...entry,\n    }\n  }\n}\n\nexport const storeDbMorph = new StoreDbMorph()\n\nexport const buildSubscriptionDbId = (subscription: SubscriptionModel) => {\n  if (subscription.feedId) return `${subscription.type}/${subscription.feedId}`\n  if (subscription.listId) return `${subscription.type}/${subscription.listId}`\n  if (subscription.inboxId) return `${subscription.type}/${subscription.inboxId}`\n  throw new Error(\"Invalid subscription\")\n}\n"
  },
  {
    "path": "packages/internal/store/src/reset.ts",
    "content": "import type { Resetable } from \"./lib/base\"\nimport { collectionActions } from \"./modules/collection/store\"\nimport { entryActions } from \"./modules/entry/store\"\nimport { feedActions } from \"./modules/feed/store\"\nimport { imageActions } from \"./modules/image/store\"\nimport { inboxActions } from \"./modules/inbox/store\"\nimport { listActions } from \"./modules/list/store\"\nimport { subscriptionActions } from \"./modules/subscription/store\"\nimport { summaryActions } from \"./modules/summary/store\"\nimport { translationActions } from \"./modules/translation/store\"\nimport { unreadActions } from \"./modules/unread/store\"\nimport { userActions } from \"./modules/user/store\"\n\nconst resets: Resetable[] = [\n  feedActions,\n  subscriptionActions,\n  inboxActions,\n  listActions,\n  unreadActions,\n  userActions,\n  entryActions,\n  collectionActions,\n  summaryActions,\n  translationActions,\n  imageActions,\n]\n\nexport const resetStore = async () => {\n  await Promise.all(resets.map((h) => h.reset()))\n}\n"
  },
  {
    "path": "packages/internal/store/src/types.ts",
    "content": "import type { ModuleAPIs } from \"@follow-app/client-sdk\"\n\nexport type GeneralMutationOptions = {\n  onSuccess?: () => void\n  onError?: (errorMessage: Error) => void\n}\n\nexport type GeneralQueryOptions = {\n  enabled?: boolean\n}\n\nexport type FollowAPI = ModuleAPIs\n"
  },
  {
    "path": "packages/internal/store/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"types\": [\"vite/client\"]\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/internal/tracker/package.json",
    "content": "{\n  \"name\": \"@follow/tracker\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"main\": \"./src/index.ts\",\n  \"peerDependencies\": {\n    \"posthog-js\": \"1.255.0\",\n    \"posthog-react-native\": \"4.34.0\"\n  },\n  \"devDependencies\": {\n    \"@follow-app/client-sdk\": \"catalog:\",\n    \"@follow/configs\": \"workspace:*\",\n    \"@react-native-firebase/analytics\": \"22.2.1\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/adapters/base.ts",
    "content": "export type IdentifyPayload = {\n  id: string\n  name?: string | null\n  email?: string | null\n  image?: string | null\n  handle?: string | null\n}\n\nexport type TrackPayload = {\n  eventName: string\n  properties?: Record<string, unknown>\n}\n\nexport type CaptureExceptionPayload = {\n  error: unknown\n  properties?: Record<string, unknown>\n}\n\nexport interface TrackerAdapter {\n  /**\n   * Initialize the tracker adapter\n   */\n  initialize: () => Promise<void> | void\n\n  /**\n   * Track an event\n   */\n  track: (payload: TrackPayload) => Promise<void> | void\n\n  /**\n   * Capture an exception\n   */\n  captureException?: (payload: CaptureExceptionPayload) => Promise<void> | void\n\n  /**\n   * Identify a user\n   */\n  identify: (payload: IdentifyPayload) => Promise<void> | void\n\n  /**\n   * Set user properties\n   */\n  setUserProperties: (properties: Record<string, unknown>) => Promise<void> | void\n\n  /**\n   * Clear user data\n   */\n  clear: () => Promise<void> | void\n\n  /**\n   * Get the adapter name\n   */\n  getName: () => string\n\n  /**\n   * Check if the adapter is enabled\n   */\n  isEnabled: () => boolean\n\n  /**\n   * Enable or disable the adapter\n   */\n  setEnabled: (enabled: boolean) => void\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/adapters/firebase.ts",
    "content": "import type { FirebaseAnalyticsTypes } from \"@react-native-firebase/analytics\"\n\nimport { TrackerMapper } from \"../enums\"\nimport type { IdentifyPayload, TrackerAdapter, TrackPayload } from \"./base\"\n\nexport interface FirebaseAdapterConfig {\n  instance: Pick<FirebaseAnalyticsTypes.Module, \"logEvent\" | \"setUserId\" | \"setUserProperties\">\n  enabled?: boolean\n}\n\nexport class FirebaseAdapter implements TrackerAdapter {\n  private firebaseInstance: Pick<\n    FirebaseAnalyticsTypes.Module,\n    \"logEvent\" | \"setUserId\" | \"setUserProperties\"\n  >\n  private enabled: boolean\n\n  constructor(config: FirebaseAdapterConfig) {\n    this.firebaseInstance = config.instance\n    this.enabled = config.enabled ?? true\n  }\n\n  initialize(): void {\n    // Firebase is initialized when the instance is passed\n  }\n\n  async track({ eventName, properties }: TrackPayload): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      // Handle special Firebase events based on the original event code\n      const code = (properties as any)?.__code as TrackerMapper\n\n      if (code !== undefined) {\n        await this.handleSpecialEvents(code, properties)\n      } else {\n        await this.firebaseInstance.logEvent(eventName, properties)\n      }\n    } catch (error) {\n      console.error(`[Firebase] Failed to track event \"${eventName}\":`, error)\n    }\n  }\n\n  private async handleSpecialEvents(\n    code: TrackerMapper,\n    properties?: Record<string, unknown>,\n  ): Promise<void> {\n    switch (code) {\n      case TrackerMapper.Identify: {\n        // Identify is handled separately, skip here\n        break\n      }\n      case TrackerMapper.OnBoarding: {\n        if (properties?.step === 0) {\n          await this.firebaseInstance.logEvent(\"tutorial_begin\")\n        } else if (properties?.done) {\n          await this.firebaseInstance.logEvent(\"tutorial_complete\")\n        }\n        break\n      }\n      case TrackerMapper.NavigateEntry: {\n        await this.firebaseInstance.logEvent(\"select_content\", {\n          content_type: \"entry\",\n          item_id: `${properties?.feedId}/${properties?.entryId}`,\n        })\n        break\n      }\n      case TrackerMapper.UserLogin: {\n        await this.firebaseInstance.logEvent(\"login\", {\n          method: properties?.type as string,\n        })\n        break\n      }\n      case TrackerMapper.Register: {\n        await this.firebaseInstance.logEvent(\"sign_up\", {\n          method: properties?.type as string,\n        })\n        break\n      }\n      case TrackerMapper.Subscribe: {\n        let group_id\n        if (properties?.listId) {\n          group_id = `list/${properties.listId}/${properties.view}`\n        } else if (properties?.feedId) {\n          group_id = `feed/${properties.feedId}/${properties.view}`\n        }\n        if (group_id) {\n          await this.firebaseInstance.logEvent(\"join_group\", {\n            group_id,\n          })\n        }\n        break\n      }\n      case TrackerMapper.DailyRewardClaimed: {\n        await this.firebaseInstance.logEvent(\"earn_virtual_currency\", {\n          virtual_currency_name: \"POWER\",\n        })\n        break\n      }\n      default: {\n        // For other events, use the event name directly\n        const eventName = (properties?.__eventName as string) || \"unknown_event\"\n        await this.firebaseInstance.logEvent(eventName, properties)\n      }\n    }\n  }\n\n  async identify(payload: IdentifyPayload): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      await this.firebaseInstance.setUserId(payload.id)\n      await this.firebaseInstance.setUserProperties({\n        email: payload.email ?? null,\n        name: payload.name ?? null,\n        image: payload.image ?? null,\n        handle: payload.handle ?? null,\n      })\n    } catch (error) {\n      console.error(\"[Firebase] Failed to identify user:\", error)\n    }\n  }\n\n  async setUserProperties(properties: Record<string, unknown>): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      // Convert properties to Firebase's expected format\n      const firebaseProperties: Record<string, string | null> = {}\n      for (const [key, value] of Object.entries(properties)) {\n        firebaseProperties[key] = value === null || value === undefined ? null : String(value)\n      }\n      await this.firebaseInstance.setUserProperties(firebaseProperties)\n    } catch (error) {\n      console.error(\"[Firebase] Failed to set user properties:\", error)\n    }\n  }\n\n  async clear(): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      await this.firebaseInstance.setUserId(null)\n      await this.firebaseInstance.setUserProperties({})\n    } catch (error) {\n      console.error(\"[Firebase] Failed to clear user data:\", error)\n    }\n  }\n\n  getName(): string {\n    return \"Firebase\"\n  }\n\n  isEnabled(): boolean {\n    return this.enabled\n  }\n\n  setEnabled(enabled: boolean): void {\n    this.enabled = enabled\n  }\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/adapters/index.ts",
    "content": "export type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from \"./base\"\nexport { FirebaseAdapter, type FirebaseAdapterConfig } from \"./firebase\"\nexport { PostHogAdapter, type PostHogAdapterConfig } from \"./posthog\"\nexport { ProxyAdapter } from \"./proxy\"\n"
  },
  {
    "path": "packages/internal/tracker/src/adapters/posthog.ts",
    "content": "import type { PostHog } from \"posthog-js\"\nimport type PostHogReactNative from \"posthog-react-native\"\n\nimport type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from \"./base\"\n\nexport interface PostHogAdapterConfig {\n  instance: PostHog | PostHogReactNative\n  enabled?: boolean\n}\n\nexport class PostHogAdapter implements TrackerAdapter {\n  private posthogInstance: PostHog | PostHogReactNative\n  private enabled: boolean\n\n  constructor(config: PostHogAdapterConfig) {\n    this.posthogInstance = config.instance\n    this.enabled = config.enabled ?? true\n  }\n\n  initialize(): void {\n    // PostHog is initialized when the instance is passed\n  }\n\n  async track({ eventName, properties }: TrackPayload): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      this.posthogInstance.capture(eventName, properties as any)\n    } catch (error) {\n      console.error(`[PostHog] Failed to track event \"${eventName}\":`, error)\n    }\n  }\n\n  async captureException({ error, properties }: CaptureExceptionPayload): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      this.posthogInstance.captureException(\n        error,\n        properties as Parameters<typeof this.posthogInstance.captureException>[1],\n      )\n    } catch (captureError) {\n      console.error(\"[PostHog] Failed to capture exception:\", captureError)\n    }\n  }\n\n  async identify(payload: IdentifyPayload): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      this.posthogInstance.identify(payload.id, {\n        email: payload.email!,\n        name: payload.name!,\n        avatar: payload.image!,\n        handle: payload.handle!,\n      })\n    } catch (error) {\n      console.error(\"[PostHog] Failed to identify user:\", error)\n    }\n  }\n\n  async setUserProperties(properties: Record<string, unknown>): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      if (\"setPersonProperties\" in this.posthogInstance) {\n        this.posthogInstance.setPersonProperties(properties as any)\n      } else {\n        ;(this.posthogInstance as PostHogReactNative).setPersonPropertiesForFlags(properties as any)\n      }\n    } catch (error) {\n      console.error(\"[PostHog] Failed to set user properties:\", error)\n    }\n  }\n\n  async clear(): Promise<void> {\n    if (!this.isEnabled()) return\n\n    try {\n      this.posthogInstance.reset()\n    } catch (error) {\n      console.error(\"[PostHog] Failed to clear user data:\", error)\n    }\n  }\n\n  getName(): string {\n    return \"PostHog\"\n  }\n\n  isEnabled(): boolean {\n    return this.enabled\n  }\n\n  setEnabled(enabled: boolean): void {\n    this.enabled = enabled\n  }\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/adapters/proxy.ts",
    "content": "import type { TrackerAdapter, TrackPayload } from \"./base\"\n\nexport class ProxyAdapter implements TrackerAdapter {\n  private enabled = false\n  constructor(\n    private config: {\n      enabled: boolean\n      sender: (eventName: string, properties: Record<string, unknown>) => Promise<void>\n    },\n  ) {\n    this.enabled = config.enabled\n  }\n\n  private globalProperties: Record<string, unknown> = {}\n\n  initialize(): void {}\n  clear(): void {\n    this.globalProperties = {}\n  }\n  identify(): void {}\n  setUserProperties(properties: Record<string, unknown>): void {\n    this.globalProperties = {\n      ...this.globalProperties,\n      ...properties,\n    }\n  }\n\n  async track(payload: TrackPayload): Promise<void> {\n    if (!this.isEnabled()) return\n    return this.config.sender(payload.eventName, {\n      ...this.globalProperties,\n      ...payload.properties,\n    })\n  }\n\n  isEnabled(): boolean {\n    return this.enabled\n  }\n\n  setEnabled(enabled: boolean): void {\n    this.enabled = enabled\n  }\n  getName(): string {\n    return \"proxy\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/enums.ts",
    "content": "export enum TrackerMapper {\n  Identify = 1000,\n  UserLogin = 1001,\n  UiRenderInit = 1002,\n  AppInit = 1003,\n  // Biz\n  NavigateEntry = 2000,\n  Integration = 2002,\n  DailyReportModal = 2003,\n  SwitchToMasonry = 2004,\n  WideMode = 2005,\n  EntryContentHeaderImageGalleryClick = 2006,\n  SearchOpen = 2007,\n  QuickAddFeed = 2008,\n  PlayerOpenDuration = 2009,\n  UpdateRestart = 2010,\n  FeedClaimed = 2012,\n  DailyRewardClaimed = 2013,\n  SubscribeModalOpened = 2015,\n  ReviewPromptEligible = 2016,\n  ReviewPromptShown = 2017,\n  ReviewPromptDismissed = 2018,\n  ReviewPromptPositive = 2019,\n  ReviewPromptNegative = 2020,\n  ReviewPromptFeedbackOpened = 2021,\n  ReviewPromptStoreOpened = 2022,\n  ReviewPromptNativeRequested = 2023,\n\n  // https://docs.google.com/spreadsheets/d/1XlUxTxiXWIQDHFYa2eoPBeuosR1t2h8VFIjXEOqmjhY/edit?gid=0#gid=0\n  Register = 3000,\n  OnBoarding = 3001,\n  Subscribe = 3002,\n\n  // AI\n  AIChatMessageSent = 4000,\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/index.ts",
    "content": "import { improvedTrackManager } from \"./track-manager\"\nimport { TrackerPoints } from \"./tracker-points\"\n\nexport const setFirebaseTracker = improvedTrackManager.setFirebaseTracker.bind(improvedTrackManager)\nexport const setPostHogTracker = improvedTrackManager.setPostHogTracker.bind(improvedTrackManager)\nexport const setProxyTracker = improvedTrackManager.setProxyTracker.bind(improvedTrackManager)\n\nexport const tracker = new TrackerPoints()\n\nexport {\n  type CaptureExceptionPayload,\n  FirebaseAdapter,\n  type FirebaseAdapterConfig,\n  type IdentifyPayload,\n  PostHogAdapter,\n  type PostHogAdapterConfig,\n  ProxyAdapter,\n  type TrackerAdapter,\n  type TrackPayload,\n} from \"./adapters\"\nexport { TrackerMapper } from \"./enums\"\nexport { TrackerManager, type TrackerManagerConfig } from \"./manager\"\nexport { improvedTrackManager, trackManager } from \"./track-manager\"\nexport { type AllTrackers, TrackerPoints } from \"./tracker-points\"\n"
  },
  {
    "path": "packages/internal/tracker/src/manager.ts",
    "content": "import type { IdentifyPayload, TrackerAdapter, TrackPayload } from \"./adapters\"\nimport type { TrackerMapper } from \"./enums\"\nimport { CodeToTrackerName } from \"./utils\"\n\nexport interface TrackerManagerConfig {\n  enableBatchProcessing?: boolean\n  batchSize?: number\n  batchTimeout?: number\n  enableErrorRetry?: boolean\n  maxRetries?: number\n}\n\nexport class TrackerManager {\n  private adapters = new Map<string, TrackerAdapter>()\n  private config: TrackerManagerConfig\n  private batchQueue: Array<{ adapter: TrackerAdapter; payload: TrackPayload }> = []\n  private batchTimer?: ReturnType<typeof setTimeout>\n\n  constructor(config: TrackerManagerConfig = {}) {\n    this.config = {\n      enableBatchProcessing: false,\n      batchSize: 10,\n      batchTimeout: 5000,\n      enableErrorRetry: false,\n      maxRetries: 3,\n      ...config,\n    }\n  }\n\n  /**\n   * Add a tracker adapter\n   */\n  addAdapter(adapter: TrackerAdapter): void {\n    if (this.adapters.has(adapter.getName())) {\n      console.warn(`[TrackerManager] Adapter \"${adapter.getName()}\" already exists. Replacing...`)\n    }\n\n    this.adapters.set(adapter.getName(), adapter)\n\n    try {\n      adapter.initialize()\n      console.info(`[TrackerManager] Initialized adapter: ${adapter.getName()}`)\n    } catch (error) {\n      console.error(`[TrackerManager] Failed to initialize adapter \"${adapter.getName()}\":`, error)\n    }\n  }\n\n  /**\n   * Remove a tracker adapter\n   */\n  removeAdapter(name: string): boolean {\n    return this.adapters.delete(name)\n  }\n\n  /**\n   * Get a specific adapter\n   */\n  getAdapter(name: string): TrackerAdapter | undefined {\n    return this.adapters.get(name)\n  }\n\n  /**\n   * Get all enabled adapters\n   */\n  getEnabledAdapters(): TrackerAdapter[] {\n    return Array.from(this.adapters.values()).filter((adapter) => adapter.isEnabled())\n  }\n\n  /**\n   * Track an event across all enabled adapters\n   */\n  async track(code: TrackerMapper, properties?: Record<string, unknown>): Promise<void> {\n    const eventName = CodeToTrackerName(code)\n    // Include both the code and event name in properties for adapter access\n    const enhancedProperties = {\n      ...properties,\n      __code: code,\n      __eventName: eventName,\n    }\n    const payload: TrackPayload = { eventName, properties: enhancedProperties }\n    const enabledAdapters = this.getEnabledAdapters()\n\n    if (enabledAdapters.length === 0) {\n      console.warn(\"[TrackerManager] No enabled adapters found for tracking\")\n      return\n    }\n\n    if (this.config.enableBatchProcessing) {\n      this.addToBatch(enabledAdapters, payload)\n      return\n    }\n\n    await this.executeTrackingForAdapters(enabledAdapters, payload)\n  }\n\n  /**\n   * Capture an exception across all enabled adapters\n   */\n  async captureException(error: unknown, properties?: Record<string, unknown>): Promise<void> {\n    const enabledAdapters = this.getEnabledAdapters()\n\n    if (enabledAdapters.length === 0) {\n      console.warn(\"[TrackerManager] No enabled adapters found for exception capture\")\n      return\n    }\n\n    const promises = enabledAdapters.map(async (adapter) => {\n      if (!adapter.captureException) return\n\n      try {\n        await Promise.resolve(adapter.captureException({ error, properties }))\n      } catch (captureError) {\n        console.error(\n          `[TrackerManager] Failed to capture exception with adapter \"${adapter.getName()}\":`,\n          captureError,\n        )\n      }\n    })\n\n    await Promise.allSettled(promises)\n  }\n\n  /**\n   * Identify a user across all enabled adapters\n   */\n  async identify(payload: IdentifyPayload): Promise<void> {\n    const enabledAdapters = this.getEnabledAdapters()\n\n    if (enabledAdapters.length === 0) {\n      console.warn(\"[TrackerManager] No enabled adapters found for identification\")\n      return\n    }\n\n    const promises = enabledAdapters.map(async (adapter) => {\n      try {\n        await Promise.resolve(adapter.identify(payload))\n      } catch (error) {\n        console.error(\n          `[TrackerManager] Failed to identify user with adapter \"${adapter.getName()}\":`,\n          error,\n        )\n\n        if (this.config.enableErrorRetry) {\n          await this.retryOperation(\n            () => Promise.resolve(adapter.identify(payload)),\n            adapter.getName(),\n          )\n        }\n      }\n    })\n\n    await Promise.allSettled([\n      ...promises,\n      this.appendUserProperties({\n        email: payload.email ?? null,\n        name: payload.name ?? null,\n        image: payload.image ?? null,\n        handle: payload.handle ?? null,\n      }),\n    ])\n  }\n\n  private managedUserProperties: Record<string, unknown> = {}\n  private getUserProperties(): Record<string, unknown> {\n    return this.managedUserProperties\n  }\n  /**\n   * Set user properties across all enabled adapters\n   */\n  async setUserProperties(properties: Record<string, unknown>): Promise<void> {\n    this.managedUserProperties = properties\n\n    const enabledAdapters = this.getEnabledAdapters()\n\n    if (enabledAdapters.length === 0) {\n      console.warn(\"[TrackerManager] No enabled adapters found for setting user properties\")\n      return\n    }\n\n    const promises = enabledAdapters.map(async (adapter) => {\n      try {\n        await Promise.resolve(adapter.setUserProperties(properties))\n      } catch (error) {\n        console.error(\n          `[TrackerManager] Failed to set user properties with adapter \"${adapter.getName()}\":`,\n          error,\n        )\n\n        if (this.config.enableErrorRetry) {\n          await this.retryOperation(\n            () => Promise.resolve(adapter.setUserProperties(properties)),\n            adapter.getName(),\n          )\n        }\n      }\n    })\n\n    await Promise.allSettled(promises)\n  }\n\n  async appendUserProperties(properties: Record<string, unknown>): Promise<void> {\n    const newProperties = {\n      ...this.managedUserProperties,\n      ...properties,\n    }\n    await this.setUserProperties(newProperties)\n  }\n\n  /**\n   * Clear user data across all enabled adapters\n   */\n  async clear(): Promise<void> {\n    const enabledAdapters = this.getEnabledAdapters()\n\n    const promises = enabledAdapters.map(async (adapter) => {\n      try {\n        await Promise.resolve(adapter.clear())\n      } catch (error) {\n        console.error(\n          `[TrackerManager] Failed to clear user data with adapter \"${adapter.getName()}\":`,\n          error,\n        )\n      }\n    })\n\n    await Promise.allSettled([...promises, this.setUserProperties({})])\n  }\n\n  /**\n   * Flush any pending batch operations\n   */\n  async flush(): Promise<void> {\n    if (this.batchTimer) {\n      clearTimeout(this.batchTimer)\n      this.batchTimer = undefined\n    }\n\n    if (this.batchQueue.length > 0) {\n      await this.processBatch()\n    }\n  }\n\n  /**\n   * Get manager statistics\n   */\n  getStats(): {\n    totalAdapters: number\n    enabledAdapters: number\n    adapterNames: string[]\n    queueSize: number\n  } {\n    return {\n      totalAdapters: this.adapters.size,\n      enabledAdapters: this.getEnabledAdapters().length,\n      adapterNames: Array.from(this.adapters.keys()),\n      queueSize: this.batchQueue.length,\n    }\n  }\n\n  private addToBatch(adapters: TrackerAdapter[], payload: TrackPayload): void {\n    adapters.forEach((adapter) => {\n      this.batchQueue.push({ adapter, payload })\n    })\n\n    if (this.batchQueue.length >= this.config.batchSize!) {\n      this.processBatch()\n    } else if (!this.batchTimer) {\n      this.batchTimer = setTimeout(() => {\n        this.processBatch()\n      }, this.config.batchTimeout!)\n    }\n  }\n\n  private async processBatch(): Promise<void> {\n    if (this.batchTimer) {\n      clearTimeout(this.batchTimer)\n      this.batchTimer = undefined\n    }\n\n    const currentBatch = this.batchQueue.splice(0, this.config.batchSize!)\n\n    if (currentBatch.length === 0) return\n\n    const promises = currentBatch.map(({ adapter, payload }) => {\n      return this.executeTrackingForAdapter(adapter, payload)\n    })\n\n    await Promise.allSettled(promises)\n  }\n\n  private async executeTrackingForAdapters(\n    adapters: TrackerAdapter[],\n    payload: TrackPayload,\n  ): Promise<void> {\n    const promises = adapters.map((adapter) => this.executeTrackingForAdapter(adapter, payload))\n    await Promise.allSettled(promises)\n  }\n\n  private async executeTrackingForAdapter(\n    adapter: TrackerAdapter,\n    payload: TrackPayload,\n  ): Promise<void> {\n    try {\n      await Promise.resolve(adapter.track(payload))\n    } catch (error) {\n      console.error(\n        `[TrackerManager] Failed to track event with adapter \"${adapter.getName()}\":`,\n        error,\n      )\n\n      if (this.config.enableErrorRetry) {\n        await this.retryOperation(() => Promise.resolve(adapter.track(payload)), adapter.getName())\n      }\n    }\n  }\n\n  private async retryOperation(operation: () => Promise<void>, adapterName: string): Promise<void> {\n    let attempts = 0\n    const maxRetries = this.config.maxRetries!\n\n    while (attempts < maxRetries) {\n      try {\n        await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempts))) // Exponential backoff\n        await operation()\n        return\n      } catch (error) {\n        attempts++\n        if (attempts >= maxRetries) {\n          console.error(\n            `[TrackerManager] Failed to retry operation for adapter \"${adapterName}\" after ${maxRetries} attempts:`,\n            error,\n          )\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/internal/tracker/src/track-manager.ts",
    "content": "import type { FirebaseAnalyticsTypes } from \"@react-native-firebase/analytics\"\nimport type { PostHog } from \"posthog-js\"\nimport type PostHogReactNative from \"posthog-react-native\"\n\nimport { FirebaseAdapter, PostHogAdapter } from \"./adapters\"\nimport { ProxyAdapter } from \"./adapters/proxy\"\nimport type { TrackerMapper } from \"./enums\"\nimport { TrackerManager } from \"./manager\"\nimport type { Tracker } from \"./types\"\n\nclass TrackManager extends TrackerManager {\n  private trackFns: Tracker[] = []\n\n  constructor() {\n    super({\n      enableBatchProcessing: false,\n      enableErrorRetry: true,\n      maxRetries: 2,\n    })\n  }\n\n  setTrackFn(fn: Tracker) {\n    this.trackFns.push(fn)\n\n    return () => {\n      this.trackFns = this.trackFns.filter((t) => t !== fn)\n    }\n  }\n\n  getTrackFn(): Tracker {\n    if (this.trackFns.length === 0 && this.getEnabledAdapters().length === 0) {\n      console.error(\"[Tracker warn]: Track function not set\")\n    }\n    return (code, properties) => {\n      const legacyPromises = this.trackFns.map((fn) => fn(code, properties))\n      const modernPromise = this.track(code as TrackerMapper, properties)\n      return Promise.all([...legacyPromises, modernPromise])\n    }\n  }\n\n  setFirebaseTracker(\n    tracker: Pick<FirebaseAnalyticsTypes.Module, \"logEvent\" | \"setUserId\" | \"setUserProperties\">,\n  ) {\n    const adapter = new FirebaseAdapter({ instance: tracker })\n    this.addAdapter(adapter)\n  }\n\n  setPostHogTracker(posthog: PostHog | PostHogReactNative) {\n    const adapter = new PostHogAdapter({ instance: posthog })\n    this.addAdapter(adapter)\n  }\n\n  setProxyTracker(config: {\n    enabled: boolean\n    sender: (eventName: string, properties: Record<string, unknown>) => Promise<void>\n  }) {\n    const adapter = new ProxyAdapter({ enabled: config.enabled, sender: config.sender })\n    this.addAdapter(adapter)\n  }\n}\n\nexport const trackManager = new TrackManager()\nexport const improvedTrackManager = trackManager // Alias for backward compatibility\n"
  },
  {
    "path": "packages/internal/tracker/src/tracker-points.ts",
    "content": "import type { AuthUser } from \"@follow-app/client-sdk\"\n\nimport { TrackerMapper } from \"./enums\"\nimport { trackManager } from \"./track-manager\"\n\nexport class TrackerPoints {\n  // App\n  identify(props: AuthUser) {\n    this.manager.identify(props)\n    this.track(TrackerMapper.Identify, {\n      id: props.id,\n      name: props.name,\n      email: props.email,\n      image: props.image,\n      handle: props.handle,\n    })\n  }\n\n  appInit(props: {\n    electron?: boolean\n    rn?: boolean\n    loading_time?: number\n    using_indexed_db?: boolean\n    data_hydrated_time?: number\n    version?: string\n  }) {\n    this.track(TrackerMapper.AppInit, props)\n  }\n\n  userLogin(props: { type: \"email\" | \"social\" }) {\n    this.track(TrackerMapper.UserLogin, props)\n  }\n\n  /**\n   * For desktop UI only\n   */\n  uiRenderInit(spentTime: number) {\n    this.track(TrackerMapper.UiRenderInit, { spent_time: spentTime })\n  }\n\n  navigateEntry(props: { feedId?: string; entryId?: string; timelineId?: string }) {\n    this.track(TrackerMapper.NavigateEntry, props)\n  }\n\n  integration(props: { type: string; event: string }) {\n    this.track(TrackerMapper.Integration, props)\n  }\n\n  switchToMasonry() {\n    this.track(TrackerMapper.SwitchToMasonry)\n  }\n\n  wideMode(props: { mode: \"wide\" | \"normal\" }) {\n    this.track(TrackerMapper.WideMode, props)\n  }\n\n  entryContentHeaderImageGalleryClick(props: { feedId?: string }) {\n    this.track(TrackerMapper.EntryContentHeaderImageGalleryClick, props)\n  }\n  searchOpen() {\n    this.track(TrackerMapper.SearchOpen)\n  }\n\n  quickAddFeed(props: { type: \"url\" | \"search\"; defaultView: number }) {\n    this.track(TrackerMapper.QuickAddFeed, props)\n  }\n  playerOpenDuration(props: {\n    duration: number\n    status?: \"playing\" | \"loading\" | \"paused\"\n    trigger?: \"manual\" | \"beforeunload\"\n  }) {\n    this.track(TrackerMapper.PlayerOpenDuration, props)\n  }\n\n  updateRestart(props: { type: \"app\" | \"renderer\" | \"pwa\" | \"distribution\" }) {\n    this.track(TrackerMapper.UpdateRestart, props)\n  }\n\n  subscribeModalOpened(props: {\n    feedId?: string\n    listId?: string\n    feedUrl?: string\n    isError?: boolean\n  }) {\n    this.track(TrackerMapper.SubscribeModalOpened, props)\n  }\n\n  feedClaimed(props: { feedId: string }) {\n    this.track(TrackerMapper.FeedClaimed, props)\n  }\n\n  dailyRewardClaimed() {\n    this.track(TrackerMapper.DailyRewardClaimed)\n  }\n\n  register(props: { type: \"email\" | \"social\" }) {\n    this.track(TrackerMapper.Register, props)\n  }\n\n  onBoarding(props: { step: number; done: boolean } | { stepV2: string; done: boolean }) {\n    this.track(TrackerMapper.OnBoarding, props)\n  }\n\n  subscribe(props: { feedId?: string; listId?: string; view?: number }) {\n    this.track(TrackerMapper.Subscribe, props)\n  }\n\n  reviewPromptEligible(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n    score?: number\n  }) {\n    this.track(TrackerMapper.ReviewPromptEligible, props)\n  }\n\n  reviewPromptShown(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n    score?: number\n  }) {\n    this.track(TrackerMapper.ReviewPromptShown, props)\n  }\n\n  reviewPromptDismissed(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n  }) {\n    this.track(TrackerMapper.ReviewPromptDismissed, props)\n  }\n\n  reviewPromptPositive(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n  }) {\n    this.track(TrackerMapper.ReviewPromptPositive, props)\n  }\n\n  reviewPromptNegative(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n  }) {\n    this.track(TrackerMapper.ReviewPromptNegative, props)\n  }\n\n  reviewPromptFeedbackOpened(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n  }) {\n    this.track(TrackerMapper.ReviewPromptFeedbackOpened, props)\n  }\n\n  reviewPromptStoreOpened(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n  }) {\n    this.track(TrackerMapper.ReviewPromptStoreOpened, props)\n  }\n\n  reviewPromptNativeRequested(props: {\n    source: \"auto\" | \"manual\"\n    platform: string\n    distribution: string\n    score?: number\n  }) {\n    this.track(TrackerMapper.ReviewPromptNativeRequested, props)\n  }\n\n  aiChatMessageSent() {\n    this.track(TrackerMapper.AIChatMessageSent)\n  }\n\n  private track(code: TrackerMapper, properties?: Record<string, unknown>) {\n    trackManager.getTrackFn()(code, properties)\n  }\n\n  get manager() {\n    return trackManager\n  }\n}\n\nexport type AllTrackers = keyof TrackerPoints\n"
  },
  {
    "path": "packages/internal/tracker/src/types.ts",
    "content": "export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>\n\nexport type IdentifyPayload = {\n  id: string\n  name?: string | null\n  email?: string | null\n  image?: string | null\n  handle?: string | null\n}\n\nexport type Tracker = (code: number, properties?: Record<string, unknown>) => Promise<any>\n"
  },
  {
    "path": "packages/internal/tracker/src/utils.ts",
    "content": "import { TrackerMapper } from \"./enums\"\n\nexport const CodeToTrackerName = (code: number) => {\n  const map = Object.fromEntries(\n    Object.entries(TrackerMapper).map(([key, value]) => [value, key]),\n  ) as Record<number, string>\n  const name = map[code]\n  if (name) {\n    return snakeCase(name)\n  } else {\n    throw new Error(`Tracker name not found for code ${code}`)\n  }\n}\n\nconst snakeCase = (string: string) => {\n  return string.replaceAll(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/^_/, \"\")\n}\n"
  },
  {
    "path": "packages/internal/tracker/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"declaration\": false\n  }\n}\n"
  },
  {
    "path": "packages/internal/types/global.d.ts",
    "content": "declare global {\n  export type Nullable<T> = T | null | undefined\n  type IsLiteralString<T> = T extends string ? (string extends T ? never : T) : never\n\n  type OmitStringType<T> = T extends any[] ? OmitStringType<T[number]> : IsLiteralString<T>\n  type NonUndefined<T> = T extends undefined\n    ? never\n    : T extends object\n      ? { [K in keyof T]: NonUndefined<T[K]> }\n      : T\n\n  type NilValue = null | undefined | false | \"\"\n  type Prettify<T> = {\n    [K in keyof T]: T[K]\n  } & {}\n}\n\nexport {}\n"
  },
  {
    "path": "packages/internal/types/package.json",
    "content": "{\n  \"name\": \"@follow/types\",\n  \"private\": true,\n  \"sideEffects\": false,\n  \"exports\": {\n    \"./global\": \"./global.d.ts\",\n    \"./react\": \"./react-global.d.ts\",\n    \"./vite\": \"./vite-env.d.ts\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/types/react-global.d.ts",
    "content": "import type { FC, PropsWithChildren } from \"react\"\n\ndeclare global {\n  export type Component<P = object> = FC<Prettify<ComponentType & P>>\n\n  export type ComponentWithRef<P = object, Ref = object> = FC<ComponentWithRefType<P, Ref>>\n  export type ComponentWithRefType<P = object, Ref = object> = Prettify<\n    ComponentType<P> & {\n      ref?: React.Ref<Ref>\n    }\n  >\n\n  export type ComponentType<P = object> = {\n    className?: string\n  } & PropsWithChildren &\n    P\n\n  /**\n   * This function is a macro, will replace in the build stage.\n   */\n  export function tw(strings: TemplateStringsArray, ...values: any[]): string\n}\n\ndeclare module \"react\" {\n  export interface AriaAttributes {\n    \"data-testid\"?: string\n    \"data-hide-in-print\"?: boolean\n  }\n}\n\nexport {}\n"
  },
  {
    "path": "packages/internal/types/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  VITE_WEB_URL: string\n  VITE_API_URL: string\n  VITE_SENTRY_DSN: string\n  VITE_FIREBASE_CONFIG: string\n}\n\ninterface ImportMeta {\n  readonly env: ImportMetaEnv\n}\n"
  },
  {
    "path": "packages/internal/utils/package.json",
    "content": "{\n  \"name\": \"@follow/utils\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"author\": \"Folo Team\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/RSSNext\",\n  \"repository\": {\n    \"url\": \"https://github.com/RSSNext/follow\",\n    \"type\": \"git\"\n  },\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": {\n      \"require\": \"./src/index.ts\",\n      \"import\": \"./src/index.ts\",\n      \"types\": \"./src/index.ts\"\n    },\n    \"./*\": {\n      \"require\": \"./src/*.ts\",\n      \"import\": \"./src/*.ts\",\n      \"types\": \"./src/*.ts\"\n    }\n  },\n  \"scripts\": {\n    \"test\": \"vitest\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"peerDependencies\": {\n    \"react-native\": \">=0.79.6\"\n  },\n  \"dependencies\": {\n    \"@follow/shared\": \"workspace:*\",\n    \"@follow/types\": \"workspace:*\",\n    \"@mozilla/readability\": \"0.6.0\",\n    \"chardet\": \"2.1.1\",\n    \"clsx\": \"2.1.1\",\n    \"dompurify\": \"3.3.1\",\n    \"motion\": \"12.34.0\",\n    \"nanoid\": \"5.1.6\",\n    \"path-to-regexp\": \"8.3.0\",\n    \"tailwind-merge\": \"3.4.0\",\n    \"tldts\": \"7.0.23\",\n    \"uniqolor\": \"1.1.1\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\",\n    \"react-native\": \"0.81.5\",\n    \"vite-tsconfig-paths\": \"6.1.1\"\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/attribution.ts",
    "content": "import { getStorageNS } from \"./ns\"\n\nconst ATTRIBUTION_STORAGE_KEY = getStorageNS(\"attribution\")\n\nexport interface AttributionData {\n  utm_source?: string\n  utm_medium?: string\n  utm_campaign?: string\n  utm_term?: string\n  utm_content?: string\n\n  // First seen at timestamp, for recording first attribution\n  first_seen_at?: number\n}\n\n/**\n * Extract UTM parameters from URL search params\n */\nexport function extractUTMParams(searchParams: URLSearchParams): Partial<AttributionData> {\n  const attribution: Partial<AttributionData> = {}\n\n  const utmSource = searchParams.get(\"utm_source\")\n  const utmMedium = searchParams.get(\"utm_medium\")\n  const utmCampaign = searchParams.get(\"utm_campaign\")\n  const utmTerm = searchParams.get(\"utm_term\")\n  const utmContent = searchParams.get(\"utm_content\")\n\n  if (utmSource) attribution.utm_source = utmSource\n  if (utmMedium) attribution.utm_medium = utmMedium\n  if (utmCampaign) attribution.utm_campaign = utmCampaign\n  if (utmTerm) attribution.utm_term = utmTerm\n  if (utmContent) attribution.utm_content = utmContent\n\n  return attribution\n}\n\n/**\n * Get attribution data from storage\n */\nexport function getAttributionData(): AttributionData | null {\n  if (typeof window === \"undefined\") return null\n\n  try {\n    const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)\n    if (!stored) return null\n    return JSON.parse(stored) as AttributionData\n  } catch {\n    return null\n  }\n}\n\n/**\n * Save attribution data to storage (only if not already set, to preserve first attribution)\n */\nexport function saveAttributionData(data: Partial<AttributionData>): AttributionData | null {\n  if (typeof window === \"undefined\") return null\n\n  try {\n    const existing = getAttributionData()\n    const now = Date.now()\n\n    // If we already have attribution data, don't overwrite it (preserve first attribution)\n    if (existing && existing.first_seen_at) {\n      return existing\n    }\n\n    // Merge with existing data, but set first_seen_at if not present\n    const merged: AttributionData = {\n      ...existing,\n      ...data,\n      first_seen_at: existing?.first_seen_at ?? now,\n    }\n\n    localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(merged))\n    return merged\n  } catch {\n    return null\n  }\n}\n\n/**\n * Capture attribution from current URL (browser only)\n */\nexport function captureAttributionFromURL(): AttributionData | null {\n  try {\n    const url = new URL(window.location.href)\n    const utmParams = extractUTMParams(url.searchParams)\n\n    // Only save if we have at least one UTM parameter\n    if (Object.keys(utmParams).length > 0) {\n      return saveAttributionData(utmParams)\n    }\n\n    return getAttributionData()\n  } catch {\n    return getAttributionData()\n  }\n}\n\n/**\n * Capture attribution from a URL string (for deep links, etc.)\n */\nexport function captureAttributionFromURLString(urlString: string): AttributionData | null {\n  if (typeof window === \"undefined\") return null\n\n  try {\n    const url = new URL(urlString)\n    const utmParams = extractUTMParams(url.searchParams)\n\n    // Only save if we have at least one UTM parameter\n    if (Object.keys(utmParams).length > 0) {\n      return saveAttributionData(utmParams)\n    }\n\n    return getAttributionData()\n  } catch {\n    return getAttributionData()\n  }\n}\n\n/**\n * Get attribution data formatted for analytics (user properties)\n */\nexport function getAttributionForAnalytics(): Record<string, unknown> {\n  const attribution = getAttributionData()\n  if (!attribution) return {}\n\n  const result: Record<string, unknown> = {}\n\n  if (attribution.utm_source) result.utm_source = attribution.utm_source\n  if (attribution.utm_medium) result.utm_medium = attribution.utm_medium\n  if (attribution.utm_campaign) result.utm_campaign = attribution.utm_campaign\n  if (attribution.utm_term) result.utm_term = attribution.utm_term\n  if (attribution.utm_content) result.utm_content = attribution.utm_content\n  if (attribution.first_seen_at) result.first_seen_at = attribution.first_seen_at\n\n  // Add a computed channel field for easier filtering\n  if (attribution.utm_source) {\n    result.channel = attribution.utm_source\n  }\n\n  return result\n}\n"
  },
  {
    "path": "packages/internal/utils/src/bind-this.ts",
    "content": "/**\n * 自动绑定类实例的所有方法到正确的 this 上下文\n * 防止在解构赋值时丢失 this 指向\n *\n * @param instance 类实例\n * @param excludeMethods 要排除的方法名数组，默认排除 constructor\n * @returns 绑定了 this 的实例\n *\n * @example\n * ```ts\n * class MyClass {\n *   constructor() {\n *     return autoBindThis(this)\n *   }\n *\n *   myMethod() {\n *     console.log(this)\n *   }\n * }\n *\n * const instance = new MyClass()\n * const { myMethod } = instance // 解构不会丢失 this\n * myMethod() // this 仍然指向 instance\n * ```\n */\nexport function autoBindThis<T extends Record<string, any>>(\n  instance: T,\n  excludeMethods: string[] = [\"constructor\"],\n): T {\n  const prototype = Object.getPrototypeOf(instance)\n  const propertyNames = Object.getOwnPropertyNames(prototype)\n\n  for (const name of propertyNames) {\n    if (excludeMethods.includes(name)) {\n      continue\n    }\n\n    const descriptor = Object.getOwnPropertyDescriptor(prototype, name)\n    if (descriptor && typeof descriptor.value === \"function\") {\n      ;(instance as any)[name] = instance[name].bind(instance)\n    }\n  }\n\n  return instance\n}\n\n/**\n * 创建一个自动绑定 this 的类装饰器\n *\n * @param excludeMethods 要排除的方法名数组\n * @returns 类装饰器\n *\n * @example\n * ```ts\n * @AutoBindThis()\n * class MyClass {\n *   myMethod() {\n *     console.log(this)\n *   }\n * }\n *\n * const instance = new MyClass()\n * const { myMethod } = instance\n * myMethod() // this 仍然指向 instance\n * ```\n */\nexport function AutoBindThis(excludeMethods: string[] = [\"constructor\"]) {\n  return function <T extends new (...args: any[]) => any>(constructor: T) {\n    return class extends constructor {\n      constructor(...args: any[]) {\n        super(...args)\n        autoBindThis(this, excludeMethods)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/chain.ts",
    "content": "import { sleep } from \"./utils\"\n\nexport class Chain {\n  private chain: Promise<void> = Promise.resolve()\n  private abortController = new AbortController()\n\n  next(fn: () => any | Promise<any>) {\n    const { signal } = this.abortController\n    this.chain = this.chain.then(() => {\n      if (signal.aborted) {\n        throw \"Chain aborted\"\n      }\n      return fn() || Promise.resolve()\n    })\n  }\n\n  wait(ms: number): this {\n    this.chain = this.chain.then(() => sleep(ms))\n    return this\n  }\n\n  abort() {\n    this.chain = Promise.resolve()\n    this.abortController.abort()\n    this.abortController = new AbortController()\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/cjk.ts",
    "content": "export const isCJKChar = (char: string): boolean => {\n  // eslint-disable-next-line unicorn/prefer-code-point\n  const code = char.charCodeAt(0)\n  return (\n    (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs\n    (code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ideographs Extension A\n    (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs\n    (code >= 0x3040 && code <= 0x309f) || // Hiragana\n    (code >= 0x30a0 && code <= 0x30ff) // Katakana\n  )\n}\n\nexport const getNameInitials = (name?: string): string => {\n  if (!name) return \"\"\n  const first = name[0]!\n  const second = name[1]\n\n  if (isCJKChar(first)) return first\n  if (second && isCJKChar(second)) return first\n  return name.slice(0, 2)\n}\n"
  },
  {
    "path": "packages/internal/utils/src/color.ts",
    "content": "import uniqolor from \"uniqolor\"\n\nconst getRandomColor = (lightness: [number, number], saturation: [number, number], hue: number) => {\n  const satAccent = Math.floor(Math.random() * (saturation[1] - saturation[0] + 1) + saturation[0])\n  const lightAccent = Math.floor(Math.random() * (lightness[1] - lightness[0] + 1) + lightness[0])\n\n  // Generate the background color by increasing the lightness and decreasing the saturation\n  const satBackground = satAccent > 30 ? satAccent - 30 : 0\n  const lightBackground = lightAccent < 80 ? lightAccent + 20 : 100\n\n  return {\n    accent: `hsl(${hue} ${satAccent}% ${lightAccent}%)`,\n    background: `hsl(${hue} ${satBackground}% ${lightBackground}%)`,\n  }\n}\n\nexport function stringToHue(str: string) {\n  let hash = 0\n  for (let i = 0; i < str.length; i++) {\n    hash = str.codePointAt(i)! + ((hash << 5) - hash)\n  }\n  const hue = hash % 360\n  return hue < 0 ? hue + 360 : hue\n}\n\nconst memoMap = {} as Record<string, ReturnType<typeof getColorScheme>>\nexport const getColorScheme = (\n  hue?: number,\n  memo?: boolean,\n): {\n  light: {\n    accent: string\n    background: string\n  }\n  dark: {\n    accent: string\n    background: string\n  }\n} => {\n  const baseHue = hue ?? Math.floor(Math.random() * 361)\n  if (baseHue && memo) {\n    if (memoMap[baseHue]) {\n      return memoMap[baseHue]\n    }\n    const result = getColorScheme(baseHue)\n    memoMap[baseHue] = result\n    return result\n  }\n  const complementaryHue = (baseHue + 180) % 360\n\n  // For light theme, we limit the lightness between 40 and 70 to avoid too bright colors for accent\n  const lightColors = getRandomColor([40, 70], [70, 90], baseHue)\n\n  // For dark theme, we limit the lightness between 20 and 50 to avoid too dark colors for accent\n  const darkColors = getRandomColor([20, 50], [70, 90], complementaryHue)\n\n  const result = {\n    light: {\n      accent: lightColors.accent,\n      background: lightColors.background,\n    },\n    dark: {\n      accent: darkColors.accent,\n      background: darkColors.background,\n    },\n  }\n  return result\n}\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t\n\nexport const hexToRgb = (hex: string) => {\n  const bigint = Number.parseInt(hex.slice(1), 16)\n  return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]\n}\n\nexport const rgbToHex = (r: number, g: number, b: number) => {\n  return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`\n}\n\nconst adjustColorTowardsTarget = (color: string, targetColor: string, factor: number) => {\n  const [r1, g1, b1] = hexToRgb(color)\n  const [r2, g2, b2] = hexToRgb(targetColor)\n\n  const r = Math.round(lerp(r1!, r2!, factor))\n  const g = Math.round(lerp(g1!, g2!, factor))\n  const b = Math.round(lerp(b1!, b2!, factor))\n\n  return rgbToHex(r, g, b)\n}\n\nexport const getBackgroundGradient = (seed?: string | null | undefined) => {\n  const nextSeed = seed ?? Math.random().toString(36).slice(7)\n\n  const bgAccent = uniqolor(nextSeed, {\n    saturation: [30, 35],\n    lightness: [60, 70],\n  }).color\n\n  const bgAccentLight = uniqolor(nextSeed, {\n    saturation: [30, 35],\n    lightness: [80, 90],\n  }).color\n\n  const bgAccentUltraLight = uniqolor(nextSeed, {\n    saturation: [30, 35],\n    lightness: [95, 96],\n  }).color\n\n  const targetColor = \"#FF5C02\"\n  const factor = 0.3 // Adjust this value to control how close the color gets to the target color\n\n  const adjustedAccent = adjustColorTowardsTarget(bgAccent, targetColor, factor)\n  const adjustedAccentLight = adjustColorTowardsTarget(bgAccentLight, targetColor, factor)\n  const adjustedAccentUltraLight = adjustColorTowardsTarget(bgAccentUltraLight, targetColor, factor)\n\n  return [\n    adjustedAccent,\n    adjustedAccentLight,\n    adjustedAccentUltraLight,\n    bgAccent,\n    bgAccentLight,\n    bgAccentUltraLight,\n  ]\n}\n\nexport function getDominantColor(imageObject: HTMLImageElement) {\n  const canvas = document.createElement(\"canvas\"),\n    ctx = canvas.getContext(\"2d\")!\n\n  canvas.width = 1\n  canvas.height = 1\n\n  // draw the image to one pixel and let the browser find the dominant color\n  ctx.drawImage(imageObject, 0, 0, 1, 1)\n\n  // get pixel color\n  const i = ctx.getImageData(0, 0, 1, 1).data\n\n  return `#${((1 << 24) + (i[0]! << 16) + (i[1]! << 8) + i[2]!).toString(16).slice(1)}`\n}\nexport const getHighestWeightColor = (imageObject: HTMLImageElement): string => {\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = imageObject.width\n  canvas.height = imageObject.height\n  const context = canvas.getContext(\"2d\")\n  if (!context) {\n    return \"#000000\"\n  }\n  context.drawImage(imageObject, 0, 0)\n  const imageData = context.getImageData(0, 0, canvas.width, canvas.height)\n  const pixels = imageData.data\n\n  const shift = 4\n  const colorCount = new Map<string, number>()\n\n  for (let i = 0; i < pixels.length; i += 4) {\n    const r = pixels[i]! >> shift\n    const g = pixels[i + 1]! >> shift\n    const b = pixels[i + 2]! >> shift\n    const colorKey = `${r},${g},${b}`\n    colorCount.set(colorKey, (colorCount.get(colorKey) || 0) + 1)\n  }\n\n  let maxCount = 0\n  let maxColorKey = \"\"\n  for (const [key, count] of colorCount) {\n    if (count > maxCount) {\n      maxCount = count\n      maxColorKey = key\n    }\n  }\n\n  const [targetR, targetG, targetB] = maxColorKey.split(\",\").map(Number)\n  let sumR = 0\n  let sumG = 0\n  let sumB = 0\n  let count = 0\n  for (let i = 0; i < pixels.length; i += 4) {\n    const r = pixels[i]! >> shift\n    const g = pixels[i + 1]! >> shift\n    const b = pixels[i + 2]! >> shift\n    if (r === targetR && g === targetG && b === targetB) {\n      sumR += pixels[i]!\n      sumG += pixels[i + 1]!\n      sumB += pixels[i + 2]!\n      count++\n    }\n  }\n  const avgR = Math.round(sumR / count)\n  const avgG = Math.round(sumG / count)\n  const avgB = Math.round(sumB / count)\n  return rgbToHex(avgR, avgG, avgB)\n}\n\nexport const isHexColor = (color: string) => {\n  return /^#[0-9a-f]{6}$/i.test(color)\n}\n\nexport const isRGBColor = (color: string) => {\n  return /^rgb\\(\\d{1,3},\\s*\\d{1,3},\\s*\\d{1,3}\\)$/.test(color)\n}\nexport const isRGBAColor = (color: string) => {\n  return /^rgba\\(\\d{1,3},\\s*\\d{1,3},\\s*\\d{1,3},\\s*(?:0?\\.\\d+|1(?:\\.0+)?)\\)$/.test(color)\n}\n\nexport const withOpacity = (color: string, opacity: number) => {\n  switch (true) {\n    case isHexColor(color): {\n      // Convert decimal opacity to hex (0-255)\n      const alpha = Math.round(opacity * 255)\n        .toString(16)\n        .padStart(2, \"0\")\n      return `${color}${alpha}`\n    }\n    case isRGBColor(color): {\n      const [r, g, b] = color.match(/\\d+/g)!.map(Number)\n      return `rgba(${r}, ${g}, ${b}, ${opacity})`\n    }\n    case isRGBAColor(color): {\n      const [r, g, b] = color.match(/\\d+/g)!.map(Number)\n      return `rgba(${r}, ${g}, ${b}, ${opacity})`\n    }\n    default: {\n      return color\n    }\n  }\n}\nexport const rgbStringToRgb = (hex: string) => {\n  const [r, g, b, a] = hex.split(\" \").map((s) => Number.parseFloat(s))\n  return `rgba(${r}, ${g}, ${b}, ${a || 1})`\n}\n\nexport const getLuminance = (hexColor: string) => {\n  const rgb = Number.parseInt(hexColor.replace(\"#\", \"\"), 16)\n  const r = (rgb >> 16) & 0xff\n  const g = (rgb >> 8) & 0xff\n  const b = (rgb >> 0) & 0xff\n  return (0.299 * r + 0.587 * g + 0.114 * b) / 255\n}\n\nexport const shadeColor = (color: string, percent: number): string => {\n  const R = Number.parseInt(color.slice(1, 3), 16)\n  const G = Number.parseInt(color.slice(3, 5), 16)\n  const B = Number.parseInt(color.slice(5, 7), 16)\n\n  let newR = Math.round((R * (100 + percent)) / 100)\n  let newG = Math.round((G * (100 + percent)) / 100)\n  let newB = Math.round((B * (100 + percent)) / 100)\n\n  newR = Math.min(newR, 255)\n  newG = Math.min(newG, 255)\n  newB = Math.min(newB, 255)\n\n  const RR = newR.toString(16).length === 1 ? `0${newR.toString(16)}` : newR.toString(16)\n  const GG = newG.toString(16).length === 1 ? `0${newG.toString(16)}` : newG.toString(16)\n  const BB = newB.toString(16).length === 1 ? `0${newB.toString(16)}` : newB.toString(16)\n\n  return `#${RR}${GG}${BB}`\n}\nexport function hexToHslString(hex: string): string {\n  let raw = hex.replace(/^#/, \"\")\n\n  if (raw.length === 3) {\n    raw = raw\n      .split(\"\")\n      .map((ch) => ch + ch)\n      .join(\"\")\n  }\n  if (!/^[0-9a-f]{6}$/i.test(raw)) {\n    throw new Error(`非法 hex 颜色值: ${hex}`)\n  }\n\n  const r = Number.parseInt(raw.slice(0, 2), 16) / 255\n  const g = Number.parseInt(raw.slice(2, 4), 16) / 255\n  const b = Number.parseInt(raw.slice(4, 6), 16) / 255\n\n  const max = Math.max(r, g, b)\n  const min = Math.min(r, g, b)\n  const delta = max - min\n\n  const l = (max + min) / 2\n\n  let s = 0\n  if (delta !== 0) {\n    s = delta / (1 - Math.abs(2 * l - 1))\n  }\n\n  let h = 0\n  if (delta !== 0) {\n    switch (max) {\n      case r: {\n        h = ((g - b) / delta) % 6\n        break\n      }\n      case g: {\n        h = (b - r) / delta + 2\n        break\n      }\n      case b: {\n        h = (r - g) / delta + 4\n        break\n      }\n    }\n    h *= 60\n    if (h < 0) h += 360\n  }\n\n  const hStr = h.toFixed(1)\n  const sStr = `${Math.round(s * 100)}%`\n  const lStr = `${Math.round(l * 100)}%`\n\n  return `${hStr} ${sStr} ${lStr}`\n}\n"
  },
  {
    "path": "packages/internal/utils/src/data-structure/index.ts",
    "content": "export * from \"./set\"\n"
  },
  {
    "path": "packages/internal/utils/src/data-structure/set.ts",
    "content": "export class EnhanceSet<T> extends Set<T> {\n  only(value: T) {\n    return this.size === 1 && super.has(value)\n  }\n\n  clone() {\n    return new EnhanceSet(this)\n  }\n\n  static of<T>(...values: T[]) {\n    return new EnhanceSet(values)\n  }\n\n  override has(...value: T[]): boolean {\n    return value.every((v) => super.has(v))\n  }\n\n  or(...value: T[]): boolean {\n    return value.some((v) => super.has(v))\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/dom.ts",
    "content": "export const stopPropagation = <T extends { stopPropagation: () => any }>(e: T) =>\n  e.stopPropagation()\n\nexport const preventDefault = <T extends { preventDefault: () => any }>(e: T) => e.preventDefault()\n\nexport const nextFrame = (fn: (...args: any[]) => any) => {\n  let timer2: number | null = null\n  const timer1 = requestAnimationFrame(() => {\n    timer2 = requestAnimationFrame(() => {\n      fn()\n    })\n  })\n  return () => {\n    if (timer1) cancelAnimationFrame(timer1)\n    if (timer2) cancelAnimationFrame(timer2)\n  }\n}\nexport const asyncableNextFrame = <T>(fn: (...args: any[]) => Promise<T>, timeout = 0) => {\n  return new Promise((resolve) => {\n    setTimeout(() => {\n      fn().then(resolve)\n    }, timeout)\n  })\n}\n\nexport const getElementTop = (element: HTMLElement) => {\n  let actualTop = element.offsetTop\n  let current = element.offsetParent as HTMLElement\n  while (current !== null) {\n    actualTop += current.offsetTop\n    current = current.offsetParent as HTMLElement\n  }\n  return actualTop\n}\n\nexport const clearSelection = () => window.getSelection()?.removeAllRanges()\n\nexport const findElementInShadowDOM = (selector: string): HTMLElement | null => {\n  const element = document.querySelector(selector)\n  if (element) return element as HTMLElement\n\n  // find in all shadow roots\n  const getAllShadowRoots = (root: Document | Element | ShadowRoot): Element[] => {\n    const hosts = Array.from(root.querySelectorAll(\"*\")).filter((el) => el.shadowRoot)\n\n    return hosts.reduce((acc, host) => {\n      if (host.shadowRoot) {\n        const shadowElement = host.shadowRoot.querySelector(selector)\n        if (shadowElement) acc.push(shadowElement)\n        return [...acc, ...getAllShadowRoots(host.shadowRoot)]\n      }\n      return acc\n    }, [] as Element[])\n  }\n\n  const results = getAllShadowRoots(document)\n  return (results[0] as HTMLElement) || null\n}\n\nexport function composeEventHandlers<E>(\n  originalEventHandler?: ((event: E) => void) | null,\n  ourEventHandler?: ((event: E) => void) | null,\n  { checkForDefaultPrevented = true } = {},\n) {\n  return function handleEvent(event: E) {\n    originalEventHandler?.(event)\n\n    if (checkForDefaultPrevented === false || !(event as unknown as Event).defaultPrevented) {\n      return ourEventHandler?.(event)\n    }\n  }\n}\n\nexport function getNodeXInScroller(node: Element, scroller: Element) {\n  const nodeRect = node.getBoundingClientRect()\n  const scrollerRect = scroller.getBoundingClientRect()\n  return nodeRect.left - scrollerRect.left + scroller.scrollLeft\n}\n/**\n * Determines whether the node is visible in the horizontal scroll container\n */\nexport function isNodeVisibleInScroller(node: Element, scroller: Element) {\n  const nodeRect = node.getBoundingClientRect()\n  const scrollerRect = scroller.getBoundingClientRect()\n\n  const nodeLeft = nodeRect.left - scrollerRect.left + scroller.scrollLeft\n  const nodeRight = nodeLeft + nodeRect.width\n\n  const scrollerLeft = scroller.scrollLeft\n  const scrollerRight = scrollerLeft + scroller.clientWidth\n\n  return nodeRight > scrollerLeft && nodeLeft < scrollerRight\n}\n\nexport const checkIsEditableElement = (element: HTMLElement) => {\n  return (\n    element.isContentEditable ||\n    element.getAttribute(\"contenteditable\") === \"true\" ||\n    (element instanceof HTMLInputElement && element.type === \"text\") ||\n    element instanceof HTMLTextAreaElement\n  )\n}\n\nexport const detectIsEditableElement = (element: HTMLElement) => {\n  return (\n    element.isContentEditable ||\n    element.getAttribute(\"contenteditable\") === \"true\" ||\n    (element instanceof HTMLInputElement && element.type === \"text\") ||\n    element instanceof HTMLTextAreaElement\n  )\n}\n"
  },
  {
    "path": "packages/internal/utils/src/duration.ts",
    "content": "/**\n * format seconds to \"MM:SS\" or \"HH:MM:SS\"\n * @param {number} totalSeconds - 3661\n * @returns {string}            - \"01:01:01\"\n */\nexport function formatDuration(totalSeconds?: number) {\n  if (!totalSeconds || totalSeconds <= 0) {\n    return\n  }\n\n  const hours = Math.floor(totalSeconds / 3600)\n  const minutes = Math.floor((totalSeconds % 3600) / 60)\n  const seconds = Math.floor(totalSeconds % 60)\n\n  const mm = minutes.toString().padStart(2, \"0\")\n  const ss = seconds.toString().padStart(2, \"0\")\n\n  if (hours === 0) {\n    return `${mm}:${ss}` // \"MM:SS\"\n  }\n  const hh = hours.toString().padStart(2, \"0\")\n  return `${hh}:${mm}:${ss}` // \"HH:MM:SS\"\n}\n"
  },
  {
    "path": "packages/internal/utils/src/environment.ts",
    "content": "import { IN_ELECTRON, MODE } from \"@follow/shared/constants\"\nimport { nanoid } from \"nanoid\"\n\nimport { detectBrowser, getOS } from \"./utils\"\n\ndeclare const APP_VERSION: string\nexport const appSessionTraceId = nanoid()\n\nexport const getCurrentEnvironment = () => {\n  const ua = navigator.userAgent\n  const appVersion = APP_VERSION\n  const env = IN_ELECTRON ? \"electron\" : \"web\"\n  const os = getOS()\n  const browser = detectBrowser()\n\n  return [\n    \"### Environment\",\n    \"\",\n    `**App Version**: ${appVersion}`,\n    `**OS**: ${os}`,\n    `**User Agent**: ${ua}`,\n    `**Env**: ${env}`,\n    `**Browser**: ${browser}`,\n    `**Session Trace Id**: \\`${appSessionTraceId}\\``,\n    `**Mode**: ${MODE}`,\n  ]\n}\n"
  },
  {
    "path": "packages/internal/utils/src/event-bus.rn.ts",
    "content": "import { DeviceEventEmitter } from \"react-native\"\n\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface CustomEvent {}\nexport interface EventBusMap extends CustomEvent {}\n\ntype AnyObject = Record<string, any>\nclass EventBusStatic<E extends AnyObject> {\n  dispatch<T extends keyof E>(event: T, data: E[T]): void\n  dispatch<T extends keyof E>(event: T): void\n  dispatch<T extends keyof E>(event: T, data?: E[T]) {\n    DeviceEventEmitter.emit(event as string, data)\n  }\n\n  subscribe<T extends keyof E>(event: T, callback: (data: E[T]) => void) {\n    const subscription = DeviceEventEmitter.addListener(event as string, callback)\n\n    return () => subscription.remove()\n  }\n\n  unsubscribe(_event: string, _callback: (data: any) => void) {\n    // DeviceEventEmitter doesn't have a direct method to remove a specific listener\n    // This is handled by the subscription.remove() returned by subscribe\n    // This method is kept for API compatibility\n  }\n}\n\nexport const EventBus = new EventBusStatic<EventBusMap>()\nexport const createEventBus = <E extends AnyObject>() => new EventBusStatic<E>()\n"
  },
  {
    "path": "packages/internal/utils/src/event-bus.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface CustomEvent {}\nexport interface EventBusMap extends CustomEvent {}\n\nclass EventBusEvent extends Event {\n  static type = \"EventBusEvent\"\n  constructor(\n    public _type: string,\n    public data: any,\n  ) {\n    super(EventBusEvent.type)\n  }\n}\n\ntype IDispatcher<E> = <T extends keyof E>(\n  ...args: E[T] extends never ? [event: T] : [event: T, data: E[T]]\n) => void\ntype AnyObject = Record<string, any>\nclass EventBusStatic<E extends AnyObject> {\n  constructor() {\n    this.dispatch = this.dispatch.bind(this)\n    this.subscribe = this.subscribe.bind(this)\n    this.unsubscribe = this.unsubscribe.bind(this)\n  }\n  dispatch: IDispatcher<E> = <T extends keyof E>(event: T, data?: E[T]) => {\n    window.dispatchEvent(new EventBusEvent(event as string, data))\n  }\n\n  subscribe<T extends keyof E>(event: T, callback: (data: E[T]) => void) {\n    const handler = (e: any) => {\n      if (e instanceof EventBusEvent && e._type === event) {\n        callback(e.data)\n      }\n    }\n    window.addEventListener(EventBusEvent.type, handler)\n\n    return this.unsubscribe.bind(this, event as string, handler)\n  }\n\n  unsubscribe(_event: string, handler: (e: any) => void) {\n    window.removeEventListener(EventBusEvent.type, handler)\n  }\n}\n\nexport const EventBus = new EventBusStatic<EventBusMap>()\nexport const createEventBus = <E extends AnyObject>() => new EventBusStatic<E>()\n"
  },
  {
    "path": "packages/internal/utils/src/headers.ts",
    "content": "import { DEV, MICROSOFT_STORE_BUILD, WEB_BUILD } from \"@follow/shared/constants\"\n\nimport { imageRefererMatches } from \"./img-proxy\"\n\nexport const createBuildSafeHeaders =\n  (webUrl: string, selfRefererMatches: string[]) =>\n  ({ url, headers = {} }: { url: string; headers?: Record<string, string> }) => {\n    // user agent\n    if (headers[\"User-Agent\"]) {\n      headers[\"User-Agent\"] = headers[\"User-Agent\"]\n        .replace(/\\s?Electron\\/[\\d.]+/, \"\")\n        .replace(/\\s?Folo\\/[\\d.a-zA-Z-]+/, \"\")\n    } else {\n      headers[\"User-Agent\"] =\n        \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36\"\n    }\n\n    // referer and origin\n    if (selfRefererMatches.filter((i) => !!i).some((item) => url.startsWith(item))) {\n      headers.Referer = webUrl\n      return headers\n    }\n\n    const refererMatch = imageRefererMatches.find((item) => item.url.test(url))\n    const referer = refererMatch?.referer\n    if (referer) {\n      headers.Referer = referer\n      headers.Origin = referer\n      return headers\n    }\n\n    if (\n      (headers.Referer && headers.Referer !== \"app://folo.is\") ||\n      (headers.Origin && headers.Origin !== \"app://folo.is\")\n    ) {\n      return headers\n    }\n\n    try {\n      if (url) {\n        const urlObj = new URL(url)\n\n        headers.Referer = urlObj.origin\n        headers.Origin = urlObj.origin\n      }\n    } catch (error) {\n      console.warn(`Url parsing error: ${error}, url: ${url}.`)\n    }\n\n    return headers\n  }\n\nconst commonHeaders = {\n  \"Cache-Control\": \"no-store\",\n}\n\nenum DesktopPlatform {\n  Desktop = \"desktop\",\n  DesktopWeb = \"desktop/web\",\n  DesktopMacOS = \"desktop/macos\",\n  DesktopMacOSDMG = \"desktop/macos/dmg\",\n  DesktopMacOSMAS = \"desktop/macos/mas\",\n  DesktopWindowsEXE = \"desktop/windows/exe\",\n  DesktopWindowsMS = \"desktop/windows/ms\",\n  DesktopLinux = \"desktop/linux\",\n}\n\nexport const createDesktopAPIHeaders = ({ version }: { version: string }) => {\n  let platform: DesktopPlatform | null = null\n\n  if (WEB_BUILD) {\n    platform = DesktopPlatform.DesktopWeb\n  } else if (typeof process !== \"undefined\") {\n    switch (process.platform) {\n      case \"darwin\": {\n        if (process.mas) {\n          platform = DesktopPlatform.DesktopMacOSMAS\n        } else {\n          platform = DesktopPlatform.DesktopMacOSDMG\n        }\n        break\n      }\n      case \"win32\": {\n        if (MICROSOFT_STORE_BUILD) {\n          platform = DesktopPlatform.DesktopWindowsMS\n        } else {\n          platform = DesktopPlatform.DesktopWindowsEXE\n        }\n        break\n      }\n      case \"linux\": {\n        platform = DesktopPlatform.DesktopLinux\n        break\n      }\n    }\n  }\n\n  return {\n    ...commonHeaders,\n    ...(platform ? { \"X-App-Platform\": platform } : {}),\n    \"X-App-Name\": \"Folo Web\",\n    \"X-App-Version\": version,\n    ...(DEV ? { \"X-App-Dev\": \"1\" } : {}),\n  }\n}\n\nenum SSRPlatform {\n  SSR = \"ssr\",\n}\n\nexport const createSSRAPIHeaders = ({ version }: { version: string }) => {\n  return {\n    ...commonHeaders,\n    \"X-App-Platform\": SSRPlatform.SSR,\n    \"X-App-Name\": \"Folo SSR\",\n    \"X-App-Version\": version,\n    \"User-Agent\":\n      \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Folo\",\n    ...(DEV ? { \"X-App-Dev\": \"1\" } : {}),\n  }\n}\n\nenum MobilePlatform {\n  MobileIOSiPhone = \"mobile/ios/iphone\",\n  MobileIOSiPad = \"mobile/ios/ipad\",\n  MobileAndroidAPK = \"mobile/android/apk\",\n  MobileAndroidGooglePlay = \"mobile/android/googleplay\",\n}\n\nexport const createMobileAPIHeaders = ({\n  version,\n  rnPlatform,\n  installerPackageName,\n}: {\n  version: string\n  rnPlatform: {\n    OS: \"ios\" | \"android\" | \"windows\" | \"macos\" | \"web\"\n    isPad: boolean\n  }\n  installerPackageName?: string\n}) => {\n  let platform: MobilePlatform | null = null\n\n  if (rnPlatform.OS === \"ios\") {\n    if (rnPlatform.isPad) {\n      platform = MobilePlatform.MobileIOSiPad\n    } else {\n      platform = MobilePlatform.MobileIOSiPhone\n    }\n  } else if (rnPlatform.OS === \"android\") {\n    if (installerPackageName === \"com.android.vending\") {\n      platform = MobilePlatform.MobileAndroidGooglePlay\n    } else {\n      platform = MobilePlatform.MobileAndroidAPK\n    }\n  }\n\n  return {\n    ...commonHeaders,\n    ...(platform ? { \"X-App-Platform\": platform } : {}),\n    \"X-App-Name\": \"Folo Mobile\",\n    \"X-App-Version\": version,\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/html.ts",
    "content": "import type { Element, Parent, Text } from \"hast\"\nimport type { Schema } from \"hast-util-sanitize\"\nimport type { Components } from \"hast-util-to-jsx-runtime\"\nimport { toJsxRuntime } from \"hast-util-to-jsx-runtime\"\nimport { toText } from \"hast-util-to-text\"\nimport { Fragment, jsx, jsxs } from \"react/jsx-runtime\"\nimport rehypeInferDescriptionMeta from \"rehype-infer-description-meta\"\nimport rehypeParse from \"rehype-parse\"\nimport rehypeSanitize, { defaultSchema } from \"rehype-sanitize\"\nimport rehypeStringify from \"rehype-stringify\"\nimport { unified } from \"unified\"\nimport type { Node } from \"unist\"\nimport { visit } from \"unist-util-visit\"\nimport { visitParents } from \"unist-util-visit-parents\"\n\ntype ParseHtmlOptions = {\n  renderInlineStyle?: boolean\n  noMedia?: boolean\n  components?: Components\n  scrollEnabled?: boolean\n}\n\n/**\n * Remove the last <br> element in the tree\n */\nfunction rehypeTrimEndBrElement() {\n  function trim(tree: Parent): void {\n    if (!Array.isArray(tree.children) || tree.children.length === 0) {\n      return\n    }\n\n    for (let i = tree.children.length - 1; i >= 0; i--) {\n      const item = tree.children[i]!\n      if (item.type === \"element\") {\n        if (item.tagName === \"br\") {\n          tree.children.pop()\n          continue\n        } else {\n          trim(item)\n        }\n      }\n      break\n    }\n  }\n  return trim\n}\n\nexport const parseHtml = (content: string, options?: ParseHtmlOptions) => {\n  const { renderInlineStyle = false, noMedia = false, components } = options || {}\n\n  const rehypeSchema: Schema = { ...defaultSchema }\n  rehypeSchema.tagNames = [...rehypeSchema.tagNames!, \"math\"]\n\n  if (noMedia) {\n    rehypeSchema.tagNames = rehypeSchema.tagNames?.filter(\n      (tag) => tag !== \"img\" && tag !== \"picture\",\n    )\n  } else {\n    rehypeSchema.tagNames = [\n      ...rehypeSchema.tagNames!,\n      \"video\",\n      \"iframe\",\n      \"style\",\n      \"figure\",\n      // SVG\n      \"svg\",\n      \"g\",\n      \"ellipse\",\n      \"text\",\n      \"polygon\",\n      \"path\",\n      \"title\",\n      \"rect\",\n      \"line\",\n      \"circle\",\n      \"use\",\n    ]\n    rehypeSchema.attributes = {\n      ...rehypeSchema.attributes,\n      \"*\": renderInlineStyle\n        ? [...rehypeSchema.attributes![\"*\"]!, \"style\", \"class\"]\n        : rehypeSchema.attributes![\"*\"]!,\n      video: [\"src\", \"poster\"],\n      iframe: [\n        \"src\",\n        \"width\",\n        \"height\",\n        \"frameborder\",\n        \"allowfullscreen\",\n        \"sandbox\",\n        \"loading\",\n        \"title\",\n        \"id\",\n        \"class\",\n      ],\n      source: [\"src\", \"type\"],\n\n      svg: [\n        \"width\",\n        \"height\",\n        \"viewBox\",\n        \"xmlns\",\n        \"version\",\n        \"preserveAspectRatio\",\n        \"xmlns:xlink\",\n        \"xml:space\",\n        \"fill\",\n        \"stroke\",\n        \"stroke-width\",\n      ],\n      g: [\"transform\", \"fill\", \"stroke\", \"stroke-width\"],\n      path: [\"d\", \"fill\", \"stroke\", \"stroke-width\", \"transform\"],\n      polygon: [\"points\", \"fill\", \"stroke\", \"stroke-width\", \"transform\"],\n      circle: [\"cx\", \"cy\", \"r\", \"fill\", \"stroke\", \"stroke-width\", \"transform\"],\n      ellipse: [\"cx\", \"cy\", \"rx\", \"ry\", \"fill\", \"stroke\", \"stroke-width\", \"transform\"],\n      rect: [\n        \"x\",\n        \"y\",\n        \"width\",\n        \"height\",\n        \"fill\",\n        \"stroke\",\n        \"stroke-width\",\n        \"transform\",\n        \"rx\",\n        \"ry\",\n      ],\n      line: [\"x1\", \"y1\", \"x2\", \"y2\", \"stroke\", \"stroke-width\", \"transform\"],\n      text: [\"x\", \"y\", \"fill\", \"font-size\", \"font-family\", \"text-anchor\", \"transform\"],\n      use: [\"href\", \"xlink:href\", \"x\", \"y\", \"width\", \"height\", \"transform\"],\n    }\n  }\n\n  const pipeline = unified()\n    .use(rehypeParse, { fragment: true })\n    .use(rehypeSanitize, rehypeSchema)\n    .use(rehypeTrimEndBrElement)\n    .use(rehypeInferDescriptionMeta)\n    .use(rehypeStringify)\n\n  const tree = pipeline.parse(content)\n\n  rehypeUrlToAnchor(tree)\n\n  // console.log(\"tree\", tree)\n\n  const hastTree = pipeline.runSync(tree, content)\n\n  const images = [] as string[]\n\n  visit(tree, \"element\", (node) => {\n    if (node.tagName === \"img\" && node.properties.src) {\n      images.push(node.properties.src as string)\n    }\n  })\n\n  return {\n    hastTree,\n    images,\n    toContent: () =>\n      toJsxRuntime(hastTree, {\n        Fragment,\n        ignoreInvalidStyle: true,\n        jsx: (type, props, key) => jsx(type as any, props, key),\n        jsxs: (type, props, key) => jsxs(type as any, props, key),\n        passNode: true,\n        components,\n      }),\n    toText: () => toText(hastTree),\n  }\n}\n\nfunction rehypeUrlToAnchor(tree: Node) {\n  const tagsShouldNotBeWrapped = new Set([\"a\", \"pre\", \"code\"])\n  // https://chatgpt.com/share/37e0ceec-5c9e-4086-b9d6-5afc1af13bb0\n  visitParents(tree as any, \"text\", (node: Text, ancestors: Node[]) => {\n    if (\n      ancestors.some(\n        (ancestor) =>\n          \"tagName\" in ancestor && tagsShouldNotBeWrapped.has((ancestor as Element).tagName),\n      )\n    ) {\n      return\n    }\n\n    const parent = ancestors.at(-1)\n\n    const urlRegex = /https?:\\/\\/\\S+/g\n    const text = node.value\n    const matches = [...text.matchAll(urlRegex)]\n\n    if (matches.length === 0 || !parent || !(\"children\" in parent)) return\n\n    if ((parent as Element).tagName === \"a\") {\n      return\n    }\n\n    const newNodes: (Text | Element)[] = []\n    let lastIndex = 0\n\n    matches.forEach((match) => {\n      const [url] = match\n      const urlIndex = match.index || 0\n\n      if (urlIndex > lastIndex) {\n        newNodes.push({\n          type: \"text\",\n          value: text.slice(lastIndex, urlIndex),\n        })\n      }\n\n      newNodes.push({\n        type: \"element\",\n        tagName: \"a\",\n        properties: { href: url },\n        children: [{ type: \"text\", value: url }],\n      })\n\n      lastIndex = urlIndex + url.length\n    })\n\n    if (lastIndex < text.length) {\n      newNodes.push({\n        type: \"text\",\n        value: text.slice(lastIndex),\n      })\n    }\n\n    const index = (parent.children as (Text | Element)[]).indexOf(node)\n    ;(parent.children as (Text | Element)[]).splice(index, 1, ...newNodes)\n  })\n}\n"
  },
  {
    "path": "packages/internal/utils/src/img-proxy.ts",
    "content": "export const IMAGE_PROXY_URL = \"https://img.folo.is\"\n\nexport const imageRefererMatches = [\n  {\n    url: /^https:\\/\\/\\w+\\.sinaimg\\.cn/,\n    referer: \"https://weibo.com\",\n  },\n  {\n    url: /^https:\\/\\/i\\.pximg\\.net/,\n    referer: \"https://www.pixiv.net\",\n  },\n  {\n    url: /^https:\\/\\/cdnfile\\.sspai\\.com/,\n    referer: \"https://sspai.com\",\n  },\n  {\n    url: /^https:\\/\\/(?:\\w|-)+\\.cdninstagram\\.com/,\n    referer: \"https://www.instagram.com\",\n  },\n  {\n    url: /^https:\\/\\/sp1\\.piokok\\.com/,\n    referer: \"https://www.piokok.com\",\n    force: true,\n  },\n  {\n    url: /^https?:\\/\\/[\\w-]+\\.xhscdn\\.com/,\n    referer: \"https://www.xiaohongshu.com\",\n  },\n]\n\nconst webpCloudPublicServicesMatches = [\n  // https://docs.webp.se/public-services/github-avatar/\n  {\n    url: /^https:\\/\\/avatars\\.githubusercontent\\.com\\/u\\//,\n    target: \"https://avatars-githubusercontent-webp.webp.se/u/\",\n  },\n]\n\nexport const getImageProxyUrl = ({\n  url,\n  width,\n  height,\n  canUseProxy,\n}: {\n  url: string\n  width?: number\n  height?: number\n  canUseProxy?: boolean\n}) => {\n  if (!canUseProxy) {\n    return url\n  }\n  return `${IMAGE_PROXY_URL}?url=${encodeURIComponent(url)}&width=${width ? Math.round(width) : \"\"}&height=${height ? Math.round(height) : \"\"}`\n}\n\nexport const replaceImgUrlIfNeed = ({\n  url,\n  inBrowser,\n  canUseProxy,\n}: {\n  url?: string\n  inBrowser?: boolean\n  canUseProxy?: boolean\n}) => {\n  if (!url) return url\n\n  for (const rule of webpCloudPublicServicesMatches) {\n    if (rule.url.test(url)) {\n      return url.replace(rule.url, rule.target)\n    }\n  }\n\n  for (const rule of imageRefererMatches) {\n    if ((inBrowser || rule.force) && rule.url.test(url)) {\n      return getImageProxyUrl({ url, width: 0, height: 0, canUseProxy })\n    }\n  }\n  return url\n}\n"
  },
  {
    "path": "packages/internal/utils/src/index.ts",
    "content": "export * from \"./attribution\"\nexport * from \"./bind-this\"\nexport * from \"./cjk\"\nexport * from \"./color\"\nexport * from \"./data-structure/set\"\nexport * from \"./dom\"\nexport * from \"./duration\"\nexport * from \"./jotai\"\nexport * from \"./noop\"\nexport * from \"./path-parser\"\nexport * from \"./react\"\nexport * from \"./resize\"\nexport * from \"./url-for-video\"\nexport * from \"./utils\"\n"
  },
  {
    "path": "packages/internal/utils/src/jotai.ts",
    "content": "import type { Atom, PrimitiveAtom } from \"jotai\"\nimport { createStore, useAtom, useAtomValue, useSetAtom } from \"jotai\"\nimport { selectAtom } from \"jotai/utils\"\nimport { useCallback } from \"react\"\nimport { shallow } from \"zustand/shallow\"\n\nexport const jotaiStore = createStore()\n\nexport const createAtomAccessor = <T>(atom: PrimitiveAtom<T>) =>\n  [\n    () => jotaiStore.get(atom),\n    (value: T | ((prev: T) => T)) => jotaiStore.set(atom, value),\n  ] as const\n\nconst options = { store: jotaiStore }\n/**\n * @param atom - jotai\n * @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set, useSelector]\n */\nexport const createAtomHooks = <T>(atom: PrimitiveAtom<T>) => {\n  let _atomSelector: ReturnType<typeof createAtomSelector<T>> | undefined\n\n  const result = [\n    atom,\n    () => useAtom(atom, options),\n    () => useAtomValue(atom, options),\n    () => useSetAtom(atom, options),\n    ...createAtomAccessor(atom),\n  ] as const\n\n  type Result = [...typeof result, ReturnType<typeof createAtomSelector<T>>]\n\n  Object.defineProperty(result, result.length, {\n    get() {\n      if (!_atomSelector) {\n        _atomSelector = createAtomSelector(atom)\n      }\n      return _atomSelector\n    },\n  })\n\n  return result as any as Result\n}\n\nconst noop: any[] = []\nconst createAtomSelector = <T>(atom: Atom<T>) => {\n  const useHook = <R>(selector: (a: T) => R, deps: any[] = noop) =>\n    useAtomValue(\n      selectAtom(\n        atom,\n        useCallback((a) => selector(a as T), deps),\n        shallow,\n      ),\n    )\n\n  return useHook\n}\n"
  },
  {
    "path": "packages/internal/utils/src/json-codec.ts",
    "content": "export class JsonObfuscatedCodec {\n  // Custom charset for converting bytes to printable characters\n  private static charset = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_\"\n  private static charsetLength = JsonObfuscatedCodec.charset.length\n\n  // Generate a key\n  private static key = \"Folo\"\n\n  // Convert string to byte array\n  private static toBytes(str: string): Uint8Array {\n    const encoder = new TextEncoder()\n    return encoder.encode(str)\n  }\n\n  // Convert byte array to string\n  private static fromBytes(bytes: Uint8Array): string {\n    const decoder = new TextDecoder()\n    return decoder.decode(bytes)\n  }\n\n  // XOR encrypt/decrypt byte array\n  private static xorBytes(input: Uint8Array, key: string): Uint8Array {\n    const keyBytes = this.toBytes(key)\n    const output = new Uint8Array(input.length)\n    for (const [i, element] of input.entries()) {\n      const keyByte = keyBytes[i % keyBytes.length]\n      if (keyByte !== undefined) {\n        output[i] = element ^ keyByte\n      }\n    }\n    return output\n  }\n\n  // Convert byte array to custom charset string\n  private static bytesToCharset(bytes: Uint8Array): string {\n    let result = \"\"\n    for (const byte of bytes) {\n      // Map each byte to two characters (add confusion effect)\n      const high = Math.floor(byte / this.charsetLength)\n      const low = byte % this.charsetLength\n      const highChar = this.charset[high]\n      const lowChar = this.charset[low]\n      if (highChar !== undefined && lowChar !== undefined) {\n        result += highChar + lowChar\n      }\n    }\n    return result\n  }\n\n  // Convert custom charset string to byte array\n  private static charsetToBytes(str: string): Uint8Array {\n    const bytes = new Uint8Array(str.length / 2)\n    for (let i = 0; i < str.length; i += 2) {\n      const highChar = str[i]\n      const lowChar = str[i + 1]\n      if (highChar === undefined || lowChar === undefined) {\n        throw new Error(\"Invalid encoded string: incomplete character pair\")\n      }\n      const high = this.charset.indexOf(highChar)\n      const low = this.charset.indexOf(lowChar)\n      if (high === -1 || low === -1) {\n        throw new Error(\"Invalid encoded string\")\n      }\n      bytes[i / 2] = high * this.charsetLength + low\n    }\n    return bytes\n  }\n\n  // Encode JSON object to obfuscated string\n  static encode(obj: any, key: string = this.key): string {\n    try {\n      // Convert JSON object to string (support Chinese)\n      const jsonStr = JSON.stringify(obj)\n      // Convert to byte array\n      const bytes = this.toBytes(jsonStr)\n      // Use XOR encryption\n      const encrypted = this.xorBytes(bytes, key)\n      // Convert to custom charset string\n      return this.bytesToCharset(encrypted)\n    } catch (error) {\n      console.error(\"Encoding error:\", error)\n      throw new Error(\"Failed to encode JSON\")\n    }\n  }\n\n  // Decode obfuscated string to JSON object\n  static decode(encodedStr: string, key: string = this.key): any {\n    try {\n      // Convert from custom charset string to byte array\n      const bytes = this.charsetToBytes(encodedStr)\n      // Use XOR decryption\n      const decrypted = this.xorBytes(bytes, key)\n      // Convert to JSON string\n      const jsonStr = this.fromBytes(decrypted)\n      // Parse JSON\n      return JSON.parse(jsonStr)\n    } catch (error) {\n      console.error(\"Decoding error:\", error)\n      throw new Error(\"Failed to decode JSON\")\n    }\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/language.ts",
    "content": "import type { SupportedActionLanguage } from \"@follow/shared/language\"\nimport { ACTION_LANGUAGE_MAP } from \"@follow/shared/language\"\nimport { franc } from \"franc-min\"\n\nimport { parseHtml } from \"./html\"\nimport { duplicateIfLengthLessThan } from \"./utils\"\n\nexport const checkLanguage = ({\n  content,\n  language,\n}: {\n  content: string\n  language: SupportedActionLanguage\n}) => {\n  if (!content) return true\n  const pureContent = parseHtml(content)\n    .toText()\n    .replaceAll(/https?:\\/\\/\\S+|www\\.\\S+/g, \" \")\n  const { code } = ACTION_LANGUAGE_MAP[language]\n  if (!code) {\n    return false\n  }\n\n  const sourceLanguage = franc(duplicateIfLengthLessThan(pureContent, 20), {\n    only: [code],\n  })\n\n  return sourceLanguage === code\n}\n"
  },
  {
    "path": "packages/internal/utils/src/link-parser.ts",
    "content": "import { parseSafeUrl } from \"./utils\"\n\nconst GITHUB_HOST = \"github.com\"\n\nexport type LinkParserOptions = {\n  name: string\n  validator: (url: URL) => boolean\n  icon: string\n}\n\nconst defineLinkParser = (options: LinkParserOptions) => {\n  const define = (url: URL | string) => {\n    const safeUrl = typeof url === \"string\" ? parseSafeUrl(url) : url\n    const validate = safeUrl ? options.validator(safeUrl) : false\n\n    return {\n      validate,\n    }\n  }\n\n  define.parserName = options.name\n  define.icon = options.icon\n  return define\n}\n\nexport const isYoutubeUrl = defineLinkParser({\n  name: \"youtube\",\n  validator: (url) => url.hostname.includes(\"youtube.com\"),\n  icon: \"i-logos-youtube-icon\",\n})\n\nexport const isGithubUrl = defineLinkParser({\n  name: \"github\",\n  validator: (url) => url.hostname === GITHUB_HOST || url.hostname === \"github.blog\",\n  icon: \"i-mgc-github-2-cute-fi text-black dark:text-white\",\n})\n\nexport const isTwitterUrl = defineLinkParser({\n  name: \"twitter\",\n  validator: (url) => url.hostname === \"twitter.com\",\n  icon: \"i-mgc-twitter-cute-fi text-[#55acee]\",\n})\n\nexport const isXUrl = defineLinkParser({\n  name: \"x\",\n  validator: (url) => url.hostname === \"x.com\",\n  icon: \"i-mgc-social-x-cute-li\",\n})\n\nexport const isV2exUrl = defineLinkParser({\n  name: \"v2ex\",\n  validator: (url) => url.hostname.includes(\"v2ex.com\"),\n  icon: \"i-simple-icons-v2ex text-text\",\n})\n\nexport const isPixivUrl = defineLinkParser({\n  name: \"pixiv\",\n  validator: (url) => url.hostname.includes(\"pixiv.net\"),\n  icon: \"i-simple-icons-pixiv text-[#0096fa]\",\n})\n\nexport const isDockerHubUrl = defineLinkParser({\n  name: \"dockerhub\",\n  validator: (url) => url.hostname.includes(\"hub.docker.com\"),\n  icon: \"i-simple-icons-docker text-[#0db7ed]\",\n})\n\nexport const isAppleSiteUrl = defineLinkParser({\n  name: \"apple\",\n  validator: (url) => url.hostname.includes(\"apple.com\"),\n  icon: \"i-simple-icons-apple text-[#000] dark:text-[#fff] scale-75\",\n})\n\nexport const isGoogleSiteUrl = defineLinkParser({\n  name: \"google\",\n  validator: (url) => url.hostname.includes(\"google.com\"),\n  icon: \"i-logos-google-icon\",\n})\n\nexport const isTelegramUrl = defineLinkParser({\n  name: \"telegram\",\n  validator: (url) => url.hostname.includes(\"t.me\"),\n  icon: \"i-logos-telegram\",\n})\n\nexport const isSpotifyUrl = defineLinkParser({\n  name: \"spotify\",\n  validator: (url) => url.hostname.includes(\"open.spotify.com\"),\n  icon: \"i-logos-spotify-icon\",\n})\n\nexport const isWeiboUrl = defineLinkParser({\n  name: \"weibo\",\n  validator: (url) => url.hostname.includes(\"weibo.com\"),\n  icon: \"i-simple-icons-sinaweibo text-[#e6162d]\",\n})\n\nexport const isBilibiliUrl = defineLinkParser({\n  name: \"bilibili\",\n  validator: (url) => url.hostname.includes(\"bilibili.com\"),\n  icon: \"i-simple-icons-bilibili text-[#00a1d6]\",\n})\n\nexport const isDoubanUrl = defineLinkParser({\n  name: \"douban\",\n  validator: (url) => url.hostname.includes(\"douban.com\"),\n  icon: \"i-simple-icons-douban text-[#007722]\",\n})\n"
  },
  {
    "path": "packages/internal/utils/src/lru-cache.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport { LRUCache } from \"./lru-cache\"\n\ndescribe(\"LRUCache\", () => {\n  it(\"should create cache with given capacity\", () => {\n    const cache = new LRUCache<string, number>(2)\n    expect(cache.size()).toBe(0)\n  })\n\n  it(\"should throw error for invalid capacity\", () => {\n    expect(() => new LRUCache<string, number>(0)).toThrow()\n    expect(() => new LRUCache<string, number>(-1)).toThrow()\n  })\n\n  it(\"should handle basic get and put operations\", () => {\n    const cache = new LRUCache<string, number>(2)\n\n    cache.put(\"a\", 1)\n    expect(cache.get(\"a\")).toBe(1)\n\n    cache.put(\"b\", 2)\n    expect(cache.get(\"b\")).toBe(2)\n    expect(cache.size()).toBe(2)\n  })\n\n  it(\"should remove least recently used item when capacity is exceeded\", () => {\n    const cache = new LRUCache<string, number>(2)\n\n    cache.put(\"a\", 1)\n    cache.put(\"b\", 2)\n    cache.put(\"c\", 3)\n\n    expect(cache.get(\"a\")).toBeUndefined()\n    expect(cache.get(\"b\")).toBe(2)\n    expect(cache.get(\"c\")).toBe(3)\n  })\n\n  it(\"should update access order on get\", () => {\n    const cache = new LRUCache<string, number>(2)\n\n    cache.put(\"a\", 1)\n    cache.put(\"b\", 2)\n    cache.get(\"a\")\n    cache.put(\"c\", 3)\n\n    expect(cache.get(\"b\")).toBeUndefined()\n    expect(cache.get(\"a\")).toBe(1)\n    expect(cache.get(\"c\")).toBe(3)\n  })\n\n  it(\"should clear cache\", () => {\n    const cache = new LRUCache<string, number>(2)\n\n    cache.put(\"a\", 1)\n    cache.put(\"b\", 2)\n    cache.clear()\n\n    expect(cache.size()).toBe(0)\n    expect(cache.get(\"a\")).toBeUndefined()\n    expect(cache.get(\"b\")).toBeUndefined()\n  })\n})\n"
  },
  {
    "path": "packages/internal/utils/src/lru-cache.ts",
    "content": "class LinkNode<K, V> {\n  key: K\n  value: V\n  prev: LinkNode<K, V> | null\n  next: LinkNode<K, V> | null\n\n  constructor(key: K, value: V) {\n    this.key = key\n    this.value = value\n    this.prev = null\n    this.next = null\n  }\n}\n\nexport class LRUCache<K, V> {\n  private capacity: number\n  private cache: Map<K, LinkNode<K, V>>\n  private head: LinkNode<K, V>\n  private tail: LinkNode<K, V>\n\n  constructor(capacity: number) {\n    if (capacity <= 0) {\n      throw new Error(\"Capacity must be a positive number\")\n    }\n    this.capacity = capacity\n    this.cache = new Map<K, LinkNode<K, V>>()\n    // Dummy head and tail nodes for easier list management\n    this.head = new LinkNode<K, V>(null as any, null as any)\n    this.tail = new LinkNode<K, V>(null as any, null as any)\n    this.head.next = this.tail\n    this.tail.prev = this.head\n  }\n\n  /**\n   * Retrieves the value associated with the key and moves it to the front (most recently used).\n   * @param key The key to look up\n   * @returns The value if found, undefined otherwise\n   */\n  get(key: K): V | undefined {\n    const node = this.cache.get(key)\n    if (!node) {\n      return undefined\n    }\n    this.moveToFront(node)\n    return node.value\n  }\n\n  /**\n   * Adds or updates a key-value pair and moves it to the front (most recently used).\n   * Evicts the least recently used item if capacity is exceeded.\n   * @param key The key to set\n   * @param value The value to associate with the key\n   */\n  put(key: K, value: V): void {\n    let node = this.cache.get(key)\n    if (node) {\n      node.value = value\n      this.moveToFront(node)\n    } else {\n      node = new LinkNode(key, value)\n      this.cache.set(key, node)\n      this.addToFront(node)\n      if (this.cache.size > this.capacity) {\n        const lruNode = this.tail.prev!\n        this.removeNode(lruNode)\n        this.cache.delete(lruNode.key)\n      }\n    }\n  }\n\n  /**\n   * Clears all entries in the cache.\n   */\n  clear(): void {\n    this.cache.clear()\n    this.head.next = this.tail\n    this.tail.prev = this.head\n  }\n\n  /**\n   * Returns the current number of entries in the cache.\n   * @returns The size of the cache\n   */\n  size(): number {\n    return this.cache.size\n  }\n\n  /**\n   * Moves a node to the front of the list (most recently used).\n   * @param node The node to move\n   */\n  private moveToFront(node: LinkNode<K, V>): void {\n    this.removeNode(node)\n    this.addToFront(node)\n  }\n\n  /**\n   * Adds a node to the front of the list.\n   * @param node The node to add\n   */\n  private addToFront(node: LinkNode<K, V>): void {\n    node.next = this.head.next\n    node.prev = this.head\n    this.head.next!.prev = node\n    this.head.next = node\n  }\n\n  /**\n   * Removes a node from the list.\n   * @param node The node to remove\n   */\n  private removeNode(node: LinkNode<K, V>): void {\n    node.prev!.next = node.next\n    node.next!.prev = node.prev\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/noop.ts",
    "content": "export const noop = () => {}\n// eslint-disable-next-line unicorn/no-thenable\nexport const thenable: any = { then: noop }\nexport const emptyObject = {}\n\nexport const alwaysFalse = () => false\n"
  },
  {
    "path": "packages/internal/utils/src/ns.ts",
    "content": "const ns = \"follow\"\nexport const getStorageNS = (key: string) => `${ns}:${key}`\n\nexport const clearStorage = () => {\n  for (let i = 0; i < localStorage.length; i++) {\n    const key = localStorage.key(i)\n    if (key && key.startsWith(ns)) {\n      localStorage.removeItem(key)\n    }\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/path-parser.test.ts",
    "content": "import { compile, pathToRegexp } from \"path-to-regexp\"\nimport { describe, expect, test } from \"vitest\"\n\nimport {\n  MissingOptionalParamError,\n  MissingRequiredParamError,\n  parseFullPathParams,\n  parseRegexpPathParams,\n  regexpPathToPath,\n  transformUriPath,\n} from \"./path-parser\"\n\ndescribe(\"test `transformUriPath()`\", () => {\n  test(\"normal path\", () => {\n    expect(transformUriPath(\"/issues/all\")).toMatchInlineSnapshot(`\"/issues/all\"`)\n  })\n\n  test(\"has tailing optional params\", () => {\n    const path = transformUriPath(\"/issues/:id?\")\n    expect(path).toMatchInlineSnapshot(`\"/issues{/:id}\"`)\n    expect(compile(path)({})).toMatchInlineSnapshot(`\"/issues\"`)\n    const regexp = pathToRegexp(path)\n    expect(regexp.keys).toMatchInlineSnapshot(`\n      [\n        {\n          \"name\": \"id\",\n          \"type\": \"param\",\n        },\n      ]\n    `)\n  })\n\n  test(\"has tailing optional params and with value\", () => {\n    const path = transformUriPath(\"/issues/:id?\")\n\n    expect(\n      compile(path)({\n        id: \"1111111\",\n      }),\n    ).toMatchInlineSnapshot(`\"/issues/1111111\"`)\n    const regexp = pathToRegexp(path)\n    expect(regexp.keys).toMatchInlineSnapshot(`\n      [\n        {\n          \"name\": \"id\",\n          \"type\": \"param\",\n        },\n      ]\n    `)\n  })\n\n  test(\"catch all path\", () => {\n    const path = transformUriPath(\"/:path{.+}?\")\n    expect(path).toMatchInlineSnapshot(`\"{/*path}\"`)\n    expect(\n      compile(path)({\n        path: [\"a\", \"b\", \"c\"],\n      }),\n    ).toMatchInlineSnapshot(`\"/a/b/c\"`)\n  })\n\n  test(\"catch all path with leading route\", () => {\n    const path = transformUriPath(\"/issues/:path{.+}?\")\n    expect(path).toMatchInlineSnapshot(`\"/issues{/*path}\"`)\n    expect(\n      compile(path)({\n        path: [\"a\", \"b\", \"c\"],\n      }),\n    ).toMatchInlineSnapshot(`\"/issues/a/b/c\"`)\n  })\n\n  test(\"catch all path but value is query search string\", () => {\n    const path = transformUriPath(\"/issues/:path{.+}?\")\n    expect(path).toMatchInlineSnapshot(`\"/issues{/*path}\"`)\n    expect(\n      compile(path)({\n        path: [\"a=1&b=2\"],\n      }),\n    ).toMatchInlineSnapshot(`\"/issues/a%3D1%26b%3D2\"`)\n  })\n\n  test(\"catch all route via `*`\", () => {\n    expect(transformUriPath(\"/issues/*\")).toMatchInlineSnapshot(`\"/issues{/:__catchAll__}\"`)\n    expect(transformUriPath(\"*\")).toMatchInlineSnapshot(`\"{/:__catchAll__}\"`)\n  })\n})\n\ndescribe(\"test `regexpPathToPath()`\", () => {\n  test(\"normal path\", () => {\n    expect(regexpPathToPath(\"/issues/all\", {})).toMatchInlineSnapshot(`\"/issues/all\"`)\n  })\n  test(\"path with optional params\", () => {\n    expect(regexpPathToPath(\"/issues/:id?\", {})).toMatchInlineSnapshot(`\"/issues\"`)\n    expect(regexpPathToPath(\"/issues/:id?\", { id: \"1\" })).toMatchInlineSnapshot(`\"/issues/1\"`)\n  })\n\n  test(\"path with many optional params\", () => {\n    expect(\n      regexpPathToPath(\"/issue/:user/:repo/:state?/:labels?\", {\n        user: \"rssnext\",\n        repo: \"follow\",\n      }),\n    ).toMatchInlineSnapshot(`\"/issue/rssnext/follow\"`)\n  })\n\n  test(\"path with many optional params, but when using the optional parameter(s) after the optional parameter(s), the previous optional parameter(s) is/are not filled in.\", () => {\n    expect(() =>\n      regexpPathToPath(\"/issue/:user/:repo/:state?/:labels?\", {\n        user: \"rssnext\",\n        repo: \"follow\",\n        labels: \"rss\",\n      }),\n    ).toThrowError(MissingOptionalParamError)\n  })\n  test(\"path with many optional params, but when using the optional parameter(s) after the optional parameter(s), the previous optional parameter(s) is/are not filled in.\", () => {\n    expect(() =>\n      regexpPathToPath(\"/ranking/:rid?/:day?/:arc_type?/:disableEmbed?\", {\n        day: \"1\",\n      }),\n    ).toThrowError(MissingOptionalParamError)\n  })\n\n  test(\"missing required param\", () => {\n    expect(() => regexpPathToPath(\"/issue/:user/:repo/:state?/:labels?\", {})).toThrow(\n      MissingRequiredParamError,\n    )\n  })\n\n  test(\"path with many optional params and all inputted\", () => {\n    expect(\n      regexpPathToPath(\"/issue/:user/:repo/:state?/:labels?\", {\n        user: \"rssnext\",\n        repo: \"follow\",\n        state: \"open\",\n        labels: \"rss\",\n      }),\n    ).toMatchInlineSnapshot(`\"/issue/rssnext/follow/open/rss\"`)\n  })\n\n  test(\"omit empty string and nil value\", () => {\n    expect(\n      regexpPathToPath(\n        \"/issue/:user/:repo/:state?/:labels?\",\n        {\n          user: \"rssnext\",\n          repo: \"follow\",\n          state: \"open\",\n          labels: \"\",\n        },\n        {\n          omitNilAndEmptyString: true,\n        },\n      ),\n    ).toMatchInlineSnapshot(`\"/issue/rssnext/follow/open\"`)\n  })\n\n  test(\"omit empty string and nil value will throw\", () => {\n    expect(() =>\n      regexpPathToPath(\n        \"/issue/:user/:repo/:state?/:labels?\",\n        {\n          user: \"rssnext\",\n          repo: \"follow\",\n          state: \"\",\n          labels: \"l\",\n        },\n        {\n          omitNilAndEmptyString: true,\n        },\n      ),\n    ).toThrow(MissingOptionalParamError)\n  })\n\n  test(\"omit empty string and nil value will throw (default behavior)\", () => {\n    expect(() =>\n      regexpPathToPath(\"/issue/:user/:repo/:state?/:labels?\", {\n        user: \"rssnext\",\n        repo: \"follow\",\n        state: \"\",\n        labels: \"l\",\n      }),\n    ).toThrow(MissingOptionalParamError)\n  })\n\n  test(\"empty string will pass\", () => {\n    expect(\n      regexpPathToPath(\n        \"/issue/:user/:repo/:state?/:labels?\",\n        {\n          user: \"rssnext\",\n          repo: \"follow\",\n          state: \"\",\n          labels: \"l\",\n        },\n        { omitNilAndEmptyString: false },\n      ),\n    ).toMatchInlineSnapshot(`\"/issue/rssnext/follow//l\"`)\n  })\n})\n\ndescribe(\"test `parseRegexpPathParams()`\", () => {\n  test(\"normal path\", () => {\n    expect(parseRegexpPathParams(\"/issues/all\")).toMatchInlineSnapshot(`\n      []\n    `)\n  })\n\n  test(\"path with optional params\", () => {\n    expect(parseRegexpPathParams(\"/issues/:id?\")).toMatchInlineSnapshot(`\n      [\n        {\n          \"isCatchAll\": false,\n          \"name\": \"id\",\n          \"optional\": true,\n        },\n      ]\n    `)\n  })\n\n  test(\"path with many optional params\", () => {\n    expect(parseRegexpPathParams(\"/issue/:user/:repo/:state?/:labels?\")).toMatchInlineSnapshot(`\n      [\n        {\n          \"isCatchAll\": false,\n          \"name\": \"user\",\n          \"optional\": false,\n        },\n        {\n          \"isCatchAll\": false,\n          \"name\": \"repo\",\n          \"optional\": false,\n        },\n        {\n          \"isCatchAll\": false,\n          \"name\": \"state\",\n          \"optional\": true,\n        },\n        {\n          \"isCatchAll\": false,\n          \"name\": \"labels\",\n          \"optional\": true,\n        },\n      ]\n    `)\n  })\n\n  test(\"catch all path\", () => {\n    expect(parseRegexpPathParams(\"/:path{.+}?\")).toMatchInlineSnapshot(`\n      [\n        {\n          \"isCatchAll\": true,\n          \"name\": \"path\",\n          \"optional\": true,\n        },\n      ]\n    `)\n  })\n\n  test(\"with excludeNames\", () => {\n    expect(\n      parseRegexpPathParams(\"/issue/:user/:repo/:state?/:labels?/:routeParams?\", {\n        excludeNames: [\"state\", \"routeParams\"],\n      }),\n    ).toMatchInlineSnapshot(`\n      [\n        {\n          \"isCatchAll\": false,\n          \"name\": \"user\",\n          \"optional\": false,\n        },\n        {\n          \"isCatchAll\": false,\n          \"name\": \"repo\",\n          \"optional\": false,\n        },\n        {\n          \"isCatchAll\": false,\n          \"name\": \"labels\",\n          \"optional\": true,\n        },\n      ]\n    `)\n  })\n})\n\ndescribe(\"test `parseFullPathParams()`\", () => {\n  test(\"case 1\", () => {\n    expect(parseFullPathParams(\"/user/123344\", \"/user/:id\")).toMatchInlineSnapshot(`\n      {\n        \"id\": \"123344\",\n      }\n    `)\n  })\n  test(\"case 2\", () => {\n    expect(parseFullPathParams(\"/build/wangqiru/ttrss\", \"/build/:user/:name\"))\n      .toMatchInlineSnapshot(`\n      {\n        \"name\": \"ttrss\",\n        \"user\": \"wangqiru\",\n      }\n    `)\n  })\n})\n"
  },
  {
    "path": "packages/internal/utils/src/path-parser.ts",
    "content": "import { isNil } from \"es-toolkit/compat\"\nimport type { CompileOptions } from \"path-to-regexp\"\nimport { compile, match, parse } from \"path-to-regexp\"\n\nconst CATCH_ALL_GROUP_KEY = \"__catchAll__\"\nexport function transformUriPath(uri: string): string {\n  let result = \"\"\n  const parts = uri.split(\"/\")\n\n  for (const part of parts) {\n    if (!part) continue\n    if (part.startsWith(\":\")) {\n      if (part.includes(\"{\") && part.includes(\"}\")) {\n        result += `{/*${part.slice(1, part.indexOf(\"{\"))}}`\n      } else if (part.endsWith(\"?\")) {\n        result += `{/:${part.slice(1, -1)}}`\n      } else {\n        result += `/:${part.slice(1)}`\n      }\n    } else if (part === \"*\") {\n      result += `{/:${CATCH_ALL_GROUP_KEY}}`\n    } else {\n      result += `/${part}`\n    }\n  }\n  return result\n}\n\nexport class MissingOptionalParamError extends Error {\n  constructor(public param: string) {\n    super(\n      `You used the optional parameter ${param}, but there are other optional parameters before the optional parameter ${param} that you did not fill in.`,\n    )\n  }\n}\n\nexport class MissingRequiredParamError extends Error {\n  constructor(param: string) {\n    super(`Missing required param ${param}`)\n  }\n}\nexport const regexpPathToPath = (\n  regexpPath: string,\n  params: {\n    catchAll?: string\n    [key: string]: string | string[] | undefined\n  },\n  options?: Omit<CompileOptions, \"validate\"> & {\n    /** @default true */\n    omitNilAndEmptyString?: boolean\n  },\n): string => {\n  const nextParams = { ...params }\n\n  const { omitNilAndEmptyString = true, ...compileConfigs } = options || {}\n  if (omitNilAndEmptyString) {\n    //  Delete empty string values and nil value\n    for (const key in nextParams) {\n      if (nextParams[key] === \"\" || isNil(nextParams[key])) {\n        delete nextParams[key]\n      }\n    }\n  }\n\n  const transformedPath = transformUriPath(regexpPath)\n\n  const paramsKeys = parseRegexpPathParams(regexpPath)\n  const inputtedKeys = new Set(Object.keys(nextParams))\n  let prevKeyIsOptional = false\n  let prevKeyIsInputted = false\n  for (const key of paramsKeys) {\n    const value = nextParams[key.name]\n    if (key.isCatchAll && typeof value === \"string\") {\n      nextParams[key.name] = value.split(\"/\")\n      break\n    }\n    const isOptional = key.optional\n    const userInputted = inputtedKeys.has(key.name)\n    if (!isOptional && !userInputted) {\n      throw new MissingRequiredParamError(key.name)\n    }\n\n    if (prevKeyIsOptional && !isOptional) {\n      throw new Error(\"Required param after optional param\")\n    }\n\n    if (prevKeyIsOptional && !prevKeyIsInputted && userInputted) {\n      throw new MissingOptionalParamError(key.name)\n    }\n\n    prevKeyIsOptional = isOptional\n    prevKeyIsInputted = userInputted\n  }\n  return compile(transformedPath, {\n    ...compileConfigs,\n  })(nextParams)\n}\n\nexport type PathParams = {\n  name: string\n  optional: boolean\n  isCatchAll: boolean\n}\n\nexport type ParseRegexpPathParamsOptions = {\n  excludeNames?: string[]\n  forceExcludeNames?: string[]\n}\nexport const parseRegexpPathParams = (\n  regexpPath: string,\n  options?: ParseRegexpPathParamsOptions,\n) => {\n  const {\n    excludeNames = [\n      \"routeParams\",\n      \"functionalFlag\",\n      \"fulltext\",\n      \"disableEmbed\",\n      \"embed\",\n      \"date\",\n      \"language\",\n      \"lang\",\n      \"sort\",\n    ],\n    forceExcludeNames = [\"routeParams\"],\n  } = options || {}\n  const transformedPath = transformUriPath(regexpPath)\n  const { tokens } = parse(transformedPath)\n\n  return tokens\n    .map((token) => {\n      switch (token.type) {\n        case \"param\": {\n          return {\n            name: token.name,\n            optional: false,\n            isCatchAll: false,\n          }\n        }\n        case \"wildcard\": {\n          return {\n            name: token.name,\n            optional: false,\n            isCatchAll: true,\n          }\n        }\n        case \"group\": {\n          if (\"name\" in token.tokens[1]!) {\n            return {\n              name: token.tokens[1].name,\n              optional: true,\n              isCatchAll: token.tokens[1].type === \"wildcard\",\n            }\n          } else {\n            console.warn(\"Invalid token\", token)\n            return \"\"\n          }\n        }\n        default: {\n          return \"\"\n        }\n      }\n    })\n    .filter(\n      (item) =>\n        typeof item === \"object\" &&\n        \"name\" in item &&\n        (!excludeNames.includes(item.name) || !item.optional) &&\n        !forceExcludeNames.includes(item.name),\n    ) as PathParams[]\n}\nexport const parseFullPathParams = (path: string, regexpPath: string) => {\n  // path: /user/123344\n  // regexpPath: /user/:id\n  // return {id: '123344'}\n  const transformedPath = transformUriPath(regexpPath)\n  const result = match(transformedPath)(path)\n  if (!result) return {}\n  return result.params as Record<string, string>\n}\n"
  },
  {
    "path": "packages/internal/utils/src/react.ts",
    "content": "import type { ComponentType, FC, ReactElement } from \"react\"\nimport { createElement, Suspense } from \"react\"\n\ntype FallbackOptions = ReactElement | ComponentType\n\ninterface WithSuspenseOptions {\n  fallback?: FallbackOptions\n}\n\n/**\n * Higher-order component that wraps a component with React Suspense\n * @param Component - The component to wrap with Suspense\n * @param options - Configuration options for the Suspense wrapper\n * @returns A new component wrapped with Suspense\n */\nexport function withSuspense<P extends object>(\n  Component: FC<P>,\n  options: WithSuspenseOptions = {},\n): FC<P> {\n  const { fallback } = options\n\n  const WrappedComponent = (props: P) => {\n    const fallbackElement = typeof fallback === \"function\" ? createElement(fallback) : fallback\n\n    return createElement(Suspense, { fallback: fallbackElement }, createElement(Component, props))\n  }\n\n  WrappedComponent.displayName = `withSuspense(${Component.displayName || Component.name || \"Component\"})`\n\n  return WrappedComponent as FC<P>\n}\n"
  },
  {
    "path": "packages/internal/utils/src/resize.ts",
    "content": "export interface ViewportSize {\n  width: number\n  height: number\n}\n\nexport interface RectLike {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\n/**\n * Compute new top-left position when resizing from top and/or left so that the\n * opposite corner remains visually anchored. The result is clamped to viewport.\n */\nexport function computeAdjustedTopLeftPosition(\n  previous: RectLike,\n  newSize: { width: number; height: number },\n  direction: string,\n  viewport?: ViewportSize,\n): { x: number; y: number } {\n  viewport ??= { width: window.innerWidth, height: window.innerHeight }\n  const resizedFromLeft = /left/i.test(direction)\n  const resizedFromTop = /top/i.test(direction)\n\n  let newX = previous.x\n  let newY = previous.y\n\n  if (resizedFromLeft) {\n    newX = previous.x - (newSize.width - previous.width)\n  }\n  if (resizedFromTop) {\n    newY = previous.y - (newSize.height - previous.height)\n  }\n\n  const maxX = Math.max(0, viewport.width - newSize.width)\n  const maxY = Math.max(0, viewport.height - newSize.height)\n  newX = Math.min(Math.max(0, newX), maxX)\n  newY = Math.min(Math.max(0, newY), maxY)\n\n  return { x: newX, y: newY }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/scroller.ts",
    "content": "// @see https://github.com/Innei/sprightly/blob/2444dcdb789ca585337a4d241095640a524231db/src/lib/scroller.ts\n\nimport type { Transition } from \"motion/react\"\nimport { animateValue } from \"motion/react\"\n\nconst spring: Transition = {\n  type: \"spring\",\n  stiffness: 1000,\n  damping: 250,\n}\n// TODO scroller lock\nexport const springScrollTo = (\n  y: number,\n  scrollerElement: HTMLElement = document.documentElement,\n) => {\n  const scrollTop = scrollerElement?.scrollTop\n\n  let isStop = false\n  const stopSpringScrollHandler = () => {\n    isStop = true\n    animation.stop()\n  }\n\n  const el = scrollerElement || window\n  const animation = animateValue({\n    keyframes: [scrollTop + 1, y],\n    autoplay: true,\n    ...spring,\n    onPlay() {\n      el.addEventListener(\"wheel\", stopSpringScrollHandler, { capture: true })\n      el.addEventListener(\"touchmove\", stopSpringScrollHandler)\n    },\n\n    onUpdate(latest) {\n      if (latest <= 0) {\n        animation.stop()\n        return\n      }\n\n      if (isStop) {\n        return\n      }\n\n      el.scrollTo(0, latest)\n    },\n  })\n\n  animation.then(() => {\n    el.removeEventListener(\"wheel\", stopSpringScrollHandler, { capture: true })\n    el.removeEventListener(\"touchmove\", stopSpringScrollHandler)\n  })\n\n  return animation\n}\n\nexport const springScrollToElement = (\n  element: HTMLElement,\n  delta = 40,\n\n  scrollerElement: HTMLElement = document.documentElement,\n) => {\n  const y = calculateElementTop(element)\n\n  const to = y + delta\n\n  return springScrollTo(to, scrollerElement || document.documentElement)\n}\n\nconst calculateElementTop = (el: HTMLElement) => {\n  let top = 0\n  while (el) {\n    top += el.offsetTop\n    el = el.offsetParent as HTMLElement\n  }\n  return top\n}\n"
  },
  {
    "path": "packages/internal/utils/src/url-builder.ts",
    "content": "export class UrlBuilder {\n  constructor(private readonly webUrl: string) {}\n  protected join(path: string, query?: Record<string, string>) {\n    const nextUrl = new URL(this.webUrl)\n    nextUrl.pathname = path\n    if (query) {\n      for (const [key, value] of Object.entries(query)) {\n        nextUrl.searchParams.set(key, value)\n      }\n    }\n    return nextUrl.toString()\n  }\n  shareFeed(id: string, view?: number) {\n    return this.join(`share/feeds/${id}`, view ? { view: view.toString() } : undefined)\n  }\n  shareList(id: string, view?: number) {\n    return this.join(`share/lists/${id}`, view ? { view: view.toString() } : undefined)\n  }\n\n  profile(id: string) {\n    return this.join(`share/users/${id}`)\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/src/url-for-video.ts",
    "content": "export const transformVideoUrl = ({\n  url,\n  mini = false,\n  isIframe = false,\n  attachments,\n  lang,\n}: {\n  url: string\n  mini?: boolean\n  isIframe?: boolean\n  attachments?:\n    | {\n        url: string\n        mime_type?: string\n      }[]\n    | null\n  lang?: string\n}): string | null => {\n  if (url?.match(/\\/\\/www.bilibili.com\\/video\\/BV\\w+/)) {\n    const player = isIframe\n      ? \"https://player.bilibili.com/player.html\"\n      : \"https://www.bilibili.com/blackboard/newplayer.html\"\n    return `${player}?${new URLSearchParams({\n      isOutside: \"true\",\n      autoplay: \"true\",\n      danmaku: \"true\",\n      muted: mini ? \"true\" : \"false\",\n      highQuality: \"true\",\n      bvid: url.match(/\\/\\/www.bilibili.com\\/video\\/(BV\\w+)/)?.[1] || \"\",\n    }).toString()}`\n  }\n\n  if (url?.match(/\\/\\/www.youtube.com\\/(watch\\?v=|shorts\\/)[-\\w]+/)) {\n    const videoId = url.match(/\\/\\/www.youtube.com\\/(?:watch\\?v=|shorts\\/)([-\\w]+)/)?.[1]\n    return `https://www.youtube-nocookie.com/embed/${videoId}?${new URLSearchParams({\n      controls: mini ? \"0\" : \"1\",\n      autoplay: \"1\",\n      mute: mini ? \"1\" : \"0\",\n      hl: lang ?? \"en-US\",\n      cc_lang_pref: lang ?? \"en-US\",\n    }).toString()}`\n  }\n\n  if (url?.match(/\\/\\/www.pornhub.com\\/view_video.php\\?viewkey=\\w+/)) {\n    if (mini) {\n      return null\n    } else {\n      return `https://www.pornhub.com/embed/${url.match(/\\/\\/www.pornhub.com\\/view_video.php\\?viewkey=(\\w+)/)?.[1]}?${new URLSearchParams(\n        {\n          autoplay: \"1\",\n        },\n      ).toString()}`\n    }\n  }\n\n  if (attachments) {\n    return attachments.find((attachment) => attachment.mime_type === \"text/html\")?.url ?? null\n  }\n  return null\n}\n"
  },
  {
    "path": "packages/internal/utils/src/utils.spec.ts",
    "content": "import { describe, expect, test } from \"vitest\"\n\nimport { doesTextContainHTML, isBizId, omitShallow, toScientificNotation } from \"./utils\"\n\ndescribe(\"utils\", () => {\n  test(\"isBizId\", () => {\n    expect(isBizId(\"1712546615000\")).toBe(true)\n    expect(isBizId(\"17125466150000\")).toBe(true)\n    expect(isBizId(\"171254661500000\")).toBe(true)\n    expect(isBizId(\"1712546615000000\")).toBe(true)\n    expect(isBizId(\"17125466150000000\")).toBe(true)\n    expect(isBizId(\"171254661500000000\")).toBe(true)\n    expect(isBizId(\"1712546615000000000\")).toBe(true)\n    expect(isBizId(\"9994780527253199722\")).toBe(false)\n\n    // sample biz id\n    expect(isBizId(\"41147805272531997\")).toBe(true)\n\n    // test string\n    expect(isBizId(\"ep 1712546615000\")).toBe(false)\n    expect(isBizId(\"又有人在微博提到 DIYgod 了\")).toBe(false)\n\n    // test short number\n    expect(isBizId(\"123456789\")).toBe(false)\n\n    // test long number\n    expect(isBizId(\"12345678901234567890\")).toBe(false)\n  })\n\n  test(\"toScientificNotation\", () => {\n    // Test numbers below threshold (should return original number)\n    expect(toScientificNotation(123n, 3)).toBe(\"123.00\")\n    expect(toScientificNotation(999n, 3)).toBe(\"999.00\")\n    expect(toScientificNotation(1234n, 5)).toBe(\"1,234.00\")\n\n    // Test numbers above threshold (should convert to scientific notation)\n    expect(toScientificNotation(1234n, 3)).toBe(\"1.23e+3\")\n    expect(toScientificNotation(12345n, 3)).toBe(\"1.23e+4\")\n    expect(toScientificNotation(123456789n, 5)).toBe(\"1.23e+8\")\n\n    // Test with decimal numbers\n    expect(toScientificNotation([123456n, 2], 3)).toBe(\"1.23e+3\")\n\n    // Test with leading zeros\n    expect(toScientificNotation(1234n, 3)).toBe(\"1.23e+3\")\n    expect(toScientificNotation([1234n, 4], 3)).toBe(\"0.12\")\n\n    // Test with larger threshold\n    expect(toScientificNotation(12345678n, 8)).toBe(\"12,345,678.00\")\n    expect(toScientificNotation(123456789n, 8)).toBe(\"1.23e+8\")\n\n    // Edge cases\n    expect(toScientificNotation(0n, 3)).toBe(\"0\")\n\n    // Test with locales\n    // English locale (should use 'e+' notation)\n    expect(toScientificNotation(1234567n, 3, \"en-US\")).toBe(\"1.23e+6\")\n    expect(toScientificNotation(1234567n, 3, \"en-GB\")).toBe(\"1.23e+6\")\n\n    // Non-English locales (should use '×10^' notation)\n    expect(toScientificNotation(1234567n, 3, \"fr-FR\")).toBe(\"1,23×10^6\")\n    expect(toScientificNotation(1234567n, 3, \"de-DE\")).toBe(\"1,23×10^6\")\n\n    // Test different number formatting per locale\n    // Using comma as decimal separator\n    expect(toScientificNotation(1234567n, 3, \"fr-FR\")).toBe(\"1,23×10^6\")\n    expect(toScientificNotation(1234567n, 3, \"de-DE\")).toBe(\"1,23×10^6\")\n\n    // Using period as decimal separator\n    expect(toScientificNotation(1234567n, 3, \"en-US\")).toBe(\"1.23e+6\")\n\n    // Test with integer numbers below threshold with different locales\n    expect(toScientificNotation(1234n, 5, \"en-US\")).toBe(\"1,234.00\")\n    expect(toScientificNotation(1234n, 5, \"de-DE\")).toBe(\"1.234,00\")\n\n    expect(toScientificNotation(1234n, 5, \"fr-FR\")).toBe(\"1 234,00\")\n\n    // Test with real cases\n    expect(toScientificNotation([60386874408275410679920n, 18], 6)).toBe(\"60,386.87\")\n  })\n\n  test(\"omitShallow\", () => {\n    expect(omitShallow({ a: 1, b: 2, c: 3 }, \"a\", \"b\")).toEqual({ c: 3 })\n    expect(omitShallow(null)).toEqual(null)\n    expect(omitShallow(void 0)).toEqual(void 0)\n    expect(omitShallow([1, 2])).toEqual([1, 2])\n  })\n\n  test(\"does text contain html\", () => {\n    expect(doesTextContainHTML(\"a<div>b</div>\")).toBe(true)\n    expect(doesTextContainHTML(\"Test\")).toBe(false)\n    expect(doesTextContainHTML(\"<p> </p>\")).toBe(false)\n    expect(doesTextContainHTML(\"Test <p> </p>\")).toBe(false)\n    expect(doesTextContainHTML(\"Test <br/>\")).toBe(false)\n  })\n})\n"
  },
  {
    "path": "packages/internal/utils/src/utils.ts",
    "content": "import { WEB_BUILD } from \"@follow/shared/constants\"\nimport type { ClassValue } from \"clsx\"\nimport { clsx } from \"clsx\"\nimport dayjs from \"dayjs\"\nimport { extendTailwindMerge } from \"tailwind-merge\"\nimport { parse } from \"tldts\"\n\nimport { replaceImgUrlIfNeed } from \"./img-proxy\"\n\ntype Nullable<T> = T | null | undefined\n\nconst twMerge = extendTailwindMerge({\n  extend: {\n    theme: {\n      text: [\n        \"largeTitle\",\n        \"title1\",\n        \"title2\",\n        \"title3\",\n        \"headline\",\n        \"body\",\n        \"callout\",\n        \"subheadline\",\n        \"footnote\",\n        \"caption\",\n      ],\n    },\n  },\n})\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\nexport { clsx } from \"clsx\"\nexport type OS = \"macOS\" | \"iOS\" | \"Windows\" | \"Android\" | \"Linux\" | \"\"\n\ndeclare const window: {\n  platform: NodeJS.Platform\n  navigator: Navigator\n}\ndeclare const ELECTRON: boolean\n\nexport const once = <T>(fn: () => T): (() => T) => {\n  let first = true\n  let value: T\n  return () => {\n    if (first) {\n      first = false\n      value = fn()\n      return value\n    }\n    return value\n  }\n}\n\nexport const getOS = once((): OS => {\n  if (window.platform) {\n    switch (window.platform) {\n      case \"darwin\": {\n        return \"macOS\"\n      }\n      case \"win32\": {\n        return \"Windows\"\n      }\n      case \"linux\": {\n        return \"Linux\"\n      }\n    }\n  }\n\n  const { userAgent } = window.navigator,\n    macosPlatforms = [\"Macintosh\", \"MacIntel\", \"MacPPC\", \"Mac68K\"],\n    windowsPlatforms = [\"Win32\", \"Win64\", \"Windows\", \"WinCE\"],\n    iosPlatforms = [\"iPhone\", \"iPad\", \"iPod\"]\n  // @ts-expect-error\n  const platform = window.navigator.userAgentData?.platform || window.navigator.platform\n  let os = platform\n\n  if (macosPlatforms.includes(platform)) {\n    os = \"macOS\"\n  } else if (iosPlatforms.includes(platform)) {\n    os = \"iOS\"\n  } else if (windowsPlatforms.includes(platform)) {\n    os = \"Windows\"\n  } else if (/Android/.test(userAgent)) {\n    os = \"Android\"\n  } else if (!os && /Linux/.test(platform)) {\n    os = \"Linux\"\n  }\n\n  return os as OS\n})\n\nexport function detectBrowser() {\n  const { userAgent } = navigator\n  if (userAgent.includes(\"Edg\")) {\n    return \"Microsoft Edge\"\n  } else if (userAgent.includes(\"Chrome\")) {\n    return \"Chrome\"\n  } else if (userAgent.includes(\"Firefox\")) {\n    return \"Firefox\"\n  } else if (userAgent.includes(\"Safari\")) {\n    return \"Safari\"\n  } else if (userAgent.includes(\"Opera\")) {\n    return \"Opera\"\n  } else if (userAgent.includes(\"Trident\") || userAgent.includes(\"MSIE\")) {\n    return \"Internet Explorer\"\n  }\n\n  return \"Unknown\"\n}\n\nexport const isSafari = once(() => {\n  if (ELECTRON) return false\n  const ua = window.navigator.userAgent\n  return (ua.includes(\"Safari\") || ua.includes(\"AppleWebKit\")) && !ua.includes(\"Chrome\")\n})\n\n// eslint-disable-next-line no-control-regex\nexport const isASCII = (str: string) => /^[\\u0000-\\u007F]*$/.test(str)\n\nconst EPOCH = 1712546615000n // follow repo created\nconst MAX_TIMESTAMP_BITS = 41n // Maximum number of bits typically used for timestamp\n\nexport function isBizId(id: string): boolean\nexport function isBizId(id: string | undefined): id is string\n\nexport function isBizId(id: string | undefined): id is string {\n  if (!id || !/^\\d{13,19}$/.test(id)) return false\n\n  const snowflake = BigInt(id)\n\n  // Extract the timestamp assuming it's in the most significant bits after the sign bit\n  const timestamp = (snowflake >> (63n - MAX_TIMESTAMP_BITS)) + EPOCH\n  const date = new Date(Number(timestamp))\n\n  // Check if the date is reasonable (between 2024 and 2050)\n  if (date.getFullYear() >= 2024 && date.getFullYear() <= 2050) {\n    // Additional validation: check if the ID is not larger than the maximum possible value\n    const maxPossibleId = (1n << 63n) - 1n // Maximum possible 63-bit value\n    if (snowflake <= maxPossibleId) {\n      return true\n    }\n  }\n\n  return false\n}\n\nexport function formatXml(xml: string, indent = 4) {\n  const PADDING = \" \".repeat(indent)\n  let formatted = \"\"\n  const regex = /(>)(<)(\\/*)/g\n  const xmlStr = xml.replaceAll(regex, \"$1\\r\\n$2$3\")\n  let pad = 0\n  xmlStr.split(\"\\r\\n\").forEach((node) => {\n    let indent = 0\n    if (/.+<\\/\\w[^>]*>$/.test(node)) {\n      indent = 0\n    } else if (/^<\\/\\w/.test(node) && pad !== 0) {\n      pad -= 1\n    } else if (/^<\\w(?:[^>]*[^/])?>.*$/.test(node)) {\n      indent = 1\n    } else {\n      indent = 0\n    }\n\n    formatted += `${PADDING.repeat(pad) + node}\\r\\n`\n    pad += indent\n  })\n\n  return formatted.trim()\n}\n\nexport const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))\n\nexport const capitalizeFirstLetter = (string: string) =>\n  string.charAt(0).toUpperCase() + string.slice(1)\n\nexport const omitObjectUndefinedValue = (obj: Record<string, any>) => {\n  const newObj = {} as any\n  for (const key in obj) {\n    if (obj[key] !== undefined) {\n      newObj[key] = obj[key]\n    }\n  }\n  return newObj\n}\n\nexport const sortByAlphabet = (a: string | null | undefined, b: string | null | undefined) => {\n  const safeA = String(a ?? \"\")\n  const safeB = String(b ?? \"\")\n\n  const isALetter = /^[a-z]/i.test(safeA)\n  const isBLetter = /^[a-z]/i.test(safeB)\n\n  if (isALetter && !isBLetter) {\n    return -1\n  }\n  if (!isALetter && isBLetter) {\n    return 1\n  }\n\n  if (isALetter && isBLetter) {\n    return safeA.localeCompare(safeB)\n  }\n\n  return safeA.localeCompare(safeB, \"zh-CN\")\n}\n\nexport const isEmptyObject = (obj: Record<string, any>) => Object.keys(obj).length === 0\n\nexport const parseSafeUrl = (url: string) => {\n  try {\n    return new URL(url)\n  } catch {\n    return null\n  }\n}\n\n/**\n * @deprecated Remove it in the future but not now\n */\nexport const resolveUrlWithBase = (url: string, baseUrl: string) => {\n  try {\n    return new URL(url, baseUrl).href\n  } catch {\n    return url\n  }\n}\n\nexport const getUrlIcon = (url: string, _fallback?: boolean | undefined) => {\n  let src: string\n  let fallbackUrl = \"\"\n\n  try {\n    const { host } = new URL(url)\n    const pureDomain = parse(host).domainWithoutSuffix\n    fallbackUrl = `https://avatar.vercel.sh/${pureDomain}.svg?text=${pureDomain\n      ?.slice(0, 2)\n      .toUpperCase()}`\n    src = `https://icons.folo.is/${host}`\n  } catch {\n    const pureDomain = parse(url).domainWithoutSuffix\n    src = `https://avatar.vercel.sh/${pureDomain}.svg?text=${pureDomain?.slice(0, 2).toUpperCase()}`\n  }\n  const ret = {\n    src,\n    fallbackUrl,\n  }\n\n  return ret\n}\n\nexport const getAvatarUrl = (user?: {\n  email?: string | null\n  name?: string | null\n  handle?: string | null\n  image?: string | null\n}) => {\n  if (user) {\n    if (user?.image) {\n      return replaceImgUrlIfNeed({\n        url: user.image,\n        inBrowser: WEB_BUILD,\n      })\n    } else {\n      const fallbackUrl = `https://avatar.vercel.sh/${user.handle || user.name}.svg?text=${(user.handle || user.name)?.slice(0, 2).toUpperCase()}`\n      return `https://unavatar.webp.se/gravatar/${user.email}?fallback=${encodeURIComponent(fallbackUrl)}`\n    }\n  } else {\n    return `https://avatar.vercel.sh/folo`\n  }\n}\n\nexport { parse as parseUrl } from \"tldts\"\n\nexport const clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max)\n\nexport function shallowCopy<T>(input: T): T {\n  if (Array.isArray(input)) {\n    return [...input] as T\n  } else if (input && typeof input === \"object\") {\n    return { ...input } as T\n  }\n  return input\n}\n\nexport function isKeyForMultiSelectPressed(e: MouseEvent) {\n  if (getOS() === \"macOS\") {\n    return e.metaKey || e.shiftKey\n  }\n  return e.ctrlKey || e.shiftKey\n}\n\nexport const toScientificNotation = (\n  num: readonly [bigint, number] | bigint,\n  threshold: number,\n  locale?: Intl.Locale | string,\n) => {\n  // Handle string input by converting to dnum format\n  let value: bigint\n  let decimals: number\n\n  if (typeof num === \"bigint\") {\n    decimals = 0\n    value = num\n  } else {\n    // Extract number from dnum tuple\n    ;[value, decimals] = num\n  }\n\n  // Convert to decimal string representation\n  const valueAsString = value.toString()\n\n  // Handle zero case\n  if (valueAsString === \"0\") return \"0\"\n\n  // Determine length of the integer part\n  const integerLength = valueAsString.length > decimals ? valueAsString.length - decimals : 0\n\n  // Return normal formatted number if below threshold\n  if (integerLength <= threshold) {\n    // Use provided locale or default to en-US\n    const localeString = locale?.toString() || \"en-US\"\n    const formatter = new Intl.NumberFormat(localeString, {\n      maximumFractionDigits: 2,\n      minimumFractionDigits: 2,\n    })\n\n    // Convert bigint to number with correct decimal places\n    const asNumber = Number(value) / Math.pow(10, decimals)\n    return formatter.format(asNumber)\n  }\n\n  // Format in scientific notation\n  // Insert decimal point at appropriate place\n  let normalizedNumStr = valueAsString\n  if (valueAsString.length <= decimals) {\n    // Need to pad with leading zeros\n    normalizedNumStr = \"0\".repeat(decimals - valueAsString.length + 1) + valueAsString\n  }\n\n  // Find first non-zero digit\n  const firstNonZeroIndex = normalizedNumStr.search(/[1-9]/)\n  if (firstNonZeroIndex === -1) return \"0\" // All zeros\n\n  // Get first digits for the significand\n  const significandDigits = normalizedNumStr.slice(\n    firstNonZeroIndex,\n    Math.min(firstNonZeroIndex + 3, normalizedNumStr.length),\n  )\n\n  // Calculate exponent\n  const exponent = integerLength - 1\n\n  // Format significand according to locale\n  const localeString = locale?.toString() || \"en-US\"\n  const formatter = new Intl.NumberFormat(localeString, {\n    maximumFractionDigits: significandDigits.length - 1,\n  })\n\n  // Create significand as a properly formatted decimal\n  const firstDigit = Number(significandDigits[0])\n  const remainingDigits = significandDigits.slice(1)\n  const significandNumber = Number(`${firstDigit}.${remainingDigits}`)\n  const formattedSignificand = formatter.format(significandNumber)\n\n  // Format the exponent marker according to locale\n  // Many locales use \"×10^\" notation instead of \"e+\"\n  const useENotation = localeString.startsWith(\"en\")\n\n  if (useENotation) {\n    return `${formattedSignificand}e+${exponent}`\n  } else {\n    return `${formattedSignificand}×10^${exponent}`\n  }\n}\n\nexport function transformShortcut(shortcut: string, platform: OS = getOS()): string {\n  if (platform === \"macOS\") {\n    return shortcut.replace(\"$mod\", \"Meta\")\n  }\n  return shortcut.replace(\"$mod\", \"Control\")\n}\n\nconst F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/\n\nfunction getKeySortValue(key: string): number {\n  const order = [\"Shift\", \"Control\", \"Meta\", \"Alt\"]\n\n  if (order.includes(key)) {\n    return order.indexOf(key)\n  }\n\n  if (F_KEY_REGEX.test(key)) return 4\n  return 5\n}\n\nexport function sortShortcutKeys(keys: string[]): string[] {\n  return [...keys].sort((a, b) => {\n    const sortValueA = getKeySortValue(a)\n    const sortValueB = getKeySortValue(b)\n    if (sortValueA !== sortValueB) {\n      return sortValueA - sortValueB\n    }\n\n    return a.localeCompare(b)\n  })\n}\n\n// time like 1:30:00\nexport const formatTimeToSeconds = (time?: string | number) => {\n  if (typeof time === \"number\" || time === undefined) {\n    return time\n  }\n\n  const formats = [\"h:mm:ss\", \"mm:ss\", \"m:ss\"]\n\n  for (const format of formats) {\n    const date = dayjs(time, format)\n    if (date.isValid()) {\n      const totalSeconds = date.hour() * 3600 + date.minute() * 60 + date.second()\n      return totalSeconds\n    }\n  }\n}\n\n/**\n * @example\n * ```ts\n * timeStringToSeconds(\"1:30\") // 90\n * timeStringToSeconds(\"1:30:00\") // 5400\n * ```\n */\nexport function timeStringToSeconds(time: string): number | null {\n  const timeParts = time.split(\":\").map(Number)\n\n  if (timeParts.length === 2) {\n    const [minutes, seconds] = timeParts\n    return minutes! * 60 + seconds!\n  } else if (timeParts.length === 3) {\n    const [hours, minutes, seconds] = timeParts\n    return hours! * 3600 + minutes! * 60 + seconds!\n  } else {\n    return null\n  }\n}\n\nexport const formatEstimatedMins = (estimatedMins: number) => {\n  const minutesInHour = 60\n  const minutesInDay = minutesInHour * 24\n  const minutesInMonth = minutesInDay * 30\n\n  const months = Math.floor(estimatedMins / minutesInMonth)\n  const days = Math.floor((estimatedMins % minutesInMonth) / minutesInDay)\n  const hours = Math.floor((estimatedMins % minutesInDay) / minutesInHour)\n  const minutes = estimatedMins % minutesInHour\n\n  if (months > 0) {\n    return `${months}M ${days}d`\n  }\n  if (days > 0) {\n    return `${days}d ${hours}h`\n  }\n  if (hours > 0) {\n    return `${hours}h ${minutes}m`\n  }\n  return `${estimatedMins} mins`\n}\n\nexport const omitShallow = (obj: any, ...keys: string[]) => {\n  if (!obj) return obj\n  if (typeof obj !== \"object\") return obj\n  if (Array.isArray(obj)) return obj\n\n  const nextObj = { ...obj }\n  for (const key of keys) {\n    Reflect.deleteProperty(nextObj, key)\n  }\n  return nextObj\n}\n\nexport function duplicateIfLengthLessThan(text: string, length: number) {\n  return text.length > 0 && text.length < length\n    ? text.repeat(Math.ceil(length / text.length))\n    : text\n}\n\nexport function combineCleanupFunctions(...fns: Array<Nullable<(() => void) | void>>) {\n  return () => {\n    fns.forEach((fn) => {\n      if (typeof fn === \"function\") {\n        fn()\n      }\n    })\n  }\n}\n\nexport function doesTextContainHTML(text?: string | null): boolean {\n  if (!text) return false\n  return /<([a-z][a-z0-9]*)\\b[^>]*>\\s*[^<>\\s].*<\\/\\1>/i.test(text)\n}\n\n/**\n * Format number to a more readable format\n * @param num - The number to format\n * @returns The formatted number\n */\nexport function formatNumber(num: number): string {\n  // Handle negative numbers\n  const isNegative = num < 0\n  const absNum = Math.abs(num)\n\n  // Define thresholds\n  const billion = 1_000_000_000\n  const million = 1_000_000\n  const thousand = 1_000\n\n  // Format based on number size\n  if (absNum >= billion) {\n    return `${isNegative ? \"-\" : \"\"}${(absNum / billion).toFixed(1)}B`\n  } else if (absNum >= million) {\n    return `${isNegative ? \"-\" : \"\"}${(absNum / million).toFixed(1)}M`\n  } else if (absNum >= thousand) {\n    return `${isNegative ? \"-\" : \"\"}${(absNum / thousand).toFixed(1)}K`\n  }\n\n  return `${isNegative ? \"-\" : \"\"}${absNum}`\n}\n\nexport type MobilePlatform = \"iOS\" | \"Android\" | null\n\nexport const getMobilePlatform = once((): MobilePlatform => {\n  const os = getOS()\n\n  return [\"iOS\", \"Android\"].includes(os) ? (os as MobilePlatform) : null\n})\n\nexport const isMobileDevice = once((): boolean => {\n  return getMobilePlatform() !== null\n})\n\nexport function getDateISOString(dateOrDateString: Date | string | null): string | null {\n  if (!dateOrDateString) return null\n  if (typeof dateOrDateString === \"string\") {\n    return dateOrDateString\n  }\n  return dateOrDateString.toISOString()\n}\n"
  },
  {
    "path": "packages/internal/utils/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"declaration\": false,\n    \"types\": [\"@follow/types/global\", \"@follow/types/vite\"],\n    \"paths\": {\n      \"@follow/utils/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "packages/internal/utils/vitest.config.ts",
    "content": "import { readFileSync } from \"node:fs\"\nimport { fileURLToPath } from \"node:url\"\n\nimport tsconfigPath from \"vite-tsconfig-paths\"\nimport { defineProject } from \"vitest/config\"\n\nconst pkg = JSON.parse(readFileSync(\"package.json\", \"utf8\"))\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\n\nexport default defineProject({\n  root: \"./\",\n  test: {\n    globals: true,\n    environment: \"happy-dom\",\n  },\n\n  define: {\n    APP_VERSION: JSON.stringify(pkg.version),\n    APP_NAME: JSON.stringify(pkg.name),\n    APP_DEV_CWD: JSON.stringify(process.cwd()),\n\n    GIT_COMMIT_SHA: \"'SHA'\",\n    DEBUG: process.env.DEBUG === \"true\",\n    ELECTRON: \"false\",\n  },\n\n  plugins: [\n    tsconfigPath({\n      projects: [\"./tsconfig.json\"],\n    }),\n  ],\n})\n"
  },
  {
    "path": "packages/readability/bump.config.ts",
    "content": "import { defineConfig } from \"nbump\"\n\nexport default defineConfig({\n  leading: [\"npm run build\"],\n  tag: false,\n  push: false,\n  commit: false,\n  allowDirty: true,\n  changelog: false,\n  publish: true,\n  allowedBranches: [\"dev\"],\n})\n"
  },
  {
    "path": "packages/readability/package.json",
    "content": "{\n  \"name\": \"@follow-app/readability\",\n  \"type\": \"module\",\n  \"version\": \"0.1.3\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"scripts\": {\n    \"build\": \"tsdown\"\n  },\n  \"dependencies\": {\n    \"@mozilla/readability\": \"0.6.0\",\n    \"chardet\": \"2.1.1\",\n    \"dompurify\": \"3.3.1\",\n    \"linkedom\": \"0.18.12\"\n  },\n  \"devDependencies\": {\n    \"@follow/configs\": \"workspace:*\",\n    \"nbump\": \"2.1.8\",\n    \"tsdown\": \"0.20.3\"\n  },\n  \"publishConfig\": {\n    \"exports\": {\n      \".\": {\n        \"require\": \"./dist/index.cjs\",\n        \"import\": \"./dist/index.js\"\n      }\n    },\n    \"main\": \"./dist/index.cjs\",\n    \"module\": \"./dist/index.js\",\n    \"types\": \"./dist/index.d.ts\"\n  }\n}\n"
  },
  {
    "path": "packages/readability/src/index.ts",
    "content": "import { Readability } from \"@mozilla/readability\"\nimport chardet from \"chardet\"\nimport DOMPurify from \"dompurify\"\nimport { parseHTML } from \"linkedom/worker\"\n\nconst isDev = process.env.NODE_ENV === \"development\"\n\nconst userAgents =\n  \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36\"\n\n// For avoiding xss attack from readability, the raw document string should be sanitized.\n// The xss attack in electron may lead to more serious outcomes than browser environment.\n// It may allows remotely execute malicious scripts in main process.\n// Before the sanitizing, the DOMPurify requires a `window` environment provided by linkedom.\nfunction sanitizeHTMLString(dirtyDocumentString: string) {\n  const parser = parseHTML(dirtyDocumentString)\n  const purify = DOMPurify(parser.window)\n  // How do DOMPurify changes the origin html structure,\n  // You can refer its document https://github.com/cure53/DOMPurify?tab=readme-ov-file#can-i-configure-dompurify\n  const sanitizedDocumentString = purify.sanitize(dirtyDocumentString)\n  return sanitizedDocumentString\n}\n\n/**\n * Decodes the response body of a `fetch` request into a string, ensuring proper character set handling.\n * @throws Will return \"Failed to decode response content.\" if the decoding process encounters any errors.\n */\nasync function decodeResponseBodyChars(res: Response) {\n  // Read the response body as an ArrayBuffer\n  const buffer = await res.arrayBuffer()\n  // Step 1: Get charset from Content-Type header\n  const contentType = res.headers.get(\"content-type\")\n  const httpCharset = contentType?.match(/charset=([\\w-]+)/i)?.[1]\n  // Step 2: Use charset from Content-Type header or fall back to chardet\n  const detectedCharset = httpCharset || chardet.detect(Buffer.from(buffer)) || \"utf-8\"\n  // Step 3: Decode the response body using the detected charset\n  try {\n    const decodedText = new TextDecoder(detectedCharset, { fatal: false }).decode(buffer)\n    return decodedText\n  } catch {\n    return \"Failed to decode response content.\"\n  }\n}\n\nexport async function readability(baseUrl: string) {\n  const dirtyDocumentString = await fetch(baseUrl, {\n    headers: {\n      \"User-Agent\": userAgents,\n      Accept: \"text/html\",\n    },\n  }).then(decodeResponseBodyChars)\n\n  const sanitizedDocumentString = sanitizeHTMLString(dirtyDocumentString)\n  const baseOrigin = new URL(baseUrl).origin\n\n  // FIXME: linkedom does not handle relative addresses in strings. Refer to\n  // @see https://github.com/WebReflection/linkedom/issues/153\n  // JSDOM handles it correctly, but JSDOM introduces canvas binding.\n  const { document } = parseHTML(sanitizedDocumentString)\n\n  document.querySelectorAll(\"a\").forEach((a) => {\n    a.href = replaceRelativeAddress(baseOrigin, a.href)\n  })\n  ;([\"img\", \"audio\", \"video\"] as const).forEach((tag) => {\n    document.querySelectorAll(tag).forEach((img) => {\n      img.src = img.src && replaceRelativeAddress(baseOrigin, img.src)\n    })\n  })\n\n  const reader = new Readability(document, {\n    debug: isDev,\n    // keep classes to set the right code language\n    // https://github.com/RSSNext/Follow/issues/1058\n    keepClasses: true,\n  })\n  return reader.parse()\n}\n\nconst replaceRelativeAddress = (baseUrl: string, url: string) => {\n  if (url.startsWith(\"http\")) {\n    return url\n  }\n  return new URL(url, baseUrl).href\n}\n"
  },
  {
    "path": "packages/readability/tsconfig.json",
    "content": "{\n  \"extends\": \"@follow/configs/tsconfig.extend.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"declaration\": true,\n    \"esModuleInterop\": true,\n    \"paths\": {}\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/readability/tsdown.config.ts",
    "content": "import { defineConfig } from \"tsdown\"\n\nexport default defineConfig({\n  entry: [\"src/index.ts\"],\n  outDir: \"dist\",\n  dts: true,\n  clean: true,\n  format: [\"cjs\", \"esm\"],\n  treeshake: true,\n})\n"
  },
  {
    "path": "patches/@microflash__remark-callout-directives.patch",
    "content": "diff --git a/package.json b/package.json\nindex de27f20188873e2f432fff3aba04bbe5a0a33389..d4926c3f2eb58a65d04354665db3b30c940dd3e5 100644\n--- a/package.json\n+++ b/package.json\n@@ -28,6 +28,7 @@\n     \"./config/github\": \"./themes/github/index.js\",\n     \"./config/vitepress\": \"./themes/vitepress/index.js\",\n     \"./theme/github\": \"./themes/github/index.css\",\n+    \"./themes/*\": \"./themes/*\",\n     \"./theme/microflash\": \"./themes/microflash/index.css\",\n     \"./theme/vitepress\": \"./themes/vitepress/index.css\"\n   },\n"
  },
  {
    "path": "patches/@mozilla__readability@0.6.0.patch",
    "content": "diff --git a/Readability-readerable.js b/Readability-readerable.js\nindex e50a6e8a9b6e409a39adff0b77d35159cd9deb74..ae4496260700b09d5028616f30f4dd32f401feab 100644\n--- a/Readability-readerable.js\n+++ b/Readability-readerable.js\n@@ -32,6 +32,7 @@ function isNodeVisible(node) {\n   return (\n     (!node.style || node.style.display != \"none\") &&\n     !node.hasAttribute(\"hidden\") &&\n+    !node.id.startsWith(\"S:\") &&\n     //check for \"fallback-image\" so that wikimedia math images are displayed\n     (!node.hasAttribute(\"aria-hidden\") ||\n       node.getAttribute(\"aria-hidden\") != \"true\" ||\n"
  },
  {
    "path": "patches/@pengx17__electron-forge-maker-appimage.patch",
    "content": "diff --git a/dist/src/MakerAppimage.d.ts b/dist/src/MakerAppimage.d.ts\nindex 389100d510dd90c10b28d3f3ee0996c01a7714d9..7fa2bfd46bbf7b7d10e4b0190fda6a3fae053c37 100644\n--- a/dist/src/MakerAppimage.d.ts\n+++ b/dist/src/MakerAppimage.d.ts\n@@ -1,14 +1,28 @@\n-import MakerBase, { MakerOptions } from \"@electron-forge/maker-base\";\n-import { ForgePlatform } from \"@electron-forge/shared-types\";\n-import { MakerAppImageConfig } from \"./Config\";\n+import MakerBase, { MakerOptions } from \"@electron-forge/maker-base\"\n+import { ForgePlatform } from \"@electron-forge/shared-types\"\n+import { MakerAppImageConfig } from \"./Config\"\n+\n+export interface AppImageForgeConfig {\n+  template?: string\n+  chmodChromeSandbox?: string\n+  icons?: { file: string; size: number }[]\n+}\n+\n export default class MakerAppImage extends MakerBase<MakerAppImageConfig> {\n-    name: string;\n-    defaultPlatforms: ForgePlatform[];\n-    isSupportedOnCurrentPlatform(): boolean;\n-    make({ dir, // '/home/build/Software/monorepo/packages/electron/out/canary/name-linux-x64'\n+  constructor(private config: { config: AppImageForgeConfig }) {\n+    super()\n+  }\n+\n+  name: string\n+  defaultPlatforms: ForgePlatform[]\n+  isSupportedOnCurrentPlatform(): boolean\n+  make({\n+    dir, // '/home/build/Software/monorepo/packages/electron/out/canary/name-linux-x64'\n     appName, // 'name'\n     makeDir, // '/home/build/Software/monorepo/packages/electron/out/canary/make',\n     targetArch, // 'x64'\n-    packageJSON, targetPlatform, //'linux',\n-    forgeConfig, }: MakerOptions): Promise<string[]>;\n+    packageJSON,\n+    targetPlatform, //'linux',\n+    forgeConfig,\n+  }: MakerOptions): Promise<string[]>\n }\ndiff --git a/dist/src/MakerAppimage.js b/dist/src/MakerAppimage.js\nindex a57b183ad759c91aa36691a430e87c385ba63f62..032877c9fd3fe2dd6905e75de76c17936b19534e 100644\n--- a/dist/src/MakerAppimage.js\n+++ b/dist/src/MakerAppimage.js\n@@ -48,7 +48,7 @@ const isIForgeResolvableMaker = (maker) => {\n class MakerAppImage extends maker_base_1.default {\n     constructor() {\n         super(...arguments);\n-        this.name = \"appImage\";\n+        this.name = makerPackageName;\n         this.defaultPlatforms = [\"linux\"];\n     }\n     isSupportedOnCurrentPlatform() {\n@@ -65,9 +65,9 @@ class MakerAppImage extends maker_base_1.default {\n             const executableName = forgeConfig.packagerConfig.executableName || appName;\n             // Check for any optional configuration data passed in from forge config, specific to this maker.\n             let config;\n-            const maker = forgeConfig.makers.find((maker) => isIForgeResolvableMaker(maker) && maker.name === makerPackageName);\n+            const maker = forgeConfig.makers.find((maker) => maker?.name === makerPackageName);\n             if (maker !== undefined && isIForgeResolvableMaker(maker)) {\n-                config = maker.config;\n+                config = maker.configOrConfigFetcher?.config;\n             }\n             const appFileName = `${appName}-${packageJSON.version}.AppImage`;\n             const appPath = path_1.default.join(makeDir, appFileName);\n"
  },
  {
    "path": "patches/daisyui@4.12.24.patch",
    "content": "diff --git a/src/index.js b/src/index.js\nindex 18ee99048c31ab0e8e82601cb8021bd54cb5d780..0c8d04d9b615a9e894c3b860b44dd67844471f23 100644\n--- a/src/index.js\n+++ b/src/index.js\n@@ -131,17 +131,21 @@ module.exports = tailwindPlugin(mainFunction, {\n       colors: {\n         ...colorObject,\n         // adding all Tailwind `neutral` shades here so they don't get overridden by daisyUI `neutral` color\n-        \"neutral-50\": \"#fafafa\",\n-        \"neutral-100\": \"#f5f5f5\",\n-        \"neutral-200\": \"#e5e5e5\",\n-        \"neutral-300\": \"#d4d4d4\",\n-        \"neutral-400\": \"#a3a3a3\",\n-        \"neutral-500\": \"#737373\",\n-        \"neutral-600\": \"#525252\",\n-        \"neutral-700\": \"#404040\",\n-        \"neutral-800\": \"#262626\",\n-        \"neutral-900\": \"#171717\",\n-        \"neutral-950\": \"#0a0a0a\",\n+        \"neutral\": {\n+          DEFAULT: \"var(--fallback-n,oklch(var(--n)/<alpha-value>))\",\n+          50: \"#fafafa\",\n+          100: \"#f5f5f5\",\n+          200: \"#e5e5e5\",\n+          300: \"#d4d4d4\",\n+          400: \"#a3a3a3\",\n+          500: \"#737373\",\n+          600: \"#525252\",\n+          700: \"#404040\",\n+          800: \"#262626\",\n+          900: \"#171717\",\n+          950: \"#0a0a0a\",\n+         },\n+         \"neutral-content\": \"var(--fallback-nc,oklch(var(--nc)/<alpha-value>))\",\n       },\n       ...utilityClasses,\n     },\n"
  },
  {
    "path": "patches/re-resizable@6.11.2.patch",
    "content": "diff --git a/lib/resizer.js b/lib/resizer.js\nindex c465561d31edf2087c45cdf7b7a2af16e3edd9c3..31c0a258f0c8176eb274e4b292fde1b12040891e 100644\n--- a/lib/resizer.js\n+++ b/lib/resizer.js\n@@ -13,33 +13,33 @@ import { jsx as _jsx } from \"react/jsx-runtime\";\n import { memo, useCallback, useMemo } from 'react';\n var rowSizeBase = {\n     width: '100%',\n-    height: '10px',\n+    height: '20px',\n     top: '0px',\n     left: '0px',\n     cursor: 'row-resize',\n };\n var colSizeBase = {\n-    width: '10px',\n+    width: '20px',\n     height: '100%',\n     top: '0px',\n     left: '0px',\n     cursor: 'col-resize',\n };\n var edgeBase = {\n-    width: '20px',\n-    height: '20px',\n+    width: '40px',\n+    height: '40px',\n     position: 'absolute',\n     zIndex: 1,\n };\n var styles = {\n-    top: __assign(__assign({}, rowSizeBase), { top: '-5px' }),\n-    right: __assign(__assign({}, colSizeBase), { left: undefined, right: '-5px' }),\n-    bottom: __assign(__assign({}, rowSizeBase), { top: undefined, bottom: '-5px' }),\n-    left: __assign(__assign({}, colSizeBase), { left: '-5px' }),\n-    topRight: __assign(__assign({}, edgeBase), { right: '-10px', top: '-10px', cursor: 'ne-resize' }),\n-    bottomRight: __assign(__assign({}, edgeBase), { right: '-10px', bottom: '-10px', cursor: 'se-resize' }),\n-    bottomLeft: __assign(__assign({}, edgeBase), { left: '-10px', bottom: '-10px', cursor: 'sw-resize' }),\n-    topLeft: __assign(__assign({}, edgeBase), { left: '-10px', top: '-10px', cursor: 'nw-resize' }),\n+    top: __assign(__assign({}, rowSizeBase), { top: '-10px' }),\n+    right: __assign(__assign({}, colSizeBase), { left: undefined, right: '-10px' }),\n+    bottom: __assign(__assign({}, rowSizeBase), { top: undefined, bottom: '-10px' }),\n+    left: __assign(__assign({}, colSizeBase), { left: '-10px' }),\n+    topRight: __assign(__assign({}, edgeBase), { right: '-20px', top: '-20px', cursor: 'ne-resize' }),\n+    bottomRight: __assign(__assign({}, edgeBase), { right: '-20px', bottom: '-20px', cursor: 'se-resize' }),\n+    bottomLeft: __assign(__assign({}, edgeBase), { left: '-20px', bottom: '-20px', cursor: 'sw-resize' }),\n+    topLeft: __assign(__assign({}, edgeBase), { left: '-20px', top: '-20px', cursor: 'nw-resize' }),\n };\n export var Resizer = memo(function (props) {\n     var onResizeStart = props.onResizeStart, direction = props.direction, children = props.children, replaceStyles = props.replaceStyles, className = props.className;\n"
  },
  {
    "path": "patches/react-native-sheet-transitions.patch",
    "content": "diff --git a/dist/SheetProvider.js b/dist/SheetProvider.js\nindex 212e57ad58d533723060dd2f05670d3b99901858..c14820ae5e35835ee88d64e4f67511c8e4bd7041 100644\n--- a/dist/SheetProvider.js\n+++ b/dist/SheetProvider.js\n@@ -31,6 +31,7 @@ const SheetContext = (0, react_1.createContext)(null);\n function SheetProvider({ children, resizeType = 'decremental', enableForWeb = false }) {\n     const scale = (0, react_native_reanimated_1.useSharedValue)(1);\n     const isMounted = (0, react_native_reanimated_1.useSharedValue)(false);\n+    const [isScaling, setIsScaling] = react_1.useState(false);\n     (0, react_1.useEffect)(() => {\n         // Delay setting isMounted to ensure view is ready\n         requestAnimationFrame(() => {\n@@ -48,6 +49,7 @@ function SheetProvider({ children, resizeType = 'decremental', enableForWeb = fa\n             scale.value = newScale;\n             return;\n         }\n+        setIsScaling(newScale !== 1);\n         scale.value = (0, react_native_reanimated_1.withSpring)(newScale, {\n             damping: 20,\n             stiffness: 300,\n@@ -56,20 +58,14 @@ function SheetProvider({ children, resizeType = 'decremental', enableForWeb = fa\n             restSpeedThreshold: 0.01,\n         });\n     }, []);\n-    const animatedStyle = (0, react_native_reanimated_1.useAnimatedStyle)(() => {\n-        if (!isMounted.value)\n-            return {};\n-        return {\n-            transform: [{ scale: scale.value }],\n-        };\n-    }, []);\n     const isEnabled = react_native_1.Platform.OS === 'web' ? enableForWeb : true;\n-    return (<SheetContext.Provider value={{\n+    return (<SheetContext.Provider value={react_1.useMemo(() => ({\n             scale,\n             setScale,\n+            isScaling,\n             resizeType,\n             enableForWeb: isEnabled\n-        }}>\n+        }), [scale, setScale, isScaling, resizeType, isEnabled])}>\n       {/* <View style={{ flex: 1, backgroundColor: 'red',\n             position: 'absolute',\n             top: 0,\n@@ -78,19 +74,7 @@ function SheetProvider({ children, resizeType = 'decremental', enableForWeb = fa\n             bottom: 0,\n             zIndex: -1\n            }}/> */}\n-\n-      <react_native_reanimated_1.default.View style={[\n-            {\n-                flex: 1,\n-                backfaceVisibility: 'hidden',\n-            },\n-            react_native_1.Platform.OS === 'ios' ? animatedStyle : null\n-        ]} collapsable={false}>\n-\n         {children}\n-\n-      </react_native_reanimated_1.default.View>\n-\n     </SheetContext.Provider>);\n }\n exports.SheetProvider = SheetProvider;\ndiff --git a/dist/SheetScreen.js b/dist/SheetScreen.js\nindex 3f95f54763b2f223bd1120254c83135096c80b82..dc5ac1bb45a543ec68a615c729a96d0b0204c8b0 100644\n--- a/dist/SheetScreen.js\n+++ b/dist/SheetScreen.js\n@@ -193,7 +193,7 @@ function SheetScreen({ children, onClose, scaleFactor = 0.83, dragThreshold = 15\n                 { scale }\n             ],\n             opacity: opacity.value,\n-            borderRadius: borderRadius.value,\n+            borderRadius: translateY.value === 0 ? 0 : borderRadius.value,\n         };\n     }, [disableSheetContentResizeOnDragDown]);\n     const backgroundStyle = (0, react_native_reanimated_1.useAnimatedStyle)(() => ({\ndiff --git a/package.json b/package.json\nindex ba7644c884d4326c59d404a0d11d74bdde2ac568..5c344c96dc0cf1d3bab787e95d7ffbe6fd8d992a 100644\n--- a/package.json\n+++ b/package.json\n@@ -14,9 +14,9 @@\n     \"transitions\",\n     \"animation\"\n   ],\n-  \"main\": \"src/index.ts\",\n-  \"module\": \"src/index.ts\",\n-  \"types\": \"src/index.ts\",\n+  \"main\": \"dist/index.js\",\n+  \"module\": \"dist/index.js\",\n+  \"types\": \"dist/index.d.ts\",\n   \"files\": [\n     \"src\",\n     \"dist\",\n@@ -32,4 +32,4 @@\n     \"react-native-reanimated\": \"*\",\n     \"react-native-gesture-handler\": \"*\"\n   }\n-} \n\\ No newline at end of file\n+}\n"
  },
  {
    "path": "patches/react-native-track-player@4.1.1.patch",
    "content": "diff --git a/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt\nindex b2409a09939164c49c0f7a16bb6d3284e8eab8fb..cde697e28b1a098cfc654ebe5c7825f26bf2397e 100644\n--- a/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt\n+++ b/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt\n@@ -251,8 +251,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun updateOptions(data: ReadableMap?, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun updateOptions(data: ReadableMap?, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         val options = Arguments.toBundle(data)\n \n@@ -264,14 +264,14 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         try {\n             val tracks = readableArrayToTrackList(data);\n             if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) {\n                 callback.reject(\"index_out_of_bounds\", \"The track index is out of bounds\")\n-                return@launch\n+                return@launchInScope\n             }\n             val index = if (insertBeforeIndex == -1) musicService.tracks.size else insertBeforeIndex\n             musicService.add(\n@@ -285,11 +285,11 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun load(data: ReadableMap?, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun load(data: ReadableMap?, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         if (data == null) {\n             callback.resolve(null)\n-            return@launch\n+            return@launchInScope\n         }\n         val bundle = Arguments.toBundle(data);\n         if (bundle is Bundle) {\n@@ -301,15 +301,15 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun move(fromIndex: Int, toIndex: Int, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun move(fromIndex: Int, toIndex: Int, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         musicService.move(fromIndex, toIndex)\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun remove(data: ReadableArray?, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun remove(data: ReadableArray?, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         val inputIndexes = Arguments.toList(data)\n         if (inputIndexes != null) {\n             val size = musicService.tracks.size\n@@ -321,7 +321,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n                         \"index_out_of_bounds\",\n                         \"One or more indexes was out of bounds\"\n                     )\n-                    return@launch\n+                    return@launchInScope\n                 }\n                 indexes.add(index)\n             }\n@@ -332,8 +332,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n \n     @ReactMethod\n     fun updateMetadataForTrack(index: Int, map: ReadableMap?, callback: Promise) =\n-        scope.launch {\n-            if (verifyServiceBoundOrReject(callback)) return@launch\n+        launchInScope {\n+            if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n             if (index < 0 || index >= musicService.tracks.size) {\n                 callback.reject(\"index_out_of_bounds\", \"The index is out of bounds\")\n@@ -348,8 +348,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n         }\n \n     @ReactMethod\n-    fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         if (musicService.tracks.isEmpty())\n             callback.reject(\"no_current_item\", \"There is no current item in the player\")\n@@ -364,8 +364,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun clearNowPlayingMetadata(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun clearNowPlayingMetadata(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         if (musicService.tracks.isEmpty())\n             callback.reject(\"no_current_item\", \"There is no current item in the player\")\n@@ -375,16 +375,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun removeUpcomingTracks(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun removeUpcomingTracks(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.removeUpcomingTracks()\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun skip(index: Int, initialTime: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun skip(index: Int, initialTime: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.skip(index)\n \n@@ -396,8 +396,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun skipToNext(initialTime: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun skipToNext(initialTime: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.skipToNext()\n \n@@ -409,8 +409,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun skipToPrevious(initialTime: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun skipToPrevious(initialTime: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.skipToPrevious()\n \n@@ -422,8 +422,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun reset(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun reset(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.stop()\n         delay(300) // Allow playback to stop\n@@ -433,134 +433,134 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun play(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun play(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.play()\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun pause(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun pause(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.pause()\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun stop(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun stop(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.stop()\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun seekTo(seconds: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun seekTo(seconds: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.seekTo(seconds)\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun seekBy(offset: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun seekBy(offset: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.seekBy(offset)\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun retry(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun retry(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.retry()\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun setVolume(volume: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun setVolume(volume: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.setVolume(volume)\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun getVolume(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getVolume(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.getVolume())\n     }\n \n     @ReactMethod\n-    fun setRate(rate: Float, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun setRate(rate: Float, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.setRate(rate)\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun getRate(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getRate(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.getRate())\n     }\n \n     @ReactMethod\n-    fun setRepeatMode(mode: Int, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun setRepeatMode(mode: Int, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.setRepeatMode(RepeatMode.fromOrdinal(mode))\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun getRepeatMode(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getRepeatMode(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.getRepeatMode().ordinal)\n     }\n \n     @ReactMethod\n-    fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         musicService.playWhenReady = playWhenReady\n         callback.resolve(null)\n     }\n \n     @ReactMethod\n-    fun getPlayWhenReady(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getPlayWhenReady(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.playWhenReady)\n     }\n \n     @ReactMethod\n-    fun getTrack(index: Int, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getTrack(index: Int, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         if (index >= 0 && index < musicService.tracks.size) {\n-            callback.resolve(Arguments.fromBundle(musicService.tracks[index].originalItem))\n+            callback.resolve(musicService.tracks[index].originalItem?.let { Arguments.fromBundle(it) })\n         } else {\n             callback.resolve(null)\n         }\n     }\n \n     @ReactMethod\n-    fun getQueue(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getQueue(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(Arguments.fromList(musicService.tracks.map { it.originalItem }))\n     }\n \n     @ReactMethod\n-    fun setQueue(data: ReadableArray?, callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun setQueue(data: ReadableArray?, callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         try {\n             musicService.clear()\n@@ -572,48 +572,48 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun getActiveTrackIndex(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getActiveTrackIndex(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         callback.resolve(\n             if (musicService.tracks.isEmpty()) null else musicService.getCurrentTrackIndex()\n         )\n     }\n \n     @ReactMethod\n-    fun getActiveTrack(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getActiveTrack(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         callback.resolve(\n             if (musicService.tracks.isEmpty()) null\n-            else Arguments.fromBundle(\n-                musicService.tracks[musicService.getCurrentTrackIndex()].originalItem\n-            )\n+            else musicService.tracks[musicService.getCurrentTrackIndex()].originalItem?.let {\n+                Arguments.fromBundle(it)\n+            }\n         )\n     }\n \n     @ReactMethod\n-    fun getDuration(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getDuration(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.getDurationInSeconds())\n     }\n \n     @ReactMethod\n-    fun getBufferedPosition(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getBufferedPosition(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.getBufferedPositionInSeconds())\n     }\n \n     @ReactMethod\n-    fun getPosition(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getPosition(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n \n         callback.resolve(musicService.getPositionInSeconds())\n     }\n \n     @ReactMethod\n-    fun getProgress(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getProgress(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         var bundle = Bundle()\n         bundle.putDouble(\"duration\", musicService.getDurationInSeconds());\n         bundle.putDouble(\"position\", musicService.getPositionInSeconds());\n@@ -622,8 +622,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM\n     }\n \n     @ReactMethod\n-    fun getPlaybackState(callback: Promise) = scope.launch {\n-        if (verifyServiceBoundOrReject(callback)) return@launch\n+    fun getPlaybackState(callback: Promise) = launchInScope {\n+        if (verifyServiceBoundOrReject(callback)) return@launchInScope\n         callback.resolve(Arguments.fromBundle(musicService.getPlayerStateBundle(musicService.state)))\n     }\n+\n+    // Bridgeless interop layer tries to pass the `Job` from `scope.launch` to the JS side\n+    // which causes an exception. We can work around this using a wrapper.\n+    private fun launchInScope(block: suspend () -> Unit) {\n+        scope.launch {\n+            block()\n+        }\n+    }\n }\ndiff --git a/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt\nindex 9d6d869efcece065618d4f2cefdc8c54831af9ed..37f41fd3fcd8dcb7b5b54c0ad3f207ddeb6d961f 100644\n--- a/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt\n+++ b/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt\n@@ -741,9 +741,7 @@ class MusicService : HeadlessJsTaskService() {\n \n     @MainThread\n     private fun emit(event: String, data: Bundle? = null) {\n-        reactNativeHost.reactInstanceManager.currentReactContext\n-            ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)\n-            ?.emit(event, data?.let { Arguments.fromBundle(it) })\n+        reactContext?.emitDeviceEvent(event, data?.let { Arguments.fromBundle(it) })\n     }\n \n     @MainThread\n@@ -751,17 +749,16 @@ class MusicService : HeadlessJsTaskService() {\n         val payload = Arguments.createArray()\n         data.forEach { payload.pushMap(Arguments.fromBundle(it)) }\n \n-        reactNativeHost.reactInstanceManager.currentReactContext\n-            ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)\n-            ?.emit(event, payload)\n+        reactContext?.emitDeviceEvent(event, payload)\n     }\n \n     override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig {\n         return HeadlessJsTaskConfig(TASK_KEY, Arguments.createMap(), 0, true)\n     }\n \n+    // https://github.com/doublesymmetry/react-native-track-player/pull/2451\n     @MainThread\n-    override fun onBind(intent: Intent?): IBinder {\n+    override fun onBind(intent: Intent): IBinder {\n         return binder\n     }\n \ndiff --git a/ios/RNTrackPlayer/RNTrackPlayerBridge.m b/ios/RNTrackPlayer/RNTrackPlayerBridge.m\nindex 7741994191921ed86f7577f7ce589b3939d9dd49..776c09ec0ae450daa4dc9031ccd3ff4baba016d3 100644\n--- a/ios/RNTrackPlayer/RNTrackPlayerBridge.m\n+++ b/ios/RNTrackPlayer/RNTrackPlayerBridge.m\n@@ -151,17 +151,4 @@ RCT_EXTERN_METHOD(updateNowPlayingMetadata:(NSDictionary *)metadata\n                   resolver:(RCTPromiseResolveBlock)resolve\n                   rejecter:(RCTPromiseRejectBlock)reject);\n \n-RCT_EXTERN_METHOD(getSleepTimerProgress:(RCTPromiseResolveBlock)resolve\n-              rejecter:(RCTPromiseRejectBlock)reject);\n-\n-RCT_EXTERN_METHOD(setSleepTimer:(double)time\n-                  resolver:(RCTPromiseResolveBlock)resolve\n-                  rejecter:(RCTPromiseRejectBlock)reject);\n-\n-RCT_EXTERN_METHOD(sleepWhenActiveTrackReachesEnd:(RCTPromiseResolveBlock)resolve\n-                  rejecter:(RCTPromiseRejectBlock)reject);\n-\n-RCT_EXTERN_METHOD(clearSleepTimer:(RCTPromiseResolveBlock)resolve\n-              rejecter:(RCTPromiseRejectBlock)reject);\n-\n @end\n"
  },
  {
    "path": "patches/workbox-precaching.patch",
    "content": "diff --git a/PrecacheController.js b/PrecacheController.js\nindex e00975e3762dc6382c39bebee04a89a651aae3d0..d2b9c0169d6e79e4e7dce6e0d59e97aa842fc97a 100644\n--- a/PrecacheController.js\n+++ b/PrecacheController.js\n@@ -5,6 +5,7 @@\n   license that can be found in the LICENSE file or at\n   https://opensource.org/licenses/MIT.\n */\n+import eachLimit from 'async-es/eachLimit';\n import { assert } from 'workbox-core/_private/assert.js';\n import { cacheNames } from 'workbox-core/_private/cacheNames.js';\n import { logger } from 'workbox-core/_private/logger.js';\n@@ -150,9 +151,8 @@ class PrecacheController {\n         return waitUntil(event, async () => {\n             const installReportPlugin = new PrecacheInstallReportPlugin();\n             this.strategy.plugins.push(installReportPlugin);\n-            // Cache entries one at a time.\n             // See https://github.com/GoogleChrome/workbox/issues/2528\n-            for (const [url, cacheKey] of this._urlsToCacheKeys) {\n+            await eachLimit(this._urlsToCacheKeys, 10, async ([url, cacheKey]) => {\n                 const integrity = this._cacheKeysToIntegrities.get(cacheKey);\n                 const cacheMode = this._urlsToCacheModes.get(url);\n                 const request = new Request(url, {\n@@ -165,7 +165,7 @@ class PrecacheController {\n                     request,\n                     event,\n                 }));\n-            }\n+            })\n             const { updatedURLs, notUpdatedURLs } = installReportPlugin;\n             if (process.env.NODE_ENV !== 'production') {\n                 printInstallDetails(updatedURLs, notUpdatedURLs);\n"
  },
  {
    "path": "plugins/eslint/eslint-check-i18n-json.js",
    "content": "// @ts-check\n/** @type {import(\"eslint\").ESLint.Plugin} */\nimport fs from \"node:fs\"\n\nimport path, { normalize, sep } from \"pathe\"\n\nimport { cleanJsonText } from \"../utils.js\"\n\nexport default {\n  rules: {\n    \"valid-i18n-keys\": {\n      meta: {\n        type: \"problem\",\n        docs: {\n          description: \"Ensure i18n JSON keys are flat and valid as object paths\",\n          category: \"Possible Errors\",\n          recommended: true,\n        },\n        fixable: null,\n      },\n      create(context) {\n        return {\n          Program(node) {\n            const { filename, sourceCode } = context\n\n            if (!filename.endsWith(\".json\")) return\n\n            let json\n            try {\n              json = JSON.parse(cleanJsonText(sourceCode.text))\n            } catch {\n              context.report({\n                node,\n                message: \"Invalid JSON format\",\n              })\n              return\n            }\n\n            const keys = Object.keys(json)\n            const keyPrefixes = new Set()\n\n            for (const key of keys) {\n              if (key.includes(\".\")) {\n                const parts = key.split(\".\")\n                for (let i = 1; i < parts.length; i++) {\n                  const prefix = parts.slice(0, i).join(\".\")\n                  if (keys.includes(prefix)) {\n                    context.report({\n                      node,\n                      message: `Invalid key structure: '${key}' conflicts with '${prefix}'`,\n                    })\n                  }\n                  keyPrefixes.add(prefix)\n                }\n              }\n            }\n\n            for (const key of keys) {\n              if (keyPrefixes.has(key)) {\n                context.report({\n                  node,\n                  message: `Invalid key structure: '${key}' is a prefix of another key`,\n                })\n              }\n            }\n          },\n        }\n      },\n    },\n    \"no-extra-keys\": {\n      meta: {\n        type: \"problem\",\n        docs: {\n          description: \"Ensure non-English JSON files don't have extra keys not present in en.json\",\n          category: \"Possible Errors\",\n          recommended: true,\n        },\n        fixable: \"code\", // Set fixable to \"code\"\n      },\n      create(context) {\n        return {\n          Program(node) {\n            const { filename, sourceCode } = context\n\n            if (!filename.endsWith(\".json\")) return\n\n            const parts = normalize(filename).split(sep)\n            // @ts-ignore\n            const lang = parts.at(-1).split(\".\")[0]\n            const namespace = parts.at(-2)\n\n            if (lang === \"en\") return\n\n            let currentJson = {}\n            let englishJson = {}\n\n            try {\n              currentJson = JSON.parse(sourceCode.text)\n              // @ts-ignore\n              const englishFilePath = path.join(path.dirname(filename), \"../\", namespace, \"en.json\")\n              englishJson = JSON.parse(fs.readFileSync(englishFilePath, \"utf8\"))\n            } catch (error) {\n              context.report({\n                node,\n                message: `Error parsing JSON: ${error.message}`,\n              })\n              return\n            }\n\n            const extraKeys = Object.keys(currentJson).filter(\n              (key) => !Object.prototype.hasOwnProperty.call(englishJson, key),\n            )\n\n            for (const key of extraKeys) {\n              context.report({\n                node,\n                message: `Key \"${key}\" is present in ${lang}.json but not in en.json for namespace \"${namespace}\"`,\n                fix(fixer) {\n                  const newJson = Object.fromEntries(\n                    Object.entries(currentJson).filter(([k]) => !extraKeys.includes(k)),\n                  )\n\n                  const newText = `${JSON.stringify(newJson, null, 2)}\\n`\n\n                  return fixer.replaceText(node, newText)\n                },\n              })\n            }\n          },\n        }\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "plugins/eslint/eslint-no-debug.js",
    "content": "/**\n * @type {import(\"eslint\").ESLint.Plugin}\n */\nexport default {\n  rules: {\n    \"no-debug-stack\": {\n      meta: {\n        type: \"problem\",\n        docs: {\n          description: \"Disallow use of debugStack() function\",\n          category: \"Possible Errors\",\n          recommended: true,\n        },\n        fixable: null,\n      },\n      create(context) {\n        return {\n          CallExpression(node) {\n            if (node.callee.type === \"Identifier\" && node.callee.name === \"debugStack\") {\n              context.report({\n                node,\n                message:\n                  \"Unexpected debugStack() statement. Remove debugStack() calls from production code.\",\n              })\n            }\n          },\n        }\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "plugins/eslint/eslint-package-json.js",
    "content": "// @ts-check\nimport fs from \"node:fs\"\nimport process from \"node:process\"\n\nimport fg from \"fast-glob\"\nimport path from \"pathe\"\n\nconst dependencyKeys = [\"dependencies\", \"devDependencies\"]\nconst ignoredEnsureVersionFiles = new Set([\"apps/landing/package.json\"])\n\n/** @type {import(\"eslint\").ESLint.Plugin} */\nexport default {\n  rules: {\n    \"ensure-package-version\": {\n      meta: {\n        type: \"problem\",\n        docs: {\n          description: \"Ensure that the versions of packages in the workspace are consistent\",\n          category: \"Possible Errors\",\n          recommended: true,\n        },\n        fixable: \"code\",\n        hasSuggestions: true,\n      },\n      create(context) {\n        if (!context.filename.endsWith(\"package.json\")) return {}\n\n        const cwd = process.cwd()\n        const currentFilePath = path.relative(cwd, context.filename).replaceAll(\"\\\\\", \"/\")\n        if (ignoredEnsureVersionFiles.has(currentFilePath)) return {}\n\n        const packageJsonFilePaths = fg.globSync(\n          [\"packages/*/package.json\", \"apps/*/package.json\", \"package.json\"],\n          {\n            cwd,\n            ignore: [\"**/node_modules/**\"],\n          },\n        )\n\n        /** @type {Map<string, { version: string, filePath: string }[]>} */\n        const packageVersionMap = new Map()\n\n        packageJsonFilePaths.forEach((filePath) => {\n          if (ignoredEnsureVersionFiles.has(filePath)) return\n          if (filePath === path.relative(cwd, context.filename)) return\n\n          const packageJson = JSON.parse(fs.readFileSync(filePath, \"utf-8\"))\n\n          dependencyKeys.forEach((key) => {\n            const dependencies = packageJson[key]\n            if (!dependencies) return\n\n            Object.keys(dependencies).forEach((dependency) => {\n              if (!packageVersionMap.has(dependency)) {\n                packageVersionMap.set(dependency, [])\n              }\n              packageVersionMap.get(dependency)?.push({\n                version: dependencies[dependency],\n                filePath,\n              })\n            })\n          })\n        })\n\n        return {\n          \"Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty > JSONObjectExpression > JSONProperty\"(\n            node,\n          ) {\n            const parent = node?.parent?.parent\n            if (!parent) return\n            const packageCategory = parent.key.value\n            if (!dependencyKeys.includes(packageCategory)) return\n            const packageName = node.key.value\n            const packageVersion = node.value.value\n\n            const versions = packageVersionMap.get(packageName)\n            if (!versions || versions.find((v) => v.version === packageVersion)) return\n\n            context.report({\n              node,\n              message: `Inconsistent versions of ${packageName}: ${Array.from(new Set(versions.map((v) => v.version))).join(\", \")}`,\n              suggest: versions.map((version) => ({\n                desc: `Follow the version ${version.version} in ${version.filePath}`,\n                fix: (fixer) => fixer.replaceText(node.value, `\"${version.version}\"`),\n              })),\n            })\n          },\n        }\n      },\n    },\n    \"no-duplicate-package\": {\n      meta: {\n        type: \"problem\",\n        docs: {\n          description: \"Ensure packages are not duplicated in one package.json\",\n          category: \"Possible Errors\",\n          recommended: true,\n        },\n      },\n      create(context) {\n        if (!context.filename.endsWith(\"package.json\")) return {}\n\n        let json\n        try {\n          json = JSON.parse(fs.readFileSync(context.filename, \"utf-8\"))\n        } catch {\n          return {}\n        }\n\n        const dependencyMap = new Map()\n        dependencyKeys.forEach((key) => {\n          const dependencies = json[key]\n          if (!dependencies) return\n\n          if (!dependencyMap.get(key)) {\n            dependencyMap.set(key, new Set())\n          }\n\n          const dependencySet = dependencyMap.get(key)\n          Object.keys(dependencies).forEach((dependency) => {\n            dependencySet.add(dependency)\n          })\n        })\n\n        return {\n          \"Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty > JSONObjectExpression > JSONProperty\"(\n            node,\n          ) {\n            const parent = node?.parent?.parent\n            if (!parent) return\n            const packageCategory = parent.key.value\n            if (!dependencyKeys.includes(packageCategory)) return\n            const packageName = node.key.value\n\n            dependencyKeys.forEach((key) => {\n              if (key === packageCategory) return\n              if (!dependencyMap.get(key)?.has(packageName)) return\n\n              context.report({\n                node,\n                message: `Duplicated package ${packageName} in ${key}`,\n              })\n            })\n          },\n        }\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "plugins/eslint/eslint-recursive-sort.js",
    "content": "import { cleanJsonText, sortObjectKeys } from \"../utils.js\"\n\n/**\n * @type {import(\"eslint\").ESLint.Plugin}\n */\nexport default {\n  rules: {\n    \"recursive-sort\": {\n      meta: {\n        type: \"layout\",\n        fixable: \"code\",\n      },\n      create(context) {\n        return {\n          Program(node) {\n            if (context.filename.endsWith(\".json\")) {\n              const { sourceCode } = context\n              const text = cleanJsonText(sourceCode.getText())\n\n              try {\n                const json = JSON.parse(text)\n                const sortedJson = sortObjectKeys(json)\n                const sortedText = `${JSON.stringify(sortedJson, null, 2)}\\n`\n\n                const noWhiteSpaceDiff = (a, b) =>\n                  a.replaceAll(/\\s/g, \"\") === b.replaceAll(/\\s/g, \"\")\n\n                if (!noWhiteSpaceDiff(text, sortedText)) {\n                  context.report({\n                    node,\n                    message: \"JSON keys are not sorted recursively\",\n                    fix(fixer) {\n                      return fixer.replaceText(node, sortedText)\n                    },\n                  })\n                }\n              } catch (error) {\n                context.report({\n                  node,\n                  message: `Invalid JSON: ${error.message}`,\n                })\n              }\n            }\n          },\n        }\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "plugins/utils.js",
    "content": "export const sortObjectKeys = (obj) => {\n  if (typeof obj !== \"object\" || obj === null) {\n    return obj\n  }\n\n  if (Array.isArray(obj)) {\n    return obj.map((element) => sortObjectKeys(element))\n  }\n\n  return Object.keys(obj)\n    .sort()\n    .reduce((acc, key) => {\n      acc[key] = sortObjectKeys(obj[key])\n      return acc\n    }, {})\n}\n\nexport const cleanJsonText = (text) => {\n  const cleaned = text.replaceAll(/,\\s*\\}/g, \"}\")\n  try {\n    JSON.parse(cleaned)\n    return cleaned\n  } catch {\n    return text\n  }\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - apps/*\n  - packages/**/*\n  - apps/desktop/layer/*\n  - \"!apps/mobile/native/example\"\n  - apps/mobile/web-app\n  - \"!**/example\"\n  - \"!**/example/**\"\n\ncatalog:\n  \"@follow-app/client-sdk\": 0.3.92\n  \"@folo-services/ai-tools\": 0.2.65\n  tailwindcss-uikit-colors: 1.0.0\n  typescript: 5.9.3\n\nignorePatchFailures: false\n\nonlyBuiltDependencies:\n  - \"@firebase/util\"\n  - \"@tsslint/core\"\n  - \"@tsslint/eslint\"\n  - bufferutil\n  - core-js\n  - dtrace-provider\n  - electron\n  - electron-winstaller\n  - esbuild\n  - fast-folder-size\n  - fs-xattr\n  - macos-alias\n  - msedge-tts\n  - protobufjs\n  - sharp\n  - simple-git-hooks\n  - utf-8-validate\n\noverrides:\n  \"@electron/node-gyp\": 10.2.0-electron.2\n  \"@floating-ui/core\": 1.7.2\n  \"@floating-ui/dom\": 1.7.2\n  \"@floating-ui/react\": 0.27.14\n  \"@floating-ui/react-dom\": 2.1.4\n  \"@react-native-menu/menu\": 2.0.0\n  \"@types/react\": 19.1.17\n  array-includes: npm:@nolyfill/array-includes@latest\n  array.prototype.findlastindex: npm:@nolyfill/array.prototype.findlastindex@latest\n  array.prototype.flat: npm:@nolyfill/array.prototype.flat@latest\n  array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@latest\n  array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@latest\n  drizzle-orm: 0.45.1\n  global-agent>serialize-error: 7.0.1\n  has: npm:@nolyfill/has@latest\n  is-core-module: npm:@nolyfill/is-core-module@1.0.39\n  isarray: npm:@nolyfill/isarray@1.0.44\n  lan-network@<0.1.7: 0.1.7\n  nativewind>react-native-css-interop: 0.2.1\n  object.assign: npm:@nolyfill/object.assign@latest\n  object.entries: npm:@nolyfill/object.entries@latest\n  object.fromentries: npm:@nolyfill/object.fromentries@latest\n  object.groupby: npm:@nolyfill/object.groupby@latest\n  object.hasown: npm:@nolyfill/object.hasown@latest\n  object.values: npm:@nolyfill/object.values@latest\n  react: 19.1.0\n  react-dom: 19.1.0\n  react-native-ios-context-menu: 3.2.1\n  react-native-ios-utilities: 5.2.0\n  serialize-error: 2.1.0\n  string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@latest\n\npatchedDependencies:\n  \"@microflash/remark-callout-directives\": patches/@microflash__remark-callout-directives.patch\n  \"@mozilla/readability@0.6.0\": patches/@mozilla__readability@0.6.0.patch\n  \"@pengx17/electron-forge-maker-appimage\": patches/@pengx17__electron-forge-maker-appimage.patch\n  daisyui@4.12.24: patches/daisyui@4.12.24.patch\n  re-resizable@6.11.2: patches/re-resizable@6.11.2.patch\n  react-native-sheet-transitions: patches/react-native-sheet-transitions.patch\n  react-native-track-player@4.1.1: patches/react-native-track-player@4.1.1.patch\n  workbox-precaching: patches/workbox-precaching.patch\n"
  },
  {
    "path": "scripts/copy-translation.ts",
    "content": "import fs from \"node:fs\"\n\nimport path from \"pathe\"\n\nconst sourceDir = \"./locales/app\"\nconst targetDir = \"./locales/mobile/default\"\nconst keysToCopy: string[] = [\n  \"feed_form.title\",\n  \"feed_form.title_description\",\n  \"feed_form.category\",\n  \"feed_form.category_description\",\n  \"feed_form.private_follow\",\n  \"feed_form.private_follow_description\",\n  \"feed_form.view\",\n]\nconst keysToPlace = [\n  \"subscription_form.title\",\n  \"subscription_form.title_description\",\n  \"subscription_form.category\",\n  \"subscription_form.category_description\",\n  \"subscription_form.private_follow\",\n  \"subscription_form.private_follow_description\",\n  \"subscription_form.view\",\n]\n\nconst copyTranslations = (sourceDir: string, targetDir: string) => {\n  const sourceFiles = fs.readdirSync(sourceDir)\n\n  sourceFiles.forEach((file) => {\n    const sourceFilePath = path.join(sourceDir, file)\n    const targetFilePath = path.join(targetDir, file)\n\n    if (fs.statSync(sourceFilePath).isDirectory()) {\n      // Recursively copy translations from subdirectories\n      copyTranslations(sourceFilePath, targetFilePath)\n    } else if (path.extname(file) === \".json\") {\n      const sourceContent = JSON.parse(fs.readFileSync(sourceFilePath, \"utf-8\"))\n      const targetContent = fs.existsSync(targetFilePath)\n        ? JSON.parse(fs.readFileSync(targetFilePath, \"utf-8\"))\n        : {}\n\n      keysToCopy.forEach((key, index) => {\n        const targetKey = keysToPlace[index] ?? key\n        if (sourceContent[key] && !targetContent[targetKey]) {\n          targetContent[targetKey] = sourceContent[key]\n        }\n      })\n\n      fs.writeFileSync(targetFilePath, `${JSON.stringify(targetContent, null, 2)}\\n`)\n    }\n  })\n}\n\ncopyTranslations(sourceDir, targetDir)\n"
  },
  {
    "path": "scripts/increment-build-id.sh",
    "content": "#!/bin/bash\n\n# Script to check if the current branch is a release branch and if there are staged changes in apps/mobile/src\n# If both conditions are met, automatically increment the iOS and Android build ID\n# Use -f flag to force increment regardless of branch or staged changes\n\nset -e\n\n# Parse command line arguments\nFORCE=false\nwhile [[ $# -gt 0 ]]; do\n  case $1 in\n  -f | --force)\n    FORCE=true\n    shift\n    ;;\n  *)\n    shift\n    ;;\n  esac\ndone\n\n# If force flag is not set, perform the usual checks\nif [ \"$FORCE\" = false ]; then\n  # Get the current branch name\n  CURRENT_BRANCH=$(git branch --show-current)\n\n  # Check if it is a release branch\n  if [[ ! \"$CURRENT_BRANCH\" =~ ^release/ ]]; then\n    exit 0\n  fi\n\n  # Check if there are staged changes in apps/mobile/src\n  MOBILE_SRC_CHANGES=$(git diff --cached --name-only | grep \"^apps/mobile/src\" || true)\n\n  if [ -z \"$MOBILE_SRC_CHANGES\" ]; then\n    echo \"No changes in apps/mobile/src, skipping build ID increment\"\n    exit 0\n  fi\n\n  echo \"Found changes in apps/mobile/src on release branch, incrementing build ID...\"\nelse\n  echo \"Force flag detected, incrementing build ID...\"\nfi\n\n# iOS Info.plist path\nIOS_INFO_PLIST=\"apps/mobile/ios/Folo/Info.plist\"\n\nif [ -f \"$IOS_INFO_PLIST\" ]; then\n  # Get the current build ID\n  CURRENT_BUILD=$(plutil -extract CFBundleVersion raw \"$IOS_INFO_PLIST\")\n\n  # Increment build ID\n  NEW_BUILD=$((CURRENT_BUILD + 1))\n\n  # Update Info.plist\n  plutil -replace CFBundleVersion -string \"$NEW_BUILD\" \"$IOS_INFO_PLIST\"\n\n  echo \"iOS build ID updated from $CURRENT_BUILD to $NEW_BUILD\"\n\n  # Add the updated Info.plist to the staged area\n  git add \"$IOS_INFO_PLIST\"\nelse\n  echo \"Warning: iOS Info.plist not found at $IOS_INFO_PLIST\"\nfi\n\necho \"Build ID increment completed\"\n"
  },
  {
    "path": "scripts/lib.ts",
    "content": "import { execSync } from \"node:child_process\"\n\nexport const getGitHash = () => {\n  try {\n    return execSync(\"git rev-parse HEAD\").toString().trim()\n  } catch (e) {\n    console.error(\"Failed to get git hash\", e)\n    return \"\"\n  }\n}\n"
  },
  {
    "path": "scripts/mitproxy.py",
    "content": "from mitmproxy import http\n\ndef request(flow: http.HTTPFlow) -> None:\n    if flow.request.pretty_host == \"app.folo.is\":\n        flow.request.host = \"localhost\"\n        flow.request.port = 2233"
  },
  {
    "path": "scripts/run-proxy.sh",
    "content": "#!/bin/bash\n\nPROXY_HOST=\"127.0.0.1\"\nPROXY_PORT=\"8080\"\n\nenable_proxy() {\n  echo \"Enabling proxy on $PROXY_HOST:$PROXY_PORT...\"\n  networksetup -listallnetworkservices | grep -v '^*' | while IFS= read -r svc; do\n    networksetup -setwebproxy \"$svc\" \"$PROXY_HOST\" \"$PROXY_PORT\"\n    networksetup -setsecurewebproxy \"$svc\" \"$PROXY_HOST\" \"$PROXY_PORT\"\n    networksetup -setwebproxystate \"$svc\" on\n    networksetup -setsecurewebproxystate \"$svc\" on\n  done\n  echo \"Proxy enabled.\"\n}\n\ndisable_proxy() {\n  echo \"Disabling proxy...\"\n  networksetup -listallnetworkservices | grep -v '^*' | while IFS= read -r svc; do\n    networksetup -setwebproxystate \"$svc\" off\n    networksetup -setsecurewebproxystate \"$svc\" off\n  done\n  echo \"Proxy disabled.\"\n}\n\ncleanup() {\n  echo \"Exiting...\"\n  disable_proxy\n  exit 0\n}\n\ntrap cleanup SIGINT SIGTERM\n\nenable_proxy\n\necho \"Starting mitmproxy...\"\nmitmproxy -s scripts/mitproxy.py --ssl-insecure\n\ncleanup\n"
  },
  {
    "path": "scripts/skip-main-app-vercel-build.sh",
    "content": "#!/bin/bash\n\nLAST_DEPLOY_COMMIT=$(git rev-parse HEAD^)\n\nCHANGED_FILES=$(git diff --name-only $LAST_DEPLOY_COMMIT HEAD)\n\nONLY_SERVER_CHANGES=true\nfor file in $CHANGED_FILES; do\n  if [[ $file != apps/ssr/* ]]; then\n    ONLY_SERVER_CHANGES=false\n    break\n  fi\ndone\n\nif [ \"$ONLY_SERVER_CHANGES\" = true ]; then\n\n  echo \"skip\"\n  exit 0\nelse\n  echo \"continue\"\n  exit 1\nfi\n"
  },
  {
    "path": "scripts/svg-to-rn.ts",
    "content": "import fs from \"node:fs\"\n\nimport path from \"pathe\"\nimport { parse } from \"svg-parser\"\n\nconst DIST_DIR = \"apps/mobile/src/icons\"\n\nconst DEFAULT_COLOR = \"#10161F\"\ninterface SvgNode {\n  type: string\n  tagName: string\n  properties: Record<string, any>\n  children?: SvgNode[]\n}\n\nconst generateElement = (node: SvgNode): string => {\n  const props = Object.entries(node.properties)\n    .map(([key, value]) => {\n      const camelKey = key.replaceAll(/-([a-z])/g, (_match, p1: string) => p1.toUpperCase())\n\n      if (\n        [\"stroke\", \"fill\"].includes(key) &&\n        (!value || value === \"currentColor\" || value === DEFAULT_COLOR)\n      ) {\n        return `${camelKey}={color}`\n      }\n\n      if (typeof value === \"number\") {\n        return `${camelKey}={${value}}`\n      }\n\n      return `${camelKey}=\"${value}\"`\n    })\n    .join(\" \")\n\n  const tagName = `${node.tagName.charAt(0).toUpperCase()}${node.tagName.slice(1)}`\n\n  return `      <${tagName} ${props} />`\n}\n\nconst isPureBackgroundPath = (node: any) => {\n  return (\n    node.tagName === \"path\" &&\n    node.properties.fill === \"#fff\" &&\n    node.properties[\"fill-opacity\"] === 0.01 &&\n    node.properties.d === \"M24 0v24H0V0z\"\n  )\n}\n\nconst supportedTags = new Set([\"path\", \"circle\"])\n\nconst convertSvgToRN = (svgContent: string, componentName: string) => {\n  const ast = parse(svgContent)\n  const svgNode = ast.children[0] as SvgNode\n  const { width, height } = svgNode.properties\n\n  const pathElements =\n    svgNode.children\n      ?.filter((child) => supportedTags.has(child.tagName))\n      .filter((child) => !isPureBackgroundPath(child))\n      .map((node) => generateElement(node))\n      .join(\"\\n\") ?? \"\"\n\n  return `import * as React from \"react\"\nimport Svg, { Circle, Path } from \"react-native-svg\"\n\ninterface ${componentName}Props {\n  width?: number\n  height?: number\n  color?: string\n}\n\nexport const ${componentName} = ({\n  width = ${width},\n  height = ${height},\n  ${pathElements.includes(`{color}`) ? `color = \"${DEFAULT_COLOR}\",` : \"\"}\n}: ${componentName}Props) => {\n  return (\n    <Svg width={width} height={height} fill=\"none\" viewBox=\"0 0 ${width} ${height}\">\n${pathElements}\n    </Svg>\n  )\n}\n`\n}\n\nconst processFile = (filePath: string) => {\n  const svgContent = fs.readFileSync(filePath, \"utf-8\")\n  const fileName = path.basename(filePath, \".svg\")\n  const componentName = `${fileName\n    .split(\"_\")\n    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n    .join(\"\")}Icon`\n\n  const rnComponent = convertSvgToRN(svgContent, componentName)\n\n  const outputDir = path.join(process.cwd(), DIST_DIR)\n  if (!fs.existsSync(outputDir)) {\n    fs.mkdirSync(outputDir, { recursive: true })\n  }\n\n  const outputPath = path.join(outputDir, `${fileName}.tsx`)\n  fs.writeFileSync(outputPath, rnComponent)\n  // eslint-disable-next-line no-console\n  console.log(`Converted ${filePath} to ${outputPath}`)\n}\n\n// 处理整个目录\nconst processDirectory = (dirPath: string) => {\n  const files = fs.readdirSync(dirPath)\n  files.forEach((file) => {\n    if (file.endsWith(\".svg\")) {\n      processFile(path.join(dirPath, file))\n    }\n  })\n}\n\nprocessDirectory(path.join(process.cwd(), \"icons/mgc\"))\n"
  },
  {
    "path": "scripts/update-icon.ts",
    "content": "import fs from \"node:fs\"\n\nimport path from \"pathe\"\n\nconst PATH_TO_NEW_ICONS_FOLDER = \"../SVG 1.36\"\nconst PATH_TO_PROJECT_ICONS_FOLDER = \"./icons/mgc\"\n\nconst loadFiles = (dirPath: string) => {\n  // load all files from the new icons folder recursively\n  // get file name -> file full path map\n  const icons = new Map<string, string>()\n\n  function readFiles(dirPath: string) {\n    const files = fs.readdirSync(dirPath)\n    files.forEach((file) => {\n      const fullPath = path.join(dirPath, file)\n      if (fs.statSync(fullPath).isDirectory()) {\n        readFiles(fullPath)\n      } else {\n        icons.set(file, fullPath)\n      }\n    })\n  }\n\n  readFiles(dirPath)\n\n  return icons\n}\n\nconst newIcons = loadFiles(PATH_TO_NEW_ICONS_FOLDER)\nconst projectIcons = loadFiles(PATH_TO_PROJECT_ICONS_FOLDER)\n\n// update project icons with new icons\nprojectIcons.forEach((iconPath, iconName) => {\n  const newIconPath = newIcons.get(iconName)\n  if (newIconPath) {\n    fs.copyFileSync(newIconPath, iconPath)\n  }\n})\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.node.json\",\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true\n  },\n  \"exclude\": [\"apps\", \"packages\"]\n}\n"
  },
  {
    "path": "tsslint.config.ts",
    "content": "import { defineConfig } from \"@tsslint/config\"\nimport { convertRules } from \"@tsslint/eslint\"\n\nexport default defineConfig({\n  rules: await convertRules({\n    \"react-x/no-leaked-conditional-rendering\": \"error\",\n  }),\n})\n"
  },
  {
    "path": "turbo.json",
    "content": "{\n  \"$schema\": \"https://turbo.build/schema.json\",\n  \"envMode\": \"loose\",\n  \"tasks\": {\n    \"Folo#build:web\": {\n      \"outputs\": [\"out/**\"]\n    },\n    \"//#format:check\": {},\n    \"//#lint\": {},\n    \"test\": {},\n    \"@follow/electron-main#build\": {\n      \"outputs\": [\"dist/**\"]\n    },\n    \"typecheck\": {\n      \"dependsOn\": [\"@follow/electron-main#build\"]\n    },\n    \"@follow/ssr#build\": {\n      \"outputs\": [\"dist/**\"]\n    },\n    \"@follow/landing#cf:build\": {\n      \"outputs\": [\".open-next/**\", \".next/**\"]\n    },\n    \"build\": {\n      \"outputs\": [\"dist/**\"]\n    },\n    \"dev\": {\n      \"persistent\": true,\n      \"cache\": false\n    }\n  }\n}\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n  \"$schema\": \"https://openapi.vercel.sh/vercel.json\",\n  \"rewrites\": [\n    {\n      \"source\": \"/__debug_proxy\",\n      \"destination\": \"/__debug_proxy.html\"\n    },\n    {\n      \"source\": \"/__debug_proxy/:path*\",\n      \"destination\": \"/__debug_proxy.html\"\n    },\n    {\n      \"source\": \"/share/:path*\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/share/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/og/:path*\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/og/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/login\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/login\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/register\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/register\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/forget-password\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/forget-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/reset-password\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/reset-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/external-dist/:path*\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/external-dist/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/dist-external/:path*\",\n      \"destination\": \"https://follow-external-ssr-follow.vercel.app/dist-external/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.follow.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/share/:path*\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/share/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/og/:path*\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/og/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/login\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/login\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/register\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/register\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/forget-password\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/forget-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/reset-password\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/reset-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/external-dist/:path*\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/external-dist/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/dist-external/:path*\",\n      \"destination\": \"https://follow-external-ssr.vercel.app/dist-external/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"app.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/share/:path*\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/share/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/og/:path*\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/og/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/login\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/login\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/register\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/register\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/forget-password\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/forget-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/reset-password\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/reset-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/external-dist/:path*\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/external-dist/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/dist-external/:path*\",\n      \"destination\": \"https://follow-external-ssr-dev.vercel.app/dist-external/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"dev.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/share/:path*\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/share/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/og/:path*\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/og/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/login\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/login\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/register\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/register\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/forget-password\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/forget-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/reset-password\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/reset-password\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/external-dist/:path*\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/external-dist/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/dist-external/:path*\",\n      \"destination\": \"https://follow-external-ssr-staging.vercel.app/dist-external/:path*\",\n      \"has\": [\n        {\n          \"type\": \"host\",\n          \"value\": \"staging.folo.is\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/((?!assets|vendor|locales|dist-external|external-dist/).*)\",\n      \"destination\": \"/index.html\"\n    }\n  ],\n  \"redirects\": [\n    {\n      \"source\": \"/feed/:id\",\n      \"destination\": \"/share/feeds/:id\",\n      \"permanent\": true\n    },\n    {\n      \"source\": \"/list/:id\",\n      \"destination\": \"/share/lists/:id\",\n      \"permanent\": true\n    },\n    {\n      \"source\": \"/profile/:path*\",\n      \"destination\": \"/share/users/:path*\",\n      \"permanent\": true\n    }\n  ],\n  \"headers\": [\n    {\n      \"source\": \"/vendor/(.*)\",\n      \"headers\": [\n        {\n          \"key\": \"Cache-Tag\",\n          \"value\": \"follow-assets\"\n        }\n      ]\n    },\n    {\n      \"source\": \"/assets/(.*)\",\n      \"headers\": [\n        {\n          \"key\": \"Cache-Tag\",\n          \"value\": \"follow-assets\"\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "vitest.workspace.js",
    "content": "import { defineWorkspace } from \"vitest/config\"\n// NOT work\nexport default defineWorkspace([\"apps/*\"])\n"
  },
  {
    "path": "vitest.workspace.ts",
    "content": "import { defineWorkspace } from \"vitest/config\"\n\n// NOT work\nexport default defineWorkspace([\"apps/*\"])\n"
  },
  {
    "path": "wiki/contribute-i18n.md",
    "content": "# Contributing to Internationalization (i18n)\n\nWe welcome contributions to our internationalization efforts! This guide will help you get started with adding or updating translations for our project.\n\n## Pay Attention\n\nIf it's a new language, please check for [existing issues](https://github.com/RSSNext/Follow/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen+label%3Ai18n) before starting. If none exist, submit a new issue to avoid duplicating efforts.\n\n## Adding a New Language\n\nTo add support for a new language:\n\n1. Run the following command in the project root:\n\n   ```bash\n   npm run generator:i18n-template\n   ```\n\n2. Select the desired locale from the list.\n\n3. The script will:\n   - Create new resource files for the selected locale\n   - Update the necessary configuration files\n\n4. Open your editor and navigate to the `locales/` directory. You'll find new JSON files for the selected locale.\n\n5. Translate the keys in these JSON files to the target language.\n\n## Updating Existing Translations\n\nTo update or improve existing translations:\n\n1. Navigate to the `locales/` directory.\n2. Find the JSON files for the language you want to update.\n3. Edit the translations as needed.\n\n## Translation Guidelines\n\n- Maintain the same structure and keys as the original English version.\n- Ensure translations are culturally appropriate and context-aware.\n- Use gender-neutral language where possible.\n- Keep special placeholders (e.g., `{{variable}}`) intact.\n\n## Testing Your Translations\n\nAfter making changes:\n\n1. Run the application locally.\n2. Switch to the language you've edited.\n3. Navigate through the app to verify your translations in context.\n\n## Submitting Your Contribution\n\n1. Create a new branch for your changes.\n2. Commit your changes with a clear, descriptive message.\n3. Open a pull request with details about the languages and sections you've updated.\n\nThank you for helping make our project more accessible to users worldwide!\n"
  }
]